Every prompt and reply is now kept under <out>/llm/. An empty reply is a retry, not a fatal error; a spec that never parses stops the run before the structure phase; --visitor-llm lets a different model play the visitor. The file-block parser finds markers anywhere in a line, accepts a short END and lets a new FILE close an open one, after a reply glued two files together with `<<<END>></think>`. The reference gains a language section: copy in the intake's language, identifiers in ASCII. runs/rehab-entreprenor-no-2026-09-21: nemotron-3.5-lightning:30b-a3b did not produce a loadable site. Written up with the reason and the way forward: structure from needs.yaml deterministically, words from the model. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
7.8 KiB
Portal content reference (for the generator)
Portal serves a site that is one question per page. Each page offers a few alternatives. An alternative may carry a form; submitting it records the answer into a bucket whose records move through a state machine, worked from a desk: a page gated to a group, listing the bucket with one button per allowed move. There is no other mechanism. Everything a business needs is expressed as: who answers which question, where the answer lands, who moves it, through which states.
A content repo is these files and nothing else:
needs.yaml what the business needs (the spec you are held to)
aggregates.yaml one state machine per bucket
site.yaml title and language
questions/index.yaml the front door, "/"
questions/<name>.yaml "/<name>"
questions/<dir>/index.yaml "/<dir>"
questions/<dir>/_section.yaml applies `qualifies` to the whole directory
questions/<dir>/<name>.yaml "/<dir>/<name>" - a followup page by default
needs.yaml
needs:
- id: kebab-case
who: the kind of person, in plain words
wants: what they want from the business
enters: / # page they arrive on; default /
enters_as: <group> # only when they are logged-in members of a group; omit for the public
via: <alternative name> # only when several alternatives on the entry page record into the same bucket
lands_in: <bucket>
handled_by: <group> # the role inside the business that must see and move the record
done_when: [<state>, <state>] # every state that counts as finished, good or bad
max_steps: 1 # submissions before the record exists (1 or 2)
max_fields: 4 # required fields the visitor will tolerate across the path
success: [<state>] # the done_when states that are a good outcome for the business
stages: [open, <stage>, <stage>, <end>, <end>]
# EVERY stage the business said a case goes through, first to last,
# read off their own sentences one verb at a time ("they come in, we
# go and look, we send a quote, we win or lose it" is five stages).
# The state machine must have a state for each, or the verifier fails.
personas:
- id: kebab-case
need: <need id>
text: >
Two or three sentences in the visitor's own voice and vocabulary.
Rules:
- One need per (kind of person, thing they want). Internal work counts:
a role inside the business recording or posting something is a need
with
enters_as. - States come from the business's own description of how a thing progresses. A pipeline is not an inbox: "answered" is rarely the real outcome.
max_fieldsis small on purpose. Reporting something from a phone gets 3. A first enquiry gets 4 to 6.- Two or three personas per public need. At least one must avoid the trade's own vocabulary. A persona must never quote a page heading.
aggregates.yaml
aggregates:
- bucket: leads
initial: open
states: # state: { event: past-tense verb }
open: { event: received }
quoted: { event: quoted }
won: { event: won }
lost: { event: lost }
transitions: # only moves the business described
open: [quoted, lost]
quoted: [won, lost]
The initial state should be open (a desk transition with no from
means from open).
A page
name: What brings you here? # always a question the visitor could ask themselves
description: >
One or two sentences.
responsible: { name: <person or team>, contact: <email> } # required on every entry page
alternatives:
- name: <heading> # what the visitor picks. Must work WITHOUT the description.
description: >
One or two sentences.
action: /received # absolute path of an existing page
consequence: [Send] # the button label
record_as: leads # omit for a pure navigation alternative
features:
- name: About the building
description: Optional line of guidance.
requirements:
- { name: email, label: Email, type: email }
- { name: problem, label: What is wrong, type: textarea }
- { name: urgency, label: How soon, type: select, options: [This week, This month, No rush] }
- { name: address, label: Address, optional: true }
- Field types:
text(default),email,tel,textarea,date,select(must listoptions). Use no other types. - Every
actionmust name a page that exists. After a recording alternative, send the visitor to a followup page that says who reads it, when they answer, and what happens next. A followup page is a top-level file withfollowup: true, or any non-index file in a subdirectory. Give it one alternative withaction: /to go back. - A page for members of a group sets
qualifies: <group>(or sits in a directory whose_section.yamldoes). Onlyenters_as: <group>needs can use it. - Every bucket that is recorded into must be read by a desk.
A desk
# questions/desk/_section.yaml
qualifies: project_managers
# questions/desk/index.yaml
name: What came in?
responsible: { name: ..., contact: ... }
alternatives:
- name: Leads
description: One line on what to do with them.
action: /desk
consequence: [Confirm]
features:
- name: ""
description: ""
resource:
source: { kind: kv, bucket: leads }
requires_group: project_managers
transitions:
- { to: quoted, label: Quote sent }
- { to: lost, label: Not for us }
- { from: quoted, to: won, label: Won }
- { from: quoted, to: lost, label: Lost }
- One desk directory per handling group; one alternative per bucket that group handles.
- Offer every move the state machine allows and no other. A state with no move out of it, on the way to a finished state, strands records.
- A group may read a bucket it does not handle: the same
resourceblock with its ownrequires_groupand notransitions.
site.yaml
title: <business name>
lang: en
Language
Everything a person reads is written in the intake's language: page
names, headings, descriptions, field labels, button labels, the
who/wants of each need, and the personas. Write it the way people
in that trade and country actually speak, not as a translation.
Everything a machine reads stays plain ASCII snake_case or kebab-case:
need ids, persona ids, bucket names, state names, field names, file
names and paths. Letters such as æ, ø and å belong in the copy and
never in an identifier. Set lang in site.yaml to the intake's code.
Copy rules
- Headings say what the visitor gets, in the visitor's words.
- Lead with what a thing enables. Never define something by what it is not. No negations in headings.
- Two alternatives must never be separable only by a distinction the business makes and the visitor does not.
- Every required field is one the business will use to answer.
The verifier
question_lint runs after every attempt. Non-zero exit means you are
not done. Its messages are exact:
no path from ... records into ...: no chain of alternatives from the entry page reaches an alternative with thatrecord_as.the path asks for N required field(s), over the budget: mark fieldsoptional: trueor drop them.no desk gated on "g" reads "b": add the desk."g" cannot move a record to "s"/records strand in "s": the desk is missing a transition the state machine allows, or the state machine is missing the edge.action ... does not match any declared question id: the page does not exist.entry page ... names no one responsible: addresponsible.