diff --git a/BUGS.md b/BUGS.md index 6e7feb7b..329809c2 100644 --- a/BUGS.md +++ b/BUGS.md @@ -116,3 +116,20 @@ entries' IDs; manual edits to fix typos are fine. - **Proposed fix**: After session.prompt(), check the last assistant message's stopReason; on error, write errorMessage to stderr and exit non-zero. - **Resolved**: 2026-10-15. After session.prompt(), pi-watch reads the last assistant message the way pi's print mode does: stopReason error or aborted exits 1 with errorMessage on stderr, and length exits 1 as truncated. The check runs before --require-trailer, so a provider failure no longer surfaces as exit 6. BUG-9 (--check passing an expired token) stays open. +## BUG-13: Review page markdown renders in quadratic time on long runs of unmatched '[' +- **Observed**: 2026-10-15 +- **File**: skills/writing-specs/interactive/app/src/views/scenario-example.tsx:51 +- **Description**: The Astryx 0.6.2 Markdown parser is quadratic on long runs of unmatched '['. It affects every markdown field on the writing-specs review page, examples included, through SpecMarkdown in src/views/shared.tsx too. Timings: 100k '[' 92ms, 200k 334ms, 400k 1301ms, 800k 5172ms. Found by the round-5 security review of scenario-data-states. Triggering it takes a hostile 400k+ character spec field, and spec JSON is Claude-written. +- **Introduced by**: upstream @astryxdesign/core 0.6.2 Markdown parser (pre-dates scenario-data-states) +- **Severity**: low +- **Proposed fix**: Bound example and markdown field length in spec validation, or escape pathological unmatched delimiters before parsing. +- **Resolved**: 2026-10-15. render_spec.py validation now rejects any logic.json string over MAX_TEXT_CHARS (10,000), reported at its JSON pointer. The parser cost is per paragraph, so the cap bounds every render site (SpecMarkdown, the example panes, and the generated logic.md in SpecView) to about 24ms per field in the worst case. The parser itself is unchanged. + +## BUG-14: Review page links lose their underscores' meaning: SpecMarkdown inserts zero-width spaces into link URLs +- **Observed**: 2026-10-15 +- **File**: skills/writing-specs/interactive/app/src/views/shared.tsx:37 +- **Description**: SpecMarkdown's page-wide underscore guard (children.replace(/_/g, '_\u200b')) also rewrites Markdown link destinations, so [r](https://x.test/a_b) renders href="https://x.test/a_\u200bb" and the link opens the wrong URL. Affects every SpecMarkdown field on the review page: explanations, details, custom views. Found by the round-1 correctness review of the details sections, which fixed the same defect in scenario-example.tsx; shared.tsx is fenced in that work. +- **Introduced by**: pre-dates the details-section work (SpecMarkdown underscore guard) +- **Severity**: medium +- **Proposed fix**: Skip link destinations and autolinks when applying the underscore guard, as scenario-example.tsx now does. + diff --git a/docs/quirk/explorations/2026-10-12-scenario-data-states.md b/docs/quirk/explorations/2026-10-12-scenario-data-states.md new file mode 100644 index 00000000..611bf263 --- /dev/null +++ b/docs/quirk/explorations/2026-10-12-scenario-data-states.md @@ -0,0 +1,88 @@ +> 🧭 EXPLORATION — not a spec. No locked decisions; nothing here is build-ready. + +# Exploring: data-state examples for logic-spec scenarios + +**Date**: 2026-10-12 + +## Framing + +A logic-spec scenario states a rule as Given / When / Then. That's precise enough for the person who wrote it, but a reader still has to picture the records and run the numbers in their head. A data-state example does that work for them: it shows the records before the scenario, each step with its intermediate values, and the records after. + +This came out of a real review. After a subscription-discount logic spec was approved, its 68 scenarios were exported to a spreadsheet and three data-state columns were added by hand. Writing them surfaced four questions the approved Given / When / Then had skipped over. One example: once a "first 3 charges" discount runs out, does it stay attached to the subscription or get removed? Reviewers also found the examples much faster to check than the prose. + +The question for brainstorming is whether, and how, the logic-spec flow should produce these examples itself. + +## What a data-state example is + +Three parts, attached to one scenario: + +- **Data before:** the records the scenario touches, with only the values that matter to it. +- **Walkthrough:** numbered steps from the When, showing each intermediate value. +- **Data after:** the same records once the scenario finishes. This is the Then written as data. + +### Example: two percentage discounts compound + +Scenario: *Given a $100 line with a 10% and a 15% line discount, when the charge is calculated, then the line nets $76.50.* + +| Data before | Walkthrough | Data after | +|---|---|---| +| **Charge 1**
Coffee line: $100.00, quantity 1
Line discount "Loyalty": 10% off
Line discount "Winback": 15% off | 1. "Loyalty": $100.00 × 0.90 = $90.00
2. "Winback": $90.00 × 0.85 = $76.50 | **Charge 1**
Coffee line: $76.50
"Loyalty" took $10.00
"Winback" took $13.50 | + +### Example: a limited discount runs out + +Scenario: *Given a subscription with a line discount limited to the first 3 charges, when three charges have processed and the next one is generated, then the fourth charge doesn't include it.* + +| Data before | Walkthrough | Data after | +|---|---|---| +| **Subscription**
Line discount "Welcome": 10%* off, first 3 charges
Uses left: 3 | 1. Charge 1 processes → uses left 2
2. Charge 2 processes → uses left 1
3. Charge 3 processes → uses left 0
4. Charge 4 is generated | **Charge 4**: Coffee line has no line discounts
**Subscription**: "Welcome" uses left 0
[ASK: does "Welcome" stay on the subscription once its uses run out, or get removed?] | + +### Example: one scenario, two branches + +Scenario: *Given a shared discount definition at 10%, when the merchant edits it to 15%, then the edit is ignored once the definition has been used, and allowed while it hasn't.* + +| Data before | Walkthrough | Data after | +|---|---|---| +| **Discount definition** "Loyalty": 10% off
(a) used 4* times
(b) never used | 1. The merchant changes "Loyalty" to 15%
(a) The request succeeds, but the change is ignored
(b) The change is saved | (a) "Loyalty" stays 10%
(b) "Loyalty" is 15%; queued charges pick it up on their next regen | + +## Conventions that made them readable + +The first draft used IDs (`C1 line A D1 10% amount 4.00`). Reviewers found it hard to follow, so the second draft switched to these conventions: + +- **Records are named in full:** "Subscription", "Charge 2 (queued)", "Discount definition". +- **Lines are named after a product:** "Coffee line", not "line A". +- **Discounts have titles:** "Loyalty", "Sorry", "Welcome". The same title is reused for the same kind of discount across scenarios. +- **Every number has a label:** `"Winback": $90.00 × 0.85 = $76.50` and `"Loyalty" took $10.00`, not bare figures. +- **Steps say who acts:** "The customer skips Charge 2", "An API client tries to add…". +- **Invented numbers are marked `*`.** Where the scenario doesn't give a value, one is picked to make the example concrete, and the mark says it isn't part of the rule. Product names and discount titles are always illustrative. +- **Branches are labelled (a), (b)** when one scenario covers several outcomes. +- **`[ASK: …]` marks a gap,** a detail the scenario doesn't settle that the example can't show without guessing. +- **Not every scenario changes data over time.** For API-shape, webhook and feature-flag scenarios, the walkthrough is the request and *Data after* is the response or payload. + +In the spreadsheet, colour carried part of the meaning: records in blue bold, step numbers in purple, results in green bold, rejections in red, illustrative values in grey italic, and `[ASK]` in underlined amber. *Data before* had a grey background and *Data after* a green one. + +## What they were good for + +- **Finding gaps.** Writing *Data after* forces a concrete answer. Four of the 68 scenarios had a question the prose hadn't answered: + - what happens to an expired discount; + - what happens to a discount left off because the currency changed; + - the format of a "created by" field; + - a timing mechanism that had been deferred. +- **Checking arithmetic.** Calculation scenarios (stacking, rounding, clamping, tax bases, currency conversion) became easy to verify line by line. +- **Onboarding readers.** People who weren't in the review could follow a scenario without the surrounding prose. + +## Costs and risks seen + +- **Invented values can read as spec.** The `*` mark and the "names are illustrative" note are what keep this from happening. Without them, a reader could take an example value as a requirement. +- **Field names leak in.** It's tempting to write `uses_remaining` or `source_type`, but at logic-spec stage those names aren't decided. The examples worked better in plain words. +- **Effort is uneven.** Calculation scenarios almost write themselves from the Given / Then. Integration scenarios (external payloads, sync timing) took real effort and some could only be partly worked. +- **They can drift.** When a scenario is rewritten during review, its example has to be rewritten too, or it contradicts the rule. + +## Questions for brainstorming + +- **When are they written?** Options include alongside each scenario at stage 1, only after scenarios are approved, or on request. +- **Who writes them, and how are they checked?** Is a wrong example a scenario dispute, or something separate? +- **Every scenario, or only some?** Possible cut-offs: calculation and state-change scenarios only, or any scenario where the reviewer asks. +- **Where do they live?** Options include a field on each scenario in `logic.json`, a rendered block in `logic.md`, or a view in the review page. +- **How are `[ASK]` gaps routed?** Should a gap become a reviewer question, a blind spot, or a new scenario? +- **What happens on rewrite?** How does an example stay in sync when its scenario is rewritten or reopened? +- **How does the review page render them?** Does the colour coding carry over, and does an example count toward approving the scenario? diff --git a/docs/quirk/specs/2026-10-12-scenario-data-states/logic.md b/docs/quirk/specs/2026-10-12-scenario-data-states/logic.md new file mode 100644 index 00000000..98f63aac --- /dev/null +++ b/docs/quirk/specs/2026-10-12-scenario-data-states/logic.md @@ -0,0 +1,517 @@ +# Data-state examples for logic-spec scenarios + +**Status:** Approved. Tech spec: authored (tier gate fired: requested, ≳3 source files) + +Logic-spec scenarios can carry a data-state example: the records before, a step-by-step walkthrough, +and the records after. A scenario whose illustration isn't records can carry a titled details section +instead, or as well. It applies to both spec formats and the interactive review page. Source +exploration: [2026-10-12-scenario-data-states.md](../../explorations/2026-10-12-scenario-data-states.md). + +## Conceptual model + +A scenario's Given / When / Then is precise enough for its author, but a reader still has to picture +the records and run the numbers in their head. An example does that work for them. Writing one also +forces a concrete answer to anything the scenario left open. When this was tried by hand on an approved +68-scenario spec, the examples turned up four questions the approved scenarios had never answered. + +### What an example is + +An example is an optional part of one scenario, in three parts: + +- **Data before** lists the records the scenario touches, with only the values that matter to it. +- **Walkthrough** gives numbered steps from the When. Each step says who acts and shows the intermediate value. +- **Data after** shows the same records once the scenario finishes. It is the Then written as data. + +Request/response scenarios (API shape, webhooks, CLI output) keep the same three labels. The +walkthrough is the request or command, and Data after is the response or output. + +**The Then is the contract; the example is a checked restatement of it.** Approving a scenario means +the reviewer checked that its example agrees with the Then. When they disagree, the Then stands and +the example is fixed. Anything the example would need that the Then doesn't settle becomes a gap for +the reviewer, so an example never quietly sets policy. + +Not everything a scenario needs to show is a record changing. Screen states, message copy, a timeline +or a decision table have no before and after. For those the example carries a **details section**: one +Markdown block under a one-line title Claude chooses, such as "Screen states". Tables, code blocks and +lists all work inside it. A scenario can carry the three data-state parts, a details section, or both. +The details section is part of the example, so everything above holds for it too: the Then is the +contract, the reviewer checks it against the Then, and a gap in it is an `[ASK]`. + +### Conventions + +These rules come from the hand trial, where a first draft written with IDs (`C1 line A D1 10%`) was +hard to follow. Both rubrics follow one shared copy of them. + +- Records are named in full and in bold: **Subscription**, **Charge 2 (queued)**, **Discount definition**. +- Lines are named after a product ("Coffee line"), never "line A". +- A title names a kind of thing ("Loyalty", "Welcome") and is reused for the same kind across + scenarios. Each example states the values it needs, so one title can carry different values in + different scenarios. There is no spec-level cast list. +- Every number has a label: `"Winback": $90.00 × 0.85 = $76.50`, `"Loyalty" took $10.00`. +- Steps say who acts: "The customer skips Charge 2", "An API client tries to add…". +- `✓` marks a result and `✗` marks a rejection. +- A trailing `*` marks an invented value, picked to make the example concrete. It is not part of the rule. + Product names and titles are always illustrative. +- `(a)`, `(b)` label branches when one scenario covers several outcomes. +- `[ASK: …]` marks a gap: something the scenario doesn't settle that the example can't show without guessing. +- Plain words only. Field and column names belong to the tech spec. + +On the review page the marks map to colour. Records are blue bold, step numbers purple, `✓` phrases +green bold, `✗` phrases red, `*` values grey italic, and `[ASK]` amber underlined. Data before sits +on a grey background and Data after on a green one. A details section uses the same marks and +colours, except inside code blocks, where text stays plain. In `logic.md` the marks stay plain text. + +### Where an example lives + +In `logic.json`, an example is three typed parts holding marked-up markdown: a `before` string, a +`steps` list with one string per step, and an `after` string. In a markdown spec, and in the +`logic.md` the renderer generates, the same text appears as three labelled sub-bullets under the +scenario: + +```markdown +1. **SC-15** + - **Given** a $100 line with a 10% and a 15% line discount + - **When** the charge is calculated + - **Then** the line nets $76.50 + - **Data before:** **Charge 1**: Coffee line $100.00; "Loyalty" 10% off; "Winback" 15% off + - **Walkthrough:** + 1. "Loyalty": $100.00 × 0.90 = $90.00 + 2. "Winback": $90.00 × 0.85 = $76.50 + - **Data after:** **Charge 1**: ✓ Coffee line $76.50; "Loyalty" took $10.00; "Winback" took $13.50 +``` + +A details section is a `details` object holding a `title` string and a `body` string of markdown. An +example needs the three data-state parts, a details section, or both. In `logic.md` the details section +is one more sub-bullet after Data after, labelled with its bold title, with the body indented under it: + +```markdown +1. **SC-22** + - **Given** a customer whose card was declined twice + - **When** they open the billing page + - **Then** the page shows a retry banner and hides the skip button + - **Screen states:** + + | Element | Shown | + |---|---| + | Retry banner | ✓ "Your last payment failed. Update your card." | + | Skip button | ✗ hidden | +``` + +## Data flow + +**Markdown specs.** + +1. Claude writes `logic.md`, giving each scenario with something to show an example in the same pass: + data-state parts, a details section, or both. +2. Self-review checks that every example agrees with its Then, and that each open `[ASK]` is a question + the design didn't already answer. +3. At the review gate Claude lists every open `[ASK]` with its scenario. +4. Each answer goes into the scenario's Then, or becomes a new scenario. Claude rewrites the example + to match, which removes the `[ASK]`. +5. The user approves. Later amendments rewrite an example in the same amendment as its scenario. + +**Interactive specs.** + +1. Claude writes stage-1 `logic.json` with an example on each scenario that has data to show. +2. On the page, rows carry an Example token, plus an Open question token while an `[ASK]` is open. + Clicking any row opens the focus dialog, which shows the data-state parts in three columns and any + details section full-width below them. +3. The reviewer decides each scenario. An open `[ASK]` or a wrong example is answered through Update. + Picking a different outcome on a scenario with an example also becomes an update. Claude rewrites + the Then and the example together, and the scenario returns for a fresh decision. +4. The stage-1 fold refuses while an approved scenario's example has an open `[ASK]`. Otherwise it + pins each scenario's hash, and the hash covers the example. +5. Derivation reads scenarios only. Fixing an example after the stage-1 sign-off goes through the + existing scenario dispute, reopen, and re-approve path. +6. The final fold generates `logic.md` with the same sub-bullets as a markdown spec. + +## Behavior & scenarios + +Scenarios that walk through review state changing carry an example in the format this spec defines. + +### 1. Claude writes an example for every scenario with data to show + +1. **1.1** + - **Given** a scenario that computes or changes records + - **When** Claude writes the spec, in either format + - **Then** the scenario carries an example, written in the same pass as its Given / When / Then +2. **1.2** + - **Given** a scenario with a concrete input and output, such as an API request or a CLI command + - **When** Claude writes it + - **Then** its example keeps the three labels, with the request or command as the walkthrough and + the response or output as Data after +3. **1.3** + - **Given** a scenario with nothing to show, neither records nor any other concrete illustration, + such as a naming rule or a placement rule + - **When** Claude writes it + - **Then** it has no example and no note explaining the absence +4. **1.4** + - **Given** a scenario whose illustration isn't records changing, such as screen states, message + copy, a timeline or a decision table + - **When** Claude writes it + - **Then** its example is a details section: a one-line plain-text title naming the kind of + illustration, and a Markdown body +5. **1.5** + - **Given** a scenario that changes records and also has something else to show, such as the + message the customer sees + - **When** Claude writes it + - **Then** its example carries the three data-state parts and a details section + +### 2. Examples follow the shared conventions + +1. **2.1** + - **Given** a value the scenario doesn't state, such as a discount's percentage + - **When** Claude needs one to make the example concrete + - **Then** it picks one and marks it `*` +2. **2.2** + - **Given** a choice the Then doesn't settle, such as the rounding point, whether discounts + compound or add, or whether records the example doesn't list stay unchanged + - **When** the walkthrough would need that choice + - **Then** Claude writes an `[ASK]` instead of choosing +3. **2.3** + - **Given** a scenario that covers several outcomes + - **When** Claude writes its example + - **Then** the outcomes are labelled `(a)`, `(b)` in every part that differs +4. **2.4** + - **Given** a concept with an obvious column name, such as remaining uses + - **When** Claude writes it into an example + - **Then** it uses plain words ("Uses left"), never the field name + +### 3. The Then is the contract + +1. **3.1** + - **Given** an example that disagrees with its Then + - **When** Claude or the reviewer notices + - **Then** the Then stands and the example is rewritten to match it +2. **3.2** + - **Given** a reviewer who spots a wrong example on the page + - **When** they want it fixed + - **Then** they use Update, and stage 1 can't be signed until Claude rewrites the example + +### 4. An open gap blocks its scenario until the answer is in the Then + +1. **4.1** + - **Given** a stage-1 scenario whose example has an open `[ASK]` + - **When** the reviewer opens it in the focus dialog + - **Then** Approve is unavailable, the row shows an Open question token, and Update and Drop still work + - **Data before:** **Scenario SC-14**: example Data after ends "[ASK: does "Welcome" stay on the + subscription once its uses run out?]"; **Review state**: SC-14 undecided + - **Walkthrough:** + 1. The reviewer opens SC-14 → ✗ Approve is unavailable + 2. The reviewer chooses Update and writes "It gets removed once uses hit 0" + - **Data after:** **Review state**: ✓ SC-14 has an update request; stage 1 can't be signed while it's open +2. **4.2** + - **Given** a reviewer who answered an `[ASK]` through Update + - **When** Claude processes the export + - **Then** the answer goes into the Then (or a new scenario), the example is rewritten without the + `[ASK]`, and the scenario returns for a fresh decision + - **Data before:** **Scenario SC-14**: Then "the fourth charge doesn't include it"; example ends with + the `[ASK]`; **Review state**: SC-14 update "It gets removed once uses hit 0" + - **Walkthrough:** + 1. Claude reads SC-14's update from the export + 2. Claude rewrites SC-14's Then and its Data after + 3. Claude re-renders with the export as the prior review + - **Data after:** **Scenario SC-14**: Then "the fourth charge doesn't include it, and "Welcome" is + removed from the subscription"; Data after "✓ **Subscription**: no "Welcome" discount", no `[ASK]`; + **Review state**: ✓ SC-14 undecided, its update request cleared +3. **4.3** + - **Given** a signed stage-1 export that approves a scenario whose example still has an open `[ASK]` + - **When** Claude runs the stage-1 fold + - **Then** the fold refuses and names the scenario +4. **4.4** + - **Given** a dropped scenario whose example has an open `[ASK]` + - **When** stage 1 is signed and folded + - **Then** nothing blocks, because a dropped scenario isn't part of the contract +5. **4.5** + - **Given** a markdown spec with open `[ASK]`s + - **When** Claude reaches the user review gate + - **Then** it lists each `[ASK]` with its scenario, and the answers go into the Thens before approval +6. **4.6** + - **Given** a stage-1 scenario whose details section has an open `[ASK]` and whose data-state parts + have none + - **When** the reviewer opens it in the focus dialog + - **Then** it is blocked exactly as in 4.1: Approve is unavailable and the row shows an Open + question token + +### 5. An example changes with its scenario + +1. **5.1** + - **Given** a stage-1 scenario the reviewer has approved but not yet signed + - **When** Claude edits only its example and re-renders + - **Then** the scenario's decision clears and it returns for a fresh decision + - **Data before:** **Scenario SC-15**: approved; walkthrough step 1 reads "$100.00 × 0.90 = $90.00" + - **Walkthrough:** + 1. Claude corrects a label in SC-15's walkthrough step 1 and re-renders with the prior review + 2. The reviewer reloads the page + - **Data after:** **Review state**: ✓ SC-15 undecided and marked as changed since review; + **Scenario SC-15**: Then unchanged +2. **5.2** + - **Given** a scenario with an example + - **When** the reviewer picks one of Claude's alternative outcomes or writes their own + - **Then** the scenario becomes an update request to rewrite the example for that outcome, and it + can't be approved until Claude rewrites the Then and the example together + - **Data before:** **Scenario SC-15**: Then "the line nets $76.50"; alternative "the line nets + $75.00"; walkthrough compounds the discounts; **Review state**: SC-15 undecided + - **Walkthrough:** + 1. The reviewer picks "the line nets $75.00" → ✓ SC-15 gets an update request to rewrite its example + 2. The reviewer tries to approve → ✗ Approve is unavailable while the update is open + 3. Claude rewrites SC-15's Then and walkthrough: $100.00 × (1 − 0.10 − 0.15) = $75.00 + - **Data after:** **Scenario SC-15**: Then "the line nets $75.00"; walkthrough adds the discounts; + **Review state**: SC-15 undecided, awaiting a fresh decision +3. **5.3** + - **Given** a scenario without an example + - **When** the reviewer picks an alternative outcome + - **Then** they can approve it directly, as they can today +4. **5.4** + - **Given** a signed stage 1 + - **When** an example turns out to be wrong + - **Then** fixing it goes through a scenario dispute, which reopens the scenario until the reviewer + re-approves it +5. **5.5** + - **Given** an approved spec in either format + - **When** an amendment changes a scenario + - **Then** the same amendment rewrites that scenario's example +6. **5.6** + - **Given** a spec Claude has just written or revised + - **When** Claude runs self-review + - **Then** it checks that every example agrees with its Then, and that each open `[ASK]` is a question + the design didn't already answer + +### 6. The review page shows examples in the focus dialog + +1. **6.1** + - **Given** a scenario with an example + - **When** the scenarios table renders + - **Then** its row shows an Example token, plus an amber Open question token while an `[ASK]` is + open, and never the example itself +2. **6.2** + - **Given** any scenario row, in either stage + - **When** the reviewer clicks it + - **Then** the focus dialog opens on that scenario, with Previous and Next across that behavior's + scenarios, while "Review undecided" keeps its own queue +3. **6.3** + - **Given** a scenario with an example open in the focus dialog + - **When** it renders + - **Then** Data before, Walkthrough and Data after appear as three columns in the full palette, + stacking on narrow screens, and the dialog widens to fit them +4. **6.4** + - **Given** a scenario whose example has a details section + - **When** the focus dialog renders it + - **Then** the details section appears full-width under its title, below the three columns when + the scenario has them, and the row shows the same Example token as any other example + +### 7. `logic.md` shows examples as labelled sub-bullets + +1. **7.1** + - **Given** a scenario with an example, in a hand-written or generated `logic.md` + - **When** the spec is read + - **Then** the example appears after the Then (and any alternatives) as **Data before**, + **Walkthrough** as a numbered list, and **Data after**, with `✓`, `✗`, `*` and `[ASK]` as plain text +2. **7.2** + - **Given** a scenario whose example has a details section + - **When** `logic.md` is read + - **Then** the details section is the last sub-bullet, labelled with its bold title, with its body + indented under it so tables and code blocks render +3. **7.3** + - **Given** a details title that is blank, runs over one line, or reads Data before, Walkthrough or + Data after + - **When** the spec is validated + - **Then** validation fails and names the title + +### 8. Later stages take their contract from the Then + +1. **8.1** + - **Given** an approved scenario with an example + - **When** Claude derives stage-2 requirements + - **Then** it derives from the scenario alone, and `derivedFrom` names scenarios only +2. **8.2** + - **Given** a `*` value in an approved example + - **When** an execution agent writes tests + - **Then** it may use the value as test input, but an expected value always comes from the Then +3. **8.3** + - **Given** the logic-spec rubrics + - **When** they describe examples + - **Then** they say nothing about turning examples into tests + +### 9. Specs without examples are unchanged + +1. **9.1** + - **Given** an existing signed v2 spec whose scenarios have no examples + - **When** it is validated and re-rendered after this change + - **Then** validation passes, its stage-1 pin still matches, and `logic.md` is byte-identical + - **Data before:** **Spec**: stage 1 signed; 40* scenarios, none with an example; + **Stage-1 pin**: one hash per scenario + - **Walkthrough:** + 1. The maintainer runs `validate` → ✓ passes + 2. The maintainer runs `render` → `logic.md` rewritten + - **Data after:** **Stage-1 pin**: ✓ matches every scenario; **logic.md**: ✓ byte-identical to before +2. **9.2** + - **Given** a v1 spec + - **When** it is validated or rendered + - **Then** it behaves exactly as today + +## Key decisions & rationale + +- **The Then stays the contract.** If examples bound the spec, every invented value would become a + requirement. That was the main risk the hand trial reported. Keeping rules and examples as + separate tiers matches Example Mapping. The example's job is to expose a gap, and the Then + absorbs the answer. +- **The example is hashed with its scenario.** Approval covers the example, so a pinned approval can + never sit next to an example the reviewer didn't see. Specs without examples keep their hashes + because an absent part changes nothing. +- **Gaps block before approval.** In the trial, examples were written after approval, so each question + they found needed an amendment. Writing them with the scenarios and blocking on `[ASK]` moves that + discovery into review. +- **The focus dialog is the only place examples show.** The table stays as scannable as it is today. + Any row opens the dialog, so approved scenarios and stage 2 keep access. +- **The roles live in plain-text marks.** The full palette needs results and rejections marked, and + `✓`/`✗` read naturally in raw `logic.md`. The same text then works in both formats. +- **Three typed parts with markdown values.** This was chosen over one markdown blob, which parses + fragilely, and over fully structured records, where deep nesting hurts authoring accuracy and markdown + specs couldn't share the format. See Industry Insights. +- **A details section is one titled blob, kept inside the example.** Its content has no fixed shape, so + typed parts would only constrain it. The title stays a typed field, so the label never has to be + parsed out of the body. Keeping it inside the example means every example rule applies to it with no + second set of rules. + +**Success looks like this.** An interactive fixture spec built from the exploration's three examples +renders correctly in the focus dialog and in `logic.md`. Its open `[ASK]` blocks approval, and picking a +different outcome sends the scenario back. Existing specs re-render with byte-identical `logic.md`, and +their stage-1 pins hold. + +## Scope & non-goals + +In scope: both logic-spec rubrics and the shared conventions they link to, the interactive spec's +scenario shape, validation, hashing and fold checks, generated `logic.md`, and the review page's table +tokens and focus dialog. + +Out of scope: + +- Backfilling examples into existing specs. An owner can add them later through an amendment. +- An automatic checker that recomputes walkthrough arithmetic. The reviewer and self-review check the numbers. +- Spreadsheet or CSV export of scenarios and examples. +- v1 `logic.json` specs. +- A defined cast list, or a check that a title keeps the same values across scenarios. +- Any rubric guidance on turning examples into tests. +- Showing examples in the scenarios table rows. + +## Decisions Locked + +**Coverage & timing** +- Both formats get examples, with the same three parts and one shared set of conventions. +- A scenario gets an example wherever there's data to show, meaning it computes or changes records or + has a concrete input and output. Other scenarios are skipped without comment. +- Examples are written with the scenarios, before the first review. +- A reviewer asks for an example through a scenario comment. There is no new control. + +**Review semantics & gaps** +- The example is a checked restatement and the Then is the contract. +- A wrong example is flagged through Update, which blocks sign-off. +- A choice the Then never stated becomes an `[ASK]`. +- An open `[ASK]` blocks its scenario until answered, and the answer goes into the Then. Markdown + specs list open `[ASK]`s at the review gate. + +**Drift on rewrite** +- The example is hashed with its scenario, so after the stage-1 sign-off a fix goes through reopen. +- Picking a different outcome on a scenario with an example sends it back for Claude to rewrite the example. +- Both rubrics get a sync rule (rewrite the example in the same edit, amendments included) and a + self-review check. + +**Rendering** +- On the page, examples appear only in the focus dialog, and any scenario row opens it. +- Inside the dialog the example is three columns: before, walkthrough, after. +- The full spreadsheet palette carries over. +- `✓` marks a result and `✗` marks a rejection. +- `logic.md` shows the example as labelled sub-bullets. +- Rows show an Example token and an Open question token, and the dialog widens for three columns. + Both were proposed in the design and approved with it. + +**Downstream use** +- Requirements derive from the scenario alone. +- A `*` value may seed test inputs but is never an expected value. +- The rubric says nothing about turning examples into tests. + +**Shared cast** +- Recurring names are a writing convention, defined nowhere. +- A title names a kind of thing, not its values. + +**Format & approach** +- The three labels stay fixed, including for request/response scenarios. +- Storage uses three typed parts holding marked-up markdown (approach A). +- Success is a fixture spec plus a no-regression check. +- The out-of-scope list was approved with the design. + +**Details sections** (amendment, 2026-10-15) +- A scenario can carry data-state parts, a details section, or both. +- A details section is one Markdown block with a required one-line plain-text title Claude chooses. +- Every example rule applies to it: the Then is the contract, marks are coloured, an open `[ASK]` + blocks, editing it clears the decision, and a different outcome sends it back for a rewrite. +- Both formats get it. In the dialog it sits full-width below the three columns. Rows keep the one + Example token. In `logic.md` it is a sub-bullet labelled with its title, which can't reuse a + data-state label. + +## Industry Insights + +Research ran through WebSearch and WebFetch. The Brave search skills were unavailable in this session. + +- Example Mapping keeps rules, examples and open questions as separate tiers: rules on blue cards, + examples on green, questions on red. That matches Then, example and `[ASK]`. + [Cucumber: Example Mapping](https://www.cucumber.io/docs/bdd/example-mapping) +- Gherkin's `Rule` keyword groups illustrating scenarios under one business rule. It's the closest + built-in split between a rule and its examples. [Cucumber: Gherkin reference](https://www.cucumber.io/docs/gherkin/reference), + [Gherkin rules](https://cucumber.io/blog/bdd/gherkin-rules/) +- Incidental detail hides what an example tests, and UI or element detail makes specs brittle. Hence + "only values that matter" and the no-field-names rule. + [Cucumber anti-patterns part 1](https://cucumber.io/blog/bdd/cucumber-antipatterns-part-one/), + [part 2](https://cucumber.io/blog/bdd/cucumber-anti-patterns-part-two/) +- A stale example can pass while encoding a rule the business has dropped, so a rule change needs a + human review of its examples in the same change. This is why the example is hashed with its scenario. + [qaskills: BDD living documentation](https://qaskills.sh/blog/bdd-living-documentation-reports) +- No source found prescribes a marker for illustrative versus binding example values. The `*` + convention is this project's own. +- Markdown inside generated strings often breaks at its boundaries: 38% of valid outputs in one + benchmark. Typed fields, as in GitHub issue forms, move the contract into the schema. This argues + against one markdown blob. [LatentMD](https://arxiv.org/abs/2609.06993), + [GitHub issue forms](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms) +- Deeply nested JSON lowers extraction accuracy: strict scores under 60% at 5–7 levels, and string + lists score worst. This argues against fully structured records. + [DeepJSONEval](https://arxiv.org/abs/2509.25922) +- A trailing `*` can pair with an earlier emphasis opener under CommonMark's flanking rules, so the + renderer and page need an agreed escape for it. [CommonMark spec](https://spec.commonmark.org/0.31.2/) + +## Deferred Ideas + +- Use approved examples as test seeds. This would be a writing-plans change. +- Spreadsheet or CSV export of scenarios with their examples. +- A warning when one title carries different values across scenarios, if that drift shows up in practice. +- Show examples when brainstorming presents scenarios during design, before the spec is written. +- `[tech-spec]` The escape rule for a trailing `*` against markdown emphasis, in both the page and the renderer. +- `[tech-spec]` The exact patterns the page uses to detect each mark for colouring. +- `[tech-spec]` Validator errors for near-miss `[ASK]` forms, such as `[ASK]` with no colon or `ASK:`. +- `[tech-spec]` The wording of the automatic update request when a reviewer picks a different outcome. +- `[tech-spec]` How wide the focus dialog gets for a scenario with an example. +- `[tech-spec]` How `✓`/`✗` phrases are coloured inside a details body without breaking tables, lists + or code blocks. + +## Glossary + +| Term | Definition | +|---|---| +| Data-state example | A scenario's optional Data before / Walkthrough / Data after. | +| Details section | An example's optional titled Markdown block, for an illustration that isn't records changing. | +| Checked restatement | The example's role: data that must agree with the Then and never overrides it. | +| Invented value | A value marked `*` and picked to make an example concrete. It is not part of the rule. | +| Gap | An `[ASK: …]`: something the scenario doesn't settle that the example can't show without guessing. | +| Shared conventions | The single set of example-writing rules that both logic-spec rubrics link to. | +| Data to show | A scenario that computes or changes records, or has a concrete input and output. | +| Something to show | Data to show, or any other concrete illustration a details section can carry. | + +## Status & amendments + +**Status:** Approved. Tech spec: authored (tier gate fired: requested, ≳3 source files) + +**Amendments:** + +- 2026-10-15: An example can carry a titled details section, instead of or as well as the data-state + parts, for scenarios whose illustration isn't records changing. Adds 1.4, 1.5, 4.6, 6.4, 7.2 and 7.3, + and narrows 1.3. diff --git a/docs/quirk/specs/2026-10-12-scenario-data-states/tech.md b/docs/quirk/specs/2026-10-12-scenario-data-states/tech.md new file mode 100644 index 00000000..24b62ee9 --- /dev/null +++ b/docs/quirk/specs/2026-10-12-scenario-data-states/tech.md @@ -0,0 +1,351 @@ +# Tech spec: data-state examples for logic-spec scenarios + +**Status:** Reviewed +**Logic spec:** [logic.md](logic.md) (Approved. Tech spec: requested) + +All paths are relative to the repo root (`/Users/zpyoung/orca/workspaces/quirk/scenario-data-states`). +Line numbers are as of `RUN_BASE` `1a39a23` and drift as tasks land; the symbol names are the stable anchor. + +## Architecture + +Implements [Where an example lives](logic.md#where-an-example-lives) and [Data flow](logic.md#data-flow). + +Four units change, and they talk only through `logic.json` and the render payload: + +| Unit | Files | Role | +|---|---|---| +| Rubrics | `skills/writing-specs/logic-spec.md`, `skills/writing-specs/interactive-logic-spec.md`, new `skills/writing-specs/scenario-examples.md` | Tell Claude when and how to write an example. The new file is the one home for the conventions; both rubrics link to it. | +| Renderer | `skills/writing-specs/interactive/render_spec.py` (Python 3.9+, stdlib only) | Validates `example`, hashes it with its scenario, gates the stage-1 fold, renders sub-bullets into `logic.md`, and puts `openAsks` into the page payload. It is the **only** place that decides whether an `[ASK]` is open. | +| Page types and state | `skills/writing-specs/interactive/app/src/spec-types.ts`, `review-state.ts` | Type the new field and payload key; one pure helper that says why Approve is blocked. | +| Page views | `app/src/views/scenarios-view.tsx`, `app/src/views/scenario-focus-dialog.tsx`, new `app/src/views/scenario-example.tsx`, `app/src/index.css` | Row tokens, any-row-opens-dialog, three-column example with the palette, outcome change → update request. | + +The page is React 19 + `@astryxdesign/core` 0.6.2, built by Vite with `vite-plugin-singlefile` into the +committed `skills/writing-specs/interactive/review-template.html`. `render_spec.py` embeds the payload +into that template at its single `PAYLOAD_PLACEHOLDER`. Tests are pytest (`pytest -q` from the repo root, +`tests/` per `pyproject.toml`); the page has no JS test runner, only `tsx` check scripts under `app/scripts/`. + +`renderId` is `render_id(logic)` over `logic.json` alone (`render_spec.py` `render_id`, ~L176), so +template or payload changes never move a spec's `renderId`. + +## Code references + +### Renderer: `skills/writing-specs/interactive/render_spec.py` + +Back-links: [4](logic.md#4-an-open-gap-blocks-its-scenario-until-the-answer-is-in-the-then), +[5](logic.md#5-an-example-changes-with-its-scenario), +[7](logic.md#7-logicmd-shows-examples-as-labelled-sub-bullets), +[9](logic.md#9-specs-without-examples-are-unchanged). + +| Symbol | Where | Change | +|---|---|---| +| `RENDER_OUTPUT_FIELDS_V2` | ~L25-37 | **None.** `example` must stay hashed. See fences. | +| `item_hashes`, `stage1_item_hashes`, `canonical_json`, `render_id` | ~L172-209 | **None.** | +| `Validator` (`error`, `string`, `string_array`, `object`, ...) | ~L72 | Use as-is to validate `example`. | +| `validate_logic_v2` scenario loop | ~L898-937 | Add `example` validation and the malformed-marker check after the `answeredComment` check (~L935). | +| `validate_logic_v2` stage-2 branch | ~L1321-1353 | Add the open-`[ASK]` error for non-dropped scenarios. | +| `stage1_gate_failures(logic, state)` | ~L2653 | Add an `examples` gate after the `scenarios` gate. | +| `command_fold` | ~L2914-2958 | **None.** It already prints `gate : ` per failure and exits 4 (~L2942). | +| `render_markdown_v2` scenario block | ~L2207-2231 | Emit example lines after the `**Alternatives**` line (~L2222) and before the explanation line (~L2223). | +| `prepare_render_files` | ~L2387-2413 | Add `openAsks` to the embedded payload. | +| new `scenario_open_asks`, `_example_markdown_lines`, regex constants | module level, near the other helpers | Create. | + +### Page: `skills/writing-specs/interactive/app/src/` + +Back-links: [4](logic.md#4-an-open-gap-blocks-its-scenario-until-the-answer-is-in-the-then), +[5](logic.md#5-an-example-changes-with-its-scenario), +[6](logic.md#6-the-review-page-shows-examples-in-the-focus-dialog), [Conventions](logic.md#conventions). + +| Symbol | Where | Change | +|---|---|---| +| `Scenario` | `spec-types.ts` ~L35-56 | Add `example?: ScenarioExample`. | +| `LogicSpecPayload` | `spec-types.ts` ~L348-355 | Add `openAsks: Record`. | +| `isPayload` (used by `readPayload`) | `review-state.ts` ~L222 | Also require `openAsks`: an object whose values are arrays of strings. A payload without it fails the existing schema-mismatch error instead of crashing later. | +| new `approveBlockReason` | `review-state.ts`, next to `scenarioThen` (~L581-588) | Create. | +| new `EXAMPLE_OUTCOME_UPDATE_PREFIX`, `exampleOutcomeUpdate` | `review-state.ts` | Create. | +| `isScenarioUndecided`, `gateList`, `carryOver`, `changedSinceReviewIds` | `review-state.ts` ~L306-666 | **None.** See fences. | +| `OutcomeCell` `set` | `views/scenarios-view.tsx` ~L41-79 (`set` ~L44-53) | Write/remove the automatic update when the scenario has an example. | +| `DecisionCell` | `views/scenarios-view.tsx` ~L83-144 (Approve item ~L116, hint ~L120) | Disable Approve from `approveBlockReason`; show its hint. | +| `scenarioColumns` `id` column | `views/scenarios-view.tsx` ~L258-274 | Example / Open question tokens; the ID becomes a button that opens the dialog. | +| `BehaviorGroup` | `views/scenarios-view.tsx` ~L351-371 | Own the browse-dialog state; add a row-click `TablePlugin`; render `ScenarioFocusDialog` in browse mode. | +| row-click precedent | `views/board-view.tsx` ~L245-260 (`dragPlugin.transformBodyRow`, `onClick`) | Follow this pattern. | +| `ScenarioFocusDialog`, `OpenFocusDialog`, `FocusedScenario`, `undecidedQueue` | `views/scenario-focus-dialog.tsx` (export ~L46, queue ~L12-18, width ~L76, body ~L28-43) | Add browse mode, example pane, width rule, stage-2 read-only body. | +| `SpecMarkdown` | `views/shared.tsx` ~L21-28 | Reuse. Its `_` → `_​` trick stays. | +| `MarkdownInlinePlugin` | `@astryxdesign/core/Markdown`; usage in `views/item-links.tsx` (`ItemLinksProvider`) | Mark colouring uses the same `{ pattern, render }` plugin shape. | +| new `ScenarioExampleView`, `exampleMarkPlugins` | new `views/scenario-example.tsx` | Create. | +| `.scenario-example` rules | `index.css` (after the decision colours, ~L27-38) | Create. | + +### Rubrics + +Back-links: [1](logic.md#1-claude-writes-an-example-for-every-scenario-with-data-to-show), +[2](logic.md#2-examples-follow-the-shared-conventions), [3](logic.md#3-the-then-is-the-contract), +[8](logic.md#8-later-stages-take-their-contract-from-the-then). + +| File | Anchor | Change | +|---|---|---| +| new `skills/writing-specs/scenario-examples.md` | | The conventions reference. Content contract below. | +| `skills/writing-specs/logic-spec.md` | `## Writing it` bullets (L13-27, coverage at L16) | One bullet: scenarios with data to show carry an example per `scenario-examples.md`, written in the same pass. | +| same | `## Logic-spec self-review` checks (L33-36) | Add check 5, the example check. | +| same | `## User Review Gate` (between L44 and L46) | List every open `[ASK]` with its scenario; answers go into the Thens before approval. | +| `skills/writing-specs/interactive-logic-spec.md` | `## What to write in stage 1`, Scenarios bullet (L14) | `example` field, linked to `scenario-examples.md`. | +| same | `## Stage-1 feedback rounds` step 2 (L54) | Handling updates on scenarios with examples, including the automatic outcome update. | +| same | `## Stage-1 sign-off and fold` (L65-71) | The `examples` gate. | +| same | `## Derivation` (L77) | Derive from scenario text; ignore examples. | +| same | `## Amendments after approval` (L137-146), `## Self-review` (L152-154) | Point at the sync rule and the example check in `scenario-examples.md`. | + +## Contracts & interfaces + +### Open-`[ASK]` detection (renderer only) + +Back-link: [4](logic.md#4-an-open-gap-blocks-its-scenario-until-the-answer-is-in-the-then). + +`REGEX:` open gap, case-sensitive: `\[ASK: ([^\[\]\n]+)\]` (no square brackets inside the question, which keeps detection linear and makes a nested marker malformed) + +`REGEX:` anything that looks like a gap, case-insensitive: `\[\s*ask\b` + +`CONTRACT: scenario_open_asks(scenario: dict) -> List[str]` +- Pre: `scenario` passed `validate_logic_v2`'s scenario checks (or has no `example`). +- Post: the captured question text of every open-gap match across `before`, each of `steps` in order, `after`, then `details.body`, skipping parts the example doesn't have. `[]` when there is no `example`. +- Invariant: pure; no I/O; the only function in the repo that decides whether a gap is open. The page reads its result from the payload. + +### Validation (`validate_logic_v2`) + +Back-links: [Where an example lives](logic.md#where-an-example-lives), [9](logic.md#9-specs-without-examples-are-unchanged). + +- `example` absent: no change in behavior. +- `example` present: must be an object whose keys are drawn from `before`, `steps`, `after`, `details`. Unknown keys inside `example` are errors. + - The data-state parts are all or nothing: when any of `before`, `steps`, `after` is present, all three are required. `before` and `after` are non-empty strings; `steps` is a non-empty array of non-empty strings. + - An example with none of the three and no `details` gets one error at `/scenarios//example`: `example needs before, steps and after, or details`. + - `details` ([1.4](logic.md#1-claude-writes-an-example-for-every-scenario-with-data-to-show), [7.3](logic.md#7-logicmd-shows-examples-as-labelled-sub-bullets)) is an object with exactly `title` and `body`. `body` is a non-empty string. `title` is a non-empty string of one line of plain text: no `\n` or `\r`, none of `` * _ ` [ ] < > ``, and, after trimming and dropping one trailing `:`, not equal ignoring case to `Data before`, `Walkthrough` or `Data after`. Errors: `details title must be one line of plain text` and `details title reuses a data-state label`, at `/scenarios//example/details/title`. + - Each violation is one `Validator.error` at the JSON pointer of the offending key (`/scenarios//example`, `/scenarios//example/steps/`, `/scenarios//example/details/body`, ...). +- Malformed marker: every match of the look-alike regex in any part, `details.body` included, must start a match of the open-gap regex at the same offset. Otherwise `Validator.error` at that part's pointer with the message `malformed [ASK] marker; write [ASK: ]`. Applies in both stages. +- Stage 2 only: a scenario without `dropReason` whose `scenario_open_asks` is non-empty gets `Validator.error` at `/scenarios//example` with the message `open [ASK] after stage-1 sign-off`. +- Errors never raise; they surface through the existing `emit_errors` path with exit 1. + +### Stage-1 fold gate (`stage1_gate_failures`) + +Back-links: [4.3](logic.md#4-an-open-gap-blocks-its-scenario-until-the-answer-is-in-the-then), [5.2](logic.md#5-an-example-changes-with-its-scenario). + +A new gate named `examples`, appended after `scenarios` in the returned list, one failure tuple per offending scenario, in scenario order: + +| Condition, read from the export's `state` | Message | +|---|---| +| `scenarioApproved[id]` is true, `id` is not in `scenarioDrops`, and `scenario_open_asks` is non-empty | `open [ASK] in ` | +| `scenarioApproved[id]` is true, the scenario has `example`, and `scenarioOutcomes[id].choice` exists and is not `"spec"` | `outcome changed on ; Claude must rewrite its example first` | + +Any non-`spec` choice counts as changed, including `custom` with blank text. When both conditions hold for one scenario, only the open-`[ASK]` tuple is emitted. + +`command_fold` prints these as `gate examples: ` and exits 4 through its existing loop. A dropped scenario never fails this gate. The page already prevents both states; this gate is the backstop for a hand-edited or stale export. + +### Markdown rendering (`render_markdown_v2`) + +Back-links: [7](logic.md#7-logicmd-shows-examples-as-labelled-sub-bullets), [9](logic.md#9-specs-without-examples-are-unchanged). + +`CONTRACT: _example_markdown_lines(example: dict) -> List[str]` +- Called once per scenario that has `example`, between the alternatives line and the explanation line. A scenario without `example` emits exactly the lines it emits today. +- Output, in order: ` - **Data before:** `, ` - **Walkthrough:**`, one ` . ` per step (1-based), ` - **Data after:** `, each only when the example has the data-state parts. Then, when it has `details` ([7.2](logic.md#7-logicmd-shows-examples-as-labelled-sub-bullets)): ` - **:**`, an empty line, the body with every non-empty line indented 5 spaces (empty lines stay empty, with no trailing spaces), then an empty line. The empty lines keep tables and code blocks as block content of the sub-bullet and end them before the next sub-bullet. +- A newline inside a data-state part renders as a hard break (never inside `details.body`, whose newlines are ordinary Markdown): the line before it ends with two spaces, and the continuation line is indented to that item's content column (5 spaces for Data before/after, 8 for a step, so continuation under ` 1. ` aligns). CRLF and CR normalize to LF first, as `markdown_cell` does. +- Mark characters (`✓`, `✗`, `*`, `[ASK: …]`, `**`) pass through unchanged. No escaping. +- Deterministic: same input, same bytes. + +### Render payload (`prepare_render_files`) + +Back-link: [6](logic.md#6-the-review-page-shows-examples-in-the-focus-dialog). + +`SCHEMA:` the payload gains `"openAsks": { "<scenario id>": ["<question>", ...] }`, containing only scenarios with at least one open gap, from `scenario_open_asks`. An empty object when none. v1 specs never build a page payload, so this is v2 only. + +### Approve blocking (page) + +Back-links: [4.1](logic.md#4-an-open-gap-blocks-its-scenario-until-the-answer-is-in-the-then), [5.2](logic.md#5-an-example-changes-with-its-scenario). + +`CONTRACT: approveBlockReason(state: ReviewState, scenario: Scenario, openAsks: Record<ScenarioId, string[]>) -> 'outcome-missing' | 'open-ask' | 'outcome-changed' | null` +- `'outcome-missing'`: the outcome is `custom` with blank text (today's `isOutcomeMissing`, moved here). +- `'open-ask'`: `openAsks[scenario.id]` is non-empty. +- `'outcome-changed'`: `scenario.example` exists and `scenarioThen(state, scenario) !== scenario.then`. +- Otherwise `null`. Checked in that order; the first match wins. Pure. + +`DecisionCell` passes `isDisabled={reason !== null}` to the Approve item and shows one hint line per reason: `Write your outcome first.` (unchanged), `Answer the open question in an update first.`, `Claude has to rewrite the example for this outcome first.` Update and Drop are never disabled. + +### Outcome change on a scenario with an example (page) + +Back-link: [5.2](logic.md#5-an-example-changes-with-its-scenario). + +`CONFIG:` `EXAMPLE_OUTCOME_UPDATE_PREFIX = 'Rewrite this scenario for the outcome I picked: '` + +`CONTRACT: exampleOutcomeUpdate(outcomeText: string) -> string` returns the prefix, then the outcome in double quotes, then `. Update its Then and example together.` + +In `OutcomeCell` `set`, only when `s.example` exists, in the same `update` call that clears approval: +- New choice is not `spec`: set `scenarioUpdates[s.id]` to `exampleOutcomeUpdate(<the chosen outcome's text>)`, unless the existing update text exists and does not start with the prefix (the reviewer wrote their own; leave it). +- A `custom` choice with blank text counts as `spec` here: it is the moment before the reviewer has typed an outcome, Approve is already blocked by `'outcome-missing'`, and an update quoting an empty outcome would give Claude nothing to act on. The fold gate still treats it as changed, as a backstop. +- Typing in the custom outcome field re-derives the update text the same way, under the same "unless" rule. +- New choice is `spec`: delete `scenarioUpdates[s.id]` only if its text starts with the prefix. +- A scenario without `example` behaves exactly as today. + +### Browse mode and the example pane (page) + +Back-link: [6](logic.md#6-the-review-page-shows-examples-in-the-focus-dialog). + +`CONTRACT: ScenarioFocusDialog({ isOpen, onClose, behaviorId?, startScenarioId?, props })` +- No `startScenarioId`: the undecided queue, exactly as today (`undecidedQueue`, Approve advances). +- With `startScenarioId`: the queue is every scenario of that scenario's behavior, in spec order, regardless of decision or stage. It opens at `startScenarioId`. The queue is captured once at open, as today. Previous/Next and arrow keys work as today; Approve still advances. +- Width: `1040` when any scenario in the queue has `example`, else `760`. +- Body order: Given, When, Then, the example pane (when present), Why, decision, comment. In stage 2 the Then is the read-only `Clause` of `scenarioThen`, and the decision area shows what the table's stage-2 decision column shows (Approved/Dropped token, or the re-approve checkbox for a reopened scenario). There is no comment field in stage 2. + +`CONTRACT: ScenarioExampleView({ example }: { example: ScenarioExample })` +- Three labelled panes, `Data before`, `Walkthrough`, `Data after`, in a CSS grid of three columns (`1fr 1.2fr 1fr`) that stacks to one column below 720px. Rendered only when the example has the data-state parts. +- When the example has `details` ([6.4](logic.md#6-the-review-page-shows-examples-in-the-focus-dialog)): one full-width pane below the grid, `aria-label` and visible label set to `details.title`, class `example-pane example-details`. The body renders as one block `Markdown` (so tables, lists and code blocks keep their structure) with the item-link plugins, `exampleMarkPlugins`, and `EXAMPLE_PHRASE_PLUGIN`. Text inside code spans and code blocks renders exactly as written: no placeholder swap, no underscore guard, no colouring. The body takes the same CRLF/CR normalization and underscore guard as the other parts, outside code. +- `before` and `after` render through `SpecMarkdown` with every single newline turned into a markdown hard break, after CRLF and CR normalize to LF as on the markdown side. `steps` render as an ordered list, one `SpecMarkdown` per step. +- Mark colouring is via `exampleMarkPlugins`, passed only inside this view, alongside the existing item-link plugins. + +The pane colours marks in two ways: + +- `CONTRACT: EXAMPLE_PHRASE`: result and rejection phrases are cut out of each line before parsing. `REGEX:` `([✓✗])([^;\n]+)`. Each phrase is rendered through inline Markdown inside a green bold (`✓`) or red (`✗`) span, so nested records and invented values stay inside the colour. +- `CONTRACT: exampleMarkPlugins: MarkdownInlinePlugin[]`: two global `{ pattern, render }` plugins, as in `ItemLinksProvider`, for the marks that survive parsing: + - Invented value, rendered as a grey italic span with the `*` kept. `REGEX:` `(?<=^|\s|\*,)[^\s*]+\*(?=[\s,;:.)\]]|$)` finds it in the source text. Because the Astryx parser pairs two such stars in one part as emphasis, the pane first escapes any literal U+E000 in the text, then swaps each matched `*` for the private-use placeholder U+E000; the plugin matches the placeholder and the rendered span restores the `*`. + - Gap, rendered as an amber underlined span. `REGEX:` `\[ASK: [^\[\]\n]+\]` + +- `CONTRACT: EXAMPLE_PHRASE_PLUGIN: MarkdownInlinePlugin`, details body only: colours a `✓`/`✗` phrase within one text node, `REGEX:` `[✓✗][^;\n]+`. A phrase is coloured up to the next `;`, line end, or inline formatting, because block Markdown can't be cut into per-line phrases without breaking tables and lists. + +The gap pattern here is display only and must match the renderer's open-gap regex. An invented value inside a result or rejection phrase renders as an invented-value span nested inside that phrase's span. Invented-value matching starts only at whitespace or right after a previous value's `*,` (so `4*,5*` marks both), which keeps long runs without `*` linear. Links in any page markdown render only for `http:`, `https:`, `mailto:` and `#` targets, via `OFFLINE_MARKDOWN_COMPONENTS`. Records need no plugin: `strong` inside `.scenario-example` is styled blue. + +`CONFIG:` `index.css` colours, using theme variables already in the bundle: + +| Selector | Property | +|---|---| +| `.scenario-example strong` | `color: var(--color-text-blue)` | +| `.scenario-example ol > li::marker` | `color: var(--color-text-purple); font-weight: 700` | +| result span | `color: var(--color-text-green); font-weight: 700` | +| rejection span | `color: var(--color-text-red)` | +| invented span | `color: var(--color-text-secondary); font-style: italic` | +| gap span | `color: var(--color-text-orange)`, `background: color-mix(in srgb, var(--color-background-yellow) 45%, transparent)`, underline | +| before pane | `background: var(--color-background-muted)` | +| after pane | `background: color-mix(in srgb, var(--color-background-green) 35%, transparent)` | + +### Table changes (page) + +Back-link: [6.1-6.2](logic.md#6-the-review-page-shows-examples-in-the-focus-dialog). + +- `id` column: when `s.example`, a `Token size="sm"` labelled `Example`. When `openAsks[s.id]` is non-empty, also a `Token size="sm" color="yellow"` labelled `Open question`, with the first question as its description. Existing tokens (Reopened, Claude-added, Requested, changed label) stay. +- The scenario ID text becomes a `Button` (link style) that opens the dialog in browse mode at that scenario. This is the keyboard path. +- `BehaviorGroup` adds a `TablePlugin` whose `transformBodyRow` sets `onClick` on each row (pattern: `board-view.tsx` `dragPlugin`). The handler ignores clicks whose target is inside `button, a, input, textarea, select, label, [role="radiogroup"], [role="switch"], [role="checkbox"]`, and ignores clicks while text is selected. It composes with `changedRowsPlugin`, which keeps working. +- No example content ever renders in the table. + +## Data models / schemas + +Back-link: [Where an example lives](logic.md#where-an-example-lives). + +`SCHEMA:` `logic.json` v2 scenario, new optional key (no `schemaVersion` change): + +| Key | Type | Rule | +|---|---|---| +| `example` | object, optional | `before`, `steps`, `after` (all or none), plus optional `details`; at least one form. Hashed with the scenario. | +| `example.before` | string, non-empty | Markdown with marks. | +| `example.steps` | string[], length ≥ 1, each non-empty | One step per entry; the renderer numbers them. | +| `example.after` | string, non-empty | Markdown with marks. | +| `example.details` | object, optional | Exactly `title`, `body`. | +| `example.details.title` | string | One line of plain text; not a data-state label. | +| `example.details.body` | string, non-empty | Block Markdown with marks: tables, lists, code blocks. | + +`SCHEMA:` `spec-types.ts`: + +| Type | Shape | +|---|---| +| `ScenarioExample` | `{ before?: Markdown; steps?: string[]; after?: Markdown; details?: ScenarioDetails }` | +| `ScenarioDetails` | `{ title: string; body: Markdown }` | +| `Scenario` | adds `example?: ScenarioExample` | +| `LogicSpecPayload` | adds `openAsks: Record<ScenarioId, string[]>` | + +`ReviewState`, `DecisionRecord` and `Export` do not change. The automatic outcome update is an ordinary `scenarioUpdates` entry. + +## Shared conventions file + +Back-links: [Conventions](logic.md#conventions), [What an example is](logic.md#what-an-example-is), [5](logic.md#5-an-example-changes-with-its-scenario), [8](logic.md#8-later-stages-take-their-contract-from-the-then). + +`skills/writing-specs/scenario-examples.md` must contain, each once: +- What an example is (the three parts) and that the Then is the contract. +- When to write one: wherever there's data to show, written in the same pass as the scenario. Request/response scenarios keep the three labels. +- The conventions list from the logic spec, verbatim in substance. +- The exact mark syntax: `**bold**` for records; `✓`/`✗` mark the phrase up to the next `;` or line end; a trailing `*` on an invented value; `[ASK: <question>]`, capitalised, colon and space; `(a)`/`(b)` branches. **No single-asterisk emphasis inside an example**, because a trailing `*` must never pair with an opener. +- Where it lives: the `logic.json` keys, and the markdown sub-bullet layout with the two-trailing-space line-break rule. +- The sync rule: rewrite the example in the same edit as its scenario, amendments included. +- The self-review example check: every example agrees with its Then, and each open `[ASK]` is a question the design didn't already answer. +- The downstream rule: requirements derive from the scenario, and a `*` value is never an expected value. Nothing about turning examples into tests. +- Details sections ([1.3-1.5](logic.md#1-claude-writes-an-example-for-every-scenario-with-data-to-show)): when to write one instead of or beside the data-state parts, the title rules, that the body may hold tables, lists and code blocks, that the same marks apply except inside code, and the `logic.md` layout. + +## DO-NOT-CHANGE fences + +| Region | Why | +|---|---| +| `RENDER_OUTPUT_FIELDS_V2` in `render_spec.py`: never add `example` | Approval covers the example, so it must be hashed ([Key decisions](logic.md#key-decisions--rationale)). | +| `canonical_json`, `item_hashes`, `stage1_item_hashes`, `render_id` | Specs without examples must keep their hashes, pins and `renderId` ([9](logic.md#9-specs-without-examples-are-unchanged)). An absent key already hashes identically. | +| `render_markdown_v2` output for scenarios without `example`, and all of `render_markdown` (v1) | `logic.md` must stay byte-identical for existing specs; the goldens enforce it. | +| `validate_logic` (v1) and every v1 command path | v1 behaves exactly as today ([9.2](logic.md#9-specs-without-examples-are-unchanged)). | +| Existing gate names and messages in `stage1_gate_failures` / `stage2_gate_failures` | Tests and the rubric match on them. | +| `isScenarioUndecided`, `gateList`, `carryOver`, `changedSinceReviewIds` in `review-state.ts` | The undecided queue and decision clearing already do the right thing: an example edit changes the hash and `carryOver` clears the decision. Changing them would ripple through every view. | +| `SpecMarkdown`'s `_` → `_​` replacement in `views/shared.tsx` | Stops underscores in prose becoming emphasis everywhere on the page. | +| `schemaVersion` stays `2` | The new key is optional and additive. | +| Headings in `skills/writing-specs/logic-spec.md` | `tests/test_writing_specs_skill.py` `test_brainstorming_section_names_match_logic_spec` matches brainstorming's references against them. | +| `skills/writing-specs/SKILL.md` "Which document, which rubric" table | It lists one rubric per document; the conventions file is a reference the rubrics link to, not a rubric. | + +## Always / Ask / Never + +**Always** +- Keep `render_spec.py` stdlib-only and Python 3.9 compatible. +- Decide whether a gap is open in `scenario_open_asks` only; the page reads `openAsks`. +- Rebuild `review-template.html` after any `app/src` change: `pnpm install --frozen-lockfile && pnpm run build && pnpm run check` from `skills/writing-specs/interactive/app/`. +- Keep goldens byte-identical; a golden diff is a regression, never a fixture update. + +**Ask** +- Before adding any key to `ReviewState`, `DecisionRecord` or `Export`. +- Before changing an existing gate's name or message. +- If an Astryx component cannot do something this spec assumes (row `onClick` via `htmlProps`, `MarkdownInlinePlugin` seeing a trailing `*` as plain text, `Dialog` `width` 1040). + +**Never** +- Add `example` to `RENDER_OUTPUT_FIELDS_V2`. +- Escape or rewrite mark characters in `logic.md`. +- Show example content in table rows. +- Add a JS test framework or any new npm dependency. + +## Cross-cutting + +- **Migration:** none. Existing v1 and v2 specs validate and render unchanged; `review.html` changes only because the template is rebuilt, and it is never committed. +- **Rollback:** revert the branch. `logic.json` files with `example` would then still validate under the old renderer (unknown keys are ignored) but would hash differently from a pin taken with examples, which is acceptable because no spec carries examples before this ships. +- **Security:** marks and markdown render through Astryx `Markdown` with the existing offline component overrides (images omitted). No raw HTML is injected; inline plugins return React elements. +- **Observability:** not applicable (local CLI and offline page). + +## Testing strategy + +Back-links: [Key decisions](logic.md#key-decisions--rationale) (success criteria), [9](logic.md#9-specs-without-examples-are-unchanged). + +**Goldens, first task, before any renderer change:** +- `tests/fixtures/interactive/goldens/v2-stage1.logic.md` and `v2-stage2.logic.md`, rendered from `tests/fixtures/interactive/v2/stage1/logic.json` and `v2/stage2/logic.json` at `RUN_BASE`. +- A test renders each fixture into `tmp_path` and asserts byte equality with its golden, and asserts that rendering a copy of `docs/quirk/specs/2026-09-24-interactive-logic-spec-v2/logic.json` (v1) reproduces the committed `docs/quirk/specs/2026-09-24-interactive-logic-spec-v2/logic.md` byte for byte (verified true at `RUN_BASE`). + +**Example fixture:** `tests/fixtures/interactive/v2/examples/logic.json`, stage 1, built from the exploration's three examples (compounding discounts; the limited discount with its `[ASK]`; the two-branch definition edit with `(a)`/`(b)` and a `✗`), plus one scenario with no example. Exports beside it in `exports/`: a signed export approving the open-`[ASK]` scenario; a signed export dropping it and approving the rest; a signed export approving the compounding scenario with an alternative outcome. + +**Python tests** (`tests/test_interactive_logic_spec.py`, or a new `tests/test_scenario_examples.py` using the same `make_v2_spec`-style fixture copying): +- `validate` accepts the example fixture. +- `validate` rejects each malformed shape: missing part, empty `steps`, empty step, non-string part, unknown key inside `example`. Each yields an error at the expected pointer. +- `validate` rejects `[ask: …]`, `[ASK]` and `[ASK:x]` (no space) with the malformed-marker message, in stage 1. +- A stage-2 spec with an open gap on a non-dropped scenario fails with `open [ASK] after stage-1 sign-off`; the same on a dropped scenario passes. +- `item_hashes`: adding an `example` changes the scenario's hash; editing one step changes it; a scenario without `example` hashes the same as at `RUN_BASE` (the goldens' fixtures still validate their pins). +- `render`: the exact example lines, including a multi-line `before` and a multi-line step (hard breaks and continuation indent), and placement between alternatives and explanation. +- `fold`: the open-`[ASK]` export exits 4 with `gate examples: open [ASK] in <id>` on stderr; the alternative-outcome export exits 4 with the outcome message; the drop export folds, and the folded scenario keeps its `example` and pin. +- The payload embedded in `review.html` has `openAsks` with exactly the open-gap scenario. + +**Page checks:** a new `app/scripts/check-examples.ts`, wired into `pnpm run check`, that imports `exampleMarkPlugins`, `approveBlockReason` and `exampleOutcomeUpdate` and asserts on fixed strings: each mark pattern matches its sample and not `**bold**`; the invented pattern matches `10%*` and `4*,` and not `**Subscription**`; each block reason fires on a minimal state; the update text round-trips with the prefix; `isPayload` rejects a payload without `openAsks` and accepts one with it. `pnpm run check` must pass, including `template-check` against the rebuilt template. + +**Manual fixture review** (the logic spec's success criterion): `render_spec.py render tests/fixtures/interactive/v2/examples --open`, then confirm the tokens on rows, that clicking a row and the ID button both open browse mode, three columns with every mark coloured, Approve disabled with the right hint on the `[ASK]` scenario, and that picking an alternative on the compounding scenario writes the update and disables Approve. + +**Doc tests** (`tests/test_writing_specs_skill.py`): add `scenario-examples.md` to `test_skill_files_exist`; assert both rubrics link it. The existing cross-file link and anchor test covers the new file automatically. + +**Details sections** (amendment 2026-10-15): a fixture `tests/fixtures/interactive/v2/details/logic.json`, stage 1, with one details-only scenario holding a table and an `[ASK]`, one scenario with both forms, and one with neither. Python tests cover every new validation error, the all-or-nothing rule, `scenario_open_asks` reading the body, the fold gate on a details-only `[ASK]`, and the exact `logic.md` lines including a table body. `check-examples.ts` asserts the details pane renders under its title, a table survives as a `<table>`, marks are coloured outside code and plain inside it, and no column grid renders for a details-only example. The existing goldens stay byte-identical. + +**Acceptance bar:** `pytest -q` green from the repo root, and `pnpm run check` green in `skills/writing-specs/interactive/app/`. + +## Non-goals + +Everything in the logic spec's [Scope & non-goals](logic.md#scope--non-goals), plus: +- No change to `ReviewState`, exports, or the export record. +- No JS unit-test framework; `tsx` check scripts only. +- No change to the brainstorming skill; it already defers to the rubrics. +- The `[tech-spec]` Deferred Ideas are resolved here as follows: the `*` escape rule is "no single-asterisk emphasis in examples, no escaping"; the mark patterns are the table above; near-miss `[ASK]` forms are validation errors; the automatic update wording is `EXAMPLE_OUTCOME_UPDATE_PREFIX`; the dialog width is 1040; phrase colouring in a details body is `EXAMPLE_PHRASE_PLUGIN`, per text node. diff --git a/skills/writing-specs/interactive-logic-spec.md b/skills/writing-specs/interactive-logic-spec.md index a02b89b8..921a2fea 100644 --- a/skills/writing-specs/interactive-logic-spec.md +++ b/skills/writing-specs/interactive-logic-spec.md @@ -8,10 +8,10 @@ The user explicitly chose interactive when brainstorming offered markdown (the d ## What to write in stage 1 -Write `<spec-dir>/logic.json` at `schemaVersion: 2`, `stage: 1`, `requirements: []`, using the `LogicSpecV2` shape in [Data models / schemas](../../docs/quirk/specs/2026-09-24-interactive-logic-spec-v2/tech.md#data-models--schemas) and the page's [TypeScript source of truth](interactive/app/src/spec-types.ts). Include every section [logic-spec.md](logic-spec.md) requires; the Requirements section renders as a single placeholder line until stage 1 is signed. +Write `<spec-dir>/logic.json` at `schemaVersion: 2`, `stage: 1`, `requirements: []`, using the `LogicSpecV2` shape in [Data models / schemas](../../docs/quirk/specs/2026-09-24-interactive-logic-spec-v2/tech.md#data-models--schemas) and the page's [TypeScript source of truth](interactive/app/src/spec-types.ts). Include every section [logic-spec.md](logic-spec.md) requires; the Requirements section renders as a single placeholder line until stage 1 is signed. Keep every text field to 10,000 characters or fewer; `validate` rejects a longer one, because the review page would stall rendering it. - **Behaviors**: a named one-line rule. Every scenario belongs to exactly one behavior. -- **Scenarios**: Given/When/Then plus `alternatives`, grouped under their behavior. Write a scenario for every case a reviewer could rule on differently: each branch of the rule, each edge case, each known failure mode. There is no per-behavior limit, so never merge or cut a scenario to keep a behavior short; skip only one whose outcome another scenario under the same behavior already shows. A case no behavior's rule covers gets a new behavior rather than a stretched one. Specs written under the old three-per-behavior limit may still carry `extraReason`; it is ignored. +- **Scenarios**: Given/When/Then plus `alternatives`, grouped under their behavior, plus an optional `example` for any scenario with something to show (data-state parts, a [details section](scenario-examples.md#details-sections), or both), written to [scenario-examples.md](scenario-examples.md) in the same pass. Write a scenario for every case a reviewer could rule on differently: each branch of the rule, each edge case, each known failure mode. There is no per-behavior limit, so never merge or cut a scenario to keep a behavior short; skip only one whose outcome another scenario under the same behavior already shows. A case no behavior's rule covers gets a new behavior rather than a stretched one. Specs written under the old three-per-behavior limit may still carry `extraReason`; it is ignored. - **Constraints**: non-behavioral requirements of kind `placement`, `verification`, `naming`, or `non-goal`, plus `other` for anything the four don't fit. Draw them from Decisions Locked and scope. - **Provenance**: a scenario or constraint Claude adds without asking carries `provenance: "claude"` and a `rationale`; anything decided in brainstorming carries the deciding `question` instead. A Claude-added item is preselected — Claude's `then` or `approve` shows as the value — but stays marked as Claude-added until a state entry exists; the preselection is not itself a decision. - Point assumption `affects` and blind-spot `sources` / `resolvedBy` at scenario or constraint ids, not requirement ids — requirements do not exist yet. @@ -52,7 +52,7 @@ render_spec.py check-export <spec-dir> <export.json> For a current **unsigned** stage-1 export: 1. Answer research requests in `research[]` and turn scenario requests into new `scenarios[]`, retaining each request's ID. -2. Apply the reviewer's requested content changes — new or edited scenario and constraint text — to `logic.json`. Rewrite each scenario named in `state.scenarioUpdates` (also `record.scenarios[].update`) as its feedback asks; the rewrite changes the scenario's hash, which clears the request and returns the scenario for a fresh decision. A scenario you leave unchanged keeps its update request, and stage 1 cannot sign while one is open. Read `state.scenarioComments` (also `record.scenarios[].comment`) as the reviewer's notes: answer a question in chat, and rewrite the scenario only when the comment plainly asks for a change. A comment is never an update request and never gates sign-off. A rewrite clears the comment by itself. For a comment you answered or acted on without rewriting its scenario, set that scenario's `answeredComment` to the comment text exactly, and the page clears it on the next load; `render --prior` warns when the text doesn't match. Leave `answeredComment` out for a comment you haven't handled. The stage-1 fold removes it. +2. Apply the reviewer's requested content changes — new or edited scenario and constraint text — to `logic.json`. Rewrite each scenario named in `state.scenarioUpdates` (also `record.scenarios[].update`) as its feedback asks; the rewrite changes the scenario's hash, which clears the request and returns the scenario for a fresh decision. A scenario you leave unchanged keeps its update request, and stage 1 cannot sign while one is open. Read `state.scenarioComments` (also `record.scenarios[].comment`) as the reviewer's notes: answer a question in chat, and rewrite the scenario only when the comment plainly asks for a change. A scenario that carries an `example` is rewritten together with it, Then and example in one edit. When the reviewer picks a different outcome on such a scenario, the page fills `scenarioUpdates` for it automatically ("Rewrite this scenario for the outcome I picked: …"), so rewrite the Then and the example to that outcome unless the reviewer wrote their own update text. A comment is never an update request and never gates sign-off. A rewrite clears the comment by itself. For a comment you answered or acted on without rewriting its scenario, set that scenario's `answeredComment` to the comment text exactly, and the page clears it on the next load; `render --prior` warns when the text doesn't match. Leave `answeredComment` out for a comment you haven't handled. The stage-1 fold removes it. 3. Do **not** write drops, constraint rulings, or rewritten constraint text into `logic.json`; they live only in the export's state and travel forward until the fold. Re-render with `--prior` so unchanged decisions carry into the next review: ```text @@ -63,7 +63,7 @@ Never fold a stale or unsigned export. ## Stage-1 sign-off and fold -Stage 1 signs when every Claude-added scenario and constraint is decided, every scenario is approved or dropped with a reason and no scenario request or update is pending, every constraint is approved, rewritten with text, or rejected with a reason, every assumption is ruled, and every active blind spot is either accepted with the reviewer's own sentence or has a research request. Asking for research settles a blind spot the way a verify-first ruling settles an assumption. Run `check-export` first and fold only a current signed export: +Stage 1 signs when every Claude-added scenario and constraint is decided, every scenario is approved or dropped with a reason and no scenario request or update is pending, every constraint is approved, rewritten with text, or rejected with a reason, every assumption is ruled, no approved scenario has an open `[ASK]` in its example, and every active blind spot is either accepted with the reviewer's own sentence or has a research request. Asking for research settles a blind spot the way a verify-first ruling settles an assumption. The fold's `examples` gate refuses (`gate examples: <message>`, exit 4) an approved, non-dropped scenario whose example still has an open `[ASK]` (`open [ASK] in <id>`), and an approved scenario whose outcome the reviewer changed without Claude rewriting its example (`outcome changed on <id>; Claude must rewrite its example first`). The page already prevents both, so the gate only catches a hand-edited or stale export. Run `check-export` first and fold only a current signed export: ```text render_spec.py fold <spec-dir> <export.json> @@ -75,7 +75,7 @@ A `flagged` scenario is one the reviewer marked for deeper review in a separate ## Derivation -After the stage-1 fold, first answer every blind spot that carries a `researchRequest` (a research request still open at sign-off) with a `research[]` finding whose `requestId` is that request's `id`. Then derive `requirements[]` yourself, plus any [optional views](#optional-views) the requirements support — no page round is needed for this step. Each requirement's `derivedFrom` names one or more approved scenarios (no `dropReason`) or approved/rewritten constraints, never a dropped scenario or a rejected constraint. An approved or rewritten **non-goal** constraint derives one `scope: "out"` requirement, which satisfies the under-derived gate for it. Pre-fill each requirement's `group` and `placement` (in / conditional / out) with your best judgment; the reviewer only needs to move the ones you got wrong, with a reason. Requirements carry no `provenance`, `question`, or `rationale` — `derivedFrom` is their only origin. Re-render so the page shows the stage-2 tabs with stage 1 read-only. +After the stage-1 fold, first answer every blind spot that carries a `researchRequest` (a research request still open at sign-off) with a `research[]` finding whose `requestId` is that request's `id`. Then derive `requirements[]` yourself, plus any [optional views](#optional-views) the requirements support — no page round is needed for this step. Each requirement's `derivedFrom` names one or more approved scenarios (no `dropReason`) or approved/rewritten constraints, never a dropped scenario or a rejected constraint. Derive from each scenario's Given/When/Then text and ignore its `example`; a `*` value is never an expected value (see [scenario-examples.md](scenario-examples.md#downstream-stages)). An open `[ASK]` on a non-dropped scenario is a stage-2 `validate` error, and fixing an example after the stage-1 sign-off goes through the scenario dispute and reopen path below. An approved or rewritten **non-goal** constraint derives one `scope: "out"` requirement, which satisfies the under-derived gate for it. Pre-fill each requirement's `group` and `placement` (in / conditional / out) with your best judgment; the reviewer only needs to move the ones you got wrong, with a reason. Requirements carry no `provenance`, `question`, or `rationale` — `derivedFrom` is their only origin. Re-render so the page shows the stage-2 tabs with stage 1 read-only. ## Optional views @@ -174,7 +174,7 @@ render_spec.py validate <spec-dir> render_spec.py render <spec-dir> ``` -Do not edit generated `logic.md`. The chat approval and dated amendment are sufficient; do not send the spec through page review again. +An amendment that changes a scenario rewrites its example in the same entry; see [the sync rule](scenario-examples.md#keeping-an-example-in-sync). Do not edit generated `logic.md`. The chat approval and dated amendment are sufficient; do not send the spec through page review again. ## v1 specs are read-only @@ -182,4 +182,4 @@ A `logic.json` at `schemaVersion: 1` still validates and regenerates `logic.md`, ## Self-review -Run the four [logic-spec.md self-review checks](logic-spec.md#logic-spec-self-review) against `logic.json`: scan for placeholders or incomplete sections; check internal consistency; check scope; and remove ambiguity. Also confirm every `dependsOn`, `derivedFrom`, `affects`, `sources`, `resolvedBy`, and `requestId` resolves. Run `render_spec.py validate <spec-dir>`; it checks the schema, unique IDs across every item kind, all cross-references, and, once stage 2 exists, the stage-1 lock. Fix all failures before rendering or hand-off. +Run the five [logic-spec.md self-review checks](logic-spec.md#logic-spec-self-review) against `logic.json`: scan for placeholders or incomplete sections; check internal consistency; check scope; remove ambiguity; and run the [example check](scenario-examples.md#self-review-check). Also confirm every `dependsOn`, `derivedFrom`, `affects`, `sources`, `resolvedBy`, and `requestId` resolves. Run `render_spec.py validate <spec-dir>`; it checks the schema, unique IDs across every item kind, all cross-references, and, once stage 2 exists, the stage-1 lock. Fix all failures before rendering or hand-off. diff --git a/skills/writing-specs/interactive/app/package.json b/skills/writing-specs/interactive/app/package.json index 54afe3a7..aa1b6f6d 100644 --- a/skills/writing-specs/interactive/app/package.json +++ b/skills/writing-specs/interactive/app/package.json @@ -9,7 +9,8 @@ "layout-check": "tsx scripts/check-layout.ts", "compare-check": "tsx scripts/check-compare.ts", "template-check": "tsx scripts/check-template.ts", - "check": "pnpm run typecheck && pnpm run layout-check && pnpm run compare-check && pnpm run template-check", + "examples-check": "tsx scripts/check-examples.ts", + "check": "pnpm run typecheck && pnpm run layout-check && pnpm run examples-check && pnpm run compare-check && pnpm run template-check", "build": "vite build" }, "dependencies": { diff --git a/skills/writing-specs/interactive/app/pnpm-workspace.yaml b/skills/writing-specs/interactive/app/pnpm-workspace.yaml new file mode 100644 index 00000000..729285f3 --- /dev/null +++ b/skills/writing-specs/interactive/app/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@astryxdesign/core': true + esbuild: true diff --git a/skills/writing-specs/interactive/app/scripts/check-examples.ts b/skills/writing-specs/interactive/app/scripts/check-examples.ts new file mode 100644 index 00000000..4a142108 --- /dev/null +++ b/skills/writing-specs/interactive/app/scripts/check-examples.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import fixture from '../../../../../tests/fixtures/interactive/v2/stage1/logic.json' +import { approveBlockReason, EXAMPLE_OUTCOME_UPDATE_PREFIX, emptyState, exampleOutcomeUpdate, isPayload } from '../src/review-state' +import type { LogicSpec, Scenario, ScenarioExample } from '../src/spec-types' +import { EXAMPLE_PHRASE, EXAMPLE_PHRASE_PLUGIN, exampleMarkPlugins, ScenarioExampleView } from '../src/views/scenario-example' +import { SpecMarkdown } from '../src/views/shared' + +const [invented, gap] = exampleMarkPlugins.map((plugin) => plugin.pattern) + +function matches(pattern: RegExp, text: string): string[] { + assert.ok(pattern.global, `${pattern} must be global`) + return [...text.matchAll(new RegExp(pattern.source, pattern.flags))].map((m) => m[0]) +} + +assert.equal(exampleMarkPlugins.length, 2) +assert.deepEqual(matches(EXAMPLE_PHRASE, 'The charge is ✓ queued for May 5; next'), ['✓ queued for May 5']) +assert.deepEqual(matches(EXAMPLE_PHRASE, 'An API client ✗ cannot add a second line; ok'), ['✗ cannot add a second line']) +assert.deepEqual(matches(gap, 'Date is [ASK: Does a skip move the date?] here'), ['[ASK: Does a skip move the date?]']) +for (const pattern of [EXAMPLE_PHRASE, invented, gap]) assert.deepEqual(matches(pattern, '**bold** text'), []) +assert.deepEqual(matches(invented, '"Winback" took 10%\uE000 off'), ['10%\uE000']) +assert.deepEqual(matches(invented, 'Charge 2 on 4\uE000, then'), ['4\uE000']) +assert.deepEqual(matches(invented, '**Subscription**'), []) +assert.deepEqual(matches(invented, 'Coffee line costs $9\uE000.'), ['$9\uE000']) +assert.deepEqual(matches(invented, '$100.00\uE000'), ['$100.00\uE000']) + +const rendered = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'Values 10%* and 4*,', steps: ['**Subscription** step'], after: 'done' } })) +assert.equal((rendered.match(/example-invented/g) ?? []).length, 2) +assert.ok(rendered.includes('"example-invented">10%*</span>') && rendered.includes('"example-invented">4*</span>')) +assert.ok(!rendered.includes('<em>')) +assert.ok(/<strong[^>]*>Subscription<\/strong>/.test(rendered)) + +const MARKDOWN_WRAPPER = '<span class="astryx-markdown[^"]*"[^>]*>' + +const starred = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'b', steps: ['s'], after: '✓ total 4*' } })) +assert.equal((starred.match(/class="example-result"/g) ?? []).length, 1) +assert.ok(new RegExp(`<span class="example-result">${MARKDOWN_WRAPPER}[^<]*<span class="example-invented">4\\*</span></span></span>`).test(starred)) +assert.equal((starred.match(/example-invented/g) ?? []).length, 1) + +const nestedResult = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'b', steps: ['s'], after: '✓ **Subscription** saved' } })) +assert.equal((nestedResult.match(/class="example-result"/g) ?? []).length, 1) +assert.ok(new RegExp(`<span class="example-result">${MARKDOWN_WRAPPER}✓ <strong[^>]*>Subscription</strong> saved</span></span>`).test(nestedResult)) + +const nestedRejection = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'b', steps: ['s'], after: '✗ The change is ignored; **Charge 2** stays' } })) +assert.equal((nestedRejection.match(/class="example-rejection"/g) ?? []).length, 1) +assert.ok(new RegExp(`<span class="example-rejection">${MARKDOWN_WRAPPER}✗ The change is ignored</span></span>${MARKDOWN_WRAPPER}; <strong[^>]*>Charge 2</strong> stays</span>`).test(nestedRejection)) + +const lines = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'Charge 1 ✓ queued\nCharge 2 ✗ skipped', steps: ['s'], after: 'a' } })) +assert.equal((lines.match(/class="example-result"/g) ?? []).length, 1) +assert.equal((lines.match(/class="example-rejection"/g) ?? []).length, 1) +assert.ok(lines.includes('<br/>')) + +assert.deepEqual(matches(invented, '4,5 and $1,000,$2,000'), ['4', '5', '$1,000', '$2,000']) +const adjacent = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'Values 4*,5* and $1,000*,$2,000*', steps: ['s'], after: '✓ totals 4*,5*' } })) +assert.deepEqual(adjacent.match(/(?<="example-invented">)[^<]*/g), ['4*', '5*', '$1,000*', '$2,000*', '4*', '5*']) + +for (const before of ['one\rtwo', 'one\r\ntwo', 'Charge 1 ✓ queued\rCharge 2 ✗ skipped']) { + const html = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before, steps: ['s'], after: 'a' } })) + assert.ok(/<br\s*\/?>/.test(html), `a line break in ${JSON.stringify(before)} must render as a hard break`) + assert.ok(!html.includes('\r')) +} +const crPhrases = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'Charge 1 ✓ queued\rCharge 2 ✗ skipped', steps: ['s'], after: 'a' } })) +assert.equal((crPhrases.match(/class="example-result"/g) ?? []).length, 1) +assert.equal((crPhrases.match(/class="example-rejection"/g) ?? []).length, 1) + +const invent = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'x 10%* y', steps: ['s'], after: 'a' } })) +assert.ok(invent.includes('"example-invented">10%*</span>')) + +const literal = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: 'Literal value\uE000', steps: ['s \uE000 and 3*'], after: '✓ kept\uE000' } })) +assert.ok(literal.includes('Literal value\uE000') && literal.includes('s \uE000 and ')) +assert.ok(literal.includes('kept\uE000</span>')) +assert.equal((literal.match(/example-invented/g) ?? []).length, 1) +assert.ok(literal.includes('"example-invented">3*</span>')) +assert.ok(!literal.includes('\uE001')) + +const HOSTILE_LINK = '[click](jAvAsCrIp:alert(1))' +const GOOD_LINK = '[ok](https://x.y)' +const specLink = renderToStaticMarkup(React.createElement(SpecMarkdown, null, `${HOSTILE_LINK} ${GOOD_LINK}`)) +const exampleLink = renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: HOSTILE_LINK, steps: [GOOD_LINK], after: `✓ ${HOSTILE_LINK}` } })) +for (const html of [specLink, exampleLink]) { + assert.ok(!/href="jAvAsCrIp/i.test(html), 'a javascript: href must never reach the page') + assert.ok(html.includes('click')) + assert.ok(/<a [^>]*href="https:\/\/x\.y"[^>]*>ok<\/a>/.test(html)) +} +assert.equal((specLink.match(/<a /g) ?? []).length, 1) +assert.equal((exampleLink.match(/<a /g) ?? []).length, 1) +for (const href of ['#frag', 'mailto:a@b.c', ' HTTP://x.y']) { + const html = renderToStaticMarkup(React.createElement(SpecMarkdown, null, `[t](${href})`)) + assert.ok(html.includes('<a '), `${href} must stay a link`) +} +for (const href of ['vbscript:x', 'data:text/html,x', '/relative', 'ftp://x.y']) { + const html = renderToStaticMarkup(React.createElement(SpecMarkdown, null, `[t](${href})`)) + assert.ok(!html.includes('<a '), `${href} must render as plain text`) +} + +const long = 'a'.repeat(80_000) +const startedAt = performance.now() +renderToStaticMarkup(React.createElement(ScenarioExampleView, { example: { before: long, steps: [long], after: `✓ ${long}` } })) +assert.ok(performance.now() - startedAt < 500, 'a long run without a star must render in linear time') + +const render = (example: ScenarioExample) => renderToStaticMarkup(React.createElement(ScenarioExampleView, { example })) +const countOf = (html: string, pattern: RegExp) => (html.match(pattern) ?? []).length + +assert.equal(EXAMPLE_PHRASE_PLUGIN.pattern.source, '[✓✗][^;\\n]+') +assert.ok(EXAMPLE_PHRASE_PLUGIN.pattern.global) +assert.deepEqual(matches(EXAMPLE_PHRASE_PLUGIN.pattern, 'a ✓ queued; b ✗ skipped\nc'), ['✓ queued', '✗ skipped']) + +const detailsOnly = render({ details: { title: 'Pricing rules', body: 'Plain **bold** body' } }) +assert.ok(detailsOnly.includes('aria-label="Pricing rules"')) +assert.ok(/class="example-pane example-details"/.test(detailsOnly)) +assert.ok(/<span[^>]*>Pricing rules<\/span>/.test(detailsOnly), 'the title must show as the visible label') +assert.ok(/<strong[^>]*>bold<\/strong>/.test(detailsOnly)) +for (const gone of ['Data before', 'Walkthrough', 'Data after', 'example-grid', 'example-before', 'example-after']) assert.ok(!detailsOnly.includes(gone), `${gone} must not render for a details-only example`) + +const both = render({ before: 'b', steps: ['s'], after: 'a', details: { title: 'Edge cases', body: 'body' } }) +const order = ['Data before', 'Walkthrough', 'Data after', 'aria-label="Edge cases"'].map((part) => both.indexOf(part)) +assert.ok(order.every((at) => at >= 0) && order.every((at, i) => i === 0 || at > order[i - 1]), 'the grid must render before the details pane') +assert.equal(countOf(both, /example-details/g), 1) + +const dataOnly = render({ before: 'b', steps: ['s'], after: 'a' }) +assert.ok(!dataOnly.includes('example-details')) + +const table = render({ details: { title: 'Rates', body: '| Plan | Rate |\n| --- | --- |\n| Basic | 10%* |\n| Pro | ✓ saved |' } }) +assert.ok(/<table[\s>]/.test(table) && /<\/table>/.test(table), 'a Markdown table in the body must render as a table') +assert.equal(countOf(table, /<tr[\s>]/g), 3) +assert.ok(table.includes('"example-invented">10%*</span>')) +assert.equal(countOf(table, /class="example-result"/g), 1) + +const bodyMarks = render({ details: { title: 'Marks', body: 'Total 4* and ✓ charge queued for 3*; then ✗ nothing changes\n\nDate [ASK: Does a skip move the date?] here\n\n- ✓ in a list\n- plain_name stays plain' } }) +assert.equal(countOf(bodyMarks, /class="example-result"/g), 2) +assert.equal(countOf(bodyMarks, /class="example-rejection"/g), 1) +assert.equal(countOf(bodyMarks, /class="example-gap"/g), 1) +assert.ok(bodyMarks.includes('"example-invented">4*</span>')) +assert.ok(new RegExp(`class="example-result">✓ charge queued for <span class="example-invented">3\\*</span></span>`).test(bodyMarks), 'a value inside a phrase nests inside the phrase colour') +assert.ok(bodyMarks.includes('Does a skip move the date?')) +assert.ok(!bodyMarks.includes('') && !bodyMarks.includes('')) + +const FORBIDDEN = /[​]/ +const codeSpan = render({ details: { title: 'Code', body: 'Use `✓ queued; 4* [ASK: why?] a_b` here' } }) +const codeSpanText = codeSpan.match(/<code[^>]*>([\s\S]*?)<\/code>/) +assert.ok(codeSpanText, 'an inline code span must render as code') +assert.equal(codeSpanText[1], '✓ queued; 4* [ASK: why?] a_b') +assert.ok(!/example-(result|rejection|invented|gap)/.test(codeSpan), 'marks inside a code span must stay plain') +assert.ok(!FORBIDDEN.test(codeSpan)) + +const fenced = render({ details: { title: 'Fence', body: 'Before 2* here\n\n```json\n{"a_b": "✓ ok; ✗ no", "n": 4*, "g": "[ASK: why?]"}\n```\n\n~~~\n✗ tilde 3* a_b\n~~~\n\nAfter 5*' } }) +const codeBlocks = [...fenced.matchAll(/<code[^>]*>([\s\S]*?)<\/code>/g)].map((m) => m[1].replace(/<[^>]+>/g, '')) +assert.equal(codeBlocks.length, 2) +assert.ok(codeBlocks[0].includes('{"a_b": "✓ ok; ✗ no", "n": 4*, "g": "[ASK: why?]"}')) +assert.ok(codeBlocks[1].includes('✗ tilde 3* a_b')) +assert.equal(countOf(fenced, /class="example-invented"/g), 2, 'only the two values outside the fence are marked') +assert.ok(!/example-(result|rejection|gap)/.test(fenced)) +assert.ok(!FORBIDDEN.test(fenced)) + +const literalInCode = render({ details: { title: 'Literal', body: 'Out  and a_b and 6*\n\n```\nin  code\n```\n\n`span `' } }) +assert.ok(literalInCode.includes('in  code') && literalInCode.includes('span ')) +assert.ok(literalInCode.includes('Out  and a_')) +assert.ok(!literalInCode.includes('')) +assert.equal(countOf(literalInCode, /example-invented/g), 1) + +const crlfBody = render({ details: { title: 'Newlines', body: 'one 1*\r\n\r\ntwo ✓ ok\r\n\r\n```\r\nkeep_this 2*\r\n```' } }) +assert.ok(!crlfBody.includes('\r')) +assert.equal(countOf(crlfBody, /role="paragraph"/g), 2) +assert.equal(countOf(crlfBody, /class="example-result"/g), 1) +assert.ok(crlfBody.includes('keep_this 2*</span>')) +assert.ok(render({ details: { title: 'CR', body: 'one 1*\r\rtwo ✓ ok' } }).includes('example-result')) + +const detailsLink = render({ details: { title: 'Links', body: `${HOSTILE_LINK} ${GOOD_LINK}\n\n| a |\n| - |\n| ${HOSTILE_LINK} |` } }) +assert.ok(!/href="jAvAsCrIp/i.test(detailsLink) && !/javascript:/i.test(detailsLink.replace(/>[^<]*</g, '><'))) +assert.equal(countOf(detailsLink, /<a /g), 1) +assert.ok(detailsLink.includes('click')) + +const longBody = 'a'.repeat(80_000) +const bodyStartedAt = performance.now() +render({ details: { title: 'Long', body: `${longBody} \`${longBody}\`\n\n${'`a '.repeat(20_000)}\n\n${'x*'.repeat(20_000)} ✓ ${longBody}` } }) +assert.ok(performance.now() - bodyStartedAt < 1500, 'a long details body must render in linear time') + +const ZWSP = '\u200b' +const PLACEHOLDER = /[\uE000\uE001]/ +const codeText = (html: string) => [...html.matchAll(/<code[^>]*>([\s\S]*?)<\/code>/g)].map((m) => m[1].replace(/<[^>]+>/g, '')) +const MARKED_LINE = 'a_b 4* ✓ ok' +for (const [name, body] of [ + ['bullet', `- \`\`\`\n ${MARKED_LINE}\n \`\`\``], + ['ordered', `1. \`\`\`\n ${MARKED_LINE}\n \`\`\``], + ['paren ordered', `1) \`\`\`\n ${MARKED_LINE}\n \`\`\``], + ['nested list in a quote', `> - \`\`\`\n> ${MARKED_LINE}\n> \`\`\``], + ['nested lists', `- outer\n 1. \`\`\`\n ${MARKED_LINE}\n \`\`\`\n\nAfter 5*`], + ['tilde in a list', `* ~~~\n ${MARKED_LINE}\n ~~~`], +] as const) { + const html = render({ details: { title: 'Listed fence', body } }) + const blocks = codeText(html) + assert.equal(blocks.length, 1, `${name}: the fence must render as one code block`) + assert.ok(blocks[0].includes(MARKED_LINE), `${name}: code contents must render as written`) + assert.ok(!html.includes(ZWSP) && !PLACEHOLDER.test(html), `${name}: no guard characters may reach the page`) + assert.ok(!/example-(result|rejection|invented|gap)/.test(html.replace(/<span class="example-invented">5\*<\/span>/g, '')), `${name}: no mark spans inside the code`) +} +const afterListFence = render({ details: { title: 'Listed fence', body: `- \`\`\`\n keep_me 1*\n \`\`\`\n\nout_side 2*` } }) +assert.ok(afterListFence.includes('out_' + ZWSP + 'side'), 'prose after the closed list fence is guarded again') +assert.equal(countOf(afterListFence, /example-invented/g), 1) + +const escapedTicks = render({ details: { title: 'Escaped', body: 'Escaped \\` 4* \\` and 5*' } }) +assert.equal(countOf(escapedTicks, /class="example-invented"/g), 2, 'a backslash-escaped backtick opens no code span') +assert.ok(escapedTicks.includes('"example-invented">4*</span>') && escapedTicks.includes('"example-invented">5*</span>')) +const escapedThenReal = render({ details: { title: 'Escaped', body: 'Lead \\`a_b `x_y 3*` and \\\\`c_d 6*` end 7*' } }) +assert.deepEqual(codeText(escapedThenReal), ['x_y 3*', 'c_d 6*']) +assert.equal(countOf(escapedThenReal, /class="example-invented"/g), 1, 'only the value outside both spans is marked') +const backslashInSpan = render({ details: { title: 'Backslash', body: 'A `x_y\\` then 2*' } }) +assert.deepEqual(codeText(backslashInSpan), ['x_y\\'], 'a backslash before a closing backtick is literal inside a span') +assert.ok(backslashInSpan.includes('"example-invented">2*</span>')) + +const LINK_URL = 'https://example.test/a_b?q=c_d' +const hrefsOf = (html: string) => [...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1]) +const linkBody = render({ details: { title: 'Links', body: `See [the_report](${LINK_URL}) and <${LINK_URL}> but plain_name stays guarded` } }) +assert.deepEqual(hrefsOf(linkBody), [LINK_URL]) +assert.ok(linkBody.includes(`<${LINK_URL}>`), 'an autolink keeps its underscores verbatim') +assert.ok(linkBody.includes('the_' + ZWSP + 'report') || linkBody.includes('the_report'), 'the link text still renders') +assert.ok(linkBody.includes('plain_' + ZWSP + 'name'), 'prose underscores outside the links keep the guard') +const linkState = render({ before: `[report_one](${LINK_URL}) 4*`, steps: [`[report_two](${LINK_URL})`], after: `✓ [report_three](${LINK_URL}) 5*` }) +assert.equal(hrefsOf(linkState).length, 3) +assert.ok(hrefsOf(linkState).every((href) => href === LINK_URL), 'a data-state link destination keeps its underscores') +for (const text of ['report_one', 'report_two', 'report_three']) assert.ok(linkState.includes(text.replace('_', '_' + ZWSP)) || linkState.includes(text), `${text} link text renders`) +assert.equal(countOf(linkState, /class="example-invented"/g), 2) +const parenLink = render({ details: { title: 'Links', body: '[w](https://example.test/a_(b_c)) a_b' } }) +assert.deepEqual(hrefsOf(parenLink), ['https://example.test/a_(b_c)']) +const linkThenValue = render({ details: { title: 'Links', body: '[w_x](https://example.test/x_y) then 4* and b_c' } }) +assert.deepEqual(hrefsOf(linkThenValue), ['https://example.test/x_y']) +assert.equal(countOf(linkThenValue, /class="example-invented"/g), 1) +assert.ok(linkThenValue.includes('b_' + ZWSP + 'c')) + +const spacedLinks = '](' .repeat(20_000) +const linkStartedAt = performance.now() +render({ details: { title: 'Long', body: `${spacedLinks}\n\n${'<a:'.repeat(20_000)}\n\n${'\\`'.repeat(20_000)}\n\n${'`a``b```'.repeat(5_000)}` } }) +assert.ok(performance.now() - linkStartedAt < 1500, 'link targets and escaped backticks must stay linear-time') + +const base: Scenario = { + id: 'S1', behavior: 'B1', given: 'g', when: 'w', then: 'Then the spec outcome', alternatives: ['Then alt'], explanation: 'e', provenance: 'claude', +} +const withExample: Scenario = { ...base, example: { before: 'b', steps: ['s'], after: 'a' } } +const state = emptyState(1) +assert.equal(approveBlockReason(state, base, {}), null) +assert.equal(approveBlockReason(state, withExample, {}), null) +const custom = { ...state, scenarioOutcomes: { S1: { choice: 'custom' as const, custom: ' ' } } } +assert.equal(approveBlockReason(custom, withExample, { S1: ['q?'] }), 'outcome-missing') +assert.equal(approveBlockReason(state, base, { S1: ['q?'] }), 'open-ask') +assert.equal(approveBlockReason(state, base, { S1: [] }), null) +assert.equal(approveBlockReason(state, base, { S2: ['q?'] }), null) +const alt = { ...state, scenarioOutcomes: { S1: { choice: 'alt-0' as const, custom: '' } } } +assert.equal(approveBlockReason(alt, withExample, {}), 'outcome-changed') +assert.equal(approveBlockReason(alt, withExample, { S1: ['q?'] }), 'open-ask') +assert.equal(approveBlockReason(alt, base, {}), null) + +const update = exampleOutcomeUpdate('Then alt') +assert.ok(update.startsWith(EXAMPLE_OUTCOME_UPDATE_PREFIX)) +assert.equal(EXAMPLE_OUTCOME_UPDATE_PREFIX, 'Rewrite this scenario for the outcome I picked: ') +assert.equal(update, 'Rewrite this scenario for the outcome I picked: "Then alt". Update its Then and example together.') + +const spec = fixture as unknown as LogicSpec +const payload = { spec, slug: 'x', renderId: 'r', itemHashes: {}, logicMarkdown: '', priorDecisions: null } +assert.equal(isPayload(payload), false) +assert.equal(isPayload({ ...payload, openAsks: {} }), true) +assert.equal(isPayload({ ...payload, openAsks: { S1: ['q?'] } }), true) +assert.equal(isPayload({ ...payload, openAsks: { S1: 'q?' } }), false) +assert.equal(isPayload({ ...payload, openAsks: { S1: [1] } }), false) + +console.log('example checks passed.') diff --git a/skills/writing-specs/interactive/app/src/index.css b/skills/writing-specs/interactive/app/src/index.css index d81ef4a3..4c247ec2 100644 --- a/skills/writing-specs/interactive/app/src/index.css +++ b/skills/writing-specs/interactive/app/src/index.css @@ -36,3 +36,79 @@ tr[data-changed='true'] > td { background-color: var(--color-background-red); color: var(--color-text-red); } + +.scenario-example { + display: flex; + flex-direction: column; + gap: 12px; +} +.example-grid { + display: grid; + grid-template-columns: 1fr 1.2fr 1fr; + gap: 12px; +} +@media (max-width: 720px) { + .example-grid { + grid-template-columns: 1fr; + } +} +.example-pane { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 8px 12px; + border-radius: 8px; +} +.example-pane > ol { + margin: 0; + padding-left: 20px; +} +.example-before { + background: var(--color-background-muted); +} +.example-after { + background: color-mix(in srgb, var(--color-background-green) 35%, transparent); +} +.example-details { + background: var(--color-background-muted); +} +.example-details table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; +} +.example-details th, +.example-details td { + padding: 4px 8px; + border: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} +.example-details th { + background: color-mix(in srgb, var(--color-background-muted) 60%, transparent); +} +.scenario-example strong { + color: var(--color-text-blue); +} +.scenario-example ol > li::marker { + color: var(--color-text-purple); + font-weight: 700; +} +.example-result { + color: var(--color-text-green); + font-weight: 700; +} +.example-rejection { + color: var(--color-text-red); +} +.example-invented { + color: var(--color-text-secondary); + font-style: italic; +} +.example-gap { + color: var(--color-text-orange); + background: color-mix(in srgb, var(--color-background-yellow) 45%, transparent); + text-decoration: underline; +} diff --git a/skills/writing-specs/interactive/app/src/review-state.ts b/skills/writing-specs/interactive/app/src/review-state.ts index c7ca05fd..d82f9cb2 100644 --- a/skills/writing-specs/interactive/app/src/review-state.ts +++ b/skills/writing-specs/interactive/app/src/review-state.ts @@ -227,6 +227,8 @@ export function isPayload(value: unknown): value is LogicSpecPayload { typeof value.renderId === 'string' && isStringMap(value.itemHashes) && typeof value.logicMarkdown === 'string' && + isObject(value.openAsks) && + Object.values(value.openAsks).every((asks) => Array.isArray(asks) && asks.every((ask) => typeof ask === 'string')) && (value.priorDecisions === null || isDecisionExport(value.priorDecisions, value.slug)) ) } @@ -586,6 +588,30 @@ export function scenarioThen(state: ReviewState, scenario: Scenario): string { return scenario.alternatives[index] ?? scenario.then } +/** + * Why Approve is unavailable for a scenario, or null when it is available. Checked in order, so the first + * reason wins: a blank custom outcome, an open question from the renderer, or an outcome change that leaves + * the scenario's example stale. + */ +export function approveBlockReason( + state: ReviewState, + scenario: Scenario, + openAsks: Record<string, string[]>, +): 'outcome-missing' | 'open-ask' | 'outcome-changed' | null { + const outcome = state.scenarioOutcomes[scenario.id] + if (outcome?.choice === 'custom' && !outcome.custom.trim()) return 'outcome-missing' + if (openAsks[scenario.id]?.length) return 'open-ask' + if (scenario.example && scenarioThen(state, scenario) !== scenario.then) return 'outcome-changed' + return null +} + +export const EXAMPLE_OUTCOME_UPDATE_PREFIX = 'Rewrite this scenario for the outcome I picked: ' + +/** The update note that asks Claude to rewrite a scenario's Then and example together for the outcome the reviewer picked. */ +export function exampleOutcomeUpdate(outcomeText: string): string { + return `${EXAMPLE_OUTCOME_UPDATE_PREFIX}"${outcomeText}". Update its Then and example together.` +} + /** The next stored ruling for a constraint given a UI patch; switching into `rewrite` starts from blank so the gate can't be satisfied with the original constraint text. */ export function nextConstraintRuling( entry: ConstraintRulingDecision | undefined, diff --git a/skills/writing-specs/interactive/app/src/spec-types.ts b/skills/writing-specs/interactive/app/src/spec-types.ts index 64cd35d0..a3e65367 100644 --- a/skills/writing-specs/interactive/app/src/spec-types.ts +++ b/skills/writing-specs/interactive/app/src/spec-types.ts @@ -34,6 +34,20 @@ export interface Behavior { detail?: Markdown } +/** A titled block of Markdown beside or instead of the data-state parts: a table, a list, a code sample. */ +export interface ScenarioDetails { + title: string + body: Markdown +} + +/** A worked example for a scenario: data-state parts (records before, steps, records after), a titled details section, or both. */ +export interface ScenarioExample { + before?: Markdown + steps?: string[] + after?: Markdown + details?: ScenarioDetails +} + export interface Scenario { id: ScenarioId behavior: BehaviorId @@ -55,6 +69,7 @@ export interface Scenario { flagged?: true /** The reviewer comment Claude answered or acted on; the page clears that comment when its text still matches. */ answeredComment?: string + example?: ScenarioExample } export interface Constraint { @@ -354,6 +369,8 @@ export interface LogicSpecPayload { itemHashes: Record<string, string> logicMarkdown: string priorDecisions: Export | null + /** Open `[ASK]` questions per scenario, as judged by the renderer; the page never decides this itself. */ + openAsks: Record<ScenarioId, string[]> comparison?: Comparison comparisonId?: string } diff --git a/skills/writing-specs/interactive/app/src/views/scenario-example.tsx b/skills/writing-specs/interactive/app/src/views/scenario-example.tsx new file mode 100644 index 00000000..a2918885 --- /dev/null +++ b/skills/writing-specs/interactive/app/src/views/scenario-example.tsx @@ -0,0 +1,273 @@ +import React, { type ReactNode } from 'react' +import { Markdown, type MarkdownInlinePlugin } from '@astryxdesign/core/Markdown' +import { Text } from '@astryxdesign/core/Text' +import type { ScenarioDetails, ScenarioExample } from '../spec-types' +import { useItemLinkPlugins } from './item-links' +import { OFFLINE_MARKDOWN_COMPONENTS } from './shared' + +/** Stands in for an invented-value star while the parser runs, because Astryx pairs two trailing stars in one part as emphasis. */ +const STAR = '\uE000' +/** Takes the place of a literal U+E000 in the text, so only a swapped star ever reads as the placeholder. */ +const LITERAL_STAR_SENTINEL = '\uE001' +const restoreStar = (text: string) => text.replaceAll(STAR, '*').replaceAll(LITERAL_STAR_SENTINEL, STAR) + +/** A result or rejection phrase: its mark plus everything up to the next semicolon or line end. */ +export const EXAMPLE_PHRASE = /([✓✗])([^;\n]+)/g + +/** Invented-value runs start at whitespace or right after a previous value's `*,`, so each position of a long run without a star fails in constant time. */ +const INVENTED_MARK = /(?<=^|\s|\*,)([^\s*]+)\*(?=[\s,;:.)\]]|$)/g + +/** Colours the marks that survive Markdown parsing; the gap pattern must match the renderer's open-gap regex. */ +export const exampleMarkPlugins: MarkdownInlinePlugin[] = [ + { pattern: /(?<=^|\s|\uE000,)[^\s\uE000]+\uE000(?=[\s,;:.)\]]|$)/g, render: (match, key) => <span key={key} className="example-invented">{restoreStar(match[0])}</span> }, + { pattern: /\[ASK: [^\[\]\n]+\]/g, render: (match, key) => <span key={key} className="example-gap">{restoreStar(match[0])}</span> }, +] + +/** Last in the plugin order so it only sees placeholders the marks above left in plain text. */ +const restorePlaceholders: MarkdownInlinePlugin = { + pattern: /[\uE000\uE001]/g, + render: (match, key) => <React.Fragment key={key}>{restoreStar(match[0])}</React.Fragment>, +} + +/** Colours a result or rejection phrase inside one text node of the details body; nested values and gaps keep their own marks. */ +export const EXAMPLE_PHRASE_PLUGIN: MarkdownInlinePlugin = { + pattern: /[✓✗][^;\n]+/g, + render: (match, key) => ( + <span key={key} className={match[0][0] === '✓' ? 'example-result' : 'example-rejection'}> + {renderNestedMarks(match[0])} + </span> + ), +} + +/** Splits phrase text on the same patterns exampleMarkPlugins use, so a value or gap inside a phrase gets its own span. */ +function renderNestedMarks(text: string): ReactNode[] { + const [invented, gap] = exampleMarkPlugins.map((plugin) => plugin.pattern) + const nested = new RegExp(`${invented.source}|${gap.source}`, 'g') + const nodes: ReactNode[] = [] + let last = 0 + for (const m of text.matchAll(nested)) { + if (m.index > last) nodes.push(restoreStar(text.slice(last, m.index))) + nodes.push(<span key={m.index} className={m[0].startsWith('[ASK: ') ? 'example-gap' : 'example-invented'}>{restoreStar(m[0])}</span>) + last = m.index + m[0].length + } + if (last < text.length) nodes.push(restoreStar(text.slice(last))) + return nodes +} + +type Segment = { text: string; className?: string } + +/** Cuts one line into plain stretches and ✓/✗ phrases so a phrase can be parsed whole, nested marks included. */ +function segmentLine(line: string): Segment[] { + const segments: Segment[] = [] + let last = 0 + for (const m of line.matchAll(EXAMPLE_PHRASE)) { + if (m.index > last) segments.push({ text: line.slice(last, m.index) }) + segments.push({ text: m[0], className: m[1] === '✓' ? 'example-result' : 'example-rejection' }) + last = m.index + m[0].length + } + if (last < line.length) segments.push({ text: line.slice(last) }) + return segments +} + +// SpecMarkdown takes no extra plugins, so this repeats its underscore guard to keep prose underscores from becoming emphasis. +function ExampleMarkdown({ children, hasLineBreaks }: { children: string; hasLineBreaks?: boolean }) { + const itemLinkPlugins = useItemLinkPlugins() + const inlinePlugins = [...(itemLinkPlugins ?? []), ...exampleMarkPlugins, restorePlaceholders] + const text = guardProse(children.replace(/\r\n?/g, '\n')) + if (!new RegExp(EXAMPLE_PHRASE.source).test(text)) { + return ( + <Markdown components={OFFLINE_MARKDOWN_COMPONENTS} inlinePlugins={inlinePlugins} contentWidth="100%" density="compact"> + {hasLineBreaks ? text.replace(/\n/g, ' \n') : text} + </Markdown> + ) + } + const inline = (content: string, key: string) => ( + <Markdown key={key} display="inline" components={OFFLINE_MARKDOWN_COMPONENTS} inlinePlugins={inlinePlugins} density="compact"> + {content} + </Markdown> + ) + return ( + <div> + {text.split('\n').map((line, lineIndex) => ( + <React.Fragment key={lineIndex}> + {lineIndex > 0 && (hasLineBreaks ? <br /> : ' ')} + {segmentLine(line).map((segment, i) => { + const key = `${lineIndex}-${i}` + const [, lead, core, trail] = /^(\s*)([\s\S]*?)(\s*)$/.exec(segment.text)! + const body = core && (segment.className + ? <span key={key} className={segment.className}>{inline(core, key)}</span> + : inline(core, key)) + return <React.Fragment key={key}>{lead}{body}{trail}</React.Fragment> + })} + </React.Fragment> + ))} + </div> + ) +} + +/** Underscore guard plus the invented-value star swap, for plain prose with no link destinations or code in it. */ +function guardPlain(text: string): string { + return text.replace(/_/g, '_\u200b').replaceAll(STAR, LITERAL_STAR_SENTINEL).replace(INVENTED_MARK, `$1${STAR}`) +} + +/** Inline link destinations (`](...)`) and autolinks, whose text is a URL that the guards would corrupt. Every branch starts at a distinct character and the destination body always matches, so it cannot backtrack. */ +const LINK_TARGET = /\]\((?:<[^<>\n]*>|(?:[^\s()]|\([^\s()]*\))*)\)?|<[a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*>/g + +/** Guards text that is not inside code, leaving link destinations and autolinks exactly as written. */ +function guardProse(text: string): string { + let out = '' + let last = 0 + for (const m of text.matchAll(LINK_TARGET)) { + out += guardBesideVerbatim(text.slice(last, m.index), last > 0, true, guardPlain) + m[0] + last = m.index + m[0].length + } + return out + guardBesideVerbatim(text.slice(last), last > 0, false, guardPlain) +} + +/** Guards one stretch of prose that sits beside verbatim text (code or a link target), padding the edges that text touches so the star pattern's start and end anchors don't treat it as the stretch's boundary. */ +function guardBesideVerbatim(text: string, afterVerbatim: boolean, beforeVerbatim: boolean, guard: (text: string) => string): string { + const guarded = guard(`${afterVerbatim ? '\u0001' : ''}${text}${beforeVerbatim ? '\u0001' : ''}`) + return guarded.slice(afterVerbatim ? 1 : 0, beforeVerbatim ? -1 : undefined) +} + +/** Whether the character at `index` is escaped by an odd number of backslashes right before it. */ +function isBackslashEscaped(text: string, index: number): boolean { + let count = 0 + while (index - count > 0 && text[index - count - 1] === '\\') count++ + return count % 2 === 1 +} + +/** + * Guards one paragraph, leaving its inline code spans exactly as written. A run of N backticks opens a span + * that the next run of exactly N closes; an unmatched run is literal. A backtick outside code that follows an + * odd number of backslashes is a literal character, so it neither opens a span nor shortens a run it closes; + * inside a span backslashes are literal. + */ +function guardParagraph(paragraph: string): string { + const runs = [...paragraph.matchAll(/`+/g)].map((m) => ({ start: m.index, length: m[0].length })) + const runsByLength = new Map<number, number[]>() + runs.forEach((run, i) => { + const indexes = runsByLength.get(run.length) + if (indexes) indexes.push(i) + else runsByLength.set(run.length, [i]) + }) + const scanned = new Map<number, number>() + // opener positions only grow, so each length's list is walked once overall + const nextRunOfLength = (length: number, after: number): number => { + const indexes = runsByLength.get(length) + if (!indexes) return -1 + let at = scanned.get(length) ?? 0 + while (at < indexes.length && indexes[at] <= after) at++ + scanned.set(length, at) + return at < indexes.length ? indexes[at] : -1 + } + let out = '' + let cursor = 0 + let i = 0 + while (i < runs.length) { + const escaped = isBackslashEscaped(paragraph, runs[i].start) ? 1 : 0 + const openStart = runs[i].start + escaped + const openLength = runs[i].length - escaped + const closer = openLength > 0 ? nextRunOfLength(openLength, i) : -1 + if (closer === -1) { + i++ + continue + } + const spanEnd = runs[closer].start + runs[closer].length + out += guardBesideVerbatim(paragraph.slice(cursor, openStart), cursor > 0, true, guardProse) + paragraph.slice(openStart, spanEnd) + cursor = spanEnd + i = closer + 1 + } + return out + guardBesideVerbatim(paragraph.slice(cursor), cursor > 0, false, guardProse) +} + +/** Guards prose outside code; a code span never reaches across a blank line. */ +function guardProseOutsideCode(text: string): string { + return text + .split(/(\n[ \t]*\n)/) + .map((part, i) => (i % 2 === 1 ? part : guardParagraph(part))) + .join('') +} + +/** A fence line, including one that opens or closes inside blockquote and list-item markers (`> `, `- `, `1. `, nested or mixed). */ +const FENCE_LINE = /^[ \t]*(?:>[ \t]*|(?:[-*+]|\d{1,9}[.)])[ \t]+)*(`{3,}|~{3,})(.*)$/ + +/** Normalizes line endings, then applies the guards to everything except fenced code blocks and inline code spans, which pass through as written. */ +function prepareDetailsBody(body: string): string { + const lines = body.replace(/\r\n?/g, '\n').match(/[^\n]*\n|[^\n]+/g) ?? [] + let out = '' + let prose = '' + let fence: { char: string; length: number } | null = null + for (const line of lines) { + const content = line.replace(/\n$/, '') + const marker = FENCE_LINE.exec(content) + if (fence) { + out += line + if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && marker[2].trim() === '') fence = null + continue + } + if (marker && !(marker[1][0] === '`' && marker[2].includes('`'))) { + out += guardProseOutsideCode(prose) + line + prose = '' + fence = { char: marker[1][0], length: marker[1].length } + continue + } + prose += line + } + return out + guardProseOutsideCode(prose) +} + +/** The details body as one block of Markdown, so tables, lists and code blocks keep their structure. */ +function ExampleDetailsBody({ children }: { children: string }) { + const itemLinkPlugins = useItemLinkPlugins() + const inlinePlugins = [...(itemLinkPlugins ?? []), EXAMPLE_PHRASE_PLUGIN, ...exampleMarkPlugins, restorePlaceholders] + return ( + <Markdown components={OFFLINE_MARKDOWN_COMPONENTS} inlinePlugins={inlinePlugins} contentWidth="100%" density="compact"> + {prepareDetailsBody(children)} + </Markdown> + ) +} + +function ExamplePane({ label, className, children }: { label: string; className?: string; children: ReactNode }) { + return ( + <section className={`example-pane${className ? ` ${className}` : ''}`} aria-label={label}> + <Text type="label" color="secondary">{label}</Text> + {children} + </section> + ) +} + +/** A scenario's worked example: the data-state panes (records before, steps, records after) when it has them, then its titled details pane. */ +export function ScenarioExampleView({ example }: { example: ScenarioExample }) { + const { before, steps, after, details } = example + return ( + <div className="scenario-example"> + {before !== undefined && steps !== undefined && after !== undefined && ( + <div className="example-grid"> + <ExamplePane label="Data before" className="example-before"> + <ExampleMarkdown hasLineBreaks>{before}</ExampleMarkdown> + </ExamplePane> + <ExamplePane label="Walkthrough"> + <ol> + {steps.map((step, i) => ( + <li key={i}><ExampleMarkdown>{step}</ExampleMarkdown></li> + ))} + </ol> + </ExamplePane> + <ExamplePane label="Data after" className="example-after"> + <ExampleMarkdown hasLineBreaks>{after}</ExampleMarkdown> + </ExamplePane> + </div> + )} + {details && <ExampleDetails details={details} />} + </div> + ) +} + +function ExampleDetails({ details }: { details: ScenarioDetails }) { + return ( + <ExamplePane label={details.title} className="example-details"> + <ExampleDetailsBody>{details.body}</ExampleDetailsBody> + </ExamplePane> + ) +} diff --git a/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx b/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx index 0828fb1c..6524bccc 100644 --- a/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx +++ b/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx @@ -4,11 +4,19 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog' import { HStack, VStack } from '@astryxdesign/core/Layout' import { Text } from '@astryxdesign/core/Text' import type { Scenario } from '../spec-types' -import { isScenarioUndecided } from '../review-state' -import { Clause, CommentField, DecisionCell, FlagToggle, OutcomeCell, type Decision } from './scenarios-view' +import { isScenarioUndecided, scenarioThen } from '../review-state' +import { Clause, CommentField, DecisionCell, FlagToggle, OutcomeCell, Stage2Decision, type Decision } from './scenarios-view' +import { ScenarioExampleView } from './scenario-example' import { ClaudeField, Field } from './shared' import type { ViewProps } from './view-props' +/** Every scenario under the behavior that owns `startScenarioId`, in spec order, whatever its decision or stage. */ +function browseQueue(props: ViewProps, startScenarioId: string): string[] { + const { spec } = props + const behavior = spec.scenarios.find((s) => s.id === startScenarioId)?.behavior + return spec.scenarios.filter((s) => s.behavior === behavior).map((s) => s.id) +} + function undecidedQueue(props: ViewProps, behaviorId?: string): string[] { const { spec, state } = props const behaviors = behaviorId ? spec.behaviors.filter((b) => b.id === behaviorId) : spec.behaviors @@ -26,32 +34,45 @@ function ownsArrowKeys(target: EventTarget | null): boolean { } function FocusedScenario({ s, props, onDecided }: { s: Scenario; props: ViewProps; onDecided: (decision: Decision) => void }) { + const isStage2 = props.spec.stage === 2 return ( <VStack gap={3}> <Clause keyword="Given" text={s.given} /> <Clause keyword="When" text={s.when} /> - <Field label="Then"> - <OutcomeCell s={s} props={props} /> - </Field> + {isStage2 ? ( + <Clause keyword="Then" text={scenarioThen(props.state, s)} /> + ) : ( + <Field label="Then"> + <OutcomeCell s={s} props={props} /> + </Field> + )} + {s.example ? <ScenarioExampleView example={s.example} /> : null} <ClaudeField label="Why">{s.explanation}</ClaudeField> <Field label="Your decision"> - <DecisionCell s={s} props={props} onDecided={onDecided} /> + {isStage2 ? <Stage2Decision s={s} props={props} /> : <DecisionCell s={s} props={props} onDecided={onDecided} />} </Field> - <CommentField key={s.id} s={s} props={props} isInitiallyOpen /> + {isStage2 ? null : <CommentField key={s.id} s={s} props={props} isInitiallyOpen />} </VStack> ) } -/** Walks the scenarios that were undecided when it opened, one at a time; the list is fixed so a scenario stays put while its reason is typed. */ -export function ScenarioFocusDialog({ isOpen, onClose, behaviorId, props }: { isOpen: boolean; onClose: () => void; behaviorId?: string; props: ViewProps }) { +type FocusDialogProps = { onClose: () => void; behaviorId?: string; startScenarioId?: string; props: ViewProps } + +/** + * Walks scenarios one at a time from a list fixed when it opens, so a scenario stays put while its reason is typed. + * Without `startScenarioId` the list is the scenarios undecided at that moment; with it, every scenario under that + * scenario's behavior, opened at that scenario. + */ +export function ScenarioFocusDialog({ isOpen, ...rest }: FocusDialogProps & { isOpen: boolean }) { if (!isOpen) return null - return <OpenFocusDialog onClose={onClose} behaviorId={behaviorId} props={props} /> + return <OpenFocusDialog {...rest} /> } -function OpenFocusDialog({ onClose, behaviorId, props }: { onClose: () => void; behaviorId?: string; props: ViewProps }) { +function OpenFocusDialog({ onClose, behaviorId, startScenarioId, props }: FocusDialogProps) { const { spec } = props - const [queue] = useState(() => undecidedQueue(props, behaviorId)) - const [index, setIndex] = useState(0) + const isBrowsing = startScenarioId !== undefined + const [queue] = useState(() => (startScenarioId ? browseQueue(props, startScenarioId) : undecidedQueue(props, behaviorId))) + const [index, setIndex] = useState(() => (startScenarioId ? Math.max(0, queue.indexOf(startScenarioId)) : 0)) const isDone = index >= queue.length const scenario = isDone ? undefined : spec.scenarios.find((s) => s.id === queue[index]) const behavior = scenario ? spec.behaviors.find((b) => b.id === scenario.behavior) : undefined @@ -70,14 +91,15 @@ function OpenFocusDialog({ onClose, behaviorId, props }: { onClose: () => void; return () => window.removeEventListener('keydown', onKey) }) - const title = scenario ? `${scenario.id} · ${behavior?.rule ?? scenario.behavior}` : 'Undecided scenarios' + const hasExample = queue.some((id) => spec.scenarios.find((s) => s.id === id)?.example) + const title = scenario ? `${scenario.id} · ${behavior?.rule ?? scenario.behavior}` : isBrowsing ? 'Scenarios' : 'Undecided scenarios' const subtitle = isDone ? 'End of the list' : `Scenario ${index + 1} of ${queue.length}` return ( - <Dialog isOpen onOpenChange={(open) => !open && onClose()} width={760}> + <Dialog isOpen onOpenChange={(open) => !open && onClose()} width={hasExample ? 1040 : 760}> <DialogHeader title={title} subtitle={subtitle} - startContent={scenario ? <FlagToggle key={scenario.id} s={scenario} props={props} /> : undefined} + startContent={scenario && !props.readOnly ? <FlagToggle key={scenario.id} s={scenario} props={props} /> : undefined} onOpenChange={(open) => !open && onClose()} /> <VStack gap={4} padding={4}> @@ -91,7 +113,9 @@ function OpenFocusDialog({ onClose, behaviorId, props }: { onClose: () => void; }} /> ) : ( - <Text>That's every undecided scenario. Anything you skipped is still undecided in the table.</Text> + <Text> + {isBrowsing ? "That's every scenario under this behavior." : "That's every undecided scenario. Anything you skipped is still undecided in the table."} + </Text> )} <HStack gap={2} justify="between" align="center"> <Text type="supporting">← → to move between scenarios</Text> diff --git a/skills/writing-specs/interactive/app/src/views/scenarios-view.tsx b/skills/writing-specs/interactive/app/src/views/scenarios-view.tsx index f68bc5be..8d7d6e25 100644 --- a/skills/writing-specs/interactive/app/src/views/scenarios-view.tsx +++ b/skills/writing-specs/interactive/app/src/views/scenarios-view.tsx @@ -7,14 +7,24 @@ import { HStack, VStack } from '@astryxdesign/core/Layout' import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl' import { Selector, SelectorOption } from '@astryxdesign/core/Selector' import { Switch } from '@astryxdesign/core/Switch' -import { Table, pixel, proportional, type TableColumn } from '@astryxdesign/core/Table' +import { Table, pixel, proportional, type TableColumn, type TablePlugin } from '@astryxdesign/core/Table' import { Text } from '@astryxdesign/core/Text' import { TextArea } from '@astryxdesign/core/TextArea' import { TextInput } from '@astryxdesign/core/TextInput' import { Token } from '@astryxdesign/core/Token' import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden' import type { Behavior, Scenario, ScenarioOutcome, ScenarioRequest, ReviewState } from '../spec-types' -import { isScenarioFlagged, isScenarioUndecided, pendingScenarioRequests, requestId, scenarioThen, type Update } from '../review-state' +import { + approveBlockReason, + EXAMPLE_OUTCOME_UPDATE_PREFIX, + exampleOutcomeUpdate, + isScenarioFlagged, + isScenarioUndecided, + pendingScenarioRequests, + requestId, + scenarioThen, + type Update, +} from '../review-state' import { ScenarioFocusDialog } from './scenario-focus-dialog' import { ChangedRowLabel, changedRowsPlugin, ClaudeAddedToken, SpecMarkdown } from './shared' import type { ViewProps } from './view-props' @@ -38,19 +48,35 @@ function ThenLabel({ label }: { label: string }) { return <><strong>Then</strong>{label.slice(4)}</> } +/** Keeps the automatic "rewrite for this outcome" note in step with the picked outcome, without touching a note the reviewer wrote or a scenario they dropped. */ +function exampleUpdates(st: ReviewState, s: Scenario, outcomeText: string, outcome: ScenarioOutcome): ReviewState['scenarioUpdates'] { + const scenarioUpdates = { ...st.scenarioUpdates } + const existing = scenarioUpdates[s.id]?.trim() ? scenarioUpdates[s.id] : undefined + if (existing && !existing.startsWith(EXAMPLE_OUTCOME_UPDATE_PREFIX)) return scenarioUpdates + const isSpecOutcome = outcome.choice === 'spec' || (outcome.choice === 'custom' && !outcome.custom.trim()) + if (isSpecOutcome) { + if (existing) delete scenarioUpdates[s.id] + } else if (!Object.hasOwn(st.scenarioDrops, s.id)) { + scenarioUpdates[s.id] = exampleOutcomeUpdate(outcomeText) + } + return scenarioUpdates +} + export function OutcomeCell({ s, props }: { s: Scenario; props: ViewProps }) { const { state, update } = props const outcome = state.scenarioOutcomes[s.id] ?? { choice: 'spec' as const, custom: '' } const set = (patch: Partial<ScenarioOutcome>) => - update( - (st) => ({ + update((st) => { + const next = { ...outcome, ...patch } + const scenarioOutcomes = { ...st.scenarioOutcomes, [s.id]: next } + return { ...st, - scenarioOutcomes: { ...st.scenarioOutcomes, [s.id]: { ...outcome, ...patch } }, + scenarioOutcomes, // a different outcome is a different scenario, so an earlier approval no longer applies scenarioApproved: { ...st.scenarioApproved, [s.id]: false }, - }), - s.id, - ) + ...(s.example ? { scenarioUpdates: exampleUpdates(st, s, scenarioThen({ ...st, scenarioOutcomes }, s), next) } : {}), + } + }, s.id) return ( <VStack gap={1.5}> <Selector @@ -78,6 +104,12 @@ export function OutcomeCell({ s, props }: { s: Scenario; props: ViewProps }) { ) } +const APPROVE_BLOCK_HINT = { + 'outcome-missing': 'Write your outcome first.', + 'open-ask': 'Answer the open question in an update first.', + 'outcome-changed': 'Claude has to rewrite the example for this outcome first.', +} as const + export type Decision = 'approve' | 'update' | 'drop' export function DecisionCell({ s, props, onDecided }: { s: Scenario; props: ViewProps; onDecided?: (decision: Decision) => void }) { @@ -86,8 +118,7 @@ export function DecisionCell({ s, props, onDecided }: { s: Scenario; props: View const isDropped = Object.hasOwn(state.scenarioDrops, s.id) const isUpdating = Object.hasOwn(state.scenarioUpdates, s.id) const value: Decision | '' = isDropped ? 'drop' : isUpdating ? 'update' : state.scenarioApproved[s.id] ? 'approve' : '' - const outcome = state.scenarioOutcomes[s.id] - const isOutcomeMissing = outcome?.choice === 'custom' && !outcome.custom.trim() + const blockReason = approveBlockReason(state, s, props.payload.openAsks) const decide = (decision: Decision) => update((st) => { const scenarioDrops = { ...st.scenarioDrops } @@ -113,11 +144,11 @@ export function DecisionCell({ s, props, onDecided }: { s: Scenario; props: View onDecided?.(next as Decision) }} > - <SegmentedControlItem value="approve" label="Approve" isDisabled={isOutcomeMissing} /> + <SegmentedControlItem value="approve" label="Approve" isDisabled={blockReason !== null} /> <SegmentedControlItem value="update" label="Update" /> <SegmentedControlItem value="drop" label="Drop" /> </SegmentedControl> - {isOutcomeMissing && value === '' ? <Text type="supporting">Write your outcome first.</Text> : null} + {blockReason && (blockReason !== 'outcome-missing' || value === '') ? <Text type="supporting">{APPROVE_BLOCK_HINT[blockReason]}</Text> : null} {isUpdating ? ( <TextArea label={`What should change in ${s.id}`} @@ -227,8 +258,16 @@ function ReapproveCell({ s, props }: { s: Scenario; props: ViewProps }) { ) } -function scenarioColumns(props: ViewProps, isStage2: boolean): TableColumn<ScenarioRow>[] { - const { state, changedIds } = props +/** What stage 2 shows for a scenario's decision: the folded outcome, or the re-approve checkbox for a reopened one unless the page is read-only. */ +export function Stage2Decision({ s, props }: { s: Scenario; props: ViewProps }) { + if (s.reopened && !props.readOnly) return <ReapproveCell s={s} props={props} /> + // folded stage-2 scenarios carry their final outcome directly; only a reopened scenario still tracks state + const approved = !s.dropReason + return <Token size="sm" color={approved ? 'green' : 'default'} label={approved ? 'Approved' : 'Dropped'} description={s.dropReason} /> +} + +function scenarioColumns(props: ViewProps, isStage2: boolean, onBrowse: (id: string) => void): TableColumn<ScenarioRow>[] { + const { state, changedIds, payload } = props const columns: TableColumn<ScenarioRow>[] = [ { key: 'flag', @@ -244,12 +283,7 @@ function scenarioColumns(props: ViewProps, isStage2: boolean): TableColumn<Scena header: 'Decision', width: pixel(isStage2 ? 128 : 240), renderCell: (s) => { - if (isStage2 && (!s.reopened || props.readOnly)) { - // folded stage-2 scenarios carry their final outcome directly; only a reopened scenario still tracks state - const approved = !s.dropReason - return <Token size="sm" color={approved ? 'green' : 'default'} label={approved ? 'Approved' : 'Dropped'} description={s.dropReason} /> - } - if (isStage2) return <ReapproveCell s={s} props={props} /> + if (isStage2) return <Stage2Decision s={s} props={props} /> return ( <VStack gap={1.5}> <DecisionCell s={s} props={props} /> @@ -264,7 +298,11 @@ function scenarioColumns(props: ViewProps, isStage2: boolean): TableColumn<Scena width: pixel(160), renderCell: (s) => ( <VStack gap={1}> - <Text type="label">{s.id}</Text> + <HStack><Button size="sm" variant="ghost" label={s.id} tooltip="Open this scenario's details" onClick={() => onBrowse(s.id)} /></HStack> + {s.example ? <HStack><Token size="sm" label="Example" description="This scenario has a worked data example; open it to read it." /></HStack> : null} + {payload.openAsks[s.id]?.length ? ( + <HStack><Token size="sm" color="yellow" label="Open question" description={payload.openAsks[s.id][0]} /></HStack> + ) : null} {s.reopened ? ( <HStack><Token size="sm" color="orange" label="Reopened" description={s.reopened.reason} /></HStack> ) : s.provenance === 'claude' && (!isStage2 || !hasScenarioEntry(state, s.id)) ? ( @@ -351,11 +389,31 @@ function BehaviorRequests({ behavior, props }: { behavior: Behavior; props: View ) } +const ROW_CLICK_IGNORED = 'button, a, input, textarea, select, label, [role="radiogroup"], [role="switch"], [role="checkbox"]' + +/** Opens a scenario from a click anywhere on its row, except on a control inside the row or while the reviewer is selecting text. */ +function browseRowPlugin(open: (id: string) => void): TablePlugin<ScenarioRow> { + return { + transformBodyRow: (props, row) => ({ + ...props, + htmlProps: { + ...props.htmlProps, + onClick: (event) => { + if (event.target instanceof Element && event.target.closest(ROW_CLICK_IGNORED)) return + if (window.getSelection()?.toString()) return + open(row.id) + }, + }, + }), + } +} + function BehaviorGroup({ behavior, props, isFlaggedOnly }: { behavior: Behavior; props: ViewProps; isFlaggedOnly: boolean }) { const { spec, state, changedIds } = props const scenarios = spec.scenarios.filter((s) => s.behavior === behavior.id && (!isFlaggedOnly || isScenarioFlagged(state, s))) - const plugins = useMemo(() => ({ changed: changedRowsPlugin<ScenarioRow>(changedIds) }), [changedIds]) - const columns = scenarioColumns(props, spec.stage === 2) + const [browseId, setBrowseId] = useState<string | null>(null) + const plugins = useMemo(() => ({ changed: changedRowsPlugin<ScenarioRow>(changedIds), browse: browseRowPlugin(setBrowseId) }), [changedIds]) + const columns = scenarioColumns(props, spec.stage === 2, setBrowseId) if (isFlaggedOnly && scenarios.length === 0) return null return ( <VStack gap={2}> @@ -369,6 +427,13 @@ function BehaviorGroup({ behavior, props, isFlaggedOnly }: { behavior: Behavior; </VStack> <Table data={scenarios} columns={columns} plugins={plugins} idKey="id" verticalAlign="top" dividers="rows" /> {spec.stage === 1 ? <BehaviorRequests behavior={behavior} props={props} /> : null} + <ScenarioFocusDialog + isOpen={browseId !== null} + onClose={() => setBrowseId(null)} + behaviorId={behavior.id} + startScenarioId={browseId ?? undefined} + props={props} + /> </VStack> ) } diff --git a/skills/writing-specs/interactive/app/src/views/shared.tsx b/skills/writing-specs/interactive/app/src/views/shared.tsx index c203eb12..12eecfcb 100644 --- a/skills/writing-specs/interactive/app/src/views/shared.tsx +++ b/skills/writing-specs/interactive/app/src/views/shared.tsx @@ -12,8 +12,20 @@ export const CERTAINTY_LABEL: Record<Certainty, string> = { confirmed: 'Confirme export const CERTAINTY_COLOR = { confirmed: 'green', assumed: 'yellow', unverified: 'orange' } as const export const GROUP_COLOR: Record<RequirementGroup, 'blue' | 'orange' | 'yellow' | 'cyan'> = { min: 'blue', i3: 'orange', i2: 'yellow', i1: 'cyan' } -/** Keeps the page offline: images in Claude-written markdown are named, never fetched. */ +const ALLOWED_LINK_SCHEME = /^(https?:|mailto:|#)/ + +/** Keeps the page offline and inert: images in Claude-written markdown are named, never fetched, and a link renders as a link only for http(s), mailto or in-page hrefs. */ export const OFFLINE_MARKDOWN_COMPONENTS: MarkdownComponents = { + link: ({ href, children }) => { + const target = href.trim().toLowerCase() + if (!ALLOWED_LINK_SCHEME.test(target)) return <span>{children}</span> + const external = target.startsWith('http') + return ( + <a href={href} className="xjse4m1 x1bvjpef" {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}> + {children} + </a> + ) + }, image: ({ alt }) => <span>{alt ? `Image omitted in offline review: ${alt}` : 'Image omitted in offline review.'}</span>, } diff --git a/skills/writing-specs/interactive/render_spec.py b/skills/writing-specs/interactive/render_spec.py index 7378f132..8da7355b 100755 --- a/skills/writing-specs/interactive/render_spec.py +++ b/skills/writing-specs/interactive/render_spec.py @@ -99,6 +99,138 @@ SCHEMA_MAX_REFS = 6 +OPEN_ASK_RE = re.compile(r"\[ASK: ([^\[\]\n]+)\]") +ASK_LOOKALIKE_RE = re.compile(r"\[\s*ask\b", re.IGNORECASE) +EXAMPLE_KEYS = ("before", "steps", "after", "details") +DATA_STATE_KEYS = ("before", "steps", "after") +DATA_STATE_LABELS = ("data before", "walkthrough", "data after") +DETAILS_TITLE_FORBIDDEN = re.compile(r"[\n\r*_`\[\]<>]") +MALFORMED_ASK_MESSAGE = "malformed [ASK] marker; write [ASK: <question>]" + + +def _example_parts(example: dict) -> List[str]: + """Every text part of an example in reading order: before, steps, after, then the details body.""" + parts: List[str] = [] + if "before" in example: + parts.append(example["before"]) + parts.extend(example["steps"]) + parts.append(example["after"]) + if "details" in example: + parts.append(example["details"]["body"]) + return parts + + +def scenario_open_asks(scenario: dict) -> List[str]: + """Questions of every open [ASK: ...] gap in the scenario's example, in reading order; [] without one.""" + example = scenario.get("example") + if not example: + return [] + return [match.group(1) for part in _example_parts(example) for match in OPEN_ASK_RE.finditer(part)] + + +def _normalized_lines(text: str) -> List[str]: + return text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + +def _example_part_lines(first_prefix: str, indent: str, text: str) -> List[str]: + rows = _normalized_lines(text) + lines = [first_prefix + rows[0]] + [indent + row for row in rows[1:]] + return [line + " " for line in lines[:-1]] + lines[-1:] + + +def _example_markdown_lines(example: dict) -> List[str]: + """Sub-bullets for a scenario's example: data-state parts when present, then the titled details block. + + Newlines inside a data-state part become hard breaks; the details body keeps ordinary Markdown newlines so + tables, lists and code blocks survive. + """ + lines: List[str] = [] + if "before" in example: + lines.extend(_example_part_lines(" - **Data before:** ", " ", example["before"])) + lines.append(" - **Walkthrough:**") + for number, step in enumerate(example["steps"], 1): + lines.extend(_example_part_lines(" " + str(number) + ". ", " ", step)) + lines.extend(_example_part_lines(" - **Data after:** ", " ", example["after"])) + if "details" in example: + details = example["details"] + lines.extend([" - **" + details["title"] + ":**", ""]) + lines.extend(" " + row if row else "" for row in _normalized_lines(details["body"])) + lines.append("") + return lines + + +def _validate_example_details(v: "Validator", raw: Any, details_path: str) -> Optional[str]: + """Validate `example.details`; returns the body when it is a usable string, for the [ASK] check.""" + details = v.object(raw, details_path) + if details is None: + return None + for key in details: + if key not in ("title", "body"): + v.error(pointer(details_path, key), "unexpected key") + if "title" not in details: + _required(v, details, "title", details_path) + else: + title_path = pointer(details_path, "title") + title = details["title"] + if v.string(title, title_path, nonempty=True): + if DETAILS_TITLE_FORBIDDEN.search(title): + v.error(title_path, "details title must be one line of plain text") + else: + label = title.strip() + if label.endswith(":"): + label = label[:-1].strip() + if label.lower() in DATA_STATE_LABELS: + v.error(title_path, "details title reuses a data-state label") + if "body" not in details: + _required(v, details, "body", details_path) + return None + body_path = pointer(details_path, "body") + return details["body"] if v.string(details["body"], body_path, nonempty=True) else None + + +def _validate_scenario_example(v: "Validator", item: dict, path: str) -> bool: + """Validate `example`; True when it is well formed enough for scenario_open_asks.""" + errors_before = len(v.errors) + example_path = pointer(path, "example") + example = v.object(item["example"], example_path) + if example is None: + return False + for key in example: + if key not in EXAMPLE_KEYS: + v.error(pointer(example_path, key), "unexpected key") + if not any(key in example for key in EXAMPLE_KEYS): + v.error(example_path, "example needs before, steps and after, or details") + parts: List[Tuple[str, str]] = [] + if any(key in example for key in DATA_STATE_KEYS): + for key in ("before", "after"): + if key not in example: + _required(v, example, key, example_path) + elif v.string(example[key], pointer(example_path, key), nonempty=True): + parts.append((pointer(example_path, key), example[key])) + if "steps" not in example: + _required(v, example, "steps", example_path) + else: + steps_path = pointer(example_path, "steps") + steps = v.array(example["steps"], steps_path) + if steps is not None: + if not steps: + v.error(steps_path, "must not be empty") + for index, step in enumerate(steps): + if v.string(step, pointer(steps_path, index), nonempty=True): + parts.append((pointer(steps_path, index), step)) + if "details" in example: + details_path = pointer(example_path, "details") + body = _validate_example_details(v, example["details"], details_path) + if body is not None: + parts.append((pointer(details_path, "body"), body)) + for part_path, text in parts: + for match in ASK_LOOKALIKE_RE.finditer(text): + if not OPEN_ASK_RE.match(text, match.start()): + v.error(part_path, MALFORMED_ASK_MESSAGE) + break + return len(v.errors) == errors_before + + class Validator: """Small schema validator that reports every actionable JSON-pointer error.""" @@ -926,6 +1058,7 @@ def validate_logic_v2(logic: dict) -> Tuple[List[Tuple[str, str]], List[str]]: v.string(item["rejectReason"], pointer(path, "rejectReason")) scenario_rows: List[Any] = [] + checked_example_rows: set = set() scenarios = _required(v, root, "scenarios", "") if "scenarios" in root: rows = v.array(scenarios, "/scenarios") @@ -964,6 +1097,9 @@ def validate_logic_v2(logic: dict) -> Tuple[List[Tuple[str, str]], List[str]]: v.error(pointer(path, "flagged"), "must be true when present") if "answeredComment" in item: v.string(item["answeredComment"], pointer(path, "answeredComment"), nonempty=True) + if "example" in item: + if _validate_scenario_example(v, item, path): + checked_example_rows.add(index) if "reopened" in item and "reapprovedHash" in item: v.error(pointer(path, "reapprovedHash"), "reopened and reapprovedHash cannot both be present") @@ -1356,6 +1492,11 @@ def validate_logic_v2(logic: dict) -> Tuple[List[Tuple[str, str]], List[str]]: if isinstance(raw, dict) and "ruling" not in raw: v.error(pointer(pointer("/constraints", index), "ruling"), "required in stage 2") + for index in sorted(checked_example_rows): + raw = scenario_rows[index] + if "dropReason" not in raw and scenario_open_asks(raw): + v.error(pointer(pointer("/scenarios", index), "example"), "open [ASK] after stage-1 sign-off") + approved_scenario_ids = { item["id"] for item in scenario_rows @@ -1418,6 +1559,22 @@ def _validate_stage1_lock(v: Validator, root: dict) -> None: v.error(pointer("/stage1Pin/itemHashes", ident), "stage-1 item changed after sign-off") +def oversized_text_errors(value: Any, path: str = "") -> List[Tuple[str, str]]: + """Report every string longer than MAX_TEXT_CHARS, so no markdown paragraph is long enough to stall the review page's quadratic parser.""" + if isinstance(value, str): + if len(value) <= MAX_TEXT_CHARS: + return [] + message = f"text is {len(value)} characters; the review page renders at most {MAX_TEXT_CHARS} per field, so shorten or split it" + return [(path or "/", message)] + if isinstance(value, dict): + children = value.items() + elif isinstance(value, list): + children = enumerate(value) + else: + return [] + return [error for key, child in children for error in oversized_text_errors(child, pointer(path, key))] + + def validate_logic_dispatch(logic: Any) -> Tuple[List[Tuple[str, str]], List[str]]: """Validate logic.json against the schema for its declared schemaVersion.""" v = Validator() @@ -1427,9 +1584,11 @@ def validate_logic_dispatch(logic: Any) -> Tuple[List[Tuple[str, str]], List[str version = root.get("schemaVersion") if not v.exact_version(version, "/schemaVersion", (1, 2)): return v.errors, [] + oversized = oversized_text_errors(root) if version == 1: - return validate_logic(logic), [] - return validate_logic_v2(logic) + return validate_logic(logic) + oversized, [] + errors, warnings = validate_logic_v2(logic) + return errors + oversized, warnings def validate_export_shape(export: Any) -> List[Tuple[str, str]]: @@ -1995,6 +2154,7 @@ def _reject_json_constant(constant: str) -> Any: JSON_MAX_DEPTH = 200 +MAX_TEXT_CHARS = 10_000 def _json_depth_exceeded(value: Any, limit: int = JSON_MAX_DEPTH) -> Optional[str]: @@ -3090,6 +3250,8 @@ def render_markdown_v2(logic: dict) -> str: lines.append(" - **Then** " + scenario["then"]) if scenario["alternatives"]: lines.append(" - **Alternatives** " + "; ".join(scenario["alternatives"])) + if "example" in scenario: + lines.extend(_example_markdown_lines(scenario["example"])) lines.append(" - " + scenario["explanation"]) if scenario.get("dropReason"): lines.append(" - **Dropped:** " + scenario["dropReason"]) @@ -3254,6 +3416,11 @@ def write_files_atomically(files: Dict[Path, str]) -> None: pass +def open_asks_by_scenario(logic: dict) -> Dict[str, List[str]]: + asks = {scenario["id"]: scenario_open_asks(scenario) for scenario in logic.get("scenarios", [])} + return {ident: questions for ident, questions in asks.items() if questions} + + def prepare_render_files( spec_dir: Path, logic: dict, @@ -3277,6 +3444,7 @@ def prepare_render_files( "renderId": current_render_id, "itemHashes": item_hashes(logic), "logicMarkdown": markdown, + "openAsks": open_asks_by_scenario(logic), "priorDecisions": prior, } if comparison is not None and comparison_id is not None: @@ -3572,6 +3740,19 @@ def stage1_gate_failures(logic: dict, state: dict) -> List[Tuple[str, str]]: detail.append("pending scenario requests: " + ", ".join(request["id"] for request in pending_requests)) failures.append(("scenarios", "; ".join(detail))) + for scenario in logic["scenarios"]: + ident = scenario["id"] + if not state["scenarioApproved"].get(ident, False): + continue + if ident not in state["scenarioDrops"] and scenario_open_asks(scenario): + failures.append(("examples", "open [ASK] in " + ident)) + continue + choice = state["scenarioOutcomes"].get(ident, {}).get("choice") + if "example" in scenario and choice is not None and choice != "spec": + failures.append( + ("examples", "outcome changed on " + ident + "; Claude must rewrite its example first") + ) + missing_constraints = [] for constraint in logic["constraints"]: ruling = state["constraintRulings"].get(constraint["id"]) diff --git a/skills/writing-specs/interactive/review-template.html b/skills/writing-specs/interactive/review-template.html index f022ac38..766f4744 100644 --- a/skills/writing-specs/interactive/review-template.html +++ b/skills/writing-specs/interactive/review-template.html @@ -5,20 +5,20 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="color-scheme" content="light dark" /> <title>Interactive logic spec review - - diff --git a/skills/writing-specs/logic-spec.md b/skills/writing-specs/logic-spec.md index ddc9cc39..cf2e467b 100644 --- a/skills/writing-specs/logic-spec.md +++ b/skills/writing-specs/logic-spec.md @@ -17,6 +17,7 @@ the user read and signed off on. It is never silently overridden downstream. & non-goals, glossary — precise architecture, components, error handling, and testing belong in the tech spec authored later at execution when warranted (see [tech-spec.md](tech-spec.md)); name file-level structure here only when it is itself the user-facing decision +- Give each scenario with something to show an example per [scenario-examples.md](scenario-examples.md): Data before, Walkthrough and Data after, a [details section](scenario-examples.md#details-sections), or both, written in the same pass as the scenario - Include these sections, in addition to the coverage above: - **Decisions Locked** — the gray-area decisions confirmed during drill-in (one bullet per locked decision, grouped by area) - **Industry Insights** — distilled key findings from research agents, with source URLs; mark "(offline mode — validation pending)" if research was skipped @@ -34,6 +35,7 @@ After writing the logic spec, look at it with fresh eyes: 2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions? 3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition? 4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit. +5. **Example check:** Does every example agree with its Then, and is each open `[ASK]` a question the design didn't already answer? See [scenario-examples.md](scenario-examples.md#self-review-check). Fix any issues inline. No need to re-review — just fix and move on. @@ -43,6 +45,8 @@ After the spec review loop passes, ask the user to review the written spec befor > "Logic spec written and committed to ``. Please review it and let me know if you want to make any changes before we move to implementation — for larger work, the execution skill may first author a tech spec from it." +List every open `[ASK]` with its scenario when you present the spec. Each answer goes into that scenario's Then (or becomes a new scenario) before approval, and you rewrite the example to match; see [scenario-examples.md](scenario-examples.md#keeping-an-example-in-sync). + Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves. diff --git a/skills/writing-specs/scenario-examples.md b/skills/writing-specs/scenario-examples.md new file mode 100644 index 00000000..45fa9f3e --- /dev/null +++ b/skills/writing-specs/scenario-examples.md @@ -0,0 +1,99 @@ +# Scenario Examples + +The shared conventions for the optional example on a logic-spec scenario: the data-state parts and the details section. Both rubrics, [logic-spec.md](logic-spec.md) and [interactive-logic-spec.md](interactive-logic-spec.md), follow this one copy. It follows the approved [Conventions](../../docs/quirk/specs/2026-10-12-scenario-data-states/logic.md#conventions), [What an example is](../../docs/quirk/specs/2026-10-12-scenario-data-states/logic.md#what-an-example-is), [5](../../docs/quirk/specs/2026-10-12-scenario-data-states/logic.md#5-an-example-changes-with-its-scenario), and [8](../../docs/quirk/specs/2026-10-12-scenario-data-states/logic.md#8-later-stages-take-their-contract-from-the-then) contracts. + +## What an example is + +An example is an optional part of one scenario that shows its data, and the Then stays the contract. The data-state form has three parts: + +- **Data before** lists the records the scenario touches, with only the values that matter to it. +- **Walkthrough** gives numbered steps from the When. Each step says who acts and shows the intermediate value. +- **Data after** shows the same records once the scenario finishes. It is the Then written as data. + +The example is a checked restatement of the Then. Approving a scenario means the reviewer checked that the two agree, and when they disagree the Then stands and the example is fixed. Anything the example needs that the Then doesn't settle becomes a gap (`[ASK]`) for the reviewer, so an example never quietly sets policy. + +An example can also carry a [details section](#details-sections) for an illustration that isn't records changing. + +## When to write one + +Write an example wherever the scenario has something to show, in the same pass as the scenario itself. A scenario with nothing to show carries none: neither records nor any other concrete illustration (a naming rule, a placement rule). Request/response scenarios (API shape, webhooks, CLI output) keep the same three labels, never a details section (the section is for what the three labels can't hold): the walkthrough is the request or command, and Data after is the response or output. + +## Details sections + +Some things a scenario shows aren't records changing: screen states, message copy, a timeline, a decision table. Give those a details section, one titled block of markdown on the example. + +- Write a details section alone when the illustration has no before and after. +- Write both when the scenario changes records and also has something else to show, such as the message the customer sees. The three data-state parts stay together; the details section sits beside them. +- The title is required. It is one line of plain text that names the kind of illustration, such as "Screen states" or "Customer email". It never reuses a data-state label (Data before, Walkthrough, Data after), and it avoids `*`, `_`, backticks, square brackets and angle brackets. +- The body can hold tables, lists and code blocks, so use them where they fit. +- The same marks apply in the body, except inside code, where text stays plain. Still no single-asterisk emphasis. +- Everything above about the Then, gaps and sync holds for the details section too. + +## Conventions + +The marks (`✓`, `✗`, `*`, `[ASK]`, `(a)`) are defined under [Mark syntax](#mark-syntax). + +- Records are named in full and in bold: **Subscription**, **Charge 2 (queued)**, **Discount definition**. +- Lines are named after a product ("Coffee line"), never "line A". +- A title names a kind of thing ("Loyalty", "Welcome") and is reused for the same kind across scenarios. Each example states the values it needs, so one title can carry different values in different scenarios. There is no spec-level cast list. +- Every number has a label: `"Winback": $90.00 × 0.85 = $76.50`, `"Loyalty" took $10.00`. +- Steps say who acts: "The customer skips Charge 2", "An API client tries to add…". +- Product names and titles are always illustrative. +- Plain words only. Field and column names belong to the tech spec. + +## Mark syntax + +The marks are plain text in the source. The review page colours them, and `logic.md` keeps them as written. + +- `**bold**` marks a record. +- `✓` marks a result and `✗` marks a rejection. Each mark covers the phrase up to the next `;` or the end of the line. +- A trailing `*` marks an invented value, picked to make the example concrete and not part of the rule, as in `$100.00*`. +- `[ASK: ]` marks a gap: something the scenario doesn't settle that the example can't show without guessing. Write `ASK` capitalised, with a colon and a space before the question, and the question cannot contain square brackets. The page and the renderer reject any other spelling as a malformed marker. +- `(a)` and `(b)` label branches when one scenario covers several outcomes. + +The review page colours the marks like this: records are blue and bold, step numbers are purple, `✓` phrases are green and bold, `✗` phrases are red, `*` values are grey italic, and `[ASK]` is amber and underlined. The Data before pane has a grey background and Data after a green one. In `logic.md` the marks stay plain text. + +**Never use single-asterisk emphasis inside an example.** A trailing `*` must never pair with an opener, so use plain words for emphasis. + +## Where an example lives + +In `logic.json`, an example is three typed parts on the scenario's `example` field, holding marked-up markdown: a `before` string, a `steps` list with one string per step, and an `after` string. A details section is a `details` object on the same field, holding a `title` string and a `body` string of markdown. In a markdown spec, and in the `logic.md` the renderer generates, the same text appears as three labelled sub-bullets under the scenario: + +```markdown +1. **SC-15** + - **Given** a $100 line with a 10% and a 15% line discount + - **When** the charge is calculated + - **Then** the line nets $76.50 + - **Data before:** **Charge 1**: Coffee line $100.00; "Loyalty" 10% off; "Winback" 15% off + - **Walkthrough:** + 1. "Loyalty": $100.00 × 0.90 = $90.00 + 2. "Winback": $90.00 × 0.85 = $76.50 + - **Data after:** **Charge 1**: ✓ Coffee line $76.50; "Loyalty" took $10.00; "Winback" took $13.50 +``` + +A line break inside one data-state part ends the line with two trailing spaces, because a lone newline collapses to a space on render. + +A details section is one more sub-bullet after Data after, labelled with its bold title. The body is indented 5 spaces, with an empty line before and after, so tables and code blocks render: + +```markdown + - **Screen states:** + + | Element | Shown | + |---|---| + | Retry banner | ✓ "Your last payment failed. Update your card." | + | Skip button | ✗ hidden | +``` + +Newlines in the body are ordinary markdown, so the two-space rule doesn't apply there. + +## Keeping an example in sync + +An example changes in the same edit as its scenario. When an edit rewrites the Then, whether it answers an `[ASK]`, takes a reviewer's update request, or lands as a post-approval amendment, rewrite the example in that edit too. The rewrite removes the `[ASK]` it answered. A Then and an example that were edited separately drift apart, and the reviewer approved the pair. + +## Self-review check + +Before the review gate, check that every example agrees with its Then, and that each open `[ASK]` is a question the design didn't already answer. When the design already answers it, put the answer in the Then and rewrite the example, so a reviewer is only asked what is genuinely open. + +## Downstream stages + +Requirements derive from the scenario text alone, and a value marked `*` is never an expected value. Claude ignores the example when it derives requirements, so a fix to an example after approval goes through the scenario's own amendment or reopen path. diff --git a/tests/fixtures/interactive/golden/stage1/payload.json b/tests/fixtures/interactive/golden/stage1/payload.json index c106b79e..b0beb06d 100644 --- a/tests/fixtures/interactive/golden/stage1/payload.json +++ b/tests/fixtures/interactive/golden/stage1/payload.json @@ -1 +1 @@ -{"spec":{"schemaVersion":2,"stage":1,"title":"Local archive workflow","status":"Draft","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries use forward slashes for paths.","provenance":"you-recommended","question":"Should paths use a single separator style?"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run."}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low"}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"]}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[],"conflicts":[]},"slug":"stage1","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Draft\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | pending |\n| CON-02 | naming | Index entries use forward slashes for paths. | pending |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | pending |\n| CON-04 | verification | A destination permission check runs before writing begins. | pending |\n\n## Requirements\n\nDerived after stage 1 is signed.\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","priorDecisions":null} \ No newline at end of file +{"spec":{"schemaVersion":2,"stage":1,"title":"Local archive workflow","status":"Draft","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries use forward slashes for paths.","provenance":"you-recommended","question":"Should paths use a single separator style?"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run."}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low"}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"]}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[],"conflicts":[]},"slug":"stage1","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Draft\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | pending |\n| CON-02 | naming | Index entries use forward slashes for paths. | pending |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | pending |\n| CON-04 | verification | A destination permission check runs before writing begins. | pending |\n\n## Requirements\n\nDerived after stage 1 is signed.\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","openAsks":{},"priorDecisions":null} \ No newline at end of file diff --git a/tests/fixtures/interactive/golden/stage2-signed/payload.json b/tests/fixtures/interactive/golden/stage2-signed/payload.json index eace1bea..addc63a6 100644 --- a/tests/fixtures/interactive/golden/stage2-signed/payload.json +++ b/tests/fixtures/interactive/golden/stage2-signed/payload.json @@ -1 +1 @@ -{"spec":{"schemaVersion":2,"stage":2,"title":"Local archive workflow","status":"Approved","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?","ruling":"approved"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries always use forward slashes for paths, even on Windows.","provenance":"you-recommended","question":"Should paths use a single separator style?","originalText":"Index entries use forward slashes for paths.","ruling":"rewritten"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?","ruling":"approved"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run.","ruling":"approved"}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low","ruling":"build-on","rulingNote":"Confirmed elsewhere; no additional check needed."}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"],"acceptance":"Atomic rename mitigates this; accepted without a resolving item."}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[{"id":"REQ-01","area":"Archive","text":"Write a compressed archive of the selected files.","summary":"Create a portable archive.","detail":"Preserve relative paths so the archive is portable across machines.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-01","SC-03"]},{"id":"REQ-02","area":"Archive","text":"Report unreadable source files by name instead of failing the whole archive.","summary":"Report unreadable sources.","detail":"A destination permission check runs first, then per-file read failures are reported by name.","scope":"in","group":"i3","certainty":"assumed","dependsOn":["REQ-01"],"derivedFrom":["SC-02","CON-04"]},{"id":"REQ-03","area":"Archive","text":"Remove a partial archive when the destination runs out of space mid-write.","summary":"Clean up failed writes.","detail":"A half-written archive is removed rather than left behind for the reviewer to find.","scope":"in","condition":"under-10","group":"i2","certainty":"unverified","dependsOn":[],"derivedFrom":["SC-04"]},{"id":"REQ-04","area":"Selection","text":"Archive only the files the user explicitly selected.","summary":"Selection stays explicit.","detail":"The archive never includes a file the reviewer did not pick, and output is written beside the source directory.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-05","CON-01"]},{"id":"REQ-05","area":"Archive","text":"Use forward slashes for index paths, even on Windows.","summary":"Normalize index path separators.","detail":"A single separator style keeps the index portable across platforms.","scope":"out","group":"i3","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-02"],"reviewReason":"Overlaps with REQ-03; keep only one enforced limit."},{"id":"OOS-01","area":"Sync","text":"Synchronize archives to a remote service.","summary":"No remote sync.","detail":"Remote synchronization would need accounts and conflict handling, which is out of scope.","scope":"out","group":"i1","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-03"]}],"conflicts":[["REQ-03","REQ-05"]],"stage1Pin":{"signedAt":"2026-09-23T16:00:00Z","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"}},"views":{"stateMachine":{"states":[{"id":"ST-READY","label":"Ready","detail":"Inputs are selected and the destination is writable.","focal":true},{"id":"ST-ARCHIVED","label":"Archived","detail":"The archive and index were written."},{"id":"ST-FAILED","label":"Failed","detail":"The write stopped and the partial archive was removed."}],"entries":[{"id":"ENTRY-START","label":"User starts"}],"transitions":[{"id":"TR-SELECT","from":"ENTRY-START","to":"ST-READY","short":"select","event":"The user selects files to archive.","reqs":["REQ-04"]},{"id":"TR-WRITE","from":"ST-READY","to":"ST-ARCHIVED","short":"write archive","event":"The application writes the archive and index.","reqs":["REQ-01","REQ-02"]},{"id":"TR-ABORT","from":"ST-READY","to":"ST-FAILED","short":"abort write","event":"The destination runs out of space mid-write.","reqs":["REQ-03"]}]}},"signoff":{"signedAt":"2026-09-23T17:00:00Z","renderId":"04a0331ca3ac"}},"slug":"stage2","renderId":"0fe7350eeb9d","itemHashes":{"REQ-01":"2e7c6fe8ef591ec795102e56ab78bcf592dc69cb224d9d5f2f5eebb0053fad1e","REQ-02":"36906144ab80ead9ef3015dd65c3a9168ab23c07e0481f27cc5d2ca51954cc5b","REQ-03":"83321629fe8ee71eb6077339b5557cbb0e60e5e2fccbb370459d3dd71ceb0839","REQ-04":"29454520acec320ae9414fab6b2763cae281e9572585467ebd9303587e5b93d4","REQ-05":"c41e977a11205fc24ad1e64ca27c046059851bb7701ca78a992416429d69fd2e","OOS-01":"27b79eeadce3a2cfd1a983c9677b60e9034b1f7ced3ff0fa5ae3d9c80c14106a","ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Approved\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | approved |\n| CON-02 | naming | Index entries always use forward slashes for paths, even on Windows. | rewritten |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | approved |\n| CON-04 | verification | A destination permission check runs before writing begins. | approved |\n\n## Requirements\n\n### In scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-01 | Archive | Write a compressed archive of the selected files. | Create a portable archive. | Preserve relative paths so the archive is portable across machines. | min | SC-01, SC-03 |\n| REQ-02 | Archive | Report unreadable source files by name instead of failing the whole archive. | Report unreadable sources. | A destination permission check runs first, then per-file read failures are reported by name. | i3 | SC-02, CON-04 |\n| REQ-04 | Selection | Archive only the files the user explicitly selected. | Selection stays explicit. | The archive never includes a file the reviewer did not pick, and output is written beside the source directory. | min | SC-05, CON-01 |\n\n### Conditionally in scope\n\n| ID | Area | Condition | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|---|\n| REQ-03 | Archive | under-10 | Remove a partial archive when the destination runs out of space mid-write. | Clean up failed writes. | A half-written archive is removed rather than left behind for the reviewer to find. | i2 | SC-04 |\n\n### Out of scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-05 | Archive | Use forward slashes for index paths, even on Windows. | Normalize index path separators. | A single separator style keeps the index portable across platforms. | i3 | CON-02 |\n| OOS-01 | Sync | Synchronize archives to a remote service. | No remote sync. | Remote synchronization would need accounts and conflict handling, which is out of scope. | i1 | CON-03 |\n\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n - Reviewer ruling: build-on — Confirmed elsewhere; no additional check needed.\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - Reviewer acceptance: Atomic rename mitigates this; accepted without a resolving item.\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","priorDecisions":null} \ No newline at end of file +{"spec":{"schemaVersion":2,"stage":2,"title":"Local archive workflow","status":"Approved","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?","ruling":"approved"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries always use forward slashes for paths, even on Windows.","provenance":"you-recommended","question":"Should paths use a single separator style?","originalText":"Index entries use forward slashes for paths.","ruling":"rewritten"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?","ruling":"approved"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run.","ruling":"approved"}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low","ruling":"build-on","rulingNote":"Confirmed elsewhere; no additional check needed."}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"],"acceptance":"Atomic rename mitigates this; accepted without a resolving item."}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[{"id":"REQ-01","area":"Archive","text":"Write a compressed archive of the selected files.","summary":"Create a portable archive.","detail":"Preserve relative paths so the archive is portable across machines.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-01","SC-03"]},{"id":"REQ-02","area":"Archive","text":"Report unreadable source files by name instead of failing the whole archive.","summary":"Report unreadable sources.","detail":"A destination permission check runs first, then per-file read failures are reported by name.","scope":"in","group":"i3","certainty":"assumed","dependsOn":["REQ-01"],"derivedFrom":["SC-02","CON-04"]},{"id":"REQ-03","area":"Archive","text":"Remove a partial archive when the destination runs out of space mid-write.","summary":"Clean up failed writes.","detail":"A half-written archive is removed rather than left behind for the reviewer to find.","scope":"in","condition":"under-10","group":"i2","certainty":"unverified","dependsOn":[],"derivedFrom":["SC-04"]},{"id":"REQ-04","area":"Selection","text":"Archive only the files the user explicitly selected.","summary":"Selection stays explicit.","detail":"The archive never includes a file the reviewer did not pick, and output is written beside the source directory.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-05","CON-01"]},{"id":"REQ-05","area":"Archive","text":"Use forward slashes for index paths, even on Windows.","summary":"Normalize index path separators.","detail":"A single separator style keeps the index portable across platforms.","scope":"out","group":"i3","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-02"],"reviewReason":"Overlaps with REQ-03; keep only one enforced limit."},{"id":"OOS-01","area":"Sync","text":"Synchronize archives to a remote service.","summary":"No remote sync.","detail":"Remote synchronization would need accounts and conflict handling, which is out of scope.","scope":"out","group":"i1","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-03"]}],"conflicts":[["REQ-03","REQ-05"]],"stage1Pin":{"signedAt":"2026-09-23T16:00:00Z","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"}},"views":{"stateMachine":{"states":[{"id":"ST-READY","label":"Ready","detail":"Inputs are selected and the destination is writable.","focal":true},{"id":"ST-ARCHIVED","label":"Archived","detail":"The archive and index were written."},{"id":"ST-FAILED","label":"Failed","detail":"The write stopped and the partial archive was removed."}],"entries":[{"id":"ENTRY-START","label":"User starts"}],"transitions":[{"id":"TR-SELECT","from":"ENTRY-START","to":"ST-READY","short":"select","event":"The user selects files to archive.","reqs":["REQ-04"]},{"id":"TR-WRITE","from":"ST-READY","to":"ST-ARCHIVED","short":"write archive","event":"The application writes the archive and index.","reqs":["REQ-01","REQ-02"]},{"id":"TR-ABORT","from":"ST-READY","to":"ST-FAILED","short":"abort write","event":"The destination runs out of space mid-write.","reqs":["REQ-03"]}]}},"signoff":{"signedAt":"2026-09-23T17:00:00Z","renderId":"04a0331ca3ac"}},"slug":"stage2","renderId":"0fe7350eeb9d","itemHashes":{"REQ-01":"2e7c6fe8ef591ec795102e56ab78bcf592dc69cb224d9d5f2f5eebb0053fad1e","REQ-02":"36906144ab80ead9ef3015dd65c3a9168ab23c07e0481f27cc5d2ca51954cc5b","REQ-03":"83321629fe8ee71eb6077339b5557cbb0e60e5e2fccbb370459d3dd71ceb0839","REQ-04":"29454520acec320ae9414fab6b2763cae281e9572585467ebd9303587e5b93d4","REQ-05":"c41e977a11205fc24ad1e64ca27c046059851bb7701ca78a992416429d69fd2e","OOS-01":"27b79eeadce3a2cfd1a983c9677b60e9034b1f7ced3ff0fa5ae3d9c80c14106a","ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Approved\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | approved |\n| CON-02 | naming | Index entries always use forward slashes for paths, even on Windows. | rewritten |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | approved |\n| CON-04 | verification | A destination permission check runs before writing begins. | approved |\n\n## Requirements\n\n### In scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-01 | Archive | Write a compressed archive of the selected files. | Create a portable archive. | Preserve relative paths so the archive is portable across machines. | min | SC-01, SC-03 |\n| REQ-02 | Archive | Report unreadable source files by name instead of failing the whole archive. | Report unreadable sources. | A destination permission check runs first, then per-file read failures are reported by name. | i3 | SC-02, CON-04 |\n| REQ-04 | Selection | Archive only the files the user explicitly selected. | Selection stays explicit. | The archive never includes a file the reviewer did not pick, and output is written beside the source directory. | min | SC-05, CON-01 |\n\n### Conditionally in scope\n\n| ID | Area | Condition | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|---|\n| REQ-03 | Archive | under-10 | Remove a partial archive when the destination runs out of space mid-write. | Clean up failed writes. | A half-written archive is removed rather than left behind for the reviewer to find. | i2 | SC-04 |\n\n### Out of scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-05 | Archive | Use forward slashes for index paths, even on Windows. | Normalize index path separators. | A single separator style keeps the index portable across platforms. | i3 | CON-02 |\n| OOS-01 | Sync | Synchronize archives to a remote service. | No remote sync. | Remote synchronization would need accounts and conflict handling, which is out of scope. | i1 | CON-03 |\n\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n - Reviewer ruling: build-on — Confirmed elsewhere; no additional check needed.\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - Reviewer acceptance: Atomic rename mitigates this; accepted without a resolving item.\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","openAsks":{},"priorDecisions":null} \ No newline at end of file diff --git a/tests/fixtures/interactive/golden/stage2/payload.json b/tests/fixtures/interactive/golden/stage2/payload.json index d50e70dc..35f76172 100644 --- a/tests/fixtures/interactive/golden/stage2/payload.json +++ b/tests/fixtures/interactive/golden/stage2/payload.json @@ -1 +1 @@ -{"spec":{"schemaVersion":2,"stage":2,"title":"Local archive workflow","status":"Stage 1 approved","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?","ruling":"approved"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries always use forward slashes for paths, even on Windows.","provenance":"you-recommended","question":"Should paths use a single separator style?","originalText":"Index entries use forward slashes for paths.","ruling":"rewritten"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?","ruling":"approved"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run.","ruling":"approved"}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low","ruling":"build-on","rulingNote":"Confirmed elsewhere; no additional check needed."}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"],"acceptance":"Atomic rename mitigates this; accepted without a resolving item."}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[{"id":"REQ-01","area":"Archive","text":"Write a compressed archive of the selected files.","summary":"Create a portable archive.","detail":"Preserve relative paths so the archive is portable across machines.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-01","SC-03"]},{"id":"REQ-02","area":"Archive","text":"Report unreadable source files by name instead of failing the whole archive.","summary":"Report unreadable sources.","detail":"A destination permission check runs first, then per-file read failures are reported by name.","scope":"in","group":"i3","certainty":"assumed","dependsOn":["REQ-01"],"derivedFrom":["SC-02","CON-04"]},{"id":"REQ-03","area":"Archive","text":"Remove a partial archive when the destination runs out of space mid-write.","summary":"Clean up failed writes.","detail":"A half-written archive is removed rather than left behind for the reviewer to find.","scope":"in","condition":"under-10","group":"i2","certainty":"unverified","dependsOn":[],"derivedFrom":["SC-04"]},{"id":"REQ-04","area":"Selection","text":"Archive only the files the user explicitly selected.","summary":"Selection stays explicit.","detail":"The archive never includes a file the reviewer did not pick, and output is written beside the source directory.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-05","CON-01"]},{"id":"REQ-05","area":"Archive","text":"Use forward slashes for index paths, even on Windows.","summary":"Normalize index path separators.","detail":"A single separator style keeps the index portable across platforms.","scope":"in","group":"i3","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-02"]},{"id":"OOS-01","area":"Sync","text":"Synchronize archives to a remote service.","summary":"No remote sync.","detail":"Remote synchronization would need accounts and conflict handling, which is out of scope.","scope":"out","group":"i1","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-03"]}],"conflicts":[["REQ-03","REQ-05"]],"stage1Pin":{"signedAt":"2026-09-23T16:00:00Z","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"}},"views":{"stateMachine":{"states":[{"id":"ST-READY","label":"Ready","detail":"Inputs are selected and the destination is writable.","focal":true},{"id":"ST-ARCHIVED","label":"Archived","detail":"The archive and index were written."},{"id":"ST-FAILED","label":"Failed","detail":"The write stopped and the partial archive was removed."}],"entries":[{"id":"ENTRY-START","label":"User starts"}],"transitions":[{"id":"TR-SELECT","from":"ENTRY-START","to":"ST-READY","short":"select","event":"The user selects files to archive.","reqs":["REQ-04"]},{"id":"TR-WRITE","from":"ST-READY","to":"ST-ARCHIVED","short":"write archive","event":"The application writes the archive and index.","reqs":["REQ-01","REQ-02"]},{"id":"TR-ABORT","from":"ST-READY","to":"ST-FAILED","short":"abort write","event":"The destination runs out of space mid-write.","reqs":["REQ-03"]}]}}},"slug":"stage2","renderId":"04a0331ca3ac","itemHashes":{"REQ-01":"2e7c6fe8ef591ec795102e56ab78bcf592dc69cb224d9d5f2f5eebb0053fad1e","REQ-02":"36906144ab80ead9ef3015dd65c3a9168ab23c07e0481f27cc5d2ca51954cc5b","REQ-03":"83321629fe8ee71eb6077339b5557cbb0e60e5e2fccbb370459d3dd71ceb0839","REQ-04":"29454520acec320ae9414fab6b2763cae281e9572585467ebd9303587e5b93d4","REQ-05":"fcc9ca9001102f2177f76836813916d0d356b333b9ad33a79496af446c59d6e8","OOS-01":"27b79eeadce3a2cfd1a983c9677b60e9034b1f7ced3ff0fa5ae3d9c80c14106a","ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Stage 1 approved\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | approved |\n| CON-02 | naming | Index entries always use forward slashes for paths, even on Windows. | rewritten |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | approved |\n| CON-04 | verification | A destination permission check runs before writing begins. | approved |\n\n## Requirements\n\n### In scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-01 | Archive | Write a compressed archive of the selected files. | Create a portable archive. | Preserve relative paths so the archive is portable across machines. | min | SC-01, SC-03 |\n| REQ-02 | Archive | Report unreadable source files by name instead of failing the whole archive. | Report unreadable sources. | A destination permission check runs first, then per-file read failures are reported by name. | i3 | SC-02, CON-04 |\n| REQ-04 | Selection | Archive only the files the user explicitly selected. | Selection stays explicit. | The archive never includes a file the reviewer did not pick, and output is written beside the source directory. | min | SC-05, CON-01 |\n| REQ-05 | Archive | Use forward slashes for index paths, even on Windows. | Normalize index path separators. | A single separator style keeps the index portable across platforms. | i3 | CON-02 |\n\n### Conditionally in scope\n\n| ID | Area | Condition | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|---|\n| REQ-03 | Archive | under-10 | Remove a partial archive when the destination runs out of space mid-write. | Clean up failed writes. | A half-written archive is removed rather than left behind for the reviewer to find. | i2 | SC-04 |\n\n### Out of scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| OOS-01 | Sync | Synchronize archives to a remote service. | No remote sync. | Remote synchronization would need accounts and conflict handling, which is out of scope. | i1 | CON-03 |\n\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n - Reviewer ruling: build-on — Confirmed elsewhere; no additional check needed.\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - Reviewer acceptance: Atomic rename mitigates this; accepted without a resolving item.\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","priorDecisions":null} \ No newline at end of file +{"spec":{"schemaVersion":2,"stage":2,"title":"Local archive workflow","status":"Stage 1 approved","amendments":[],"sections":{"purpose":"Describe a small, local-first archive workflow.","conceptualModel":"An archive groups selected source files with a compact index.","dataFlow":"The reviewer chooses sources, then the tool writes the archive and index.","decisionsLocked":[{"area":"Storage","decision":"The archive is written beside the selected source directory."}],"industryInsights":[{"finding":"A portable index reduces dependence on one viewer.","sources":["https://example.test/portable-index"]}],"scopeNonGoals":["Remote synchronization is not part of this change."],"deferredIdeas":[{"text":"Add archive signing after the local format stabilizes.","techSpec":true}],"glossary":[{"term":"Index","definition":"A compact listing of archive contents."}]},"behaviors":[{"id":"BH-01","rule":"Archiving writes a portable index."},{"id":"BH-02","rule":"Selecting sources is explicit."}],"scenarios":[{"id":"SC-01","behavior":"BH-01","given":"The user has selected readable files.","when":"They create an archive.","then":"An archive and compact index are written locally.","alternatives":[],"explanation":"The index makes the result inspectable without the application.","provenance":"you-chose","question":"Should archiving always write an index?"},{"id":"SC-02","behavior":"BH-01","given":"A source file becomes unreadable after selection.","when":"The archive is written.","then":"The archive completes and reports the unreadable file by name.","alternatives":[],"explanation":"Partial failures should not silently drop content.","provenance":"claude","rationale":"Unreadable sources are a common failure mode worth covering explicitly."},{"id":"SC-03","behavior":"BH-01","given":"The user archives a large selection.","when":"They choose the default settings.","then":"An archive and index are written.","alternatives":["Skip creating an index file."],"explanation":"The default keeps the index unless the reviewer says otherwise.","provenance":"you-chose","question":"Should the index be skippable?"},{"id":"SC-04","behavior":"BH-01","given":"The destination runs out of space mid-write.","when":"The archive is being written.","then":"The write stops and the partial archive is removed.","alternatives":[],"explanation":"A half-written archive is worse than no archive.","provenance":"you-recommended","question":"Should a failed write clean up after itself?","extraReason":"Covers a known failure mode distinct from the other three scenarios."},{"id":"SC-05","behavior":"BH-02","given":"The user opens the source picker.","when":"They select files to archive.","then":"Only explicitly selected files are archived.","alternatives":[],"explanation":"Selection stays explicit and reviewable before writing begins.","provenance":"you-chose","question":"Should selection remain fully explicit?"}],"constraints":[{"id":"CON-01","area":"Archive","kind":"placement","text":"Archive output goes beside the selected source directory.","provenance":"you-chose","question":"Where should the archive be written?","ruling":"approved"},{"id":"CON-02","area":"Archive","kind":"naming","text":"Index entries always use forward slashes for paths, even on Windows.","provenance":"you-recommended","question":"Should paths use a single separator style?","originalText":"Index entries use forward slashes for paths.","ruling":"rewritten"},{"id":"CON-03","area":"Sync","kind":"non-goal","text":"Remote synchronization of archives is out of scope.","provenance":"you-chose","question":"Should remote sync be included?","ruling":"approved"},{"id":"CON-04","area":"Archive","kind":"verification","text":"A destination permission check runs before writing begins.","provenance":"claude","rationale":"Prevents partial writes when the destination becomes read-only mid-run.","ruling":"approved"}],"assumptions":[{"id":"ASM-01","claim":"Selected inputs are readable when writing starts.","basis":"The workflow selects files before creating the archive.","certainty":"assumed","ifWrong":"Unreadable sources need a clear per-file error.","affects":["SC-01","CON-04"],"meaning":"The tool can open every selected source file.","check":"Try a source that becomes unreadable after selection.","checkCost":"Low","ruling":"build-on","rulingNote":"Confirmed elsewhere; no additional check needed."}],"blindSpots":[{"id":"BS-01","title":"Filesystem permissions","detail":"The destination may not be writable even when sources are readable.","sources":["SC-01"],"resolvedBy":"CON-04"},{"id":"BS-02","title":"Partial writes on interruption","detail":"An interrupted write can leave a corrupt archive behind.","sources":["SC-04"],"acceptance":"Atomic rename mitigates this; accepted without a resolving item."}],"research":[{"requestId":"RESEARCH-01","blindSpotId":"BS-02","summary":"Writing to a temporary file and renaming into place avoids partial archives.","detail":"A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.","answeredAt":"2026-09-21T10:30:00Z"}],"requirements":[{"id":"REQ-01","area":"Archive","text":"Write a compressed archive of the selected files.","summary":"Create a portable archive.","detail":"Preserve relative paths so the archive is portable across machines.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-01","SC-03"]},{"id":"REQ-02","area":"Archive","text":"Report unreadable source files by name instead of failing the whole archive.","summary":"Report unreadable sources.","detail":"A destination permission check runs first, then per-file read failures are reported by name.","scope":"in","group":"i3","certainty":"assumed","dependsOn":["REQ-01"],"derivedFrom":["SC-02","CON-04"]},{"id":"REQ-03","area":"Archive","text":"Remove a partial archive when the destination runs out of space mid-write.","summary":"Clean up failed writes.","detail":"A half-written archive is removed rather than left behind for the reviewer to find.","scope":"in","condition":"under-10","group":"i2","certainty":"unverified","dependsOn":[],"derivedFrom":["SC-04"]},{"id":"REQ-04","area":"Selection","text":"Archive only the files the user explicitly selected.","summary":"Selection stays explicit.","detail":"The archive never includes a file the reviewer did not pick, and output is written beside the source directory.","scope":"in","group":"min","certainty":"confirmed","dependsOn":[],"derivedFrom":["SC-05","CON-01"]},{"id":"REQ-05","area":"Archive","text":"Use forward slashes for index paths, even on Windows.","summary":"Normalize index path separators.","detail":"A single separator style keeps the index portable across platforms.","scope":"in","group":"i3","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-02"]},{"id":"OOS-01","area":"Sync","text":"Synchronize archives to a remote service.","summary":"No remote sync.","detail":"Remote synchronization would need accounts and conflict handling, which is out of scope.","scope":"out","group":"i1","certainty":"assumed","dependsOn":[],"derivedFrom":["CON-03"]}],"conflicts":[["REQ-03","REQ-05"]],"stage1Pin":{"signedAt":"2026-09-23T16:00:00Z","renderId":"bfb33ddde6fe","itemHashes":{"ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"}},"views":{"stateMachine":{"states":[{"id":"ST-READY","label":"Ready","detail":"Inputs are selected and the destination is writable.","focal":true},{"id":"ST-ARCHIVED","label":"Archived","detail":"The archive and index were written."},{"id":"ST-FAILED","label":"Failed","detail":"The write stopped and the partial archive was removed."}],"entries":[{"id":"ENTRY-START","label":"User starts"}],"transitions":[{"id":"TR-SELECT","from":"ENTRY-START","to":"ST-READY","short":"select","event":"The user selects files to archive.","reqs":["REQ-04"]},{"id":"TR-WRITE","from":"ST-READY","to":"ST-ARCHIVED","short":"write archive","event":"The application writes the archive and index.","reqs":["REQ-01","REQ-02"]},{"id":"TR-ABORT","from":"ST-READY","to":"ST-FAILED","short":"abort write","event":"The destination runs out of space mid-write.","reqs":["REQ-03"]}]}}},"slug":"stage2","renderId":"04a0331ca3ac","itemHashes":{"REQ-01":"2e7c6fe8ef591ec795102e56ab78bcf592dc69cb224d9d5f2f5eebb0053fad1e","REQ-02":"36906144ab80ead9ef3015dd65c3a9168ab23c07e0481f27cc5d2ca51954cc5b","REQ-03":"83321629fe8ee71eb6077339b5557cbb0e60e5e2fccbb370459d3dd71ceb0839","REQ-04":"29454520acec320ae9414fab6b2763cae281e9572585467ebd9303587e5b93d4","REQ-05":"fcc9ca9001102f2177f76836813916d0d356b333b9ad33a79496af446c59d6e8","OOS-01":"27b79eeadce3a2cfd1a983c9677b60e9034b1f7ced3ff0fa5ae3d9c80c14106a","ASM-01":"d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d","BS-01":"cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05","BS-02":"9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e","SC-01":"f1c6d0a5adc47d589b5f389c02eed8701dcb96a6840e1d7d14443719f5cbc34e","SC-02":"8153c3567100704c40964373757182cc07309149edafee654117f9b2e0b5ed05","SC-03":"f63b88e0bc4524e255e77f506dbab3960124627f503adcd4b5fbf55f58ac1e99","SC-04":"4101f9904c5dde91d5a1b2c31be935a99012a7f476169b22118e15045098e146","SC-05":"a654cd8abc1b093b4e991e34481182eabb6d3972daeabdcead18b01c7841b37b","BH-01":"474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13","BH-02":"e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41","CON-01":"cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42","CON-02":"2b21713470520c5890a5fe62a2182498469496bc07f658c6bd7687e6513b2a0a","CON-03":"9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29","CON-04":"ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5"},"logicMarkdown":"\u003c!-- generated from logic.json by render_spec.py — do not edit -->\n\n# Local archive workflow\n\n## Status & amendments\n\n**Status:** Stage 1 approved\n\n**Amendments:**\nnone\n\n## Purpose\n\nDescribe a small, local-first archive workflow.\n\n## Conceptual model\n\nAn archive groups selected source files with a compact index.\n\n## Data flow\n\nThe reviewer chooses sources, then the tool writes the archive and index.\n\n## Behavior & scenarios\n\n### Archiving writes a portable index.\n\n1. **SC-01**\n - **Given** The user has selected readable files.\n - **When** They create an archive.\n - **Then** An archive and compact index are written locally.\n - The index makes the result inspectable without the application.\n\n2. **SC-02**\n - **Given** A source file becomes unreadable after selection.\n - **When** The archive is written.\n - **Then** The archive completes and reports the unreadable file by name.\n - Partial failures should not silently drop content.\n\n3. **SC-03**\n - **Given** The user archives a large selection.\n - **When** They choose the default settings.\n - **Then** An archive and index are written.\n - **Alternatives** Skip creating an index file.\n - The default keeps the index unless the reviewer says otherwise.\n\n4. **SC-04**\n - **Given** The destination runs out of space mid-write.\n - **When** The archive is being written.\n - **Then** The write stops and the partial archive is removed.\n - A half-written archive is worse than no archive.\n\n### Selecting sources is explicit.\n\n1. **SC-05**\n - **Given** The user opens the source picker.\n - **When** They select files to archive.\n - **Then** Only explicitly selected files are archived.\n - Selection stays explicit and reviewable before writing begins.\n\n## Constraints\n\n| ID | Kind | Text | Ruling |\n|---|---|---|---|\n| CON-01 | placement | Archive output goes beside the selected source directory. | approved |\n| CON-02 | naming | Index entries always use forward slashes for paths, even on Windows. | rewritten |\n| CON-03 | non-goal | Remote synchronization of archives is out of scope. | approved |\n| CON-04 | verification | A destination permission check runs before writing begins. | approved |\n\n## Requirements\n\n### In scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| REQ-01 | Archive | Write a compressed archive of the selected files. | Create a portable archive. | Preserve relative paths so the archive is portable across machines. | min | SC-01, SC-03 |\n| REQ-02 | Archive | Report unreadable source files by name instead of failing the whole archive. | Report unreadable sources. | A destination permission check runs first, then per-file read failures are reported by name. | i3 | SC-02, CON-04 |\n| REQ-04 | Selection | Archive only the files the user explicitly selected. | Selection stays explicit. | The archive never includes a file the reviewer did not pick, and output is written beside the source directory. | min | SC-05, CON-01 |\n| REQ-05 | Archive | Use forward slashes for index paths, even on Windows. | Normalize index path separators. | A single separator style keeps the index portable across platforms. | i3 | CON-02 |\n\n### Conditionally in scope\n\n| ID | Area | Condition | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|---|\n| REQ-03 | Archive | under-10 | Remove a partial archive when the destination runs out of space mid-write. | Clean up failed writes. | A half-written archive is removed rather than left behind for the reviewer to find. | i2 | SC-04 |\n\n### Out of scope\n\n| ID | Area | Requirement | Summary | Detail | Group | Derived from |\n|---|---|---|---|---|---|---|\n| OOS-01 | Sync | Synchronize archives to a remote service. | No remote sync. | Remote synchronization would need accounts and conflict handling, which is out of scope. | i1 | CON-03 |\n\n## Assumptions & blind spots\n\n### Assumptions\n\n- **ASM-01 — Selected inputs are readable when writing starts.** (assumed)\n - Basis: The workflow selects files before creating the archive.\n - If wrong: Unreadable sources need a clear per-file error.\n - Affects: SC-01, CON-04\n - Meaning: The tool can open every selected source file.\n - Check: Try a source that becomes unreadable after selection. (cost: Low)\n - Reviewer ruling: build-on — Confirmed elsewhere; no additional check needed.\n\n### Blind spots\n\n- **BS-01 — Filesystem permissions**\n - The destination may not be writable even when sources are readable.\n - Sources: SC-01\n - Resolved by: CON-04\n- **BS-02 — Partial writes on interruption**\n - An interrupted write can leave a corrupt archive behind.\n - Sources: SC-04\n - Reviewer acceptance: Atomic rename mitigates this; accepted without a resolving item.\n - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.\n\n## Decisions Locked\n\n### Storage\n\n- The archive is written beside the selected source directory.\n\n\n## Industry Insights\n\n- A portable index reduces dependence on one viewer. — https://example.test/portable-index\n\n## Scope & non-goals\n\n- Remote synchronization is not part of this change.\n\n## Deferred Ideas\n\n- [tech-spec] Add archive signing after the local format stabilizes.\n\n## Glossary\n\n| Term | Definition |\n|---|---|\n| Index | A compact listing of archive contents. |\n","openAsks":{},"priorDecisions":null} \ No newline at end of file diff --git a/tests/fixtures/interactive/goldens/v2-stage1.logic.md b/tests/fixtures/interactive/goldens/v2-stage1.logic.md new file mode 100644 index 00000000..caeee6e9 --- /dev/null +++ b/tests/fixtures/interactive/goldens/v2-stage1.logic.md @@ -0,0 +1,118 @@ + + +# Local archive workflow + +## Status & amendments + +**Status:** Draft + +**Amendments:** +none + +## Purpose + +Describe a small, local-first archive workflow. + +## Conceptual model + +An archive groups selected source files with a compact index. + +## Data flow + +The reviewer chooses sources, then the tool writes the archive and index. + +## Behavior & scenarios + +### Archiving writes a portable index. + +1. **SC-01** + - **Given** The user has selected readable files. + - **When** They create an archive. + - **Then** An archive and compact index are written locally. + - The index makes the result inspectable without the application. + +2. **SC-02** + - **Given** A source file becomes unreadable after selection. + - **When** The archive is written. + - **Then** The archive completes and reports the unreadable file by name. + - Partial failures should not silently drop content. + +3. **SC-03** + - **Given** The user archives a large selection. + - **When** They choose the default settings. + - **Then** An archive and index are written. + - **Alternatives** Skip creating an index file. + - The default keeps the index unless the reviewer says otherwise. + +4. **SC-04** + - **Given** The destination runs out of space mid-write. + - **When** The archive is being written. + - **Then** The write stops and the partial archive is removed. + - A half-written archive is worse than no archive. + +### Selecting sources is explicit. + +1. **SC-05** + - **Given** The user opens the source picker. + - **When** They select files to archive. + - **Then** Only explicitly selected files are archived. + - Selection stays explicit and reviewable before writing begins. + +## Constraints + +| ID | Kind | Text | Ruling | +|---|---|---|---| +| CON-01 | placement | Archive output goes beside the selected source directory. | pending | +| CON-02 | naming | Index entries use forward slashes for paths. | pending | +| CON-03 | non-goal | Remote synchronization of archives is out of scope. | pending | +| CON-04 | verification | A destination permission check runs before writing begins. | pending | + +## Requirements + +Derived after stage 1 is signed. +## Assumptions & blind spots + +### Assumptions + +- **ASM-01 — Selected inputs are readable when writing starts.** (assumed) + - Basis: The workflow selects files before creating the archive. + - If wrong: Unreadable sources need a clear per-file error. + - Affects: SC-01, CON-04 + - Meaning: The tool can open every selected source file. + - Check: Try a source that becomes unreadable after selection. (cost: Low) + +### Blind spots + +- **BS-01 — Filesystem permissions** + - The destination may not be writable even when sources are readable. + - Sources: SC-01 + - Resolved by: CON-04 +- **BS-02 — Partial writes on interruption** + - An interrupted write can leave a corrupt archive behind. + - Sources: SC-04 + - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file. + +## Decisions Locked + +### Storage + +- The archive is written beside the selected source directory. + + +## Industry Insights + +- A portable index reduces dependence on one viewer. — https://example.test/portable-index + +## Scope & non-goals + +- Remote synchronization is not part of this change. + +## Deferred Ideas + +- [tech-spec] Add archive signing after the local format stabilizes. + +## Glossary + +| Term | Definition | +|---|---| +| Index | A compact listing of archive contents. | diff --git a/tests/fixtures/interactive/goldens/v2-stage2.logic.md b/tests/fixtures/interactive/goldens/v2-stage2.logic.md new file mode 100644 index 00000000..f8e7c96c --- /dev/null +++ b/tests/fixtures/interactive/goldens/v2-stage2.logic.md @@ -0,0 +1,140 @@ + + +# Local archive workflow + +## Status & amendments + +**Status:** Stage 1 approved + +**Amendments:** +none + +## Purpose + +Describe a small, local-first archive workflow. + +## Conceptual model + +An archive groups selected source files with a compact index. + +## Data flow + +The reviewer chooses sources, then the tool writes the archive and index. + +## Behavior & scenarios + +### Archiving writes a portable index. + +1. **SC-01** + - **Given** The user has selected readable files. + - **When** They create an archive. + - **Then** An archive and compact index are written locally. + - The index makes the result inspectable without the application. + +2. **SC-02** + - **Given** A source file becomes unreadable after selection. + - **When** The archive is written. + - **Then** The archive completes and reports the unreadable file by name. + - Partial failures should not silently drop content. + +3. **SC-03** + - **Given** The user archives a large selection. + - **When** They choose the default settings. + - **Then** An archive and index are written. + - **Alternatives** Skip creating an index file. + - The default keeps the index unless the reviewer says otherwise. + +4. **SC-04** + - **Given** The destination runs out of space mid-write. + - **When** The archive is being written. + - **Then** The write stops and the partial archive is removed. + - A half-written archive is worse than no archive. + +### Selecting sources is explicit. + +1. **SC-05** + - **Given** The user opens the source picker. + - **When** They select files to archive. + - **Then** Only explicitly selected files are archived. + - Selection stays explicit and reviewable before writing begins. + +## Constraints + +| ID | Kind | Text | Ruling | +|---|---|---|---| +| CON-01 | placement | Archive output goes beside the selected source directory. | approved | +| CON-02 | naming | Index entries always use forward slashes for paths, even on Windows. | rewritten | +| CON-03 | non-goal | Remote synchronization of archives is out of scope. | approved | +| CON-04 | verification | A destination permission check runs before writing begins. | approved | + +## Requirements + +### In scope + +| ID | Area | Requirement | Summary | Detail | Group | Derived from | +|---|---|---|---|---|---|---| +| REQ-01 | Archive | Write a compressed archive of the selected files. | Create a portable archive. | Preserve relative paths so the archive is portable across machines. | min | SC-01, SC-03 | +| REQ-02 | Archive | Report unreadable source files by name instead of failing the whole archive. | Report unreadable sources. | A destination permission check runs first, then per-file read failures are reported by name. | i3 | SC-02, CON-04 | +| REQ-04 | Selection | Archive only the files the user explicitly selected. | Selection stays explicit. | The archive never includes a file the reviewer did not pick, and output is written beside the source directory. | min | SC-05, CON-01 | +| REQ-05 | Archive | Use forward slashes for index paths, even on Windows. | Normalize index path separators. | A single separator style keeps the index portable across platforms. | i3 | CON-02 | + +### Conditionally in scope + +| ID | Area | Condition | Requirement | Summary | Detail | Group | Derived from | +|---|---|---|---|---|---|---|---| +| REQ-03 | Archive | under-10 | Remove a partial archive when the destination runs out of space mid-write. | Clean up failed writes. | A half-written archive is removed rather than left behind for the reviewer to find. | i2 | SC-04 | + +### Out of scope + +| ID | Area | Requirement | Summary | Detail | Group | Derived from | +|---|---|---|---|---|---|---| +| OOS-01 | Sync | Synchronize archives to a remote service. | No remote sync. | Remote synchronization would need accounts and conflict handling, which is out of scope. | i1 | CON-03 | + +## Assumptions & blind spots + +### Assumptions + +- **ASM-01 — Selected inputs are readable when writing starts.** (assumed) + - Basis: The workflow selects files before creating the archive. + - If wrong: Unreadable sources need a clear per-file error. + - Affects: SC-01, CON-04 + - Meaning: The tool can open every selected source file. + - Check: Try a source that becomes unreadable after selection. (cost: Low) + - Reviewer ruling: build-on — Confirmed elsewhere; no additional check needed. + +### Blind spots + +- **BS-01 — Filesystem permissions** + - The destination may not be writable even when sources are readable. + - Sources: SC-01 + - Resolved by: CON-04 +- **BS-02 — Partial writes on interruption** + - An interrupted write can leave a corrupt archive behind. + - Sources: SC-04 + - Reviewer acceptance: Atomic rename mitigates this; accepted without a resolving item. + - **Research finding (2026-09-21T10:30:00Z)** Writing to a temporary file and renaming into place avoids partial archives. — A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file. + +## Decisions Locked + +### Storage + +- The archive is written beside the selected source directory. + + +## Industry Insights + +- A portable index reduces dependence on one viewer. — https://example.test/portable-index + +## Scope & non-goals + +- Remote synchronization is not part of this change. + +## Deferred Ideas + +- [tech-spec] Add archive signing after the local format stabilizes. + +## Glossary + +| Term | Definition | +|---|---| +| Index | A compact listing of archive contents. | diff --git a/tests/fixtures/interactive/v2/details/exports/approve-open-ask.json b/tests/fixtures/interactive/v2/details/exports/approve-open-ask.json new file mode 100644 index 00000000..4366cb12 --- /dev/null +++ b/tests/fixtures/interactive/v2/details/exports/approve-open-ask.json @@ -0,0 +1,152 @@ +{ + "kind": "quirk-logic-spec-decisions", + "schemaVersion": 2, + "stage": 1, + "slug": "details", + "renderId": "2cfbe5f04856", + "exportedAt": "2026-09-23T16:00:00Z", + "signed": true, + "seen": { + "ASM-01": "d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d", + "BS-01": "cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05", + "BS-02": "7c35237bf15c1d52e0b7991c85dc6a0a1b7615d9c1d8724b44363565bffdd2c3", + "SC-01": "e47d2fb30271a03e1e758461269e8fc7e82b7edf91d16a7a7c27afec41a6f19a", + "SC-02": "2b8a1907aadda8fbf6d49014bd2d22a11ab9dce7b0b0deead30eec8fe7f925f2", + "SC-03": "05a0389b9bd5359d12d4f5dda091da2d23d33997ed5decbd867f795def4cac47", + "BH-01": "474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13", + "BH-02": "e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41", + "CON-01": "cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42", + "CON-02": "8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17", + "CON-03": "9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29", + "CON-04": "ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5" + }, + "state": { + "stage": 1, + "scenarioOutcomes": {}, + "scenarioApproved": { + "SC-01": true, + "SC-02": true, + "SC-03": true + }, + "scenarioDrops": {}, + "scenarioRequests": [], + "constraintRulings": { + "CON-01": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-02": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-03": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-04": { + "ruling": "approve", + "text": "", + "reason": "" + } + }, + "assumptions": { + "ASM-01": { + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + }, + "blindSpots": { + "BS-02": { + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + }, + "researchRequests": [], + "placements": {}, + "conditions": {}, + "moveReasons": {}, + "notes": {}, + "disputes": {}, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "updatedAt": "2026-09-23T16:00:00Z" + }, + "record": { + "stage": 1, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "scenarios": [ + { + "id": "SC-01", + "approved": true, + "dropped": null, + "then": "Each discount is applied in order and the line nets $76.50.", + "changesSpec": false + }, + { + "id": "SC-02", + "approved": true, + "dropped": null, + "then": "The fourth charge does not include the discount.", + "changesSpec": false + }, + { + "id": "SC-03", + "approved": true, + "dropped": null, + "then": "Only the selected discount is shown.", + "changesSpec": false + } + ], + "constraints": [ + { + "id": "CON-01", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-02", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-03", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-04", + "ruling": "approve", + "text": "", + "reason": "" + } + ], + "assumptions": [ + { + "id": "ASM-01", + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + ], + "blindSpots": [ + { + "id": "BS-02", + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + ], + "placements": [], + "disputes": [], + "scenarioRequests": [], + "researchRequests": [], + "openWarnings": [] + } +} diff --git a/tests/fixtures/interactive/v2/details/logic.json b/tests/fixtures/interactive/v2/details/logic.json new file mode 100644 index 00000000..90706d4e --- /dev/null +++ b/tests/fixtures/interactive/v2/details/logic.json @@ -0,0 +1,186 @@ +{ + "schemaVersion": 2, + "stage": 1, + "title": "Subscription discount details", + "status": "Draft", + "amendments": [], + "sections": { + "purpose": "Describe a small, local-first archive workflow.", + "conceptualModel": "An archive groups selected source files with a compact index.", + "dataFlow": "The reviewer chooses sources, then the tool writes the archive and index.", + "decisionsLocked": [ + { + "area": "Storage", + "decision": "The archive is written beside the selected source directory." + } + ], + "industryInsights": [ + { + "finding": "A portable index reduces dependence on one viewer.", + "sources": [ + "https://example.test/portable-index" + ] + } + ], + "scopeNonGoals": [ + "Remote synchronization is not part of this change." + ], + "deferredIdeas": [ + { + "text": "Add archive signing after the local format stabilizes.", + "techSpec": true + } + ], + "glossary": [ + { + "term": "Index", + "definition": "A compact listing of archive contents." + } + ] + }, + "behaviors": [ + { + "id": "BH-01", + "rule": "Archiving writes a portable index." + }, + { + "id": "BH-02", + "rule": "Selecting sources is explicit." + } + ], + "scenarios": [ + { + "id": "SC-01", + "behavior": "BH-01", + "given": "A subscription with two line discounts that stack.", + "when": "The charge is calculated.", + "then": "Each discount is applied in order and the line nets $76.50.", + "alternatives": [], + "explanation": "Percentage discounts compound instead of adding.", + "provenance": "you-chose", + "question": "Do percentage discounts compound?", + "example": { + "details": { + "title": "Discount order", + "body": "| Order | Discount | Line after |\n| --- | --- | --- |\n| 1 | \"Loyalty\" 10% | $90.00 |\n| 2 | \"Winback\" 15% | $76.50 |\n\n[ASK: does a fixed-amount discount apply before or after the percentage ones?]" + } + } + }, + { + "id": "SC-02", + "behavior": "BH-01", + "given": "A subscription with a line discount limited to the first 3 charges.", + "when": "Three charges have processed and the next one is generated.", + "then": "The fourth charge does not include the discount.", + "alternatives": [], + "explanation": "A used-up discount stops applying.", + "provenance": "claude", + "rationale": "Limited discounts need an explicit end-of-life case.", + "example": { + "before": "**Subscription**: \"Welcome\" 10%* off, first 3 charges; uses left 3", + "steps": [ + "Charge 1 processes → uses left 2", + "Charge 2 processes → uses left 1", + "Charge 3 processes → uses left 0", + "Charge 4 is generated" + ], + "after": "**Charge 4**: Coffee line has no line discounts\n**Subscription**: \"Welcome\" uses left 0", + "details": { + "title": "Discount lifecycle", + "body": "- Uses left starts at 3\n- Each processed charge uses one\n- At 0 the discount stops applying" + } + } + }, + { + "id": "SC-03", + "behavior": "BH-02", + "given": "The user opens the discount list.", + "when": "They select a discount.", + "then": "Only the selected discount is shown.", + "alternatives": [], + "explanation": "Selection stays explicit.", + "provenance": "you-chose", + "question": "Should selection stay explicit?" + } + ], + "constraints": [ + { + "id": "CON-01", + "area": "Archive", + "kind": "placement", + "text": "Archive output goes beside the selected source directory.", + "provenance": "you-chose", + "question": "Where should the archive be written?" + }, + { + "id": "CON-02", + "area": "Archive", + "kind": "naming", + "text": "Index entries use forward slashes for paths.", + "provenance": "you-recommended", + "question": "Should paths use a single separator style?" + }, + { + "id": "CON-03", + "area": "Sync", + "kind": "non-goal", + "text": "Remote synchronization of archives is out of scope.", + "provenance": "you-chose", + "question": "Should remote sync be included?" + }, + { + "id": "CON-04", + "area": "Archive", + "kind": "verification", + "text": "A destination permission check runs before writing begins.", + "provenance": "claude", + "rationale": "Prevents partial writes when the destination becomes read-only mid-run." + } + ], + "assumptions": [ + { + "id": "ASM-01", + "claim": "Selected inputs are readable when writing starts.", + "basis": "The workflow selects files before creating the archive.", + "certainty": "assumed", + "ifWrong": "Unreadable sources need a clear per-file error.", + "affects": [ + "SC-01", + "CON-04" + ], + "meaning": "The tool can open every selected source file.", + "check": "Try a source that becomes unreadable after selection.", + "checkCost": "Low" + } + ], + "blindSpots": [ + { + "id": "BS-01", + "title": "Filesystem permissions", + "detail": "The destination may not be writable even when sources are readable.", + "sources": [ + "SC-01" + ], + "resolvedBy": "CON-04" + }, + { + "id": "BS-02", + "title": "Partial writes on interruption", + "detail": "An interrupted write can leave a corrupt archive behind.", + "sources": [ + "SC-03" + ] + } + ], + "research": [ + { + "requestId": "RESEARCH-01", + "blindSpotId": "BS-02", + "summary": "Writing to a temporary file and renaming into place avoids partial archives.", + "detail": "A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.", + "answeredAt": "2026-09-21T10:30:00Z" + } + ], + "requirements": [], + "conflicts": [] +} diff --git a/tests/fixtures/interactive/v2/examples/exports/approve-changed-outcome.json b/tests/fixtures/interactive/v2/examples/exports/approve-changed-outcome.json new file mode 100644 index 00000000..49d180a7 --- /dev/null +++ b/tests/fixtures/interactive/v2/examples/exports/approve-changed-outcome.json @@ -0,0 +1,167 @@ +{ + "kind": "quirk-logic-spec-decisions", + "schemaVersion": 2, + "stage": 1, + "slug": "examples", + "renderId": "1d23e0705926", + "exportedAt": "2026-09-23T16:00:00Z", + "signed": true, + "seen": { + "ASM-01": "d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d", + "BS-01": "cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05", + "BS-02": "9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e", + "SC-01": "74ee837e53a42f149769eba8c37f9c90de32041248873e7da51d6806dd38f6c5", + "SC-02": "a60d731e96bc261aeb0fd7b3a01bf65c96ead65c00c5297ece27dcd411327035", + "SC-03": "8c6b9fa0fabe1342161ede20edc8a5ce53a8c5a7225398054d66bd341828f971", + "SC-04": "71bead2964fcf4698fffdfd836bdf8b95f56e303e66d1710e4d94cddea6ddf21", + "BH-01": "474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13", + "BH-02": "e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41", + "CON-01": "cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42", + "CON-02": "8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17", + "CON-03": "9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29", + "CON-04": "ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5" + }, + "state": { + "stage": 1, + "scenarioOutcomes": { + "SC-01": { + "choice": "alt-0", + "custom": "" + } + }, + "scenarioApproved": { + "SC-01": true, + "SC-03": true, + "SC-04": true + }, + "scenarioDrops": { + "SC-02": "Deferred pending the open question." + }, + "scenarioRequests": [], + "constraintRulings": { + "CON-01": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-02": { + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + "CON-03": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-04": { + "ruling": "approve", + "text": "", + "reason": "" + } + }, + "assumptions": { + "ASM-01": { + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + }, + "blindSpots": { + "BS-02": { + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + }, + "researchRequests": [], + "placements": {}, + "conditions": {}, + "moveReasons": {}, + "notes": {}, + "disputes": {}, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "updatedAt": "2026-09-23T16:00:00Z" + }, + "record": { + "stage": 1, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "scenarios": [ + { + "id": "SC-01", + "approved": true, + "dropped": null, + "then": "The line nets $75.00.", + "changesSpec": true + }, + { + "id": "SC-02", + "approved": false, + "dropped": "Deferred pending the open question.", + "then": "The fourth charge does not include the discount.", + "changesSpec": false + }, + { + "id": "SC-03", + "approved": true, + "dropped": null, + "then": "The edit is ignored once the definition has been used, and allowed while it has not.", + "changesSpec": false + }, + { + "id": "SC-04", + "approved": true, + "dropped": null, + "then": "Only the selected discount is shown.", + "changesSpec": false + } + ], + "constraints": [ + { + "id": "CON-01", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-02", + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + { + "id": "CON-03", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-04", + "ruling": "approve", + "text": "", + "reason": "" + } + ], + "assumptions": [ + { + "id": "ASM-01", + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + ], + "blindSpots": [ + { + "id": "BS-02", + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + ], + "placements": [], + "disputes": [], + "scenarioRequests": [], + "researchRequests": [], + "openWarnings": [] + } +} diff --git a/tests/fixtures/interactive/v2/examples/exports/approve-open-ask.json b/tests/fixtures/interactive/v2/examples/exports/approve-open-ask.json new file mode 100644 index 00000000..039cc12b --- /dev/null +++ b/tests/fixtures/interactive/v2/examples/exports/approve-open-ask.json @@ -0,0 +1,161 @@ +{ + "kind": "quirk-logic-spec-decisions", + "schemaVersion": 2, + "stage": 1, + "slug": "examples", + "renderId": "1d23e0705926", + "exportedAt": "2026-09-23T16:00:00Z", + "signed": true, + "seen": { + "ASM-01": "d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d", + "BS-01": "cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05", + "BS-02": "9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e", + "SC-01": "74ee837e53a42f149769eba8c37f9c90de32041248873e7da51d6806dd38f6c5", + "SC-02": "a60d731e96bc261aeb0fd7b3a01bf65c96ead65c00c5297ece27dcd411327035", + "SC-03": "8c6b9fa0fabe1342161ede20edc8a5ce53a8c5a7225398054d66bd341828f971", + "SC-04": "71bead2964fcf4698fffdfd836bdf8b95f56e303e66d1710e4d94cddea6ddf21", + "BH-01": "474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13", + "BH-02": "e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41", + "CON-01": "cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42", + "CON-02": "8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17", + "CON-03": "9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29", + "CON-04": "ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5" + }, + "state": { + "stage": 1, + "scenarioOutcomes": {}, + "scenarioApproved": { + "SC-01": true, + "SC-02": true, + "SC-03": true, + "SC-04": true + }, + "scenarioDrops": {}, + "scenarioRequests": [], + "constraintRulings": { + "CON-01": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-02": { + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + "CON-03": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-04": { + "ruling": "approve", + "text": "", + "reason": "" + } + }, + "assumptions": { + "ASM-01": { + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + }, + "blindSpots": { + "BS-02": { + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + }, + "researchRequests": [], + "placements": {}, + "conditions": {}, + "moveReasons": {}, + "notes": {}, + "disputes": {}, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "updatedAt": "2026-09-23T16:00:00Z" + }, + "record": { + "stage": 1, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "scenarios": [ + { + "id": "SC-01", + "approved": true, + "dropped": null, + "then": "The line nets $76.50.", + "changesSpec": false + }, + { + "id": "SC-02", + "approved": true, + "dropped": null, + "then": "The fourth charge does not include the discount.", + "changesSpec": false + }, + { + "id": "SC-03", + "approved": true, + "dropped": null, + "then": "The edit is ignored once the definition has been used, and allowed while it has not.", + "changesSpec": false + }, + { + "id": "SC-04", + "approved": true, + "dropped": null, + "then": "Only the selected discount is shown.", + "changesSpec": false + } + ], + "constraints": [ + { + "id": "CON-01", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-02", + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + { + "id": "CON-03", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-04", + "ruling": "approve", + "text": "", + "reason": "" + } + ], + "assumptions": [ + { + "id": "ASM-01", + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + ], + "blindSpots": [ + { + "id": "BS-02", + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + ], + "placements": [], + "disputes": [], + "scenarioRequests": [], + "researchRequests": [], + "openWarnings": [] + } +} diff --git a/tests/fixtures/interactive/v2/examples/exports/drop-open-ask.json b/tests/fixtures/interactive/v2/examples/exports/drop-open-ask.json new file mode 100644 index 00000000..343c8d3e --- /dev/null +++ b/tests/fixtures/interactive/v2/examples/exports/drop-open-ask.json @@ -0,0 +1,162 @@ +{ + "kind": "quirk-logic-spec-decisions", + "schemaVersion": 2, + "stage": 1, + "slug": "examples", + "renderId": "1d23e0705926", + "exportedAt": "2026-09-23T16:00:00Z", + "signed": true, + "seen": { + "ASM-01": "d4e4ab96ffeb2a7b6578d4c104cf5d33d61d68a2e287c1f0624777d814c4dc3d", + "BS-01": "cd34b0dde67e3b16ab16d85a3f16d8c1cce1330f225dd3ccb2b119cedd1cfc05", + "BS-02": "9635764fb43fb5a19af71ded4acffc2a2a10c6c0368055fb399a7d281b55606e", + "SC-01": "74ee837e53a42f149769eba8c37f9c90de32041248873e7da51d6806dd38f6c5", + "SC-02": "a60d731e96bc261aeb0fd7b3a01bf65c96ead65c00c5297ece27dcd411327035", + "SC-03": "8c6b9fa0fabe1342161ede20edc8a5ce53a8c5a7225398054d66bd341828f971", + "SC-04": "71bead2964fcf4698fffdfd836bdf8b95f56e303e66d1710e4d94cddea6ddf21", + "BH-01": "474d3c2b130b4c64d9529f575acacabd10e91bce22e00f368a896d2169f90e13", + "BH-02": "e9bfa7594863ec68a709323910e6d1ce1593fd9340367ad53d92187964444a41", + "CON-01": "cdd9b1ca6ea987694327d5e4d0944a666af009b521af69c8821501650a3d1e42", + "CON-02": "8d0be02f41efb64361953effe21239ba63c6fc92def8ac4169d3365f35780e17", + "CON-03": "9802916c0e06d44fa6a1367fdadab180fcbbae6d73b2c348c2f65a6d32234f29", + "CON-04": "ca76b240b4c5e201014af828149170fb43c04b9a40661501fee39ff9903f66b5" + }, + "state": { + "stage": 1, + "scenarioOutcomes": {}, + "scenarioApproved": { + "SC-01": true, + "SC-03": true, + "SC-04": true + }, + "scenarioDrops": { + "SC-02": "Example gap is unresolved; scenario deferred." + }, + "scenarioRequests": [], + "constraintRulings": { + "CON-01": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-02": { + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + "CON-03": { + "ruling": "approve", + "text": "", + "reason": "" + }, + "CON-04": { + "ruling": "approve", + "text": "", + "reason": "" + } + }, + "assumptions": { + "ASM-01": { + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + }, + "blindSpots": { + "BS-02": { + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + }, + "researchRequests": [], + "placements": {}, + "conditions": {}, + "moveReasons": {}, + "notes": {}, + "disputes": {}, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "updatedAt": "2026-09-23T16:00:00Z" + }, + "record": { + "stage": 1, + "verdict": "approve", + "verdictNote": "Stage 1 reviewed and approved.", + "signedAt": "2026-09-23T16:00:00Z", + "scenarios": [ + { + "id": "SC-01", + "approved": true, + "dropped": null, + "then": "The line nets $76.50.", + "changesSpec": false + }, + { + "id": "SC-02", + "approved": false, + "dropped": "Example gap is unresolved; scenario deferred.", + "then": "The fourth charge does not include the discount.", + "changesSpec": false + }, + { + "id": "SC-03", + "approved": true, + "dropped": null, + "then": "The edit is ignored once the definition has been used, and allowed while it has not.", + "changesSpec": false + }, + { + "id": "SC-04", + "approved": true, + "dropped": null, + "then": "Only the selected discount is shown.", + "changesSpec": false + } + ], + "constraints": [ + { + "id": "CON-01", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-02", + "ruling": "rewrite", + "text": "Index entries always use forward slashes for paths, even on Windows.", + "reason": "" + }, + { + "id": "CON-03", + "ruling": "approve", + "text": "", + "reason": "" + }, + { + "id": "CON-04", + "ruling": "approve", + "text": "", + "reason": "" + } + ], + "assumptions": [ + { + "id": "ASM-01", + "ruling": "build-on", + "note": "Confirmed elsewhere; no additional check needed." + } + ], + "blindSpots": [ + { + "id": "BS-02", + "accepted": true, + "note": "Atomic rename mitigates this; accepted without a resolving item." + } + ], + "placements": [], + "disputes": [], + "scenarioRequests": [], + "researchRequests": [], + "openWarnings": [] + } +} diff --git a/tests/fixtures/interactive/v2/examples/logic.json b/tests/fixtures/interactive/v2/examples/logic.json new file mode 100644 index 00000000..bc8db90e --- /dev/null +++ b/tests/fixtures/interactive/v2/examples/logic.json @@ -0,0 +1,205 @@ +{ + "schemaVersion": 2, + "stage": 1, + "title": "Subscription discount examples", + "status": "Draft", + "amendments": [], + "sections": { + "purpose": "Describe a small, local-first archive workflow.", + "conceptualModel": "An archive groups selected source files with a compact index.", + "dataFlow": "The reviewer chooses sources, then the tool writes the archive and index.", + "decisionsLocked": [ + { + "area": "Storage", + "decision": "The archive is written beside the selected source directory." + } + ], + "industryInsights": [ + { + "finding": "A portable index reduces dependence on one viewer.", + "sources": [ + "https://example.test/portable-index" + ] + } + ], + "scopeNonGoals": [ + "Remote synchronization is not part of this change." + ], + "deferredIdeas": [ + { + "text": "Add archive signing after the local format stabilizes.", + "techSpec": true + } + ], + "glossary": [ + { + "term": "Index", + "definition": "A compact listing of archive contents." + } + ] + }, + "behaviors": [ + { + "id": "BH-01", + "rule": "Archiving writes a portable index." + }, + { + "id": "BH-02", + "rule": "Selecting sources is explicit." + } + ], + "scenarios": [ + { + "id": "SC-01", + "behavior": "BH-01", + "given": "A $100 line with a 10% and a 15% line discount.", + "when": "The charge is calculated.", + "then": "The line nets $76.50.", + "alternatives": [ + "The line nets $75.00." + ], + "explanation": "Percentage discounts compound instead of adding.", + "provenance": "you-chose", + "question": "Do percentage discounts compound?", + "example": { + "before": "**Charge 1**: Coffee line $100.00; \"Loyalty\" 10% off; \"Winback\" 15% off", + "steps": [ + "\"Loyalty\": $100.00 × 0.90 = $90.00", + "\"Winback\": $90.00 × 0.85 = $76.50" + ], + "after": "**Charge 1**: ✓ Coffee line $76.50; \"Loyalty\" took $10.00; \"Winback\" took $13.50" + } + }, + { + "id": "SC-02", + "behavior": "BH-01", + "given": "A subscription with a line discount limited to the first 3 charges.", + "when": "Three charges have processed and the next one is generated.", + "then": "The fourth charge does not include the discount.", + "alternatives": [], + "explanation": "A used-up discount stops applying.", + "provenance": "claude", + "rationale": "Limited discounts need an explicit end-of-life case.", + "example": { + "before": "**Subscription**: \"Welcome\" 10%* off, first 3 charges; uses left 3", + "steps": [ + "Charge 1 processes → uses left 2", + "Charge 2 processes → uses left 1", + "Charge 3 processes → uses left 0", + "Charge 4 is generated" + ], + "after": "**Charge 4**: Coffee line has no line discounts\n**Subscription**: \"Welcome\" uses left 0\n[ASK: does \"Welcome\" stay on the subscription once its uses run out, or get removed?]" + } + }, + { + "id": "SC-03", + "behavior": "BH-02", + "given": "A shared discount definition at 10%.", + "when": "The merchant edits it to 15%.", + "then": "The edit is ignored once the definition has been used, and allowed while it has not.", + "alternatives": [], + "explanation": "Used definitions are immutable.", + "provenance": "you-recommended", + "question": "Can a used definition be edited?", + "extraReason": "One rule with two outcomes.", + "example": { + "before": "**Discount definition** \"Loyalty\": 10% off\n(a) used 4* times\n(b) never used", + "steps": [ + "The merchant changes \"Loyalty\" to 15%\n(a) The request succeeds, but the change is ignored\n(b) The change is saved" + ], + "after": "(a) ✗ \"Loyalty\" stays 10%\n(b) ✓ \"Loyalty\" is 15%; queued charges pick it up on their next regen" + } + }, + { + "id": "SC-04", + "behavior": "BH-02", + "given": "The user opens the discount list.", + "when": "They select a discount.", + "then": "Only the selected discount is shown.", + "alternatives": [], + "explanation": "Selection stays explicit.", + "provenance": "you-chose", + "question": "Should selection stay explicit?" + } + ], + "constraints": [ + { + "id": "CON-01", + "area": "Archive", + "kind": "placement", + "text": "Archive output goes beside the selected source directory.", + "provenance": "you-chose", + "question": "Where should the archive be written?" + }, + { + "id": "CON-02", + "area": "Archive", + "kind": "naming", + "text": "Index entries use forward slashes for paths.", + "provenance": "you-recommended", + "question": "Should paths use a single separator style?" + }, + { + "id": "CON-03", + "area": "Sync", + "kind": "non-goal", + "text": "Remote synchronization of archives is out of scope.", + "provenance": "you-chose", + "question": "Should remote sync be included?" + }, + { + "id": "CON-04", + "area": "Archive", + "kind": "verification", + "text": "A destination permission check runs before writing begins.", + "provenance": "claude", + "rationale": "Prevents partial writes when the destination becomes read-only mid-run." + } + ], + "assumptions": [ + { + "id": "ASM-01", + "claim": "Selected inputs are readable when writing starts.", + "basis": "The workflow selects files before creating the archive.", + "certainty": "assumed", + "ifWrong": "Unreadable sources need a clear per-file error.", + "affects": [ + "SC-01", + "CON-04" + ], + "meaning": "The tool can open every selected source file.", + "check": "Try a source that becomes unreadable after selection.", + "checkCost": "Low" + } + ], + "blindSpots": [ + { + "id": "BS-01", + "title": "Filesystem permissions", + "detail": "The destination may not be writable even when sources are readable.", + "sources": [ + "SC-01" + ], + "resolvedBy": "CON-04" + }, + { + "id": "BS-02", + "title": "Partial writes on interruption", + "detail": "An interrupted write can leave a corrupt archive behind.", + "sources": [ + "SC-04" + ] + } + ], + "research": [ + { + "requestId": "RESEARCH-01", + "blindSpotId": "BS-02", + "summary": "Writing to a temporary file and renaming into place avoids partial archives.", + "detail": "A rename is atomic on the same filesystem, so an interrupted write never leaves a corrupt destination file.", + "answeredAt": "2026-09-21T10:30:00Z" + } + ], + "requirements": [], + "conflicts": [] +} diff --git a/tests/test_interactive_logic_spec.py b/tests/test_interactive_logic_spec.py index cd571696..dfc346ed 100644 --- a/tests/test_interactive_logic_spec.py +++ b/tests/test_interactive_logic_spec.py @@ -799,6 +799,41 @@ def test_v2_final_fold_moves_reapproved_hash_into_pin(tmp_path: Path) -> None: assert folded["stage1Pin"]["itemHashes"]["SC-01"] == current_hash +def test_validate_caps_text_fields_at_the_review_page_limit() -> None: + logic = load_json(FIXTURES / "v2" / "examples" / "logic.json") + cap = render_spec.MAX_TEXT_CHARS + + logic["scenarios"][0]["explanation"] = "x" * cap + assert render_spec.validate_logic_dispatch(logic)[0] == [] + + logic["scenarios"][0]["explanation"] = "x" * (cap + 1) + logic["scenarios"][1]["example"]["before"] = "[a](" * (cap // 4 + 1) + errors = dict(render_spec.validate_logic_dispatch(logic)[0]) + assert set(errors) == {"/scenarios/0/explanation", "/scenarios/1/example/before"} + assert errors["/scenarios/0/explanation"].startswith(f"text is {cap + 1} characters;") + + +def test_v1_validate_caps_text_fields_at_the_review_page_limit() -> None: + logic = load_json(SAMPLE / "logic.json") + logic["scenarios"][0]["then"] = "x" * (render_spec.MAX_TEXT_CHARS + 1) + + errors = dict(render_spec.validate_logic_dispatch(logic)[0]) + assert "/scenarios/0/then" in errors + + +def test_render_refuses_a_spec_with_an_oversized_field(tmp_path: Path) -> None: + spec_dir = make_v2_spec(tmp_path, FIXTURES / "v2" / "examples", "examples") + logic = load_json(spec_dir / "logic.json") + logic["scenarios"][0]["example"]["before"] = "[a](" * 100_000 + save_json(spec_dir / "logic.json", logic) + + result = run_cli("render", str(spec_dir)) + + assert result.returncode != 0 + assert "/scenarios/0/example/before: text is 400000 characters" in result.stderr + assert not (spec_dir / "review.html").exists() + + def test_v2_validate_rejects_a_stale_reapproved_hash_even_when_the_pin_still_matches() -> None: logic = load_json(V2_STAGE2 / "logic.json") assert render_spec.validate_logic_v2(logic)[0] == [] diff --git a/tests/test_render_goldens.py b/tests/test_render_goldens.py new file mode 100644 index 00000000..6c3e4b86 --- /dev/null +++ b/tests/test_render_goldens.py @@ -0,0 +1,43 @@ +"""No-regression goldens: logic.md output for specs without data-state examples.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "skills" / "writing-specs" / "interactive" / "render_spec.py" +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "interactive" +GOLDENS = FIXTURES / "goldens" +V1_SPEC = REPO_ROOT / "docs" / "quirk" / "specs" / "2026-09-24-interactive-logic-spec-v2" + + +def render_copy(logic_json: Path, tmp_path: Path, slug: str) -> bytes: + """Render a copy of logic_json in a dir named slug (the basename is the spec slug).""" + spec_dir = tmp_path / slug + spec_dir.mkdir(parents=True) + shutil.copy(logic_json, spec_dir / "logic.json") + result = subprocess.run( + [sys.executable, str(SCRIPT), "render", str(spec_dir)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return (spec_dir / "logic.md").read_bytes() + + +@pytest.mark.parametrize("stage", ["stage1", "stage2"]) +def test_v2_fixture_logic_md_matches_golden(tmp_path: Path, stage: str) -> None: + rendered = render_copy(FIXTURES / "v2" / stage / "logic.json", tmp_path, stage) + assert rendered == (GOLDENS / f"v2-{stage}.logic.md").read_bytes() + + +def test_v1_spec_renders_committed_logic_md(tmp_path: Path) -> None: + rendered = render_copy(V1_SPEC / "logic.json", tmp_path, V1_SPEC.name) + assert rendered == (V1_SPEC / "logic.md").read_bytes() diff --git a/tests/test_scenario_examples.py b/tests/test_scenario_examples.py new file mode 100644 index 00000000..d6dc611b --- /dev/null +++ b/tests/test_scenario_examples.py @@ -0,0 +1,592 @@ +"""Behavioral contract for scenario data-state examples in the interactive logic-spec renderer.""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +import time +from html.parser import HTMLParser +from pathlib import Path +from typing import Any, Dict, List + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "skills" / "writing-specs" / "interactive" / "render_spec.py" +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "interactive" / "v2" +EXAMPLES = FIXTURES / "examples" +EXAMPLES_EXPORTS = EXAMPLES / "exports" +DETAILS = FIXTURES / "details" +DETAILS_EXPORTS = DETAILS / "exports" +STAGE1 = FIXTURES / "stage1" + +sys.path.insert(0, str(SCRIPT.parent)) +import render_spec # noqa: E402 + +MALFORMED = "malformed [ASK] marker; write [ASK: ]" + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], cwd=REPO_ROOT, capture_output=True, text=True, check=False + ) + + +def load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def save_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def make_spec(tmp_path: Path, name: str = "examples") -> Path: + spec_dir = tmp_path / name + spec_dir.mkdir(parents=True) + shutil.copy(EXAMPLES / "logic.json", spec_dir / "logic.json") + return spec_dir + + +def example_logic() -> dict: + return load_json(EXAMPLES / "logic.json") + + +def scenario(logic: dict, ident: str) -> dict: + return next(item for item in logic["scenarios"] if item["id"] == ident) + + +def errors_for(logic: dict) -> List[tuple]: + return render_spec.validate_logic_v2(logic)[0] + + +def valid_example() -> dict: + return {"before": "Before.", "steps": ["One.", "Two."], "after": "After."} + + +def test_validate_accepts_the_example_fixture() -> None: + result = run_cli("validate", str(EXAMPLES)) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + + +def test_scenario_without_example_is_still_valid() -> None: + logic = example_logic() + assert "example" not in scenario(logic, "SC-04") + assert errors_for(logic) == [] + + +@pytest.mark.parametrize( + "mutate, pointer", + [ + (lambda ex: ex.pop("before"), "/scenarios/0/example/before"), + (lambda ex: ex.pop("steps"), "/scenarios/0/example/steps"), + (lambda ex: ex.pop("after"), "/scenarios/0/example/after"), + (lambda ex: ex.update(steps=[]), "/scenarios/0/example/steps"), + (lambda ex: ex.update(steps=["One.", ""]), "/scenarios/0/example/steps/1"), + (lambda ex: ex.update(steps=["One.", 3]), "/scenarios/0/example/steps/1"), + (lambda ex: ex.update(steps="One."), "/scenarios/0/example/steps"), + (lambda ex: ex.update(before=""), "/scenarios/0/example/before"), + (lambda ex: ex.update(before=7), "/scenarios/0/example/before"), + (lambda ex: ex.update(after=["After."]), "/scenarios/0/example/after"), + (lambda ex: ex.update(extra="x"), "/scenarios/0/example/extra"), + ], +) +def test_validate_rejects_malformed_example_shapes(mutate: Any, pointer: str) -> None: + logic = example_logic() + example = valid_example() + mutate(example) + logic["scenarios"][0]["example"] = example + assert pointer in [path for path, _ in errors_for(logic)] + + +def test_validate_rejects_a_non_object_example() -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = "text" + assert ("/scenarios/0/example", "expected an object") in errors_for(logic) + + +@pytest.mark.parametrize("marker", ["[ask: is this right?]", "[ASK]", "[ASK:x]", "[ Ask: x]", "[ASK: ]"]) +def test_validate_rejects_malformed_ask_markers_in_every_part_in_stage_1(marker: str) -> None: + for part, pointer in ( + ("before", "/scenarios/0/example/before"), + ("after", "/scenarios/0/example/after"), + ("steps", "/scenarios/0/example/steps/1"), + ): + logic = example_logic() + example = valid_example() + if part == "steps": + example["steps"][1] = "Step " + marker + else: + example[part] = "Text " + marker + logic["scenarios"][0]["example"] = example + assert (pointer, MALFORMED) in errors_for(logic), (marker, part) + + +def test_nested_ask_prefixes_validate_in_linear_time_and_are_malformed() -> None: + logic = example_logic() + count = 16000 + logic["scenarios"][0]["example"]["before"] = "[ASK: " * count + "x" + "]" * count + started = time.perf_counter() + errors = errors_for(logic) + assert time.perf_counter() - started < 1 + assert ("/scenarios/0/example/before", MALFORMED) in errors + + +def test_ask_question_cannot_contain_square_brackets() -> None: + assert render_spec.scenario_open_asks({"example": {"before": "[ASK: a [ASK: b]]", "steps": [], "after": ""}}) == ["b"] + + +def test_validate_accepts_a_well_formed_open_ask_in_stage_1() -> None: + logic = example_logic() + logic["scenarios"][0]["example"]["after"] = "Result. [ASK: which one?]" + assert errors_for(logic) == [] + + +def test_scenario_open_asks_collects_questions_in_part_order() -> None: + sc = { + "example": { + "before": "A [ASK: first?] B [ASK: second?]", + "steps": ["no gap", "[ASK: third?]"], + "after": "[ASK: fourth?]", + } + } + assert render_spec.scenario_open_asks(sc) == ["first?", "second?", "third?", "fourth?"] + assert render_spec.scenario_open_asks({"id": "SC-9"}) == [] + + +def test_scenario_open_asks_is_case_sensitive_and_single_line() -> None: + assert render_spec.scenario_open_asks({"example": {"before": "[ask: x]", "steps": ["s"], "after": "a"}}) == [] + assert render_spec.scenario_open_asks({"example": {"before": "[ASK: a\nb]", "steps": ["s"], "after": "a"}}) == [] + + +def test_stage_2_open_ask_on_a_live_scenario_fails_and_on_a_dropped_one_passes(tmp_path: Path) -> None: + spec_dir = make_spec(tmp_path) + fold = run_cli("fold", str(spec_dir), str(EXAMPLES_EXPORTS / "drop-open-ask.json")) + assert fold.returncode == 0, fold.stderr + folded = load_json(spec_dir / "logic.json") + assert folded["stage"] == 2 + assert render_spec.scenario_open_asks(scenario(folded, "SC-02")) + assert "dropReason" in scenario(folded, "SC-02") + assert errors_for(folded) == [] + + live = copy.deepcopy(folded) + index = next(i for i, item in enumerate(live["scenarios"]) if item["id"] == "SC-01") + live["scenarios"][index]["example"]["after"] += " [ASK: still open?]" + assert (pointer_of(index), "open [ASK] after stage-1 sign-off") in errors_for(live) + + +def pointer_of(index: int) -> str: + return "/scenarios/" + str(index) + "/example" + + +def test_item_hash_covers_the_example() -> None: + logic = example_logic() + base = render_spec.item_hashes(logic) + + without = copy.deepcopy(logic) + del scenario(without, "SC-01")["example"] + assert render_spec.item_hashes(without)["SC-01"] != base["SC-01"] + + edited = copy.deepcopy(logic) + scenario(edited, "SC-01")["example"]["steps"][0] += " " + assert render_spec.item_hashes(edited)["SC-01"] != base["SC-01"] + assert render_spec.item_hashes(edited)["SC-02"] == base["SC-02"] + + +def test_scenarios_without_an_example_keep_their_existing_hashes() -> None: + logic = load_json(STAGE1 / "logic.json") + signed = load_json(STAGE1 / "exports" / "signed.json") + hashes = render_spec.item_hashes(logic) + for ident in ("SC-01", "SC-02", "SC-03", "SC-04", "SC-05"): + assert hashes[ident] == signed["seen"][ident] + assert render_spec.render_id(logic) == signed["renderId"] + + +def test_example_markdown_lines_are_exact_for_single_line_parts() -> None: + lines = render_spec._example_markdown_lines(scenario(example_logic(), "SC-01")["example"]) + assert lines == [ + ' - **Data before:** **Charge 1**: Coffee line $100.00; "Loyalty" 10% off; "Winback" 15% off', + " - **Walkthrough:**", + ' 1. "Loyalty": $100.00 × 0.90 = $90.00', + ' 2. "Winback": $90.00 × 0.85 = $76.50', + ' - **Data after:** **Charge 1**: ✓ Coffee line $76.50; "Loyalty" took $10.00; "Winback" took $13.50', + ] + + +def test_example_markdown_lines_hard_break_and_indent_multi_line_parts() -> None: + lines = render_spec._example_markdown_lines(scenario(example_logic(), "SC-03")["example"]) + assert lines == [ + ' - **Data before:** **Discount definition** "Loyalty": 10% off ', + " (a) used 4* times ", + " (b) never used", + " - **Walkthrough:**", + ' 1. The merchant changes "Loyalty" to 15% ', + " (a) The request succeeds, but the change is ignored ", + " (b) The change is saved", + ' - **Data after:** (a) ✗ "Loyalty" stays 10% ', + " (b) ✓ \"Loyalty\" is 15%; queued charges pick it up on their next regen", + ] + + +def test_example_markdown_lines_normalize_line_endings_and_pass_marks_through() -> None: + lines = render_spec._example_markdown_lines( + {"before": "a\r\nb\rc", "steps": ["**x** *y* [ASK: z?]"], "after": "done"} + ) + assert lines[0] == " - **Data before:** a " + assert lines[1:3] == [" b ", " c"] + assert lines[4] == " 1. **x** *y* [ASK: z?]" + assert render_spec._example_markdown_lines({"before": "a", "steps": ["s"], "after": "b"}) == ( + render_spec._example_markdown_lines({"before": "a", "steps": ["s"], "after": "b"}) + ) + + +def test_render_places_example_between_alternatives_and_explanation(tmp_path: Path) -> None: + logic = example_logic() + lines = render_spec.render_markdown_v2(logic).split("\n") + start = lines.index("1. **SC-01**") + block = lines[start : start + 12] + assert block[4].startswith(" - **Alternatives** ") + assert block[5].startswith(" - **Data before:**") + assert block[9].startswith(" - **Data after:**") + assert block[10] == " - Percentage discounts compound instead of adding." + + plain = lines.index("1. **SC-04**") if "1. **SC-04**" in lines else lines.index("2. **SC-04**") + assert lines[plain + 4] == " - Selection stays explicit." + assert not any("Data before" in line for line in lines[plain : plain + 6]) + + +def test_render_leaves_markdown_unchanged_for_specs_without_examples() -> None: + logic = load_json(STAGE1 / "logic.json") + assert "Data before" not in render_spec.render_markdown_v2(logic) + + +def fold_export(tmp_path: Path, name: str) -> tuple[subprocess.CompletedProcess[str], Path]: + spec_dir = make_spec(tmp_path) + result = run_cli("fold", str(spec_dir), str(EXAMPLES_EXPORTS / (name + ".json"))) + return result, spec_dir + + +def test_exports_are_consistent_with_the_fixture() -> None: + logic = example_logic() + for path in sorted(EXAMPLES_EXPORTS.glob("*.json")): + export = load_json(path) + assert export["renderId"] == render_spec.render_id(logic), path.name + hashes = render_spec.item_hashes(logic) + assert all(hashes[ident] == value for ident, value in export["seen"].items()), path.name + assert export["signed"] is True + + +def test_fold_refuses_an_approved_scenario_with_an_open_ask(tmp_path: Path) -> None: + result, spec_dir = fold_export(tmp_path, "approve-open-ask") + assert result.returncode == 4, result.stderr + assert "gate examples: open [ASK] in SC-02" in result.stderr + assert load_json(spec_dir / "logic.json")["stage"] == 1 + + +def test_fold_refuses_an_approved_scenario_whose_outcome_changed(tmp_path: Path) -> None: + result, spec_dir = fold_export(tmp_path, "approve-changed-outcome") + assert result.returncode == 4, result.stderr + assert "gate examples: outcome changed on SC-01; Claude must rewrite its example first" in result.stderr + assert "open [ASK]" not in result.stderr + assert load_json(spec_dir / "logic.json")["stage"] == 1 + + +def test_fold_accepts_a_dropped_open_ask_scenario_and_keeps_its_example_and_pin(tmp_path: Path) -> None: + result, spec_dir = fold_export(tmp_path, "drop-open-ask") + assert result.returncode == 0, result.stderr + folded = load_json(spec_dir / "logic.json") + original = example_logic() + assert scenario(folded, "SC-02")["example"] == scenario(original, "SC-02")["example"] + assert scenario(folded, "SC-01")["example"] == scenario(original, "SC-01")["example"] + assert folded["stage1Pin"]["itemHashes"] == render_spec.stage1_item_hashes(folded) + assert "SC-02" in folded["stage1Pin"]["itemHashes"] + + +@pytest.mark.parametrize("choice, custom", [("custom", ""), ("custom", "Other."), ("alt-0", "")]) +def gate_state(approved: List[str], drops: Dict[str, str], outcomes: Dict[str, Any]) -> dict: + state = copy.deepcopy(load_json(EXAMPLES_EXPORTS / "drop-open-ask.json")["state"]) + state["scenarioApproved"] = {ident: True for ident in approved} + state["scenarioDrops"] = drops + state["scenarioOutcomes"] = outcomes + return state + + +@pytest.mark.parametrize("choice, custom", [("custom", ""), ("custom", "Other."), ("alt-0", "")]) +def test_gate_treats_any_non_spec_choice_as_changed(choice: str, custom: str) -> None: + logic = example_logic() + state = gate_state(["SC-01"], {}, {"SC-01": {"choice": choice, "custom": custom}}) + failures = render_spec.stage1_gate_failures(logic, state) + assert ("examples", "outcome changed on SC-01; Claude must rewrite its example first") in failures + + +def test_gate_ignores_spec_choice_dropped_scenarios_and_scenarios_without_examples() -> None: + logic = example_logic() + state = gate_state(["SC-01", "SC-02", "SC-04"], {"SC-02": "deferred"}, {"SC-01": {"choice": "spec", "custom": ""}}) + assert [name for name, _ in render_spec.stage1_gate_failures(logic, state)].count("examples") == 0 + state["scenarioOutcomes"]["SC-04"] = {"choice": "custom", "custom": "x"} + assert [name for name, _ in render_spec.stage1_gate_failures(logic, state)].count("examples") == 0 + + +def test_gate_reports_one_failure_per_offending_scenario_in_scenario_order() -> None: + logic = example_logic() + scenario(logic, "SC-03")["example"]["after"] += " [ASK: open?]" + state = gate_state(["SC-01", "SC-02", "SC-03", "SC-04"], {}, {"SC-01": {"choice": "alt-0", "custom": ""}}) + messages = [msg for name, msg in render_spec.stage1_gate_failures(logic, state) if name == "examples"] + assert messages == [ + "outcome changed on SC-01; Claude must rewrite its example first", + "open [ASK] in SC-02", + "open [ASK] in SC-03", + ] + + +def test_gate_emits_only_the_open_ask_tuple_when_the_outcome_also_changed() -> None: + logic = example_logic() + state = gate_state(["SC-02"], {}, {"SC-02": {"choice": "custom", "custom": "Other."}}) + examples = [msg for name, msg in render_spec.stage1_gate_failures(logic, state) if name == "examples"] + assert examples == ["open [ASK] in SC-02"] + + +class PayloadParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.in_payload = False + self.chunks: List[str] = [] + + def handle_starttag(self, tag: str, attrs: Any) -> None: + if tag == "script" and dict(attrs).get("id") == "quirk-logic-spec-payload": + self.in_payload = True + + def handle_data(self, data: str) -> None: + if self.in_payload: + self.chunks.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "script": + self.in_payload = False + + +def test_review_page_payload_lists_open_asks_for_exactly_the_open_scenario(tmp_path: Path) -> None: + spec_dir = make_spec(tmp_path) + result = run_cli("render", str(spec_dir)) + assert result.returncode == 0, result.stderr + parser = PayloadParser() + parser.feed((spec_dir / "review.html").read_text(encoding="utf-8")) + payload = json.loads("".join(parser.chunks)) + assert payload["openAsks"] == { + "SC-02": ['does "Welcome" stay on the subscription once its uses run out, or get removed?'] + } + assert "**Data before:**" in payload["logicMarkdown"] + + +def test_review_page_payload_has_an_empty_open_asks_object_without_gaps(tmp_path: Path) -> None: + spec_dir = tmp_path / "plain" + spec_dir.mkdir() + shutil.copy(STAGE1 / "logic.json", spec_dir / "logic.json") + assert run_cli("render", str(spec_dir)).returncode == 0 + parser = PayloadParser() + parser.feed((spec_dir / "review.html").read_text(encoding="utf-8")) + assert json.loads("".join(parser.chunks))["openAsks"] == {} + + +def details_logic() -> dict: + return load_json(DETAILS / "logic.json") + + +def test_validate_accepts_the_details_fixture() -> None: + result = run_cli("validate", str(DETAILS)) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + + +def test_a_details_only_example_validates() -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": {"title": "Order", "body": "Text."}} + assert errors_for(logic) == [] + + +def test_data_state_parts_may_come_with_details() -> None: + logic = example_logic() + logic["scenarios"][0]["example"]["details"] = {"title": "Order", "body": "Text."} + assert errors_for(logic) == [] + + +def test_an_example_with_neither_form_is_rejected_at_the_example() -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {} + assert errors_for(logic) == [("/scenarios/0/example", "example needs before, steps and after, or details")] + + +@pytest.mark.parametrize("present", [["before"], ["steps"], ["after"], ["before", "after"], ["steps", "after"]]) +def test_data_state_parts_are_all_or_nothing_beside_details(present: List[str]) -> None: + logic = example_logic() + full = valid_example() + example: Dict[str, Any] = {key: full[key] for key in present} + example["details"] = {"title": "Order", "body": "Text."} + logic["scenarios"][0]["example"] = example + paths = [path for path, _ in errors_for(logic)] + for key in ("before", "steps", "after"): + assert ("/scenarios/0/example/" + key in paths) == (key not in present) + + +@pytest.mark.parametrize( + "details, expected", + [ + ("text", [("/scenarios/0/example/details", "expected an object")]), + ({"title": "T"}, [("/scenarios/0/example/details/body", "required key is missing")]), + ({"body": "B."}, [("/scenarios/0/example/details/title", "required key is missing")]), + ({"title": "T", "body": "B.", "extra": 1}, [("/scenarios/0/example/details/extra", "unexpected key")]), + ({"title": "T", "body": ""}, [("/scenarios/0/example/details/body", "must not be empty")]), + ({"title": "T", "body": 4}, [("/scenarios/0/example/details/body", "expected a string")]), + ({"title": "", "body": "B."}, [("/scenarios/0/example/details/title", "must not be empty")]), + ({"title": 4, "body": "B."}, [("/scenarios/0/example/details/title", "expected a string")]), + ], +) +def test_details_shape_errors_sit_at_the_offending_key(details: Any, expected: List[tuple]) -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": details} + assert errors_for(logic) == expected + + +@pytest.mark.parametrize( + "title", ["Two\nlines", "Two\rlines", "a*b", "a_b", "a`b", "a[b", "a]b", "ab", "**Bold**", "[link](x)"] +) +def test_details_title_must_be_one_line_of_plain_text(title: str) -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": {"title": title, "body": "B."}} + assert errors_for(logic) == [ + ("/scenarios/0/example/details/title", "details title must be one line of plain text") + ] + + +@pytest.mark.parametrize( + "title", + ["Data before", "data BEFORE", " Walkthrough ", "Walkthrough:", "Data after: ", " data after:"], +) +def test_details_title_may_not_reuse_a_data_state_label(title: str) -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": {"title": title, "body": "B."}} + assert errors_for(logic) == [("/scenarios/0/example/details/title", "details title reuses a data-state label")] + + +@pytest.mark.parametrize("title", ["Data before the change", "Walkthrough::", "Data", "Discount order: totals"]) +def test_details_title_near_misses_are_accepted(title: str) -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": {"title": title, "body": "B."}} + assert errors_for(logic) == [] + + +@pytest.mark.parametrize("marker", ["[ask: is this right?]", "[ASK]", "[ASK:x]", "[ Ask: x]", "[ASK: ]"]) +def test_validate_rejects_malformed_ask_markers_in_the_details_body(marker: str) -> None: + logic = example_logic() + logic["scenarios"][0]["example"] = {"details": {"title": "Order", "body": "Text " + marker}} + assert ("/scenarios/0/example/details/body", MALFORMED) in errors_for(logic) + + +def test_scenario_open_asks_reads_the_details_body_after_the_data_state_parts() -> None: + sc = { + "example": { + "before": "[ASK: first?]", + "steps": ["[ASK: second?]"], + "after": "[ASK: third?]", + "details": {"title": "T", "body": "x [ASK: fourth?]\n[ASK: fifth?]"}, + } + } + assert render_spec.scenario_open_asks(sc) == ["first?", "second?", "third?", "fourth?", "fifth?"] + only = {"example": {"details": {"title": "T", "body": "[ASK: only?]"}}} + assert render_spec.scenario_open_asks(only) == ["only?"] + assert render_spec.scenario_open_asks({"example": {"details": {"title": "T", "body": "closed"}}}) == [] + + +def test_stage_2_open_ask_fires_for_a_details_only_gap(tmp_path: Path) -> None: + spec_dir = make_spec(tmp_path) + assert run_cli("fold", str(spec_dir), str(EXAMPLES_EXPORTS / "drop-open-ask.json")).returncode == 0 + folded = load_json(spec_dir / "logic.json") + assert folded["stage"] == 2 + index = next(i for i, item in enumerate(folded["scenarios"]) if item["id"] == "SC-01") + folded["scenarios"][index]["example"] = {"details": {"title": "Order", "body": "Open. [ASK: still open?]"}} + assert (pointer_of(index), "open [ASK] after stage-1 sign-off") in errors_for(folded) + folded["scenarios"][index]["example"]["details"]["body"] = "Closed." + assert (pointer_of(index), "open [ASK] after stage-1 sign-off") not in errors_for(folded) + + +def test_fold_refuses_an_export_approving_the_details_only_open_ask(tmp_path: Path) -> None: + spec_dir = tmp_path / "details" + spec_dir.mkdir() + shutil.copy(DETAILS / "logic.json", spec_dir / "logic.json") + result = run_cli("fold", str(spec_dir), str(DETAILS_EXPORTS / "approve-open-ask.json")) + assert result.returncode == 4, result.stderr + assert "gate examples: open [ASK] in SC-01" in result.stderr + assert load_json(spec_dir / "logic.json")["stage"] == 1 + + +def test_details_exports_are_consistent_with_the_fixture() -> None: + logic = details_logic() + for path in sorted(DETAILS_EXPORTS.glob("*.json")): + export = load_json(path) + assert export["renderId"] == render_spec.render_id(logic), path.name + hashes = render_spec.item_hashes(logic) + assert all(hashes[ident] == value for ident, value in export["seen"].items()), path.name + + +def test_details_markdown_lines_for_a_details_only_example() -> None: + lines = render_spec._example_markdown_lines(scenario(details_logic(), "SC-01")["example"]) + assert lines == [ + " - **Discount order:**", + "", + " | Order | Discount | Line after |", + " | --- | --- | --- |", + ' | 1 | "Loyalty" 10% | $90.00 |', + ' | 2 | "Winback" 15% | $76.50 |', + "", + " [ASK: does a fixed-amount discount apply before or after the percentage ones?]", + "", + ] + + +def test_details_markdown_lines_follow_the_data_state_lines_when_both_forms_are_present() -> None: + lines = render_spec._example_markdown_lines(scenario(details_logic(), "SC-02")["example"]) + assert lines[:2] == [ + ' - **Data before:** **Subscription**: "Welcome" 10%* off, first 3 charges; uses left 3', + " - **Walkthrough:**", + ] + assert lines[-8:-6] == [ + ' - **Data after:** **Charge 4**: Coffee line has no line discounts ', + ' **Subscription**: "Welcome" uses left 0', + ] + assert lines[-6:] == [ + " - **Discount lifecycle:**", + "", + " - Uses left starts at 3", + " - Each processed charge uses one", + " - At 0 the discount stops applying", + "", + ] + + +def test_details_body_normalizes_line_endings_and_has_no_trailing_spaces() -> None: + lines = render_spec._example_markdown_lines( + {"details": {"title": "T", "body": "a\r\nb\r\r c \n\nd"}} + ) + assert lines == [" - **T:**", "", " a", " b", "", " c ", "", " d", ""] + + +def test_render_emits_the_details_blocks_in_logic_md() -> None: + markdown = render_spec.render_markdown_v2(details_logic()) + assert " - **Discount order:**\n\n | Order | Discount | Line after |\n | --- | --- |" in markdown + assert " At 0" not in markdown + assert markdown.count(" - **Discount order:**") == 1 + + +def test_review_page_payload_lists_the_details_only_scenario_in_open_asks(tmp_path: Path) -> None: + spec_dir = tmp_path / "details" + spec_dir.mkdir() + shutil.copy(DETAILS / "logic.json", spec_dir / "logic.json") + result = run_cli("render", str(spec_dir)) + assert result.returncode == 0, result.stderr + parser = PayloadParser() + parser.feed((spec_dir / "review.html").read_text(encoding="utf-8")) + payload = json.loads("".join(parser.chunks)) + assert payload["openAsks"] == {"SC-01": ["does a fixed-amount discount apply before or after the percentage ones?"]} diff --git a/tests/test_writing_specs_skill.py b/tests/test_writing_specs_skill.py index 687f9a1e..2b91ae2a 100644 --- a/tests/test_writing_specs_skill.py +++ b/tests/test_writing_specs_skill.py @@ -15,13 +15,21 @@ TECH = SKILL_DIR / "tech-spec.md" REVIEWER = SKILL_DIR / "tech-spec-reviewer-prompt.md" INTERACTIVE = SKILL_DIR / "interactive-logic-spec.md" +EXAMPLES = SKILL_DIR / "scenario-examples.md" def test_skill_files_exist() -> None: - for path in (HUB, LOGIC, TECH, REVIEWER, INTERACTIVE): + for path in (HUB, LOGIC, TECH, REVIEWER, INTERACTIVE, EXAMPLES): assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}" +def test_both_rubrics_link_the_scenario_examples_reference() -> None: + for rubric in (LOGIC, INTERACTIVE): + assert "(scenario-examples.md" in rubric.read_text(), ( + f"{rubric.name} must link scenario-examples.md" + ) + + def test_frontmatter() -> None: match = re.search(r"^---\n(.*?)\n---\n", HUB.read_text(), re.DOTALL) assert match, "SKILL.md missing YAML frontmatter" @@ -130,3 +138,17 @@ def test_cross_file_links_and_anchors_resolve() -> None: if anchor and anchor not in _heading_slugs(resolved.read_text()): broken.append(f"{source.relative_to(REPO_ROOT)} -> {target} (no such heading)") assert not broken, f"unresolvable links: {broken}" + + +def test_scenario_examples_documents_details_sections() -> None: + """The shared conventions file is the one home for the details-section rules.""" + body = EXAMPLES.read_text() + assert "## Details sections" in body + for literal in ("`details`", "`title`", "`body`"): + assert literal in body, f"scenario-examples.md does not document {literal}" + for rubric in (LOGIC, INTERACTIVE): + text = rubric.read_text() + assert "(scenario-examples.md" in text, f"{rubric.name} must still link scenario-examples.md" + assert "(scenario-examples.md#details-sections)" in text, ( + f"{rubric.name} must link the details-sections section" + )