diff --git a/.gitignore b/.gitignore index 8d7c438..2d0841b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ coverage/ /docs/reviews/REVIEW-20260407-M1-012-fs-project-root.md /docs/reviews/REVIEW-20260407-M1-013-fs-artifact-path.md /docs/reviews/REVIEW-20260407-M1-014-fs-git-commit.md -/docs/aeos-decisions-artifact-tech-spec.md -/docs/auggie-cli-docs.md +/docs/old-secs/aeos-decisions-artifact-tech-spec.md +/docs/old-secs/auggie-cli-docs.md /code-review/REVIEW-20260418-213154-commit-cf33254.md /code-review/REVIEW-20260420-104719-aeos-decisions-tech-spec.md /.idea/ diff --git a/docs/gap-interrogation-runbook.md b/docs/gap-interrogation-runbook.md new file mode 100644 index 0000000..b836b3e --- /dev/null +++ b/docs/gap-interrogation-runbook.md @@ -0,0 +1,228 @@ +# Gap Interrogation — Session Runbook + +**Phase 2 of the prototype-to-spec playbook.** The session where absences become decisions. + +**Timebox:** 5 min questions, 15 min conflicts, then 20 minutes per story. **People:** two minimum. **Output:** `decisions.yaml`. + +--- + +## What this session is and isn't + +The harvester has already enumerated two things: where the sources **disagree** (conflicts) and what none of them **say** (absences). You are not discovering these — you are **triaging and deciding** them. That makes this much faster than a blank-page Example Mapping session, and it means the failure mode is different: the risk is not missing things, it's rushing the decisions. + +Questions come first, then conflicts, then story work. Questions are unknowns about the *sources* rather than product decisions — and answering one often promotes it into a conflict or an absence that then needs working in the same session, so leaving them for later reopens rounds you thought were closed. + +Conflicts are resolved before any story work. This is an ordering constraint rather than a preference — an unresolved vocabulary conflict makes half the absence descriptions ambiguous, and you will re-litigate every one of them after the rename. + +**This session decides *what*, never *how*.** The moment someone says "we could store that in a separate table," stop. Implementation is downstream and it is not your problem right now. + +--- + +## Before the session (15 minutes, solo) + +**Facilitator prep:** + +1. Read `feature-inventory.yaml` — `questions` first, then `conflicts`, then `absences`. + Pre-research every question marked `researchable: true` before the session; most will already have answers. +2. Filter conflicts to `requires_human: true`. Precedence has already resolved the rest; do not re-open them. +3. Sort absences by severity: `blocking` first, then `high`, then `normal`. +4. Count them. **Over 15 blocking absences means the story is too big** — split it before the session rather than discovering it 20 minutes in. +5. Flag any absence you can decide unilaterally (there are usually two or three that are obvious). Pre-decide them and mark for confirmation, not discussion. +6. Check `data[].glossary_term` for `UNMAPPED` entries. Vocabulary drift is a five-minute conversation now and a three-week refactor later. + +**Everyone reads the inventory beforehand.** Reading it in the room burns half the timebox. + +--- + +## Roles + +With two founders you're missing the third seat. Example Mapping assumes three perspectives — the one who wants it built, the one who builds it, and **the one whose job is to break it**. That third voice is the entire value of the format, and with two people it disappears unless you make it explicit. + +| Role | Who | Job | +|---|---|---| +| **Facilitator** | Whoever prepped | Keeps time, forces classification, blocks solutioning | +| **Advocate** | Usually the designer | Explains intent, what the user is trying to do | +| **Adversary** | **Rotates every story** | Only asks what breaks. Never defends anything. | + +The adversary role is non-optional and it must rotate. If the same person always plays it, it becomes their personality rather than a function, and the other person stops engaging with it. + +**If you're alone:** run `stakeholder-simulator` against the absence list before deciding. It won't replace the conversation, but it catches the categories a single perspective skips. + +--- + +## The session + +### Round 0 — questions, 5 minutes total + +Fast. Most are settled by someone who knows the codebase, or by a quick grep. + +| Outcome | Meaning | +|---|---| +| **ANSWERED** | Someone knew, or research settled it | +| **RECLASSIFIED** | It was a conflict or absence all along — create it, work it this session | +| **ESCALATED** | Nobody present knows | +| **DROPPED** | Based on a misreading — record the reason | + +**30 seconds per question.** Anything slower is not a question; it is an absence or conflict in disguise. Reclassify and move on rather than debating it here — that debate belongs in the round built for it. + +Every RECLASSIFIED question must produce a new item that is itself resolved before the session ends. An orphaned reclassification looks handled and isn't. + +### Round 1 — conflicts, 15 minutes total + +Not per story. Once, at the top, for the whole feature. + +Work only the conflicts marked `requires_human: true`, blocking first. For each, the facilitator reads the positions aloud and the `default_resolution` that precedence computed, then asks: + +> **"Does precedence get this right?"** + +Most of the time it does, and the answer takes ten seconds. Where it doesn't, classify: + +| Outcome | Meaning | Consequence | +|---|---|---| +| **RESOLVED** | One source wins | Retire the losing vocabulary or behaviour everywhere | +| **RECONCILED** | Neither was right | New answer, written down as the canonical one | +| **SPLIT** | Both are right | They were two concepts wearing one name — name both | +| **ESCALATED** | Needs someone not here | Named owner, dated | + +**SPLIT is the outcome worth slowing down for.** An apparent vocabulary clash sometimes reveals that you have been conflating two genuinely distinct concepts, and discovering that at spec time is worth more than the rest of the session combined. + +**Every resolution creates work.** Retiring a vocabulary means renaming modules, routes and tables; superseding a PRD means rewriting it before it can be used as a spec source. Record it as `debt_created` with an owner, or it silently doesn't happen and the conflict returns next feature. + +**Hard stop at 15 minutes.** Unresolved conflicts become ESCALATED, and any story they block does not proceed to Round 2. Building on an unresolved blocking conflict guarantees rework. + +### Round 2 — per story, 20 minutes + +**Minutes 0–2 — Frame** +Read the story aloud. State the non-goals you already know. Confirm the pre-decided absences in one pass — no discussion unless someone objects. + +**Minutes 2–15 — Work the absences, blocking first** + +For each absence, the facilitator asks exactly two questions: + +> **"What happens?"** — the advocate answers. +> **"What if it doesn't?"** — the adversary answers. + +Then classify. Every absence resolves to exactly one of four outcomes, and it must be spoken aloud before moving on: + +| Outcome | Meaning | Becomes | +|---|---|---| +| **DECIDED** | We know the behaviour | An acceptance criterion | +| **DEFERRED** | Explicitly out of scope | A written non-goal | +| **ASSUMED** | We're guessing, and we know it | An entry in the assumption register | +| **ESCALATED** | Needs someone or something not here | A blocker with a named owner and a date | + +**ASSUMED is a legitimate answer and you should expect several.** The point is not to decide everything — it is to make every guess visible. An unrecorded assumption is the same defect as an unnoticed gap, just harder to find later. + +**Minutes 15–18 — Sweep** +Any `normal` severity absences still untouched become ASSUMED by default, with a one-line note. Do not skip this — silent absences are what agents hallucinate into. + +**Minutes 18–20 — Record** +Facilitator reads back every ESCALATED item with its owner. If an escalation has no name attached, it isn't an escalation, it's a wish. + +--- + +## Hard stops + +**The timebox is not advisory.** When 20 minutes elapse: + +- **Story resolved** → move to the next one. +- **Story not resolved, but converging** → one 10-minute extension, once. Never twice. +- **Story not resolved and not converging** → **split the story.** Inability to resolve gaps within the box is the clearest available signal that the story is too large. This is a feature of the format, not a failure of the session. + +**Disagreement on a rule:** do not resolve it in the room. Record both positions in `decisions.yaml`, mark ESCALATED, and move on. Two founders arguing a product rule for fifteen minutes is how a two-hour session becomes a four-hour one and nothing else gets covered. + +--- + +## Output + +`specs//decisions.yaml`: + +```yaml +feature: shared-standup-notes +session: 2026-07-29 +participants: [kris, parker] +inventory: feature-inventory.yaml@abc123 + +conflict_resolutions: + - conflict: CON-9 + outcome: RESOLVED + resolution: > + "brief" is canonical for the entity, in copy and in new code. + CLAUDE.md precedence stands; PRD vocabulary is retired. + debt_created: + - task: Rename lib/wraps/, app/api/wraps/, app/app/wraps/ to briefs + owner: kris + - task: Rewrite PRD in current vocabulary before reuse as a spec source + owner: parker + affects_downstream: [api-contract, table-names, event-names, ui-copy] + +decisions: + - absence: ABS-4 + outcome: DECIDED + resolution: > + Relative timestamps render in viewer local time. Flip to absolute + after 7 days. Team-canonical timezone governs day boundaries for + digest grouping only. + becomes: AC-12 + + - absence: ABS-5 + outcome: ASSUMED + resolution: > + Concurrent edit by author while viewer has digest open: last-write-wins, + no conflict surface. + rationale: No evidence users co-edit entries; cost of merge UI unjustified + revisit_when: Any support ticket mentions lost edits, or >5 concurrent + editors observed on one digest + risk: medium + + - absence: ABS-9 + outcome: DEFERRED + resolution: Offline queueing out of scope for v1 + becomes: NON-GOAL-3 + + - absence: ABS-11 + outcome: ESCALATED + question: Does the digest need to respect per-workspace retention policy? + owner: kris + due: 2026-08-05 + blocks: [AC-15] + +conflicts_resolved: [CON-9] +conflicts_escalated: [] +assumptions_registered: [ABS-5] +escalations_open: [ABS-11] +debt_created: 2 +``` + +**The assumption register persists across features.** Every ASSUMED entry carries a `revisit_when` trigger. Review the register at the start of each planning cycle — assumptions have a shelf life, and the ones nobody revisits are the ones that eventually cost you a weekend. + +--- + +## Anti-patterns + +- **Re-opening precedence.** If `default_resolution` looks wrong for a whole class of conflict, fix `source_precedence` at the project level afterwards — do not relitigate it conflict by conflict in the room. +- **Resolving a conflict without assigning the rename.** The decision is free; the refactor is not. An unowned `debt_created` entry means the conflict comes back next feature. +- **Solutioning.** "We could use a CRDT for that" is not an answer to "what happens when two people edit?" Facilitator kills it immediately. +- **Defending the prototype.** The designer will want to explain why the gap isn't really a gap. Sometimes true, usually not — and either way it's the adversary's call, not the author's. +- **Resolving everything.** A session with zero ASSUMED entries means you either had a genuinely complete spec or you rubber-stamped guesses as decisions. It's almost always the second. +- **Skipping the boring categories.** Accessibility and lifecycle feel like they can wait. They cannot — they're cheap now and structural later. +- **Running it async.** This is the one part of the process that needs synchronous conversation. The value is in the speed of "what if" → "oh, then—" → "no wait." Comment threads flatten that into nothing. Slightly awkward for a company building async collaboration tooling, but true. + +--- + +## Definition of done + +- [ ] Every question has an outcome +- [ ] Every RECLASSIFIED question's new item is present and resolved +- [ ] Every `requires_human` conflict has an outcome +- [ ] Every conflict resolution records `debt_created` with owners +- [ ] No story proceeded past a conflict blocking it +- [ ] Every `blocking` absence has an outcome +- [ ] Every `high` absence has an outcome +- [ ] Remaining absences are explicitly ASSUMED with a note +- [ ] Every ASSUMED entry has a `revisit_when` trigger +- [ ] Every ESCALATED entry has a named owner and a date +- [ ] Every `UNMAPPED` glossary term is resolved or escalated +- [ ] No entry in `decisions.yaml` describes an implementation + +When this is done, the story is ready for spec writing. Not before. diff --git a/docs/01-product-brief.md b/docs/old-secs/01-product-brief.md similarity index 100% rename from docs/01-product-brief.md rename to docs/old-secs/01-product-brief.md diff --git a/docs/02-prd.md b/docs/old-secs/02-prd.md similarity index 100% rename from docs/02-prd.md rename to docs/old-secs/02-prd.md diff --git a/docs/03-system-design.md b/docs/old-secs/03-system-design.md similarity index 100% rename from docs/03-system-design.md rename to docs/old-secs/03-system-design.md diff --git a/docs/05-action-plan-v1.md b/docs/old-secs/05-action-plan-v1.md similarity index 100% rename from docs/05-action-plan-v1.md rename to docs/old-secs/05-action-plan-v1.md diff --git a/docs/aeos-autonomous-orchestrator-tech-spec.md b/docs/old-secs/aeos-autonomous-orchestrator-tech-spec.md similarity index 98% rename from docs/aeos-autonomous-orchestrator-tech-spec.md rename to docs/old-secs/aeos-autonomous-orchestrator-tech-spec.md index df88dcf..a030787 100644 --- a/docs/aeos-autonomous-orchestrator-tech-spec.md +++ b/docs/old-secs/aeos-autonomous-orchestrator-tech-spec.md @@ -2,7 +2,7 @@ **Status:** Proposed **Date:** 2026-07-20 -**Supersedes the decision gate in:** [LangGraph Orchestration Tech Spec](./aeos-langgraph-orchestration-tech-spec.md) +**Supersedes the decision gate in:** [LangGraph Orchestration Tech Spec](aeos-langgraph-orchestration-tech-spec.md) ## Summary @@ -256,6 +256,6 @@ What does **not** change: the outer column pipeline stays in SQLite and `StateMa ## See also -- [LangGraph Orchestration Tech Spec](./aeos-langgraph-orchestration-tech-spec.md) -- [Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md) -- [System Design](./03-system-design.md) +- [LangGraph Orchestration Tech Spec](aeos-langgraph-orchestration-tech-spec.md) +- [Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md) +- [System Design](03-system-design.md) diff --git a/docs/aeos-langgraph-orchestration-tech-spec.md b/docs/old-secs/aeos-langgraph-orchestration-tech-spec.md similarity index 97% rename from docs/aeos-langgraph-orchestration-tech-spec.md rename to docs/old-secs/aeos-langgraph-orchestration-tech-spec.md index 9e7f115..aff658e 100644 --- a/docs/aeos-langgraph-orchestration-tech-spec.md +++ b/docs/old-secs/aeos-langgraph-orchestration-tech-spec.md @@ -53,7 +53,7 @@ Three structural problems: ### What already works and must not regress -- **The event model.** `src/domain/model/ticket-run-event.ts` and `TicketRunEventEmitter` implement the design in [the event model proposal](./aeos-ticket-run-event-model-proposal.md): a single run-scoped sequencer assigns monotonic `sequence`, stamps run metadata, and forwards to an optional observer. The doc's `TicketRunPhase` union is *already* an enumeration of orchestration stages — it maps 1:1 onto graph nodes, which is strong evidence the decomposition below is natural rather than imposed. +- **The event model.** `src/domain/model/ticket-run-event.ts` and `TicketRunEventEmitter` implement the design in [the event model proposal](aeos-ticket-run-event-model-proposal.md): a single run-scoped sequencer assigns monotonic `sequence`, stamps run metadata, and forwards to an optional observer. The doc's `TicketRunPhase` union is *already* an enumeration of orchestration stages — it maps 1:1 onto graph nodes, which is strong evidence the decomposition below is natural rather than imposed. - **Streaming.** Executor adapters forward `stdout`/`stderr` chunks live via `onChunk` → emitter → Ink. LangGraph's streaming adds nothing here. - **The Ink TUI.** `src/cli/ui/ticket-run-shell-state.ts` (475 lines) consumes the event stream. It must be left untouched by this migration. @@ -366,6 +366,6 @@ A secondary consideration, not decisive: `docs/01-product-brief.md` positions AE ## See also -- [AEOS Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md) -- [AEOS Ticket Run Implementation Plan](./aeos-ticket-run-implementation-plan.md) -- [System Design](./03-system-design.md) +- [AEOS Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md) +- [AEOS Ticket Run Implementation Plan](aeos-ticket-run-implementation-plan.md) +- [System Design](03-system-design.md) diff --git a/docs/aeos-pipeline-integrity-tech-spec.md b/docs/old-secs/aeos-pipeline-integrity-tech-spec.md similarity index 100% rename from docs/aeos-pipeline-integrity-tech-spec.md rename to docs/old-secs/aeos-pipeline-integrity-tech-spec.md diff --git a/docs/aeos-postmortem-stan1.md b/docs/old-secs/aeos-postmortem-stan1.md similarity index 100% rename from docs/aeos-postmortem-stan1.md rename to docs/old-secs/aeos-postmortem-stan1.md diff --git a/docs/aeos-ticket-run-doc-artifacts-plan.md b/docs/old-secs/aeos-ticket-run-doc-artifacts-plan.md similarity index 100% rename from docs/aeos-ticket-run-doc-artifacts-plan.md rename to docs/old-secs/aeos-ticket-run-doc-artifacts-plan.md diff --git a/docs/aeos-ticket-run-event-model-proposal.md b/docs/old-secs/aeos-ticket-run-event-model-proposal.md similarity index 97% rename from docs/aeos-ticket-run-event-model-proposal.md rename to docs/old-secs/aeos-ticket-run-event-model-proposal.md index 10abe61..6a9bafa 100644 --- a/docs/aeos-ticket-run-event-model-proposal.md +++ b/docs/old-secs/aeos-ticket-run-event-model-proposal.md @@ -196,7 +196,7 @@ Observer delivery should be documented as **in-process, best-effort, and non-dur ## See also -- [AEOS Ticket Run Terminal Layout Spec](./aeos-ticket-run-terminal-layout-spec.md) -- [AEOS Ticket Run Implementation Plan](./aeos-ticket-run-implementation-plan.md) -- [System Design](./03-system-design.md) -- [M7-003 Dashboard Task](./tasks/M7-003-aeos-dashboard.md) \ No newline at end of file +- [AEOS Ticket Run Terminal Layout Spec](aeos-ticket-run-terminal-layout-spec.md) +- [AEOS Ticket Run Implementation Plan](aeos-ticket-run-implementation-plan.md) +- [System Design](03-system-design.md) +- [M7-003 Dashboard Task](../tasks/M7-003-aeos-dashboard.md) \ No newline at end of file diff --git a/docs/aeos-ticket-run-implementation-plan.md b/docs/old-secs/aeos-ticket-run-implementation-plan.md similarity index 96% rename from docs/aeos-ticket-run-implementation-plan.md rename to docs/old-secs/aeos-ticket-run-implementation-plan.md index 832acaf..eecb2bc 100644 --- a/docs/aeos-ticket-run-implementation-plan.md +++ b/docs/old-secs/aeos-ticket-run-implementation-plan.md @@ -32,7 +32,7 @@ This keeps the current `TicketRunResult` return type intact while making progres **Changes** -- Introduce the event types defined in [AEOS Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md). +- Introduce the event types defined in [AEOS Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md). - Treat the event stream as an application/orchestration contract even if it lives near the driving port for pragmatic reasons. - Define terminal lifecycle semantics up front, including `ticket-run.interrupted` on the stream and its compatibility story with the unchanged `TicketRunResult` contract. - Define the rule that every `stage.started` must close with exactly one `stage.completed` or `stage.failed` event. @@ -196,7 +196,7 @@ This keeps the current `TicketRunResult` return type intact while making progres ## See also -- [AEOS Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md) -- [AEOS Ticket Run Terminal Layout Spec](./aeos-ticket-run-terminal-layout-spec.md) -- [System Design](./03-system-design.md) -- [M7-003 Dashboard Task](./tasks/M7-003-aeos-dashboard.md) \ No newline at end of file +- [AEOS Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md) +- [AEOS Ticket Run Terminal Layout Spec](aeos-ticket-run-terminal-layout-spec.md) +- [System Design](03-system-design.md) +- [M7-003 Dashboard Task](../tasks/M7-003-aeos-dashboard.md) \ No newline at end of file diff --git a/docs/aeos-ticket-run-terminal-layout-spec.md b/docs/old-secs/aeos-ticket-run-terminal-layout-spec.md similarity index 97% rename from docs/aeos-ticket-run-terminal-layout-spec.md rename to docs/old-secs/aeos-ticket-run-terminal-layout-spec.md index 33aa5e5..62dff85 100644 --- a/docs/aeos-ticket-run-terminal-layout-spec.md +++ b/docs/old-secs/aeos-ticket-run-terminal-layout-spec.md @@ -22,7 +22,7 @@ Specify the live split-pane UI for `aeos ticket run` so users can see both AEOS ## Inputs -This UI consumes the event types proposed in [AEOS Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md). +This UI consumes the event types proposed in [AEOS Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md). - Left pane: lifecycle, stage, sub-state, artifact, review, and cost events. - Right pane: `executor.stdout.chunk` and `executor.stderr.chunk` events. @@ -212,6 +212,6 @@ Example fallback: ## See also -- [AEOS Ticket Run Event Model Proposal](./aeos-ticket-run-event-model-proposal.md) -- [AEOS Ticket Run Implementation Plan](./aeos-ticket-run-implementation-plan.md) -- [System Design](./03-system-design.md) \ No newline at end of file +- [AEOS Ticket Run Event Model Proposal](aeos-ticket-run-event-model-proposal.md) +- [AEOS Ticket Run Implementation Plan](aeos-ticket-run-implementation-plan.md) +- [System Design](03-system-design.md) \ No newline at end of file diff --git a/docs/standin-prototype-to-spec-playbook.md b/docs/standin-prototype-to-spec-playbook.md new file mode 100644 index 0000000..95277bb --- /dev/null +++ b/docs/standin-prototype-to-spec-playbook.md @@ -0,0 +1,198 @@ +# From Prototype to Agent-Ready Spec — StandIn Playbook + +**Input:** an HTML prototype and a handful of user stories written as bare statements. +**Output:** a specification package an agent can implement without inventing anything. +**Cost:** roughly one to one and a half days of front-loaded human work per feature. + +--- + +## The reframe + +Two things are true about the prototype at the same time, and the procedure exists to exploit the first and defend against the second. + +**It is an under-extracted specification.** It already encodes the screen states, the interaction affordances, the field-level data shape, the transitions, and much of the validation. Most teams throw this away and let the implementer re-derive it from scratch. That is the single largest waste in a design handoff. + +**It is a dangerous illusion of completeness.** A prototype shows the happy path for one role at one moment. It does not show conflict, permission denial, partial failure, or staleness — and an agent handed a prototype will confidently invent all of them. + +For StandIn specifically the second problem is acute. **A prototype shows a moment; async collaboration is about the gaps between moments.** The hardest questions in your domain — what two people see when they act simultaneously, what someone sees returning after twelve hours across a timezone boundary, what happens to state that was true when it was written and isn't now — are structurally invisible in an HTML mockup. If the procedure has one job, it is dragging those into the open before an agent guesses. + +--- + +## Phase 0 — Domain language (once per bounded context, not per feature) + +Before any feature work: a glossary for the context this feature lives in. + +If Parker's prototype says *workspace*, your API says *team*, and the schema says *org*, agents will produce all three inconsistently and you will spend the rest of the project translating. Ubiquitous language is not documentation hygiene here — it is the literal input format for every agent prompt. + +**Deliverable:** `contexts//glossary.md` — term, definition, and explicitly the terms it is *not* a synonym for. + +--- + +## Phase 1 — Harvest every source + +Mechanical, LLM-assisted, human-verified. This is where the review-surface principle applies to design handoff: **the human reviews the extracted inventory, not the sources.** + +The prototype is rarely alone. Expect four or five sources describing the same feature — PRD, story set, prototype, existing codebase, conventions file (`CLAUDE.md`) — and expect them to disagree. That produces a second class of finding alongside absence: + +| | Meaning | Failure mode | +|---|---|---| +| **Absence** | Nobody decided | The agent invents it | +| **Conflict** | Somebody decided, twice, incompatibly | Downstream artefacts **fork** | + +Vocabulary conflicts are the expensive ones — four names for one entity forks the API contract, table names, event names, UI copy and every ticket. Watch particularly for a prototype using one vocabulary in **copy** and another in **identifiers**; designers rename copy long before anyone renames code. + +Declare **source precedence** once per project (typically: conventions → codebase → prototype → stories → PRD). Most conflicts then resolve automatically, and only genuine product questions reach Phase 2. + +Extract into a structured file: + +| Inventory | What to capture | +|---|---| +| **Screens & states** | Every distinct view state present in the HTML | +| **Interactions** | Every control, what it triggers, what disables it | +| **Data** | Every field displayed or edited → candidate entity attributes | +| **Transitions** | The state machine the prototype implies | +| **Present states** | Which empty / loading / error states were actually designed | +| **Absent states** | Which were not — this list feeds Phase 2 | + +**Tooling:** none of the off-the-shelf design-to-code tools do this — they generate components, which is the opposite of what you want here. You need a Claude Code skill you write yourself (suggested name: `prototype-harvest`) that takes the HTML and emits `feature-inventory.yaml`. Point it at every source and **pin each to a commit SHA** — a spec derived from an unpinned source drifts silently when someone pushes again. + +**Deliverable:** `feature-inventory.yaml` — conflicts first, then the inventory, then absences. + +--- + +## Phase 2 — Interrogate the gaps + +The expensive human phase, and the one that defines the Product Engineering Architect role. Run the inventory against a fixed checklist. Fixed matters — memory is not a procedure. + +**Round 0 — answer questions. Round 1 — resolve conflicts. Round 2 — absences, per story.** + +Questions come first because they are unknowns about the *sources*, not product decisions — and answering one often promotes it into a conflict or an absence that then needs working in the same session. Pre-research the `researchable: true` ones; most are settled by grep. Anything taking more than 30 seconds is not a question and gets reclassified. + +Then conflicts, before absences. This is an ordering constraint, not a preference: an unresolved vocabulary conflict makes half the absence descriptions ambiguous, and you will re-litigate them after renaming. Precedence will have pre-resolved most; work only the `requires_human: true` ones. Conflicts resolve as RESOLVED (one source wins), RECONCILED (neither was right), SPLIT (both right — they were two concepts all along), or ESCALATED. + +**Async-specific (StandIn's real risk surface)** +- Two people act on the same object simultaneously — last-write-wins, merge, or conflict surface? +- Someone returns after twelve hours. What is highlighted as changed? What is silently different? +- What is the ordering guarantee, and is it wall-clock or causal? +- What does a user see about state that was true when written and isn't now? +- Timezone: whose day boundary governs? What renders in whose local time? +- Who gets notified, how is it batched, and what is the fatigue ceiling? + +**Universal** +- **Permissions** — who can see and do each thing? Prototypes are always drawn as the most privileged role. +- **Failure** — network drops mid-action. What is the recovery, and is the action idempotent? +- **Boundaries** — zero items, one item, ten thousand items, a 4,000-character title. +- **Lifecycle** — what happens to this when its parent is archived or deleted? +- **Concurrency of the mundane** — double-click on submit. + +Each unanswered question is either a product decision you make now, or a bug an agent invents later. There is no third outcome. + +**Format:** run this as **Example Mapping** — story in the centre, rules above, examples below, open questions on the side. Twenty minutes per story with both founders. Questions that survive the session are escalations, not assumptions. + +**Runbook:** `gap-interrogation-runbook.md` — session structure, roles, the four outcome types, timeboxing rules, and the `decisions.yaml` output format. + +**Deliverable:** `decisions.yaml` — every absence resolved as DECIDED, DEFERRED, ASSUMED, or ESCALATED. + +--- + +## Phase 3 — Write the specification + +Only now. The user stories become real. + +- **Acceptance criteria in EARS**, each with a stable ID (`AC-1`, `AC-2`…). +- **Decision tables** for anything with more than three interacting conditions — EARS becomes unreadable there and agents misparse nested conditionals. +- **Gherkin scenarios** for the flows worth exemplifying, each tagged with the AC IDs it covers. +- **Invariants** stated explicitly — the things that must always be true regardless of path. In async collaboration these are usually about ordering and convergence. +- **Non-goals** stated explicitly. Agents fill silence with plausible defaults; the cheapest defence is not leaving silence. +- **Risk tag** per story — drives which verification tier applies. + +Your existing `user-story-writer` and `agile-task-writer` skills cover part of this. What they need adding: the AC ID scheme and the requirement that every criterion trace to a prototype element or an answered Phase 2 question. + +**Deliverable:** `spec.md` with AC IDs, `features/*.feature`, `decisions/*.dmn` or a markdown decision table. + +--- + +## Phase 4 — Contracts and constraints, before any implementation + +Everything structural gets defined and generated deterministically. Nothing here is left to an agent's judgment. + +| Artifact | Format | Derived from | +|---|---|---| +| API contract | OpenAPI 3.1 | Interaction + data inventory | +| Domain model | Entities, value objects, invariants | Data inventory + Phase 2 answers | +| DB schema & migration | Prisma / Atlas | Domain model, diff-based, human-gated | +| Event schema | AsyncAPI / CloudEvents | Notification and side-effect answers | +| State machine | XState | Transition inventory | +| Architecture rules | ArchUnit / Konsist tests | Context boundaries | +| Validation | JSON Schema | Domain model | + +**Two non-negotiables:** + +The **migration plan is human-reviewed**, always. It is the one artifact where a wrong generation is expensive and irreversible. + +The **architecture rules ship as failing tests before implementation starts.** An agent that cannot import infrastructure into the domain layer will not, regardless of what it "understood" from the prompt. This is the difference between a constraint and a suggestion. + +--- + +## Phase 5 — The handoff package + +What actually reaches the agent: + +``` +├── glossary.md # ubiquitous language, this context only +├── spec.md # AC-1..n, EARS, non-goals, risk tags +├── features/*.feature # Gherkin, tagged with AC IDs +├── contracts/openapi.yaml # generated, authoritative +├── generated/ # types, DTOs, stubs, validators — do not edit +├── migrations/ # reviewed, approved +├── arch-tests/ # ArchUnit rules, currently failing +└── prototype/ # HTML @ commit abc123 — REFERENCE ONLY +``` + +**Mark the prototype non-authoritative for behaviour, explicitly, in the prompt.** Prototypes contain lorem ipsum, arbitrary spacing, placeholder counts, and dead links. An agent cannot distinguish "this is the design" from "this is filler," and will faithfully implement a hardcoded list of three fake users if you let it. The spec is authoritative; the prototype is visual reference for layout and nothing else. + +--- + +## Phase 6 — Close the loop back to the prototype + +The prototype stops being a discarded artifact and becomes a test fixture. + +- **Visual regression** (Playwright screenshots) against the prototype for the components that should match. +- **DOM structure comparison** for layout-critical views. +- Divergence here is either a bug or a design change — and either way it should be a conversation with Parker, triggered mechanically rather than noticed in a demo six weeks later. + +--- + +## The gate + +An agent does not start until every line is true: + +- [ ] Glossary exists for this bounded context +- [ ] Every user story has EARS acceptance criteria with stable IDs +- [ ] Every Phase 2 question is answered or explicitly deferred with a named owner +- [ ] Non-goals are written down +- [ ] API contract exists and is generated, not hand-written +- [ ] Domain model and invariants are stated +- [ ] Migration plan is human-reviewed +- [ ] Architecture rules exist as failing tests +- [ ] Prototype is pinned to a commit SHA and marked non-authoritative + +Nine checkboxes. If you cannot tick them, the work is not ready — and "we'll figure it out while building" was survivable when a human held the intent in their head throughout. It is not survivable when the builder has no memory and no stake. + +--- + +## On the cost + +A day and a half of specification before a line of code feels expensive with a two-person team. + +The comparison that matters is not "a day and a half versus zero." It is "a day and a half now versus the same work later, done in review comments, at a worse moment, against code that already exists and has to be argued with." Front-loading is not overhead; it is the same cost paid where it is cheapest. + +The honest exception: for genuinely exploratory work where nobody knows the acceptance criteria until something has been built and looked at, skip all of this and build a throwaway. Then spec it properly and rebuild. Do not pretend the throwaway was the feature. + +--- + +## Mapping to AEOS + +This procedure is the concrete form of the `PRODUCT_SCOPING` → `TECH_SPEC` columns, and building it manually for StandIn first is the right sequence — it gives you a real corpus for the Q3 evaluation harness and a real test of whether human-authored scoping is workable before you automate it. + +Four new Claude Code skills to write, none of which exist today: `prototype-harvest` (Phase 1), `gap-interrogation` (Phase 2), `spec-writer` (Phase 3 — extends your existing `user-story-writer` and `agile-task-writer`), `contract-generator` (Phase 4). Phase 2 is the one to leave human longest — it is where the product decisions actually live. diff --git a/files.zip b/files.zip new file mode 100644 index 0000000..306af0f Binary files /dev/null and b/files.zip differ diff --git a/skills/gap-interrogation-capture/SKILL.md b/skills/gap-interrogation-capture/SKILL.md new file mode 100644 index 0000000..130c658 --- /dev/null +++ b/skills/gap-interrogation-capture/SKILL.md @@ -0,0 +1,183 @@ +--- +name: gap-interrogation-capture +description: Turn the raw output of a real gap-interrogation workshop — transcript, meeting notes, whiteboard photo, Miro board, or bullet points — into a structured decisions.yaml, and update every downstream document that depends on it. Use when the user has already held the session and has notes or a recording to process, or says things like "here's the transcript", "we ran the workshop", "process my notes", "capture what we decided", or shares a recording alongside a feature inventory. Reconciles what was discussed against what was harvested, in both directions. This skill records what was said — it never decides what was not. +allowed-tools: Read, Write, Glob, Grep, AskUserQuestion +--- + +# Gap Interrogation — Capture + +Phase 2 of prototype-to-spec, after a real session. + +Humans hold the workshop; this turns its residue into structured artefacts and propagates them. Transcripts are messy, ambiguous, and full of half-finished thoughts. The entire difficulty is telling a **decision** apart from a **proposal that nobody responded to**. + +**Output contract:** `decisions.yaml` with `mode: workshop-captured`, identical in structure to `gap-interrogation-solo`. Downstream spec writing does not care which produced it. + +--- + +## The one rule + +**Record what was decided. Never decide what was not.** + +A transcript contains proposals, options, thinking-aloud, and decisions, interleaved and rarely marked. Promoting a proposal to a decision is the failure mode of this skill, and it is silent — the artefact looks complete and is wrong. + +When the evidence is ambiguous, mark it `capture_confidence: ambiguous` and **ask**. Never resolve ambiguity by picking the reading that produces a tidier document. + +--- + +## Inputs + +| Artefact | Notes | +|---|---| +| Transcript | Otter, Granola, Zoom, Meet — speaker labels help but are not required | +| Meeting notes | Bullets, prose, anything | +| Whiteboard photo | Read the image directly | +| Miro board | Via the Miro connector if available | +| Voice memo transcript | Expect heavy ambiguity; confirm more | + +Plus, always: `/features//feature-inventory.yaml` — the absence and conflict IDs are the checklist the session was working against. + +If the inventory is missing, stop and say so. Without it there is nothing to reconcile against and the output is just reformatted notes. + +--- + +## Process + +### Step 0: Locate the artefact tree + +All specification artefacts live in a **separate git tree** from the code. Resolve its root before doing anything else — env `AEOS_ARTEFACT_TREE`, then project config, then a sibling `*-specs` directory, then **ask**. Never fall back to the working directory, and never write specification artefacts into the code repo. + +Record artefact-tree `HEAD` now; you will need it to detect concurrent writes before committing. See `references/artefact-tree.md`. + +### Step 1: Load both sides + +Read `feature-inventory.yaml` (conflicts and absences with their IDs) and the session artefact. Read `assumption-register.yaml` from the artefact tree if present. + +### Step 2: Extract candidate outcomes + +Walk the artefact and pull every passage that looks like a resolution. For each, capture the **verbatim supporting text** — you will need it for the confidence judgement and the user will need it to confirm. + +### Step 3: Map to inventory IDs + +Match each candidate to a `Q-n`, `CON-n` or `ABS-n`. + +**Questions resolve differently.** A question is an unknown about the sources, not a product decision, and answering one often reclassifies it. Map question outcomes to `ANSWERED`, `RECLASSIFIED`, `ESCALATED`, or `DROPPED`. A RECLASSIFIED question must produce a new `CON-n` or `ABS-n` — if the session answered a question and then discussed the thing it became, capture both, linked. + +**Attempt research for unanswered questions.** Any `researchable: true` question the session never reached can often be settled by reading the code. Do that and record `answered_by: research` rather than reporting it as still open. Matching is by topic, not by anyone having said the ID aloud — real sessions never do. + +**Unmatched in both directions, and both matter:** + +- **Inventory item never discussed** → still open. Do not silently drop it. These are the gaps the session ran out of time for, and they are exactly what an agent will hallucinate into. +- **Decision about something not in the inventory** → **the harvest missed something.** Record it as a new finding and report it as feedback to `prototype-harvest`. This is how the harvester improves. + +The second is the more valuable output. A session that only ever discussed harvested items means either a very good harvest or a session that never went off-script — and it is usually the second. + +### Step 4: Classify and rate confidence + +Assign each mapped item an outcome — `RESOLVED`/`RECONCILED`/`SPLIT`/`ESCALATED` for conflicts, `DECIDED`/`DEFERRED`/`ASSUMED`/`ESCALATED` for absences — and a confidence: + +| Confidence | Means | Action | +|---|---|---| +| `explicit` | Someone stated the decision and nobody contradicted it | Record | +| `inferred` | Clear from context, never stated in one piece | Record, flag in report | +| `ambiguous` | Could be a decision or a floated option | **Ask before recording** | + +**Signals of a proposal rather than a decision:** +- Conditional framing — "we could", "one option is", "what if we" +- No response from anyone else +- Contradicted later and never reconciled +- Trailing off, or a topic change mid-thought + +**Signals of a genuine decision:** +- Stated flatly, in the present or future indicative +- Acknowledged by another participant +- Referred back to later as settled +- Someone recorded an action from it + +Attribution matters too: a facilitator restating an option is not the same as a founder choosing it. + +### Step 5: Fill required fields + +Real sessions skip these constantly. Ask rather than invent: + +| Outcome | Missing field | Ask | +|---|---|---| +| ASSUMED | `revisit_when` | "What would tell you this assumption was wrong?" | +| ESCALATED | `owner`, `due` | "Who owns this, and by when?" | +| RESOLVED | `debt_created` | "What does this decision oblige someone to change?" | +| DECIDED | one-sentence statement | Offer a draft from the transcript, ask to confirm | + +Batch these into a single round of questions rather than interrupting per item. + +### Step 6: Write + +Write `/features//decisions.yaml` per `/schemas/decisions.schema.json`, validating before commit, with `meta.mode: workshop-captured`, `meta.source_artefact`, and `capture_confidence` on every entry. + +### Step 7: Propagate + +Update the documents that depend on these decisions: + +| Document | Update | +|---|---| +| `/assumption-register.yaml` | Append every ASSUMED entry | +| `feature-inventory.yaml` | Mark resolved items; add findings the session surfaced | +| `contexts//glossary.md` | Apply vocabulary resolutions from RESOLVED/RECONCILED/SPLIT conflicts | +| `contexts//CONSTRAINTS.md` | Add any architecture constraint the session agreed | +| Task tracker | One task per `debt_created` entry, with its owner — **outside** the artefact tree | + +All artefact-tree edits land in a **single commit** so the specification moves atomically: + +``` +decide(): 3 conflicts resolved, 18 absences closed (workshop) + +Inventory: +Source: notes/2026-07-29-session.md +Refs: CON-9, ABS-4, ABS-5 +``` + +If artefact-tree `HEAD` moved since Step 0, re-read the affected files and merge — never overwrite a concurrent write. Commit; never push. + +For glossary and constraints changes, **show the diff and confirm before writing.** These are project-wide and a misread transcript propagates further than a wrong `decisions.yaml`. + +### Step 8: Validate and report + +- [ ] `meta.mode: workshop-captured` and `source_artefact` recorded +- [ ] Every question has an outcome +- [ ] Every RECLASSIFIED question's new CON/ABS is present and resolved +- [ ] Every entry has `capture_confidence` +- [ ] No `ambiguous` entry was recorded without confirmation +- [ ] Every ASSUMED has an observable `revisit_when` +- [ ] Every ESCALATED has an owner and date +- [ ] Every RESOLVED/RECONCILED has `debt_created` with owners +- [ ] Unmatched inventory items are listed as still open +- [ ] Off-inventory decisions are reported as harvest feedback + +**Report, in this order:** + +1. **Inventory items the session never reached** — still open, and the most likely source of downstream surprise +2. **Decisions made about things not harvested** — feedback for `prototype-harvest` +3. Everything recorded as `inferred` — worth a second pair of eyes +4. Conflicts resolved and debt created, with owners +5. Documents updated +6. Stories now ready for spec writing + +Lead with what is still open. A capture that reports only what was decided reads as complete and is the more dangerous artefact. + +--- + +## What this skill does NOT do + +- Decide anything the session did not +- Resolve ambiguity by choosing the tidier reading +- Write acceptance criteria +- Invent `revisit_when` triggers, owners, or dates +- Silently drop inventory items the session never reached + +--- + +## Tips for Best Results + +- **Speaker labels change the reading.** The advocate floating an option and the adversary conceding one are different events. Where labels exist, use them. +- **The end of a session is the least reliable part.** People compress, agree quickly, and stop challenging. Rate late decisions `inferred` more readily. +- **"We'll figure it out during implementation" is ASSUMED, not DEFERRED.** Deferred means explicitly out of scope; this is a guess with no trigger attached. +- **A session with zero off-inventory decisions is worth questioning.** Either the harvest was excellent or nobody went off-script. +- **Photographs of whiteboards lose ordering.** Ask which cluster was resolved first if the sequence matters to the reading. diff --git a/skills/gap-interrogation-capture/references/artefact-tree.md b/skills/gap-interrogation-capture/references/artefact-tree.md new file mode 100644 index 0000000..e07480b --- /dev/null +++ b/skills/gap-interrogation-capture/references/artefact-tree.md @@ -0,0 +1,108 @@ +# Artefact Tree Conventions + +All specification artefacts are versioned in a **separate git tree** from the code. Every skill in the spec pipeline reads from and writes to that tree, never to the code repository. + +This is not a filing preference. It gives the specification its own history, uncontaminated by code churn — which is what makes "the spec is the system, the code is a build artefact" mechanically true rather than aspirational, and what makes divergence detection a diff rather than an opinion. + +--- + +## Layout + +``` +/ +├── assumption-register.yaml # project-wide, persists across features +├── source-precedence.yaml # project-wide, declared once +├── contexts/ +│ └── / +│ ├── glossary.md # ubiquitous language +│ ├── CONSTRAINTS.md # architecture rules +│ └── decisions/ # ADRs +└── features/ + └── / + ├── feature-inventory.yaml # prototype-harvest + ├── decisions.yaml # gap-interrogation-* + ├── spec.md # spec-writer + ├── features/*.feature # Gherkin + └── contracts/openapi.yaml # generated +``` + +**Never write specification artefacts into the code repo.** If a skill cannot locate the artefact tree, it stops and asks — it does not fall back to the working directory. + +--- + +## Locating the tree + +Resolve in this order: + +1. `AEOS_ARTEFACT_TREE` environment variable +2. `artefactTree` in project config +3. A sibling directory matching `*-specs` or `*-artefacts` +4. **Ask the user.** Do not guess. + +Record the resolved root and its branch in every output's `meta`. + +--- + +## References are triples, never bare SHAs + +A commit hash means nothing without its repository. Sources for one feature routinely live in three or four different trees — design, code, artefacts — and a bare `@abc123` is ambiguous at best and silently wrong at worst. + +Every reference carries repo, commit, and path: + +```yaml +ref: + repo: standin-design + commit: abc123def456 + path: prototype/composer-standalone-src.html +``` + +This applies to sources in `feature-inventory.yaml`, to the inventory reference in `decisions.yaml`, and to anything a downstream artefact points back at. + +--- + +## Committing + +Every skill commits its own output. A written-but-uncommitted artefact is invisible to everything downstream. + +**Before writing:** record artefact-tree `HEAD`. **After writing:** if `HEAD` moved, re-read the affected files and merge rather than overwrite — another skill or another person may have written concurrently. + +**Message format:** + +``` +(): + +Sources: standin-design@abc123, standin@def456 +Inventory: +Refs: CON-9, ABS-4, ABS-5 +``` + +| Skill | Verb | Summary | +|---|---|---| +| `prototype-harvest` | `harvest` | inventory from N sources, M conflicts, K absences | +| `gap-interrogation-solo` | `decide` | M conflicts resolved, K absences closed (simulated) | +| `gap-interrogation-capture` | `decide` | M conflicts resolved, K absences closed (workshop) | +| `spec-writer` | `spec` | N acceptance criteria | + +Commit; never push. Pushing is a human action. + +--- + +## Linking code back to artefacts + +The code repo carries the link, as commit trailers: + +``` +Spec: standin-specs@abc123 +Implements: AC-12, AC-13 +``` + +This is what makes traceability mechanical across trees: given any code commit you can resolve the exact specification revision it claims to implement, and given any specification revision you can find what implements it. Divergence detection, AC coverage reporting, and the "is anything unimplemented?" question all depend on it. + +--- + +## Branching + +- Artefact work for a feature happens on `feature/` in the artefact tree +- Merged to `main` when the feature's spec is approved — **not** when the code ships +- The artefact tree's `main` is the current agreed specification of the system, always +- Tag artefact-tree commits at release so a shipped version resolves to the spec it was built from diff --git a/skills/gap-interrogation-capture/references/outcomes.md b/skills/gap-interrogation-capture/references/outcomes.md new file mode 100644 index 0000000..f2897e1 --- /dev/null +++ b/skills/gap-interrogation-capture/references/outcomes.md @@ -0,0 +1,95 @@ +# Outcome Semantics + +What each outcome *means* and when to choose it. The machine-checkable structure lives in `/schemas/decisions.schema.json` — this file is judgment, that file is shape. + +--- + +## Round 0 — questions + +A question is an unknown about the **sources**, not a product decision. Most resolve in seconds. + +| Outcome | Choose when | Requires | +|---|---|---| +| **ANSWERED** | Someone knew, or research settled it | `answer`, `answered_by` | +| **RECLASSIFIED** | It was a conflict or absence all along | `reclassified_as`, `becomes` | +| **ESCALATED** | Nobody present knows | `owner`, `due` | +| **DROPPED** | Based on a misreading of the sources | `reason` | + +**Research first.** `researchable: true` questions are usually answered by Grep, not judgment. Finding a fact is not deciding. + +**The 30-second rule.** Anything slower is not a question — it is an absence or conflict wearing a question's clothes. Reclassify rather than debate. + +**RECLASSIFIED is not terminal.** The new `CON-n` or `ABS-n` must be resolved in the same session. An orphaned reclassification looks handled and isn't. + +**DROPPED feeds the harvester.** A question asked because of a misreading means the extraction prompt needs work. The reason is that feedback. + +--- + +## Round 1 — conflicts + +Somebody already decided, twice, incompatibly. Downstream artefacts fork here. + +| Outcome | Choose when | Requires | +|---|---|---| +| **RESOLVED** | One source wins | `debt_created` | +| **RECONCILED** | None was right; new canonical answer | `debt_created` | +| **SPLIT** | Both right — two concepts, one name | `split_into` (≥2) | +| **ESCALATED** | Needs someone not present | `owner`, `due` | + +**`debt_created` is mandatory on RESOLVED and RECONCILED.** The decision is free; the rename, migration, or document rewrite it implies is not. An unowned entry means it silently doesn't happen and the conflict returns next feature. + +**SPLIT is worth slowing down for.** When positions describe things that behave differently, ask directly: *are these actually two concepts?* Discovering a conflated concept at spec time is worth more than the rest of the round. + +**Do not re-open what precedence settled.** If `default_resolution` is systematically wrong, fix `source-precedence.yaml` at project level afterwards — never item by item in the room. + +--- + +## Round 2 — absences + +Nobody decided. The agent will invent it. + +| Outcome | Choose when | Requires | Becomes | +|---|---|---|---| +| **DECIDED** | The behaviour is known | one-sentence `resolution` | An acceptance criterion | +| **DEFERRED** | Explicitly out of scope | — | A written non-goal | +| **ASSUMED** | Guessing, and we know it | `rationale`, `revisit_when`, `risk` | An assumption-register entry | +| **ESCALATED** | Needs someone not present | `owner`, `due` | A dated blocker | + +**Expect several ASSUMED per story.** The goal is not deciding everything — it is making every guess visible. A story with zero assumptions usually means guesses were rubber-stamped as decisions. Say so when you see it. + +**"We'll figure it out during implementation" is ASSUMED, not DEFERRED.** Deferred means explicitly out of scope. This is a guess with no trigger attached. + +**`decided_by: agent` forces ASSUMED.** When a human declines to decide and asks the system to pick, that is never `DECIDED`. A decision made by the thing that will implement it is the oracle inside the loop — the schema enforces this, but understand why. + +--- + +## What makes a `revisit_when` observable + +The trigger has to be something a person or a system can actually detect. + +| Bad | Why | Better | +|---|---|---| +| "If users are unhappy" | Nobody is watching for this | "Any support ticket mentioning lost edits" | +| "If it becomes a problem" | Circular | "p95 digest render exceeds 2s" | +| "Later" | Not a trigger | "Before the first customer with >50 seats" | +| "TBD" | Not a trigger | *(schema rejects this)* | + +Good triggers name a ticket, a metric, a threshold, a log line, or a specific upcoming event. + +--- + +## Capture confidence + +`workshop-captured` only. A transcript interleaves proposals and decisions and marks neither. + +| Level | Means | Action | +|---|---|---| +| `explicit` | Stated, uncontradicted | Record | +| `inferred` | Clear from context, never said in one piece | Record, flag in report | +| `ambiguous` | Could be a decision or a floated option | **Ask before recording** | + +**Proposal signals:** conditional framing ("we could", "one option is"), no response from anyone, contradicted later and never reconciled, trailing off mid-thought. + +**Decision signals:** flat indicative statement, acknowledged by another participant, referred back to later as settled, an action recorded from it. + +Attribution matters: a facilitator restating an option is not a founder choosing it. And the end of a session is the least reliable part — people compress, agree fast, stop challenging. Rate late items `inferred` more readily. diff --git a/skills/gap-interrogation-solo/SKILL.md b/skills/gap-interrogation-solo/SKILL.md new file mode 100644 index 0000000..6c7d241 --- /dev/null +++ b/skills/gap-interrogation-solo/SKILL.md @@ -0,0 +1,229 @@ +--- +name: gap-interrogation-solo +description: Run a simulated gap-interrogation workshop for a founder working alone. Spins up facilitator and adversary persona agents that interrogate every absence and conflict in a harvested feature inventory, so a single person gets the challenge a second participant would provide. Use when the user has a feature-inventory.yaml and no one to run the session with, or says they are working through gaps solo, need a sounding board, want to be challenged on a spec, or ask to "run the interrogation" alone. Produces decisions.yaml. This skill interrogates and records — the human always decides. Do NOT use it to write acceptance criteria or propose implementations. +allowed-tools: Read, Write, Glob, Grep, Skill, Task, AskUserQuestion +--- + +# Gap Interrogation — Solo + +Phase 2 of prototype-to-spec, for one person. + +Example Mapping's value is the third seat — the participant whose only job is asking what breaks. A founder working alone cannot hold advocate and adversary simultaneously; they unconsciously defend their own framing. This skill supplies the missing seat as persona agents. + +**Output contract:** `decisions.yaml` with `mode: solo-simulated`, identical in structure to `gap-interrogation-capture`. Downstream spec writing does not care which produced it. + +--- + +## The one rule + +**The personas interrogate. The human decides.** + +No persona classifies an absence. No persona proposes an implementation. They ask what breaks, and the founder answers. + +When the user says "just pick one," state the options and what each costs, then ask again. If they still decline, record `ASSUMED` with `decided_by: agent` and a `revisit_when` trigger — **never** `DECIDED`. A decision made by the system that will implement it is the oracle inside the loop. + +--- + +## Session Configuration + +Use AskUserQuestion: + +```yaml +# Question 1: Depth +question: "How thorough should this session be?" +header: "Mode" +options: + - label: "Full (Recommended)" + description: "All 3 adversaries per absence, blocking + high (~12K tokens)" + - label: "Quick" + description: "Facilitator only, blocking absences (~3K tokens)" + - label: "Focused" + description: "One adversary on one category — pick next" + - label: "Questions + Conflicts" + description: "Rounds 0 and 1 only, skip absence work" + +# Question 2: Decision posture +question: "How should unresolved items be recorded?" +header: "Posture" +options: + - label: "Conservative (Recommended)" + description: "Non-obvious blocking absences → ASSUMED pending co-founder review" + - label: "Decisive" + description: "Record my calls as DECIDED; I own them" +``` + +**Conservative is the default for a reason.** A blocking absence resolved alone, without the challenge a second person provides, is a guess with good posture. Recording it as an assumption pending review costs nothing and catches the ones that were wrong. + +--- + +## Personas + +Three adversaries, each owning specific absence-checklist categories, so coverage is provable rather than felt. Plus a facilitator who runs process and never contributes domain opinion. + +| Persona | Owns | Asks | +|---|---|---| +| **The Operator** | error, loading, concurrency, notification, offline | "What does this look like at 3am when it's broken?" | +| **The Gatekeeper** | permission, lifecycle, undo | "Who's allowed, and can we take it back?" | +| **The Edge** | empty state, boundary/scale, time, accessibility | "What happens at zero, at ten thousand, and across a timezone?" | +| **The Facilitator** | process only | "Decided, deferred, assumed, or escalated?" | + +Full persona prompts in `references/personas.md`. + +Every absence-checklist category maps to exactly one adversary. If a category has no findings and no persona raised it, that is a coverage failure — report it. + +--- + +## Process + +### Step 0: Locate the artefact tree + +All specification artefacts live in a **separate git tree** from the code. Resolve its root before doing anything else — env `AEOS_ARTEFACT_TREE`, then project config, then a sibling `*-specs` directory, then **ask**. Never fall back to the working directory, and never write specification artefacts into the code repo. + +Record artefact-tree `HEAD` now; you will need it to detect concurrent writes before committing. See `references/artefact-tree.md`. + +### Step 1: Assumption register + +Read `assumption-register.yaml` from the artefact tree. Evaluate each open assumption's `revisit_when` trigger against the codebase and current inventory. **Report any that have fired** before starting. This is the only moment anyone reliably looks at them. + +### Step 2: Agenda + +Read `/features//feature-inventory.yaml`: + +- Filter conflicts to `requires_human: true` — precedence resolved the rest, do not re-open +- Sort absences by severity within each story +- **Over 15 blocking absences on one story means it is too big** — say so before starting +- Flag `UNMAPPED` glossary terms +- Flag stories blocked by unresolved conflicts + +Present the agenda. Do not proceed until the user confirms. + +### Step 3: Round 0 — questions + +Questions are not product decisions. They are unknowns about the sources, and they come first because answering one frequently **promotes** it into a conflict or an absence that then needs working in the same session. Several also carry `blocks`, so leaving them open stalls later rounds. + +**Research before asking.** Unlike absences and conflicts, many questions are factual — "is `wrapIngest` dead code or the live path?" is settled by Grep, not by judgment. For every question marked `researchable: true`, attempt to answer it from the codebase and sources first, then present findings. Finding a fact is not deciding, so this does not violate the one rule. + +Per question, present the research finding if any, then: + +| Outcome | Meaning | +|---|---| +| **ANSWERED** | Someone knew, or research settled it — record `answered_by` | +| **RECLASSIFIED** | It was a conflict or absence all along — create the item and work it this session | +| **ESCALATED** | Nobody present knows — owner and date | +| **DROPPED** | Based on a misreading — reason required, feeds harvest feedback | + +**Timebox: 5 minutes for the whole set, 30 seconds per question.** If a question takes longer than that, it is not a question — it is an absence or a conflict wearing a question's clothes. Reclassify it and move on rather than debating it here. + +Every RECLASSIFIED question must produce a new `CON-n` or `ABS-n` that is itself resolved before the session ends. An orphaned reclassification is worse than an open question, because it looks handled. + +### Step 4: Round 1 — conflicts + +Facilitator only; adversaries are not needed here. Per conflict, present every source's position and the computed `default_resolution`, then ask: + +> **"Does precedence get this right?"** + +Record `RESOLVED`, `RECONCILED`, `SPLIT`, or `ESCALATED`. + +**Every RESOLVED and RECONCILED requires `debt_created`** — the renames, migrations or rewrites implied, each with an owner. Do not close the item without it. + +When positions describe things that behave differently, always ask: *"Are these actually two different concepts?"* SPLIT is the highest-value outcome available and it is easy to miss. + +### Step 5: Round 2 — absences + +Per absence, in `full` mode: + +1. **Facilitator** states the absence and asks: *"What happens?"* +2. User answers. +3. **Dispatch the owning adversary** via Task. It returns 2–4 specific failure questions — never generic, never a proposed solution. +4. Present them. User responds. +5. **Facilitator** asks: *"Decided, deferred, assumed, or escalated?"* +6. Record, with the required follow-up for that outcome. + +In `quick` mode, skip the adversary dispatch — facilitator questions only, blocking absences only. + +**Dispatch adversaries in parallel** where several absences share an owner. One Task call returning questions for five concurrency absences beats five calls. + +**Required follow-ups:** + +| Outcome | Must capture | +|---|---| +| DECIDED | One-sentence statement | +| DEFERRED | Confirmation it is explicitly out of scope | +| ASSUMED | `rationale`, `revisit_when`, `risk` | +| ESCALATED | `owner`, `due` | + +### Step 6: Coverage sweep + +Before writing output, verify every absence-checklist category was either raised by its owning adversary or explicitly marked `none-found` in the inventory. Untouched `normal` absences become `ASSUMED` with a one-line note — never silently dropped. + +### Step 7: Write and validate + +Write `/features//decisions.yaml` per `/schemas/decisions.schema.json`, validating before commit, then append every ASSUMED entry to `/assumption-register.yaml`. Commit both in one atomic change: + +``` +decide(): 2 conflicts resolved, 14 absences closed (simulated) + +Inventory: +Refs: CON-3, ABS-1, ABS-4 +``` + +If artefact-tree `HEAD` moved since Step 0, re-read and merge. Commit; never push. + +- [ ] `meta.mode: solo-simulated` +- [ ] Every question has an outcome +- [ ] Every RECLASSIFIED question's new CON/ABS is present **and resolved** +- [ ] Every ANSWERED question records `answered_by` +- [ ] Every DROPPED question records a reason +- [ ] Every `requires_human` conflict has an outcome +- [ ] Every RESOLVED/RECONCILED has `debt_created` with owners +- [ ] Every blocking and high absence has an outcome +- [ ] Every ASSUMED has an observable `revisit_when` +- [ ] Every ESCALATED has an owner and date +- [ ] Under conservative posture, non-obvious blocking absences are ASSUMED, not DECIDED +- [ ] No entry describes an implementation +- [ ] Every checklist category was covered or explicitly excluded + +### Step 8: Report + +- Questions answered by research, and which ones reclassified +- Conflicts resolved and the debt each created +- Decisions made, and which are held as assumptions pending review +- Open escalations with owners +- Existing assumptions whose triggers fired +- Stories ready for spec writing, blocked, or shown to be too big +- **Coverage gaps** — categories no adversary raised + +--- + +## Enforcement + +Block these actively: + +| Pattern | Response | +|---|---| +| User proposes an implementation | "That's how — what should *happen*?" | +| Persona proposes a solution | Discard and re-dispatch. Adversaries ask, never answer. | +| Item passed without classification | Ask again. Do not advance. | +| ASSUMED with no observable trigger | "What would tell us this was wrong? 'If users are unhappy' isn't observable." | +| RESOLVED with no `debt_created` | "What does this oblige someone to change?" | +| Zero assumptions across a whole story | Say so. Solo sessions with no assumptions almost always rubber-stamped guesses. | + +--- + +## What this skill does NOT do + +- Decide anything +- Write acceptance criteria +- Propose implementations +- Replace a real session — it supplies challenge, not domain knowledge +- Re-open conflicts that precedence settled + +--- + +## Tips for Best Results + +- **Run `stakeholder-simulator` first for unfamiliar domains.** It covers commercial and organisational angles these three personas deliberately skip. +- **The adversaries catch structural gaps, not domain ones.** They will ask about concurrency; they will not know that your users habitually paste 400-line updates. Only a second founder catches that. +- **Conservative posture is not timidity.** An assumption with a revisit trigger is strictly more useful than a decision nobody will re-examine. +- **If a persona's questions feel generic, the absence description is too thin.** Go back to the inventory rather than accepting weak interrogation. +- **This is a rehearsal, not a substitute.** When the co-founder is available, run the real session — and use `gap-interrogation-capture` on its notes. diff --git a/skills/gap-interrogation-solo/references/artefact-tree.md b/skills/gap-interrogation-solo/references/artefact-tree.md new file mode 100644 index 0000000..e07480b --- /dev/null +++ b/skills/gap-interrogation-solo/references/artefact-tree.md @@ -0,0 +1,108 @@ +# Artefact Tree Conventions + +All specification artefacts are versioned in a **separate git tree** from the code. Every skill in the spec pipeline reads from and writes to that tree, never to the code repository. + +This is not a filing preference. It gives the specification its own history, uncontaminated by code churn — which is what makes "the spec is the system, the code is a build artefact" mechanically true rather than aspirational, and what makes divergence detection a diff rather than an opinion. + +--- + +## Layout + +``` +/ +├── assumption-register.yaml # project-wide, persists across features +├── source-precedence.yaml # project-wide, declared once +├── contexts/ +│ └── / +│ ├── glossary.md # ubiquitous language +│ ├── CONSTRAINTS.md # architecture rules +│ └── decisions/ # ADRs +└── features/ + └── / + ├── feature-inventory.yaml # prototype-harvest + ├── decisions.yaml # gap-interrogation-* + ├── spec.md # spec-writer + ├── features/*.feature # Gherkin + └── contracts/openapi.yaml # generated +``` + +**Never write specification artefacts into the code repo.** If a skill cannot locate the artefact tree, it stops and asks — it does not fall back to the working directory. + +--- + +## Locating the tree + +Resolve in this order: + +1. `AEOS_ARTEFACT_TREE` environment variable +2. `artefactTree` in project config +3. A sibling directory matching `*-specs` or `*-artefacts` +4. **Ask the user.** Do not guess. + +Record the resolved root and its branch in every output's `meta`. + +--- + +## References are triples, never bare SHAs + +A commit hash means nothing without its repository. Sources for one feature routinely live in three or four different trees — design, code, artefacts — and a bare `@abc123` is ambiguous at best and silently wrong at worst. + +Every reference carries repo, commit, and path: + +```yaml +ref: + repo: standin-design + commit: abc123def456 + path: prototype/composer-standalone-src.html +``` + +This applies to sources in `feature-inventory.yaml`, to the inventory reference in `decisions.yaml`, and to anything a downstream artefact points back at. + +--- + +## Committing + +Every skill commits its own output. A written-but-uncommitted artefact is invisible to everything downstream. + +**Before writing:** record artefact-tree `HEAD`. **After writing:** if `HEAD` moved, re-read the affected files and merge rather than overwrite — another skill or another person may have written concurrently. + +**Message format:** + +``` +(): + +Sources: standin-design@abc123, standin@def456 +Inventory: +Refs: CON-9, ABS-4, ABS-5 +``` + +| Skill | Verb | Summary | +|---|---|---| +| `prototype-harvest` | `harvest` | inventory from N sources, M conflicts, K absences | +| `gap-interrogation-solo` | `decide` | M conflicts resolved, K absences closed (simulated) | +| `gap-interrogation-capture` | `decide` | M conflicts resolved, K absences closed (workshop) | +| `spec-writer` | `spec` | N acceptance criteria | + +Commit; never push. Pushing is a human action. + +--- + +## Linking code back to artefacts + +The code repo carries the link, as commit trailers: + +``` +Spec: standin-specs@abc123 +Implements: AC-12, AC-13 +``` + +This is what makes traceability mechanical across trees: given any code commit you can resolve the exact specification revision it claims to implement, and given any specification revision you can find what implements it. Divergence detection, AC coverage reporting, and the "is anything unimplemented?" question all depend on it. + +--- + +## Branching + +- Artefact work for a feature happens on `feature/` in the artefact tree +- Merged to `main` when the feature's spec is approved — **not** when the code ships +- The artefact tree's `main` is the current agreed specification of the system, always +- Tag artefact-tree commits at release so a shipped version resolves to the spec it was built from diff --git a/skills/gap-interrogation-solo/references/outcomes.md b/skills/gap-interrogation-solo/references/outcomes.md new file mode 100644 index 0000000..f2897e1 --- /dev/null +++ b/skills/gap-interrogation-solo/references/outcomes.md @@ -0,0 +1,95 @@ +# Outcome Semantics + +What each outcome *means* and when to choose it. The machine-checkable structure lives in `/schemas/decisions.schema.json` — this file is judgment, that file is shape. + +--- + +## Round 0 — questions + +A question is an unknown about the **sources**, not a product decision. Most resolve in seconds. + +| Outcome | Choose when | Requires | +|---|---|---| +| **ANSWERED** | Someone knew, or research settled it | `answer`, `answered_by` | +| **RECLASSIFIED** | It was a conflict or absence all along | `reclassified_as`, `becomes` | +| **ESCALATED** | Nobody present knows | `owner`, `due` | +| **DROPPED** | Based on a misreading of the sources | `reason` | + +**Research first.** `researchable: true` questions are usually answered by Grep, not judgment. Finding a fact is not deciding. + +**The 30-second rule.** Anything slower is not a question — it is an absence or conflict wearing a question's clothes. Reclassify rather than debate. + +**RECLASSIFIED is not terminal.** The new `CON-n` or `ABS-n` must be resolved in the same session. An orphaned reclassification looks handled and isn't. + +**DROPPED feeds the harvester.** A question asked because of a misreading means the extraction prompt needs work. The reason is that feedback. + +--- + +## Round 1 — conflicts + +Somebody already decided, twice, incompatibly. Downstream artefacts fork here. + +| Outcome | Choose when | Requires | +|---|---|---| +| **RESOLVED** | One source wins | `debt_created` | +| **RECONCILED** | None was right; new canonical answer | `debt_created` | +| **SPLIT** | Both right — two concepts, one name | `split_into` (≥2) | +| **ESCALATED** | Needs someone not present | `owner`, `due` | + +**`debt_created` is mandatory on RESOLVED and RECONCILED.** The decision is free; the rename, migration, or document rewrite it implies is not. An unowned entry means it silently doesn't happen and the conflict returns next feature. + +**SPLIT is worth slowing down for.** When positions describe things that behave differently, ask directly: *are these actually two concepts?* Discovering a conflated concept at spec time is worth more than the rest of the round. + +**Do not re-open what precedence settled.** If `default_resolution` is systematically wrong, fix `source-precedence.yaml` at project level afterwards — never item by item in the room. + +--- + +## Round 2 — absences + +Nobody decided. The agent will invent it. + +| Outcome | Choose when | Requires | Becomes | +|---|---|---|---| +| **DECIDED** | The behaviour is known | one-sentence `resolution` | An acceptance criterion | +| **DEFERRED** | Explicitly out of scope | — | A written non-goal | +| **ASSUMED** | Guessing, and we know it | `rationale`, `revisit_when`, `risk` | An assumption-register entry | +| **ESCALATED** | Needs someone not present | `owner`, `due` | A dated blocker | + +**Expect several ASSUMED per story.** The goal is not deciding everything — it is making every guess visible. A story with zero assumptions usually means guesses were rubber-stamped as decisions. Say so when you see it. + +**"We'll figure it out during implementation" is ASSUMED, not DEFERRED.** Deferred means explicitly out of scope. This is a guess with no trigger attached. + +**`decided_by: agent` forces ASSUMED.** When a human declines to decide and asks the system to pick, that is never `DECIDED`. A decision made by the thing that will implement it is the oracle inside the loop — the schema enforces this, but understand why. + +--- + +## What makes a `revisit_when` observable + +The trigger has to be something a person or a system can actually detect. + +| Bad | Why | Better | +|---|---|---| +| "If users are unhappy" | Nobody is watching for this | "Any support ticket mentioning lost edits" | +| "If it becomes a problem" | Circular | "p95 digest render exceeds 2s" | +| "Later" | Not a trigger | "Before the first customer with >50 seats" | +| "TBD" | Not a trigger | *(schema rejects this)* | + +Good triggers name a ticket, a metric, a threshold, a log line, or a specific upcoming event. + +--- + +## Capture confidence + +`workshop-captured` only. A transcript interleaves proposals and decisions and marks neither. + +| Level | Means | Action | +|---|---|---| +| `explicit` | Stated, uncontradicted | Record | +| `inferred` | Clear from context, never said in one piece | Record, flag in report | +| `ambiguous` | Could be a decision or a floated option | **Ask before recording** | + +**Proposal signals:** conditional framing ("we could", "one option is"), no response from anyone, contradicted later and never reconciled, trailing off mid-thought. + +**Decision signals:** flat indicative statement, acknowledged by another participant, referred back to later as settled, an action recorded from it. + +Attribution matters: a facilitator restating an option is not a founder choosing it. And the end of a session is the least reliable part — people compress, agree fast, stop challenging. Rate late items `inferred` more readily. diff --git a/skills/gap-interrogation-solo/references/personas.md b/skills/gap-interrogation-solo/references/personas.md new file mode 100644 index 0000000..cf0c5de --- /dev/null +++ b/skills/gap-interrogation-solo/references/personas.md @@ -0,0 +1,122 @@ +# Persona Prompts + +Dispatch via Task. Each returns **questions only** — never classifications, never proposed solutions. + +Every absence-checklist category is owned by exactly one adversary, so coverage is provable: + +| Category | Owner | +|---|---| +| 1 Empty state | Edge | +| 2 Error state | Operator | +| 3 Loading state | Operator | +| 4 Permission and role | Gatekeeper | +| 5 Concurrency | Operator | +| 6 Boundary and scale | Edge | +| 7 Lifecycle | Gatekeeper | +| 8 Notification and side-effects | Operator | +| 9 Time | Edge | +| 10 Offline and degraded network | Operator | +| 11 Accessibility | Edge | +| 12 Undo and reversibility | Gatekeeper | + +--- + +## Shared preamble + +Prepend to every adversary dispatch: + +> You are an adversary in a specification session. Your only job is to ask what breaks. +> +> **Rules, absolute:** +> - Return questions only. Never propose a solution, an implementation, or a technology. +> - Never classify. You do not decide what is in scope, deferred, or assumed. +> - Be specific to this feature. "What about errors?" is useless; "what happens to the composed body when the POST fails after the optimistic update rendered?" is the job. +> - 2–4 questions maximum per absence. More than that and they get skimmed. +> - If an absence is genuinely well-covered, say so in one line rather than manufacturing doubt. +> +> Return as a flat list, one question per line, prefixed with the absence ID. + +--- + +## The Operator + +> You have carried a pager for a decade. You have been woken at 3am by systems that worked perfectly in the demo. You are not pessimistic — you are experienced. +> +> **You own:** error states, loading states, concurrency, notifications and side-effects, offline and degraded network. +> +> **You ask about:** what the user sees when it fails, what happens to in-flight work, whether the action is idempotent, what two simultaneous actors do to each other, what fires downstream and how loudly, what happens on a train with two bars of signal. +> +> **Your instinct:** every optimistic update has a rollback nobody designed. Every write has a partial-failure state. Every notification has a volume at which it becomes noise and gets muted permanently. + +--- + +## The Gatekeeper + +> You think about who is allowed to do what, and what happens to things after they stop being wanted. You have seen a product leak data between tenants and you have seen a delete cascade take out six months of work. +> +> **You own:** permissions and roles, lifecycle, undo and reversibility. +> +> **You ask about:** which roles can see and do each thing, whether restricted actions are hidden or disabled, what a user without access sees, what happens when permissions change while a view is open, what happens to this object when its parent is archived, whether deletion is soft or hard, what references break, whether this is reversible and for how long. +> +> **Your instinct:** the prototype was drawn for an admin. Every destructive action needs its blast radius named before it needs a confirm dialog — confirmation is often the wrong pattern and undo is the right one. + +--- + +## The Edge + +> You are interested in the values nobody tested. Zero. One. Ten thousand. The 4,000-character title. The user in Auckland reading something written in Lisbon. +> +> **You own:** empty states, boundary and scale, time, accessibility. +> +> **You ask about:** what this looks like with nothing in it, whether first-run differs from became-empty, what happens at 10,000 items, what the longest realistic value does to the layout, whose timezone governs display and whose governs the day boundary, whether ordering is wall-clock or causal, when relative timestamps flip to absolute, what a keyboard-only user does, what a screen reader announces on state change, whether colour alone carries meaning. +> +> **Your instinct:** the demo data was four items with short names, all created today, all by the same person, in one timezone. Nothing about that is representative. +> +> **For async or collaborative products, always ask:** what does someone see returning after twelve hours — what is marked as changed, and what is silently different? + +--- + +## The Facilitator + +Not dispatched as a subagent. This is the orchestrating voice, and it holds process only — never domain opinion. + +**Round 0, per question** (adversaries are not dispatched — questions are factual, not adversarial): +> "Q-*n*: *question*." +> *[if researchable, present the finding first]* "Code says: *finding*. Does that settle it?" +> "Answered, reclassified, escalated, or dropped?" +> On reclassification: "Conflict or absence? Creating *CON-n* / *ABS-n* — we work it this round." + +> **30-second rule:** "That's taking longer than a question should. It's an absence — reclassifying." + +**Round 1, per conflict:** +> "CON-*n*, *topic*. *Source A* says X. *Source B* says Y. Precedence says: *default*." +> "Does precedence get this right?" +> On resolution: "What does this decision oblige someone to change?" +> When positions differ behaviourally: "Are these actually two different concepts?" + +**Round 2, per absence:** +> "What happens?" +> *[dispatch owning adversary, present its questions]* +> "Decided, deferred, assumed, or escalated?" + +**Follow-ups:** +| Outcome | Ask | +|---|---| +| ANSWERED | "Recording as fact — from research or from you?" | +| RECLASSIFIED | "Conflict or absence?" | +| DROPPED | "What was the misreading? It feeds back to the harvester." | +| DECIDED | "State it in one sentence." | +| DEFERRED | "So explicitly out of scope for v1?" | +| ASSUMED | "What would tell us this was wrong?" | +| ESCALATED | "Who owns this, and by when?" | + +**Redirects — deliver flat, then move on:** +| Pattern | Redirect | +|---|---| +| Solutioning | "That's implementation — what should *happen*?" | +| "It depends" | "On what? Name the variable." | +| "We'll figure it out later" | "That's an assumption. What would tell us it was wrong?" | +| Silence past ~20 seconds | "Decided, deferred, assumed, or escalated?" | +| Re-opening a settled conflict | "Precedence settled that. If it's systematically wrong we fix precedence after." | + +**Never** offer a candidate answer between "what happens?" and the adversary's questions. The pause is where the thinking happens. diff --git a/skills/prototype-harvest/SKILL.md b/skills/prototype-harvest/SKILL.md new file mode 100644 index 0000000..8c52d33 --- /dev/null +++ b/skills/prototype-harvest/SKILL.md @@ -0,0 +1,181 @@ +--- +name: prototype-harvest +description: Extract a structured specification inventory from a design handoff and every other source that describes the same feature — PRD, user stories, HTML/Figma prototype, existing codebase, conventions files. Produces screens, states, interactions, data fields, transitions, and critically what the sources CONFLICT about and what they all leave MISSING. Use this skill whenever the user hands over a prototype, mockup, design handoff, PRD, or story set and wants to turn it into specs, requirements, acceptance criteria, or tickets. Trigger on phrasing like "here's the prototype", "designs are ready", "Parker sent the handoff", "break this design down", "what do we need to build this", or when a design artifact appears alongside user stories or a PRD. Do NOT use this skill to generate components or code from designs — this skill produces a specification inventory, never implementation. +--- + +# Prototype Harvest + +Turn a feature's source material into a machine-readable specification inventory that a human can review in ten minutes, and that feeds requirements writing, contract definition, and agent handoff. + +A prototype is two things at once: **an under-extracted specification** (it already encodes screens, states, data shape and transitions that teams routinely throw away and re-derive) and **a dangerous illusion of completeness** (it shows the happy path for one role at one moment, and says nothing about conflict, permissions, failure or staleness). + +And a prototype is rarely alone. A PRD, a story set, the existing codebase, and a conventions file usually describe the same feature — **differently**. That produces a second class of finding entirely. + +**Two kinds of finding, and they fail differently:** + +| | Meaning | Failure mode | +|---|---|---| +| **Absence** | Nobody decided | The agent invents it | +| **Conflict** | Somebody decided, twice, incompatibly | Downstream artefacts **fork** | + +Conflicts come first in the output and first in resolution, because an unresolved vocabulary conflict makes half the absence descriptions ambiguous. + +--- + +## Guiding Principles + +- **Extract, never infer** — Every entry traces to something observable in a source. Anything you worked out goes in `questions`, not the inventory. +- **Absence and conflict are both first-class** — What nobody said, and what two sources said differently, are the two things an agent cannot resolve alone. +- **Precedence resolves most conflicts without a human** — Declare source authority once; compute the default resolution; escalate only what precedence cannot settle. +- **Filler is flagged, not implemented** — Lorem ipsum, three hardcoded users, placeholder counts. +- **Vocabulary drift surfaces immediately** — Every label maps to a glossary term or is marked `UNMAPPED`. Every cross-source naming disagreement is a blocking conflict. +- **The inventory is the review surface** — A human reviews this file, not the sources. Optimise for reading. +- **This skill produces no code** — Not components, not JSX, not CSS. Output is YAML. + +--- + +## Process + +### Step 0: Locate the artefact tree + +All specification artefacts live in a **separate git tree** from the code. Resolve its root before doing anything else — env `AEOS_ARTEFACT_TREE`, then project config, then a sibling `*-specs` directory, then **ask**. Never fall back to the working directory, and never write specification artefacts into the code repo. + +Record artefact-tree `HEAD` now; you will need it to detect concurrent writes before committing. See `references/artefact-tree.md`. + +### Step 1: Locate and pin every source + +Find everything that describes this feature. Expect four or five, not one: + +| Kind | Typical location | +|---|---| +| `prd` | product docs | +| `stories` | CSV, tracker export, backlog | +| `prototype` | HTML export or Figma file | +| `codebase` | existing modules for adjacent features | +| `conventions` | `CLAUDE.md`, coding standards, glossary | + +Sources routinely live in three or four different trees — design, code, artefacts. **A bare commit hash is meaningless without its repository**, so every source is recorded as a triple: `repo`, `commit`, `path`. + +```bash +git -C rev-parse HEAD +``` + +A spec derived from an unpinned source drifts silently when someone pushes again. If a source is not in git, record a file hash instead and say so. + +Note `version_note` where a source announces its own lineage ("rewrite of the earlier wrap set"). That single field resolves a surprising number of conflicts later. + +If the prototype comes from Figma, prefer the node tree to rendered markup — component names, variants and auto-layout carry semantics that divs do not. + +### Step 2: Declare source precedence + +Read `meta.source_precedence` from project config. If none exists, propose one and **ask the user to confirm before proceeding** — this ordering determines how every conflict resolves, and guessing it silently is worse than pausing. + +Typical ordering, highest authority first: conventions → codebase → prototype → stories → PRD. + +### Step 3: Check for a glossary + +Look for `glossary.md` in the bounded context. If none exists, proceed but flag it: every `data.glossary_term` returns `UNMAPPED`, and vocabulary drift is among the most expensive defects to fix late. + +### Step 4: Reconcile across sources + +Work through `reference/conflict-checklist.md` in full — vocabulary, behaviour, scope, data shape, state model, permissions, staleness. + +For each conflict found: +- Record **every** source's position, not just the two that clash +- State `why_it_matters` in terms of what forks downstream +- Compute `default_resolution` from precedence +- Set `requires_human: true` when precedence is silent, the winning source looks stale, resolution creates work someone must own, or resolution changes behaviour rather than naming + +**Vocabulary conflicts are `blocking` by default.** Check the prototype's identifiers separately from its copy — designers rename copy long before anyone renames code, and the gap between `onWrapDropZone` and "Add to brief →" is exactly the kind of thing that forks an API contract. + +### Step 5: Extract the inventory + +Populate each section of `reference/inventory-schema.yaml`. Tag every entry with the `source` it came from. + +| Section | What to capture | +|---|---| +| `screens` | Every distinct view | +| `states` | Every distinct state present in a source | +| `interactions` | Every control, its label, apparent action, destination | +| `data` | Every field displayed or edited — candidate entity attributes | +| `transitions` | The state machine the sources imply | +| `filler` | Placeholder content that must not be implemented | + +Mark every entry `confidence: observed` or `confidence: implied`. Observed means it is literally in the source. Implied means you reasoned about it — implied entries are review targets, not facts. + +### Step 6: Run the absence checklist + +Work through `reference/absence-checklist.md` in full. Do not skip categories because they seem irrelevant — the ones that seem irrelevant produce production incidents. + +Every gap becomes an `absences` entry with a category and severity. Every gap you cannot even frame becomes a `questions` entry. + +**This step is not optional and not abbreviated.** An inventory with a thin `absences` section is a failed harvest, not a clean set of sources. + +### Step 7: Validate + +- [ ] Every source in `meta.sources` has a commit SHA or a stated reason it has none +- [ ] `meta.source_precedence` is set and confirmed, not guessed +- [ ] Every `conflicts` entry lists a position for every source that has one +- [ ] Every `conflicts` entry has `default_resolution` and `requires_human` +- [ ] Every `screens` entry has at least one `states` entry +- [ ] Every entry has a `source` and an `evidence` field +- [ ] Every `data` entry has a `glossary_term` or explicit `UNMAPPED` +- [ ] Every absence-checklist category is populated or marked `none-found` with a reason +- [ ] No entry contains invented business rules — if it reads like a rule, it belongs in `questions` +- [ ] `filler` is populated (zero placeholder content is suspicious — look again) + +### Step 8: Report + +Write `feature-inventory.yaml`, then summarise in chat, **in this order**: + +1. **Blocking conflicts**, stated plainly — what forks if they stay unresolved +2. Which conflicts precedence resolves automatically, and which need a human +3. **The three highest-severity absences** +4. Any `UNMAPPED` vocabulary +5. Counts per section +6. What the gap-interrogation session needs a human to decide + +Lead with conflicts, then absences. The extracted inventory is the cheap half; the disagreements and the gaps are what the human is being asked to look at. + +--- + +## Output + +A single file, `feature-inventory.yaml`, conforming to `reference/inventory-schema.yaml`. + +Written to the **artefact tree** at `features//feature-inventory.yaml`, then committed: + +``` +harvest(): inventory from 4 sources, 9 conflicts, 23 absences + +Sources: standin-design@abc123, standin@def456 +Refs: CON-1..CON-9 +``` + +If artefact-tree `HEAD` moved since Step 0, re-read and merge before committing. Commit; never push. + +--- + +## What this skill does NOT do + +- Generate components, markup or styles +- Write acceptance criteria (downstream, in spec writing) +- **Resolve conflicts that need a human** — it computes what precedence implies and flags the rest +- Answer the questions it raises +- Judge the design +- Guess at business rules no source states + +If asked to do any of these, produce the inventory first and say what is unresolved before proceeding. + +--- + +## Tips for Best Results + +- **Check identifiers separately from copy.** A prototype routinely carries two vocabularies — the one users see and the one the code uses. The gap between them is a blocking conflict hiding in plain sight. +- **A PRD is usually the oldest source in the room.** Treat its vocabulary as suspect until confirmed against the conventions file. +- **`version_note` earns its keep.** "Rewrite of the earlier X set" converts an apparent conflict into a straightforward staleness finding. +- **Empty states are the richest source of absence** — a populated list and nothing else hides the zero case, the one-item case and the ten-thousand-item case. +- **Every disabled control is a rule you do not have** — the condition that greys it is a business rule no source states. +- **Every count, badge and timestamp is a data question** — "3 unread" implies a read model; "2 hours ago" implies a timezone decision. +- **Roles are almost never drawn.** Prototypes are made for the most privileged user. Assume permissions are entirely unspecified until proven otherwise. +- **For async or collaborative products, add the returning-user question by default** — what does someone see coming back after twelve hours, and what is marked as changed? Prototypes show a moment; async products live in the gaps between moments. diff --git a/skills/prototype-harvest/references/absence-checklist.md b/skills/prototype-harvest/references/absence-checklist.md new file mode 100644 index 0000000..2c61080 --- /dev/null +++ b/skills/prototype-harvest/references/absence-checklist.md @@ -0,0 +1,103 @@ +# Absence Checklist + +Work through every category. Do not skip one because it seems irrelevant — the categories that seem irrelevant are the ones that produce production incidents. + +Each category produces either `absences` entries or an explicit `none-found` with a reason. Silence is not an acceptable output. + +**Severity:** mark `blocking` when an agent cannot implement without someone making a decision. Most absences are blocking. That is the expected result, not a sign you were too harsh. + +--- + +## 1. Empty state + +- What does this look like with zero items? +- Is there a first-run state distinct from a became-empty state? +- Is there a call to action, and what does it do? + +## 2. Error state + +- What happens when the load fails? +- What happens when the write fails — is the optimistic update rolled back? +- Is the error recoverable in place, or does the user lose input? +- Are errors per-field, per-form, or global? + +## 3. Loading state + +- Skeleton, spinner, or nothing? +- Is there a partial-load state where some data arrived? +- What is the timeout, and what is shown after it? + +## 4. Permission and role + +Prototypes are drawn for the most privileged user. Assume nothing is specified. + +- Who can see this screen? Who can see each field? +- Which interactions are hidden versus visible-but-disabled for lesser roles? +- What does a user without access see — a 404, a 403, or a degraded view? +- Can permissions change while the view is open? + +## 5. Concurrency + +- Two users act on the same object simultaneously — last-write-wins, merge, or conflict surface? +- Is the action idempotent? Double-click on submit. +- What happens to a form open against an object someone else just deleted? +- Is there a locking or presence model, or none? + +## 6. Boundary and scale + +- One item. Ten thousand items. Is there pagination, virtualisation, or a cap? +- A 4,000-character string in a field designed for eight words. +- Zero-width and RTL text. Emoji in names. +- The longest realistic value, not the prettiest one. + +## 7. Lifecycle + +- What happens to this when its parent is archived or deleted? +- Is deletion soft or hard? Is there a restore path? +- What happens to references from elsewhere in the product? + +## 8. Notification and side-effects + +- Who gets told when this happens? +- Is it batched, immediate, or digested? +- Can it be muted, and at what granularity? +- What is the fatigue ceiling — how many of these can fire before it is spam? + +## 9. Time + +Critical for anything async or distributed. + +- Whose timezone governs display? Whose governs the day boundary? +- Is ordering wall-clock or causal? +- When does a relative timestamp flip to absolute? +- What does someone see returning after twelve hours — what is marked as changed, and what is silently different? +- Is there state that was true when written and is not now, and is that visible? + +## 10. Offline and degraded network + +- Is the action queued or rejected? +- What happens to work in progress when the connection drops? +- Is there a stale-data indicator? + +## 11. Accessibility + +- Keyboard path through every interaction +- Focus order and focus trapping in modals +- What screen readers announce for state changes +- Whether colour alone carries meaning anywhere + +## 12. Undo and reversibility + +- Is this action reversible? For how long? +- Is there a confirmation, and is confirmation the right pattern versus undo? +- What is the blast radius of the destructive version of this action? + +--- + +## Framing an absence well + +**Weak:** "Error states not designed." + +**Strong:** "No state shown for a failed post submission. Undefined: whether the composed body is preserved, whether retry is automatic or manual, and whether a partial write to the digest is possible. Blocking — an agent will invent all three." + +The difference is that the strong version tells the human exactly what decision they owe, and tells the reader what goes wrong if nobody makes it. diff --git a/skills/prototype-harvest/references/artefact-tree.md b/skills/prototype-harvest/references/artefact-tree.md new file mode 100644 index 0000000..e07480b --- /dev/null +++ b/skills/prototype-harvest/references/artefact-tree.md @@ -0,0 +1,108 @@ +# Artefact Tree Conventions + +All specification artefacts are versioned in a **separate git tree** from the code. Every skill in the spec pipeline reads from and writes to that tree, never to the code repository. + +This is not a filing preference. It gives the specification its own history, uncontaminated by code churn — which is what makes "the spec is the system, the code is a build artefact" mechanically true rather than aspirational, and what makes divergence detection a diff rather than an opinion. + +--- + +## Layout + +``` +/ +├── assumption-register.yaml # project-wide, persists across features +├── source-precedence.yaml # project-wide, declared once +├── contexts/ +│ └── / +│ ├── glossary.md # ubiquitous language +│ ├── CONSTRAINTS.md # architecture rules +│ └── decisions/ # ADRs +└── features/ + └── / + ├── feature-inventory.yaml # prototype-harvest + ├── decisions.yaml # gap-interrogation-* + ├── spec.md # spec-writer + ├── features/*.feature # Gherkin + └── contracts/openapi.yaml # generated +``` + +**Never write specification artefacts into the code repo.** If a skill cannot locate the artefact tree, it stops and asks — it does not fall back to the working directory. + +--- + +## Locating the tree + +Resolve in this order: + +1. `AEOS_ARTEFACT_TREE` environment variable +2. `artefactTree` in project config +3. A sibling directory matching `*-specs` or `*-artefacts` +4. **Ask the user.** Do not guess. + +Record the resolved root and its branch in every output's `meta`. + +--- + +## References are triples, never bare SHAs + +A commit hash means nothing without its repository. Sources for one feature routinely live in three or four different trees — design, code, artefacts — and a bare `@abc123` is ambiguous at best and silently wrong at worst. + +Every reference carries repo, commit, and path: + +```yaml +ref: + repo: standin-design + commit: abc123def456 + path: prototype/composer-standalone-src.html +``` + +This applies to sources in `feature-inventory.yaml`, to the inventory reference in `decisions.yaml`, and to anything a downstream artefact points back at. + +--- + +## Committing + +Every skill commits its own output. A written-but-uncommitted artefact is invisible to everything downstream. + +**Before writing:** record artefact-tree `HEAD`. **After writing:** if `HEAD` moved, re-read the affected files and merge rather than overwrite — another skill or another person may have written concurrently. + +**Message format:** + +``` +(): + +Sources: standin-design@abc123, standin@def456 +Inventory: +Refs: CON-9, ABS-4, ABS-5 +``` + +| Skill | Verb | Summary | +|---|---|---| +| `prototype-harvest` | `harvest` | inventory from N sources, M conflicts, K absences | +| `gap-interrogation-solo` | `decide` | M conflicts resolved, K absences closed (simulated) | +| `gap-interrogation-capture` | `decide` | M conflicts resolved, K absences closed (workshop) | +| `spec-writer` | `spec` | N acceptance criteria | + +Commit; never push. Pushing is a human action. + +--- + +## Linking code back to artefacts + +The code repo carries the link, as commit trailers: + +``` +Spec: standin-specs@abc123 +Implements: AC-12, AC-13 +``` + +This is what makes traceability mechanical across trees: given any code commit you can resolve the exact specification revision it claims to implement, and given any specification revision you can find what implements it. Divergence detection, AC coverage reporting, and the "is anything unimplemented?" question all depend on it. + +--- + +## Branching + +- Artefact work for a feature happens on `feature/` in the artefact tree +- Merged to `main` when the feature's spec is approved — **not** when the code ships +- The artefact tree's `main` is the current agreed specification of the system, always +- Tag artefact-tree commits at release so a shipped version resolves to the spec it was built from diff --git a/skills/prototype-harvest/references/conflict-checklist.md b/skills/prototype-harvest/references/conflict-checklist.md new file mode 100644 index 0000000..eb8e44a --- /dev/null +++ b/skills/prototype-harvest/references/conflict-checklist.md @@ -0,0 +1,95 @@ +# Conflict Checklist + +Run this **before** the absence checklist. A vocabulary conflict makes half your absence descriptions ambiguous, so resolving conflicts first is not a preference — it is an ordering constraint. + +An absence is *nobody decided*. A conflict is *somebody decided, twice, incompatibly*. They fail differently: an absence gets hallucinated, a conflict gets **forked** — two sources each produce a consistent downstream artefact, and the artefacts disagree. + +Compare every pair of sources across each category below. + +--- + +## 1. Vocabulary + +The most common and the most expensive, because it forks everything. + +- Does each source use the same word for the same entity? +- Does the prototype use **one vocabulary in copy and another in identifiers**? Check `onXDropZone`, handler names, CSS classes, data attributes — designers rename copy long before anyone renames code. +- Does the codebase use a term the conventions file marks as retired? +- Is the PRD written in a vocabulary that has since been abandoned? +- Does one name cover two concepts, or two names cover one? + +**Every vocabulary conflict is `blocking` by default.** API paths, table names, event names, type names, ticket titles and UI copy all fork here, and unforking them later is a rename across every layer. + +## 2. Behaviour + +- Do two sources describe different outcomes for the same trigger? +- Does the prototype show a flow the stories do not mention, or vice versa? +- Does the codebase already implement something the PRD describes differently? + +## 3. Scope + +- Does one source include a capability another excludes? +- Is something in the prototype that no story covers? (Design speculation, or a missing story — determine which.) +- Is there a story with no prototype support? + +## 4. Data shape + +- Same entity, different fields across sources? +- Same field, different type, format, or optionality? +- Different identifiers — slug in one place, numeric id in another? +- Different cardinality — one-to-many in the schema, one-to-one in the UI? + +## 5. State model + +- Do the sources agree on how many states exist? +- Same state name, different meaning? (`draft` meaning unsaved versus unpublished.) +- Different transitions permitted between the same states? + +## 6. Permission and role + +- Do the sources name the same roles? +- Does the PRD grant something the codebase restricts? +- Does the prototype show an action to a role the stories say cannot perform it? + +## 7. Staleness + +Not strictly a conflict — a versioning failure that looks like one. + +- Is one source simply an older revision that nobody retired? +- Does a source carry a `version_note` indicating it superseded another? +- Is the PRD predating a pivot still being treated as authoritative? + +**Mark these `kind: stale` rather than forcing them into another category.** The resolution is different: retire the source, don't decide between positions. + +--- + +## Recording a conflict well + +**Weak:** "Sources use different terminology." + +**Strong:** + +> **Four vocabularies for one entity.** PRD uses *declaration*/*Representative*; stories use *brief*; prototype uses *brief* in copy but *wrap* in identifiers; CLAUDE.md retires *wrap*, *declaration* and *Representative* explicitly. The PRD is written almost entirely in the retired set. API contract, table names, event names, UI copy and tickets all fork here. + +The strong version states every position, names the authority, and says what breaks. That is enough for precedence to resolve it without a meeting — or, where it isn't, enough for the session to resolve it in two minutes instead of fifteen. + +--- + +## Applying precedence + +`meta.source_precedence` is declared once per project. Typical ordering, highest first: + +1. **Conventions** (`CLAUDE.md`, coding standards) — explicitly authoritative about naming and retirement +2. **Codebase** — what is actually true today +3. **Prototype** — most recent product thinking, usually +4. **Stories** — often a rewrite, check `version_note` +5. **PRD** — oldest, drifts fastest + +Compute `default_resolution` from precedence. Then set `requires_human: true` when any of these hold: + +- Precedence is silent on this dimension +- The winning source looks stale +- **Resolution creates work someone must own** — a rename, a migration, a PRD rewrite +- Resolving it changes product behaviour rather than just naming + +The last two are why a conflict with a clean precedence answer can still need a human. Precedence tells you which word wins; it does not tell you who is renaming `lib/wraps/` or when. diff --git a/skills/prototype-harvest/references/inventory-schema.yaml b/skills/prototype-harvest/references/inventory-schema.yaml new file mode 100644 index 0000000..d325f4d --- /dev/null +++ b/skills/prototype-harvest/references/inventory-schema.yaml @@ -0,0 +1,235 @@ +# feature-inventory.yaml — schema and worked example +# +# Every entry carries `evidence` (where in the source it came from) and +# `confidence` (observed = literally in the source; implied = you reasoned). +# Implied entries are review targets, not facts. +# +# Two kinds of finding, and the difference matters: +# absences — nobody decided. The agent will invent it. +# conflicts — somebody decided, twice, incompatibly. Downstream artefacts fork. +# Conflicts are listed first and resolved first. + +meta: + feature: string # short slug, e.g. shared-standup-notes + context: string # bounded context this belongs to + + # All sources harvested. More than one source is the normal case. + artefact_tree: # where this file lives + repo: string + branch: string + + # Sources live in several trees. A bare commit hash is meaningless without + # its repository, so every reference is a triple. + sources: + - id: SRC-1 + kind: prd | stories | prototype | codebase | conventions | api-contract + repo: string # REQUIRED + commit: string # REQUIRED where the source is in git + path: string # REQUIRED + version_note: string | null # "rewrite of earlier wrap-set, 2026-07" + + # Which source wins when two disagree. Declared ONCE per project, not per + # feature. Without this, every conflict needs a human; with it, most + # auto-resolve and only genuine product questions reach the session. + source_precedence: [SRC-4, SRC-3, SRC-1, SRC-2] # highest authority first + + glossary: string | null # path to glossary.md, or null if absent + harvested_at: iso8601 + +# --------------------------------------------------------------------------- +# CONFLICTS — listed first, resolved first. +# +# A conflict is not an absence. Somebody has already decided, twice, +# incompatibly. Every downstream artefact — API contract, table names, event +# names, UI copy, tickets — forks at an unresolved conflict. + +conflicts: + - id: CON-1 + topic: string # short, human-readable + kind: > + vocabulary | behaviour | scope | data-shape | state | + permission | stale + severity: blocking | high | normal + # blocking = forks downstream artefacts + # high = different behaviour, decidable + # normal = cosmetic or deferrable + + positions: # what each source says — one per source + - source: SRC-1 + says: string + - source: SRC-3 + says: string + + why_it_matters: string # what forks if this is not resolved + evidence: string # file:line / selector, per source + + # What source_precedence implies, computed not decided. + default_resolution: string | null + requires_human: true | false # false when precedence settles it cleanly + # true when precedence is silent, contested, + # or when the winning source looks stale + +# --------------------------------------------------------------------------- + +screens: + - id: SCR-1 + name: string + purpose: string + source: SRC-3 # which source this came from + evidence: string + +states: + - id: SCR-1-S1 + screen: SCR-1 + kind: default | empty | loading | error | success | partial | disabled + description: string + source: SRC-3 + evidence: string + confidence: observed | implied + +interactions: + - id: INT-1 + screen: SCR-1 + control: button | link | form | input | drag | menu | other + label: string # visible text, verbatim + action: string # what it appears to do — descriptive + destination: SCR-2 | external | unknown + enabled_when: string | not-observable + source: SRC-3 + evidence: string + confidence: observed | implied + +data: + - id: DAT-1 + screen: SCR-1 + label: string # as shown in the UI, verbatim + glossary_term: string | UNMAPPED + kind: display | input | both + format: text | number | date | datetime | currency | count | enum | file | other + constraints_visible: [string] + sample_values: [string] + source: SRC-3 + evidence: string + confidence: observed | implied + +transitions: + - id: TRN-1 + from: SCR-1-S1 + to: SCR-2-S1 + trigger: INT-1 + source: SRC-3 + evidence: string + confidence: observed | implied + +# --------------------------------------------------------------------------- +# ABSENCES — see reference/absence-checklist.md. +# Every checklist category appears here, with findings or an explicit +# none-found plus reason. + +absences: + - id: ABS-1 + category: > + empty-state | error-state | loading-state | permission | concurrency | + boundary | lifecycle | notification | time | offline | accessibility | + role-variation | undo + description: string + affects: [SCR-1] + severity: blocking | high | normal + + - id: ABS-n + category: offline + description: none-found + reason: string + +# Things you could not even frame as an absence or a conflict — genuine +# unknowns about the sources. Resolved in Round 0, before conflicts, because +# answering one frequently PROMOTES it into a conflict or an absence that then +# needs working in the same session. +questions: + - id: Q-1 + question: string + context: string # why this matters + suspected: conflict | absence | fact | unknown + # your guess at what this becomes once + # answered. Lets the session pre-sort. + researchable: true | false # can this be settled by reading code or + # sources, rather than by a human deciding? + blocks: [ABS-1, CON-1] # items that cannot resolve until this does + +filler: + - id: FIL-1 + description: string # "three hardcoded users in the member list" + evidence: string + +# --------------------------------------------------------------------------- +# WORKED FRAGMENTS — illustrative only +# --------------------------------------------------------------------------- +# +# meta: +# sources: +# - id: SRC-1 +# kind: prd +# repo: standin-specs +# commit: 9f2c1ab +# path: docs/prd-standup-digest.md +# - id: SRC-2 +# kind: stories +# path: docs/stories.csv +# version_note: rewrite of an earlier "wrap" set +# - id: SRC-3 +# kind: prototype +# path: prototype/composer-standalone-src.html +# repo: standin-design +# commit: abc123def456 +# - id: SRC-4 +# kind: conventions +# path: CLAUDE.md +# source_precedence: [SRC-4, SRC-3, SRC-2, SRC-1] +# +# conflicts: +# - id: CON-9 +# topic: Product vocabulary for the core entity +# kind: vocabulary +# severity: blocking +# positions: +# - source: SRC-1 +# says: > +# "declaration", "declared", "declare", "Representative" throughout, +# including in the definition of the core promise. +# - source: SRC-2 +# says: '"brief" exclusively; row 6 reads "brief drop zone".' +# - source: SRC-3 +# says: > +# "brief" in all UI copy; "wrap" in code identifiers +# (onWrapDropZone, wrapIngest, wrapDropText, wrapDirect). +# - source: SRC-4 +# says: > +# Lists "wrap(s)", "declared state", "declaration", "refusal", +# "Representative(s)" as RETIRED — must never appear in copy. +# why_it_matters: > +# Four vocabularies for one entity, and the PRD is written almost +# entirely in the retired set. API contract, table names, event names, +# UI copy and tickets all fork here. +# evidence: "PRD passim; stories.csv passim; composer-standalone-src.html:213,:553; CLAUDE.md copy rules" +# default_resolution: > +# SRC-4 has highest precedence and explicitly retires the PRD and +# identifier vocabularies. "brief" wins for copy and for new code. +# requires_human: true +# # ^ true despite a clean precedence answer: resolving this creates +# # refactor debt (lib/wraps/, app/api/wraps/) that needs an owner, +# # and the PRD needs rewriting before it can be used as a spec source. +# +# absences: +# - id: ABS-4 +# category: time +# description: > +# Relative timestamps shown but the reference point is undefined — +# viewer local, author local, or team-canonical. Also undefined: the +# cutoff after which relative flips to absolute. +# affects: [SCR-1] +# severity: blocking +# +# filler: +# - id: FIL-1 +# description: Four hardcoded teammate entries with placeholder avatars +# evidence: "composer-standalone-src.html:.digest-list > .entry" diff --git a/skills/spec-writer/SKILL.md b/skills/spec-writer/SKILL.md new file mode 100644 index 0000000..c0b34c2 --- /dev/null +++ b/skills/spec-writer/SKILL.md @@ -0,0 +1,193 @@ +--- +name: spec-writer +description: Turn a resolved decisions.yaml into the specification — EARS acceptance criteria, Gherkin scenarios, invariants, and non-goals — with every criterion traceable back to the decision that produced it. Use after gap-interrogation has produced a decisions.yaml with no open blocking items, or when the user asks to write the spec, draft acceptance criteria, generate Gherkin, or turn decisions into requirements. Trigger on "write the spec", "generate the criteria", "turn these decisions into requirements", "draft the Gherkin". This skill transcribes decisions into specification form — it never invents requirements the decisions do not contain. Do NOT use it to make product decisions or to generate code. +allowed-tools: Read, Write, Glob, Grep +--- + +# Spec Writer + +Phase 3 of prototype-to-spec. Turn resolved decisions into the specification that contracts, tests, and agents consume. + +Every DECIDED absence already has an `AC` ID assigned. Every DEFERRED absence has a `NON-GOAL` ID. The gap interrogation did the deciding; this skill does the **transcribing** — into a form that is precise, traceable, and machine-checkable. + +**The output is the review surface for the whole feature.** A Product Engineering Architect reads the spec, not the code. Optimise for a human reading it in one sitting and an agent implementing from it without guessing. + +--- + +## The one rule + +**Transcribe decisions. Never invent requirements.** + +Every acceptance criterion traces to a `decisions.yaml` entry by ID. If writing the spec surfaces a requirement the decisions do not contain, that is not a gap for this skill to fill — it is a gap in the decisions, and it goes back to gap-interrogation. + +When a DECIDED resolution is too vague to become a testable criterion — "handle errors gracefully" — do not sharpen it by choosing what graceful means. Flag it as `underspecified` and return it. Sharpening it here is deciding, and deciding here is the oracle inside the loop. + +--- + +## Inputs + +Read from the **artefact tree**: + +- `features//decisions.yaml` — the source. Nothing is authored that isn't here. +- `features//feature-inventory.yaml` — for the data model behind invariants and for AC traceability back to screens. +- `contexts//glossary.md` — ubiquitous language. Every term in the spec uses the glossary's word, not a synonym. +- `assumption-register.yaml` — ASSUMED items become criteria too, tagged as resting on an assumption. + +**Precondition:** `decisions.yaml` must have no open blocking items. Run `lint_decisions.py` first; if it errors, stop and report — a spec built on unresolved decisions is a spec built on sand. + +--- + +## Process + +### Step 0: Locate the artefact tree and validate inputs + +Resolve the artefact-tree root (see `references/artefact-tree.md`). Record `HEAD`. + +Run the linter against the feature: + +``` +python schemas/lint_decisions.py features/ \ + --register assumption-register.yaml --schemas schemas/ +``` + +**If it errors, stop.** Report the errors and do not write. The decisions are not ready. + +### Step 1: Partition the decisions + +Sort every resolved item by what it becomes: + +| Source outcome | Becomes | +|---|---| +| DECIDED absence | An EARS acceptance criterion | +| DECIDED conflict (behavioural) | An EARS acceptance criterion | +| ASSUMED absence | An EARS criterion, tagged `assumption: ASM-n` | +| DEFERRED absence | A non-goal | +| SPLIT conflict | Two glossary entries + criteria for each concept | +| RECONCILED/RESOLVED vocabulary conflict | A glossary update (see Step 5) | +| ESCALATED (still open) | **Nothing yet** — the criterion it blocks is marked `blocked-by: ` | + +Anything still ESCALATED means the spec is partial. Write what you can and list the holes explicitly. + +### Step 2: Write EARS criteria + +One criterion per DECIDED item, in the pattern that fits. Preserve the ID. + +| Pattern | Shape | +|---|---| +| Ubiquitous | The `` shall `` | +| Event-driven | When ``, the `` shall `` | +| State-driven | While ``, the `` shall `` | +| Unwanted | If ``, then the `` shall `` | +| Optional | Where ``, the `` shall `` | +| Complex | Combinations, for genuinely conditional behaviour | + +Rules: +- **Use the glossary term, always.** If the decision says "brief" and the glossary says "brief", the criterion says "brief" — never a synonym that crept in from the PRD. +- **One criterion, one behaviour.** If a decision resolved two behaviours, it produces two criteria sharing a parent ID (`AC-12a`, `AC-12b`). +- **Testable or it isn't done.** Every criterion must admit a pass/fail test. If it doesn't, it is `underspecified` — flag and return, do not sharpen. +- **Carry the decision's exact bounds.** "Flip to absolute after 7 days" stays 7 days. Do not round, generalise, or improve the number. + +See `references/ears-patterns.md` for pattern selection and worked transcriptions. + +### Step 3: Decision tables for conditional logic + +Where a decision has more than three interacting conditions, EARS becomes unreadable and agents misparse the nesting. Emit a decision table instead (`decisions/.md` as a markdown table, or DMN if the project uses it). Reference the AC IDs it implements. + +### Step 4: Write Gherkin scenarios + +Not for every criterion — for the flows worth exemplifying. Each scenario is tagged with the AC IDs it covers, so coverage is mechanical. + +- One `.feature` per coherent flow +- `@AC-12 @AC-13` tags above each scenario +- Concrete example values, drawn from the inventory's real data shape — never `foo`/`bar` +- Cover the unwanted-behaviour criteria explicitly; happy-path-only Gherkin is how error handling rots + +See `references/gherkin-style.md`. + +### Step 5: Invariants + +The thing EARS and Gherkin both miss. An invariant is what must always hold regardless of path — and for async products these are usually about ordering and convergence. + +Derive invariants from: +- SPLIT conflicts that revealed distinct concepts with distinct rules +- Data relationships in the inventory ("a digest's entry count equals its visible entries") +- Any DECIDED item phrased as "always" or "never" + +Write each as a checkable statement with an `INV` ID. These feed property-based test generation downstream, so phrase them as properties, not prose: quantified over inputs, decidable. + +### Step 6: Apply vocabulary resolutions + +For every RESOLVED/RECONCILED/SPLIT vocabulary conflict, update `contexts//glossary.md`: +- RESOLVED/RECONCILED → the canonical term, with retired terms listed as "not: X, Y" +- SPLIT → two entries, each defining one concept and naming the other as distinct + +**Show the glossary diff and confirm before writing.** The glossary is project-wide; a wrong edit propagates further than a wrong criterion. + +### Step 7: Assemble spec.md + +Structure, in order: +1. **Feature** — one paragraph, what and why, from the decisions +2. **Ubiquitous language** — the terms this spec uses, linked to the glossary +3. **Acceptance criteria** — EARS, grouped by area, each with its ID and source decision +4. **Invariants** — the always-true statements +5. **Non-goals** — DEFERRED items, stated as what this does not do +6. **Open** — ESCALATED items still blocking, with owners and what they block +7. **Assumptions in force** — ASSUMED items the spec rests on, with revisit triggers + +### Step 8: Write, validate, commit + +Write `features//spec.md` and `features//features/*.feature`. + +Validate: +- [ ] Every DECIDED decision produced exactly one criterion (or a parent with children) +- [ ] Every criterion's ID matches its source decision's `becomes` +- [ ] Every criterion uses glossary terms, no synonyms +- [ ] Every criterion is testable, or flagged `underspecified` and returned +- [ ] Every DEFERRED item appears as a non-goal +- [ ] Every ASSUMED-derived criterion is tagged with its `ASM` ID +- [ ] Every ESCALATED item appears under Open, nothing silently dropped +- [ ] Every Gherkin scenario tags at least one AC +- [ ] No criterion introduces a requirement absent from decisions.yaml +- [ ] Numeric bounds match the decision exactly + +Commit to the artefact tree: + +``` +spec(): 18 acceptance criteria, 4 invariants, 3 non-goals + +Decisions: +Refs: AC-1..AC-18, INV-1..INV-4 +``` + +If artefact-tree `HEAD` moved since Step 0, re-read and merge. Commit; never push. + +### Step 9: Report + +- Criteria written, grouped by area +- Invariants derived +- Anything returned as `underspecified` — the decisions that need re-interrogation +- Open escalations still blocking parts of the spec +- Assumptions the spec rests on +- Glossary changes applied + +Lead with `underspecified` returns. A spec that quietly invented answers to vague decisions is the dangerous artefact — it looks complete and encodes guesses as requirements. + +--- + +## What this skill does NOT do + +- Make product decisions, or resolve anything left open +- Sharpen a vague DECIDED resolution by choosing what it means +- Invent requirements, edge cases, or criteria not traceable to a decision +- Generate code, contracts, or schemas (Phase 4, downstream) +- Write tests (derived from criteria later, by generation) + +--- + +## Tips for Best Results + +- **The ID is the spine.** `ABS-5 → AC-12 → INV-2 → @AC-12 in the .feature → test`. If a criterion has no traceable source ID, it should not exist. +- **A criterion you can't write a failing test for is not a criterion.** It is a returned decision. Resist the urge to make it testable by inventing the missing detail. +- **Numeric drift is the silent failure.** "About a week" is not "7 days". Carry the decision's exact figure; if the decision was vague about it, the decision is underspecified. +- **Invariants are where the real correctness lives.** EARS covers what happens on triggers; invariants cover what must never stop being true. For an async product they are the most valuable output — spend time here. +- **If you're transcribing a lot and returning nothing, look harder.** Real decision sets almost always contain one or two resolutions too vague to be criteria. Finding zero usually means you sharpened them silently. diff --git a/skills/spec-writer/references/artefact-tree.md b/skills/spec-writer/references/artefact-tree.md new file mode 100644 index 0000000..e07480b --- /dev/null +++ b/skills/spec-writer/references/artefact-tree.md @@ -0,0 +1,108 @@ +# Artefact Tree Conventions + +All specification artefacts are versioned in a **separate git tree** from the code. Every skill in the spec pipeline reads from and writes to that tree, never to the code repository. + +This is not a filing preference. It gives the specification its own history, uncontaminated by code churn — which is what makes "the spec is the system, the code is a build artefact" mechanically true rather than aspirational, and what makes divergence detection a diff rather than an opinion. + +--- + +## Layout + +``` +/ +├── assumption-register.yaml # project-wide, persists across features +├── source-precedence.yaml # project-wide, declared once +├── contexts/ +│ └── / +│ ├── glossary.md # ubiquitous language +│ ├── CONSTRAINTS.md # architecture rules +│ └── decisions/ # ADRs +└── features/ + └── / + ├── feature-inventory.yaml # prototype-harvest + ├── decisions.yaml # gap-interrogation-* + ├── spec.md # spec-writer + ├── features/*.feature # Gherkin + └── contracts/openapi.yaml # generated +``` + +**Never write specification artefacts into the code repo.** If a skill cannot locate the artefact tree, it stops and asks — it does not fall back to the working directory. + +--- + +## Locating the tree + +Resolve in this order: + +1. `AEOS_ARTEFACT_TREE` environment variable +2. `artefactTree` in project config +3. A sibling directory matching `*-specs` or `*-artefacts` +4. **Ask the user.** Do not guess. + +Record the resolved root and its branch in every output's `meta`. + +--- + +## References are triples, never bare SHAs + +A commit hash means nothing without its repository. Sources for one feature routinely live in three or four different trees — design, code, artefacts — and a bare `@abc123` is ambiguous at best and silently wrong at worst. + +Every reference carries repo, commit, and path: + +```yaml +ref: + repo: standin-design + commit: abc123def456 + path: prototype/composer-standalone-src.html +``` + +This applies to sources in `feature-inventory.yaml`, to the inventory reference in `decisions.yaml`, and to anything a downstream artefact points back at. + +--- + +## Committing + +Every skill commits its own output. A written-but-uncommitted artefact is invisible to everything downstream. + +**Before writing:** record artefact-tree `HEAD`. **After writing:** if `HEAD` moved, re-read the affected files and merge rather than overwrite — another skill or another person may have written concurrently. + +**Message format:** + +``` +(): + +Sources: standin-design@abc123, standin@def456 +Inventory: +Refs: CON-9, ABS-4, ABS-5 +``` + +| Skill | Verb | Summary | +|---|---|---| +| `prototype-harvest` | `harvest` | inventory from N sources, M conflicts, K absences | +| `gap-interrogation-solo` | `decide` | M conflicts resolved, K absences closed (simulated) | +| `gap-interrogation-capture` | `decide` | M conflicts resolved, K absences closed (workshop) | +| `spec-writer` | `spec` | N acceptance criteria | + +Commit; never push. Pushing is a human action. + +--- + +## Linking code back to artefacts + +The code repo carries the link, as commit trailers: + +``` +Spec: standin-specs@abc123 +Implements: AC-12, AC-13 +``` + +This is what makes traceability mechanical across trees: given any code commit you can resolve the exact specification revision it claims to implement, and given any specification revision you can find what implements it. Divergence detection, AC coverage reporting, and the "is anything unimplemented?" question all depend on it. + +--- + +## Branching + +- Artefact work for a feature happens on `feature/` in the artefact tree +- Merged to `main` when the feature's spec is approved — **not** when the code ships +- The artefact tree's `main` is the current agreed specification of the system, always +- Tag artefact-tree commits at release so a shipped version resolves to the spec it was built from diff --git a/skills/spec-writer/references/ears-patterns.md b/skills/spec-writer/references/ears-patterns.md new file mode 100644 index 0000000..d5caeb6 --- /dev/null +++ b/skills/spec-writer/references/ears-patterns.md @@ -0,0 +1,121 @@ +# EARS Patterns + +Easy Approach to Requirements Syntax. Five patterns plus a complex combination. The job is choosing the right one for each decision and transcribing without inventing. + +--- + +## The patterns + +**Ubiquitous** — always true, no trigger, no precondition. +> The `` shall ``. + +*Use for:* invariant-like behaviour that isn't conditional. "The digest shall display entries in reverse chronological order." + +**Event-driven** — a discrete trigger causes a response. +> When ``, the `` shall ``. + +*Use for:* anything that happens in reaction to something. The most common pattern. "When a teammate posts an update, the system shall add it to the current digest." + +**State-driven** — behaviour that holds throughout a state. +> While ``, the `` shall ``. + +*Use for:* behaviour tied to a condition that persists. "While the digest is loading, the system shall display a skeleton placeholder." + +**Unwanted behaviour** — handling something that shouldn't happen but might. +> If ``, then the `` shall ``. + +*Use for:* error handling, failure, invalid input. These come straight from the `error-state`, `offline`, and `boundary` absences. "If a post submission fails after the optimistic update rendered, then the system shall restore the composed text and surface a retry." + +**Optional feature** — behaviour present only when a feature is. +> Where ``, the `` shall ``. + +*Use for:* behaviour gated on configuration or plan. "Where a workspace has retention policy enabled, the system shall expire digests after the configured window." + +**Complex** — genuinely multi-conditional. Combine the keywords. +> When ``, while ``, the `` shall ``. + +*Use sparingly.* If it takes three keywords to express, first ask whether it should be a decision table instead (§ Step 3 in SKILL). Nested EARS is where agents misparse. + +--- + +## Choosing the pattern + +Read the decision's `resolution` and ask, in order: + +1. Is it handling something going wrong? → **Unwanted** (`If`) +2. Is there a discrete trigger? → **Event-driven** (`When`) +3. Does it hold throughout a state? → **State-driven** (`While`) +4. Is it gated on a feature or plan? → **Optional** (`Where`) +5. Always true, unconditional? → **Ubiquitous** +6. More than three conditions interacting? → **decision table**, not EARS + +--- + +## Worked transcriptions + +Each shows a `decisions.yaml` entry and the criterion it becomes. Note what is carried exactly and what is never added. + +**DECIDED → event-driven** + +```yaml +- absence: ABS-4 + outcome: DECIDED + resolution: > + Relative timestamps render in viewer local time. Flip to absolute + after 7 days. + becomes: AC-12 +``` + +> **AC-12** (from ABS-4) — While a digest entry is under 7 days old, the system shall display its timestamp relative to the viewer's local time. When an entry reaches 7 days old, the system shall display an absolute timestamp. + +Two behaviours in one decision → `AC-12a`/`AC-12b` if they need separating for testing. The "7 days" is carried verbatim. Nothing about *format* of the absolute timestamp is added — the decision didn't say, so the criterion doesn't either. If format matters, that's an `underspecified` return. + +**DECIDED → unwanted** + +```yaml +- absence: ABS-5 + outcome: DECIDED + resolution: Concurrent edit — last-write-wins, no conflict surface. + becomes: AC-15 +``` + +> **AC-15** (from ABS-5) — If two users edit the same digest entry concurrently, then the system shall retain the later write and discard the earlier without surfacing a conflict. + +**ASSUMED → tagged criterion** + +```yaml +- absence: ABS-7 + outcome: ASSUMED + resolution: Digest holds at most 200 entries before paginating. + revisit_when: any digest exceeds 200 entries in production + becomes: AC-18 +``` + +> **AC-18** (from ABS-7, assumption ASM-3) — When a digest reaches 200 entries, the system shall paginate rather than extend the single view. + +Tagged with the assumption ID so the spec reader knows this rests on a guess with a revisit trigger. Same EARS shape; different provenance marker. + +**DECIDED but underspecified → RETURN, do not write** + +```yaml +- absence: ABS-9 + outcome: DECIDED + resolution: Handle large paste gracefully. + becomes: AC-20 +``` + +Not a criterion. "Gracefully" admits no test. Return ABS-9 to gap-interrogation: + +> ABS-9 is marked DECIDED but "gracefully" is untestable. Needs a concrete bound: truncate at N characters? reject with a message? accept and scroll? This is a decision, not a transcription. + +Do **not** rescue it by picking one. Picking is deciding. + +--- + +## Anti-patterns + +- **Synonym drift.** The decision says the glossary term; the criterion must too. If "brief" became canonical, no criterion says "wrap" or "post" or "update". +- **Silent precision.** "About a week" → do not write "7 days". Return it as underspecified. +- **Invented edge cases.** If the decisions don't mention what happens at zero entries, the spec doesn't either — it goes back as a missing absence, it does not get filled in here. +- **Compound criteria.** "The system shall X and also Y" is two criteria. Split them. +- **Untestable verbs.** "handle", "manage", "support", "gracefully", "appropriately" — each is a returned decision, not a criterion. diff --git a/src/artefact-tree/schemas/README.md b/src/artefact-tree/schemas/README.md new file mode 100644 index 0000000..e4b6717 --- /dev/null +++ b/src/artefact-tree/schemas/README.md @@ -0,0 +1,107 @@ +# Artefact Schemas + +Normative contracts for everything in the artefact tree. Lives at `/schemas/`. + +**These are the single source of truth for structure.** Skills do not carry their own copies — they carry *semantics* guides explaining what the values mean, and validate against these. + +``` +/schemas/ +├── feature-inventory.schema.json +├── decisions.schema.json +├── assumption-register.schema.json +└── lint_decisions.py # cross-file invariants a schema can't see +``` + +## The linter + +JSON Schema checks one file's shape. `lint_decisions.py` checks what spans files: + +```bash +python schemas/lint_decisions.py features/ \ + --register assumption-register.yaml \ + --schemas schemas/ +``` + +Exit 0 clean, 1 on error. Warnings never fail the build. It runs the JSON Schema +pass too when `jsonschema` is installed, so it's a single gate. What it catches +that the schemas cannot: + +- A `RECLASSIFIED` question whose `becomes` ID is never actually resolved +- A `requires_human` conflict left unresolved +- A blocking/high absence missing from decisions.yaml +- An `ASSUMED` decision that never reached the register +- `source_precedence` or a conflict position citing a source not in `meta.sources` +- A resolution that smells like implementation, not behaviour (warning) +- `UNMAPPED` glossary terms still open (warning) + +Wire it into the artefact-tree pre-commit hook alongside the schema check. + +--- + +## Why here and not in the skills + +| Concern | Where | Why | +|---|---|---| +| **Structure** — fields, enums, required-ness | Artefact tree | Evolves with the artefacts it describes; needs the same version history; must be identical for every producer | +| **Semantics** — what ASSUMED means, what makes a good `revisit_when` | Skill `references/` | Facilitation guidance, not data | + +Two skills produce `decisions.yaml`. A copy of the contract in each guarantees drift — the shape changed three times while it was being designed. One copy in the tree, versioned alongside the files it governs, cannot drift. + +--- + +## Validation + +YAML is a JSON superset, so JSON Schema validates it directly. + +```bash +check-jsonschema --schemafile schemas/decisions.schema.json \ + features/*/decisions.yaml +``` + +Alternatives: `ajv-cli`, `yajsv`. + +**Editor validation, free.** First line of every artefact: + +```yaml +# yaml-language-server: $schema=../../schemas/decisions.schema.json +``` + +VS Code then shows errors as you type, with completion on enums. + +**Pre-commit hook** in the artefact tree validates every changed artefact. A malformed `decisions.yaml` never lands, which converts several checklist items into mechanical checks — deterministic tooling deciding what it can decide, rather than an LLM reviewer being asked to notice a missing field. + +--- + +## Constraints the schema enforces + +Things previously left to a checklist, now unfalsifiable: + +- `ASSUMED` requires `rationale`, `revisit_when`, `risk`, `decided_by` +- `revisit_when` has a minimum length — "TBD" fails +- `RESOLVED` / `RECONCILED` conflicts require at least one `debt_created` entry, each with an owner +- `SPLIT` requires at least two `split_into` concepts +- `ESCALATED` requires `owner` and `due` +- `RECLASSIFIED` questions require `reclassified_as` and `becomes` +- `DROPPED` questions require a `reason` +- **`decided_by: agent` forces `outcome: ASSUMED`** — the one rule, enforced by the schema rather than by the skill remembering it +- All references are `{repo, commit, path}` triples +- `workshop-captured` requires `source_artefact`; `solo-simulated` requires `posture` + +What the schema *cannot* enforce, and stays a skill responsibility: + +- Every `RECLASSIFIED` question's `becomes` ID actually resolves somewhere in the file +- Every `blocking` absence in the inventory appears here +- No `resolution` describes an implementation + +These are exactly what `lint_decisions.py` covers. + +--- + +## Versioning + +Every artefact carries `schema_version`. + +- **Additive** (new optional field) — no bump +- **Breaking** (new required field, renamed field, narrowed enum) — bump `schema_version`, keep the old schema file, write a migration + +Expect churn early. The shape moved three times during design and will move again once real sessions run against it — so make migration cheap rather than trying to get it right first. diff --git a/src/artefact-tree/schemas/assumption-register.schema.json b/src/artefact-tree/schemas/assumption-register.schema.json new file mode 100644 index 0000000..5053c58 --- /dev/null +++ b/src/artefact-tree/schemas/assumption-register.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://standin.co/schemas/assumption-register/1", + "title": "assumption-register.yaml", + "description": "Project-wide, persists across features. Every ASSUMED outcome from any session lands here. Reviewed at the start of every session and every planning cycle.", + "type": "object", + "required": ["schema_version", "assumptions"], + "properties": { + "schema_version": { "const": 1 }, + "assumptions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "from", "feature", "registered", "assumption", "revisit_when", "risk", "status"], + "properties": { + "id": { "type": "string", "pattern": "^ASM-[0-9]+$" }, + "from": { + "type": "string", + "pattern": "^(ABS|Q|CON)-[0-9]+$", + "description": "The inventory finding this assumption came from" + }, + "feature": { "type": "string" }, + "registered": { "type": "string", "format": "date" }, + "assumption": { "type": "string" }, + "revisit_when": { + "type": "string", + "minLength": 20, + "description": "An observable trigger — a ticket, metric, threshold, log line, or upcoming event. Not 'TBD', not 'if users are unhappy'." + }, + "risk": { "enum": ["low", "medium", "high"] }, + "status": { "enum": ["open", "fired", "retired"] }, + "fired_on": { "type": ["string", "null"], "format": "date" }, + "decided_by": { "enum": ["human", "agent"] }, + "session_mode": { "enum": ["solo-simulated", "workshop-captured", "paired-live"] } + }, + "allOf": [ + { + "comment": "A fired assumption must record when it fired.", + "if": { "properties": { "status": { "const": "fired" } } }, + "then": { + "properties": { "fired_on": { "type": "string", "format": "date" } }, + "required": ["fired_on"] + } + } + ] + } + } + } +} diff --git a/src/artefact-tree/schemas/decisions.schema.json b/src/artefact-tree/schemas/decisions.schema.json new file mode 100644 index 0000000..a950ea3 --- /dev/null +++ b/src/artefact-tree/schemas/decisions.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://standin.co/schemas/decisions/1", + "title": "decisions.yaml", + "description": "Output of gap-interrogation. Produced identically by gap-interrogation-solo and gap-interrogation-capture.", + "type": "object", + "required": ["schema_version", "meta", "summary"], + "properties": { + "schema_version": { "const": 1 }, + + "meta": { + "type": "object", + "required": ["feature", "session", "mode", "artefact_tree", "inventory"], + "properties": { + "feature": { "type": "string" }, + "session": { "type": "string", "format": "date" }, + "mode": { "enum": ["solo-simulated", "workshop-captured", "paired-live"] }, + "participants": { "type": "array", "items": { "type": "string" } }, + "artefact_tree": { "$ref": "#/$defs/treeRef" }, + "inventory": { "$ref": "#/$defs/ref" }, + "source_artefact": { "type": ["string", "null"] }, + "posture": { "enum": ["conservative", "decisive"] }, + "adversary_rotation": { "type": "array", "items": { "type": "string" } } + }, + "allOf": [ + { + "if": { "properties": { "mode": { "const": "workshop-captured" } } }, + "then": { "required": ["source_artefact"] } + }, + { + "if": { "properties": { "mode": { "const": "solo-simulated" } } }, + "then": { "required": ["posture"] } + } + ] + }, + + "question_resolutions": { + "type": "array", + "items": { + "type": "object", + "required": ["question", "outcome"], + "properties": { + "question": { "$ref": "#/$defs/id" }, + "outcome": { "enum": ["ANSWERED", "RECLASSIFIED", "ESCALATED", "DROPPED"] }, + "answer": { "type": "string" }, + "answered_by": { "enum": ["research", "human"] }, + "reclassified_as": { "enum": ["conflict", "absence"] }, + "becomes": { "$ref": "#/$defs/id" }, + "reason": { "type": "string" }, + "owner": { "type": "string" }, + "due": { "type": "string", "format": "date" }, + "blocks_released": { "type": "array", "items": { "$ref": "#/$defs/id" } }, + "capture_confidence": { "$ref": "#/$defs/captureConfidence" }, + "evidence": { "type": "string" } + }, + "allOf": [ + { "if": { "properties": { "outcome": { "const": "ANSWERED" } } }, + "then": { "required": ["answer", "answered_by"] } }, + { "if": { "properties": { "outcome": { "const": "RECLASSIFIED" } } }, + "then": { "required": ["reclassified_as", "becomes"] } }, + { "if": { "properties": { "outcome": { "const": "DROPPED" } } }, + "then": { "required": ["reason"] } }, + { "if": { "properties": { "outcome": { "const": "ESCALATED" } } }, + "then": { "required": ["owner", "due"] } } + ] + } + }, + + "conflict_resolutions": { + "type": "array", + "items": { + "type": "object", + "required": ["conflict", "outcome", "resolution"], + "properties": { + "conflict": { "$ref": "#/$defs/id" }, + "outcome": { "enum": ["RESOLVED", "RECONCILED", "SPLIT", "ESCALATED"] }, + "resolution": { "type": "string" }, + "debt_created": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["task", "owner"], + "properties": { + "task": { "type": "string" }, + "owner": { "type": "string" }, + "due": { "type": ["string", "null"], "format": "date" } + } + } + }, + "affects_downstream": { + "type": "array", + "items": { "enum": ["api-contract", "table-names", "event-names", "ui-copy", "tickets", "glossary"] } + }, + "split_into": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["name", "definition"], + "properties": { + "name": { "type": "string" }, + "definition": { "type": "string" } + } + } + }, + "owner": { "type": "string" }, + "due": { "type": "string", "format": "date" }, + "capture_confidence": { "$ref": "#/$defs/captureConfidence" }, + "evidence": { "type": "string" } + }, + "allOf": [ + { "if": { "properties": { "outcome": { "enum": ["RESOLVED", "RECONCILED"] } } }, + "then": { "required": ["debt_created"] } }, + { "if": { "properties": { "outcome": { "const": "SPLIT" } } }, + "then": { "required": ["split_into"] } }, + { "if": { "properties": { "outcome": { "const": "ESCALATED" } } }, + "then": { "required": ["owner", "due"] } } + ] + } + }, + + "decisions": { + "type": "array", + "items": { + "type": "object", + "required": ["absence", "outcome", "resolution"], + "properties": { + "absence": { "$ref": "#/$defs/id" }, + "outcome": { "enum": ["DECIDED", "DEFERRED", "ASSUMED", "ESCALATED"] }, + "resolution": { "type": "string" }, + "becomes": { "type": "string", "pattern": "^(AC|NON-GOAL)-[0-9]+$" }, + "rationale": { "type": "string" }, + "revisit_when": { "type": "string", "minLength": 20 }, + "risk": { "enum": ["low", "medium", "high"] }, + "decided_by": { "enum": ["human", "agent"] }, + "question": { "type": "string" }, + "owner": { "type": "string" }, + "due": { "type": "string", "format": "date" }, + "blocks": { "type": "array", "items": { "type": "string" } }, + "capture_confidence": { "$ref": "#/$defs/captureConfidence" }, + "evidence": { "type": "string" } + }, + "allOf": [ + { "if": { "properties": { "outcome": { "enum": ["DECIDED", "DEFERRED"] } } }, + "then": { "required": ["becomes"] } }, + { "if": { "properties": { "outcome": { "const": "ASSUMED" } } }, + "then": { "required": ["rationale", "revisit_when", "risk", "decided_by"] } }, + { "if": { "properties": { "outcome": { "const": "ESCALATED" } } }, + "then": { "required": ["owner", "due"] } }, + { + "comment": "An agent-originated call is never a decision.", + "if": { "properties": { "decided_by": { "const": "agent" } } }, + "then": { "properties": { "outcome": { "const": "ASSUMED" } } } + } + ] + } + }, + + "summary": { + "type": "object", + "required": ["stories_ready", "stories_blocked"], + "properties": { + "questions_answered": { "$ref": "#/$defs/idList" }, + "questions_reclassified": { "$ref": "#/$defs/idList" }, + "questions_escalated": { "$ref": "#/$defs/idList" }, + "questions_dropped": { "$ref": "#/$defs/idList" }, + "conflicts_resolved": { "$ref": "#/$defs/idList" }, + "conflicts_escalated": { "$ref": "#/$defs/idList" }, + "assumptions_registered": { "$ref": "#/$defs/idList" }, + "escalations_open": { "$ref": "#/$defs/idList" }, + "debt_created": { "type": "integer", "minimum": 0 }, + "stories_ready": { "type": "array", "items": { "type": "string" } }, + "stories_blocked": { "type": "array", "items": { "type": "string" } }, + "stories_too_big": { "type": "array", "items": { "type": "string" } }, + "inventory_items_untouched": { "$ref": "#/$defs/idList" }, + "off_inventory_decisions": { "$ref": "#/$defs/idList" }, + "low_confidence_entries": { "$ref": "#/$defs/idList" } + } + } + }, + + "$defs": { + "id": { "type": "string", "pattern": "^(Q|CON|ABS|NEW)-[0-9]+$" }, + "idList": { "type": "array", "items": { "$ref": "#/$defs/id" } }, + "captureConfidence": { "enum": ["explicit", "inferred", "ambiguous"] }, + "treeRef": { + "type": "object", + "required": ["repo", "branch"], + "properties": { + "repo": { "type": "string" }, + "branch": { "type": "string" } + } + }, + "ref": { + "type": "object", + "description": "References are triples. A commit hash is meaningless without its repository.", + "required": ["repo", "commit", "path"], + "properties": { + "repo": { "type": "string" }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" }, + "path": { "type": "string" } + } + } + } +} diff --git a/src/artefact-tree/schemas/feature-inventory.schema.json b/src/artefact-tree/schemas/feature-inventory.schema.json new file mode 100644 index 0000000..4ef09cf --- /dev/null +++ b/src/artefact-tree/schemas/feature-inventory.schema.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://standin.co/schemas/feature-inventory/1", + "title": "feature-inventory.yaml", + "description": "Output of prototype-harvest. Structured inventory of a feature's sources: conflicts, screens, states, interactions, data, transitions, absences, questions, filler.", + "type": "object", + "required": ["schema_version", "meta", "absences"], + "properties": { + "schema_version": { "const": 1 }, + + "meta": { + "type": "object", + "required": ["feature", "context", "artefact_tree", "sources", "source_precedence", "harvested_at"], + "properties": { + "feature": { "type": "string" }, + "context": { "type": "string" }, + "artefact_tree": { "$ref": "#/$defs/treeRef" }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "kind", "repo", "path"], + "properties": { + "id": { "$ref": "#/$defs/srcId" }, + "kind": { "enum": ["prd", "stories", "prototype", "codebase", "conventions", "api-contract"] }, + "repo": { "type": "string" }, + "commit": { "type": ["string", "null"], "pattern": "^[0-9a-f]{7,40}$" }, + "path": { "type": "string" }, + "version_note": { "type": ["string", "null"] } + } + } + }, + "source_precedence": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/srcId" }, + "description": "Highest authority first. Every id must appear in sources." + }, + "glossary": { "type": ["string", "null"] }, + "harvested_at": { "type": "string", "format": "date-time" } + } + }, + + "conflicts": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "topic", "kind", "severity", "positions", "why_it_matters", "evidence", "requires_human"], + "properties": { + "id": { "$ref": "#/$defs/conId" }, + "topic": { "type": "string" }, + "kind": { "enum": ["vocabulary", "behaviour", "scope", "data-shape", "state", "permission", "stale"] }, + "severity": { "$ref": "#/$defs/severity" }, + "positions": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["source", "says"], + "properties": { + "source": { "$ref": "#/$defs/srcId" }, + "says": { "type": "string" } + } + } + }, + "why_it_matters": { "type": "string" }, + "evidence": { "type": "string" }, + "default_resolution": { "type": ["string", "null"] }, + "requires_human": { "type": "boolean" } + } + } + }, + + "screens": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "purpose", "source", "evidence"], + "properties": { + "id": { "$ref": "#/$defs/scrId" }, + "name": { "type": "string" }, + "purpose": { "type": "string" }, + "source": { "$ref": "#/$defs/srcId" }, + "evidence": { "type": "string" } + } + } + }, + + "states": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "screen", "kind", "description", "source", "evidence", "confidence"], + "properties": { + "id": { "type": "string", "pattern": "^SCR-[0-9]+-S[0-9]+$" }, + "screen": { "$ref": "#/$defs/scrId" }, + "kind": { "enum": ["default", "empty", "loading", "error", "success", "partial", "disabled"] }, + "description": { "type": "string" }, + "source": { "$ref": "#/$defs/srcId" }, + "evidence": { "type": "string" }, + "confidence": { "$ref": "#/$defs/confidence" } + } + } + }, + + "interactions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "screen", "control", "label", "action", "source", "evidence", "confidence"], + "properties": { + "id": { "type": "string", "pattern": "^INT-[0-9]+$" }, + "screen": { "$ref": "#/$defs/scrId" }, + "control": { "enum": ["button", "link", "form", "input", "drag", "menu", "other"] }, + "label": { "type": "string" }, + "action": { "type": "string" }, + "destination": { "type": "string" }, + "enabled_when": { "type": "string" }, + "source": { "$ref": "#/$defs/srcId" }, + "evidence": { "type": "string" }, + "confidence": { "$ref": "#/$defs/confidence" } + } + } + }, + + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "screen", "label", "glossary_term", "kind", "format", "source", "evidence", "confidence"], + "properties": { + "id": { "type": "string", "pattern": "^DAT-[0-9]+$" }, + "screen": { "$ref": "#/$defs/scrId" }, + "label": { "type": "string" }, + "glossary_term": { "type": "string", "description": "A glossary term or the literal UNMAPPED" }, + "kind": { "enum": ["display", "input", "both"] }, + "format": { "enum": ["text", "number", "date", "datetime", "currency", "count", "enum", "file", "other"] }, + "constraints_visible": { "type": "array", "items": { "type": "string" } }, + "sample_values": { "type": "array", "items": { "type": "string" } }, + "source": { "$ref": "#/$defs/srcId" }, + "evidence": { "type": "string" }, + "confidence": { "$ref": "#/$defs/confidence" } + } + } + }, + + "transitions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "from", "to", "trigger", "source", "evidence", "confidence"], + "properties": { + "id": { "type": "string", "pattern": "^TRN-[0-9]+$" }, + "from": { "type": "string" }, + "to": { "type": "string" }, + "trigger": { "type": "string" }, + "source": { "$ref": "#/$defs/srcId" }, + "evidence": { "type": "string" }, + "confidence": { "$ref": "#/$defs/confidence" } + } + } + }, + + "absences": { + "type": "array", + "description": "Every checklist category appears — with findings, or an entry whose description is none-found plus a reason.", + "items": { + "type": "object", + "required": ["id", "category", "description"], + "properties": { + "id": { "$ref": "#/$defs/absId" }, + "category": { "$ref": "#/$defs/absCategory" }, + "description": { "type": "string" }, + "affects": { "type": "array", "items": { "$ref": "#/$defs/scrId" } }, + "severity": { "$ref": "#/$defs/severity" }, + "reason": { "type": "string", "description": "Required when description is none-found" } + }, + "if": { "properties": { "description": { "const": "none-found" } } }, + "then": { "required": ["reason"] }, + "else": { "required": ["severity"] } + } + }, + + "questions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "question", "context", "suspected", "researchable"], + "properties": { + "id": { "$ref": "#/$defs/qId" }, + "question": { "type": "string" }, + "context": { "type": "string" }, + "suspected": { "enum": ["conflict", "absence", "fact", "unknown"] }, + "researchable": { "type": "boolean" }, + "blocks": { "type": "array", "items": { "$ref": "#/$defs/anyFindingId" } } + } + } + }, + + "filler": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "description", "evidence"], + "properties": { + "id": { "type": "string", "pattern": "^FIL-[0-9]+$" }, + "description": { "type": "string" }, + "evidence": { "type": "string" } + } + } + } + }, + + "$defs": { + "srcId": { "type": "string", "pattern": "^SRC-[0-9]+$" }, + "conId": { "type": "string", "pattern": "^CON-[0-9]+$" }, + "scrId": { "type": "string", "pattern": "^SCR-[0-9]+$" }, + "absId": { "type": "string", "pattern": "^ABS-[0-9]+$" }, + "qId": { "type": "string", "pattern": "^Q-[0-9]+$" }, + "anyFindingId": { "type": "string", "pattern": "^(CON|ABS)-[0-9]+$" }, + "severity": { "enum": ["blocking", "high", "normal"] }, + "confidence": { "enum": ["observed", "implied"] }, + "treeRef": { + "type": "object", + "required": ["repo", "branch"], + "properties": { "repo": { "type": "string" }, "branch": { "type": "string" } } + }, + "absCategory": { + "enum": [ + "empty-state", "error-state", "loading-state", "permission", "concurrency", + "boundary", "lifecycle", "notification", "time", "offline", "accessibility", + "role-variation", "undo" + ] + } + } +} diff --git a/src/artefact-tree/schemas/lint_decisions.py b/src/artefact-tree/schemas/lint_decisions.py new file mode 100644 index 0000000..c357c43 --- /dev/null +++ b/src/artefact-tree/schemas/lint_decisions.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Cross-file linter for the AEOS spec pipeline. + +JSON Schema validates each file's shape. This checks the invariants that span +files and IDs — the things a schema structurally cannot see: + + - every reference points at an ID that exists + - every RECLASSIFIED question's `becomes` actually resolves to a resolved item + - every blocking/high absence in the inventory is addressed in decisions + - every ASSUMED decision reached the register + - precedence and source references are internally consistent + - no resolution text looks like an implementation + +Usage: + python lint_decisions.py [--register PATH] [--schemas DIR] + +Exit code 0 = clean, 1 = errors. Warnings never fail the build. +Reads YAML if PyYAML is present, else falls back to JSON. +""" +import sys, json, argparse, pathlib, re + +def _isoify(o): + """YAML parses unquoted dates into date/datetime objects; the contract types + them as strings (format: date). Coerce back so schema validation matches.""" + import datetime + if isinstance(o, dict): + return {k: _isoify(v) for k, v in o.items()} + if isinstance(o, list): + return [_isoify(v) for v in o] + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return o + +def load(p): + p = pathlib.Path(p) + if not p.exists(): + return None + text = p.read_text() + try: + import yaml + return _isoify(yaml.safe_load(text)) + except ImportError: + return json.loads(text) + +def ids(seq, key="id"): + return {x[key] for x in (seq or []) if isinstance(x, dict) and key in x} + +# Heuristics for "this resolution describes HOW, not WHAT" +IMPL_MARKERS = [ + r"\bCRDT\b", r"\buse a\b .*\b(table|queue|cache|index|column)\b", + r"\b(postgres|redis|kafka|s3|dynamo)\b", r"\bstore (it|them|this) in\b", + r"\b(useState|useEffect|websocket|polling)\b", r"\badd a .*\b(field|table|endpoint)\b", +] + +class Linter: + def __init__(self): + self.errors, self.warnings = [], [] + def err(self, msg): self.errors.append(msg) + def warn(self, msg): self.warnings.append(msg) + + def run(self, feature_dir, register_path, schemas_dir): + d = pathlib.Path(feature_dir) + inv = load(d / "feature-inventory.yaml") + dec = load(d / "decisions.yaml") + reg = load(register_path) if register_path else None + + if inv is None: self.err(f"no feature-inventory.yaml in {d}") + if dec is None: self.err(f"no decisions.yaml in {d}") + if self.errors: return self.report() + + if schemas_dir: + self.schema_check(inv, dec, reg, pathlib.Path(schemas_dir)) + + self.check_precedence(inv) + self.check_source_refs(inv) + self.check_reference_targets(inv, dec) + self.check_reclassification(inv, dec) + self.check_absence_coverage(inv, dec) + self.check_conflict_coverage(inv, dec) + self.check_register(dec, reg) + self.check_no_implementations(dec) + self.check_unmapped(inv, dec) + return self.report() + + # ---- optional JSON Schema pass ---- + def schema_check(self, inv, dec, reg, sdir): + try: + import jsonschema + except ImportError: + self.warn("jsonschema not installed — skipping shape validation (run check-jsonschema separately)") + return + pairs = [(inv, "feature-inventory.schema.json"), (dec, "decisions.schema.json")] + if reg is not None: pairs.append((reg, "assumption-register.schema.json")) + for doc, name in pairs: + sp = sdir / name + if not sp.exists(): + self.warn(f"schema {name} not found — skipping") + continue + try: + jsonschema.validate(doc, json.loads(sp.read_text())) + except jsonschema.ValidationError as e: + loc = "/".join(str(x) for x in e.absolute_path) or "(root)" + self.err(f"{name}: {loc}: {e.message}") + + # ---- cross-file invariants ---- + def check_precedence(self, inv): + srcs = ids(inv.get("meta", {}).get("sources"), "id") + for s in inv.get("meta", {}).get("source_precedence", []): + if s not in srcs: + self.err(f"source_precedence lists {s}, not in meta.sources") + if srcs and set(inv["meta"].get("source_precedence", [])) != srcs: + missing = srcs - set(inv["meta"].get("source_precedence", [])) + if missing: + self.warn(f"sources not ranked in precedence: {sorted(missing)}") + + def check_source_refs(self, inv): + srcs = ids(inv.get("meta", {}).get("sources"), "id") + for sec in ["screens", "states", "interactions", "data", "transitions"]: + for item in inv.get(sec) or []: + s = item.get("source") + if s and s not in srcs: + self.err(f"{sec} {item.get('id')} cites {s}, not in meta.sources") + for c in inv.get("conflicts") or []: + for pos in c.get("positions", []): + if pos.get("source") not in srcs: + self.err(f"conflict {c.get('id')} position cites {pos.get('source')}, not in meta.sources") + + def check_reference_targets(self, inv, dec): + con_ids = ids(inv.get("conflicts")) + abs_ids = ids(inv.get("absences")) + q_ids = ids(inv.get("questions")) + # decisions must reference real inventory items + for r in dec.get("conflict_resolutions") or []: + if r.get("conflict") not in con_ids: + self.err(f"conflict_resolution references {r.get('conflict')}, absent from inventory") + for r in dec.get("decisions") or []: + if r.get("absence") not in abs_ids | q_ids: # a reclassified Q may land here as new ABS + # allow NEW-* created off-inventory + if not re.match(r"^(ABS|NEW)-", str(r.get("absence"))): + self.err(f"decision references {r.get('absence')}, absent from inventory") + for r in dec.get("question_resolutions") or []: + if r.get("question") not in q_ids: + self.err(f"question_resolution references {r.get('question')}, absent from inventory") + # blocks must point at real ids + allf = con_ids | abs_ids + for q in inv.get("questions") or []: + for b in q.get("blocks", []): + if b not in allf: + self.warn(f"question {q.get('id')} blocks {b}, which is not a known finding") + + def check_reclassification(self, inv, dec): + """The headline check: a RECLASSIFIED question's `becomes` must resolve.""" + resolved_con = {r["conflict"] for r in dec.get("conflict_resolutions") or [] if "conflict" in r} + resolved_abs = {r["absence"] for r in dec.get("decisions") or [] if "absence" in r} + resolved = resolved_con | resolved_abs + for r in dec.get("question_resolutions") or []: + if r.get("outcome") != "RECLASSIFIED": + continue + becomes = r.get("becomes") + if not becomes: + self.err(f"{r.get('question')} RECLASSIFIED but has no `becomes`") + elif becomes not in resolved: + self.err(f"{r.get('question')} reclassified to {becomes}, which is never resolved in this session " + f"— an orphaned reclassification looks handled and isn't") + + def check_absence_coverage(self, inv, dec): + addressed = {r["absence"] for r in dec.get("decisions") or [] if "absence" in r} + for a in inv.get("absences") or []: + if a.get("description") == "none-found": + continue + if a.get("severity") in ("blocking", "high") and a["id"] not in addressed: + self.err(f"{a['id']} ({a.get('severity')}) is unaddressed in decisions.yaml") + elif a["id"] not in addressed: + self.warn(f"{a['id']} (normal) not addressed — should have been swept to ASSUMED") + + def check_conflict_coverage(self, inv, dec): + addressed = {r["conflict"] for r in dec.get("conflict_resolutions") or [] if "conflict" in r} + for c in inv.get("conflicts") or []: + if c.get("requires_human") and c["id"] not in addressed: + self.err(f"{c['id']} requires_human but is unresolved in decisions.yaml") + + def check_register(self, dec, reg): + assumed = {r["absence"] for r in dec.get("decisions") or [] if r.get("outcome") == "ASSUMED"} + if not assumed: + return + if reg is None: + self.err(f"{len(assumed)} ASSUMED decisions but no assumption-register.yaml found") + return + registered_from = {a.get("from") for a in reg.get("assumptions") or []} + for a in assumed: + if a not in registered_from: + self.err(f"ASSUMED {a} never reached the assumption register") + + def check_no_implementations(self, dec): + for r in (dec.get("decisions") or []) + (dec.get("conflict_resolutions") or []): + text = r.get("resolution", "") + for pat in IMPL_MARKERS: + if re.search(pat, text, re.I): + self.warn(f"resolution for {r.get('absence') or r.get('conflict')} may describe " + f"implementation, not behaviour: matched /{pat}/") + break + + def check_unmapped(self, inv, dec): + unmapped = [d["id"] for d in inv.get("data") or [] if d.get("glossary_term") == "UNMAPPED"] + if unmapped: + self.warn(f"{len(unmapped)} UNMAPPED glossary terms in inventory: {unmapped} " + f"— resolve or escalate before spec writing") + + def report(self): + for w in self.warnings: print(f" warn: {w}") + for e in self.errors: print(f" ERROR: {e}") + if self.errors: + print(f"\n{len(self.errors)} error(s), {len(self.warnings)} warning(s)") + return 1 + print(f"clean ({len(self.warnings)} warning(s))") + return 0 + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("feature_dir") + ap.add_argument("--register", default=None, help="path to assumption-register.yaml") + ap.add_argument("--schemas", default=None, help="dir of *.schema.json for shape validation") + a = ap.parse_args() + sys.exit(Linter().run(a.feature_dir, a.register, a.schemas)) + +if __name__ == "__main__": + main()