diff --git a/.agents/skills/sce-change-to-plan/references/plan-authoring.md b/.agents/skills/sce-change-to-plan/references/plan-authoring.md index 5b614219b..0c8ecb0a3 100644 --- a/.agents/skills/sce-change-to-plan/references/plan-authoring.md +++ b/.agents/skills/sce-change-to-plan/references/plan-authoring.md @@ -28,9 +28,13 @@ This phase owns the planning process: - Write or revise exactly one `context/plans/{plan_name}.md`. `references/plan-template.md` is the sole owner of the persisted plan schema. -Read its `Acceptance criteria rules`, `Task rules` and `No validation task` rules, -and `Updating an existing plan` rules before authoring or revising. Apply those -rules rather than restating them here. +Read its `Acceptance criteria rules`, `Design decision rules`, +`Verification scenario rules`, `Reference rules`, `Task rules` and +`No validation task` rules, and `Updating an existing plan` rules before +authoring or revising. Apply those rules rather than restating them here. + +Author plan content in this order: design decisions, then acceptance criteria, +then verification scenarios, then tasks. The workflow renders this phase's result as the summary defined in `references/output.md`. @@ -57,7 +61,9 @@ When multiple existing plans match and none can be selected safely, return `blocked` with the matching candidates. When the request targets a new plan, derive `plan_name` as a short kebab-case -slug of the change, and confirm it does not collide with an existing plan. +slug of the change, and confirm it does not collide with an existing plan. Do +not create the plan file here; step 2.10 initializes it after the clarification +gate. Resolve exactly one plan target per invocation. @@ -151,31 +157,109 @@ the repository does not yet have. Do not explore the entire repository by default. -## 2.5 Author the acceptance criteria +## 2.5 Record design decisions + +Record the architectural choices this plan commits to under `## Design decisions`, +applying the `Design decision rules` in `references/plan-template.md`. Do not +manufacture a decision to fill the section. When the change commits to no such +choice, omit the section. + +## 2.6 Author the acceptance criteria Derive the plan-specific success outcomes and checks before slicing tasks, then apply the `Acceptance criteria rules` and exact section shape in `references/plan-template.md`. The template owns their generic validation semantics and placement. -## 2.6 Author the task stack +Give each criterion its `Decisions:` and `Scenarios:` fields as the +`Reference rules` define them. + +## 2.7 Author the verification scenarios + +Write the scenarios the acceptance criteria depend on under +`### Verification scenarios`, applying the `Verification scenario rules` in +`references/plan-template.md`. Cover normal, boundary, failure, cancellation, +concurrency, and recovery cases only where the change makes them relevant. +Do not invent test names: a `Check:` names an existing test, a test a task in +the stack creates, a command, or a named inspection. + +## 2.8 Author the task stack -Slice and order the plan-specific work after the acceptance criteria, applying the -`Task rules` and `No validation task` rules in `references/plan-template.md`. +Slice and order the plan-specific work after the acceptance criteria and +verification scenarios, applying the `Task rules` and `No validation task` rules in `references/plan-template.md`. Do not restate those generic task rules here. +Give each task its `Covers:` field as the `Reference rules` define it. + A finished stack always leaves at least one incomplete task, so the workflow can always hand off to `/next-task`. When the request resolves to a plan but produces no incomplete task, because the change is already implemented or already covered by completed tasks, set internal status `blocked` with category `no_actionable_work` instead of writing the plan. -## 2.7 Write the plan +## 2.9 Check references + +Before writing the plan, run a reference-consistency pass over the +complete draft that applies every `Reference rules` entry in +`references/plan-template.md`. Fix each defect in the draft before writing. Do +not write a plan that fails this pass. + +This pass is pre-write authoring guidance only. It never substitutes for a +successful `sce plan check` in step 2.10. + +## 2.10 Write the plan + +Structured plans are the only supported format. This step has no fallback for +`sce plan init` or `sce plan check`. + +### New plan + +Run `sce plan init {plan_name}`. Initialization succeeds only when all three +hold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`, +and that file exists. Every other result returns `blocked` with the exit code +and stderr. This includes a missing `sce` executable, a binary without `init`, +a nonzero exit, an exit `0` without the expected line, and an expected line +with no created file. + +Fill the generated scaffold with the plan content, applying the authoring rules +in `references/plan-template.md`. The template is the authoring reference for +filling the scaffold, never a substitute for `sce plan init`. Never write a new +plan without it. + +### Existing plan + +Do not run `sce plan init`. Revise the plan by applying the +`Updating an existing plan` rules in `references/plan-template.md`, which +preserve completed tasks and their evidence. The revised plan must satisfy the +same structured plan contract. + +### Completeness gate + +For new plans and revisions alike, the plan contains no scaffold `TODO` +placeholder, and optional sections without real entries (`Design decisions`, +`Assumptions`) are removed. The change summary, acceptance criteria, design +decisions, verification scenarios, task definitions, and verify instructions +are authored for this change. + +While any placeholder remains, keep authoring or return `blocked`. A passing +`sce plan check` is required but not sufficient: it is structural and does not +detect placeholders. + +### Structural check + +For new plans and revisions alike, run `sce plan check {plan-path}`. It passes +only on exit `0` with `structured plan: reference integrity holds` on stdout. + +On exit `3` with structural diagnostics, fix each reported defect in the plan +and run it again, or return `blocked` with the remaining diagnostics when they +cannot be resolved. Every other result returns `blocked` with the exit code and +stderr. This includes a missing `sce` executable, a binary without `check`, any +other exit code, and an exit `0` without the success marker. -Write `context/plans/{plan_name}.md` by applying `references/plan-template.md` -exactly. For revisions, apply its `Updating an existing plan` rules. +Set internal status `plan_ready` only after both the completeness gate and +`sce plan check` pass. -## 2.8 Return the result +## 2.11 Return the result Return one internal result with one of these statuses: diff --git a/.agents/skills/sce-change-to-plan/references/plan-template.md b/.agents/skills/sce-change-to-plan/references/plan-template.md index fe0190b94..7f2674e3a 100644 --- a/.agents/skills/sce-change-to-plan/references/plan-template.md +++ b/.agents/skills/sce-change-to-plan/references/plan-template.md @@ -18,6 +18,19 @@ entirely rather than writing them empty. {One or two paragraphs: what changes, where, and why. State whether this extends existing behavior, replaces it, or preserves work already in progress.} +## Design decisions + +{Optional. Include only real architectural choices this plan commits to. Remove +the section when there are none.} + +- D1: {decision} + - Boundary: {architectural boundary the decision governs} + - Rationale: {why} + - Rejected: {alternative} — {why not} + - Trade-offs: {known limitation or cost} + - Review: required + - Review question: {optional; one question for senior engineering judgment; remove both review lines when none is needed} + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -26,8 +39,19 @@ performs final validation. - [ ] AC1: {observable outcome, stated as behavior rather than as work done} - Validate: `{command, assertion, or inspection that proves AC1}` + - Decisions: {decision IDs, e.g. D1; omit the field when no decision applies} + - Scenarios: {scenario IDs, e.g. S1, S2} - [ ] AC2: {observable outcome} - Validate: `{command, assertion, or inspection that proves AC2}` + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - When: {action} + - Then: {exact observable result or invariant} + - Check: {planned test, command, or named inspection} ### Full validation @@ -70,6 +94,7 @@ recorded. Remove the section otherwise.} - [ ] T01: `{single intent title}` (status:todo) - Task ID: T01 + - Covers: {acceptance criterion IDs, e.g. AC1} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: {task IDs, or none} - Done when: {clear acceptance for one coherent change} @@ -78,6 +103,7 @@ recorded. Remove the section otherwise.} - [ ] T02: `{single intent title}` (status:todo) - Task ID: T02 + - Covers: {acceptance criterion IDs, e.g. AC2} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: T01 - Done when: {clear acceptance for one coherent change} @@ -103,6 +129,7 @@ invent one: `None.` is the expected answer for a well-specified change.} ```markdown - [ ] T02: `Add /auth/refresh endpoint` (status:todo) - Task ID: T02 + - Covers: AC1 - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. - Dependencies: T01 - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. @@ -110,6 +137,47 @@ invent one: `None.` is the expected answer for a well-specified change.} - Context synchronization: pending ``` +## Filled-in structured plan example + +```markdown +## Design decisions + +- D1: The existing session store validates refresh tokens; no new token service is added. + - Boundary: `src/auth/` owns token validation; route handlers call it and never read the session store directly. + - Rationale: The session store already tracks revocation, so one owner keeps revocation consistent. + - Rejected: Stateless refresh tokens — they cannot be revoked before they expire. + - Trade-offs: Every refresh costs one session-store read. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` returns a signed JWT for a valid refresh token and 401 for an expired or revoked one. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh` with its 200 and 401 responses. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A refresh token that has not expired but is revoked in the session store. + - When: The client calls `POST /auth/refresh` with that token. + - Then: The response is 401 and no JWT is issued. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"`, created by T01. + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 + - Scope: In — route handler, session-store validation call, OpenAPI entry. Out — refresh token rotation policy. + - Dependencies: none + - Done when: The endpoint returns a JWT or 401 as AC1 states, the revoked-token test exists and passes, and the OpenAPI entry is present. + - Verify: `pnpm test src/auth/refresh.test.ts`. + - Context synchronization: pending +``` + ## Acceptance criteria rules - Acceptance criteria describe the finished system, not the work. Prefer "the @@ -127,6 +195,49 @@ invent one: `None.` is the expected answer for a well-specified change.} change request. If a criterion has no task that could satisfy it, the task stack is incomplete. +## Design decision rules + +- Record a design decision only for a real architectural choice the plan + commits to, such as an ownership boundary, a data shape, an interface, or an + alternative a reviewer would otherwise reopen. +- Every decision carries `Boundary:`, `Rationale:`, `Rejected:`, and + `Trade-offs:`. +- A decision may also carry the optional review pair `Review: required` and + `Review question: {one question}`. Mark only a decision that needs senior + engineering judgment before the change merges, such as a trade-off a reviewer + must accept. Leave every other decision unmarked. +- Write the review question as one specific question that a reviewer can + accept or reject. Name the architectural choice and its consequence, so the + reviewer can answer from the decision alone. Prefer "Is reading plans from + the working tree instead of the `--head` commit acceptable for stable + links?" over "Is this design OK?" or "Any concerns about plan reads?". +- Ask about each concern once. Do not mark a second decision that raises the + same concern as another marked decision, and do not combine two concerns in + one question. +- Mark every decision that meets this bar. There is no maximum number of + marked decisions. Do not drop an important review question or merge + unrelated questions to lower the count. +- Three or more marked decisions are a signal that the change may be too large + for one focused review. Consider moving independent changes into a separate + pull request. This is advisory: keep tightly coupled changes together, and + complete the plan either way. +- The review pair is all or nothing: a decision carries both fields or + neither. `Review:` is exactly `required`, and `Review question:` is one + nonempty line that states one question. +- An unmarked decision carries no review field. Do not add a placeholder value + such as `Review: none` or an empty `Review question:`. +- A design decision belongs to this plan only. It does not replace an + architecture decision record. + +## Verification scenario rules + +- A scenario states one behavior that an acceptance criterion depends on, as + `Given:`, `When:`, `Then:`, and `Check:`. +- `Then:` states an exact observable result or invariant, not an intention. +- `Check:` names the planned test, command, or named inspection that would + prove `Then:`. Name a test only when it already exists or a task in the stack + creates it. + ## Task rules - Number tasks sequentially as `T01..T0N`. @@ -142,6 +253,51 @@ invent one: `None.` is the expected answer for a well-specified change.} acceptance checks. - Order tasks so each one's declared dependencies precede it. +## Reference rules + +- Read plan structure only from Markdown outside fenced code blocks. A fenced + code block runs from a line that opens a fence of three or more backticks or + tildes to its closing fence. Headings, definitions, and fields inside it are + example text, not plan structure. Keep fenced examples in place; never delete + one to make a plan read correctly. +- A section runs from its heading to the next heading of any level outside + fenced code blocks. A definition counts only in its own section: `D` entries + under `## Design decisions`, `AC` entries under `## Acceptance criteria`, `S` + entries under `### Verification scenarios`, and `T` entries under + `## Task stack`. A line shaped like a definition anywhere else, such as in a + fenced example or a validation report, defines nothing. +- A definition's fields are the `- Field:` lines indented one level under its + entry line, up to the next entry. `Boundary:`, `Rationale:`, `Rejected:`, + `Trade-offs:`, and the optional `Review:` and `Review question:` belong to a + design decision; `Validate:`, `Decisions:`, and + `Scenarios:` to an acceptance criterion; `Given:`, `When:`, `Then:`, and + `Check:` to a verification scenario; and `Covers:` to a task. +- To check a fenced example itself, extract the block's content and apply these + rules to it as a separate sample document. +- IDs are `D1..Dn` for design decisions, `AC1..ACn` for acceptance criteria, + `S1..Sn` for verification scenarios, and `T01..T0N` for tasks. Each entry + starts its own line with its ID: `- D1:`, `- [ ] AC1:`, `- S1:`, and + `- [ ] T01:`. +- Define each ID exactly once. +- Number decisions, criteria, and scenarios sequentially in order of first + assignment. Never renumber or reuse a decision, criterion, or scenario ID; a + withdrawn entry keeps its ID and says that it is withdrawn. +- Each reference field fits on one line: `Decisions:` and `Scenarios:` on an + acceptance criterion, and `Covers:` on a task. Its value is a list of IDs + separated by a comma and a space, for example `S1, S2`. +- Every referenced ID resolves to a definition in the same plan. +- Every acceptance criterion carries `Scenarios:`, with scenario IDs or the + literal `none (inspection)` when only an inspection can prove it. Omit + `Decisions:` when no decision applies. +- Every task carries `Covers:` naming at least one + acceptance criterion defined in the plan. A task without `Covers:` or with an + empty `Covers:` is a defect even when other tasks cover every criterion. +- Every acceptance criterion is named by at least one task's `Covers:`. +- Every scenario is referenced by at least one acceptance criterion's + `Scenarios:`. +- A planned `Check:` is not execution evidence. Only `/validate` running or + performing the check verifies a scenario. + ## No validation task - The last task in the stack is an ordinary implementation task. Do not author a @@ -183,3 +339,4 @@ Do not author either while planning. run earlier, and never renumber a completed task. - Add acceptance criteria for newly planned outcomes rather than rewriting criteria already satisfied. +- Give each new decision, criterion, or scenario the next unused ID of its kind. diff --git a/.agents/skills/sce-next-task/SKILL.md b/.agents/skills/sce-next-task/SKILL.md index b4dd35aba..20d4f1358 100644 --- a/.agents/skills/sce-next-task/SKILL.md +++ b/.agents/skills/sce-next-task/SKILL.md @@ -93,7 +93,7 @@ selected or resumed, before allowing a new implementation task to start. A missing field, or any value other than `synced`, is unresolved synchronization debt. Never infer `synced` from conversation history. When the debt-carrying task has no durable completed-task record, the **Plan review phase** returns -`blocked` directly with a legacy-migration required action; otherwise it returns +`blocked` directly with a plan-migration required action; otherwise it returns `sync_debt` with the resolved plan path, debt task ID/title, completed task record, and persisted blocker when present, for the branch above to route. diff --git a/.agents/skills/sce-next-task/references/output.md b/.agents/skills/sce-next-task/references/output.md index 1ddc071ab..bf897f69b 100644 --- a/.agents/skills/sce-next-task/references/output.md +++ b/.agents/skills/sce-next-task/references/output.md @@ -113,6 +113,12 @@ Describe the smallest coherent implementation approach with enough detail for ap - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. +## Acceptance coverage + +- List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. +- Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} diff --git a/.agents/skills/sce-next-task/references/plan-review.md b/.agents/skills/sce-next-task/references/plan-review.md index ae6a97b66..6d6c4943e 100644 --- a/.agents/skills/sce-next-task/references/plan-review.md +++ b/.agents/skills/sce-next-task/references/plan-review.md @@ -29,8 +29,8 @@ any value other than `synced`, is unresolved synchronization debt. Never infer For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating - that structure, or an incomplete write), do not attempt a reconstructed retry. + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. Set internal status `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -74,6 +74,18 @@ architecture, shared terminology, or cross-domain interfaces. Do not explore the entire repository by default. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Read the selected task's `Covers:` IDs. For each covered +acceptance criterion, read its `Scenarios:` and `Decisions:` fields and the +scenario and decision definitions they name. A covered ID or referenced ID with +no definition in the plan is a dangling reference. Record it as a review risk; +it does not make the task `blocked`. + ## 1.4 Determine readiness A task is `ready` when: @@ -128,6 +140,14 @@ A `ready` result must identify: - Relevant files and context. - Review assumptions. +A `ready` result also identifies: + +- The covered acceptance criteria: each `Covers:` ID with its outcome and + `Validate:` check. +- Their verification scenarios: each scenario ID with its `Then:` and `Check:`. +- Their related design decisions: each decision ID with its decision text. +- Every dangling reference, as a review risk. + A `sync_debt` result must identify: - The resolved plan path. diff --git a/.agents/skills/sce-next-task/references/task-execution.md b/.agents/skills/sce-next-task/references/task-execution.md index ae68ed4c3..4c92e6a70 100644 --- a/.agents/skills/sce-next-task/references/task-execution.md +++ b/.agents/skills/sce-next-task/references/task-execution.md @@ -132,6 +132,12 @@ Run the narrowest authoritative checks that demonstrate the done checks. Start with verification supplied by the readiness result. Add nearby or directly relevant checks only when needed. +The covered scenarios' `Then:` and `Check:` may shape +the task's own `Verify` checks. Running a scenario's check during the task is +task-level evidence only. Task execution never checks an acceptance-criterion +checkbox and never records a scenario status; only `/validate` verifies +acceptance criteria and scenarios. + Verification may include: - Targeted tests. @@ -167,6 +173,7 @@ Only after successful implementation and task-level verification: facts above and before the synchronization phase is invoked. - Record material deviations or approved assumptions. - Preserve the plan's existing structure and terminology. +- Keep the task's `Covers:` field as authored. Do not mark the task complete when returning `declined`, `blocked`, or `incomplete`. diff --git a/.agents/skills/sce-validate/references/validation-report.md b/.agents/skills/sce-validate/references/validation-report.md index b0fa7778b..f3ef7379f 100644 --- a/.agents/skills/sce-validate/references/validation-report.md +++ b/.agents/skills/sce-validate/references/validation-report.md @@ -31,8 +31,12 @@ writes it. ### Success-criteria verification -- [x] AC1: {criterion statement} -> {evidence} -- [ ] AC2: {criterion statement} -> {evidence of failure or not checked} +- [x] AC1: {criterion statement} -> {evidence}; scenarios: S1 +- [ ] AC2: {criterion statement} -> {evidence of failure or not checked}; scenarios: S2, S3 + +### Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified} ### Failed checks and follow-ups @@ -61,7 +65,15 @@ After repairs, rerun: run**. Preserve its exit code and concise outcome; do not invent either. - Under **Success-criteria verification**, render the acceptance-criterion checkbox state and evidence already established by validation execution. Do - not independently re-evaluate or reclassify criteria here. + not independently re-evaluate or reclassify criteria here. End each + acceptance-criterion line with the scenario IDs its + `Scenarios:` field names, or `none (inspection)`. +- Include **Scenario verification** with exactly one entry per scenario in + plan order. Each entry gives the scenario ID, the + acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status already established by validation + execution. - Under **Failed checks and follow-ups**, render every failure and follow-up supplied by validation execution. Write `None.` when status is `validated`. - When status is `failed`, always include **Retry** with the exact diff --git a/.agents/skills/sce-validate/references/validation.md b/.agents/skills/sce-validate/references/validation.md index 5fdf8e6f9..2bc27bb88 100644 --- a/.agents/skills/sce-validate/references/validation.md +++ b/.agents/skills/sce-validate/references/validation.md @@ -57,6 +57,19 @@ From the plan, collect: - Every acceptance criterion and its `Validate:` check. - The `Full validation` command list. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Also collect: + +- Every design decision. +- Every acceptance criterion's `Decisions:` and `Scenarios:` fields. +- Every verification scenario with its `Then:` and `Check:`. +- Every task's `Covers:` field. + Return `blocked` when the plan has no usable acceptance criteria, or when no validation commands can be determined from the plan or repository conventions. @@ -64,7 +77,35 @@ Prefer the plan's authored checks. Fall back to repository-primary test, lint, and format commands only when `Full validation` is absent, and record that fallback under notes on a `validated` or `failed` result. -### 4. Run full validation and acceptance checks +### 4. Check plan references + +Run `sce plan check {plan-path}` before scenario +verification. Record each reported diagnostic as a failed check under +**Failed checks and follow-ups**, quoting its line and message. + +Trust the checker only when it actually ran: a result counts only on exit `0` +with `structured plan: reference integrity holds` on stdout, or on exit `3` +with diagnostics. Any other outcome means the checker is unavailable; record +that fallback under notes and apply the manual pass below instead. + +For the manual pass, apply every entry of the plan template's +`Reference rules` to the plan. Each of these is a relationship-integrity +defect: + +- An ID defined more than once. +- A dangling reference: a `Decisions:`, `Scenarios:`, or `Covers:` ID with no + definition in the plan. +- An acceptance criterion without `Scenarios:`. +- An acceptance criterion that no task's `Covers:` names. +- A task without `Covers:` or with an empty `Covers:`. +- A scenario that no acceptance criterion's `Scenarios:` references. + +Record each defect as a failed check under **Failed checks and follow-ups**, +naming the IDs involved. A relationship-integrity defect makes the result +`failed`; never tolerate one silently. Continue gathering the remaining +evidence. + +### 5. Run full validation and acceptance checks Run the plan's `Full validation` commands. @@ -72,6 +113,26 @@ Then verify each acceptance criterion using its `Validate:` line. Prefer a runnable command. Use a named inspection only when the criterion authorizes it, and say exactly what was inspected. +Run or perform every scenario's `Check:` yourself in +this run, and compare the observed result with the scenario's `Then:`. Give +each scenario exactly one status: + +- `Verified` when this run's evidence proves its `Then:` in full. +- `Partially verified` when the check ran but proves `Then:` only in part, for + example at a narrower level than `Then:` requires. +- `Not verified` when the check failed, could not be run or located, or was + not performed. + +Only evidence gathered by this run counts. Task completion, a task's recorded +`Verify` outcomes, a planned `Check:` line, and the plan's own claims are never +evidence. Record every `Partially verified` or `Not verified` scenario as a +failed check with the gap and a required action. + +An acceptance criterion is met only when its `Validate:` +check passes and every scenario its `Scenarios:` names is `Verified`. An +acceptance criterion marked `none (inspection)` is met by its `Validate:` check +alone. + Treat leftover debug-only flags, temporary files, intermediate artifacts, or local scaffolding as a failed validation check. Record the path and evidence under **Failed checks and follow-ups**; never delete or repair it during @@ -87,7 +148,7 @@ inspection confirmed the criterion. Do not run task-by-task implementation work for incomplete tasks. That belongs to `/next-task`. -### 5. Update the plan +### 6. Update the plan For `validated` and `failed` outcomes: @@ -101,7 +162,7 @@ Do not reopen completed tasks, rewrite task evidence, or change the task stack. For `blocked`, leave the plan file unchanged. -### 6. Return the Markdown report +### 7. Return the Markdown report Return exactly one Markdown report: @@ -176,8 +237,12 @@ invoking workflow. ## Acceptance criteria -- [x] AC1: {criterion statement} — {evidence} -- [x] AC2: {criterion statement} — {evidence} +- [x] AC1: {criterion statement} — {evidence}; scenarios: S1 +- [x] AC2: {criterion statement} — {evidence}; scenarios: none (inspection) + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified ## Residual risks @@ -218,11 +283,19 @@ a normal work session, then rerun validation. - `{check or AC id}`: {problem} - Evidence: {command output, exit summary, or inspection finding} - Required action: {concrete repair or decision} +- `{scenario id}` ({AC ids}): {Partially verified | Not verified} — {gap between the observed result and `Then:`} + - Evidence: {exact test, command, or inspected location and its actual result} + - Required action: {concrete repair or decision} ## Acceptance criteria -- [x] AC1: {criterion} — {evidence} -- [ ] AC2: {criterion} — {why unmet} +- [x] AC1: {criterion} — {evidence}; scenarios: S1 +- [ ] AC2: {criterion} — {why unmet}; scenarios: S2 + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified +- S2 (AC2): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Partially verified | Not verified} ## Commands run @@ -303,6 +376,13 @@ a normal work session, then rerun validation. `/validate {plan path}` as the final step after repairs. - The failed variant must be self-contained enough to hand to another session without the original chat. +- The validated and failed variants include **Scenario + verification** with exactly one entry per scenario in plan order: the scenario + ID, the acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status. Each acceptance-criterion line + cites its scenario IDs, or `none (inspection)`. In the failed variant, list + every `Partially verified` or `Not verified` scenario under **What failed**. - Do not include durable context synchronization results in this report. - Do not select or describe an unrelated next implementation task when status is `validated`. diff --git a/.claude/skills/sce-change-to-plan/references/plan-authoring.md b/.claude/skills/sce-change-to-plan/references/plan-authoring.md index 5b614219b..0c8ecb0a3 100644 --- a/.claude/skills/sce-change-to-plan/references/plan-authoring.md +++ b/.claude/skills/sce-change-to-plan/references/plan-authoring.md @@ -28,9 +28,13 @@ This phase owns the planning process: - Write or revise exactly one `context/plans/{plan_name}.md`. `references/plan-template.md` is the sole owner of the persisted plan schema. -Read its `Acceptance criteria rules`, `Task rules` and `No validation task` rules, -and `Updating an existing plan` rules before authoring or revising. Apply those -rules rather than restating them here. +Read its `Acceptance criteria rules`, `Design decision rules`, +`Verification scenario rules`, `Reference rules`, `Task rules` and +`No validation task` rules, and `Updating an existing plan` rules before +authoring or revising. Apply those rules rather than restating them here. + +Author plan content in this order: design decisions, then acceptance criteria, +then verification scenarios, then tasks. The workflow renders this phase's result as the summary defined in `references/output.md`. @@ -57,7 +61,9 @@ When multiple existing plans match and none can be selected safely, return `blocked` with the matching candidates. When the request targets a new plan, derive `plan_name` as a short kebab-case -slug of the change, and confirm it does not collide with an existing plan. +slug of the change, and confirm it does not collide with an existing plan. Do +not create the plan file here; step 2.10 initializes it after the clarification +gate. Resolve exactly one plan target per invocation. @@ -151,31 +157,109 @@ the repository does not yet have. Do not explore the entire repository by default. -## 2.5 Author the acceptance criteria +## 2.5 Record design decisions + +Record the architectural choices this plan commits to under `## Design decisions`, +applying the `Design decision rules` in `references/plan-template.md`. Do not +manufacture a decision to fill the section. When the change commits to no such +choice, omit the section. + +## 2.6 Author the acceptance criteria Derive the plan-specific success outcomes and checks before slicing tasks, then apply the `Acceptance criteria rules` and exact section shape in `references/plan-template.md`. The template owns their generic validation semantics and placement. -## 2.6 Author the task stack +Give each criterion its `Decisions:` and `Scenarios:` fields as the +`Reference rules` define them. + +## 2.7 Author the verification scenarios + +Write the scenarios the acceptance criteria depend on under +`### Verification scenarios`, applying the `Verification scenario rules` in +`references/plan-template.md`. Cover normal, boundary, failure, cancellation, +concurrency, and recovery cases only where the change makes them relevant. +Do not invent test names: a `Check:` names an existing test, a test a task in +the stack creates, a command, or a named inspection. + +## 2.8 Author the task stack -Slice and order the plan-specific work after the acceptance criteria, applying the -`Task rules` and `No validation task` rules in `references/plan-template.md`. +Slice and order the plan-specific work after the acceptance criteria and +verification scenarios, applying the `Task rules` and `No validation task` rules in `references/plan-template.md`. Do not restate those generic task rules here. +Give each task its `Covers:` field as the `Reference rules` define it. + A finished stack always leaves at least one incomplete task, so the workflow can always hand off to `/next-task`. When the request resolves to a plan but produces no incomplete task, because the change is already implemented or already covered by completed tasks, set internal status `blocked` with category `no_actionable_work` instead of writing the plan. -## 2.7 Write the plan +## 2.9 Check references + +Before writing the plan, run a reference-consistency pass over the +complete draft that applies every `Reference rules` entry in +`references/plan-template.md`. Fix each defect in the draft before writing. Do +not write a plan that fails this pass. + +This pass is pre-write authoring guidance only. It never substitutes for a +successful `sce plan check` in step 2.10. + +## 2.10 Write the plan + +Structured plans are the only supported format. This step has no fallback for +`sce plan init` or `sce plan check`. + +### New plan + +Run `sce plan init {plan_name}`. Initialization succeeds only when all three +hold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`, +and that file exists. Every other result returns `blocked` with the exit code +and stderr. This includes a missing `sce` executable, a binary without `init`, +a nonzero exit, an exit `0` without the expected line, and an expected line +with no created file. + +Fill the generated scaffold with the plan content, applying the authoring rules +in `references/plan-template.md`. The template is the authoring reference for +filling the scaffold, never a substitute for `sce plan init`. Never write a new +plan without it. + +### Existing plan + +Do not run `sce plan init`. Revise the plan by applying the +`Updating an existing plan` rules in `references/plan-template.md`, which +preserve completed tasks and their evidence. The revised plan must satisfy the +same structured plan contract. + +### Completeness gate + +For new plans and revisions alike, the plan contains no scaffold `TODO` +placeholder, and optional sections without real entries (`Design decisions`, +`Assumptions`) are removed. The change summary, acceptance criteria, design +decisions, verification scenarios, task definitions, and verify instructions +are authored for this change. + +While any placeholder remains, keep authoring or return `blocked`. A passing +`sce plan check` is required but not sufficient: it is structural and does not +detect placeholders. + +### Structural check + +For new plans and revisions alike, run `sce plan check {plan-path}`. It passes +only on exit `0` with `structured plan: reference integrity holds` on stdout. + +On exit `3` with structural diagnostics, fix each reported defect in the plan +and run it again, or return `blocked` with the remaining diagnostics when they +cannot be resolved. Every other result returns `blocked` with the exit code and +stderr. This includes a missing `sce` executable, a binary without `check`, any +other exit code, and an exit `0` without the success marker. -Write `context/plans/{plan_name}.md` by applying `references/plan-template.md` -exactly. For revisions, apply its `Updating an existing plan` rules. +Set internal status `plan_ready` only after both the completeness gate and +`sce plan check` pass. -## 2.8 Return the result +## 2.11 Return the result Return one internal result with one of these statuses: diff --git a/.claude/skills/sce-change-to-plan/references/plan-template.md b/.claude/skills/sce-change-to-plan/references/plan-template.md index fe0190b94..7f2674e3a 100644 --- a/.claude/skills/sce-change-to-plan/references/plan-template.md +++ b/.claude/skills/sce-change-to-plan/references/plan-template.md @@ -18,6 +18,19 @@ entirely rather than writing them empty. {One or two paragraphs: what changes, where, and why. State whether this extends existing behavior, replaces it, or preserves work already in progress.} +## Design decisions + +{Optional. Include only real architectural choices this plan commits to. Remove +the section when there are none.} + +- D1: {decision} + - Boundary: {architectural boundary the decision governs} + - Rationale: {why} + - Rejected: {alternative} — {why not} + - Trade-offs: {known limitation or cost} + - Review: required + - Review question: {optional; one question for senior engineering judgment; remove both review lines when none is needed} + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -26,8 +39,19 @@ performs final validation. - [ ] AC1: {observable outcome, stated as behavior rather than as work done} - Validate: `{command, assertion, or inspection that proves AC1}` + - Decisions: {decision IDs, e.g. D1; omit the field when no decision applies} + - Scenarios: {scenario IDs, e.g. S1, S2} - [ ] AC2: {observable outcome} - Validate: `{command, assertion, or inspection that proves AC2}` + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - When: {action} + - Then: {exact observable result or invariant} + - Check: {planned test, command, or named inspection} ### Full validation @@ -70,6 +94,7 @@ recorded. Remove the section otherwise.} - [ ] T01: `{single intent title}` (status:todo) - Task ID: T01 + - Covers: {acceptance criterion IDs, e.g. AC1} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: {task IDs, or none} - Done when: {clear acceptance for one coherent change} @@ -78,6 +103,7 @@ recorded. Remove the section otherwise.} - [ ] T02: `{single intent title}` (status:todo) - Task ID: T02 + - Covers: {acceptance criterion IDs, e.g. AC2} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: T01 - Done when: {clear acceptance for one coherent change} @@ -103,6 +129,7 @@ invent one: `None.` is the expected answer for a well-specified change.} ```markdown - [ ] T02: `Add /auth/refresh endpoint` (status:todo) - Task ID: T02 + - Covers: AC1 - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. - Dependencies: T01 - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. @@ -110,6 +137,47 @@ invent one: `None.` is the expected answer for a well-specified change.} - Context synchronization: pending ``` +## Filled-in structured plan example + +```markdown +## Design decisions + +- D1: The existing session store validates refresh tokens; no new token service is added. + - Boundary: `src/auth/` owns token validation; route handlers call it and never read the session store directly. + - Rationale: The session store already tracks revocation, so one owner keeps revocation consistent. + - Rejected: Stateless refresh tokens — they cannot be revoked before they expire. + - Trade-offs: Every refresh costs one session-store read. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` returns a signed JWT for a valid refresh token and 401 for an expired or revoked one. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh` with its 200 and 401 responses. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A refresh token that has not expired but is revoked in the session store. + - When: The client calls `POST /auth/refresh` with that token. + - Then: The response is 401 and no JWT is issued. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"`, created by T01. + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 + - Scope: In — route handler, session-store validation call, OpenAPI entry. Out — refresh token rotation policy. + - Dependencies: none + - Done when: The endpoint returns a JWT or 401 as AC1 states, the revoked-token test exists and passes, and the OpenAPI entry is present. + - Verify: `pnpm test src/auth/refresh.test.ts`. + - Context synchronization: pending +``` + ## Acceptance criteria rules - Acceptance criteria describe the finished system, not the work. Prefer "the @@ -127,6 +195,49 @@ invent one: `None.` is the expected answer for a well-specified change.} change request. If a criterion has no task that could satisfy it, the task stack is incomplete. +## Design decision rules + +- Record a design decision only for a real architectural choice the plan + commits to, such as an ownership boundary, a data shape, an interface, or an + alternative a reviewer would otherwise reopen. +- Every decision carries `Boundary:`, `Rationale:`, `Rejected:`, and + `Trade-offs:`. +- A decision may also carry the optional review pair `Review: required` and + `Review question: {one question}`. Mark only a decision that needs senior + engineering judgment before the change merges, such as a trade-off a reviewer + must accept. Leave every other decision unmarked. +- Write the review question as one specific question that a reviewer can + accept or reject. Name the architectural choice and its consequence, so the + reviewer can answer from the decision alone. Prefer "Is reading plans from + the working tree instead of the `--head` commit acceptable for stable + links?" over "Is this design OK?" or "Any concerns about plan reads?". +- Ask about each concern once. Do not mark a second decision that raises the + same concern as another marked decision, and do not combine two concerns in + one question. +- Mark every decision that meets this bar. There is no maximum number of + marked decisions. Do not drop an important review question or merge + unrelated questions to lower the count. +- Three or more marked decisions are a signal that the change may be too large + for one focused review. Consider moving independent changes into a separate + pull request. This is advisory: keep tightly coupled changes together, and + complete the plan either way. +- The review pair is all or nothing: a decision carries both fields or + neither. `Review:` is exactly `required`, and `Review question:` is one + nonempty line that states one question. +- An unmarked decision carries no review field. Do not add a placeholder value + such as `Review: none` or an empty `Review question:`. +- A design decision belongs to this plan only. It does not replace an + architecture decision record. + +## Verification scenario rules + +- A scenario states one behavior that an acceptance criterion depends on, as + `Given:`, `When:`, `Then:`, and `Check:`. +- `Then:` states an exact observable result or invariant, not an intention. +- `Check:` names the planned test, command, or named inspection that would + prove `Then:`. Name a test only when it already exists or a task in the stack + creates it. + ## Task rules - Number tasks sequentially as `T01..T0N`. @@ -142,6 +253,51 @@ invent one: `None.` is the expected answer for a well-specified change.} acceptance checks. - Order tasks so each one's declared dependencies precede it. +## Reference rules + +- Read plan structure only from Markdown outside fenced code blocks. A fenced + code block runs from a line that opens a fence of three or more backticks or + tildes to its closing fence. Headings, definitions, and fields inside it are + example text, not plan structure. Keep fenced examples in place; never delete + one to make a plan read correctly. +- A section runs from its heading to the next heading of any level outside + fenced code blocks. A definition counts only in its own section: `D` entries + under `## Design decisions`, `AC` entries under `## Acceptance criteria`, `S` + entries under `### Verification scenarios`, and `T` entries under + `## Task stack`. A line shaped like a definition anywhere else, such as in a + fenced example or a validation report, defines nothing. +- A definition's fields are the `- Field:` lines indented one level under its + entry line, up to the next entry. `Boundary:`, `Rationale:`, `Rejected:`, + `Trade-offs:`, and the optional `Review:` and `Review question:` belong to a + design decision; `Validate:`, `Decisions:`, and + `Scenarios:` to an acceptance criterion; `Given:`, `When:`, `Then:`, and + `Check:` to a verification scenario; and `Covers:` to a task. +- To check a fenced example itself, extract the block's content and apply these + rules to it as a separate sample document. +- IDs are `D1..Dn` for design decisions, `AC1..ACn` for acceptance criteria, + `S1..Sn` for verification scenarios, and `T01..T0N` for tasks. Each entry + starts its own line with its ID: `- D1:`, `- [ ] AC1:`, `- S1:`, and + `- [ ] T01:`. +- Define each ID exactly once. +- Number decisions, criteria, and scenarios sequentially in order of first + assignment. Never renumber or reuse a decision, criterion, or scenario ID; a + withdrawn entry keeps its ID and says that it is withdrawn. +- Each reference field fits on one line: `Decisions:` and `Scenarios:` on an + acceptance criterion, and `Covers:` on a task. Its value is a list of IDs + separated by a comma and a space, for example `S1, S2`. +- Every referenced ID resolves to a definition in the same plan. +- Every acceptance criterion carries `Scenarios:`, with scenario IDs or the + literal `none (inspection)` when only an inspection can prove it. Omit + `Decisions:` when no decision applies. +- Every task carries `Covers:` naming at least one + acceptance criterion defined in the plan. A task without `Covers:` or with an + empty `Covers:` is a defect even when other tasks cover every criterion. +- Every acceptance criterion is named by at least one task's `Covers:`. +- Every scenario is referenced by at least one acceptance criterion's + `Scenarios:`. +- A planned `Check:` is not execution evidence. Only `/validate` running or + performing the check verifies a scenario. + ## No validation task - The last task in the stack is an ordinary implementation task. Do not author a @@ -183,3 +339,4 @@ Do not author either while planning. run earlier, and never renumber a completed task. - Add acceptance criteria for newly planned outcomes rather than rewriting criteria already satisfied. +- Give each new decision, criterion, or scenario the next unused ID of its kind. diff --git a/.claude/skills/sce-next-task/SKILL.md b/.claude/skills/sce-next-task/SKILL.md index 2806e3a51..282f228df 100644 --- a/.claude/skills/sce-next-task/SKILL.md +++ b/.claude/skills/sce-next-task/SKILL.md @@ -92,7 +92,7 @@ selected or resumed, before allowing a new implementation task to start. A missing field, or any value other than `synced`, is unresolved synchronization debt. Never infer `synced` from conversation history. When the debt-carrying task has no durable completed-task record, the **Plan review phase** returns -`blocked` directly with a legacy-migration required action; otherwise it returns +`blocked` directly with a plan-migration required action; otherwise it returns `sync_debt` with the resolved plan path, debt task ID/title, completed task record, and persisted blocker when present, for the branch above to route. diff --git a/.claude/skills/sce-next-task/references/output.md b/.claude/skills/sce-next-task/references/output.md index 1ddc071ab..bf897f69b 100644 --- a/.claude/skills/sce-next-task/references/output.md +++ b/.claude/skills/sce-next-task/references/output.md @@ -113,6 +113,12 @@ Describe the smallest coherent implementation approach with enough detail for ap - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. +## Acceptance coverage + +- List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. +- Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} diff --git a/.claude/skills/sce-next-task/references/plan-review.md b/.claude/skills/sce-next-task/references/plan-review.md index ae6a97b66..6d6c4943e 100644 --- a/.claude/skills/sce-next-task/references/plan-review.md +++ b/.claude/skills/sce-next-task/references/plan-review.md @@ -29,8 +29,8 @@ any value other than `synced`, is unresolved synchronization debt. Never infer For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating - that structure, or an incomplete write), do not attempt a reconstructed retry. + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. Set internal status `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -74,6 +74,18 @@ architecture, shared terminology, or cross-domain interfaces. Do not explore the entire repository by default. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Read the selected task's `Covers:` IDs. For each covered +acceptance criterion, read its `Scenarios:` and `Decisions:` fields and the +scenario and decision definitions they name. A covered ID or referenced ID with +no definition in the plan is a dangling reference. Record it as a review risk; +it does not make the task `blocked`. + ## 1.4 Determine readiness A task is `ready` when: @@ -128,6 +140,14 @@ A `ready` result must identify: - Relevant files and context. - Review assumptions. +A `ready` result also identifies: + +- The covered acceptance criteria: each `Covers:` ID with its outcome and + `Validate:` check. +- Their verification scenarios: each scenario ID with its `Then:` and `Check:`. +- Their related design decisions: each decision ID with its decision text. +- Every dangling reference, as a review risk. + A `sync_debt` result must identify: - The resolved plan path. diff --git a/.claude/skills/sce-next-task/references/task-execution.md b/.claude/skills/sce-next-task/references/task-execution.md index ae68ed4c3..4c92e6a70 100644 --- a/.claude/skills/sce-next-task/references/task-execution.md +++ b/.claude/skills/sce-next-task/references/task-execution.md @@ -132,6 +132,12 @@ Run the narrowest authoritative checks that demonstrate the done checks. Start with verification supplied by the readiness result. Add nearby or directly relevant checks only when needed. +The covered scenarios' `Then:` and `Check:` may shape +the task's own `Verify` checks. Running a scenario's check during the task is +task-level evidence only. Task execution never checks an acceptance-criterion +checkbox and never records a scenario status; only `/validate` verifies +acceptance criteria and scenarios. + Verification may include: - Targeted tests. @@ -167,6 +173,7 @@ Only after successful implementation and task-level verification: facts above and before the synchronization phase is invoked. - Record material deviations or approved assumptions. - Preserve the plan's existing structure and terminology. +- Keep the task's `Covers:` field as authored. Do not mark the task complete when returning `declined`, `blocked`, or `incomplete`. diff --git a/.claude/skills/sce-validate/references/validation-report.md b/.claude/skills/sce-validate/references/validation-report.md index b0fa7778b..f3ef7379f 100644 --- a/.claude/skills/sce-validate/references/validation-report.md +++ b/.claude/skills/sce-validate/references/validation-report.md @@ -31,8 +31,12 @@ writes it. ### Success-criteria verification -- [x] AC1: {criterion statement} -> {evidence} -- [ ] AC2: {criterion statement} -> {evidence of failure or not checked} +- [x] AC1: {criterion statement} -> {evidence}; scenarios: S1 +- [ ] AC2: {criterion statement} -> {evidence of failure or not checked}; scenarios: S2, S3 + +### Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified} ### Failed checks and follow-ups @@ -61,7 +65,15 @@ After repairs, rerun: run**. Preserve its exit code and concise outcome; do not invent either. - Under **Success-criteria verification**, render the acceptance-criterion checkbox state and evidence already established by validation execution. Do - not independently re-evaluate or reclassify criteria here. + not independently re-evaluate or reclassify criteria here. End each + acceptance-criterion line with the scenario IDs its + `Scenarios:` field names, or `none (inspection)`. +- Include **Scenario verification** with exactly one entry per scenario in + plan order. Each entry gives the scenario ID, the + acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status already established by validation + execution. - Under **Failed checks and follow-ups**, render every failure and follow-up supplied by validation execution. Write `None.` when status is `validated`. - When status is `failed`, always include **Retry** with the exact diff --git a/.claude/skills/sce-validate/references/validation.md b/.claude/skills/sce-validate/references/validation.md index 5fdf8e6f9..2bc27bb88 100644 --- a/.claude/skills/sce-validate/references/validation.md +++ b/.claude/skills/sce-validate/references/validation.md @@ -57,6 +57,19 @@ From the plan, collect: - Every acceptance criterion and its `Validate:` check. - The `Full validation` command list. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Also collect: + +- Every design decision. +- Every acceptance criterion's `Decisions:` and `Scenarios:` fields. +- Every verification scenario with its `Then:` and `Check:`. +- Every task's `Covers:` field. + Return `blocked` when the plan has no usable acceptance criteria, or when no validation commands can be determined from the plan or repository conventions. @@ -64,7 +77,35 @@ Prefer the plan's authored checks. Fall back to repository-primary test, lint, and format commands only when `Full validation` is absent, and record that fallback under notes on a `validated` or `failed` result. -### 4. Run full validation and acceptance checks +### 4. Check plan references + +Run `sce plan check {plan-path}` before scenario +verification. Record each reported diagnostic as a failed check under +**Failed checks and follow-ups**, quoting its line and message. + +Trust the checker only when it actually ran: a result counts only on exit `0` +with `structured plan: reference integrity holds` on stdout, or on exit `3` +with diagnostics. Any other outcome means the checker is unavailable; record +that fallback under notes and apply the manual pass below instead. + +For the manual pass, apply every entry of the plan template's +`Reference rules` to the plan. Each of these is a relationship-integrity +defect: + +- An ID defined more than once. +- A dangling reference: a `Decisions:`, `Scenarios:`, or `Covers:` ID with no + definition in the plan. +- An acceptance criterion without `Scenarios:`. +- An acceptance criterion that no task's `Covers:` names. +- A task without `Covers:` or with an empty `Covers:`. +- A scenario that no acceptance criterion's `Scenarios:` references. + +Record each defect as a failed check under **Failed checks and follow-ups**, +naming the IDs involved. A relationship-integrity defect makes the result +`failed`; never tolerate one silently. Continue gathering the remaining +evidence. + +### 5. Run full validation and acceptance checks Run the plan's `Full validation` commands. @@ -72,6 +113,26 @@ Then verify each acceptance criterion using its `Validate:` line. Prefer a runnable command. Use a named inspection only when the criterion authorizes it, and say exactly what was inspected. +Run or perform every scenario's `Check:` yourself in +this run, and compare the observed result with the scenario's `Then:`. Give +each scenario exactly one status: + +- `Verified` when this run's evidence proves its `Then:` in full. +- `Partially verified` when the check ran but proves `Then:` only in part, for + example at a narrower level than `Then:` requires. +- `Not verified` when the check failed, could not be run or located, or was + not performed. + +Only evidence gathered by this run counts. Task completion, a task's recorded +`Verify` outcomes, a planned `Check:` line, and the plan's own claims are never +evidence. Record every `Partially verified` or `Not verified` scenario as a +failed check with the gap and a required action. + +An acceptance criterion is met only when its `Validate:` +check passes and every scenario its `Scenarios:` names is `Verified`. An +acceptance criterion marked `none (inspection)` is met by its `Validate:` check +alone. + Treat leftover debug-only flags, temporary files, intermediate artifacts, or local scaffolding as a failed validation check. Record the path and evidence under **Failed checks and follow-ups**; never delete or repair it during @@ -87,7 +148,7 @@ inspection confirmed the criterion. Do not run task-by-task implementation work for incomplete tasks. That belongs to `/next-task`. -### 5. Update the plan +### 6. Update the plan For `validated` and `failed` outcomes: @@ -101,7 +162,7 @@ Do not reopen completed tasks, rewrite task evidence, or change the task stack. For `blocked`, leave the plan file unchanged. -### 6. Return the Markdown report +### 7. Return the Markdown report Return exactly one Markdown report: @@ -176,8 +237,12 @@ invoking workflow. ## Acceptance criteria -- [x] AC1: {criterion statement} — {evidence} -- [x] AC2: {criterion statement} — {evidence} +- [x] AC1: {criterion statement} — {evidence}; scenarios: S1 +- [x] AC2: {criterion statement} — {evidence}; scenarios: none (inspection) + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified ## Residual risks @@ -218,11 +283,19 @@ a normal work session, then rerun validation. - `{check or AC id}`: {problem} - Evidence: {command output, exit summary, or inspection finding} - Required action: {concrete repair or decision} +- `{scenario id}` ({AC ids}): {Partially verified | Not verified} — {gap between the observed result and `Then:`} + - Evidence: {exact test, command, or inspected location and its actual result} + - Required action: {concrete repair or decision} ## Acceptance criteria -- [x] AC1: {criterion} — {evidence} -- [ ] AC2: {criterion} — {why unmet} +- [x] AC1: {criterion} — {evidence}; scenarios: S1 +- [ ] AC2: {criterion} — {why unmet}; scenarios: S2 + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified +- S2 (AC2): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Partially verified | Not verified} ## Commands run @@ -303,6 +376,13 @@ a normal work session, then rerun validation. `/validate {plan path}` as the final step after repairs. - The failed variant must be self-contained enough to hand to another session without the original chat. +- The validated and failed variants include **Scenario + verification** with exactly one entry per scenario in plan order: the scenario + ID, the acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status. Each acceptance-criterion line + cites its scenario IDs, or `none (inspection)`. In the failed variant, list + every `Partially verified` or `Not verified` scenario under **What failed**. - Do not include durable context synchronization results in this report. - Do not select or describe an unrelated next implementation task when status is `validated`. diff --git a/.opencode/skills/sce-change-to-plan/references/plan-authoring.md b/.opencode/skills/sce-change-to-plan/references/plan-authoring.md index 5b614219b..0c8ecb0a3 100644 --- a/.opencode/skills/sce-change-to-plan/references/plan-authoring.md +++ b/.opencode/skills/sce-change-to-plan/references/plan-authoring.md @@ -28,9 +28,13 @@ This phase owns the planning process: - Write or revise exactly one `context/plans/{plan_name}.md`. `references/plan-template.md` is the sole owner of the persisted plan schema. -Read its `Acceptance criteria rules`, `Task rules` and `No validation task` rules, -and `Updating an existing plan` rules before authoring or revising. Apply those -rules rather than restating them here. +Read its `Acceptance criteria rules`, `Design decision rules`, +`Verification scenario rules`, `Reference rules`, `Task rules` and +`No validation task` rules, and `Updating an existing plan` rules before +authoring or revising. Apply those rules rather than restating them here. + +Author plan content in this order: design decisions, then acceptance criteria, +then verification scenarios, then tasks. The workflow renders this phase's result as the summary defined in `references/output.md`. @@ -57,7 +61,9 @@ When multiple existing plans match and none can be selected safely, return `blocked` with the matching candidates. When the request targets a new plan, derive `plan_name` as a short kebab-case -slug of the change, and confirm it does not collide with an existing plan. +slug of the change, and confirm it does not collide with an existing plan. Do +not create the plan file here; step 2.10 initializes it after the clarification +gate. Resolve exactly one plan target per invocation. @@ -151,31 +157,109 @@ the repository does not yet have. Do not explore the entire repository by default. -## 2.5 Author the acceptance criteria +## 2.5 Record design decisions + +Record the architectural choices this plan commits to under `## Design decisions`, +applying the `Design decision rules` in `references/plan-template.md`. Do not +manufacture a decision to fill the section. When the change commits to no such +choice, omit the section. + +## 2.6 Author the acceptance criteria Derive the plan-specific success outcomes and checks before slicing tasks, then apply the `Acceptance criteria rules` and exact section shape in `references/plan-template.md`. The template owns their generic validation semantics and placement. -## 2.6 Author the task stack +Give each criterion its `Decisions:` and `Scenarios:` fields as the +`Reference rules` define them. + +## 2.7 Author the verification scenarios + +Write the scenarios the acceptance criteria depend on under +`### Verification scenarios`, applying the `Verification scenario rules` in +`references/plan-template.md`. Cover normal, boundary, failure, cancellation, +concurrency, and recovery cases only where the change makes them relevant. +Do not invent test names: a `Check:` names an existing test, a test a task in +the stack creates, a command, or a named inspection. + +## 2.8 Author the task stack -Slice and order the plan-specific work after the acceptance criteria, applying the -`Task rules` and `No validation task` rules in `references/plan-template.md`. +Slice and order the plan-specific work after the acceptance criteria and +verification scenarios, applying the `Task rules` and `No validation task` rules in `references/plan-template.md`. Do not restate those generic task rules here. +Give each task its `Covers:` field as the `Reference rules` define it. + A finished stack always leaves at least one incomplete task, so the workflow can always hand off to `/next-task`. When the request resolves to a plan but produces no incomplete task, because the change is already implemented or already covered by completed tasks, set internal status `blocked` with category `no_actionable_work` instead of writing the plan. -## 2.7 Write the plan +## 2.9 Check references + +Before writing the plan, run a reference-consistency pass over the +complete draft that applies every `Reference rules` entry in +`references/plan-template.md`. Fix each defect in the draft before writing. Do +not write a plan that fails this pass. + +This pass is pre-write authoring guidance only. It never substitutes for a +successful `sce plan check` in step 2.10. + +## 2.10 Write the plan + +Structured plans are the only supported format. This step has no fallback for +`sce plan init` or `sce plan check`. + +### New plan + +Run `sce plan init {plan_name}`. Initialization succeeds only when all three +hold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`, +and that file exists. Every other result returns `blocked` with the exit code +and stderr. This includes a missing `sce` executable, a binary without `init`, +a nonzero exit, an exit `0` without the expected line, and an expected line +with no created file. + +Fill the generated scaffold with the plan content, applying the authoring rules +in `references/plan-template.md`. The template is the authoring reference for +filling the scaffold, never a substitute for `sce plan init`. Never write a new +plan without it. + +### Existing plan + +Do not run `sce plan init`. Revise the plan by applying the +`Updating an existing plan` rules in `references/plan-template.md`, which +preserve completed tasks and their evidence. The revised plan must satisfy the +same structured plan contract. + +### Completeness gate + +For new plans and revisions alike, the plan contains no scaffold `TODO` +placeholder, and optional sections without real entries (`Design decisions`, +`Assumptions`) are removed. The change summary, acceptance criteria, design +decisions, verification scenarios, task definitions, and verify instructions +are authored for this change. + +While any placeholder remains, keep authoring or return `blocked`. A passing +`sce plan check` is required but not sufficient: it is structural and does not +detect placeholders. + +### Structural check + +For new plans and revisions alike, run `sce plan check {plan-path}`. It passes +only on exit `0` with `structured plan: reference integrity holds` on stdout. + +On exit `3` with structural diagnostics, fix each reported defect in the plan +and run it again, or return `blocked` with the remaining diagnostics when they +cannot be resolved. Every other result returns `blocked` with the exit code and +stderr. This includes a missing `sce` executable, a binary without `check`, any +other exit code, and an exit `0` without the success marker. -Write `context/plans/{plan_name}.md` by applying `references/plan-template.md` -exactly. For revisions, apply its `Updating an existing plan` rules. +Set internal status `plan_ready` only after both the completeness gate and +`sce plan check` pass. -## 2.8 Return the result +## 2.11 Return the result Return one internal result with one of these statuses: diff --git a/.opencode/skills/sce-change-to-plan/references/plan-template.md b/.opencode/skills/sce-change-to-plan/references/plan-template.md index fe0190b94..7f2674e3a 100644 --- a/.opencode/skills/sce-change-to-plan/references/plan-template.md +++ b/.opencode/skills/sce-change-to-plan/references/plan-template.md @@ -18,6 +18,19 @@ entirely rather than writing them empty. {One or two paragraphs: what changes, where, and why. State whether this extends existing behavior, replaces it, or preserves work already in progress.} +## Design decisions + +{Optional. Include only real architectural choices this plan commits to. Remove +the section when there are none.} + +- D1: {decision} + - Boundary: {architectural boundary the decision governs} + - Rationale: {why} + - Rejected: {alternative} — {why not} + - Trade-offs: {known limitation or cost} + - Review: required + - Review question: {optional; one question for senior engineering judgment; remove both review lines when none is needed} + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -26,8 +39,19 @@ performs final validation. - [ ] AC1: {observable outcome, stated as behavior rather than as work done} - Validate: `{command, assertion, or inspection that proves AC1}` + - Decisions: {decision IDs, e.g. D1; omit the field when no decision applies} + - Scenarios: {scenario IDs, e.g. S1, S2} - [ ] AC2: {observable outcome} - Validate: `{command, assertion, or inspection that proves AC2}` + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - When: {action} + - Then: {exact observable result or invariant} + - Check: {planned test, command, or named inspection} ### Full validation @@ -70,6 +94,7 @@ recorded. Remove the section otherwise.} - [ ] T01: `{single intent title}` (status:todo) - Task ID: T01 + - Covers: {acceptance criterion IDs, e.g. AC1} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: {task IDs, or none} - Done when: {clear acceptance for one coherent change} @@ -78,6 +103,7 @@ recorded. Remove the section otherwise.} - [ ] T02: `{single intent title}` (status:todo) - Task ID: T02 + - Covers: {acceptance criterion IDs, e.g. AC2} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: T01 - Done when: {clear acceptance for one coherent change} @@ -103,6 +129,7 @@ invent one: `None.` is the expected answer for a well-specified change.} ```markdown - [ ] T02: `Add /auth/refresh endpoint` (status:todo) - Task ID: T02 + - Covers: AC1 - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. - Dependencies: T01 - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. @@ -110,6 +137,47 @@ invent one: `None.` is the expected answer for a well-specified change.} - Context synchronization: pending ``` +## Filled-in structured plan example + +```markdown +## Design decisions + +- D1: The existing session store validates refresh tokens; no new token service is added. + - Boundary: `src/auth/` owns token validation; route handlers call it and never read the session store directly. + - Rationale: The session store already tracks revocation, so one owner keeps revocation consistent. + - Rejected: Stateless refresh tokens — they cannot be revoked before they expire. + - Trade-offs: Every refresh costs one session-store read. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` returns a signed JWT for a valid refresh token and 401 for an expired or revoked one. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh` with its 200 and 401 responses. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A refresh token that has not expired but is revoked in the session store. + - When: The client calls `POST /auth/refresh` with that token. + - Then: The response is 401 and no JWT is issued. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"`, created by T01. + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 + - Scope: In — route handler, session-store validation call, OpenAPI entry. Out — refresh token rotation policy. + - Dependencies: none + - Done when: The endpoint returns a JWT or 401 as AC1 states, the revoked-token test exists and passes, and the OpenAPI entry is present. + - Verify: `pnpm test src/auth/refresh.test.ts`. + - Context synchronization: pending +``` + ## Acceptance criteria rules - Acceptance criteria describe the finished system, not the work. Prefer "the @@ -127,6 +195,49 @@ invent one: `None.` is the expected answer for a well-specified change.} change request. If a criterion has no task that could satisfy it, the task stack is incomplete. +## Design decision rules + +- Record a design decision only for a real architectural choice the plan + commits to, such as an ownership boundary, a data shape, an interface, or an + alternative a reviewer would otherwise reopen. +- Every decision carries `Boundary:`, `Rationale:`, `Rejected:`, and + `Trade-offs:`. +- A decision may also carry the optional review pair `Review: required` and + `Review question: {one question}`. Mark only a decision that needs senior + engineering judgment before the change merges, such as a trade-off a reviewer + must accept. Leave every other decision unmarked. +- Write the review question as one specific question that a reviewer can + accept or reject. Name the architectural choice and its consequence, so the + reviewer can answer from the decision alone. Prefer "Is reading plans from + the working tree instead of the `--head` commit acceptable for stable + links?" over "Is this design OK?" or "Any concerns about plan reads?". +- Ask about each concern once. Do not mark a second decision that raises the + same concern as another marked decision, and do not combine two concerns in + one question. +- Mark every decision that meets this bar. There is no maximum number of + marked decisions. Do not drop an important review question or merge + unrelated questions to lower the count. +- Three or more marked decisions are a signal that the change may be too large + for one focused review. Consider moving independent changes into a separate + pull request. This is advisory: keep tightly coupled changes together, and + complete the plan either way. +- The review pair is all or nothing: a decision carries both fields or + neither. `Review:` is exactly `required`, and `Review question:` is one + nonempty line that states one question. +- An unmarked decision carries no review field. Do not add a placeholder value + such as `Review: none` or an empty `Review question:`. +- A design decision belongs to this plan only. It does not replace an + architecture decision record. + +## Verification scenario rules + +- A scenario states one behavior that an acceptance criterion depends on, as + `Given:`, `When:`, `Then:`, and `Check:`. +- `Then:` states an exact observable result or invariant, not an intention. +- `Check:` names the planned test, command, or named inspection that would + prove `Then:`. Name a test only when it already exists or a task in the stack + creates it. + ## Task rules - Number tasks sequentially as `T01..T0N`. @@ -142,6 +253,51 @@ invent one: `None.` is the expected answer for a well-specified change.} acceptance checks. - Order tasks so each one's declared dependencies precede it. +## Reference rules + +- Read plan structure only from Markdown outside fenced code blocks. A fenced + code block runs from a line that opens a fence of three or more backticks or + tildes to its closing fence. Headings, definitions, and fields inside it are + example text, not plan structure. Keep fenced examples in place; never delete + one to make a plan read correctly. +- A section runs from its heading to the next heading of any level outside + fenced code blocks. A definition counts only in its own section: `D` entries + under `## Design decisions`, `AC` entries under `## Acceptance criteria`, `S` + entries under `### Verification scenarios`, and `T` entries under + `## Task stack`. A line shaped like a definition anywhere else, such as in a + fenced example or a validation report, defines nothing. +- A definition's fields are the `- Field:` lines indented one level under its + entry line, up to the next entry. `Boundary:`, `Rationale:`, `Rejected:`, + `Trade-offs:`, and the optional `Review:` and `Review question:` belong to a + design decision; `Validate:`, `Decisions:`, and + `Scenarios:` to an acceptance criterion; `Given:`, `When:`, `Then:`, and + `Check:` to a verification scenario; and `Covers:` to a task. +- To check a fenced example itself, extract the block's content and apply these + rules to it as a separate sample document. +- IDs are `D1..Dn` for design decisions, `AC1..ACn` for acceptance criteria, + `S1..Sn` for verification scenarios, and `T01..T0N` for tasks. Each entry + starts its own line with its ID: `- D1:`, `- [ ] AC1:`, `- S1:`, and + `- [ ] T01:`. +- Define each ID exactly once. +- Number decisions, criteria, and scenarios sequentially in order of first + assignment. Never renumber or reuse a decision, criterion, or scenario ID; a + withdrawn entry keeps its ID and says that it is withdrawn. +- Each reference field fits on one line: `Decisions:` and `Scenarios:` on an + acceptance criterion, and `Covers:` on a task. Its value is a list of IDs + separated by a comma and a space, for example `S1, S2`. +- Every referenced ID resolves to a definition in the same plan. +- Every acceptance criterion carries `Scenarios:`, with scenario IDs or the + literal `none (inspection)` when only an inspection can prove it. Omit + `Decisions:` when no decision applies. +- Every task carries `Covers:` naming at least one + acceptance criterion defined in the plan. A task without `Covers:` or with an + empty `Covers:` is a defect even when other tasks cover every criterion. +- Every acceptance criterion is named by at least one task's `Covers:`. +- Every scenario is referenced by at least one acceptance criterion's + `Scenarios:`. +- A planned `Check:` is not execution evidence. Only `/validate` running or + performing the check verifies a scenario. + ## No validation task - The last task in the stack is an ordinary implementation task. Do not author a @@ -183,3 +339,4 @@ Do not author either while planning. run earlier, and never renumber a completed task. - Add acceptance criteria for newly planned outcomes rather than rewriting criteria already satisfied. +- Give each new decision, criterion, or scenario the next unused ID of its kind. diff --git a/.opencode/skills/sce-next-task/SKILL.md b/.opencode/skills/sce-next-task/SKILL.md index 70b87f4f0..99d5368c8 100644 --- a/.opencode/skills/sce-next-task/SKILL.md +++ b/.opencode/skills/sce-next-task/SKILL.md @@ -92,7 +92,7 @@ selected or resumed, before allowing a new implementation task to start. A missing field, or any value other than `synced`, is unresolved synchronization debt. Never infer `synced` from conversation history. When the debt-carrying task has no durable completed-task record, the **Plan review phase** returns -`blocked` directly with a legacy-migration required action; otherwise it returns +`blocked` directly with a plan-migration required action; otherwise it returns `sync_debt` with the resolved plan path, debt task ID/title, completed task record, and persisted blocker when present, for the branch above to route. diff --git a/.opencode/skills/sce-next-task/references/output.md b/.opencode/skills/sce-next-task/references/output.md index 1ddc071ab..bf897f69b 100644 --- a/.opencode/skills/sce-next-task/references/output.md +++ b/.opencode/skills/sce-next-task/references/output.md @@ -113,6 +113,12 @@ Describe the smallest coherent implementation approach with enough detail for ap - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. +## Acceptance coverage + +- List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. +- Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} diff --git a/.opencode/skills/sce-next-task/references/plan-review.md b/.opencode/skills/sce-next-task/references/plan-review.md index ae6a97b66..6d6c4943e 100644 --- a/.opencode/skills/sce-next-task/references/plan-review.md +++ b/.opencode/skills/sce-next-task/references/plan-review.md @@ -29,8 +29,8 @@ any value other than `synced`, is unresolved synchronization debt. Never infer For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating - that structure, or an incomplete write), do not attempt a reconstructed retry. + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. Set internal status `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -74,6 +74,18 @@ architecture, shared terminology, or cross-domain interfaces. Do not explore the entire repository by default. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Read the selected task's `Covers:` IDs. For each covered +acceptance criterion, read its `Scenarios:` and `Decisions:` fields and the +scenario and decision definitions they name. A covered ID or referenced ID with +no definition in the plan is a dangling reference. Record it as a review risk; +it does not make the task `blocked`. + ## 1.4 Determine readiness A task is `ready` when: @@ -128,6 +140,14 @@ A `ready` result must identify: - Relevant files and context. - Review assumptions. +A `ready` result also identifies: + +- The covered acceptance criteria: each `Covers:` ID with its outcome and + `Validate:` check. +- Their verification scenarios: each scenario ID with its `Then:` and `Check:`. +- Their related design decisions: each decision ID with its decision text. +- Every dangling reference, as a review risk. + A `sync_debt` result must identify: - The resolved plan path. diff --git a/.opencode/skills/sce-next-task/references/task-execution.md b/.opencode/skills/sce-next-task/references/task-execution.md index ae68ed4c3..4c92e6a70 100644 --- a/.opencode/skills/sce-next-task/references/task-execution.md +++ b/.opencode/skills/sce-next-task/references/task-execution.md @@ -132,6 +132,12 @@ Run the narrowest authoritative checks that demonstrate the done checks. Start with verification supplied by the readiness result. Add nearby or directly relevant checks only when needed. +The covered scenarios' `Then:` and `Check:` may shape +the task's own `Verify` checks. Running a scenario's check during the task is +task-level evidence only. Task execution never checks an acceptance-criterion +checkbox and never records a scenario status; only `/validate` verifies +acceptance criteria and scenarios. + Verification may include: - Targeted tests. @@ -167,6 +173,7 @@ Only after successful implementation and task-level verification: facts above and before the synchronization phase is invoked. - Record material deviations or approved assumptions. - Preserve the plan's existing structure and terminology. +- Keep the task's `Covers:` field as authored. Do not mark the task complete when returning `declined`, `blocked`, or `incomplete`. diff --git a/.opencode/skills/sce-validate/references/validation-report.md b/.opencode/skills/sce-validate/references/validation-report.md index b0fa7778b..f3ef7379f 100644 --- a/.opencode/skills/sce-validate/references/validation-report.md +++ b/.opencode/skills/sce-validate/references/validation-report.md @@ -31,8 +31,12 @@ writes it. ### Success-criteria verification -- [x] AC1: {criterion statement} -> {evidence} -- [ ] AC2: {criterion statement} -> {evidence of failure or not checked} +- [x] AC1: {criterion statement} -> {evidence}; scenarios: S1 +- [ ] AC2: {criterion statement} -> {evidence of failure or not checked}; scenarios: S2, S3 + +### Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified} ### Failed checks and follow-ups @@ -61,7 +65,15 @@ After repairs, rerun: run**. Preserve its exit code and concise outcome; do not invent either. - Under **Success-criteria verification**, render the acceptance-criterion checkbox state and evidence already established by validation execution. Do - not independently re-evaluate or reclassify criteria here. + not independently re-evaluate or reclassify criteria here. End each + acceptance-criterion line with the scenario IDs its + `Scenarios:` field names, or `none (inspection)`. +- Include **Scenario verification** with exactly one entry per scenario in + plan order. Each entry gives the scenario ID, the + acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status already established by validation + execution. - Under **Failed checks and follow-ups**, render every failure and follow-up supplied by validation execution. Write `None.` when status is `validated`. - When status is `failed`, always include **Retry** with the exact diff --git a/.opencode/skills/sce-validate/references/validation.md b/.opencode/skills/sce-validate/references/validation.md index 5fdf8e6f9..2bc27bb88 100644 --- a/.opencode/skills/sce-validate/references/validation.md +++ b/.opencode/skills/sce-validate/references/validation.md @@ -57,6 +57,19 @@ From the plan, collect: - Every acceptance criterion and its `Validate:` check. - The `Full validation` command list. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Also collect: + +- Every design decision. +- Every acceptance criterion's `Decisions:` and `Scenarios:` fields. +- Every verification scenario with its `Then:` and `Check:`. +- Every task's `Covers:` field. + Return `blocked` when the plan has no usable acceptance criteria, or when no validation commands can be determined from the plan or repository conventions. @@ -64,7 +77,35 @@ Prefer the plan's authored checks. Fall back to repository-primary test, lint, and format commands only when `Full validation` is absent, and record that fallback under notes on a `validated` or `failed` result. -### 4. Run full validation and acceptance checks +### 4. Check plan references + +Run `sce plan check {plan-path}` before scenario +verification. Record each reported diagnostic as a failed check under +**Failed checks and follow-ups**, quoting its line and message. + +Trust the checker only when it actually ran: a result counts only on exit `0` +with `structured plan: reference integrity holds` on stdout, or on exit `3` +with diagnostics. Any other outcome means the checker is unavailable; record +that fallback under notes and apply the manual pass below instead. + +For the manual pass, apply every entry of the plan template's +`Reference rules` to the plan. Each of these is a relationship-integrity +defect: + +- An ID defined more than once. +- A dangling reference: a `Decisions:`, `Scenarios:`, or `Covers:` ID with no + definition in the plan. +- An acceptance criterion without `Scenarios:`. +- An acceptance criterion that no task's `Covers:` names. +- A task without `Covers:` or with an empty `Covers:`. +- A scenario that no acceptance criterion's `Scenarios:` references. + +Record each defect as a failed check under **Failed checks and follow-ups**, +naming the IDs involved. A relationship-integrity defect makes the result +`failed`; never tolerate one silently. Continue gathering the remaining +evidence. + +### 5. Run full validation and acceptance checks Run the plan's `Full validation` commands. @@ -72,6 +113,26 @@ Then verify each acceptance criterion using its `Validate:` line. Prefer a runnable command. Use a named inspection only when the criterion authorizes it, and say exactly what was inspected. +Run or perform every scenario's `Check:` yourself in +this run, and compare the observed result with the scenario's `Then:`. Give +each scenario exactly one status: + +- `Verified` when this run's evidence proves its `Then:` in full. +- `Partially verified` when the check ran but proves `Then:` only in part, for + example at a narrower level than `Then:` requires. +- `Not verified` when the check failed, could not be run or located, or was + not performed. + +Only evidence gathered by this run counts. Task completion, a task's recorded +`Verify` outcomes, a planned `Check:` line, and the plan's own claims are never +evidence. Record every `Partially verified` or `Not verified` scenario as a +failed check with the gap and a required action. + +An acceptance criterion is met only when its `Validate:` +check passes and every scenario its `Scenarios:` names is `Verified`. An +acceptance criterion marked `none (inspection)` is met by its `Validate:` check +alone. + Treat leftover debug-only flags, temporary files, intermediate artifacts, or local scaffolding as a failed validation check. Record the path and evidence under **Failed checks and follow-ups**; never delete or repair it during @@ -87,7 +148,7 @@ inspection confirmed the criterion. Do not run task-by-task implementation work for incomplete tasks. That belongs to `/next-task`. -### 5. Update the plan +### 6. Update the plan For `validated` and `failed` outcomes: @@ -101,7 +162,7 @@ Do not reopen completed tasks, rewrite task evidence, or change the task stack. For `blocked`, leave the plan file unchanged. -### 6. Return the Markdown report +### 7. Return the Markdown report Return exactly one Markdown report: @@ -176,8 +237,12 @@ invoking workflow. ## Acceptance criteria -- [x] AC1: {criterion statement} — {evidence} -- [x] AC2: {criterion statement} — {evidence} +- [x] AC1: {criterion statement} — {evidence}; scenarios: S1 +- [x] AC2: {criterion statement} — {evidence}; scenarios: none (inspection) + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified ## Residual risks @@ -218,11 +283,19 @@ a normal work session, then rerun validation. - `{check or AC id}`: {problem} - Evidence: {command output, exit summary, or inspection finding} - Required action: {concrete repair or decision} +- `{scenario id}` ({AC ids}): {Partially verified | Not verified} — {gap between the observed result and `Then:`} + - Evidence: {exact test, command, or inspected location and its actual result} + - Required action: {concrete repair or decision} ## Acceptance criteria -- [x] AC1: {criterion} — {evidence} -- [ ] AC2: {criterion} — {why unmet} +- [x] AC1: {criterion} — {evidence}; scenarios: S1 +- [ ] AC2: {criterion} — {why unmet}; scenarios: S2 + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified +- S2 (AC2): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Partially verified | Not verified} ## Commands run @@ -303,6 +376,13 @@ a normal work session, then rerun validation. `/validate {plan path}` as the final step after repairs. - The failed variant must be self-contained enough to hand to another session without the original chat. +- The validated and failed variants include **Scenario + verification** with exactly one entry per scenario in plan order: the scenario + ID, the acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status. Each acceptance-criterion line + cites its scenario IDs, or `none (inspection)`. In the failed variant, list + every `Partially verified` or `Not verified` scenario under **What failed**. - Do not include durable context synchronization results in this report. - Do not select or describe an unrelated next implementation task when status is `validated`. diff --git a/.pi/skills/sce-change-to-plan/references/plan-authoring.md b/.pi/skills/sce-change-to-plan/references/plan-authoring.md index 5b614219b..0c8ecb0a3 100644 --- a/.pi/skills/sce-change-to-plan/references/plan-authoring.md +++ b/.pi/skills/sce-change-to-plan/references/plan-authoring.md @@ -28,9 +28,13 @@ This phase owns the planning process: - Write or revise exactly one `context/plans/{plan_name}.md`. `references/plan-template.md` is the sole owner of the persisted plan schema. -Read its `Acceptance criteria rules`, `Task rules` and `No validation task` rules, -and `Updating an existing plan` rules before authoring or revising. Apply those -rules rather than restating them here. +Read its `Acceptance criteria rules`, `Design decision rules`, +`Verification scenario rules`, `Reference rules`, `Task rules` and +`No validation task` rules, and `Updating an existing plan` rules before +authoring or revising. Apply those rules rather than restating them here. + +Author plan content in this order: design decisions, then acceptance criteria, +then verification scenarios, then tasks. The workflow renders this phase's result as the summary defined in `references/output.md`. @@ -57,7 +61,9 @@ When multiple existing plans match and none can be selected safely, return `blocked` with the matching candidates. When the request targets a new plan, derive `plan_name` as a short kebab-case -slug of the change, and confirm it does not collide with an existing plan. +slug of the change, and confirm it does not collide with an existing plan. Do +not create the plan file here; step 2.10 initializes it after the clarification +gate. Resolve exactly one plan target per invocation. @@ -151,31 +157,109 @@ the repository does not yet have. Do not explore the entire repository by default. -## 2.5 Author the acceptance criteria +## 2.5 Record design decisions + +Record the architectural choices this plan commits to under `## Design decisions`, +applying the `Design decision rules` in `references/plan-template.md`. Do not +manufacture a decision to fill the section. When the change commits to no such +choice, omit the section. + +## 2.6 Author the acceptance criteria Derive the plan-specific success outcomes and checks before slicing tasks, then apply the `Acceptance criteria rules` and exact section shape in `references/plan-template.md`. The template owns their generic validation semantics and placement. -## 2.6 Author the task stack +Give each criterion its `Decisions:` and `Scenarios:` fields as the +`Reference rules` define them. + +## 2.7 Author the verification scenarios + +Write the scenarios the acceptance criteria depend on under +`### Verification scenarios`, applying the `Verification scenario rules` in +`references/plan-template.md`. Cover normal, boundary, failure, cancellation, +concurrency, and recovery cases only where the change makes them relevant. +Do not invent test names: a `Check:` names an existing test, a test a task in +the stack creates, a command, or a named inspection. + +## 2.8 Author the task stack -Slice and order the plan-specific work after the acceptance criteria, applying the -`Task rules` and `No validation task` rules in `references/plan-template.md`. +Slice and order the plan-specific work after the acceptance criteria and +verification scenarios, applying the `Task rules` and `No validation task` rules in `references/plan-template.md`. Do not restate those generic task rules here. +Give each task its `Covers:` field as the `Reference rules` define it. + A finished stack always leaves at least one incomplete task, so the workflow can always hand off to `/next-task`. When the request resolves to a plan but produces no incomplete task, because the change is already implemented or already covered by completed tasks, set internal status `blocked` with category `no_actionable_work` instead of writing the plan. -## 2.7 Write the plan +## 2.9 Check references + +Before writing the plan, run a reference-consistency pass over the +complete draft that applies every `Reference rules` entry in +`references/plan-template.md`. Fix each defect in the draft before writing. Do +not write a plan that fails this pass. + +This pass is pre-write authoring guidance only. It never substitutes for a +successful `sce plan check` in step 2.10. + +## 2.10 Write the plan + +Structured plans are the only supported format. This step has no fallback for +`sce plan init` or `sce plan check`. + +### New plan + +Run `sce plan init {plan_name}`. Initialization succeeds only when all three +hold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`, +and that file exists. Every other result returns `blocked` with the exit code +and stderr. This includes a missing `sce` executable, a binary without `init`, +a nonzero exit, an exit `0` without the expected line, and an expected line +with no created file. + +Fill the generated scaffold with the plan content, applying the authoring rules +in `references/plan-template.md`. The template is the authoring reference for +filling the scaffold, never a substitute for `sce plan init`. Never write a new +plan without it. + +### Existing plan + +Do not run `sce plan init`. Revise the plan by applying the +`Updating an existing plan` rules in `references/plan-template.md`, which +preserve completed tasks and their evidence. The revised plan must satisfy the +same structured plan contract. + +### Completeness gate + +For new plans and revisions alike, the plan contains no scaffold `TODO` +placeholder, and optional sections without real entries (`Design decisions`, +`Assumptions`) are removed. The change summary, acceptance criteria, design +decisions, verification scenarios, task definitions, and verify instructions +are authored for this change. + +While any placeholder remains, keep authoring or return `blocked`. A passing +`sce plan check` is required but not sufficient: it is structural and does not +detect placeholders. + +### Structural check + +For new plans and revisions alike, run `sce plan check {plan-path}`. It passes +only on exit `0` with `structured plan: reference integrity holds` on stdout. + +On exit `3` with structural diagnostics, fix each reported defect in the plan +and run it again, or return `blocked` with the remaining diagnostics when they +cannot be resolved. Every other result returns `blocked` with the exit code and +stderr. This includes a missing `sce` executable, a binary without `check`, any +other exit code, and an exit `0` without the success marker. -Write `context/plans/{plan_name}.md` by applying `references/plan-template.md` -exactly. For revisions, apply its `Updating an existing plan` rules. +Set internal status `plan_ready` only after both the completeness gate and +`sce plan check` pass. -## 2.8 Return the result +## 2.11 Return the result Return one internal result with one of these statuses: diff --git a/.pi/skills/sce-change-to-plan/references/plan-template.md b/.pi/skills/sce-change-to-plan/references/plan-template.md index fe0190b94..7f2674e3a 100644 --- a/.pi/skills/sce-change-to-plan/references/plan-template.md +++ b/.pi/skills/sce-change-to-plan/references/plan-template.md @@ -18,6 +18,19 @@ entirely rather than writing them empty. {One or two paragraphs: what changes, where, and why. State whether this extends existing behavior, replaces it, or preserves work already in progress.} +## Design decisions + +{Optional. Include only real architectural choices this plan commits to. Remove +the section when there are none.} + +- D1: {decision} + - Boundary: {architectural boundary the decision governs} + - Rationale: {why} + - Rejected: {alternative} — {why not} + - Trade-offs: {known limitation or cost} + - Review: required + - Review question: {optional; one question for senior engineering judgment; remove both review lines when none is needed} + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -26,8 +39,19 @@ performs final validation. - [ ] AC1: {observable outcome, stated as behavior rather than as work done} - Validate: `{command, assertion, or inspection that proves AC1}` + - Decisions: {decision IDs, e.g. D1; omit the field when no decision applies} + - Scenarios: {scenario IDs, e.g. S1, S2} - [ ] AC2: {observable outcome} - Validate: `{command, assertion, or inspection that proves AC2}` + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - When: {action} + - Then: {exact observable result or invariant} + - Check: {planned test, command, or named inspection} ### Full validation @@ -70,6 +94,7 @@ recorded. Remove the section otherwise.} - [ ] T01: `{single intent title}` (status:todo) - Task ID: T01 + - Covers: {acceptance criterion IDs, e.g. AC1} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: {task IDs, or none} - Done when: {clear acceptance for one coherent change} @@ -78,6 +103,7 @@ recorded. Remove the section otherwise.} - [ ] T02: `{single intent title}` (status:todo) - Task ID: T02 + - Covers: {acceptance criterion IDs, e.g. AC2} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: T01 - Done when: {clear acceptance for one coherent change} @@ -103,6 +129,7 @@ invent one: `None.` is the expected answer for a well-specified change.} ```markdown - [ ] T02: `Add /auth/refresh endpoint` (status:todo) - Task ID: T02 + - Covers: AC1 - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. - Dependencies: T01 - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. @@ -110,6 +137,47 @@ invent one: `None.` is the expected answer for a well-specified change.} - Context synchronization: pending ``` +## Filled-in structured plan example + +```markdown +## Design decisions + +- D1: The existing session store validates refresh tokens; no new token service is added. + - Boundary: `src/auth/` owns token validation; route handlers call it and never read the session store directly. + - Rationale: The session store already tracks revocation, so one owner keeps revocation consistent. + - Rejected: Stateless refresh tokens — they cannot be revoked before they expire. + - Trade-offs: Every refresh costs one session-store read. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` returns a signed JWT for a valid refresh token and 401 for an expired or revoked one. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh` with its 200 and 401 responses. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A refresh token that has not expired but is revoked in the session store. + - When: The client calls `POST /auth/refresh` with that token. + - Then: The response is 401 and no JWT is issued. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"`, created by T01. + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 + - Scope: In — route handler, session-store validation call, OpenAPI entry. Out — refresh token rotation policy. + - Dependencies: none + - Done when: The endpoint returns a JWT or 401 as AC1 states, the revoked-token test exists and passes, and the OpenAPI entry is present. + - Verify: `pnpm test src/auth/refresh.test.ts`. + - Context synchronization: pending +``` + ## Acceptance criteria rules - Acceptance criteria describe the finished system, not the work. Prefer "the @@ -127,6 +195,49 @@ invent one: `None.` is the expected answer for a well-specified change.} change request. If a criterion has no task that could satisfy it, the task stack is incomplete. +## Design decision rules + +- Record a design decision only for a real architectural choice the plan + commits to, such as an ownership boundary, a data shape, an interface, or an + alternative a reviewer would otherwise reopen. +- Every decision carries `Boundary:`, `Rationale:`, `Rejected:`, and + `Trade-offs:`. +- A decision may also carry the optional review pair `Review: required` and + `Review question: {one question}`. Mark only a decision that needs senior + engineering judgment before the change merges, such as a trade-off a reviewer + must accept. Leave every other decision unmarked. +- Write the review question as one specific question that a reviewer can + accept or reject. Name the architectural choice and its consequence, so the + reviewer can answer from the decision alone. Prefer "Is reading plans from + the working tree instead of the `--head` commit acceptable for stable + links?" over "Is this design OK?" or "Any concerns about plan reads?". +- Ask about each concern once. Do not mark a second decision that raises the + same concern as another marked decision, and do not combine two concerns in + one question. +- Mark every decision that meets this bar. There is no maximum number of + marked decisions. Do not drop an important review question or merge + unrelated questions to lower the count. +- Three or more marked decisions are a signal that the change may be too large + for one focused review. Consider moving independent changes into a separate + pull request. This is advisory: keep tightly coupled changes together, and + complete the plan either way. +- The review pair is all or nothing: a decision carries both fields or + neither. `Review:` is exactly `required`, and `Review question:` is one + nonempty line that states one question. +- An unmarked decision carries no review field. Do not add a placeholder value + such as `Review: none` or an empty `Review question:`. +- A design decision belongs to this plan only. It does not replace an + architecture decision record. + +## Verification scenario rules + +- A scenario states one behavior that an acceptance criterion depends on, as + `Given:`, `When:`, `Then:`, and `Check:`. +- `Then:` states an exact observable result or invariant, not an intention. +- `Check:` names the planned test, command, or named inspection that would + prove `Then:`. Name a test only when it already exists or a task in the stack + creates it. + ## Task rules - Number tasks sequentially as `T01..T0N`. @@ -142,6 +253,51 @@ invent one: `None.` is the expected answer for a well-specified change.} acceptance checks. - Order tasks so each one's declared dependencies precede it. +## Reference rules + +- Read plan structure only from Markdown outside fenced code blocks. A fenced + code block runs from a line that opens a fence of three or more backticks or + tildes to its closing fence. Headings, definitions, and fields inside it are + example text, not plan structure. Keep fenced examples in place; never delete + one to make a plan read correctly. +- A section runs from its heading to the next heading of any level outside + fenced code blocks. A definition counts only in its own section: `D` entries + under `## Design decisions`, `AC` entries under `## Acceptance criteria`, `S` + entries under `### Verification scenarios`, and `T` entries under + `## Task stack`. A line shaped like a definition anywhere else, such as in a + fenced example or a validation report, defines nothing. +- A definition's fields are the `- Field:` lines indented one level under its + entry line, up to the next entry. `Boundary:`, `Rationale:`, `Rejected:`, + `Trade-offs:`, and the optional `Review:` and `Review question:` belong to a + design decision; `Validate:`, `Decisions:`, and + `Scenarios:` to an acceptance criterion; `Given:`, `When:`, `Then:`, and + `Check:` to a verification scenario; and `Covers:` to a task. +- To check a fenced example itself, extract the block's content and apply these + rules to it as a separate sample document. +- IDs are `D1..Dn` for design decisions, `AC1..ACn` for acceptance criteria, + `S1..Sn` for verification scenarios, and `T01..T0N` for tasks. Each entry + starts its own line with its ID: `- D1:`, `- [ ] AC1:`, `- S1:`, and + `- [ ] T01:`. +- Define each ID exactly once. +- Number decisions, criteria, and scenarios sequentially in order of first + assignment. Never renumber or reuse a decision, criterion, or scenario ID; a + withdrawn entry keeps its ID and says that it is withdrawn. +- Each reference field fits on one line: `Decisions:` and `Scenarios:` on an + acceptance criterion, and `Covers:` on a task. Its value is a list of IDs + separated by a comma and a space, for example `S1, S2`. +- Every referenced ID resolves to a definition in the same plan. +- Every acceptance criterion carries `Scenarios:`, with scenario IDs or the + literal `none (inspection)` when only an inspection can prove it. Omit + `Decisions:` when no decision applies. +- Every task carries `Covers:` naming at least one + acceptance criterion defined in the plan. A task without `Covers:` or with an + empty `Covers:` is a defect even when other tasks cover every criterion. +- Every acceptance criterion is named by at least one task's `Covers:`. +- Every scenario is referenced by at least one acceptance criterion's + `Scenarios:`. +- A planned `Check:` is not execution evidence. Only `/validate` running or + performing the check verifies a scenario. + ## No validation task - The last task in the stack is an ordinary implementation task. Do not author a @@ -183,3 +339,4 @@ Do not author either while planning. run earlier, and never renumber a completed task. - Add acceptance criteria for newly planned outcomes rather than rewriting criteria already satisfied. +- Give each new decision, criterion, or scenario the next unused ID of its kind. diff --git a/.pi/skills/sce-next-task/SKILL.md b/.pi/skills/sce-next-task/SKILL.md index 45a7b462f..1a59112c8 100644 --- a/.pi/skills/sce-next-task/SKILL.md +++ b/.pi/skills/sce-next-task/SKILL.md @@ -91,7 +91,7 @@ selected or resumed, before allowing a new implementation task to start. A missing field, or any value other than `synced`, is unresolved synchronization debt. Never infer `synced` from conversation history. When the debt-carrying task has no durable completed-task record, the **Plan review phase** returns -`blocked` directly with a legacy-migration required action; otherwise it returns +`blocked` directly with a plan-migration required action; otherwise it returns `sync_debt` with the resolved plan path, debt task ID/title, completed task record, and persisted blocker when present, for the branch above to route. diff --git a/.pi/skills/sce-next-task/references/output.md b/.pi/skills/sce-next-task/references/output.md index 1ddc071ab..bf897f69b 100644 --- a/.pi/skills/sce-next-task/references/output.md +++ b/.pi/skills/sce-next-task/references/output.md @@ -113,6 +113,12 @@ Describe the smallest coherent implementation approach with enough detail for ap - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. +## Acceptance coverage + +- List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. +- Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} diff --git a/.pi/skills/sce-next-task/references/plan-review.md b/.pi/skills/sce-next-task/references/plan-review.md index ae6a97b66..6d6c4943e 100644 --- a/.pi/skills/sce-next-task/references/plan-review.md +++ b/.pi/skills/sce-next-task/references/plan-review.md @@ -29,8 +29,8 @@ any value other than `synced`, is unresolved synchronization debt. Never infer For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating - that structure, or an incomplete write), do not attempt a reconstructed retry. + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. Set internal status `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -74,6 +74,18 @@ architecture, shared terminology, or cross-domain interfaces. Do not explore the entire repository by default. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Read the selected task's `Covers:` IDs. For each covered +acceptance criterion, read its `Scenarios:` and `Decisions:` fields and the +scenario and decision definitions they name. A covered ID or referenced ID with +no definition in the plan is a dangling reference. Record it as a review risk; +it does not make the task `blocked`. + ## 1.4 Determine readiness A task is `ready` when: @@ -128,6 +140,14 @@ A `ready` result must identify: - Relevant files and context. - Review assumptions. +A `ready` result also identifies: + +- The covered acceptance criteria: each `Covers:` ID with its outcome and + `Validate:` check. +- Their verification scenarios: each scenario ID with its `Then:` and `Check:`. +- Their related design decisions: each decision ID with its decision text. +- Every dangling reference, as a review risk. + A `sync_debt` result must identify: - The resolved plan path. diff --git a/.pi/skills/sce-next-task/references/task-execution.md b/.pi/skills/sce-next-task/references/task-execution.md index ae68ed4c3..4c92e6a70 100644 --- a/.pi/skills/sce-next-task/references/task-execution.md +++ b/.pi/skills/sce-next-task/references/task-execution.md @@ -132,6 +132,12 @@ Run the narrowest authoritative checks that demonstrate the done checks. Start with verification supplied by the readiness result. Add nearby or directly relevant checks only when needed. +The covered scenarios' `Then:` and `Check:` may shape +the task's own `Verify` checks. Running a scenario's check during the task is +task-level evidence only. Task execution never checks an acceptance-criterion +checkbox and never records a scenario status; only `/validate` verifies +acceptance criteria and scenarios. + Verification may include: - Targeted tests. @@ -167,6 +173,7 @@ Only after successful implementation and task-level verification: facts above and before the synchronization phase is invoked. - Record material deviations or approved assumptions. - Preserve the plan's existing structure and terminology. +- Keep the task's `Covers:` field as authored. Do not mark the task complete when returning `declined`, `blocked`, or `incomplete`. diff --git a/.pi/skills/sce-validate/references/validation-report.md b/.pi/skills/sce-validate/references/validation-report.md index b0fa7778b..f3ef7379f 100644 --- a/.pi/skills/sce-validate/references/validation-report.md +++ b/.pi/skills/sce-validate/references/validation-report.md @@ -31,8 +31,12 @@ writes it. ### Success-criteria verification -- [x] AC1: {criterion statement} -> {evidence} -- [ ] AC2: {criterion statement} -> {evidence of failure or not checked} +- [x] AC1: {criterion statement} -> {evidence}; scenarios: S1 +- [ ] AC2: {criterion statement} -> {evidence of failure or not checked}; scenarios: S2, S3 + +### Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified} ### Failed checks and follow-ups @@ -61,7 +65,15 @@ After repairs, rerun: run**. Preserve its exit code and concise outcome; do not invent either. - Under **Success-criteria verification**, render the acceptance-criterion checkbox state and evidence already established by validation execution. Do - not independently re-evaluate or reclassify criteria here. + not independently re-evaluate or reclassify criteria here. End each + acceptance-criterion line with the scenario IDs its + `Scenarios:` field names, or `none (inspection)`. +- Include **Scenario verification** with exactly one entry per scenario in + plan order. Each entry gives the scenario ID, the + acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status already established by validation + execution. - Under **Failed checks and follow-ups**, render every failure and follow-up supplied by validation execution. Write `None.` when status is `validated`. - When status is `failed`, always include **Retry** with the exact diff --git a/.pi/skills/sce-validate/references/validation.md b/.pi/skills/sce-validate/references/validation.md index 5fdf8e6f9..2bc27bb88 100644 --- a/.pi/skills/sce-validate/references/validation.md +++ b/.pi/skills/sce-validate/references/validation.md @@ -57,6 +57,19 @@ From the plan, collect: - Every acceptance criterion and its `Validate:` check. - The `Full validation` command list. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Also collect: + +- Every design decision. +- Every acceptance criterion's `Decisions:` and `Scenarios:` fields. +- Every verification scenario with its `Then:` and `Check:`. +- Every task's `Covers:` field. + Return `blocked` when the plan has no usable acceptance criteria, or when no validation commands can be determined from the plan or repository conventions. @@ -64,7 +77,35 @@ Prefer the plan's authored checks. Fall back to repository-primary test, lint, and format commands only when `Full validation` is absent, and record that fallback under notes on a `validated` or `failed` result. -### 4. Run full validation and acceptance checks +### 4. Check plan references + +Run `sce plan check {plan-path}` before scenario +verification. Record each reported diagnostic as a failed check under +**Failed checks and follow-ups**, quoting its line and message. + +Trust the checker only when it actually ran: a result counts only on exit `0` +with `structured plan: reference integrity holds` on stdout, or on exit `3` +with diagnostics. Any other outcome means the checker is unavailable; record +that fallback under notes and apply the manual pass below instead. + +For the manual pass, apply every entry of the plan template's +`Reference rules` to the plan. Each of these is a relationship-integrity +defect: + +- An ID defined more than once. +- A dangling reference: a `Decisions:`, `Scenarios:`, or `Covers:` ID with no + definition in the plan. +- An acceptance criterion without `Scenarios:`. +- An acceptance criterion that no task's `Covers:` names. +- A task without `Covers:` or with an empty `Covers:`. +- A scenario that no acceptance criterion's `Scenarios:` references. + +Record each defect as a failed check under **Failed checks and follow-ups**, +naming the IDs involved. A relationship-integrity defect makes the result +`failed`; never tolerate one silently. Continue gathering the remaining +evidence. + +### 5. Run full validation and acceptance checks Run the plan's `Full validation` commands. @@ -72,6 +113,26 @@ Then verify each acceptance criterion using its `Validate:` line. Prefer a runnable command. Use a named inspection only when the criterion authorizes it, and say exactly what was inspected. +Run or perform every scenario's `Check:` yourself in +this run, and compare the observed result with the scenario's `Then:`. Give +each scenario exactly one status: + +- `Verified` when this run's evidence proves its `Then:` in full. +- `Partially verified` when the check ran but proves `Then:` only in part, for + example at a narrower level than `Then:` requires. +- `Not verified` when the check failed, could not be run or located, or was + not performed. + +Only evidence gathered by this run counts. Task completion, a task's recorded +`Verify` outcomes, a planned `Check:` line, and the plan's own claims are never +evidence. Record every `Partially verified` or `Not verified` scenario as a +failed check with the gap and a required action. + +An acceptance criterion is met only when its `Validate:` +check passes and every scenario its `Scenarios:` names is `Verified`. An +acceptance criterion marked `none (inspection)` is met by its `Validate:` check +alone. + Treat leftover debug-only flags, temporary files, intermediate artifacts, or local scaffolding as a failed validation check. Record the path and evidence under **Failed checks and follow-ups**; never delete or repair it during @@ -87,7 +148,7 @@ inspection confirmed the criterion. Do not run task-by-task implementation work for incomplete tasks. That belongs to `/next-task`. -### 5. Update the plan +### 6. Update the plan For `validated` and `failed` outcomes: @@ -101,7 +162,7 @@ Do not reopen completed tasks, rewrite task evidence, or change the task stack. For `blocked`, leave the plan file unchanged. -### 6. Return the Markdown report +### 7. Return the Markdown report Return exactly one Markdown report: @@ -176,8 +237,12 @@ invoking workflow. ## Acceptance criteria -- [x] AC1: {criterion statement} — {evidence} -- [x] AC2: {criterion statement} — {evidence} +- [x] AC1: {criterion statement} — {evidence}; scenarios: S1 +- [x] AC2: {criterion statement} — {evidence}; scenarios: none (inspection) + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified ## Residual risks @@ -218,11 +283,19 @@ a normal work session, then rerun validation. - `{check or AC id}`: {problem} - Evidence: {command output, exit summary, or inspection finding} - Required action: {concrete repair or decision} +- `{scenario id}` ({AC ids}): {Partially verified | Not verified} — {gap between the observed result and `Then:`} + - Evidence: {exact test, command, or inspected location and its actual result} + - Required action: {concrete repair or decision} ## Acceptance criteria -- [x] AC1: {criterion} — {evidence} -- [ ] AC2: {criterion} — {why unmet} +- [x] AC1: {criterion} — {evidence}; scenarios: S1 +- [ ] AC2: {criterion} — {why unmet}; scenarios: S2 + +## Scenario verification + +- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified +- S2 (AC2): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Partially verified | Not verified} ## Commands run @@ -303,6 +376,13 @@ a normal work session, then rerun validation. `/validate {plan path}` as the final step after repairs. - The failed variant must be self-contained enough to hand to another session without the original chat. +- The validated and failed variants include **Scenario + verification** with exactly one entry per scenario in plan order: the scenario + ID, the acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status. Each acceptance-criterion line + cites its scenario IDs, or `none (inspection)`. In the failed variant, list + every `Partially verified` or `Not verified` scenario under **What failed**. - Do not include durable context synchronization results in this report. - Do not select or describe an unrelated next implementation task when status is `validated`. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index c364cdce0..59c54b233 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -2471,6 +2471,15 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3823,6 +3832,7 @@ dependencies = [ "inquire", "jsonschema", "keyring-core", + "markdown", "murmur3", "opentelemetry", "opentelemetry-http", @@ -4869,6 +4879,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a842db904..e9014888d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -50,6 +50,7 @@ tracing = "0.1" uuid = { version = "1", features = ["v4", "v7"] } murmur3 = "0.5.2" +markdown = "1" opentelemetry-otlp = { version = "=0.33.1", default-features = false, features = ["http-proto", "trace", "logs"] } opentelemetry-http = "=0.33.1" tracing-opentelemetry = "=0.34.0" diff --git a/cli/build.rs b/cli/build.rs index c20036677..033965555 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -326,6 +326,7 @@ fn validate_staged_artifacts(out_dir: &Path) -> io::Result<()> { "static/schema/agent-trace.schema.json", "pkl-generated/config/schema/sce-config.schema.json", "pkl-generated/config/optional-workflows.json", + "pkl-generated/config/plan-init-scaffold.md", ] { if !out_dir.join(relative_path).exists() { return Err(invalid_data(&format!( diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index c5348c670..629291d59 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -53,6 +53,12 @@ pub const SYNC_TOP_LEVEL_PURPOSE: &str = "Synchronize the current repository's Agent Trace database"; pub const SYNC_SHOW_IN_TOP_LEVEL_HELP: bool = true; +pub const PLAN_CLAP_ABOUT: &str = + "Create an SCE plan scaffold, check the structure and reference integrity of one SCE plan, or print an intent-review brief"; +pub const PLAN_TOP_LEVEL_PURPOSE: &str = + "Create or check the structure of one SCE plan, or print an intent-review brief"; +pub const PLAN_SHOW_IN_TOP_LEVEL_HELP: bool = true; + pub const TOP_LEVEL_COMMANDS: &[TopLevelCommandMetadata] = &[ TopLevelCommandMetadata { name: crate::services::auth_command::NAME, @@ -94,6 +100,11 @@ pub const TOP_LEVEL_COMMANDS: &[TopLevelCommandMetadata] = &[ purpose: COMPLETION_TOP_LEVEL_PURPOSE, show_in_top_level_help: COMPLETION_SHOW_IN_TOP_LEVEL_HELP, }, + TopLevelCommandMetadata { + name: crate::services::plan::NAME, + purpose: PLAN_TOP_LEVEL_PURPOSE, + show_in_top_level_help: PLAN_SHOW_IN_TOP_LEVEL_HELP, + }, TopLevelCommandMetadata { name: crate::services::sync::NAME, purpose: SYNC_TOP_LEVEL_PURPOSE, @@ -234,6 +245,12 @@ pub enum Commands { shell: CompletionShell, }, + #[command(about = PLAN_CLAP_ABOUT, hide = !PLAN_SHOW_IN_TOP_LEVEL_HELP)] + Plan { + #[command(subcommand)] + subcommand: PlanSubcommand, + }, + #[command(about = SYNC_CLAP_ABOUT, hide = !SYNC_SHOW_IN_TOP_LEVEL_HELP)] Sync { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] @@ -361,6 +378,83 @@ pub enum HooksSubcommand { ExternalMutationGuard, } +#[derive(Subcommand, Debug, Clone, PartialEq, Eq)] +pub enum PlanSubcommand { + #[command(about = "Validate one plan file without executing any of its commands")] + Check { + #[arg(help = "Path to the plan Markdown file to check")] + path: PathBuf, + }, + + #[command( + about = "Create context/plans/.md at the Git repository root from the plan scaffold" + )] + Init { + #[arg( + help = "Plan name: lowercase letters and digits separated by single hyphens, without `.md`" + )] + name: String, + }, + + #[command( + name = "pr-description", + about = "Print a Markdown intent-review brief for the given plans, offline and without writing files" + )] + PrDescription { + #[arg( + long, + value_name = "OWNER/NAME", + help = "GitHub repository as /" + )] + repo: String, + + #[arg( + long, + value_name = "SHA", + help = "Full 40-character lowercase commit SHA that contains the plan files" + )] + head: String, + + #[arg( + required = true, + help = "Plan paths relative to the Git repository root, each context/plans/.md" + )] + paths: Vec, + }, + + #[command( + name = "review", + about = "Print a Markdown Decision Brief for the given plans at a commit, offline and without writing files" + )] + Review { + #[arg( + long, + help = "Print the sce.plan.claims.v1 JSON claims manifest instead of the Decision Brief" + )] + claims: bool, + + #[arg( + long, + value_name = "OWNER/NAME", + help = "GitHub repository as /" + )] + repo: String, + + #[arg( + long, + value_name = "SHA", + help = "Full 40-character lowercase commit SHA to read the plan files from" + )] + head: String, + + #[arg( + required = true, + help = "Plan paths relative to the Git repository root, each context/plans/.md" + )] + paths: Vec, + }, +} + #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum PolicySubcommand { #[command(name = "bash", about = "Evaluate a bash policy request from STDIN")] diff --git a/cli/src/command_surface.rs b/cli/src/command_surface.rs index f47cc52eb..85d3e0aa4 100644 --- a/cli/src/command_surface.rs +++ b/cli/src/command_surface.rs @@ -72,6 +72,27 @@ const HELP_SECTIONS: &[HelpSection] = &[ suffix: " [--format ]", }], }, + HelpSection { + title: "Plan Usage:", + body: &[ + HelpSectionBodyLine::Command { + cmd: " sce plan", + suffix: " check ", + }, + HelpSectionBodyLine::Command { + cmd: " sce plan", + suffix: " init ", + }, + HelpSectionBodyLine::Command { + cmd: " sce plan", + suffix: " pr-description --repo --head ...", + }, + HelpSectionBodyLine::Command { + cmd: " sce plan", + suffix: " review [--claims] --repo --head ...", + }, + ], + }, HelpSection { title: "Completion Usage:", body: &[HelpSectionBodyLine::Command { diff --git a/cli/src/services/command_registry.rs b/cli/src/services/command_registry.rs index bc8ff9023..60d89e967 100644 --- a/cli/src/services/command_registry.rs +++ b/cli/src/services/command_registry.rs @@ -13,6 +13,7 @@ const DEFAULT_COMMAND_NAMES: &[&str] = &[ services::help::NAME, services::hooks::NAME, services::bash_policy::NAME, + services::plan::NAME, services::setup::NAME, services::sync::NAME, services::version::NAME, @@ -35,6 +36,7 @@ pub enum RuntimeCommand { Version(services::version::command::VersionCommand), Completion(services::completion::command::CompletionCommand), Sync(services::sync::command::SyncCommand), + Plan(services::plan::command::PlanCommand), } impl RuntimeCommand { @@ -51,6 +53,7 @@ impl RuntimeCommand { Self::Version(_) => Cow::Borrowed(services::version::NAME), Self::Completion(_) => Cow::Borrowed(services::completion::NAME), Self::Sync(_) => Cow::Borrowed(services::sync::NAME), + Self::Plan(_) => Cow::Borrowed(services::plan::NAME), } } @@ -75,6 +78,7 @@ impl RuntimeCommand { Self::Version(command) => command.execute(context), Self::Completion(command) => Ok(command.execute(context)), Self::Sync(command) => command.execute_with_stderr(context, stderr).await, + Self::Plan(command) => command.execute_with_stderr(stderr), } } } diff --git a/cli/src/services/completion/mod.rs b/cli/src/services/completion/mod.rs index defac15d3..cfe94d915 100644 --- a/cli/src/services/completion/mod.rs +++ b/cli/src/services/completion/mod.rs @@ -1,10 +1,12 @@ pub mod command; -use clap::CommandFactory; +use clap::{Command, CommandFactory}; use clap_complete::Shell; pub const NAME: &str = "completion"; +const PLAN_COMMAND_NAME: &str = "plan"; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CompletionShell { Bash, @@ -24,13 +26,35 @@ pub fn render_completion(request: CompletionRequest) -> String { CompletionShell::Fish => Shell::Fish, }; + let mut command = crate::cli_schema::Cli::command().mut_subcommands(|subcommand| { + if subcommand.get_name() == PLAN_COMMAND_NAME { + plan_without_hidden_subcommands(&subcommand) + } else { + subcommand + } + }); + let mut buffer = Vec::new(); - clap_complete::generate( - shell, - &mut crate::cli_schema::Cli::command(), - "sce", - &mut buffer, - ); + clap_complete::generate(shell, &mut command, "sce", &mut buffer); String::from_utf8(buffer).expect("Generated completion script should be valid UTF-8") } + +fn plan_without_hidden_subcommands(command: &Command) -> Command { + let mut rebuilt = Command::new(PLAN_COMMAND_NAME) + .hide(command.is_hide_set()) + .args(command.get_arguments().cloned()) + .subcommands( + command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .cloned(), + ); + if let Some(about) = command.get_about() { + rebuilt = rebuilt.about(about.clone()); + } + if let Some(long_about) = command.get_long_about() { + rebuilt = rebuilt.long_about(long_about.clone()); + } + rebuilt +} diff --git a/cli/src/services/hooks/claude_mutation_scope/state.rs b/cli/src/services/hooks/claude_mutation_scope/state.rs index 9cf2cfd8b..1a7fee541 100644 --- a/cli/src/services/hooks/claude_mutation_scope/state.rs +++ b/cli/src/services/hooks/claude_mutation_scope/state.rs @@ -390,14 +390,15 @@ mod tests { use std::time::{Duration, Instant}; use super::{ - clear_recovery_pending_if_quiescent_inner, read_state, write_state_durably_sync, - AdapterState, ClearRecoveryOutcome, + clear_recovery_pending_if_quiescent_inner, read_state, state_dir, write_state_durably_sync, + AdapterState, ClearRecoveryOutcome, STATE_LOCK, }; const INJECTED_WRITE_DELAY: Duration = Duration::from_millis(500); const UNRELATED_TIMER: Duration = Duration::from_millis(10); const RESPONSIVENESS_BUDGET: Duration = Duration::from_millis(250); const HOOK_RELEASE_TIMEOUT: Duration = Duration::from_secs(5); + const STARTED_TRANSACTION_FINISH_TIMEOUT: Duration = Duration::from_mins(2); fn seed_quiescent_recovery_pending(git_dir: &std::path::Path) { write_state_durably_sync( @@ -471,6 +472,16 @@ mod tests { .is_cancelled()); release_tx.send(()).expect("hook should still be waiting"); + drop( + STATE_LOCK + .acquire_with_timeout_async( + &state_dir(git_dir.path()), + STARTED_TRANSACTION_FINISH_TIMEOUT, + ) + .await + .expect("started transaction should finish and release the state lock"), + ); + let outcome = clear_recovery_pending_if_quiescent_inner(git_dir.path(), |_, _| Ok(())) .await .expect("state lock should be released and state should parse"); diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index 2612c646b..98bf2c290 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -29,6 +29,7 @@ pub mod observability; pub mod output_format; pub mod parse; pub mod patch; +pub mod plan; pub mod repository_identity; pub mod resilience; pub mod security; diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 38d9999fb..9fbd221a0 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -323,6 +323,7 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result Ok(convert_plan_subcommand(subcommand)), cli_schema::Commands::Sync { format } => { Ok(RuntimeCommand::Sync(services::sync::command::SyncCommand { request: services::sync::SyncRequest { format }, @@ -331,6 +332,45 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result RuntimeCommand { + let request = match subcommand { + cli_schema::PlanSubcommand::Check { path } => { + services::plan::command::PlanRequest::Check(services::plan::command::PlanCheckRequest { + path, + }) + } + cli_schema::PlanSubcommand::Init { name } => { + services::plan::command::PlanRequest::Init(services::plan::command::PlanInitRequest { + name, + }) + } + cli_schema::PlanSubcommand::PrDescription { repo, head, paths } => { + services::plan::command::PlanRequest::PrDescription( + services::plan::command::PlanPrDescriptionRequest { repo, head, paths }, + ) + } + cli_schema::PlanSubcommand::Review { + claims, + repo, + head, + paths, + } => services::plan::command::PlanRequest::Review( + services::plan::command::PlanReviewRequest { + repo, + head, + paths, + output: if claims { + services::plan::command::ReviewOutput::Claims + } else { + services::plan::command::ReviewOutput::Brief + }, + }, + ), + }; + + RuntimeCommand::Plan(services::plan::command::PlanCommand { request }) +} + fn convert_doctor_command( fix: bool, format: services::output_format::OutputFormat, diff --git a/cli/src/services/plan/claims.rs b/cli/src/services/plan/claims.rs new file mode 100644 index 000000000..24c4c23e2 --- /dev/null +++ b/cli/src/services/plan/claims.rs @@ -0,0 +1,336 @@ +use anyhow::Context; +use serde::Serialize; + +use crate::services::error::CliError; +use crate::services::plan::extract::{PlanEntry, PlanEntryKind}; +use crate::services::plan::pr_description::{PlanInput, PrDescriptionArguments}; + +pub const SCHEMA_VERSION: &str = "sce.plan.claims.v1"; +const CLAIM_KIND: &str = "acceptance_criterion"; +const CLAIM_MODALITY: &str = "intended"; +const MODE_SCENARIOS: &str = "scenarios"; +const MODE_INSPECTION: &str = "inspection"; +const INSPECTION_ONLY: &str = "none (inspection)"; +const REFERENCE_SEPARATOR: &str = ", "; +const GITHUB_URL: &str = "https://github.com"; +const REVIEW_KEY: &str = "Review"; +const REVIEW_REQUIRED: &str = "required"; +const REVIEW_QUESTION_KEY: &str = "Review question"; +const DECISIONS_KEY: &str = "Decisions"; +const SCENARIOS_KEY: &str = "Scenarios"; +const COVERS_KEY: &str = "Covers"; +const REVIEW_CLAIMS_COMMAND: &str = "sce plan review --claims"; + +#[derive(Debug, Serialize)] +struct Document<'a> { + schema_version: &'static str, + repository: Repository<'a>, + plans: Vec>, + claims: Vec>, +} + +#[derive(Debug, Serialize)] +struct Repository<'a> { + slug: &'a str, + head_sha: &'a str, +} + +#[derive(Debug, Serialize)] +struct PlanLink<'a> { + path: &'a str, + url: String, +} + +#[derive(Debug, Serialize)] +struct Claim<'a> { + id: String, + kind: &'static str, + statement: &'a str, + modality: &'static str, + author_checked: Option, + source: Source<'a>, + decisions: Vec>, + verification: Verification<'a>, + tasks: Vec>, +} + +#[derive(Debug, Serialize)] +struct Source<'a> { + path: &'a str, + entry_id: &'a str, + line: usize, + url: String, +} + +#[derive(Debug, Serialize)] +struct Decision<'a> { + id: &'a str, + statement: &'a str, + boundary: &'a str, + rationale: &'a str, + rejected: &'a str, + trade_offs: &'a str, + review: Review<'a>, + source: Source<'a>, +} + +#[derive(Debug, Serialize)] +struct Review<'a> { + required: bool, + question: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct Verification<'a> { + mode: &'static str, + validate: &'a str, + scenarios: Vec>, +} + +#[derive(Debug, Serialize)] +struct Scenario<'a> { + id: &'a str, + statement: &'a str, + given: &'a str, + when: &'a str, + then: &'a str, + check: &'a str, + source: Source<'a>, +} + +#[derive(Debug, Serialize)] +struct Task<'a> { + id: &'a str, + statement: &'a str, + author_checked: Option, + source: Source<'a>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ClaimsDiagnostic { + line: usize, + message: String, +} + +struct PlanContext<'a> { + arguments: &'a PrDescriptionArguments, + input: &'a PlanInput, +} + +pub fn render(arguments: &PrDescriptionArguments, plans: &[PlanInput]) -> Result { + let mut sorted: Vec<&PlanInput> = plans.iter().collect(); + sorted.sort_by(|left, right| left.path.cmp(&right.path)); + + for input in &sorted { + let diagnostics = plan_diagnostics(input); + if !diagnostics.is_empty() { + return Err(CliError::validation(render_diagnostics( + &input.path, + &diagnostics, + ))); + } + } + + let document = Document { + schema_version: SCHEMA_VERSION, + repository: Repository { + slug: &arguments.repo, + head_sha: &arguments.head, + }, + plans: sorted + .iter() + .map(|input| PlanLink { + path: &input.path, + url: blob_url(arguments, &input.path), + }) + .collect(), + claims: sorted + .iter() + .flat_map(|&input| { + let context = PlanContext { arguments, input }; + input + .plan + .entries_of(PlanEntryKind::AcceptanceCriterion) + .map(move |criterion| context.claim(criterion)) + }) + .collect(), + }; + + serde_json::to_string_pretty(&document) + .context("Failed to serialize the plan claims document") + .map_err(CliError::runtime) +} + +impl<'a> PlanContext<'a> { + fn claim(&self, criterion: &'a PlanEntry) -> Claim<'a> { + Claim { + id: format!("{}#{}", self.input.path, criterion.id), + kind: CLAIM_KIND, + statement: &criterion.statement, + modality: CLAIM_MODALITY, + author_checked: criterion.checked, + source: self.source(criterion), + decisions: field_value(criterion, DECISIONS_KEY) + .map(|value| { + self.resolve(value, PlanEntryKind::Decision) + .map(|decision| self.decision(decision)) + .collect() + }) + .unwrap_or_default(), + verification: self.verification(criterion), + tasks: self + .input + .plan + .entries_of(PlanEntryKind::Task) + .filter(|task| { + field_value(task, COVERS_KEY) + .is_some_and(|value| listed_ids(value).any(|id| id == criterion.id)) + }) + .map(|task| Task { + id: &task.id, + statement: &task.statement, + author_checked: task.checked, + source: self.source(task), + }) + .collect(), + } + } + + fn decision(&self, decision: &'a PlanEntry) -> Decision<'a> { + let required = field_value(decision, REVIEW_KEY) == Some(REVIEW_REQUIRED); + Decision { + id: &decision.id, + statement: &decision.statement, + boundary: field_text(decision, "Boundary"), + rationale: field_text(decision, "Rationale"), + rejected: field_text(decision, "Rejected"), + trade_offs: field_text(decision, "Trade-offs"), + review: Review { + required, + question: required + .then(|| field_value(decision, REVIEW_QUESTION_KEY)) + .flatten(), + }, + source: self.source(decision), + } + } + + fn verification(&self, criterion: &'a PlanEntry) -> Verification<'a> { + let validate = field_text(criterion, "Validate"); + match field_value(criterion, SCENARIOS_KEY) { + Some(INSPECTION_ONLY) | None => Verification { + mode: MODE_INSPECTION, + validate, + scenarios: Vec::new(), + }, + Some(value) => Verification { + mode: MODE_SCENARIOS, + validate, + scenarios: self + .resolve(value, PlanEntryKind::Scenario) + .map(|scenario| Scenario { + id: &scenario.id, + statement: &scenario.statement, + given: field_text(scenario, "Given"), + when: field_text(scenario, "When"), + then: field_text(scenario, "Then"), + check: field_text(scenario, "Check"), + source: self.source(scenario), + }) + .collect(), + }, + } + } + + fn resolve( + &self, + value: &'a str, + kind: PlanEntryKind, + ) -> impl Iterator + 'a { + let plan = &self.input.plan; + listed_ids(value).filter_map(move |id| plan.entries_of(kind).find(|entry| entry.id == id)) + } + + fn source(&self, entry: &'a PlanEntry) -> Source<'a> { + Source { + path: &self.input.path, + entry_id: &entry.id, + line: entry.line, + url: format!( + "{}#L{}", + blob_url(self.arguments, &self.input.path), + entry.line + ), + } + } +} + +fn consumed_keys(kind: PlanEntryKind) -> &'static [&'static str] { + match kind { + PlanEntryKind::Decision => &[ + "Boundary", + "Rationale", + "Rejected", + "Trade-offs", + REVIEW_KEY, + REVIEW_QUESTION_KEY, + ], + PlanEntryKind::AcceptanceCriterion => &["Validate", DECISIONS_KEY, SCENARIOS_KEY], + PlanEntryKind::Scenario => &["Given", "When", "Then", "Check"], + PlanEntryKind::Task => &[COVERS_KEY], + } +} + +fn plan_diagnostics(input: &PlanInput) -> Vec { + let mut diagnostics = Vec::new(); + + for entry in &input.plan.entries { + for key in consumed_keys(entry.kind) { + if let Some(repeated) = entry.fields.iter().filter(|field| field.key == *key).nth(1) { + diagnostics.push(ClaimsDiagnostic { + line: repeated.line, + message: format!("`{key}:` appears more than once on `{}`", entry.id), + }); + } + } + } + + diagnostics.sort(); + diagnostics +} + +fn render_diagnostics(path: &str, diagnostics: &[ClaimsDiagnostic]) -> String { + let mut lines = vec![format!("Plan claims check failed for '{path}':")]; + lines.extend( + diagnostics + .iter() + .map(|diagnostic| format!("{path}:{}: {}", diagnostic.line, diagnostic.message)), + ); + lines.push(format!( + "Try: merge each repeated field into one line, then rerun '{REVIEW_CLAIMS_COMMAND}'." + )); + lines.join("\n") +} + +fn field_value<'a>(entry: &'a PlanEntry, key: &str) -> Option<&'a str> { + entry + .fields + .iter() + .find(|field| field.key == key) + .map(|field| field.value.as_str()) +} + +fn field_text<'a>(entry: &'a PlanEntry, key: &str) -> &'a str { + field_value(entry, key).unwrap_or_default() +} + +fn listed_ids(value: &str) -> impl Iterator { + value.split(REFERENCE_SEPARATOR) +} + +fn blob_url(arguments: &PrDescriptionArguments, path: &str) -> String { + format!( + "{GITHUB_URL}/{}/blob/{}/{path}", + arguments.repo, arguments.head + ) +} diff --git a/cli/src/services/plan/command.rs b/cli/src/services/plan/command.rs new file mode 100644 index 000000000..9420922db --- /dev/null +++ b/cli/src/services/plan/command.rs @@ -0,0 +1,266 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use anyhow::{anyhow, Context}; + +use crate::services::error::CliError; +use crate::services::plan::claims; +use crate::services::plan::extract::{extract, Plan}; +use crate::services::plan::init::{create, resolve_repository_root, PlanName}; +use crate::services::plan::pr_description::{ + decision_brief_stdout_payload, parse_arguments, render_format, BriefFormat, PlanInput, +}; +use crate::services::plan::validate::{validate, PlanDiagnostic}; + +pub const STRUCTURED_PLAN_PASS: &str = "structured plan: reference integrity holds"; +const PR_DESCRIPTION_COMMAND: &str = "sce plan pr-description"; +const REVIEW_COMMAND: &str = "sce plan review"; +const REGULAR_BLOB_MODES: [&str; 2] = ["100644", "100755"]; +const BLOB_TYPE: &str = "blob"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanCheckRequest { + pub path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanInitRequest { + pub name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanPrDescriptionRequest { + pub repo: String, + pub head: String, + pub paths: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewOutput { + Brief, + Claims, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanReviewRequest { + pub repo: String, + pub head: String, + pub paths: Vec, + pub output: ReviewOutput, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlanRequest { + Check(PlanCheckRequest), + Init(PlanInitRequest), + PrDescription(PlanPrDescriptionRequest), + Review(PlanReviewRequest), +} + +pub struct PlanCommand { + pub request: PlanRequest, +} + +impl PlanCommand { + pub fn execute_with_stderr(&self, stderr: &mut impl Write) -> Result { + self.execute_with(std::env::current_dir, stderr) + } + + pub(crate) fn execute_with( + &self, + current_dir: impl FnOnce() -> std::io::Result, + stderr: &mut impl Write, + ) -> Result { + match &self.request { + PlanRequest::Check(request) => check(&request.path), + PlanRequest::Init(request) => { + let name = PlanName::parse(&request.name)?; + let start = current_dir() + .context("Failed to read the current directory") + .map_err(CliError::runtime)?; + create(&name, &start, stderr) + } + PlanRequest::PrDescription(request) => pr_description(request, current_dir), + PlanRequest::Review(request) => review(request, current_dir), + } + } +} + +fn check(path: &Path) -> Result { + load_valid_plan(path, &path.display().to_string())?; + Ok(STRUCTURED_PLAN_PASS.to_string()) +} + +fn pr_description( + request: &PlanPrDescriptionRequest, + current_dir: impl FnOnce() -> std::io::Result, +) -> Result { + let arguments = parse_arguments(&request.repo, &request.head, &request.paths)?; + let start = current_dir() + .context("Failed to read the current directory") + .map_err(CliError::runtime)?; + let root = resolve_repository_root(&start, PR_DESCRIPTION_COMMAND)?; + let plans = arguments + .paths + .iter() + .map(|path| { + Ok(PlanInput { + path: path.clone(), + plan: load_valid_plan(&Path::new(&root).join(path), path)?, + }) + }) + .collect::, CliError>>()?; + render_format(&arguments, &plans, BriefFormat::IntentReview) +} + +fn review( + request: &PlanReviewRequest, + current_dir: impl FnOnce() -> std::io::Result, +) -> Result { + let arguments = parse_arguments(&request.repo, &request.head, &request.paths)?; + let start = current_dir() + .context("Failed to read the current directory") + .map_err(CliError::runtime)?; + let root = resolve_repository_root(&start, REVIEW_COMMAND)?; + verify_commit(&root, &arguments.head)?; + let plans = arguments + .paths + .iter() + .map(|path| { + let source = read_committed_plan(&root, &arguments.head, path)?; + Ok(PlanInput { + path: path.clone(), + plan: parse_valid_plan(&source, &format!("{}:{path}", arguments.head), path)?, + }) + }) + .collect::, CliError>>()?; + match request.output { + ReviewOutput::Brief => render_format(&arguments, &plans, BriefFormat::DecisionBrief) + .map(decision_brief_stdout_payload), + ReviewOutput::Claims => claims::render(&arguments, &plans), + } +} + +fn run_git(root: &str, args: &[&str]) -> Result { + Command::new("git") + .args(args) + .current_dir(root) + .output() + .with_context(|| { + format!( + "Failed to run 'git {}'. Try: install Git and rerun '{REVIEW_COMMAND}'.", + args.join(" ") + ) + }) + .map_err(CliError::runtime) +} + +fn verify_commit(root: &str, head: &str) -> Result<(), CliError> { + let revision = format!("{head}^{{commit}}"); + let output = run_git(root, &["rev-parse", "--verify", "--quiet", &revision])?; + if !output.status.success() { + return Err(CliError::runtime(anyhow!( + "Commit '{head}' is not available in the local repository. Try: fetch the commit, then rerun '{REVIEW_COMMAND}'." + ))); + } + Ok(()) +} + +fn read_committed_plan(root: &str, head: &str, path: &str) -> Result { + let listing = run_git(root, &["ls-tree", "-z", head, "--", path])?; + if !listing.status.success() { + return Err(CliError::runtime(anyhow!( + "Failed to list plan file '{path}' at commit '{head}' ({}). Try: rerun '{REVIEW_COMMAND}' inside the repository that contains the commit.", + String::from_utf8_lossy(&listing.stderr).trim() + ))); + } + + let entry = listing + .stdout + .split(|byte| *byte == 0) + .find_map(|entry| parse_tree_entry(entry).filter(|entry| entry.name == path)) + .ok_or_else(|| { + CliError::runtime(anyhow!( + "Plan file '{path}' does not exist at commit '{head}'. Try: commit the plan file, or pass a path that exists at '{head}'." + )) + })?; + if entry.kind != BLOB_TYPE || !REGULAR_BLOB_MODES.contains(&entry.mode) { + return Err(CliError::runtime(anyhow!( + "Plan path '{path}' at commit '{head}' is not a regular file (mode {}, type {}). Try: commit the plan as a regular file, not a symlink or submodule.", + entry.mode, + entry.kind + ))); + } + + let blob = run_git(root, &["cat-file", BLOB_TYPE, entry.object])?; + if !blob.status.success() { + return Err(CliError::runtime(anyhow!( + "Failed to read plan file '{path}' at commit '{head}' ({}). Try: rerun '{REVIEW_COMMAND}' after 'git fsck' reports no missing objects.", + String::from_utf8_lossy(&blob.stderr).trim() + ))); + } + String::from_utf8(blob.stdout).map_err(|_| { + CliError::runtime(anyhow!( + "Plan file '{path}' at commit '{head}' is not valid UTF-8. Try: commit the plan as UTF-8 text." + )) + }) +} + +struct TreeEntry<'a> { + mode: &'a str, + kind: &'a str, + object: &'a str, + name: &'a str, +} + +fn parse_tree_entry(entry: &[u8]) -> Option> { + let entry = std::str::from_utf8(entry).ok()?; + let (metadata, name) = entry.split_once('\t')?; + let mut fields = metadata.split(' '); + let (mode, kind, object) = (fields.next()?, fields.next()?, fields.next()?); + if fields.next().is_some() { + return None; + } + Some(TreeEntry { + mode, + kind, + object, + name, + }) +} + +fn load_valid_plan(path: &Path, label: &str) -> Result { + let source = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read plan file '{}'", path.display())) + .map_err(CliError::runtime)?; + parse_valid_plan(&source, &path.display().to_string(), label) +} + +fn parse_valid_plan(source: &str, source_name: &str, label: &str) -> Result { + let plan = extract(source) + .with_context(|| format!("Failed to parse plan file '{source_name}'")) + .map_err(CliError::runtime)?; + let report = validate(&plan); + + if !report.diagnostics.is_empty() { + return Err(CliError::validation(render_diagnostics( + label, + &report.diagnostics, + ))); + } + + Ok(plan) +} + +fn render_diagnostics(path: &str, diagnostics: &[PlanDiagnostic]) -> String { + let mut lines = vec![format!("Plan structure check failed for '{path}':")]; + lines.extend(diagnostics.iter().map(|diagnostic| match diagnostic.line { + Some(line) => format!("{path}:{line}: {}", diagnostic.message), + None => format!("{path}: {}", diagnostic.message), + })); + lines.push(format!( + "Try: fix the reported plan defects, then rerun 'sce plan check {path}'." + )); + lines.join("\n") +} diff --git a/cli/src/services/plan/extract.rs b/cli/src/services/plan/extract.rs new file mode 100644 index 000000000..0f8b96f7d --- /dev/null +++ b/cli/src/services/plan/extract.rs @@ -0,0 +1,444 @@ +use anyhow::{anyhow, Result}; +use markdown::mdast::Node; +use markdown::{to_mdast, ParseOptions}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlanEntryKind { + Decision, + AcceptanceCriterion, + Scenario, + Task, +} + +impl PlanEntryKind { + pub fn of_id(id: &str) -> Self { + if id.starts_with("AC") { + Self::AcceptanceCriterion + } else if id.starts_with('D') { + Self::Decision + } else if id.starts_with('S') { + Self::Scenario + } else { + Self::Task + } + } + + fn of_section(heading: &PlanHeading) -> Option { + match (heading.depth, heading.text.as_str()) { + (2, "Design decisions") => Some(Self::Decision), + (2, "Acceptance criteria") => Some(Self::AcceptanceCriterion), + (3, "Verification scenarios") => Some(Self::Scenario), + (2, "Task stack") => Some(Self::Task), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanHeading { + pub depth: u8, + pub text: String, + pub line: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanField { + pub key: String, + pub value: String, + pub line: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanEntry { + pub id: String, + pub kind: PlanEntryKind, + pub checked: Option, + pub line: usize, + pub statement: String, + pub fields: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutsideDefinition { + pub id: String, + pub line: usize, + pub section: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Plan { + pub headings: Vec, + pub entries: Vec, + pub outside_definitions: Vec, + pub fenced_ids: Vec, +} + +impl Plan { + pub fn entries_of(&self, kind: PlanEntryKind) -> impl Iterator { + self.entries.iter().filter(move |entry| entry.kind == kind) + } +} + +pub fn extract(source: &str) -> Result { + let root = parse(source)?; + let mut plan = Plan::default(); + let mut section = None; + + for node in root.children().into_iter().flatten() { + match node { + Node::Heading(heading) => { + let heading = PlanHeading { + depth: heading.depth, + text: node.to_string(), + line: line_of(node), + }; + section = PlanEntryKind::of_section(&heading); + plan.headings.push(heading); + } + Node::List(_) => { + for item in node.children().into_iter().flatten() { + match section.and_then(|kind| definition(source, item, kind)) { + Some(entry) => plan.entries.push(entry), + None => collect_outside_definitions(source, item, section, &mut plan), + } + } + } + _ => collect_outside_definitions(source, node, None, &mut plan), + } + } + + Ok(plan) +} + +fn parse(source: &str) -> Result { + to_mdast(source, &ParseOptions::gfm()) + .map_err(|error| anyhow!("failed to parse Markdown: {error}")) +} + +fn definition(source: &str, item: &Node, section: PlanEntryKind) -> Option { + let Node::ListItem(list_item) = item else { + return None; + }; + let (id, rest) = parse_id_token(lead_text(source, item)?)?; + let kind = PlanEntryKind::of_id(id); + if kind != section { + return None; + } + + let fields = item + .children() + .into_iter() + .flatten() + .filter(|child| matches!(child, Node::List(_))) + .flat_map(|list| list.children().into_iter().flatten()) + .filter_map(|field_item| field(source, field_item)) + .collect(); + + Some(PlanEntry { + id: id.to_string(), + kind, + checked: list_item.checked, + line: line_of(item), + statement: rest.split_whitespace().collect::>().join(" "), + fields, + }) +} + +fn field(source: &str, item: &Node) -> Option { + let (key, value) = parse_field_key(lead_text(source, item)?)?; + Some(PlanField { + key: key.to_string(), + value: value.to_string(), + line: line_of(item), + }) +} + +fn collect_outside_definitions( + source: &str, + node: &Node, + section: Option, + plan: &mut Plan, +) { + match node { + Node::Code(code) => collect_fenced_ids(&code.value, &mut plan.fenced_ids), + Node::ListItem(_) => { + if let Some((id, _)) = lead_text(source, node).and_then(parse_id_token) { + plan.outside_definitions.push(OutsideDefinition { + id: id.to_string(), + line: line_of(node), + section, + }); + } + for child in node.children().into_iter().flatten() { + collect_outside_definitions(source, child, None, plan); + } + } + _ => { + for child in node.children().into_iter().flatten() { + collect_outside_definitions(source, child, None, plan); + } + } + } +} + +fn collect_fenced_ids(code: &str, ids: &mut Vec) { + let Ok(root) = parse(code) else { + return; + }; + let mut nested = Plan::default(); + collect_outside_definitions(code, &root, None, &mut nested); + ids.extend( + nested + .outside_definitions + .into_iter() + .map(|outside| outside.id), + ); + ids.extend(nested.fenced_ids); +} + +fn lead_text<'a>(source: &'a str, item: &Node) -> Option<&'a str> { + let paragraph = item.children()?.first()?; + if !matches!(paragraph, Node::Paragraph(_)) { + return None; + } + let position = paragraph.position()?; + source.get(position.start.offset..position.end.offset) +} + +fn line_of(node: &Node) -> usize { + node.position().map_or(0, |position| position.start.line) +} + +fn parse_id_token(text: &str) -> Option<(&str, &str)> { + let (id, rest) = text.split_once(':')?; + if !is_id(id) || !(rest.is_empty() || rest.starts_with(char::is_whitespace)) { + return None; + } + Some((id, rest)) +} + +pub fn is_id(token: &str) -> bool { + let (digits, minimum_digits) = if let Some(digits) = token.strip_prefix("AC") { + (digits, 1) + } else if let Some(digits) = token.strip_prefix('D').or_else(|| token.strip_prefix('S')) { + (digits, 1) + } else if let Some(digits) = token.strip_prefix('T') { + return digits.len() >= 2 && digits.bytes().all(|byte| byte.is_ascii_digit()); + } else { + return false; + }; + digits.len() >= minimum_digits + && digits.bytes().all(|byte| byte.is_ascii_digit()) + && !digits.starts_with('0') +} + +fn parse_field_key(text: &str) -> Option<(&str, &str)> { + let (key, rest) = text.split_once(':')?; + let mut chars = key.chars(); + let starts_upper = chars.next().is_some_and(|first| first.is_ascii_uppercase()); + let key_chars_valid = chars.all(|ch| ch.is_ascii_alphabetic() || ch == ' ' || ch == '-'); + if !starts_upper || !key_chars_valid { + return None; + } + if !(rest.is_empty() || rest.starts_with(char::is_whitespace)) { + return None; + } + Some((key, rest.trim())) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use super::{extract, is_id, OutsideDefinition, Plan, PlanEntryKind}; + + fn fixture(name: &str) -> Plan { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../config/pkl/renderers/fixtures/plan-structure") + .join(name); + let source = fs::read_to_string(&path).expect("fixture should be readable"); + extract(&source).expect("fixture should parse") + } + + fn entry_lines(plan: &Plan) -> Vec<(&str, PlanEntryKind, usize)> { + plan.entries + .iter() + .map(|entry| (entry.id.as_str(), entry.kind, entry.line)) + .collect() + } + + #[test] + fn fenced_example_plan_yields_only_out_of_fence_definitions() { + let plan = fixture("fenced-example-plan.md"); + + assert_eq!( + entry_lines(&plan), + vec![ + ("D1", PlanEntryKind::Decision, 37), + ("AC1", PlanEntryKind::AcceptanceCriterion, 45), + ("S1", PlanEntryKind::Scenario, 52), + ("T01", PlanEntryKind::Task, 60), + ] + ); + assert_eq!(plan.fenced_ids, vec!["D1", "AC1", "S1", "T01"]); + assert!(plan.outside_definitions.is_empty()); + } + + #[test] + fn fenced_example_plan_headings_skip_fenced_headings() { + let plan = fixture("fenced-example-plan.md"); + let headings: Vec<(u8, &str, usize)> = plan + .headings + .iter() + .map(|heading| (heading.depth, heading.text.as_str(), heading.line)) + .collect(); + + assert_eq!( + headings, + vec![ + (1, "Plan: fenced-example-plan", 1), + (2, "Change summary", 3), + (3, "Format at a glance", 7), + (2, "Design decisions", 35), + (2, "Acceptance criteria", 43), + (3, "Verification scenarios", 50), + (2, "Task stack", 58), + (2, "Open questions", 69), + ] + ); + } + + #[test] + fn definition_fields_are_sliced_from_source_with_lines() { + let plan = fixture("fenced-example-plan.md"); + let task = plan + .entries_of(PlanEntryKind::Task) + .next() + .expect("task should be extracted"); + let fields: Vec<(&str, &str, usize)> = task + .fields + .iter() + .map(|field| (field.key.as_str(), field.value.as_str(), field.line)) + .collect(); + + assert_eq!(task.checked, Some(false)); + assert_eq!( + fields, + vec![ + ("Task ID", "T01", 61), + ("Covers", "AC1", 62), + ("Scope", "In — route handler. Out — rotation policy.", 63), + ("Dependencies", "none", 64), + ( + "Done when", + "The endpoint returns 401 for a revoked token.", + 65 + ), + ("Verify", "`pnpm test src/auth/refresh.test.ts`", 66), + ("Context synchronization", "pending", 67), + ] + ); + + let decision = plan + .entries_of(PlanEntryKind::Decision) + .next() + .expect("decision should be extracted"); + assert_eq!(decision.checked, None); + } + + #[test] + fn misplaced_definition_yields_no_scenario_entry() { + let plan = fixture("misplaced-definition.md"); + + assert_eq!(plan.entries_of(PlanEntryKind::Scenario).count(), 0); + assert_eq!( + plan.outside_definitions, + vec![OutsideDefinition { + id: "S1".to_string(), + line: 21, + section: Some(PlanEntryKind::AcceptanceCriterion), + }] + ); + let criterion = plan + .entries_of(PlanEntryKind::AcceptanceCriterion) + .next() + .expect("acceptance criterion should be extracted"); + let keys: Vec<&str> = criterion + .fields + .iter() + .map(|field| field.key.as_str()) + .collect(); + assert_eq!(keys, vec!["Validate", "Decisions", "Scenarios"]); + } + + #[test] + fn unstructured_plan_with_fenced_structured_example_ignores_fenced_structure() { + let plan = fixture("unstructured-plan-with-fenced-structured-example.md"); + + assert_eq!( + entry_lines(&plan), + vec![ + ("AC1", PlanEntryKind::AcceptanceCriterion, 21), + ("T01", PlanEntryKind::Task, 26), + ] + ); + assert!(!plan + .headings + .iter() + .any(|heading| heading.text == "Verification scenarios")); + assert!(plan.entries[1] + .fields + .iter() + .all(|field| field.key != "Covers")); + assert_eq!(plan.fenced_ids, vec!["S1", "T01"]); + } + + #[test] + fn definition_statement_is_folded_source_text() { + let source = "# Plan: statement\n\ +\n\ +## Design decisions\n\ +\n\ +- D1: Keep the extractor authoritative. \n\ + - Rationale: One parse.\n\ +- D3: Read plans from the\n\ + head commit,\tnot the\n\ + working tree.\n\ +- D2:\n\ +\n\ +```markdown\n\ +- D9: Fenced example.\n\ +```\n"; + let plan = extract(source).expect("plan should parse"); + let statements: Vec<(&str, usize, &str)> = plan + .entries + .iter() + .map(|entry| (entry.id.as_str(), entry.line, entry.statement.as_str())) + .collect(); + + assert_eq!( + statements, + vec![ + ("D1", 5, "Keep the extractor authoritative."), + ( + "D3", + 7, + "Read plans from the head commit, not the working tree." + ), + ("D2", 10, ""), + ] + ); + assert_eq!(plan.fenced_ids, vec!["D9"]); + } + + #[test] + fn id_tokens_follow_plan_id_grammar() { + for valid in ["D1", "AC12", "S3", "T01", "T100"] { + assert!(is_id(valid), "{valid} should be an ID"); + } + for invalid in ["D0", "AC", "S01", "T1", "X1", "AC1a", ""] { + assert!(!is_id(invalid), "{invalid} should not be an ID"); + } + } +} diff --git a/cli/src/services/plan/init.rs b/cli/src/services/plan/init.rs new file mode 100644 index 000000000..15ba2b0d1 --- /dev/null +++ b/cli/src/services/plan/init.rs @@ -0,0 +1,229 @@ +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context}; + +use crate::services::error::CliError; +use crate::services::plan::{SCAFFOLD, SCAFFOLD_PLAN_NAME_TOKEN}; + +pub const PLAN_NAME_MAX_BYTES: usize = 64; +pub const PLANS_RELATIVE_DIR: &str = "context/plans"; +const PLAN_EXTENSION: &str = ".md"; +const CREATED_PREFIX: &str = "Created plan: "; +const NEXT_LINE: &str = "Next: complete the plan and run"; +const INIT_COMMAND: &str = "sce plan init"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanName(String); + +impl PlanName { + pub fn parse(value: &str) -> Result { + if value.ends_with(PLAN_EXTENSION) { + return Err(invalid_name(value, "drop the '.md' suffix")); + } + if value.len() > PLAN_NAME_MAX_BYTES { + return Err(invalid_name( + value, + &format!("use at most {PLAN_NAME_MAX_BYTES} bytes"), + )); + } + if !is_kebab_case(value) { + return Err(invalid_name( + value, + "use lowercase letters and digits separated by single hyphens", + )); + } + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn is_kebab_case(value: &str) -> bool { + !value.is_empty() + && value.split('-').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +fn invalid_name(value: &str, remedy: &str) -> CliError { + CliError::validation(format!( + "Invalid plan name '{value}'. Try: {remedy}; plan names match [a-z0-9]+(-[a-z0-9]+)*." + )) +} + +pub fn create(name: &PlanName, start: &Path, stderr: &mut impl Write) -> Result { + create_with( + name, + start, + stderr, + |temporary, destination| std::fs::hard_link(temporary, destination), + |temporary| std::fs::remove_file(temporary), + ) +} + +pub(super) fn create_with( + name: &PlanName, + start: &Path, + stderr: &mut impl Write, + link: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result { + let root = resolve_repository_root(start, INIT_COMMAND)?; + let context_dir = Path::new(&root).join("context"); + let plans_dir = Path::new(&root).join(PLANS_RELATIVE_DIR); + ensure_directory(&context_dir)?; + ensure_directory(&plans_dir)?; + + let file_name = format!("{}{PLAN_EXTENSION}", name.as_str()); + let relative_path = format!("{PLANS_RELATIVE_DIR}/{file_name}"); + let content = SCAFFOLD.replacen(SCAFFOLD_PLAN_NAME_TOKEN, name.as_str(), 1); + if let Some(warning) = publish( + &plans_dir, + &file_name, + &relative_path, + &content, + link, + remove, + )? { + let _ = writeln!(stderr, "{warning}"); + } + + let absolute_path = format!("{root}/{relative_path}"); + Ok([ + format!("{CREATED_PREFIX}{relative_path}"), + NEXT_LINE.to_string(), + format!("sce plan check {}", shell_quote(&absolute_path)), + ] + .join("\n")) +} + +pub(super) fn resolve_repository_root(start: &Path, command: &str) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(start) + .output() + .with_context(|| format!("Failed to run 'git rev-parse --show-toplevel'. Try: install Git and rerun '{command}'.")) + .map_err(CliError::runtime)?; + if !output.status.success() { + return Err(CliError::runtime(anyhow!( + "Not inside a Git repository: '{}' ({}). Try: run '{command}' inside a Git repository.", + start.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + + let stdout = String::from_utf8(output.stdout).map_err(|_| { + CliError::runtime(anyhow!( + "Repository root is not valid UTF-8. Try: move the repository to a UTF-8 path." + )) + })?; + let root = stdout.strip_suffix('\n').unwrap_or(&stdout); + if root.is_empty() || root.contains('\n') { + return Err(CliError::runtime(anyhow!( + "Repository root cannot be printed on one line. Try: move the repository to a path without newlines." + ))); + } + Ok(root.to_string()) +} + +fn ensure_directory(path: &Path) -> Result<(), CliError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) => Err(not_a_directory(path)), + Err(error) if error.kind() == ErrorKind::NotFound => match std::fs::create_dir(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + _ => Err(not_a_directory(path)), + } + } + Err(error) => Err(CliError::runtime( + anyhow!(error).context(format!("Failed to create directory '{}'", path.display())), + )), + }, + Err(error) => Err(CliError::runtime( + anyhow!(error).context(format!("Failed to inspect '{}'", path.display())), + )), + } +} + +fn not_a_directory(path: &Path) -> CliError { + CliError::runtime(anyhow!( + "'{}' is not a directory; symlinked or non-directory entries are refused. Try: replace it with a real directory.", + path.display() + )) +} + +pub(super) fn temporary_path(plans_dir: &Path, file_name: &str) -> PathBuf { + plans_dir.join(format!(".{file_name}.{}.tmp", std::process::id())) +} + +fn publish( + plans_dir: &Path, + file_name: &str, + relative_path: &str, + content: &str, + link: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result, CliError> { + let temporary = temporary_path(plans_dir, file_name); + let destination = plans_dir.join(file_name); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .with_context(|| format!("Failed to create temporary file '{}'", temporary.display())) + .map_err(CliError::runtime)?; + let written = file + .write_all(content.as_bytes()) + .and_then(|()| file.flush()) + .with_context(|| format!("Failed to write temporary file '{}'", temporary.display())); + drop(file); + + let published = written.map_err(CliError::runtime).and_then(|()| { + link(&temporary, &destination).map_err(|error| { + if error.kind() == ErrorKind::AlreadyExists { + CliError::validation(format!( + "Plan already exists: {relative_path}. Try: choose another plan name; existing plans are never overwritten." + )) + } else { + CliError::runtime(anyhow!(error).context(format!( + "Failed to publish '{}'", + destination.display() + ))) + } + }) + }); + let removed = remove(&temporary); + + match (published, removed) { + (Ok(()), Ok(())) => Ok(None), + (Ok(()), Err(error)) => Ok(Some(format!( + "Warning: Created plan {relative_path}, but failed to remove temporary file '{}': {error}. Try: remove '{}' manually; the plan was created and does not need to be initialized again.", + temporary.display(), + temporary.display() + ))), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(cleanup)) => Err(CliError::internal( + error.class(), + anyhow!( + "{error}\nCleanup also failed: could not remove temporary file '{}': {cleanup}. Try: remove it manually.", + temporary.display() + ), + )), + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/cli/src/services/plan/mod.rs b/cli/src/services/plan/mod.rs new file mode 100644 index 000000000..a865424bd --- /dev/null +++ b/cli/src/services/plan/mod.rs @@ -0,0 +1,3571 @@ +pub mod claims; +pub mod command; +pub mod extract; +pub mod init; +pub mod pr_description; +pub mod validate; + +pub const NAME: &str = "plan"; + +pub(crate) const SCAFFOLD: &str = include_str!(concat!( + env!("OUT_DIR"), + "/pkl-generated/config/plan-init-scaffold.md" +)); + +pub(crate) const SCAFFOLD_PLAN_NAME_TOKEN: &str = "{plan-name}"; + +#[cfg(test)] +fn fixture_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../config/pkl/renderers/fixtures/plan-structure") +} + +#[cfg(test)] +fn validate_source(source: &str) -> validate::PlanReport { + validate::validate(&extract::extract(source).expect("plan should parse")) +} + +#[cfg(test)] +fn validate_fixture(name: &str) -> validate::PlanReport { + let source = + std::fs::read_to_string(fixture_dir().join(name)).expect("fixture should be readable"); + validate_source(&source) +} + +#[cfg(test)] +fn diagnostic_lines(report: &validate::PlanReport) -> Vec<(Option, &str)> { + report + .diagnostics + .iter() + .map(|diagnostic| (diagnostic.line, diagnostic.message.as_str())) + .collect() +} + +#[test] +fn fixture_expectations_match_manifest() { + let manifest = std::fs::read_to_string(fixture_dir().join("expectations.tsv")) + .expect("manifest should be readable"); + let mut listed = Vec::new(); + + for row in manifest + .lines() + .filter(|row| !row.trim().is_empty() && !row.starts_with('#')) + { + let mut columns = row.split('\t'); + let fixture = columns.next().expect("row should name a fixture"); + let verdict = columns.next().expect("row should carry a verdict"); + let mut expected: Vec<&str> = columns.collect(); + let report = validate_fixture(fixture); + listed.push(fixture.to_string()); + let mut actual: Vec<&str> = report + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect(); + expected.sort_unstable(); + actual.sort_unstable(); + + match verdict { + "pass" => assert!( + expected.is_empty(), + "{fixture}: pass rows take no diagnostics" + ), + "fail" => assert!( + !expected.is_empty(), + "{fixture}: fail rows need diagnostics" + ), + other => panic!("{fixture}: unknown verdict `{other}`"), + } + assert_eq!(actual, expected, "{fixture}: diagnostics differ"); + } + + let mut fixtures: Vec = std::fs::read_dir(fixture_dir()) + .expect("fixture directory should be readable") + .map(|entry| { + entry + .expect("fixture entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .filter(|name| { + std::path::Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + }) + .collect(); + fixtures.sort(); + listed.sort(); + assert_eq!(listed, fixtures, "manifest must list every fixture once"); +} + +#[test] +fn diagnostic_lines_match_source_positions() { + assert_eq!( + diagnostic_lines(&validate_fixture("misplaced-field.md")), + vec![ + ( + Some(14), + "`Check:` on `D1` is misplaced; it belongs to a verification scenario" + ), + (Some(18), "`AC1` is not covered by any task's `Covers:`"), + ( + Some(22), + "`Covers:` on `AC1` is misplaced; it belongs to a task" + ), + (Some(26), "`S1` is missing `Check:`"), + (Some(37), "`T01` is missing `Covers:`"), + ] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("misplaced-definition.md")), + vec![ + ( + Some(20), + "`Scenarios:` on `AC1` references undefined `S1`; it appears only outside `### Verification scenarios`" + ), + ( + Some(21), + "`S1` is defined under `## Acceptance criteria`; it belongs under `### Verification scenarios`" + ), + ] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("duplicate-id.md")), + vec![(Some(29), "`S1` is defined more than once")] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("task-empty-covers.md")), + vec![( + Some(47), + "`Covers:` on `T02` is not a comma-separated ID list" + )] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("review-fields-missing-pair.md")), + vec![ + (Some(9), "`D1` has `Review:` without `Review question:`"), + (Some(15), "`D2` has `Review question:` without `Review:`"), + ] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("review-fields-malformed.md")), + vec![ + (Some(14), "`Review:` on `D1` must be `required`"), + (Some(22), "`Review question:` on `D2` is empty"), + (Some(29), "`Review question:` on `D3` must be a single line"), + (Some(36), "`Review:` on `D4` must be `required`"), + ] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("review-fields-duplicate.md")), + vec![ + (Some(16), "`Review:` appears more than once on `D1`"), + ( + Some(24), + "`Review question:` appears more than once on `D2`" + ), + ] + ); + assert_eq!( + diagnostic_lines(&validate_fixture("review-fields-misplaced.md")), + vec![ + ( + Some(21), + "`Review:` on `AC1` is misplaced; it belongs to a design decision" + ), + ( + Some(22), + "`Review question:` on `AC1` is misplaced; it belongs to a design decision" + ), + ( + Some(45), + "`Review:` on `T01` is misplaced; it belongs to a design decision" + ), + ( + Some(46), + "`Review question:` on `T01` is misplaced; it belongs to a design decision" + ), + ] + ); + assert_eq!( + diagnostic_lines(&validate_source( + "# Plan\n\n## Acceptance criteria\n\n### Verification scenarios\n" + )), + vec![(None, "heading `## Task stack` is missing")] + ); +} + +#[test] +fn misplaced_definition_ignores_prose_and_fenced_mentions() { + let source = "# Plan\n\n## Acceptance criteria\n\n- [ ] AC1: Works.\n - Validate: `true`\n - Scenarios: S1\n - S2: nested prose\n\n### Verification scenarios\n\n- S1: Works\n - Given: A.\n - When: B.\n - Then: C.\n - Check: `true`\n\n```markdown\n- T02: fenced example\n```\n\n## Task stack\n\n- [ ] T01: `Work` (status:todo)\n - Covers: AC1\n\n## Open questions\n\n- D1: incidental prose\n"; + + assert!(diagnostic_lines(&validate_source(source)).is_empty()); + assert_eq!( + diagnostic_lines(&validate_source(&source.replace(" - S2: nested prose\n", "- S2: misplaced\n"))), + vec![ + ( + Some(8), + "`S2` is defined under `## Acceptance criteria`; it belongs under `### Verification scenarios`" + ), + ] + ); +} + +#[test] +fn scaffold_untouched_fails_check() { + assert_eq!(SCAFFOLD.matches(SCAFFOLD_PLAN_NAME_TOKEN).count(), 1); + let source = SCAFFOLD.replacen(SCAFFOLD_PLAN_NAME_TOKEN, "untouched", 1); + assert!(source.starts_with("# Plan: untouched\n"), "{source}"); + + let plan = extract::extract(&source).expect("scaffold should parse"); + assert_eq!(plan.entries_of(extract::PlanEntryKind::Decision).count(), 0); + assert_eq!(plan.entries_of(extract::PlanEntryKind::Scenario).count(), 0); + + let report = validate::validate(&plan); + let mut actual: Vec<&str> = report + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect(); + let mut expected = vec![ + "`Scenarios:` on `AC1` is not a comma-separated ID list", + "`AC1` is not covered by any task's `Covers:`", + "`Covers:` on `T01` is not a comma-separated ID list", + ]; + actual.sort_unstable(); + expected.sort_unstable(); + assert_eq!(actual, expected); +} + +#[cfg(test)] +struct CommandRun { + exit_code: std::process::ExitCode, + stdout: String, + stderr: String, +} + +#[cfg(test)] +fn run_plan_args(args: &[&str], start: &std::path::Path) -> CommandRun { + let mut stderr = Vec::new(); + let result = crate::services::parse::command_runtime::parse_runtime_command( + std::iter::once("sce") + .chain(args.iter().copied()) + .map(String::from), + &crate::services::command_registry::CommandRegistry::default(), + None, + ) + .and_then(|command| { + let crate::services::command_registry::RuntimeCommand::Plan(command) = command else { + panic!("expected plan command"); + }; + command.execute_with(|| Ok(start.to_path_buf()), &mut stderr) + }); + render_plan_result(result, stderr) +} + +#[cfg(test)] +fn render_plan_result( + result: Result, + mut stderr: Vec, +) -> CommandRun { + let mut stdout = Vec::new(); + let exit_code = crate::services::app_support::render_run_outcome( + crate::services::app_support::RunOutcome { + result, + logger: None::, + startup_diagnostic: None, + }, + &mut stdout, + &mut stderr, + ); + + CommandRun { + exit_code, + stdout: String::from_utf8(stdout).expect("stdout should be UTF-8"), + stderr: strip_ansi(&String::from_utf8(stderr).expect("stderr should be UTF-8")), + } +} + +#[cfg(test)] +fn run_plan_check(path: &str) -> CommandRun { + run_plan_args(&[NAME, "check", path], std::path::Path::new(".")) +} + +#[test] +fn command_valid_plans_exit_success() { + let relative_dir = "../config/pkl/renderers/fixtures/plan-structure"; + for (fixture, expected) in [ + ("valid-structured-plan.md", command::STRUCTURED_PLAN_PASS), + ("inspection-only-plan.md", command::STRUCTURED_PLAN_PASS), + ] { + let absolute = fixture_dir().join(fixture); + for path in [ + format!("{relative_dir}/{fixture}"), + absolute.display().to_string(), + ] { + let run = run_plan_check(&path); + assert_eq!(run.exit_code, std::process::ExitCode::SUCCESS, "{path}"); + assert_eq!(run.stdout, format!("{expected}\n"), "{path}"); + assert!(run.stderr.is_empty(), "{path}: {}", run.stderr); + } + } +} + +#[test] +fn command_invalid_plan_exits_validation() { + let path = "../config/pkl/renderers/fixtures/plan-structure/duplicate-id.md"; + let run = run_plan_check(path); + + assert_eq!(run.exit_code, std::process::ExitCode::from(3)); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!(run.stderr.contains("SCE-ERR-VALIDATION"), "{}", run.stderr); + assert_eq!( + run.stderr + .lines() + .filter(|line| line.starts_with(&format!("{path}:"))) + .collect::>(), + vec![format!("{path}:29: `S1` is defined more than once")] + ); +} + +#[test] +fn command_unstructured_plan_exits_validation() { + let path = "../config/pkl/renderers/fixtures/plan-structure/unstructured-plan.md"; + let run = run_plan_check(path); + + assert_eq!(run.exit_code, std::process::ExitCode::from(3)); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!( + run.stderr + .contains(&format!("{path}:59: `T01` is missing `Covers:`")), + "{}", + run.stderr + ); +} + +#[test] +fn command_missing_file_is_runtime_failure() { + let directory = tempfile::tempdir().expect("tempdir should be created"); + let path = directory + .path() + .join("missing-plan.md") + .display() + .to_string(); + let run = run_plan_check(&path); + + assert_eq!(run.exit_code, std::process::ExitCode::from(4)); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!(run.stderr.contains("SCE-ERR-RUNTIME"), "{}", run.stderr); + assert!(run.stderr.contains(&path), "{}", run.stderr); + assert!(!run.stderr.contains(&format!("{path}:")), "{}", run.stderr); +} + +#[test] +fn command_does_not_execute_plan_commands() { + let directory = tempfile::tempdir().expect("tempdir should be created"); + let marker = directory.path().join("executed"); + let touch = format!("`touch {}`", marker.display()); + let plan_path = directory.path().join("plan.md"); + std::fs::write( + &plan_path, + format!( + "# Plan: no-execution\n\n## Acceptance criteria\n\n- [ ] AC1: Nothing runs.\n - Validate: {touch}\n - Scenarios: S1\n\n### Verification scenarios\n\n- S1: Commands stay inert\n - Given: A plan.\n - When: It is checked.\n - Then: Nothing runs.\n - Check: {touch}\n\n## Task stack\n\n- [ ] T01: `Do nothing` (status:todo)\n - Task ID: T01\n - Covers: AC1\n - Verify: {touch}\n" + ), + ) + .expect("plan should be written"); + + let run = run_plan_check(&plan_path.display().to_string()); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert_eq!(run.stdout, format!("{}\n", command::STRUCTURED_PLAN_PASS)); + assert!(!marker.exists()); +} + +#[test] +fn plan_check_appears_in_help() { + assert!(crate::services::help::help_text().contains("sce plan")); + let help = crate::cli_schema::render_help_for_path(&[NAME, "check"]) + .expect("plan check help should be renderable"); + assert!(help.contains(""), "{help}"); +} + +#[test] +fn completion_scripts_include_plan_check() { + use crate::services::completion::{render_completion, CompletionRequest, CompletionShell}; + + for shell in [ + CompletionShell::Bash, + CompletionShell::Zsh, + CompletionShell::Fish, + ] { + let script = render_completion(CompletionRequest { shell }); + assert!( + script.contains("plan") && script.contains("check"), + "{shell:?}" + ); + } +} + +#[cfg(test)] +const SCAFFOLD_CHECK_DEFECTS: [&str; 3] = [ + "`AC1` is not covered by any task's `Covers:`", + "`Covers:` on `T01` is not a comma-separated ID list", + "`Scenarios:` on `AC1` is not a comma-separated ID list", +]; + +#[cfg(test)] +fn git_init(root: &std::path::Path) { + let output = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(root) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(test)] +fn temp_repository() -> (tempfile::TempDir, std::path::PathBuf) { + let directory = tempfile::tempdir().expect("tempdir should be created"); + let root = directory + .path() + .canonicalize() + .expect("tempdir should canonicalize"); + git_init(&root); + (directory, root) +} + +#[cfg(test)] +fn tree_listing(root: &std::path::Path) -> Vec { + fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("directory should be readable") { + let path = entry.expect("entry should be readable").path(); + if path.file_name().is_some_and(|name| name == ".git") { + continue; + } + out.push( + path.strip_prefix(root) + .expect("path under root") + .to_path_buf(), + ); + if path.symlink_metadata().expect("metadata").is_dir() { + walk(root, &path, out); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out.sort(); + out +} + +#[cfg(test)] +fn sorted_entries(dir: &std::path::Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("directory should be readable") + .map(|entry| { + entry + .expect("entry should be readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names +} + +#[cfg(test)] +fn expected_init_stdout(root: &std::path::Path, name: &str) -> String { + let absolute = format!("{}/context/plans/{name}.md", root.display()); + format!( + "Created plan: context/plans/{name}.md\nNext: complete the plan and run\nsce plan check '{}'\n", + absolute.replace('\'', "'\\''") + ) +} + +#[cfg(test)] +fn check_defects(stderr: &str, path: &str) -> Vec { + let prefix = format!("{path}:"); + let mut defects: Vec = stderr + .lines() + .filter_map(|line| line.strip_prefix(&prefix)) + .map(|rest| { + rest.split_once(": ") + .map_or(rest, |(_, message)| message) + .to_string() + }) + .collect(); + defects.sort(); + defects +} + +#[test] +fn init_creates_scaffold_at_repository_root() { + let (_directory, root) = temp_repository(); + let run = run_plan_args(&[NAME, "init", "example"], &root); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert_eq!(run.stdout, expected_init_stdout(&root, "example")); + assert!(run.stderr.is_empty(), "{}", run.stderr); + + let plans = root.join("context/plans"); + assert_eq!( + std::fs::read_to_string(plans.join("example.md")).expect("plan should exist"), + SCAFFOLD.replacen(SCAFFOLD_PLAN_NAME_TOKEN, "example", 1) + ); + assert!(std::fs::read_to_string(plans.join("example.md")) + .expect("plan should exist") + .starts_with("# Plan: example\n")); + assert_eq!(sorted_entries(&plans), vec!["example.md"]); + + let status = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&root) + .output() + .expect("git status should run"); + assert_eq!(String::from_utf8_lossy(&status.stdout), "?? context/\n"); +} + +#[test] +fn init_from_nested_directory_targets_repository_root() { + let directory = tempfile::tempdir().expect("tempdir should be created"); + let root = directory + .path() + .canonicalize() + .expect("tempdir should canonicalize") + .join("repo with 'quote'"); + std::fs::create_dir(&root).expect("root should be created"); + git_init(&root); + let nested = root.join("a/b"); + std::fs::create_dir_all(&nested).expect("nested directory should be created"); + + let run = run_plan_args(&[NAME, "init", "nested-plan"], &nested); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert_eq!(run.stdout, expected_init_stdout(&root, "nested-plan")); + let created = root.join("context/plans/nested-plan.md"); + assert!(created.is_file()); + assert!(sorted_entries(&nested).is_empty()); + + let lines: Vec<&str> = run.stdout.lines().collect(); + assert_eq!(lines[0], "Created plan: context/plans/nested-plan.md"); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(format!("sce() {{ printf '%s\\0' \"$@\"; }}\n{}", lines[2])) + .current_dir(&nested) + .output() + .expect("sh should run"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let decoded: Vec = String::from_utf8(output.stdout) + .expect("decoded arguments should be UTF-8") + .split_terminator('\0') + .map(String::from) + .collect(); + let created_path = created.display().to_string(); + assert_eq!( + decoded, + vec![NAME.to_string(), "check".to_string(), created_path.clone()] + ); + + let arguments: Vec<&str> = decoded.iter().map(String::as_str).collect(); + let check = run_plan_args(&arguments, &nested); + assert_eq!( + check.exit_code, + std::process::ExitCode::from(3), + "{}", + check.stderr + ); + assert!( + check.stderr.contains("SCE-ERR-VALIDATION"), + "{}", + check.stderr + ); + assert!( + !check.stderr.contains("Failed to read plan file"), + "{}", + check.stderr + ); + assert_eq!( + check_defects(&check.stderr, &created_path), + SCAFFOLD_CHECK_DEFECTS + ); +} + +#[test] +fn init_never_overwrites_existing_entry() { + let (_directory, root) = temp_repository(); + let plans = root.join("context/plans"); + std::fs::create_dir_all(&plans).expect("plans directory should be created"); + std::fs::write(plans.join("taken.md"), b"known bytes\n").expect("taken should be written"); + std::os::unix::fs::symlink("missing-target.md", plans.join("linked.md")) + .expect("symlink should be created"); + + for name in ["taken", "linked"] { + let run = run_plan_args(&[NAME, "init", name], &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{name}: {}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{name}: {}", run.stdout); + assert!( + run.stderr.contains("SCE-ERR-VALIDATION"), + "{name}: {}", + run.stderr + ); + assert!( + run.stderr + .contains(&format!("Plan already exists: context/plans/{name}.md")), + "{name}: {}", + run.stderr + ); + } + + assert_eq!( + std::fs::read(plans.join("taken.md")).expect("taken should be readable"), + b"known bytes\n" + ); + assert_eq!( + std::fs::read_link(plans.join("linked.md")).expect("linked should stay a symlink"), + std::path::PathBuf::from("missing-target.md") + ); + assert_eq!(sorted_entries(&plans), vec!["linked.md", "taken.md"]); +} + +#[cfg(test)] +fn run_init_with( + root: &std::path::Path, + name: &str, + link: impl FnOnce(&std::path::Path, &std::path::Path) -> std::io::Result<()>, + remove: impl FnOnce(&std::path::Path) -> std::io::Result<()>, +) -> CommandRun { + let mut stderr = Vec::new(); + let plan_name = init::PlanName::parse(name).expect("name should be valid"); + let result = init::create_with(&plan_name, root, &mut stderr, link, remove); + render_plan_result(result, stderr) +} + +#[cfg(test)] +fn injected(message: &str) -> std::io::Error { + std::io::Error::other(message.to_string()) +} + +#[test] +fn init_cleanup_failure_after_publication_warns_and_succeeds() { + let (_directory, root) = temp_repository(); + let plans = root.join("context/plans"); + let temporary = init::temporary_path(&plans, "published.md"); + + let run = run_init_with( + &root, + "published", + |temporary, destination| std::fs::hard_link(temporary, destination), + |_| Err(injected("injected cleanup failure")), + ); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert_eq!(run.stdout, expected_init_stdout(&root, "published")); + assert_eq!( + run.stderr, + format!( + "Warning: Created plan context/plans/published.md, but failed to remove temporary file '{0}': injected cleanup failure. Try: remove '{0}' manually; the plan was created and does not need to be initialized again.\n", + temporary.display() + ) + ); + assert_eq!( + std::fs::read_to_string(plans.join("published.md")).expect("plan should exist"), + SCAFFOLD.replacen(SCAFFOLD_PLAN_NAME_TOKEN, "published", 1) + ); + assert!(temporary.is_file()); +} + +#[test] +fn init_publication_failure_removes_temporary_and_keeps_error() { + let (_directory, root) = temp_repository(); + let plans = root.join("context/plans"); + + let run = run_init_with( + &root, + "unpublished", + |_, _| Err(injected("injected link failure")), + |temporary| std::fs::remove_file(temporary), + ); + + assert_eq!( + run.exit_code, + std::process::ExitCode::from(4), + "{}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!(run.stderr.contains("SCE-ERR-RUNTIME"), "{}", run.stderr); + assert!(run.stderr.contains("Failed to publish"), "{}", run.stderr); + assert!( + run.stderr.contains("injected link failure"), + "{}", + run.stderr + ); + assert!( + !run.stderr.contains("Cleanup also failed"), + "{}", + run.stderr + ); + assert!(sorted_entries(&plans).is_empty()); +} + +#[test] +fn init_publication_and_cleanup_failure_keeps_original_error_and_destination() { + let (_directory, root) = temp_repository(); + let plans = root.join("context/plans"); + std::fs::create_dir_all(&plans).expect("plans directory should be created"); + std::fs::write(plans.join("taken.md"), b"known bytes\n").expect("taken should be written"); + let temporary = init::temporary_path(&plans, "taken.md"); + + let run = run_init_with( + &root, + "taken", + |temporary, destination| std::fs::hard_link(temporary, destination), + |_| Err(injected("injected cleanup failure")), + ); + + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!(run.stderr.contains("SCE-ERR-VALIDATION"), "{}", run.stderr); + assert!( + run.stderr + .contains("Plan already exists: context/plans/taken.md"), + "{}", + run.stderr + ); + assert!( + run.stderr.contains(&format!( + "Cleanup also failed: could not remove temporary file '{}': injected cleanup failure", + temporary.display() + )), + "{}", + run.stderr + ); + assert!(!run.stderr.contains("Warning:"), "{}", run.stderr); + assert_eq!( + std::fs::read(plans.join("taken.md")).expect("taken should be readable"), + b"known bytes\n" + ); +} + +#[test] +fn init_rejects_unsafe_names_without_writing() { + let (_directory, root) = temp_repository(); + let before = tree_listing(&root); + let too_long = "a".repeat(init::PLAN_NAME_MAX_BYTES + 1); + + for name in [ + "../escape", + "a/b", + "Upper", + "trailing-", + "double--hyphen", + "plan.md", + "", + too_long.as_str(), + ] { + let run = run_plan_args(&[NAME, "init", name], &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{name:?}: {}", + run.stderr + ); + assert!( + run.stderr.contains("SCE-ERR-VALIDATION"), + "{name:?}: {}", + run.stderr + ); + assert!( + run.stderr.contains("Invalid plan name"), + "{name:?}: {}", + run.stderr + ); + } + let run = run_plan_args(&[NAME, "init", "--", "-leading"], &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{}", + run.stderr + ); + assert!( + run.stderr.contains("Invalid plan name '-leading'"), + "{}", + run.stderr + ); + + for arguments in [ + vec![NAME, "init", "-leading"], + vec![NAME, "init", "--force", "example"], + vec![NAME, "init", "one", "two"], + ] { + let run = run_plan_args(&arguments, &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::from(2), + "{arguments:?}: {}", + run.stderr + ); + assert!( + run.stderr.contains("SCE-ERR-PARSE"), + "{arguments:?}: {}", + run.stderr + ); + } + + let run = run_plan_args(&[NAME, "init"], &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{}", + run.stderr + ); + assert!(run.stderr.contains("SCE-ERR-VALIDATION"), "{}", run.stderr); + assert!(run.stderr.contains(""), "{}", run.stderr); + + assert!(!root.join("context").exists()); + assert_eq!(tree_listing(&root), before); +} + +#[test] +fn init_outside_repository_is_runtime_failure() { + let directory = tempfile::tempdir().expect("tempdir should be created"); + let start = directory.path(); + let run = run_plan_args(&[NAME, "init", "example"], start); + + assert_eq!( + run.exit_code, + std::process::ExitCode::from(4), + "{}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{}", run.stdout); + assert!(run.stderr.contains("SCE-ERR-RUNTIME"), "{}", run.stderr); + assert!(tree_listing(start).is_empty()); + assert!(!start.join("context").exists()); +} + +#[test] +fn scaffold_authored_passes_check() { + let (_directory, root) = temp_repository(); + let run = run_plan_args(&[NAME, "init", "authored"], &root); + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + + let path = root.join("context/plans/authored.md"); + let source = std::fs::read_to_string(&path).expect("plan should exist"); + assert_eq!(source.matches("Scenarios: TODO").count(), 1); + assert_eq!(source.matches("Covers: TODO").count(), 1); + let authored = source + .replacen("Scenarios: TODO", "Scenarios: none (inspection)", 1) + .replacen("Covers: TODO", "Covers: AC1", 1); + std::fs::write(&path, &authored).expect("plan should be written"); + + let check = run_plan_check(&path.display().to_string()); + assert_eq!( + check.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + check.stderr + ); + assert_eq!(check.stdout, format!("{}\n", command::STRUCTURED_PLAN_PASS)); + assert!(authored.contains("TODO")); +} + +#[test] +fn plan_init_appears_in_help() { + let root_help = strip_ansi(&crate::services::help::help_text()); + assert!(root_help.contains("sce plan init"), "{root_help}"); + let plan_help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME]).expect("plan help should be renderable"), + ); + assert!(plan_help.contains("init"), "{plan_help}"); + let help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME, "init"]) + .expect("plan init help should be renderable"), + ); + assert!(help.contains(""), "{help}"); +} + +#[cfg(test)] +fn strip_ansi(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(character) = chars.next() { + if character != '\x1b' { + output.push(character); + continue; + } + match chars.next() { + Some('[') => { + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + Some(']') => { + while let Some(next) = chars.next() { + if next == '\x07' { + break; + } + if next == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => {} + } + } + output +} + +#[test] +fn completion_scripts_include_plan_init() { + use crate::services::completion::{render_completion, CompletionRequest, CompletionShell}; + + for shell in [ + CompletionShell::Bash, + CompletionShell::Zsh, + CompletionShell::Fish, + ] { + let script = render_completion(CompletionRequest { shell }); + assert!( + script.contains("plan") && script.contains("init"), + "{shell:?}" + ); + } +} + +#[cfg(test)] +const PR_REPO: &str = "crocoder-dev/shared-context-engineering"; + +#[cfg(test)] +const PR_HEAD: &str = "0123456789abcdef0123456789abcdef01234567"; + +#[cfg(test)] +fn review_plan_source(name: &str, questions: [Option<&str>; 3]) -> String { + use std::fmt::Write; + + let mut source = format!("# Plan: {name}\n\n## Design decisions\n\n"); + for (index, question) in questions.iter().enumerate() { + write!( + source, + "- D{}: Decision {}.\n - Boundary: One module.\n - Rationale: It is simple.\n - Rejected: Two modules — they split ownership.\n - Trade-offs: None.\n", + index + 1, + index + 1 + ) + .expect("writing to String should not fail"); + if let Some(question) = question { + write!( + source, + " - Review: required\n - Review question: {question}\n" + ) + .expect("writing to String should not fail"); + } + } + source.push_str( + "\n## Acceptance criteria\n\n- [ ] AC1: It works.\n - Validate: `true`\n - Scenarios: none (inspection)\n\n## Task stack\n\n- [ ] T01: `Do it` (status:todo)\n - Task ID: T01\n - Covers: AC1\n", + ); + source +} + +#[cfg(test)] +fn pr_plan_input(path: &str, source: &str) -> pr_description::PlanInput { + let report = validate_source(source); + assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics); + pr_description::PlanInput { + path: path.to_string(), + plan: extract::extract(source).expect("plan should parse"), + } +} + +#[cfg(test)] +fn pr_arguments(paths: &[&str]) -> pr_description::PrDescriptionArguments { + pr_description::PrDescriptionArguments { + repo: PR_REPO.to_string(), + head: PR_HEAD.to_string(), + paths: paths.iter().map(ToString::to_string).collect(), + } +} + +#[cfg(test)] +fn write_repository_plan(root: &std::path::Path, path: &str, source: &str) { + let destination = root.join(path); + std::fs::create_dir_all( + destination + .parent() + .expect("plan path should have a parent"), + ) + .expect("plans directory should be created"); + std::fs::write(destination, source).expect("plan should be written"); +} + +#[cfg(test)] +fn run_pr_description(root: &std::path::Path, paths: &[&str]) -> CommandRun { + let mut args = vec![NAME, "pr-description", "--repo", PR_REPO, "--head", PR_HEAD]; + args.extend_from_slice(paths); + run_plan_args(&args, root) +} + +#[test] +fn pr_description_golden_multiple_plans() { + let zeta = pr_plan_input( + "context/plans/zeta_notes.md", + &review_plan_source( + "zeta_notes", + [None, Some("Is the zeta store shared?"), None], + ), + ); + let alpha = pr_plan_input( + "context/plans/alpha-plan.md", + &review_plan_source( + "alpha-plan", + [Some("Is A acceptable?"), Some("Is B acceptable?"), None], + ), + ); + let arguments = pr_arguments(&["context/plans/zeta_notes.md", "context/plans/alpha-plan.md"]); + + let output = pr_description::render(&arguments, &[zeta, alpha]).expect("brief should render"); + + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let expected = format!( + "\n\ +## Intent review\n\ +\n\ +**Plans:** [alpha-plan]({blob}/context/plans/alpha-plan.md), [zeta\\_notes]({blob}/context/plans/zeta_notes.md)\n\ +\n\ +**Review scope:** 3 architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused.\n\ +\n\ +**Decisions requiring review**\n\ +\n\ +1. Is A acceptable? — [alpha-plan D1]({blob}/context/plans/alpha-plan.md#L5)\n\ +\n\ +2. Is B acceptable? — [alpha-plan D2]({blob}/context/plans/alpha-plan.md#L12)\n\ +\n\ +3. Is the zeta store shared? — [zeta\\_notes D2]({blob}/context/plans/zeta_notes.md#L10)\n\ +\n\ +" + ); + assert_eq!(output, expected); +} + +#[test] +fn pr_description_golden_two_decisions() { + let plan = pr_plan_input( + "context/plans/alpha-plan.md", + &review_plan_source( + "alpha-plan", + [Some("Is A acceptable?"), Some("Is B acceptable?"), None], + ), + ); + + let output = pr_description::render(&pr_arguments(&["context/plans/alpha-plan.md"]), &[plan]) + .expect("brief should render"); + + let plan_url = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567/context/plans/alpha-plan.md"; + let expected = format!( + "\n\ +## Intent review\n\ +\n\ +**Plans:** [alpha-plan]({plan_url})\n\ +\n\ +**Decisions requiring review**\n\ +\n\ +1. Is A acceptable? — [alpha-plan D1]({plan_url}#L5)\n\ +\n\ +2. Is B acceptable? — [alpha-plan D2]({plan_url}#L12)\n\ +\n\ +" + ); + assert_eq!(output, expected); +} + +#[test] +fn pr_description_no_flagged_decisions() { + let plan = pr_plan_input( + "context/plans/quiet.md", + &review_plan_source("quiet", [None, None, None]), + ); + + let output = pr_description::render(&pr_arguments(&["context/plans/quiet.md"]), &[plan]) + .expect("brief should render"); + + assert_eq!( + output, + "\n## Intent review\n\n**Plans:** [quiet](https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567/context/plans/quiet.md)\n\nNo decisions explicitly flagged for review.\n\n" + ); + assert!(!output.contains("**Decisions requiring review**")); +} + +#[test] +fn pr_description_escapes_markdown() { + let question = r"Keep `code` *bold* _it_ [l] !b |p| ~s & c:\path # (ok) — fine?"; + let expected = + r"Keep \`code\` \*bold\* \_it\_ \[l\] \ \!b \|p\| \~s \& c:\\path # (ok) — fine?"; + + assert_eq!(pr_description::escape_markdown(question), expected); + + let mut unescaped = String::new(); + let mut characters = expected.chars().peekable(); + while let Some(character) = characters.next() { + let escapes_next = character == '\\' + && characters + .peek() + .is_some_and(|next| r"\`*_[]<>!|~&".contains(*next)); + if !escapes_next { + unescaped.push(character); + } else if let Some(next) = characters.next() { + unescaped.push(next); + } + } + assert_eq!(unescaped, question); + + let plan = pr_plan_input( + "context/plans/escape.md", + &review_plan_source("escape", [Some(question), None, None]), + ); + let output = pr_description::render(&pr_arguments(&["context/plans/escape.md"]), &[plan]) + .expect("brief should render"); + let rendered = + r"Keep `code` \*bold\* \_it\_ \[l\] \ \!b \|p\| \~s \& c:\\path # (ok) — fine?"; + assert!( + output.contains(&format!("\n1. {rendered} — [escape D1](")), + "{output}" + ); +} + +#[test] +fn pr_description_repeated_runs_are_identical() { + let (_directory, root) = temp_repository(); + write_repository_plan( + &root, + "context/plans/beta.md", + &review_plan_source("beta", [None, None, Some("Is beta safe?")]), + ); + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Is alpha safe?"), None, None]), + ); + + let forward = run_pr_description(&root, &["context/plans/alpha.md", "context/plans/beta.md"]); + let reverse = run_pr_description(&root, &["context/plans/beta.md", "context/plans/alpha.md"]); + + assert_eq!( + forward.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + forward.stderr + ); + assert_eq!( + reverse.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + reverse.stderr + ); + assert_eq!(forward.stdout, reverse.stdout); + assert!(forward + .stdout + .starts_with("\n")); + assert!( + forward + .stdout + .ends_with("#L15)\n\n\n"), + "{:?}", + forward.stdout + ); + assert!(forward.stdout.contains("1. Is alpha safe? — [alpha D1](")); + assert!(forward.stdout.contains("2. Is beta safe? — [beta D3](")); +} + +#[test] +fn pr_description_rejects_invalid_arguments() { + let (_directory, root) = temp_repository(); + let valid_path = "context/plans/missing.md"; + let mut cases: Vec<(Vec<&str>, &str)> = Vec::new(); + for repo in [ + "owner", + "owner/", + "/name", + "a/b/c", + "owner/..", + "owner/.", + "own_er/name", + "owner/na me", + ] { + cases.push((vec!["--repo", repo, "--head", PR_HEAD, valid_path], repo)); + } + let short = &PR_HEAD[..39]; + let long = format!("{PR_HEAD}0"); + let upper = PR_HEAD.to_uppercase(); + let non_hex = format!("{}g", &PR_HEAD[..39]); + for head in [short, long.as_str(), upper.as_str(), non_hex.as_str()] { + cases.push((vec!["--repo", PR_REPO, "--head", head, valid_path], head)); + } + let absolute = format!("{}/context/plans/plan.md", root.display()); + for path in [ + absolute.as_str(), + "docs/plan.md", + "context/plans/nested/plan.md", + "context/plans/plan", + "context/plans/../plan.md", + "context/plans/..md", + "context/plans/.md", + "context/plans/pl@n.md", + ] { + cases.push((vec!["--repo", PR_REPO, "--head", PR_HEAD, path], path)); + } + cases.push(( + vec!["--repo", PR_REPO, "--head", PR_HEAD, valid_path, valid_path], + valid_path, + )); + + for (arguments, rejected) in cases { + let mut args = vec![NAME, "pr-description"]; + args.extend(arguments); + let run = run_plan_args(&args, &root); + + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{args:?}: {}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{args:?}: {}", run.stdout); + assert!( + run.stderr.contains("SCE-ERR-VALIDATION"), + "{args:?}: {}", + run.stderr + ); + assert!( + run.stderr.contains(&format!("'{rejected}'")), + "{args:?}: {}", + run.stderr + ); + } + + let accepted = run_plan_args( + &[ + NAME, + "pr-description", + "--repo", + "a-1/x.y_z-1", + "--head", + PR_HEAD, + "context/plans/a.b_c-1.md", + ], + &root, + ); + assert_eq!( + accepted.exit_code, + std::process::ExitCode::from(4), + "{}", + accepted.stderr + ); +} + +#[test] +fn pr_description_accepts_four_questions_across_plans() { + let (_directory, root) = temp_repository(); + write_repository_plan( + &root, + "context/plans/beta.md", + &review_plan_source("beta", [Some("One?"), Some("Two?"), None]), + ); + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Three?"), None, Some("Four?")]), + ); + for path in ["context/plans/beta.md", "context/plans/alpha.md"] { + let check = run_plan_check(root.join(path).to_str().expect("path should be UTF-8")); + assert_eq!( + check.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + check.stderr + ); + } + + let run = run_pr_description(&root, &["context/plans/beta.md", "context/plans/alpha.md"]); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert!(run.stderr.is_empty(), "{}", run.stderr); + assert_eq!( + run.stdout.matches("**Review scope:**").count(), + 1, + "{}", + run.stdout + ); + assert!( + run.stdout + .contains("**Review scope:** 4 architectural decisions require senior review."), + "{}", + run.stdout + ); + let numbered: Vec<&str> = run + .stdout + .lines() + .filter(|line| line.starts_with(|first: char| first.is_ascii_digit())) + .map(|line| line.split(" — ").next().unwrap_or(line)) + .collect(); + assert_eq!( + numbered, + vec!["1. Three?", "2. Four?", "3. One?", "4. Two?"], + "{}", + run.stdout + ); +} + +#[test] +fn pr_description_rejects_invalid_plan() { + let (_directory, root) = temp_repository(); + let path = "context/plans/duplicate-id.md"; + let source = std::fs::read_to_string(fixture_dir().join("duplicate-id.md")) + .expect("fixture should be readable"); + write_repository_plan(&root, path, &source); + + let run = run_pr_description(&root, &[path]); + let absolute = root.join(path).display().to_string(); + let check = run_plan_check(&absolute); + + assert_eq!( + run.exit_code, + std::process::ExitCode::from(3), + "{}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{}", run.stdout); + let defects = |stderr: &str, label: &str| -> Vec { + stderr + .lines() + .filter_map(|line| line.strip_prefix(&format!("{label}:"))) + .map(str::to_string) + .collect() + }; + assert_eq!( + defects(&run.stderr, path), + vec!["29: `S1` is defined more than once".to_string()] + ); + assert_eq!( + defects(&run.stderr, path), + defects(&check.stderr, &absolute) + ); + + let missing = run_pr_description(&root, &["context/plans/absent.md"]); + assert_eq!( + missing.exit_code, + std::process::ExitCode::from(4), + "{}", + missing.stderr + ); + assert!(missing.stdout.is_empty(), "{}", missing.stdout); + assert!( + missing.stderr.contains("SCE-ERR-RUNTIME"), + "{}", + missing.stderr + ); +} + +#[test] +fn plan_pr_description_appears_in_help() { + let root_help = strip_ansi(&crate::services::help::help_text()); + assert!( + root_help.contains("sce plan pr-description --repo --head ..."), + "{root_help}" + ); + let plan_help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME]).expect("plan help should be renderable"), + ); + assert!(plan_help.contains("pr-description"), "{plan_help}"); + let help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME, "pr-description"]) + .expect("plan pr-description help should be renderable"), + ); + assert!(help.contains("--repo "), "{help}"); + assert!(help.contains("--head "), "{help}"); + assert!(help.contains("..."), "{help}"); +} + +#[test] +fn completion_scripts_include_plan_pr_description() { + use crate::services::completion::{render_completion, CompletionRequest, CompletionShell}; + + for shell in [ + CompletionShell::Bash, + CompletionShell::Zsh, + CompletionShell::Fish, + ] { + let script = render_completion(CompletionRequest { shell }); + assert!(script.contains("pr-description"), "{shell:?}"); + } +} + +#[cfg(test)] +fn decision_brief_plan_source(name: &str, decisions: &[(&str, Option<&str>)]) -> String { + use std::fmt::Write; + + let mut source = format!("# Plan: {name}\n\n## Design decisions\n\n"); + for (index, (statement, question)) in decisions.iter().enumerate() { + if statement.is_empty() { + writeln!(source, "- D{}:", index + 1) + } else { + writeln!(source, "- D{}: {statement}", index + 1) + } + .expect("writing to String should not fail"); + source.push_str( + " - Boundary: One module.\n - Rationale: It is simple.\n - Rejected: Two modules — they split ownership.\n - Trade-offs: None.\n", + ); + if let Some(question) = question { + write!( + source, + " - Review: required\n - Review question: {question}\n" + ) + .expect("writing to String should not fail"); + } + } + source.push_str( + "\n## Acceptance criteria\n\n- [ ] AC1: It works.\n - Validate: `true`\n - Scenarios: none (inspection)\n\n## Task stack\n\n- [ ] T01: `Do it` (status:todo)\n - Task ID: T01\n - Covers: AC1\n", + ); + source +} + +#[cfg(test)] +fn render_decision_brief( + paths: &[&str], + plans: &[pr_description::PlanInput], +) -> Result { + pr_description::render_format( + &pr_arguments(paths), + plans, + pr_description::BriefFormat::DecisionBrief, + ) +} + +#[test] +fn decision_brief_golden_two_decisions() { + let plan = pr_plan_input( + "context/plans/brief.md", + &decision_brief_plan_source( + "brief", + &[ + ("Keep one store.", Some("Is one store enough?")), + ("Log every read.", None), + ( + "Read plans from the\n head commit only.", + Some("Should reads ignore the working tree?"), + ), + ], + ), + ); + + let output = render_decision_brief(&["context/plans/brief.md"], &[plan]) + .expect("decision brief should render"); + + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let expected = format!( + "\n\ +## Decisions requiring review (2)\n\ +\n\ +### 1. brief · D1\n\ +\n\ +**Is one store enough?**\n\ +\n\ +**Proposed:** Keep one store.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/brief.md#L5)\n\ +\n\ +### 2. brief · D3\n\ +\n\ +**Should reads ignore the working tree?**\n\ +\n\ +**Proposed:** Read plans from the head commit only.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/brief.md#L17)\n\ +\n\ +
\n\ +Source plans (1)\n\ +\n\ +- [brief]({blob}/context/plans/brief.md)\n\ +\n\ +
\n\ +\n\ +\n" + ); + assert_eq!(output, expected); + assert!(!output.contains("\n\n\n"), "{output:?}"); + assert!(!output.ends_with("\n\n"), "{output:?}"); +} + +#[test] +fn decision_brief_golden_one_decision() { + let plan = pr_plan_input( + "context/plans/example.md", + &decision_brief_plan_source( + "example", + &[( + "Use one shared store for all sessions.", + Some("Is this architectural choice acceptable?"), + )], + ), + ); + + let output = render_decision_brief(&["context/plans/example.md"], &[plan]) + .expect("decision brief should render"); + + let plan_url = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567/context/plans/example.md"; + let expected = format!( + "\n\ +## Decisions requiring review (1)\n\ +\n\ +### 1. example · D1\n\ +\n\ +**Is this architectural choice acceptable?**\n\ +\n\ +**Proposed:** Use one shared store for all sessions.\n\ +\n\ +[Rationale, alternatives and trade-offs]({plan_url}#L5)\n\ +\n\ +
\n\ +Source plans (1)\n\ +\n\ +- [example]({plan_url})\n\ +\n\ +
\n\ +\n\ +\n" + ); + assert_eq!(output, expected); + assert!(!output.contains("\n\n\n"), "{output:?}"); + assert!(!output.ends_with("\n\n"), "{output:?}"); +} + +#[test] +fn decision_brief_stdout_ends_with_one_newline() { + let plan = pr_plan_input( + "context/plans/example.md", + &decision_brief_plan_source( + "example", + &[( + "Use one shared store for all sessions.", + Some("Is this architectural choice acceptable?"), + )], + ), + ); + let brief = render_decision_brief(&["context/plans/example.md"], &[plan]) + .expect("decision brief should render"); + + let run = render_plan_result( + Ok(pr_description::decision_brief_stdout_payload(brief.clone())), + Vec::new(), + ); + + assert_eq!(run.exit_code, std::process::ExitCode::SUCCESS); + assert_eq!(run.stdout, brief); + assert!(run.stdout.ends_with('\n'), "{:?}", run.stdout); + assert!(!run.stdout.ends_with("\n\n"), "{:?}", run.stdout); +} + +#[test] +fn decision_brief_no_marked_decisions() { + let plan = pr_plan_input( + "context/plans/quiet.md", + &decision_brief_plan_source("quiet", &[("Keep it.", None), ("Log it.", None)]), + ); + + let output = render_decision_brief(&["context/plans/quiet.md"], &[plan]) + .expect("decision brief should render"); + + assert_eq!( + output, + "\n## Decisions requiring review (0)\n\nNo decisions explicitly flagged for review.\n\n
\nSource plans (1)\n\n- [quiet](https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567/context/plans/quiet.md)\n\n
\n\n\n" + ); + assert!(!output.contains("###"), "{output}"); + let lowered = output.to_lowercase(); + for claim in ["approv", "safe", "verified"] { + assert!(!lowered.contains(claim), "{output}"); + } +} + +#[test] +fn decision_brief_escapes_markdown() { + let statement = r"Read `--head` at `#`; set `author_checked` and `acceptance_criterion`. Keep *y* _z_ [a] |c| ~d & e! unmatched ` tick d:\e now"; + let question = r"Is `--head` with `#` and `C:\tmp\_x` ok for *q* _here_ [x] |z| a:\b?"; + + let plan = pr_plan_input( + "context/plans/escape_plan.md", + &decision_brief_plan_source("escape_plan", &[(statement, Some(question))]), + ); + let output = render_decision_brief(&["context/plans/escape_plan.md"], &[plan]) + .expect("decision brief should render"); + + let plan_url = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567/context/plans/escape_plan.md"; + let expected = format!( + "\n\ +## Decisions requiring review (1)\n\ +\n\ +### 1. escape\\_plan · D1\n\ +\n\ +**Is `--head` with `#` and `C:\\tmp\\_x` ok for \\*q\\* \\_here\\_ \\[x\\] \\ \\|z\\| a:\\\\b?**\n\ +\n\ +**Proposed:** Read `--head` at `#`; set `author_checked` and `acceptance_criterion`. Keep \\*y\\* \\_z\\_ \\[a\\] \\ \\|c\\| \\~d \\& e\\! unmatched \\` tick d:\\\\e now\n\ +\n\ +[Rationale, alternatives and trade-offs]({plan_url}#L5)\n\ +\n\ +
\n\ +Source plans (1)\n\ +\n\ +- [escape\\_plan]({plan_url})\n\ +\n\ +
\n\ +\n\ +\n" + ); + assert_eq!(output, expected); + + assert_eq!( + pr_description::escape_authored_markdown(r"a\`b `c`"), + r"a\\\`b `c`" + ); + assert_eq!( + pr_description::escape_authored_markdown("```\nx\n```"), + r"\`\`\` +x +\`\`\`" + ); +} + +#[test] +fn decision_brief_orders_plans_deterministically() { + let zeta = || { + pr_plan_input( + "context/plans/zeta.md", + &decision_brief_plan_source("zeta", &[("Z one.", Some("Is Z one right?"))]), + ) + }; + let alpha = || { + pr_plan_input( + "context/plans/alpha.md", + &decision_brief_plan_source( + "alpha", + &[ + ("A one.", None), + ("A two.", Some("Is A two right?")), + ("A three.", Some("Is A three right?")), + ], + ), + ) + }; + let reverse_paths = ["context/plans/zeta.md", "context/plans/alpha.md"]; + let sorted_paths = ["context/plans/alpha.md", "context/plans/zeta.md"]; + + let first = render_decision_brief(&reverse_paths, &[zeta(), alpha()]) + .expect("decision brief should render"); + let second = render_decision_brief(&reverse_paths, &[zeta(), alpha()]) + .expect("decision brief should render"); + let sorted = render_decision_brief(&sorted_paths, &[alpha(), zeta()]) + .expect("decision brief should render"); + + assert_eq!(first, second); + assert_eq!(first, sorted); + assert!( + first.contains("\n## Decisions requiring review (3)\n\n> 3 architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused.\n\n### 1. "), + "{first}" + ); + assert_eq!( + first.matches("architectural decisions require").count(), + 1, + "{first}" + ); + + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let ordered = [ + "### 1. alpha · D2\n\n**Is A two right?**", + "[Rationale, alternatives and trade-offs]", + "alpha.md#L10)", + "### 2. alpha · D3\n\n**Is A three right?**", + "[Rationale, alternatives and trade-offs]", + "alpha.md#L17)", + "### 3. zeta · D1\n\n**Is Z one right?**", + "[Rationale, alternatives and trade-offs]", + "zeta.md#L5)", + "Source plans (2)", + &format!( + "- [alpha]({blob}/context/plans/alpha.md)\n- [zeta]({blob}/context/plans/zeta.md)\n" + ), + ]; + assert_contains_in_order("sorted plans", &first, &ordered); +} + +#[cfg(test)] +fn assert_contains_in_order(case: &str, output: &str, needles: &[impl AsRef]) { + let mut cursor = 0; + for needle in needles { + let needle = needle.as_ref(); + let found = output[cursor..] + .find(needle) + .unwrap_or_else(|| panic!("{case}: missing {needle:?} after byte {cursor}: {output}")); + cursor += found + needle.len(); + } +} + +#[test] +fn decision_brief_rejects_empty_statement() { + let source = decision_brief_plan_source( + "empty", + &[ + ("Keep one store.", Some("Is one store enough?")), + ("", Some("Is the blank decision right?")), + ], + ); + assert!(source.contains("\n- D2:\n"), "{source}"); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let plan_path = directory.path().join("empty.md"); + std::fs::write(&plan_path, &source).expect("plan should be written"); + let check = run_plan_check(plan_path.to_str().expect("path should be UTF-8")); + assert_eq!( + check.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + check.stderr + ); + + let plan = pr_plan_input("context/plans/empty.md", &source); + let paths = ["context/plans/empty.md"]; + + let error = render_decision_brief(&paths, std::slice::from_ref(&plan)) + .expect_err("empty statement should fail the decision brief"); + assert_eq!(error.class().exit_code(), 3); + let message = error.to_string(); + assert!(message.contains("'empty'"), "{message}"); + assert!(message.contains("D2"), "{message}"); + assert!(!message.contains("D1"), "{message}"); + + let intent_review = pr_description::render(&pr_arguments(&paths), &[plan]) + .expect("intent review should still render"); + assert!( + intent_review.contains("2. Is the blank decision right? — [empty D2]("), + "{intent_review}" + ); +} + +#[cfg(test)] +fn scope_warning(count: usize) -> String { + format!( + "**Review scope:** {count} architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused." + ) +} + +#[cfg(test)] +fn scope_advisory(count: usize) -> String { + format!( + "> {count} architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused." + ) +} + +#[cfg(test)] +fn scope_warning_plans() -> Vec { + vec![ + pr_plan_input( + "context/plans/gamma.md", + &decision_brief_plan_source("gamma", &[("G one.", Some("Is G one right?"))]), + ), + pr_plan_input( + "context/plans/alpha.md", + &decision_brief_plan_source( + "alpha", + &[ + ("A one.", Some("Is A one right?")), + ("A two.", None), + ("A three.", Some("Is A three right?")), + ], + ), + ), + pr_plan_input( + "context/plans/beta.md", + &decision_brief_plan_source( + "beta", + &[("B one.", None), ("B two.", Some("Is B two right?"))], + ), + ), + ] +} + +#[test] +fn decision_brief_scope_warning_spans_all_plans() { + let paths = [ + "context/plans/gamma.md", + "context/plans/alpha.md", + "context/plans/beta.md", + ]; + let mut reversed_paths = paths; + reversed_paths.reverse(); + let mut reversed_plans = scope_warning_plans(); + reversed_plans.reverse(); + + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let plans_line = format!( + "**Plans:** [alpha]({blob}/context/plans/alpha.md), [beta]({blob}/context/plans/beta.md), [gamma]({blob}/context/plans/gamma.md)" + ); + let warning = scope_warning(4); + + let brief = render_decision_brief(&paths, &scope_warning_plans()) + .expect("decision brief should render"); + let advisory = scope_advisory(4); + let expected_brief = format!( + "\n\ +## Decisions requiring review (4)\n\ +\n\ +{advisory}\n\ +\n\ +### 1. alpha · D1\n\ +\n\ +**Is A one right?**\n\ +\n\ +**Proposed:** A one.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/alpha.md#L5)\n\ +\n\ +### 2. alpha · D3\n\ +\n\ +**Is A three right?**\n\ +\n\ +**Proposed:** A three.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/alpha.md#L17)\n\ +\n\ +### 3. beta · D2\n\ +\n\ +**Is B two right?**\n\ +\n\ +**Proposed:** B two.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/beta.md#L10)\n\ +\n\ +### 4. gamma · D1\n\ +\n\ +**Is G one right?**\n\ +\n\ +**Proposed:** G one.\n\ +\n\ +[Rationale, alternatives and trade-offs]({blob}/context/plans/gamma.md#L5)\n\ +\n\ +
\n\ +Source plans (3)\n\ +\n\ +- [alpha]({blob}/context/plans/alpha.md)\n\ +- [beta]({blob}/context/plans/beta.md)\n\ +- [gamma]({blob}/context/plans/gamma.md)\n\ +\n\ +
\n\ +\n\ +\n" + ); + assert_eq!(brief, expected_brief); + assert_eq!( + render_decision_brief(&reversed_paths, &reversed_plans) + .expect("decision brief should render"), + brief + ); + + let intent_review = pr_description::render(&pr_arguments(&paths), &scope_warning_plans()) + .expect("intent review should render"); + let expected_intent_review = format!( + "\n\ +## Intent review\n\ +\n\ +{plans_line}\n\ +\n\ +{warning}\n\ +\n\ +**Decisions requiring review**\n\ +\n\ +1. Is A one right? — [alpha D1]({blob}/context/plans/alpha.md#L5)\n\ +\n\ +2. Is A three right? — [alpha D3]({blob}/context/plans/alpha.md#L17)\n\ +\n\ +3. Is B two right? — [beta D2]({blob}/context/plans/beta.md#L10)\n\ +\n\ +4. Is G one right? — [gamma D1]({blob}/context/plans/gamma.md#L5)\n\ +\n\ +" + ); + assert_eq!(intent_review, expected_intent_review); + assert_eq!( + pr_description::render(&pr_arguments(&reversed_paths), &reversed_plans) + .expect("intent review should render"), + intent_review + ); +} + +#[cfg(test)] +fn scope_threshold_plans( + names: &[&str], + count: usize, +) -> ( + Vec, + Vec<(String, String, String)>, +) { + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let mut plans = Vec::new(); + let mut marked = Vec::new(); + for (plan_index, name) in names.iter().enumerate() { + let questions: Vec = (1..=2) + .map(|decision| format!("Is {name} D{decision} right?")) + .collect(); + let decisions: Vec<(&str, Option<&str>)> = questions + .iter() + .enumerate() + .map(|(index, question)| { + ( + "Keep it.", + (plan_index * 2 + index < count).then_some(question.as_str()), + ) + }) + .collect(); + let source = decision_brief_plan_source(name, &decisions); + for (index, (_, question)) in decisions.iter().enumerate() { + if let Some(question) = question { + let id = format!("D{}", index + 1); + let line = source_line(&source, &format!("- {id}:")); + marked.push(( + question.to_string(), + format!("{name} {id}"), + format!("{blob}/context/plans/{name}.md#L{line}"), + )); + } + } + plans.push(pr_plan_input(&format!("context/plans/{name}.md"), &source)); + } + (plans, marked) +} + +#[test] +fn decision_brief_scope_warning_threshold_keeps_every_decision() { + use pr_description::BriefFormat; + + let blob = "https://github.com/crocoder-dev/shared-context-engineering/blob/0123456789abcdef0123456789abcdef01234567"; + let names = ["alpha", "bravo", "charlie", "delta"]; + let paths: Vec = names + .iter() + .map(|name| format!("context/plans/{name}.md")) + .collect(); + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut reversed_paths = paths.clone(); + reversed_paths.reverse(); + let plan_links: Vec = names + .iter() + .map(|name| format!("[{name}]({blob}/context/plans/{name}.md)")) + .collect(); + let plans_line = format!("**Plans:** {}", plan_links.join(", ")); + let items: Vec = plan_links.iter().map(|link| format!("- {link}")).collect(); + let source_index = format!( + "\n
\nSource plans ({})\n\n{}\n\n
\n", + names.len(), + items.join("\n") + ); + + for count in 0..=names.len() * 2 { + let (plans, marked) = scope_threshold_plans(&names, count); + assert_eq!(marked.len(), count); + let mut reversed_plans = plans.clone(); + reversed_plans.reverse(); + + for format in [BriefFormat::IntentReview, BriefFormat::DecisionBrief] { + let case = format!("{format:?} with {count} marked decisions"); + let output = pr_description::render_format(&pr_arguments(&paths), &plans, format) + .unwrap_or_else(|error| panic!("{case}: {error}")); + let reversed = pr_description::render_format( + &pr_arguments(&reversed_paths), + &reversed_plans, + format, + ) + .unwrap_or_else(|error| panic!("{case}: {error}")); + assert_eq!(output, reversed, "{case}"); + + let mut needles = match format { + BriefFormat::IntentReview => vec![plans_line.clone()], + BriefFormat::DecisionBrief => { + vec![format!("\n## Decisions requiring review ({count})")] + } + }; + if count >= 3 { + assert_eq!( + output.matches("architectural decisions require").count(), + 1, + "{case}: {output}" + ); + let warning = match format { + BriefFormat::IntentReview => scope_warning(count), + BriefFormat::DecisionBrief => scope_advisory(count), + }; + needles.push(format!("\n\n{warning}\n")); + } else { + assert!( + !output.contains("architectural decisions require"), + "{case}: {output}" + ); + } + for (number, (question, label, url)) in marked.iter().enumerate() { + let number = number + 1; + assert_eq!( + output.matches(question.as_str()).count(), + 1, + "{case}: {output}" + ); + match format { + BriefFormat::IntentReview => { + needles.push(format!("\n{number}. {question} — [{label}]({url})\n")); + } + BriefFormat::DecisionBrief => { + let heading = label.replacen(' ', " · ", 1); + needles.push(format!("\n### {number}. {heading}\n\n**{question}**\n")); + needles.push(format!( + "\n[Rationale, alternatives and trade-offs]({url})\n" + )); + } + } + } + if format == BriefFormat::DecisionBrief { + needles.push(source_index.clone()); + } + assert_contains_in_order(&case, &output, &needles); + + let numbered = output + .lines() + .filter(|line| match format { + BriefFormat::IntentReview => { + line.starts_with(|first: char| first.is_ascii_digit()) + } + BriefFormat::DecisionBrief => line.starts_with("### "), + }) + .count(); + assert_eq!(numbered, count, "{case}: {output}"); + } + } +} + +#[cfg(test)] +fn git_output(root: &std::path::Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args([ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce-test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + ]) + .args(args) + .current_dir(root) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("git output should be UTF-8") + .trim_end() + .to_string() +} + +#[cfg(test)] +fn commit_repository(root: &std::path::Path) -> String { + git_output(root, &["add", "-A"]); + git_output(root, &["commit", "--quiet", "--no-verify", "-m", "plans"]); + git_output(root, &["rev-parse", "HEAD"]) +} + +#[cfg(test)] +fn run_review(root: &std::path::Path, head: &str, paths: &[&str]) -> CommandRun { + let mut args = vec![NAME, "review", "--repo", PR_REPO, "--head", head]; + args.extend_from_slice(paths); + run_plan_args(&args, root) +} + +#[cfg(test)] +fn assert_failure(run: &CommandRun, exit_code: u8, case: &str) { + assert_eq!( + run.exit_code, + std::process::ExitCode::from(exit_code), + "{case}: {}", + run.stderr + ); + assert!(run.stdout.is_empty(), "{case}: {}", run.stdout); +} + +#[test] +fn review_reads_committed_plan() { + let (_directory, root) = temp_repository(); + let path = "context/plans/committed.md"; + let source = decision_brief_plan_source( + "committed", + &[("Keep one store.", Some("Is one store enough?"))], + ); + write_repository_plan(&root, path, &source); + let head = commit_repository(&root); + let url = format!("https://github.com/{PR_REPO}/blob/{head}/{path}"); + let expected = format!( + "\n## Decisions requiring review (1)\n\n### 1. committed · D1\n\n**Is one store enough?**\n\n**Proposed:** Keep one store.\n\n[Rationale, alternatives and trade-offs]({url}#L5)\n\n
\nSource plans (1)\n\n- [committed]({url})\n\n
\n\n\n" + ); + + let before = run_review(&root, &head, &[path]); + assert_eq!( + before.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + before.stderr + ); + assert_eq!(before.stdout, expected); + assert!(before.stdout.ends_with('\n'), "{:?}", before.stdout); + assert!(!before.stdout.ends_with("\n\n"), "{:?}", before.stdout); + + let edited = source + .replacen( + "## Design decisions\n", + "Inserted.\n\nMore.\n\n## Design decisions\n", + 1, + ) + .replace("Is one store enough?", "Is the edited question shown?"); + write_repository_plan(&root, path, &edited); + + let after = run_review(&root, &head, &[path]); + assert_eq!( + after.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + after.stderr + ); + assert_eq!(after.stdout, before.stdout); + assert!( + !after.stdout.contains("edited question"), + "{}", + after.stdout + ); +} + +#[test] +fn review_renders_every_marked_decision() { + let (_directory, root) = temp_repository(); + write_repository_plan( + &root, + "context/plans/beta.md", + &review_plan_source("beta", [Some("One?"), Some("Two?"), None]), + ); + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Three?"), None, None]), + ); + let three = commit_repository(&root); + + let accepted = run_review( + &root, + &three, + &["context/plans/beta.md", "context/plans/alpha.md"], + ); + assert_eq!( + accepted.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + accepted.stderr + ); + assert!( + accepted + .stdout + .contains("## Decisions requiring review (3)\n"), + "{}", + accepted.stdout + ); + let headings: Vec<&str> = accepted + .stdout + .lines() + .filter(|line| line.starts_with("### ")) + .collect(); + assert_eq!( + headings, + vec!["### 1. alpha · D1", "### 2. beta · D1", "### 3. beta · D2"] + ); + + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Three?"), None, Some("Four?")]), + ); + let four = commit_repository(&root); + + let accepted = run_review( + &root, + &four, + &["context/plans/beta.md", "context/plans/alpha.md"], + ); + assert_eq!( + accepted.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + accepted.stderr + ); + assert!(accepted.stderr.is_empty(), "{}", accepted.stderr); + assert_eq!( + accepted + .stdout + .matches("architectural decisions require") + .count(), + 1, + "{}", + accepted.stdout + ); + assert!( + accepted.stdout.contains(&scope_advisory(4)), + "{}", + accepted.stdout + ); + let headings: Vec<&str> = accepted + .stdout + .lines() + .filter(|line| line.starts_with("### ")) + .collect(); + assert_eq!( + headings, + vec![ + "### 1. alpha · D1", + "### 2. alpha · D3", + "### 3. beta · D1", + "### 4. beta · D2" + ] + ); +} + +#[test] +fn review_claims_ignore_review_scope() { + let (_directory, root) = temp_repository(); + write_repository_plan( + &root, + "context/plans/beta.md", + &review_plan_source("beta", [Some("One?"), Some("Two?"), None]), + ); + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Three?"), None, Some("Four?")]), + ); + let head = commit_repository(&root); + + let claims = run_review_claims( + &root, + &head, + &["context/plans/beta.md", "context/plans/alpha.md"], + ); + assert_eq!( + claims.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + claims.stderr + ); + assert!( + !claims.stdout.contains("architectural decisions require"), + "{}", + claims.stdout + ); + let document: serde_json::Value = + serde_json::from_str(&claims.stdout).expect("claims should be JSON"); + assert_eq!( + document["claims"].as_array().map(Vec::len), + Some(2), + "{}", + claims.stdout + ); +} + +#[test] +fn review_rejects_invalid_git_inputs() { + use std::io::Write; + + let (_directory, root) = temp_repository(); + let valid = "context/plans/valid.md"; + write_repository_plan( + &root, + valid, + &decision_brief_plan_source("valid", &[("Keep one store.", Some("Is it enough?"))]), + ); + let invalid = "context/plans/invalid.md"; + write_repository_plan( + &root, + invalid, + &std::fs::read_to_string(fixture_dir().join("duplicate-id.md")) + .expect("fixture should be readable"), + ); + let linked = "context/plans/linked.md"; + std::os::unix::fs::symlink("valid.md", root.join(linked)).expect("symlink should be created"); + let binary = "context/plans/binary.md"; + let mut bytes = b"# Plan: binary\n\n".to_vec(); + bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); + std::fs::File::create(root.join(binary)) + .and_then(|mut file| file.write_all(&bytes)) + .expect("binary plan should be written"); + let head = commit_repository(&root); + let unknown = "0123456789abcdef0123456789abcdef01234567"; + assert_ne!(head, unknown); + + let run_with = |repo: &str, head: &str, paths: &[&str]| { + let mut args = vec![NAME, "review", "--repo", repo, "--head", head]; + args.extend_from_slice(paths); + run_plan_args(&args, &root) + }; + + let cases: [(&str, CommandRun, u8); 10] = [ + ("invalid repo", run_with("not-a-repo", &head, &[valid]), 3), + ("short head", run_with(PR_REPO, &head[..12], &[valid]), 3), + ( + "uppercase head", + run_with(PR_REPO, &head.to_uppercase(), &[valid]), + 3, + ), + ("unknown commit", run_with(PR_REPO, unknown, &[valid]), 4), + ( + "absent path", + run_with(PR_REPO, &head, &["context/plans/absent.md"]), + 4, + ), + ("outside plans", run_with(PR_REPO, &head, &["README.md"]), 3), + ( + "duplicate path", + run_with(PR_REPO, &head, &[valid, valid]), + 3, + ), + ("invalid plan", run_with(PR_REPO, &head, &[invalid]), 3), + ("symlink", run_with(PR_REPO, &head, &[linked]), 4), + ("non-UTF-8", run_with(PR_REPO, &head, &[binary]), 4), + ]; + for (case, run, exit_code) in &cases { + assert_failure(run, *exit_code, case); + } + + let [_, _, _, (_, unknown_run, _), (_, absent_run, _), _, _, (_, invalid_run, _), (_, symlink_run, _), (_, binary_run, _)] = + &cases; + assert!( + unknown_run + .stderr + .contains("is not available in the local repository"), + "{}", + unknown_run.stderr + ); + assert!( + absent_run.stderr.contains("does not exist at commit"), + "{}", + absent_run.stderr + ); + assert!( + invalid_run + .stderr + .contains(&format!("{invalid}:29: `S1` is defined more than once")), + "{}", + invalid_run.stderr + ); + assert!( + symlink_run + .stderr + .contains("is not a regular file (mode 120000"), + "{}", + symlink_run.stderr + ); + assert!( + binary_run.stderr.contains("is not valid UTF-8"), + "{}", + binary_run.stderr + ); +} + +#[cfg(test)] +fn run_cli_args(args: &[&str], start: &std::path::Path) -> CommandRun { + let mut stderr = Vec::new(); + let result = crate::services::parse::command_runtime::parse_runtime_command( + std::iter::once("sce") + .chain(args.iter().copied()) + .map(String::from), + &crate::services::command_registry::CommandRegistry::default(), + None, + ) + .and_then(|command| match command { + crate::services::command_registry::RuntimeCommand::HelpText(command) => Ok(command.text), + crate::services::command_registry::RuntimeCommand::Plan(command) => { + command.execute_with(|| Ok(start.to_path_buf()), &mut stderr) + } + _ => panic!("expected help text or plan command"), + }); + render_plan_result(result, stderr) +} + +#[test] +fn review_removed_name_shows_plan_help() { + let (_directory, root) = temp_repository(); + write_repository_plan( + &root, + "context/plans/beta.md", + &review_plan_source("beta", [Some("One?"), Some("Two?"), None]), + ); + write_repository_plan( + &root, + "context/plans/alpha.md", + &review_plan_source("alpha", [Some("Three?"), None, Some("Four?")]), + ); + let head = commit_repository(&root); + let paths = ["context/plans/beta.md", "context/plans/alpha.md"]; + + let plan_help = run_cli_args(&[NAME, "--help"], &root); + assert_eq!(plan_help.exit_code, std::process::ExitCode::SUCCESS); + assert!(plan_help.stderr.is_empty(), "{}", plan_help.stderr); + assert!(plan_help.stdout.contains("review"), "{}", plan_help.stdout); + + let mut removed_args = vec![NAME, "decision-brief", "--repo", PR_REPO, "--head", &head]; + removed_args.extend_from_slice(&paths); + let removed = run_cli_args(&removed_args, &root); + assert_eq!(removed.exit_code, std::process::ExitCode::SUCCESS); + assert_eq!(removed.stdout, plan_help.stdout); + assert!(removed.stderr.is_empty(), "{}", removed.stderr); + assert!( + !removed.stdout.contains("## Decisions requiring review"), + "{}", + removed.stdout + ); + + let mut review_args = vec![NAME, "review", "--repo", PR_REPO, "--head", &head]; + review_args.extend_from_slice(&paths); + let review = run_cli_args(&review_args, &root); + assert_eq!( + review.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + review.stderr + ); + assert!( + review.stdout.contains(&scope_advisory(4)), + "{}", + review.stdout + ); +} + +#[test] +fn review_appears_in_help_and_completions() { + use crate::services::completion::{render_completion, CompletionRequest, CompletionShell}; + + let root_help = strip_ansi(&crate::services::help::help_text()); + assert!( + root_help.contains("sce plan review [--claims] --repo --head ..."), + "{root_help}" + ); + assert!(!root_help.contains("decision-brief"), "{root_help}"); + + let plan_help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME]).expect("plan help should be renderable"), + ); + assert!( + plan_help + .lines() + .any(|line| line.trim_start().starts_with("review ")), + "{plan_help}" + ); + assert!(!plan_help.contains("decision-brief"), "{plan_help}"); + + let review_help = strip_ansi( + &crate::cli_schema::render_help_for_path(&[NAME, "review"]) + .expect("review help should be renderable"), + ); + assert!(review_help.contains("--repo "), "{review_help}"); + assert!(review_help.contains("--head "), "{review_help}"); + assert!(review_help.contains("..."), "{review_help}"); + assert!(review_help.contains("--claims"), "{review_help}"); + assert!(!review_help.contains("decision-brief"), "{review_help}"); + + for (shell, needle, claims_needle) in [ + ( + CompletionShell::Bash, + "check init pr-description review", + "opts=\"-h --claims --repo --head --help\"", + ), + ( + CompletionShell::Zsh, + "'review:Print a Markdown Decision Brief", + "'--claims[Print the sce.plan.claims.v1 JSON claims manifest", + ), + ( + CompletionShell::Fish, + "-f -a \"review\"", + "__fish_seen_subcommand_from review\" -l claims", + ), + ] { + let script = render_completion(CompletionRequest { shell }); + assert!(script.contains(needle), "{shell:?}"); + assert!(script.contains(claims_needle), "{shell:?}"); + assert!(!script.contains("decision-brief"), "{shell:?}"); + assert!(script.contains("pr-description"), "{shell:?}"); + } +} + +#[cfg(test)] +fn run_review_claims(root: &std::path::Path, head: &str, paths: &[&str]) -> CommandRun { + let mut args = vec![ + NAME, "review", "--claims", "--repo", PR_REPO, "--head", head, + ]; + args.extend_from_slice(paths); + run_plan_args(&args, root) +} + +#[cfg(test)] +fn source_line(source: &str, prefix: &str) -> u64 { + let index = source + .lines() + .position(|line| line.starts_with(prefix)) + .expect("prefix should appear in the source"); + u64::try_from(index + 1).expect("line should fit in u64") +} + +#[test] +fn review_claims_reads_committed_plans() { + let (_directory, root) = temp_repository(); + let beta = "context/plans/beta.md"; + let alpha = "context/plans/alpha.md"; + let beta_source = decision_brief_plan_source("beta", &[("Keep one store.", None)]); + let alpha_source = decision_brief_plan_source( + "alpha", + &[ + ("Use one queue.", Some("Is one queue enough?")), + ("Retry twice.", None), + ], + ); + write_repository_plan(&root, beta, &beta_source); + write_repository_plan(&root, alpha, &alpha_source); + let head = commit_repository(&root); + + let forward = run_review_claims(&root, &head, &[beta, alpha]); + assert_eq!( + forward.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + forward.stderr + ); + let reverse = run_review_claims(&root, &head, &[alpha, beta]); + assert_eq!(reverse.stdout, forward.stdout); + + write_repository_plan( + &root, + alpha, + &alpha_source + .replacen( + "## Acceptance criteria\n", + "Inserted.\n\nMore.\n\n## Acceptance criteria\n", + 1, + ) + .replace("It works.", "It is edited."), + ); + let edited = run_review_claims(&root, &head, &[beta, alpha]); + assert_eq!( + edited.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + edited.stderr + ); + assert_eq!(edited.stdout, forward.stdout); + assert!(!edited.stdout.contains("edited"), "{}", edited.stdout); + + let document: serde_json::Value = + serde_json::from_str(&forward.stdout).expect("stdout should be valid JSON"); + assert_eq!(document["repository"]["head_sha"], head.as_str()); + let plan_paths: Vec<&str> = document["plans"] + .as_array() + .expect("plans should be an array") + .iter() + .map(|plan| plan["path"].as_str().expect("path should be a string")) + .collect(); + assert_eq!(plan_paths, vec![alpha, beta]); + assert_eq!( + claim_ids(&document, "claims"), + vec![format!("{alpha}#AC1"), format!("{beta}#AC1")] + ); + + let alpha_claim = &document["claims"][0]; + let alpha_line = source_line(&alpha_source, "- [ ] AC1:"); + assert_eq!(alpha_claim["source"]["line"], alpha_line); + assert_eq!( + alpha_claim["source"]["url"], + format!("https://github.com/{PR_REPO}/blob/{head}/{alpha}#L{alpha_line}") + ); + assert_eq!(alpha_claim["statement"], "It works."); + assert_eq!( + alpha_claim["tasks"][0]["source"]["line"], + source_line(&alpha_source, "- [ ] T01:") + ); + assert_eq!( + document["claims"][1]["source"]["line"], + source_line(&beta_source, "- [ ] AC1:") + ); +} + +#[test] +fn review_claims_output_is_pure() { + let (_directory, root) = temp_repository(); + let path = "context/plans/pure.md"; + write_repository_plan( + &root, + path, + &decision_brief_plan_source("pure", &[("Keep one store.", Some("Is it enough?"))]), + ); + let head = commit_repository(&root); + let status_before = git_output(&root, &["status", "--porcelain", "--untracked-files=all"]); + let tree_before = tree_listing(&root); + + let run = run_review_claims(&root, &head, &[path]); + + assert_eq!( + run.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + run.stderr + ); + assert!(run.stderr.is_empty(), "{}", run.stderr); + assert!(run.stdout.ends_with("}\n"), "{:?}", run.stdout); + assert!(!run.stdout.ends_with("\n\n"), "{:?}", run.stdout); + let mut values = + serde_json::Deserializer::from_str(&run.stdout).into_iter::(); + let document = values + .next() + .expect("stdout should hold a JSON value") + .expect("stdout should be valid JSON"); + assert!(values.next().is_none(), "{}", run.stdout); + assert_eq!(document["schema_version"], "sce.plan.claims.v1"); + assert!( + run.stdout.starts_with("{\n \"schema_version\""), + "{}", + run.stdout + ); + assert_eq!( + git_output(&root, &["status", "--porcelain", "--untracked-files=all"]), + status_before + ); + assert_eq!(tree_listing(&root), tree_before); +} + +#[cfg(test)] +const CLAIMS_DEFECT_PLANS: [&str; 6] = [ + "context/plans/valid.md", + "context/plans/invalid.md", + "context/plans/repeated.md", + "context/plans/linked.md", + "context/plans/binary.md", + "context/plans/corrupt.md", +]; + +#[cfg(test)] +fn commit_claims_defect_plans(root: &std::path::Path) -> (String, String) { + use std::io::Write; + + let [valid, invalid, repeated, linked, binary, corrupt] = CLAIMS_DEFECT_PLANS; + write_repository_plan( + root, + valid, + &decision_brief_plan_source("valid", &[("Keep one store.", Some("Is it enough?"))]), + ); + write_repository_plan( + root, + invalid, + &std::fs::read_to_string(fixture_dir().join("duplicate-id.md")) + .expect("fixture should be readable"), + ); + let repeated_source = repeated_field_source( + &[], + &[ + "- [ ] AC1: First.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "- [ ] AC2: Second.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + ], + &[" - Covers: AC1", " - Covers: AC2"], + ); + write_repository_plan(root, repeated, &repeated_source); + std::os::unix::fs::symlink("valid.md", root.join(linked)).expect("symlink should be created"); + let mut bytes = b"# Plan: binary\n\n".to_vec(); + bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); + std::fs::File::create(root.join(binary)) + .and_then(|mut file| file.write_all(&bytes)) + .expect("binary plan should be written"); + write_repository_plan( + root, + corrupt, + &decision_brief_plan_source("corrupt", &[("Keep a corrupt store.", None)]), + ); + let head = commit_repository(root); + let object = git_output(root, &["rev-parse", &format!("{head}:{corrupt}")]); + std::fs::remove_file( + root.join(".git/objects") + .join(&object[..2]) + .join(&object[2..]), + ) + .expect("loose plan object should be removed"); + (head, repeated_source) +} + +#[test] +fn review_claims_rejects_invalid_inputs() { + let (_directory, root) = temp_repository(); + let (head, repeated_source) = commit_claims_defect_plans(&root); + let [valid, invalid, repeated, linked, binary, corrupt] = CLAIMS_DEFECT_PLANS; + let unknown = "0123456789abcdef0123456789abcdef01234567"; + assert_ne!(head, unknown); + let status_before = git_output(&root, &["status", "--porcelain", "--untracked-files=all"]); + + let run_with = |repo: &str, head: &str, paths: &[&str]| { + let mut args = vec![NAME, "review", "--claims", "--repo", repo, "--head", head]; + args.extend_from_slice(paths); + run_plan_args(&args, &root) + }; + + let second_covers = source_line(&repeated_source, " - Covers: AC2"); + let repeated_stderr = format!( + "Error [SCE-ERR-VALIDATION]: Plan claims check failed for '{repeated}':\n{repeated}:{second_covers}: `Covers:` appears more than once on `T01`\nTry: merge each repeated field into one line, then rerun 'sce plan review --claims'.\n" + ); + let invalid_needle = format!("{invalid}:29: `S1` is defined more than once"); + let cases: [(&str, CommandRun, u8, &str); 12] = [ + ( + "invalid repo", + run_with("not-a-repo", &head, &[valid]), + 3, + "not-a-repo", + ), + ( + "short head", + run_with(PR_REPO, &head[..12], &[valid]), + 3, + &head[..12], + ), + ( + "uppercase head", + run_with(PR_REPO, &head.to_uppercase(), &[valid]), + 3, + "--head", + ), + ( + "outside plans", + run_with(PR_REPO, &head, &["README.md"]), + 3, + "README.md", + ), + ( + "duplicate path", + run_with(PR_REPO, &head, &[valid, valid]), + 3, + valid, + ), + ( + "invalid plan", + run_with(PR_REPO, &head, &[valid, invalid]), + 3, + &invalid_needle, + ), + ( + "repeated covers", + run_with(PR_REPO, &head, &[valid, repeated]), + 3, + &repeated_stderr, + ), + ( + "unknown commit", + run_with(PR_REPO, unknown, &[valid]), + 4, + "is not available in the local repository", + ), + ( + "absent path", + run_with(PR_REPO, &head, &[valid, "context/plans/absent.md"]), + 4, + "does not exist at commit", + ), + ( + "symlink", + run_with(PR_REPO, &head, &[linked]), + 4, + "is not a regular file (mode 120000", + ), + ( + "non-UTF-8", + run_with(PR_REPO, &head, &[binary]), + 4, + "is not valid UTF-8", + ), + ( + "failed git", + run_with(PR_REPO, &head, &[valid, corrupt]), + 4, + "Failed to read plan file", + ), + ]; + for (case, run, exit_code, needle) in &cases { + assert_failure(run, *exit_code, case); + assert!(run.stderr.contains(needle), "{case}: {}", run.stderr); + } + assert_eq!(cases[6].1.stderr, repeated_stderr); + assert_eq!( + git_output(&root, &["status", "--porcelain", "--untracked-files=all"]), + status_before + ); + + assert_repeated_plan_passes_check_and_brief(&root, &head, repeated); +} + +#[cfg(test)] +fn assert_repeated_plan_passes_check_and_brief(root: &std::path::Path, head: &str, repeated: &str) { + let check = run_plan_check(root.join(repeated).to_str().expect("path should be UTF-8")); + assert_eq!( + check.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + check.stderr + ); + let brief = run_review(root, head, &[repeated]); + assert_eq!( + brief.exit_code, + std::process::ExitCode::SUCCESS, + "{}", + brief.stderr + ); +} + +#[cfg(test)] +const CLAIMS_GOLDEN_REPO: &str = "crocoder-dev/example"; + +#[cfg(test)] +const CLAIMS_GOLDEN_HEAD: &str = "4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234"; + +#[cfg(test)] +const CLAIMS_GOLDEN_PLAN: &str = r#"# Plan: token-refresh + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: Is one store read per refresh acceptable? + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +## Task stack + +- [x] T01: `Add /auth/refresh endpoint` (status:done) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: synced + +## Open questions + +None. +"#; + +#[cfg(test)] +const CLAIMS_GOLDEN_JSON: &str = r#"{ + "schema_version": "sce.plan.claims.v1", + "repository": { + "slug": "crocoder-dev/example", + "head_sha": "4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234" + }, + "plans": [ + { + "path": "context/plans/token-refresh.md", + "url": "https://github.com/crocoder-dev/example/blob/4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234/context/plans/token-refresh.md" + } + ], + "claims": [ + { + "id": "context/plans/token-refresh.md#AC1", + "kind": "acceptance_criterion", + "statement": "`POST /auth/refresh` rejects a revoked token with 401.", + "modality": "intended", + "author_checked": false, + "source": { + "path": "context/plans/token-refresh.md", + "entry_id": "AC1", + "line": 19, + "url": "https://github.com/crocoder-dev/example/blob/4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234/context/plans/token-refresh.md#L19" + }, + "decisions": [ + { + "id": "D1", + "statement": "The session store validates refresh tokens.", + "boundary": "`src/auth/` owns token validation.", + "rationale": "The store already tracks revocation.", + "rejected": "Stateless tokens — they cannot be revoked.", + "trade_offs": "One store read per refresh.", + "review": { + "required": true, + "question": "Is one store read per refresh acceptable?" + }, + "source": { + "path": "context/plans/token-refresh.md", + "entry_id": "D1", + "line": 9, + "url": "https://github.com/crocoder-dev/example/blob/4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234/context/plans/token-refresh.md#L9" + } + } + ], + "verification": { + "mode": "scenarios", + "validate": "`pnpm test src/auth/refresh.test.ts`", + "scenarios": [ + { + "id": "S1", + "statement": "Revoked refresh token is rejected", + "given": "A revoked refresh token.", + "when": "The client calls `POST /auth/refresh`.", + "then": "The response is 401.", + "check": "`pnpm test src/auth/refresh.test.ts -t \"rejects a revoked token\"`", + "source": { + "path": "context/plans/token-refresh.md", + "entry_id": "S1", + "line": 26, + "url": "https://github.com/crocoder-dev/example/blob/4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234/context/plans/token-refresh.md#L26" + } + } + ] + }, + "tasks": [ + { + "id": "T01", + "statement": "`Add /auth/refresh endpoint` (status:done)", + "author_checked": true, + "source": { + "path": "context/plans/token-refresh.md", + "entry_id": "T01", + "line": 34, + "url": "https://github.com/crocoder-dev/example/blob/4f2a9c1e8b7d6053a1c2e3f4a5b6c7d8e9f01234/context/plans/token-refresh.md#L34" + } + } + ] + } + ] +}"#; + +#[cfg(test)] +const CLAIMS_CONTRACT_KEYS: [&str; 33] = [ + "schema_version", + "repository", + "plans", + "claims", + "slug", + "head_sha", + "path", + "url", + "id", + "kind", + "statement", + "modality", + "author_checked", + "source", + "decisions", + "verification", + "tasks", + "entry_id", + "line", + "boundary", + "rationale", + "rejected", + "trade_offs", + "review", + "required", + "question", + "mode", + "validate", + "scenarios", + "given", + "when", + "then", + "check", +]; + +#[cfg(test)] +fn claims_plan_source(lines: &[&str]) -> String { + let mut source = lines.join("\n"); + source.push('\n'); + source +} + +#[cfg(test)] +fn render_claims( + plans: &[pr_description::PlanInput], +) -> Result { + let paths: Vec<&str> = plans.iter().map(|plan| plan.path.as_str()).collect(); + claims::render(&pr_arguments(&paths), plans) +} + +#[cfg(test)] +fn claims_document(plans: &[pr_description::PlanInput]) -> serde_json::Value { + let rendered = render_claims(plans).expect("claims should render"); + serde_json::from_str(&rendered).expect("claims should be valid JSON") +} + +#[cfg(test)] +fn claim_ids<'a>(values: &'a serde_json::Value, key: &str) -> Vec<&'a str> { + values[key] + .as_array() + .expect("value should be an array") + .iter() + .map(|value| value["id"].as_str().expect("id should be a string")) + .collect() +} + +#[cfg(test)] +fn collect_keys<'a>(value: &'a serde_json::Value, keys: &mut Vec<&'a str>) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + keys.push(key); + collect_keys(child, keys); + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_keys(item, keys); + } + } + _ => {} + } +} + +#[test] +fn claims_golden_one_of_each() { + let path = "context/plans/token-refresh.md"; + let plan = pr_plan_input(path, CLAIMS_GOLDEN_PLAN); + let arguments = pr_description::PrDescriptionArguments { + repo: CLAIMS_GOLDEN_REPO.to_string(), + head: CLAIMS_GOLDEN_HEAD.to_string(), + paths: vec![path.to_string()], + }; + + let rendered = claims::render(&arguments, &[plan]).expect("claims should render"); + + assert_eq!(rendered, CLAIMS_GOLDEN_JSON); +} + +#[test] +fn claims_preserve_reference_order() { + let source = claims_plan_source(&[ + "# Plan: ordering", + "", + "## Design decisions", + "", + "- D1: First decision.", + " - Boundary: Boundary one.", + " - Rationale: Rationale one.", + " - Rejected: Rejected one.", + " - Trade-offs: Trade-offs one.", + "- D2: Second decision.", + " - Boundary: Boundary two.", + " - Rationale: Rationale two.", + " - Rejected: Rejected two.", + " - Trade-offs: Trade-offs two.", + "", + "## Acceptance criteria", + "", + "- [ ] AC1: Ordered references.", + " - Validate: `true`", + " - Decisions: D2, D1", + " - Scenarios: S2, S1", + "- [ ] AC2: Other criterion.", + " - Validate: `false`", + " - Scenarios: none (inspection)", + "", + "### Verification scenarios", + "", + "- S1: First scenario", + " - Given: Given one.", + " - When: When one.", + " - Then: Then one.", + " - Check: Check one.", + "- S2: Second scenario", + " - Given: Given two.", + " - When: When two.", + " - Then: Then two.", + " - Check: Check two.", + "", + "## Task stack", + "", + "- [ ] T01: `First` (status:todo)", + " - Task ID: T01", + " - Covers: AC1", + "- [ ] T02: `Second` (status:todo)", + " - Task ID: T02", + " - Covers: AC2, AC1", + "- [ ] T03: `Third` (status:todo)", + " - Task ID: T03", + " - Covers: AC2", + ]); + let document = claims_document(&[pr_plan_input("context/plans/ordering.md", &source)]); + let first = &document["claims"][0]; + let second = &document["claims"][1]; + + assert_eq!(claim_ids(first, "decisions"), vec!["D2", "D1"]); + assert_eq!( + claim_ids(&first["verification"], "scenarios"), + vec!["S2", "S1"] + ); + assert_eq!(claim_ids(first, "tasks"), vec!["T01", "T02"]); + assert_eq!(claim_ids(second, "tasks"), vec!["T02", "T03"]); + + let decisions = &first["decisions"]; + assert_eq!(decisions[0]["statement"], "Second decision."); + assert_eq!(decisions[0]["boundary"], "Boundary two."); + assert_eq!(decisions[0]["rationale"], "Rationale two."); + assert_eq!(decisions[0]["rejected"], "Rejected two."); + assert_eq!(decisions[0]["trade_offs"], "Trade-offs two."); + assert_eq!(decisions[0]["source"]["line"], 10); + assert_eq!(decisions[1]["statement"], "First decision."); + assert_eq!(decisions[1]["boundary"], "Boundary one."); + assert_eq!(decisions[1]["source"]["line"], 5); + + let scenarios = &first["verification"]["scenarios"]; + assert_eq!(scenarios[0]["statement"], "Second scenario"); + assert_eq!(scenarios[0]["given"], "Given two."); + assert_eq!(scenarios[0]["when"], "When two."); + assert_eq!(scenarios[0]["then"], "Then two."); + assert_eq!(scenarios[0]["check"], "Check two."); + assert_eq!(scenarios[0]["source"]["line"], 33); + assert_eq!(scenarios[1]["statement"], "First scenario"); + assert_eq!(scenarios[1]["check"], "Check one."); + assert_eq!(scenarios[1]["source"]["line"], 28); + + let tasks = &first["tasks"]; + assert_eq!(tasks[0]["source"]["line"], 41); + assert_eq!(tasks[1]["source"]["line"], 44); + assert_eq!(second["source"]["line"], 22); + assert_eq!(second["id"], "context/plans/ordering.md#AC2"); +} + +#[test] +fn claims_inspection_only_criterion() { + let source = review_plan_source("inspection", [None, None, None]); + let document = claims_document(&[pr_plan_input("context/plans/inspection.md", &source)]); + + assert_eq!( + document["claims"][0]["verification"], + serde_json::json!({ + "mode": "inspection", + "validate": "`true`", + "scenarios": [] + }) + ); +} + +#[test] +fn claims_unmarked_and_absent_decisions() { + let source = claims_plan_source(&[ + "# Plan: unmarked", + "", + "## Design decisions", + "", + "- D1: Unmarked decision.", + " - Boundary: One module.", + " - Rationale: It is simple.", + " - Rejected: Two modules.", + " - Trade-offs: None.", + "- D2: Marked decision.", + " - Boundary: One module.", + " - Rationale: It is simple.", + " - Rejected: Two modules.", + " - Trade-offs: None.", + " - Review: required", + " - Review question: Is one module enough?", + "", + "## Acceptance criteria", + "", + "- [ ] AC1: References an unmarked decision.", + " - Validate: `true`", + " - Decisions: D1", + " - Scenarios: none (inspection)", + "- [ ] AC2: References no decision.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "- [ ] AC3: References a marked decision.", + " - Validate: `true`", + " - Decisions: D2", + " - Scenarios: none (inspection)", + "", + "## Task stack", + "", + "- [ ] T01: `Do it` (status:todo)", + " - Task ID: T01", + " - Covers: AC1, AC2, AC3", + ]); + let document = claims_document(&[pr_plan_input("context/plans/unmarked.md", &source)]); + let claims = &document["claims"]; + + assert_eq!( + claims[0]["decisions"][0]["review"], + serde_json::json!({"required": false, "question": null}) + ); + assert_eq!(claims[1]["decisions"], serde_json::json!([])); + assert_eq!( + claims[2]["decisions"][0]["review"], + serde_json::json!({"required": true, "question": "Is one module enough?"}) + ); +} + +#[test] +fn claims_checked_entries_stay_intended() { + let source = claims_plan_source(&[ + "# Plan: checked", + "", + "## Acceptance criteria", + "", + "- [x] AC1: Checked criterion keeps *stars* and snake_case.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "- [ ] AC2: Unchecked criterion.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "- AC3: Criterion without a checkbox.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "", + "## Task stack", + "", + "- [x] T01: `Done task` (status:done)", + " - Task ID: T01", + " - Covers: AC1, AC2, AC3", + "- [ ] T02: `Open task` (status:todo)", + " - Task ID: T02", + " - Covers: AC1", + "- T03: `Plain task`", + " - Task ID: T03", + " - Covers: AC1", + ]); + let document = claims_document(&[pr_plan_input("context/plans/checked.md", &source)]); + let claims = document["claims"] + .as_array() + .expect("claims should be an array"); + + let checked: Vec<&serde_json::Value> = claims + .iter() + .map(|claim| &claim["author_checked"]) + .collect(); + assert_eq!( + checked, + vec![ + &serde_json::json!(true), + &serde_json::json!(false), + &serde_json::Value::Null + ] + ); + let task_checked: Vec<&serde_json::Value> = claims[0]["tasks"] + .as_array() + .expect("tasks should be an array") + .iter() + .map(|task| &task["author_checked"]) + .collect(); + assert_eq!( + task_checked, + vec![ + &serde_json::json!(true), + &serde_json::json!(false), + &serde_json::Value::Null + ] + ); + assert_eq!( + claims[0]["statement"], + "Checked criterion keeps *stars* and snake_case." + ); + assert_eq!( + claims[0]["tasks"][0]["statement"], + "`Done task` (status:done)" + ); + for claim in claims { + assert_eq!(claim["kind"], "acceptance_criterion"); + assert_eq!(claim["modality"], "intended"); + } + + let mut keys = Vec::new(); + collect_keys(&document, &mut keys); + for key in keys { + assert!( + CLAIMS_CONTRACT_KEYS.contains(&key), + "unexpected key `{key}`" + ); + } +} + +#[test] +fn claims_include_every_criterion() { + use std::fmt::Write; + + let mut source = decision_brief_plan_source( + "unlimited", + &[ + ("First.", Some("Question one?")), + ("Second.", Some("Question two?")), + ("", Some("Question three?")), + ("Fourth.", Some("Question four?")), + ], + ); + let criteria_start = source + .find("\n## Acceptance criteria") + .expect("source should hold acceptance criteria"); + source.truncate(criteria_start); + source.push_str("\n## Acceptance criteria\n\n"); + for index in 1..=5 { + write!( + source, + "- [ ] AC{index}: Criterion {index}.\n - Validate: `true`\n - Decisions: D1, D2, D3, D4\n - Scenarios: none (inspection)\n" + ) + .expect("writing to String should not fail"); + } + source.push_str( + "\n## Task stack\n\n- [ ] T01: `Do it` (status:todo)\n - Task ID: T01\n - Covers: AC1, AC2, AC3, AC4, AC5\n", + ); + let unlimited = pr_plan_input("context/plans/unlimited.md", &source); + let document = claims_document(std::slice::from_ref(&unlimited)); + let ids: Vec<&str> = document["claims"] + .as_array() + .expect("claims should be an array") + .iter() + .map(|claim| claim["id"].as_str().expect("id should be a string")) + .collect(); + assert_eq!( + ids, + vec![ + "context/plans/unlimited.md#AC1", + "context/plans/unlimited.md#AC2", + "context/plans/unlimited.md#AC3", + "context/plans/unlimited.md#AC4", + "context/plans/unlimited.md#AC5", + ] + ); + assert_eq!(document["claims"][0]["decisions"][2]["statement"], ""); + assert_eq!( + claim_ids(&document["claims"][4], "decisions"), + vec!["D1", "D2", "D3", "D4"] + ); + + let empty = claims_plan_source(&[ + "# Plan: empty", + "", + "## Acceptance criteria", + "", + "None.", + "", + "## Task stack", + "", + "None.", + ]); + let empty = pr_plan_input("context/plans/empty.md", &empty); + let document = claims_document(std::slice::from_ref(&empty)); + assert_eq!(document["claims"], serde_json::json!([])); + assert_eq!(document["plans"][0]["path"], "context/plans/empty.md"); + + let both = claims_document(&[unlimited, empty]); + assert_eq!( + both["plans"] + .as_array() + .expect("plans should be an array") + .iter() + .map(|plan| plan["path"].as_str().expect("path should be a string")) + .collect::>(), + vec!["context/plans/empty.md", "context/plans/unlimited.md"] + ); + assert_eq!(both["claims"].as_array().map(Vec::len), Some(5)); +} + +#[cfg(test)] +fn repeated_field_source(decision_extra: &[&str], criteria: &[&str], covers: &[&str]) -> String { + let mut lines = vec![ + "# Plan: repeated", + "", + "## Design decisions", + "", + "- D1: One decision.", + " - Boundary: One module.", + " - Rationale: It is simple.", + " - Rejected: Two modules.", + " - Trade-offs: None.", + ]; + lines.extend(decision_extra); + lines.extend(["", "## Acceptance criteria", ""]); + lines.extend(criteria); + lines.extend([ + "", + "## Task stack", + "", + "- [ ] T01: Implement the feature", + " - Task ID: T01", + ]); + lines.extend(covers); + claims_plan_source(&lines) +} + +#[test] +fn claims_reject_repeated_consumed_fields() { + let covers = repeated_field_source( + &[], + &[ + "- [ ] AC1: First.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + "- [ ] AC2: Second.", + " - Validate: `true`", + " - Scenarios: none (inspection)", + ], + &[" - Covers: AC1", " - Covers: AC2"], + ); + let scenarios = repeated_field_source( + &[], + &[ + "- [ ] AC1: First.", + " - Validate: `true`", + " - Scenarios: S1", + " - Scenarios: S2", + "", + "### Verification scenarios", + "", + "- S1: One", + " - Given: G.", + " - When: W.", + " - Then: T.", + " - Check: C.", + "- S2: Two", + " - Given: G.", + " - When: W.", + " - Then: T.", + " - Check: C.", + ], + &[" - Covers: AC1"], + ); + let decisions = repeated_field_source( + &[], + &[ + "- [ ] AC1: First.", + " - Validate: `true`", + " - Decisions: D1", + " - Decisions: D1", + " - Scenarios: none (inspection)", + ], + &[" - Covers: AC1"], + ); + let rationale = repeated_field_source( + &[" - Rationale: It is also cheap."], + &[ + "- [ ] AC1: First.", + " - Validate: `true`", + " - Decisions: D1", + " - Scenarios: none (inspection)", + ], + &[" - Covers: AC1"], + ); + + for (source, expected) in [ + ( + &covers, + "context/plans/repeated.md:25: `Covers:` appears more than once on `T01`", + ), + ( + &scenarios, + "context/plans/repeated.md:16: `Scenarios:` appears more than once on `AC1`", + ), + ( + &decisions, + "context/plans/repeated.md:16: `Decisions:` appears more than once on `AC1`", + ), + ( + &rationale, + "context/plans/repeated.md:10: `Rationale:` appears more than once on `D1`", + ), + ] { + let plan = pr_plan_input("context/plans/repeated.md", source); + let error = render_claims(std::slice::from_ref(&plan)) + .expect_err("repeated consumed field should fail claims"); + + assert_eq!(error.class().exit_code(), 3); + assert_eq!( + error.to_string(), + format!( + "Plan claims check failed for 'context/plans/repeated.md':\n{expected}\nTry: merge each repeated field into one line, then rerun 'sce plan review --claims'." + ) + ); + } +} diff --git a/cli/src/services/plan/pr_description.rs b/cli/src/services/plan/pr_description.rs new file mode 100644 index 000000000..70c833d49 --- /dev/null +++ b/cli/src/services/plan/pr_description.rs @@ -0,0 +1,364 @@ +use std::collections::BTreeSet; + +use markdown::mdast::Node; +use markdown::{to_mdast, ParseOptions}; + +use crate::services::error::CliError; +use crate::services::plan::extract::{Plan, PlanEntryKind}; + +pub const START_MARKER: &str = ""; +pub const END_MARKER: &str = ""; +pub const HEADING: &str = "## Intent review"; +pub const PLANS_LABEL: &str = "**Plans:**"; +pub const DECISIONS_HEADING: &str = "**Decisions requiring review**"; +pub const NO_FLAGGED_DECISIONS: &str = "No decisions explicitly flagged for review."; +pub const DECISION_BRIEF_HEADING: &str = "## Decisions requiring review"; +pub const PROPOSED_LABEL: &str = "**Proposed:**"; +pub const REVIEW_LINK_LABEL: &str = "Rationale, alternatives and trade-offs"; +pub const SOURCE_PLANS_SUMMARY: &str = "Source plans"; +const DECISION_ID_SEPARATOR: &str = "·"; +pub const SHA_LENGTH: usize = 40; +pub const PLAN_PATH_PREFIX: &str = "context/plans/"; +pub const PLAN_PATH_SUFFIX: &str = ".md"; +const GITHUB_URL: &str = "https://github.com"; +const REVIEW_KEY: &str = "Review"; +const REVIEW_REQUIRED: &str = "required"; +const REVIEW_QUESTION_KEY: &str = "Review question"; +const REVIEW_SCOPE_LABEL: &str = "**Review scope:**"; +const REVIEW_SCOPE_WARNING_THRESHOLD: usize = 3; +const ESCAPED_CHARACTERS: [char; 12] = + ['\\', '`', '*', '_', '[', ']', '<', '>', '!', '|', '~', '&']; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrDescriptionArguments { + pub repo: String, + pub head: String, + pub paths: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BriefFormat { + IntentReview, + DecisionBrief, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanInput { + pub path: String, + pub plan: Plan, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ReviewQuestion<'a> { + path: &'a str, + decision_id: &'a str, + line: usize, + question: &'a str, + statement: &'a str, +} + +pub fn parse_arguments( + repo: &str, + head: &str, + paths: &[String], +) -> Result { + if !is_repository(repo) { + return Err(CliError::validation(format!( + "Invalid --repo value '{repo}'. Try: pass /, where OWNER uses [A-Za-z0-9-] and NAME uses [A-Za-z0-9._-] and is not '.' or '..'." + ))); + } + if !is_full_sha(head) { + return Err(CliError::validation(format!( + "Invalid --head value '{head}'. Try: pass the full {SHA_LENGTH}-character lowercase hexadecimal commit SHA, for example from 'git rev-parse HEAD'." + ))); + } + + let mut seen = BTreeSet::new(); + for path in paths { + if !is_plan_path(path) { + return Err(CliError::validation(format!( + "Invalid plan path '{path}'. Try: pass a path relative to the repository root as {PLAN_PATH_PREFIX}{PLAN_PATH_SUFFIX}, where FILE uses [A-Za-z0-9._-] and is not '.' or '..'." + ))); + } + if !seen.insert(path.as_str()) { + return Err(CliError::validation(format!( + "Plan path '{path}' is given more than once. Try: pass each plan path once." + ))); + } + } + + Ok(PrDescriptionArguments { + repo: repo.to_string(), + head: head.to_string(), + paths: paths.to_vec(), + }) +} + +fn is_repository(value: &str) -> bool { + let Some((owner, name)) = value.split_once('/') else { + return false; + }; + !owner.is_empty() + && owner + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && is_safe_segment(name) +} + +fn is_full_sha(value: &str) -> bool { + value.len() == SHA_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn is_plan_path(value: &str) -> bool { + value + .strip_prefix(PLAN_PATH_PREFIX) + .and_then(|file| file.strip_suffix(PLAN_PATH_SUFFIX)) + .is_some_and(is_safe_segment) +} + +fn is_safe_segment(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +#[cfg(test)] +pub fn render(arguments: &PrDescriptionArguments, plans: &[PlanInput]) -> Result { + render_format(arguments, plans, BriefFormat::IntentReview) +} + +pub fn render_format( + arguments: &PrDescriptionArguments, + plans: &[PlanInput], + format: BriefFormat, +) -> Result { + let mut sorted: Vec<&PlanInput> = plans.iter().collect(); + sorted.sort_by(|left, right| left.path.cmp(&right.path)); + + let questions: Vec = sorted + .iter() + .flat_map(|input| review_questions(input)) + .collect(); + let plan_links: Vec = sorted + .iter() + .map(|input| { + format!( + "[{}]({})", + escape_markdown(plan_label(&input.path)), + blob_url(arguments, &input.path) + ) + }) + .collect(); + + match format { + BriefFormat::IntentReview => Ok(render_intent_review(arguments, &plan_links, &questions)), + BriefFormat::DecisionBrief => render_decision_brief(arguments, &plan_links, &questions), + } +} + +fn render_intent_review( + arguments: &PrDescriptionArguments, + plan_links: &[String], + questions: &[ReviewQuestion], +) -> String { + let mut lines = vec![ + START_MARKER.to_string(), + HEADING.to_string(), + String::new(), + format!("{PLANS_LABEL} {}", plan_links.join(", ")), + String::new(), + ]; + if let Some(warning) = review_scope_warning(questions.len()) { + lines.push(warning); + lines.push(String::new()); + } + if questions.is_empty() { + lines.push(NO_FLAGGED_DECISIONS.to_string()); + } else { + lines.push(DECISIONS_HEADING.to_string()); + for (index, question) in questions.iter().enumerate() { + lines.push(String::new()); + lines.push(format!( + "{}. {} — [{} {}]({}#L{})", + index + 1, + escape_authored_markdown(question.question), + escape_markdown(plan_label(question.path)), + question.decision_id, + blob_url(arguments, question.path), + question.line + )); + } + } + lines.push(String::new()); + lines.push(END_MARKER.to_string()); + + lines.join("\n") +} + +fn render_decision_brief( + arguments: &PrDescriptionArguments, + plan_links: &[String], + questions: &[ReviewQuestion], +) -> Result { + if let Some(question) = questions + .iter() + .find(|question| question.statement.is_empty()) + { + return Err(CliError::validation(format!( + "Decision {} in plan '{}' is marked 'Review: required' but has no proposed decision statement. Try: write the proposed decision after '- {}:' in {}.", + question.decision_id, + plan_label(question.path), + question.decision_id, + question.path + ))); + } + + let mut blocks = vec![format!("{DECISION_BRIEF_HEADING} ({})", questions.len())]; + blocks.extend(review_scope_advisory(questions.len())); + if questions.is_empty() { + blocks.push(NO_FLAGGED_DECISIONS.to_string()); + } + for (index, question) in questions.iter().enumerate() { + blocks.push(format!( + "### {}. {} {DECISION_ID_SEPARATOR} {}", + index + 1, + escape_markdown(plan_label(question.path)), + question.decision_id + )); + let folded_question = question.question.split_whitespace().collect::>(); + blocks.push(format!( + "**{}**", + escape_authored_markdown(&folded_question.join(" ")) + )); + blocks.push(format!( + "{PROPOSED_LABEL} {}", + escape_authored_markdown(question.statement) + )); + blocks.push(format!( + "[{REVIEW_LINK_LABEL}]({}#L{})", + blob_url(arguments, question.path), + question.line + )); + } + if !plan_links.is_empty() { + let items: Vec = plan_links.iter().map(|link| format!("- {link}")).collect(); + blocks.push(format!( + "
\n{SOURCE_PLANS_SUMMARY} ({})\n\n{}\n\n
", + plan_links.len(), + items.join("\n") + )); + } + Ok(format!( + "{START_MARKER}\n{}\n\n{END_MARKER}\n", + blocks.join("\n\n") + )) +} + +fn review_scope_warning(count: usize) -> Option { + review_scope_text(count).map(|text| format!("{REVIEW_SCOPE_LABEL} {text}")) +} + +fn review_scope_advisory(count: usize) -> Option { + review_scope_text(count).map(|text| format!("> {text}")) +} + +fn review_scope_text(count: usize) -> Option { + (count >= REVIEW_SCOPE_WARNING_THRESHOLD).then(|| { + format!( + "{count} architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused." + ) + }) +} + +pub fn decision_brief_stdout_payload(mut brief: String) -> String { + if brief.ends_with('\n') { + brief.pop(); + } + brief +} + +fn review_questions(input: &PlanInput) -> Vec> { + input + .plan + .entries_of(PlanEntryKind::Decision) + .filter_map(|decision| { + let marked = decision + .fields + .iter() + .any(|field| field.key == REVIEW_KEY && field.value == REVIEW_REQUIRED); + let question = decision + .fields + .iter() + .find(|field| field.key == REVIEW_QUESTION_KEY)?; + marked.then_some(ReviewQuestion { + path: &input.path, + decision_id: &decision.id, + line: decision.line, + question: &question.value, + statement: &decision.statement, + }) + }) + .collect() +} + +fn plan_label(path: &str) -> &str { + path.strip_prefix(PLAN_PATH_PREFIX) + .and_then(|file| file.strip_suffix(PLAN_PATH_SUFFIX)) + .unwrap_or(path) +} + +fn blob_url(arguments: &PrDescriptionArguments, path: &str) -> String { + format!( + "{GITHUB_URL}/{}/blob/{}/{path}", + arguments.repo, arguments.head + ) +} + +pub fn escape_markdown(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + if ESCAPED_CHARACTERS.contains(&character) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +pub fn escape_authored_markdown(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + let mut cursor = 0; + for (start, end) in inline_code_spans(text) { + escaped.push_str(&escape_markdown(&text[cursor..start])); + escaped.push_str(&text[start..end]); + cursor = end; + } + escaped.push_str(&escape_markdown(&text[cursor..])); + escaped +} + +fn inline_code_spans(text: &str) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + if let Ok(root) = to_mdast(text, &ParseOptions::gfm()) { + collect_inline_code_spans(&root, &mut spans); + } + spans +} + +fn collect_inline_code_spans(node: &Node, spans: &mut Vec<(usize, usize)>) { + if let Node::InlineCode(_) = node { + if let Some(position) = node.position() { + spans.push((position.start.offset, position.end.offset)); + } + return; + } + for child in node.children().into_iter().flatten() { + collect_inline_code_spans(child, spans); + } +} diff --git a/cli/src/services/plan/validate.rs b/cli/src/services/plan/validate.rs new file mode 100644 index 000000000..0b01244ad --- /dev/null +++ b/cli/src/services/plan/validate.rs @@ -0,0 +1,409 @@ +use crate::services::plan::extract::{ + is_id, Plan, PlanEntry, PlanEntryKind, PlanField, PlanHeading, +}; + +const INSPECTION_ONLY: &str = "none (inspection)"; +const VERIFICATION_SCENARIOS_HEADING: &str = "### Verification scenarios"; +const ACCEPTANCE_CRITERIA_HEADING: &str = "## Acceptance criteria"; +const REVIEW_KEY: &str = "Review"; +const REVIEW_QUESTION_KEY: &str = "Review question"; +const REVIEW_REQUIRED: &str = "required"; +const REQUIRED_PLAN_HEADINGS: [&str; 2] = [ACCEPTANCE_CRITERIA_HEADING, "## Task stack"]; +const ORDERED_PLAN_HEADINGS: [&str; 4] = [ + "## Design decisions", + ACCEPTANCE_CRITERIA_HEADING, + VERIFICATION_SCENARIOS_HEADING, + "## Task stack", +]; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct PlanDiagnostic { + pub line: Option, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanReport { + pub diagnostics: Vec, +} + +impl PlanEntryKind { + fn authoritative_section(self) -> &'static str { + match self { + Self::Decision => "## Design decisions", + Self::AcceptanceCriterion => ACCEPTANCE_CRITERIA_HEADING, + Self::Scenario => VERIFICATION_SCENARIOS_HEADING, + Self::Task => "## Task stack", + } + } + + fn display_name(self) -> &'static str { + match self { + Self::Decision => "a design decision", + Self::AcceptanceCriterion => "an acceptance criterion", + Self::Scenario => "a verification scenario", + Self::Task => "a task", + } + } + + fn required_fields(self) -> &'static [&'static str] { + match self { + Self::Decision => &["Boundary", "Rationale", "Rejected", "Trade-offs"], + Self::AcceptanceCriterion => &["Validate", "Scenarios"], + Self::Scenario => &["Given", "When", "Then", "Check"], + Self::Task => &["Covers"], + } + } +} + +fn field_owner(key: &str) -> Option { + match key { + "Boundary" | "Rationale" | "Rejected" | "Trade-offs" | REVIEW_KEY | REVIEW_QUESTION_KEY => { + Some(PlanEntryKind::Decision) + } + "Validate" | "Decisions" | "Scenarios" => Some(PlanEntryKind::AcceptanceCriterion), + "Given" | "When" | "Then" | "Check" => Some(PlanEntryKind::Scenario), + "Covers" => Some(PlanEntryKind::Task), + _ => None, + } +} + +fn reference_kind(key: &str) -> Option { + match key { + "Decisions" => Some(PlanEntryKind::Decision), + "Scenarios" => Some(PlanEntryKind::Scenario), + "Covers" => Some(PlanEntryKind::AcceptanceCriterion), + _ => None, + } +} + +fn diagnostic(line: Option, message: String) -> PlanDiagnostic { + PlanDiagnostic { line, message } +} + +fn field_of<'a>(entry: &'a PlanEntry, key: &str) -> Option<&'a PlanField> { + entry.fields.iter().find(|field| field.key == key) +} + +fn listed_ids(value: &str) -> Option> { + let ids: Vec<&str> = value.split(", ").collect(); + ids.iter().all(|id| is_id(id)).then_some(ids) +} + +fn heading_text(heading: &PlanHeading) -> String { + format!( + "{} {}", + "#".repeat(usize::from(heading.depth)), + heading.text + ) +} + +pub fn validate(plan: &Plan) -> PlanReport { + let headings: Vec<(String, usize)> = plan + .headings + .iter() + .map(|heading| (heading_text(heading), heading.line)) + .collect(); + let mut diagnostics = heading_diagnostics(&headings); + diagnostics.extend(definition_diagnostics(plan)); + diagnostics.extend(review_diagnostics(plan)); + diagnostics.extend(reference_diagnostics(plan)); + diagnostics.sort(); + + PlanReport { diagnostics } +} + +fn first_line(headings: &[(String, usize)], text: &str) -> Option { + headings + .iter() + .find(|(heading, _)| heading == text) + .map(|(_, line)| *line) +} + +fn first_index(headings: &[(String, usize)], text: &str) -> Option { + headings.iter().position(|(heading, _)| heading == text) +} + +fn heading_diagnostics(headings: &[(String, usize)]) -> Vec { + let mut diagnostics: Vec = REQUIRED_PLAN_HEADINGS + .iter() + .filter(|required| first_index(headings, required).is_none()) + .map(|required| diagnostic(None, format!("heading `{required}` is missing"))) + .collect(); + + for ordered in ORDERED_PLAN_HEADINGS { + if let Some((_, line)) = headings + .iter() + .filter(|(heading, _)| heading == ordered) + .nth(1) + { + diagnostics.push(diagnostic( + Some(*line), + format!("heading `{ordered}` appears more than once"), + )); + } + } + + let present: Vec<&str> = ORDERED_PLAN_HEADINGS + .into_iter() + .filter(|ordered| first_index(headings, ordered).is_some()) + .collect(); + for pair in present.windows(2) { + let (earlier, later) = (pair[0], pair[1]); + if first_index(headings, earlier) > first_index(headings, later) { + diagnostics.push(diagnostic( + first_line(headings, later), + format!("heading `{later}` must come after `{earlier}`"), + )); + } + } + + if let Some(index) = first_index(headings, VERIFICATION_SCENARIOS_HEADING) { + let parent = headings[..index] + .iter() + .rev() + .find(|(heading, _)| heading.starts_with("# ") || heading.starts_with("## ")); + if parent.is_none_or(|(heading, _)| heading != ACCEPTANCE_CRITERIA_HEADING) { + diagnostics.push(diagnostic( + Some(headings[index].1), + format!( + "heading `{VERIFICATION_SCENARIOS_HEADING}` is not inside `{ACCEPTANCE_CRITERIA_HEADING}`" + ), + )); + } + } + + diagnostics +} + +fn definition_diagnostics(plan: &Plan) -> Vec { + let mut diagnostics = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + let mut reported: Vec<&str> = Vec::new(); + + for entry in &plan.entries { + if seen.contains(&entry.id.as_str()) { + if !reported.contains(&entry.id.as_str()) { + reported.push(&entry.id); + diagnostics.push(diagnostic( + Some(entry.line), + format!("`{}` is defined more than once", entry.id), + )); + } + } else { + seen.push(&entry.id); + } + + for field in &entry.fields { + if let Some(owner) = field_owner(&field.key).filter(|owner| *owner != entry.kind) { + diagnostics.push(diagnostic( + Some(field.line), + format!( + "`{}:` on `{}` is misplaced; it belongs to {}", + field.key, + entry.id, + owner.display_name() + ), + )); + } + } + + for key in entry.kind.required_fields() { + if field_of(entry, key).is_none() { + diagnostics.push(diagnostic( + Some(entry.line), + format!("`{}` is missing `{key}:`", entry.id), + )); + } + } + } + + for outside in &plan.outside_definitions { + if let Some(section) = outside.section { + diagnostics.push(diagnostic( + Some(outside.line), + format!( + "`{}` is defined under `{}`; it belongs under `{}`", + outside.id, + section.authoritative_section(), + PlanEntryKind::of_id(&outside.id).authoritative_section() + ), + )); + } + } + + diagnostics +} + +fn review_diagnostics(plan: &Plan) -> Vec { + let mut diagnostics = Vec::new(); + + for entry in plan.entries_of(PlanEntryKind::Decision) { + let fields_of = |key: &str| -> Vec<&PlanField> { + entry + .fields + .iter() + .filter(|field| field.key == key) + .collect() + }; + let reviews = fields_of(REVIEW_KEY); + let questions = fields_of(REVIEW_QUESTION_KEY); + + for (key, fields) in [(REVIEW_KEY, &reviews), (REVIEW_QUESTION_KEY, &questions)] { + if let Some(repeated) = fields.get(1) { + diagnostics.push(diagnostic( + Some(repeated.line), + format!("`{key}:` appears more than once on `{}`", entry.id), + )); + } + } + + for (present_key, present, missing_key, missing) in [ + (REVIEW_KEY, &reviews, REVIEW_QUESTION_KEY, &questions), + (REVIEW_QUESTION_KEY, &questions, REVIEW_KEY, &reviews), + ] { + if !present.is_empty() && missing.is_empty() { + diagnostics.push(diagnostic( + Some(entry.line), + format!( + "`{}` has `{present_key}:` without `{missing_key}:`", + entry.id + ), + )); + } + } + + for review in &reviews { + if review.value != REVIEW_REQUIRED { + diagnostics.push(diagnostic( + Some(review.line), + format!( + "`{REVIEW_KEY}:` on `{}` must be `{REVIEW_REQUIRED}`", + entry.id + ), + )); + } + } + + for question in &questions { + if question.value.is_empty() { + diagnostics.push(diagnostic( + Some(question.line), + format!("`{REVIEW_QUESTION_KEY}:` on `{}` is empty", entry.id), + )); + } else if question.value.contains('\n') { + diagnostics.push(diagnostic( + Some(question.line), + format!( + "`{REVIEW_QUESTION_KEY}:` on `{}` must be a single line", + entry.id + ), + )); + } + } + } + + diagnostics +} + +fn undefined_hint(plan: &Plan, id: &str) -> String { + if plan + .outside_definitions + .iter() + .any(|outside| outside.id == id) + { + format!( + "; it appears only outside `{}`", + PlanEntryKind::of_id(id).authoritative_section() + ) + } else if plan.fenced_ids.iter().any(|other| other == id) { + "; it appears only inside a fenced code block".to_string() + } else { + String::new() + } +} + +fn reference_diagnostics(plan: &Plan) -> Vec { + let references: Vec<(&PlanEntry, &PlanField, PlanEntryKind)> = plan + .entries + .iter() + .flat_map(|entry| { + entry.fields.iter().filter_map(move |field| { + let kind = reference_kind(&field.key)?; + (field_owner(&field.key) == Some(entry.kind)).then_some((entry, field, kind)) + }) + }) + .collect(); + let listed_by = |key: &str| -> Vec<&str> { + references + .iter() + .filter(|(_, field, _)| field.key == key) + .filter_map(|(_, field, _)| listed_ids(&field.value)) + .flatten() + .collect() + }; + let covered_ids = listed_by("Covers"); + let scenario_ids = listed_by("Scenarios"); + let mut diagnostics = Vec::new(); + + for (owner, field, kind) in &references { + if field.key == "Scenarios" && field.value == INSPECTION_ONLY { + continue; + } + let Some(ids) = listed_ids(&field.value) else { + diagnostics.push(diagnostic( + Some(field.line), + format!( + "`{}:` on `{}` is not a comma-separated ID list", + field.key, owner.id + ), + )); + continue; + }; + for id in ids { + if PlanEntryKind::of_id(id) != *kind { + diagnostics.push(diagnostic( + Some(field.line), + format!( + "`{}:` on `{}` names `{id}`, which is not {}", + field.key, + owner.id, + kind.display_name() + ), + )); + } else if !plan.entries.iter().any(|entry| entry.id == id) { + diagnostics.push(diagnostic( + Some(field.line), + format!( + "`{}:` on `{}` references undefined `{id}`{}", + field.key, + owner.id, + undefined_hint(plan, id) + ), + )); + } + } + } + + for entry in plan.entries_of(PlanEntryKind::AcceptanceCriterion) { + if !covered_ids.contains(&entry.id.as_str()) { + diagnostics.push(diagnostic( + Some(entry.line), + format!("`{}` is not covered by any task's `Covers:`", entry.id), + )); + } + } + for entry in plan.entries_of(PlanEntryKind::Scenario) { + if !scenario_ids.contains(&entry.id.as_str()) { + diagnostics.push(diagnostic( + Some(entry.line), + format!( + "`{}` is not referenced by any acceptance criterion's `Scenarios:`", + entry.id + ), + )); + } + } + + diagnostics +} diff --git a/config/pkl/base/plan-init-scaffold.pkl b/config/pkl/base/plan-init-scaffold.pkl new file mode 100644 index 000000000..896e97180 --- /dev/null +++ b/config/pkl/base/plan-init-scaffold.pkl @@ -0,0 +1,65 @@ +import "workflow-change-to-plan.pkl" as changeToPlan + +planNameToken = "{plan-name}" + +text = """ +# Plan: \(planNameToken) + +## Change summary + +TODO: Describe what changes, where, and why in one or two paragraphs. + +## Design decisions + +TODO: Record each real architectural choice with Boundary, Rationale, Rejected, and Trade-offs, or delete this section when there are none. + +## Acceptance criteria + +\(changeToPlan.planAcceptanceCriteriaIntro) + +- [ ] AC1: TODO: State one observable outcome. + - Validate: TODO + - Scenarios: TODO + +### Verification scenarios + +TODO: Add the scenarios that acceptance criteria reference, each with Given, When, Then, and Check. + +### Full validation + +\(changeToPlan.planFullValidationIntro) + +- TODO: List the repository-wide check commands. + +### Context sync + +- TODO: List the durable context files that must describe the change. + +\(changeToPlan.planTaskContextSynchronizationLifecycle) + +## Constraints and non-goals + +- **In scope:** TODO +- **Out of scope:** TODO +- **Constraints:** TODO +- **Non-goal:** TODO + +## Assumptions + +TODO: Record assumptions and the conventions they rest on, or delete this section when there are none. + +## Task stack + +- [ ] T01: `TODO` (status:todo) + - Task ID: T01 + - Covers: TODO + - Scope: In — TODO. Out — TODO. + - Dependencies: none + - Done when: TODO + - Verify: TODO + - Context synchronization: pending + +## Open questions + +TODO: List non-blocking questions, or write `None.` with a short justification. +""" diff --git a/config/pkl/base/workflow-change-to-plan.pkl b/config/pkl/base/workflow-change-to-plan.pkl index 80483fa22..492b057b3 100644 --- a/config/pkl/base/workflow-change-to-plan.pkl +++ b/config/pkl/base/workflow-change-to-plan.pkl @@ -274,9 +274,13 @@ This phase owns the planning process: - Write or revise exactly one `context/plans/{plan_name}.md`. `references/plan-template.md` is the sole owner of the persisted plan schema. -Read its `Acceptance criteria rules`, `Task rules` and `No validation task` rules, -and `Updating an existing plan` rules before authoring or revising. Apply those -rules rather than restating them here. +Read its `Acceptance criteria rules`, `Design decision rules`, +`Verification scenario rules`, `Reference rules`, `Task rules` and +`No validation task` rules, and `Updating an existing plan` rules before +authoring or revising. Apply those rules rather than restating them here. + +Author plan content in this order: design decisions, then acceptance criteria, +then verification scenarios, then tasks. The workflow renders this phase's result as the summary defined in `references/output.md`. @@ -303,7 +307,9 @@ When multiple existing plans match and none can be selected safely, return `blocked` with the matching candidates. When the request targets a new plan, derive `plan_name` as a short kebab-case -slug of the change, and confirm it does not collide with an existing plan. +slug of the change, and confirm it does not collide with an existing plan. Do +not create the plan file here; step 2.10 initializes it after the clarification +gate. Resolve exactly one plan target per invocation. @@ -397,31 +403,109 @@ the repository does not yet have. Do not explore the entire repository by default. -## 2.5 Author the acceptance criteria +## 2.5 Record design decisions + +Record the architectural choices this plan commits to under `## Design decisions`, +applying the `Design decision rules` in `references/plan-template.md`. Do not +manufacture a decision to fill the section. When the change commits to no such +choice, omit the section. + +## 2.6 Author the acceptance criteria Derive the plan-specific success outcomes and checks before slicing tasks, then apply the `Acceptance criteria rules` and exact section shape in `references/plan-template.md`. The template owns their generic validation semantics and placement. -## 2.6 Author the task stack +Give each criterion its `Decisions:` and `Scenarios:` fields as the +`Reference rules` define them. + +## 2.7 Author the verification scenarios + +Write the scenarios the acceptance criteria depend on under +`### Verification scenarios`, applying the `Verification scenario rules` in +`references/plan-template.md`. Cover normal, boundary, failure, cancellation, +concurrency, and recovery cases only where the change makes them relevant. +Do not invent test names: a `Check:` names an existing test, a test a task in +the stack creates, a command, or a named inspection. -Slice and order the plan-specific work after the acceptance criteria, applying the -`Task rules` and `No validation task` rules in `references/plan-template.md`. +## 2.8 Author the task stack + +Slice and order the plan-specific work after the acceptance criteria and +verification scenarios, applying the `Task rules` and `No validation task` rules in `references/plan-template.md`. Do not restate those generic task rules here. +Give each task its `Covers:` field as the `Reference rules` define it. + A finished stack always leaves at least one incomplete task, so the workflow can always hand off to `/next-task`. When the request resolves to a plan but produces no incomplete task, because the change is already implemented or already covered by completed tasks, set internal status `blocked` with category `no_actionable_work` instead of writing the plan. -## 2.7 Write the plan +## 2.9 Check references + +Before writing the plan, run a reference-consistency pass over the +complete draft that applies every `Reference rules` entry in +`references/plan-template.md`. Fix each defect in the draft before writing. Do +not write a plan that fails this pass. + +This pass is pre-write authoring guidance only. It never substitutes for a +successful `sce plan check` in step 2.10. + +## 2.10 Write the plan + +Structured plans are the only supported format. This step has no fallback for +`sce plan init` or `sce plan check`. + +### New plan + +Run `sce plan init {plan_name}`. Initialization succeeds only when all three +hold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`, +and that file exists. Every other result returns `blocked` with the exit code +and stderr. This includes a missing `sce` executable, a binary without `init`, +a nonzero exit, an exit `0` without the expected line, and an expected line +with no created file. + +Fill the generated scaffold with the plan content, applying the authoring rules +in `references/plan-template.md`. The template is the authoring reference for +filling the scaffold, never a substitute for `sce plan init`. Never write a new +plan without it. + +### Existing plan + +Do not run `sce plan init`. Revise the plan by applying the +`Updating an existing plan` rules in `references/plan-template.md`, which +preserve completed tasks and their evidence. The revised plan must satisfy the +same structured plan contract. + +### Completeness gate + +For new plans and revisions alike, the plan contains no scaffold `TODO` +placeholder, and optional sections without real entries (`Design decisions`, +`Assumptions`) are removed. The change summary, acceptance criteria, design +decisions, verification scenarios, task definitions, and verify instructions +are authored for this change. + +While any placeholder remains, keep authoring or return `blocked`. A passing +`sce plan check` is required but not sufficient: it is structural and does not +detect placeholders. + +### Structural check -Write `context/plans/{plan_name}.md` by applying `references/plan-template.md` -exactly. For revisions, apply its `Updating an existing plan` rules. +For new plans and revisions alike, run `sce plan check {plan-path}`. It passes +only on exit `0` with `structured plan: reference integrity holds` on stdout. -## 2.8 Return the result +On exit `3` with structural diagnostics, fix each reported defect in the plan +and run it again, or return `blocked` with the remaining diagnostics when they +cannot be resolved. Every other result returns `blocked` with the exit code and +stderr. This includes a missing `sce` executable, a binary without `check`, any +other exit code, and an exit `0` without the success marker. + +Set internal status `plan_ready` only after both the completeness gate and +`sce plan check` pass. + +## 2.11 Return the result Return one internal result with one of these statuses: @@ -470,6 +554,30 @@ Do not: +planAcceptanceCriteriaIntro = """ +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. +""" + +planFullValidationIntro = """ +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. +""" + +planTaskContextSynchronizationLifecycle = """ +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. +""" + changeToPlanPlanTemplate = """ # Internal persisted-document format: Plan template @@ -491,21 +599,42 @@ entirely rather than writing them empty. {One or two paragraphs: what changes, where, and why. State whether this extends existing behavior, replaces it, or preserves work already in progress.} +## Design decisions + +{Optional. Include only real architectural choices this plan commits to. Remove +the section when there are none.} + +- D1: {decision} + - Boundary: {architectural boundary the decision governs} + - Rationale: {why} + - Rejected: {alternative} — {why not} + - Trade-offs: {known limitation or cost} + - Review: required + - Review question: {optional; one question for senior engineering judgment; remove both review lines when none is needed} + ## Acceptance criteria -How this plan is proven complete. Each criterion is observable and names the -check that proves it. `/validate` runs these checks; no task in the stack -performs final validation. +\(planAcceptanceCriteriaIntro) - [ ] AC1: {observable outcome, stated as behavior rather than as work done} - Validate: `{command, assertion, or inspection that proves AC1}` + - Decisions: {decision IDs, e.g. D1; omit the field when no decision applies} + - Scenarios: {scenario IDs, e.g. S1, S2} - [ ] AC2: {observable outcome} - Validate: `{command, assertion, or inspection that proves AC2}` + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - When: {action} + - Then: {exact observable result or invariant} + - Check: {planned test, command, or named inspection} ### Full validation -Repository-wide checks `/validate` runs after the last task, regardless of -which criterion they map to. +\(planFullValidationIntro) - `{full check suite command}` - `{generated-output or parity check command, when applicable}` @@ -514,16 +643,7 @@ which criterion they map to. - {Durable context files that must describe the change once implemented.} -## Task context synchronization lifecycle - -Persist this field in every plan; this is durable plan state, not chat state: - -- **Task context synchronization:** every task carries `pending | synced | blocked`. - A completed task must be `synced` before another task can start or the plan can - finish. -- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** - beside the status. Never infer `synced` from conversation history; write every - lifecycle transition to the plan file. +\(planTaskContextSynchronizationLifecycle) ## Constraints and non-goals @@ -543,6 +663,7 @@ recorded. Remove the section otherwise.} - [ ] T01: `{single intent title}` (status:todo) - Task ID: T01 + - Covers: {acceptance criterion IDs, e.g. AC1} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: {task IDs, or none} - Done when: {clear acceptance for one coherent change} @@ -551,6 +672,7 @@ recorded. Remove the section otherwise.} - [ ] T02: `{single intent title}` (status:todo) - Task ID: T02 + - Covers: {acceptance criterion IDs, e.g. AC2} - Scope: In — {tight scope}. Out — {excluded work}. - Dependencies: T01 - Done when: {clear acceptance for one coherent change} @@ -576,6 +698,7 @@ invent one: `None.` is the expected answer for a well-specified change.} ```markdown - [ ] T02: `Add /auth/refresh endpoint` (status:todo) - Task ID: T02 + - Covers: AC1 - Scope: In — route handler, token validation logic, response schema. Out — refresh token rotation policy (covered in T03), client-side storage changes. - Dependencies: T01 - Done when: `POST /auth/refresh` returns a signed JWT on valid input and 401 on expired or invalid token; targeted tests pass; OpenAPI spec updated. @@ -583,6 +706,47 @@ invent one: `None.` is the expected answer for a well-specified change.} - Context synchronization: pending ``` +## Filled-in structured plan example + +```markdown +## Design decisions + +- D1: The existing session store validates refresh tokens; no new token service is added. + - Boundary: `src/auth/` owns token validation; route handlers call it and never read the session store directly. + - Rationale: The session store already tracks revocation, so one owner keeps revocation consistent. + - Rejected: Stateless refresh tokens — they cannot be revoked before they expire. + - Trade-offs: Every refresh costs one session-store read. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` returns a signed JWT for a valid refresh token and 401 for an expired or revoked one. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh` with its 200 and 401 responses. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A refresh token that has not expired but is revoked in the session store. + - When: The client calls `POST /auth/refresh` with that token. + - Then: The response is 401 and no JWT is issued. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"`, created by T01. + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 + - Scope: In — route handler, session-store validation call, OpenAPI entry. Out — refresh token rotation policy. + - Dependencies: none + - Done when: The endpoint returns a JWT or 401 as AC1 states, the revoked-token test exists and passes, and the OpenAPI entry is present. + - Verify: `pnpm test src/auth/refresh.test.ts`. + - Context synchronization: pending +``` + ## Acceptance criteria rules - Acceptance criteria describe the finished system, not the work. Prefer "the @@ -600,6 +764,49 @@ invent one: `None.` is the expected answer for a well-specified change.} change request. If a criterion has no task that could satisfy it, the task stack is incomplete. +## Design decision rules + +- Record a design decision only for a real architectural choice the plan + commits to, such as an ownership boundary, a data shape, an interface, or an + alternative a reviewer would otherwise reopen. +- Every decision carries `Boundary:`, `Rationale:`, `Rejected:`, and + `Trade-offs:`. +- A decision may also carry the optional review pair `Review: required` and + `Review question: {one question}`. Mark only a decision that needs senior + engineering judgment before the change merges, such as a trade-off a reviewer + must accept. Leave every other decision unmarked. +- Write the review question as one specific question that a reviewer can + accept or reject. Name the architectural choice and its consequence, so the + reviewer can answer from the decision alone. Prefer "Is reading plans from + the working tree instead of the `--head` commit acceptable for stable + links?" over "Is this design OK?" or "Any concerns about plan reads?". +- Ask about each concern once. Do not mark a second decision that raises the + same concern as another marked decision, and do not combine two concerns in + one question. +- Mark every decision that meets this bar. There is no maximum number of + marked decisions. Do not drop an important review question or merge + unrelated questions to lower the count. +- Three or more marked decisions are a signal that the change may be too large + for one focused review. Consider moving independent changes into a separate + pull request. This is advisory: keep tightly coupled changes together, and + complete the plan either way. +- The review pair is all or nothing: a decision carries both fields or + neither. `Review:` is exactly `required`, and `Review question:` is one + nonempty line that states one question. +- An unmarked decision carries no review field. Do not add a placeholder value + such as `Review: none` or an empty `Review question:`. +- A design decision belongs to this plan only. It does not replace an + architecture decision record. + +## Verification scenario rules + +- A scenario states one behavior that an acceptance criterion depends on, as + `Given:`, `When:`, `Then:`, and `Check:`. +- `Then:` states an exact observable result or invariant, not an intention. +- `Check:` names the planned test, command, or named inspection that would + prove `Then:`. Name a test only when it already exists or a task in the stack + creates it. + ## Task rules - Number tasks sequentially as `T01..T0N`. @@ -615,6 +822,51 @@ invent one: `None.` is the expected answer for a well-specified change.} acceptance checks. - Order tasks so each one's declared dependencies precede it. +## Reference rules + +- Read plan structure only from Markdown outside fenced code blocks. A fenced + code block runs from a line that opens a fence of three or more backticks or + tildes to its closing fence. Headings, definitions, and fields inside it are + example text, not plan structure. Keep fenced examples in place; never delete + one to make a plan read correctly. +- A section runs from its heading to the next heading of any level outside + fenced code blocks. A definition counts only in its own section: `D` entries + under `## Design decisions`, `AC` entries under `## Acceptance criteria`, `S` + entries under `### Verification scenarios`, and `T` entries under + `## Task stack`. A line shaped like a definition anywhere else, such as in a + fenced example or a validation report, defines nothing. +- A definition's fields are the `- Field:` lines indented one level under its + entry line, up to the next entry. `Boundary:`, `Rationale:`, `Rejected:`, + `Trade-offs:`, and the optional `Review:` and `Review question:` belong to a + design decision; `Validate:`, `Decisions:`, and + `Scenarios:` to an acceptance criterion; `Given:`, `When:`, `Then:`, and + `Check:` to a verification scenario; and `Covers:` to a task. +- To check a fenced example itself, extract the block's content and apply these + rules to it as a separate sample document. +- IDs are `D1..Dn` for design decisions, `AC1..ACn` for acceptance criteria, + `S1..Sn` for verification scenarios, and `T01..T0N` for tasks. Each entry + starts its own line with its ID: `- D1:`, `- [ ] AC1:`, `- S1:`, and + `- [ ] T01:`. +- Define each ID exactly once. +- Number decisions, criteria, and scenarios sequentially in order of first + assignment. Never renumber or reuse a decision, criterion, or scenario ID; a + withdrawn entry keeps its ID and says that it is withdrawn. +- Each reference field fits on one line: `Decisions:` and `Scenarios:` on an + acceptance criterion, and `Covers:` on a task. Its value is a list of IDs + separated by a comma and a space, for example `S1, S2`. +- Every referenced ID resolves to a definition in the same plan. +- Every acceptance criterion carries `Scenarios:`, with scenario IDs or the + literal `none (inspection)` when only an inspection can prove it. Omit + `Decisions:` when no decision applies. +- Every task carries `Covers:` naming at least one + acceptance criterion defined in the plan. A task without `Covers:` or with an + empty `Covers:` is a defect even when other tasks cover every criterion. +- Every acceptance criterion is named by at least one task's `Covers:`. +- Every scenario is referenced by at least one acceptance criterion's + `Scenarios:`. +- A planned `Check:` is not execution evidence. Only `/validate` running or + performing the check verifies a scenario. + ## No validation task - The last task in the stack is an ordinary implementation task. Do not author a @@ -656,6 +908,7 @@ Do not author either while planning. run earlier, and never renumber a completed task. - Add acceptance criteria for newly planned outcomes rather than rewriting criteria already satisfied. +- Give each new decision, criterion, or scenario the next unused ID of its kind. """ diff --git a/config/pkl/base/workflow-content.pkl b/config/pkl/base/workflow-content.pkl index 588948945..df23c6f95 100644 --- a/config/pkl/base/workflow-content.pkl +++ b/config/pkl/base/workflow-content.pkl @@ -308,7 +308,7 @@ selected or resumed, before allowing a new implementation task to start. A missing field, or any value other than `synced`, is unresolved synchronization debt. Never infer `synced` from conversation history. When the debt-carrying task has no durable completed-task record, the **Plan review phase** returns -`blocked` directly with a legacy-migration required action; otherwise it returns +`blocked` directly with a plan-migration required action; otherwise it returns `sync_debt` with the resolved plan path, debt task ID/title, completed task record, and persisted blocker when present, for the branch above to route. diff --git a/config/pkl/base/workflow-next-task.pkl b/config/pkl/base/workflow-next-task.pkl index 8f6725314..85bc3df22 100644 --- a/config/pkl/base/workflow-next-task.pkl +++ b/config/pkl/base/workflow-next-task.pkl @@ -402,8 +402,8 @@ local renderReviewSkillBody = (mode: model.WorkflowRenderMode) -> """ For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan - predating that structure, or an incomplete write), do not attempt a + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. \(returnUpper.render.apply(mode)) `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -936,7 +936,8 @@ local renderExecutionSkillBody = (mode: model.WorkflowRenderMode) -> """ checks. Use judgment for ordinary, reversible local implementation choices. - + + Stop when implementation requires: - Material scope expansion. @@ -1436,6 +1437,12 @@ local renderImplementationGate = (mode: model.WorkflowRenderMode) -> """ - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. + ## Acceptance coverage + + - List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. + - Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} @@ -1485,8 +1492,8 @@ any value other than `synced`, is unresolved synchronization debt. Never infer For the first task carrying debt: - When the task has no durable completed-task record (no `Files changed`, - `Result`, `Verify`, or `Context impact` recorded — a legacy plan predating - that structure, or an incomplete write), do not attempt a reconstructed retry. + `Result`, `Verify`, or `Context impact` recorded, for example after an + incomplete write), do not attempt a reconstructed retry. Set internal status `blocked` with a required action to migrate the plan (backfill the completion record, or resolve the debt manually) and a retry condition of the plan carrying that structure. Stop. @@ -1530,6 +1537,18 @@ architecture, shared terminology, or cross-domain interfaces. Do not explore the entire repository by default. +Read plan structure only outside fenced code blocks; headings, definitions, and +fields inside a fenced example are not plan structure. A definition counts only +in its own section: `D` entries under `## Design decisions`, `AC` entries under +`## Acceptance criteria`, `S` entries under `### Verification scenarios`, and +`T` entries under `## Task stack`. + +Read the selected task's `Covers:` IDs. For each covered +acceptance criterion, read its `Scenarios:` and `Decisions:` fields and the +scenario and decision definitions they name. A covered ID or referenced ID with +no definition in the plan is a dangling reference. Record it as a review risk; +it does not make the task `blocked`. + ## 1.4 Determine readiness A task is `ready` when: @@ -1584,6 +1603,14 @@ A `ready` result must identify: - Relevant files and context. - Review assumptions. +A `ready` result also identifies: + +- The covered acceptance criteria: each `Covers:` ID with its outcome and + `Validate:` check. +- Their verification scenarios: each scenario ID with its `Then:` and `Check:`. +- Their related design decisions: each decision ID with its decision text. +- Every dangling reference, as a review risk. + A `sync_debt` result must identify: - The resolved plan path. @@ -1725,6 +1752,7 @@ checks. Use judgment for ordinary, reversible local implementation choices. + Stop when implementation requires: - Material scope expansion. @@ -1744,6 +1772,12 @@ Run the narrowest authoritative checks that demonstrate the done checks. Start with verification supplied by the readiness result. Add nearby or directly relevant checks only when needed. +The covered scenarios' `Then:` and `Check:` may shape +the task's own `Verify` checks. Running a scenario's check during the task is +task-level evidence only. Task execution never checks an acceptance-criterion +checkbox and never records a scenario status; only `/validate` verifies +acceptance criteria and scenarios. + Verification may include: - Targeted tests. @@ -1779,6 +1813,7 @@ Only after successful implementation and task-level verification: facts above and before the synchronization phase is invoked. - Record material deviations or approved assumptions. - Preserve the plan's existing structure and terminology. +- Keep the task's `Covers:` field as authored. Do not mark the task complete when returning `declined`, `blocked`, or `incomplete`. @@ -1950,6 +1985,12 @@ Describe the smallest coherent implementation approach with enough detail for ap - Include only risks relevant to approving this task. - Omit this section when there are no meaningful risks. +## Acceptance coverage + +- List each covered acceptance criterion `{AC id}: {outcome}` with its + scenarios `{S id}: {Then}` (`{Check}`) and related decisions `{D id}: {decision}`. +- Report dangling references under **Risks or trade-offs**. + ## Verification - {task.verification} diff --git a/config/pkl/base/workflow-validate.pkl b/config/pkl/base/workflow-validate.pkl index cfd4617ea..f8998a5a1 100644 --- a/config/pkl/base/workflow-validate.pkl +++ b/config/pkl/base/workflow-validate.pkl @@ -229,22 +229,83 @@ local renderValidationSkillBody = (mode: model.WorkflowRenderMode) -> """ - Every acceptance criterion and its `Validate:` check. - The `Full validation` command list. - + + Read plan structure only outside fenced code blocks; headings, definitions, and + fields inside a fenced example are not plan structure. A definition counts only + in its own section: `D` entries under `## Design decisions`, `AC` entries under + `## Acceptance criteria`, `S` entries under `### Verification scenarios`, and + `T` entries under `## Task stack`. + + Also collect: + + - Every design decision. + - Every acceptance criterion's `Decisions:` and `Scenarios:` fields. + - Every verification scenario with its `Then:` and `Check:`. + - Every task's `Covers:` field. + \(returnUpper.render.apply(mode)) `blocked` when the plan has no usable acceptance criteria, or when no validation commands can be determined from the plan or repository conventions. - + Prefer the plan's authored checks. Fall back to repository-primary test, lint, and format commands only when `Full validation` is absent, and record that fallback under notes on a `validated` or `failed` result. - - \(validationHeadings.stepHeading.apply(mode, 4, "Run full validation and acceptance checks")) - + + \(validationHeadings.stepHeading.apply(mode, 4, "Check plan references")) + + Run `sce plan check {plan-path}` before scenario + verification. Record each reported diagnostic as a failed check under + **Failed checks and follow-ups**, quoting its line and message. + + Trust the checker only when it actually ran: a result counts only on exit `0` + with `structured plan: reference integrity holds` on stdout, or on exit `3` + with diagnostics. Any other outcome means the checker is unavailable; record + that fallback under notes and apply the manual pass below instead. + + For the manual pass, apply every entry of the plan template's + `Reference rules` to the plan. Each of these is a relationship-integrity + defect: + + - An ID defined more than once. + - A dangling reference: a `Decisions:`, `Scenarios:`, or `Covers:` ID with no + definition in the plan. + - An acceptance criterion without `Scenarios:`. + - An acceptance criterion that no task's `Covers:` names. + - A task without `Covers:` or with an empty `Covers:`. + - A scenario that no acceptance criterion's `Scenarios:` references. + + Record each defect as a failed check under **Failed checks and follow-ups**, + naming the IDs involved. A relationship-integrity defect makes the result + `failed`; never tolerate one silently. Continue gathering the remaining + evidence. + + \(validationHeadings.stepHeading.apply(mode, 5, "Run full validation and acceptance checks")) + Run the plan's `Full validation` commands. - + Then verify each acceptance criterion using its `Validate:` line. Prefer a runnable command. Use a named inspection only when the criterion authorizes it, and say exactly what was inspected. - + + Run or perform every scenario's `Check:` yourself in + this run, and compare the observed result with the scenario's `Then:`. Give + each scenario exactly one status: + + - `Verified` when this run's evidence proves its `Then:` in full. + - `Partially verified` when the check ran but proves `Then:` only in part, for + example at a narrower level than `Then:` requires. + - `Not verified` when the check failed, could not be run or located, or was + not performed. + + Only evidence gathered by this run counts. Task completion, a task's recorded + `Verify` outcomes, a planned `Check:` line, and the plan's own claims are never + evidence. Record every `Partially verified` or `Not verified` scenario as a + failed check with the gap and a required action. + + An acceptance criterion is met only when its `Validate:` + check passes and every scenario its `Scenarios:` names is `Verified`. An + acceptance criterion marked `none (inspection)` is met by its `Validate:` check + alone. + Treat leftover debug-only flags, temporary files, intermediate artifacts, or local scaffolding as a failed validation check. Record the path and evidence under **Failed checks and follow-ups**; never delete or repair it during @@ -260,7 +321,7 @@ local renderValidationSkillBody = (mode: model.WorkflowRenderMode) -> """ Do not run task-by-task implementation work for incomplete tasks. That belongs to `/next-task`. - \(validationHeadings.stepHeading.apply(mode, 5, "Update the plan")) + \(validationHeadings.stepHeading.apply(mode, 6, "Update the plan")) For `validated` and `failed` outcomes: @@ -274,7 +335,7 @@ local renderValidationSkillBody = (mode: model.WorkflowRenderMode) -> """ For `blocked`, leave the plan file unchanged. - \(validationHeadings.stepHeading.apply(mode, 6, "Return the \(markdownResult.render.apply(mode))")) + \(validationHeadings.stepHeading.apply(mode, 7, "Return the \(markdownResult.render.apply(mode))")) \(returnExactlyOneMarkdownResult.render.apply(mode)): @@ -376,9 +437,13 @@ local renderValidationReport = (mode: model.WorkflowRenderMode) -> """ ### Success-criteria verification - - [x] AC1: {criterion statement} -> {evidence} - - [ ] AC2: {criterion statement} -> {evidence of failure or not checked} - + - [x] AC1: {criterion statement} -> {evidence}; scenarios: S1 + - [ ] AC2: {criterion statement} -> {evidence of failure or not checked}; scenarios: S2, S3 + + ### Scenario verification + + - S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified} + ### Failed checks and follow-ups - {check}: {problem}; evidence: {command output or inspection}; required: {decision or next action} @@ -406,7 +471,15 @@ local renderValidationReport = (mode: model.WorkflowRenderMode) -> """ run**. Preserve its exit code and concise outcome; do not invent either. - Under **Success-criteria verification**, render the acceptance-criterion checkbox state and evidence already established by validation execution. Do - not independently re-evaluate or reclassify criteria here. + not independently re-evaluate or reclassify criteria here. End each + acceptance-criterion line with the scenario IDs its + `Scenarios:` field names, or `none (inspection)`. + - Include **Scenario verification** with exactly one entry per scenario in + plan order. Each entry gives the scenario ID, the + acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status already established by validation + execution. - Under **Failed checks and follow-ups**, render every failure and follow-up supplied by validation execution. Write `None.` when status is `validated`. - When status is `failed`, always include **Retry** with the exact @@ -449,9 +522,13 @@ local renderValidationResult = (mode: model.WorkflowRenderMode) -> """ ## Acceptance criteria - - [x] AC1: {criterion statement} — {evidence} - - [x] AC2: {criterion statement} — {evidence} - + - [x] AC1: {criterion statement} — {evidence}; scenarios: S1 + - [x] AC2: {criterion statement} — {evidence}; scenarios: none (inspection) + + ## Scenario verification + + - S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified + ## Residual risks - {risk} @@ -491,12 +568,20 @@ local renderValidationResult = (mode: model.WorkflowRenderMode) -> """ - `{check or AC id}`: {problem} - Evidence: {command output, exit summary, or inspection finding} - Required action: {concrete repair or decision} - + - `{scenario id}` ({AC ids}): {Partially verified | Not verified} — {gap between the observed result and `Then:`} + - Evidence: {exact test, command, or inspected location and its actual result} + - Required action: {concrete repair or decision} + ## Acceptance criteria - - - [x] AC1: {criterion} — {evidence} - - [ ] AC2: {criterion} — {why unmet} - + + - [x] AC1: {criterion} — {evidence}; scenarios: S1 + - [ ] AC2: {criterion} — {why unmet}; scenarios: S2 + + ## Scenario verification + + - S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: Verified + - S2 (AC2): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Partially verified | Not verified} + ## Commands run - `{command}` -> {passed | failed | not_run} — {concise outcome summary} @@ -576,6 +661,13 @@ local renderValidationResult = (mode: model.WorkflowRenderMode) -> """ `/validate {plan path}` as the final step after repairs. - The failed variant must be self-contained enough to hand to another session without the original chat. + - The validated and failed variants include **Scenario + verification** with exactly one entry per scenario in plan order: the scenario + ID, the acceptance-criterion IDs that reference it, the evidence kind (`test`, + `command`, or `inspection`), the exact test, command, or inspected location, + the actual result, and the scenario status. Each acceptance-criterion line + cites its scenario IDs, or `none (inspection)`. In the failed variant, list + every `Partially verified` or `Not verified` scenario under **What failed**. - Do not include durable context synchronization results in this report. - Do not select or describe an unrelated next implementation task when status is `validated`. diff --git a/config/pkl/check-generated.sh b/config/pkl/check-generated.sh index 23a4ad29b..7e5749054 100755 --- a/config/pkl/check-generated.sh +++ b/config/pkl/check-generated.sh @@ -36,6 +36,7 @@ legacy_paths=( "config/.claude" "config/.pi" "config/schema/sce-config.schema.json" + "config/plan-init-scaffold.md" "cli/assets/generated" ) @@ -151,6 +152,15 @@ expect_pkl_fixture_failure \ expect_pkl_fixture_failure \ "config/pkl/renderers/fixtures/compact-plan-template-schema-check.pkl" \ "generated plan-template.md must use the compact task and completion schema and must not reference the removed Goal, Boundaries (in/out of scope), Verification notes, Implementation evidence, Verification evidence, or Context synchronization handoff fields" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/structured-plan-template-schema-check.pkl" \ + "generated plan-template.md must define the Design decisions section, the Decisions:, Scenarios:, and Covers: reference fields, the Verification scenarios section, and the Design decision, Verification scenario, and Reference rules" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/plan-authoring-structured-references-check.pkl" \ + "generated plan-authoring.md must author design decisions, acceptance criteria, verification scenarios, and tasks in that order, forbid manufactured decisions and invented test names, add Covers:, and run a reference-consistency pass before writing, run sce plan check after writing the plan, and keep sce plan check out of every sce-next-task document" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/next-task-structured-references-check.pkl" \ + "generated sce-next-task plan-review.md, output.md, and task-execution.md must carry covered acceptance criteria, scenarios, and decisions, treat dangling references as review risks, and never check an acceptance criterion or record a scenario status during task execution" expect_pkl_fixture_failure \ "config/pkl/renderers/fixtures/next-task-compact-completion-writing-check.pkl" \ "sce-next-task task-execution.md must record execution facts directly on the completed task and must not construct a separate Implementation evidence, Verification evidence, or Context synchronization handoff record" @@ -175,6 +185,140 @@ expect_pkl_fixture_failure \ expect_pkl_fixture_failure \ "config/pkl/renderers/fixtures/validate-decision-sync-boundary-check.pkl" \ "generated sce-validate document must not contain a sce-decision reference or plan-context-sync wording" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/validate-scenario-evidence-check.pkl" \ + "generated sce-validate validation.md must fail on relationship-integrity defects, run every scenario check itself with exactly one of Verified, Partially verified, or Not verified, check an acceptance criterion only when its Validate: check passes and all its scenarios are Verified, exclude task completion and plan claims as evidence, run sce plan check before scenario verification with the checker trust rule and manual Reference rules fallback, and keep sce plan check out of every sce-next-task document" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/validation-report-scenario-section-check.pkl" \ + "generated sce-validate validation-report.md and validation.md must carry a scenario verification section mapping each scenario to its acceptance criteria, evidence kind, exact evidence, actual result, and status, cite scenario IDs on acceptance-criterion lines, and list unverified scenarios under What failed" + +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/plan-template-misplaced-field-check.pkl" \ + "structured example: \`Covers:\` on \`AC2\` is misplaced; it belongs to a task" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/plan-template-keywords-in-examples-check.pkl" \ + "heading \`## Reference rules\` is missing outside fenced code blocks" +expect_pkl_fixture_failure \ + "config/pkl/renderers/fixtures/plan-template-dangling-reference-check.pkl" \ + "structured example: \`Scenarios:\` on \`AC1\` references undefined \`S2\`" + +plan_structure_check="config/pkl/renderers/plan-structure-check.pkl" +plan_structure_report="config/pkl/renderers/fixtures/plan-structure-report.pkl" +plan_structure_fixtures="config/pkl/renderers/fixtures/plan-structure" + +expect_plan_structure_pass() { + local fixture_name="$1" + local output + + if ! output="$(pkl eval -p "planPath=$repo_root/$plan_structure_fixtures/$fixture_name" \ + "$plan_structure_check" 2>&1)"; then + printf 'Plan structure fixture unexpectedly failed: %s\n%s\n' \ + "$fixture_name" "$output" >&2 + exit 1 + fi + + if [[ "$output" != *"$structured_plan_result"* ]]; then + printf 'Plan structure fixture passed with the wrong result: %s\n%s\n' \ + "$fixture_name" "$output" >&2 + exit 1 + fi +} + +expect_plan_structure_failure() { + local fixture_name="$1" + + if pkl eval -p "planPath=$repo_root/$plan_structure_fixtures/$fixture_name" \ + "$plan_structure_check" >/dev/null 2>&1; then + printf 'Negative plan structure fixture unexpectedly passed: %s\n' \ + "$fixture_name" >&2 + exit 1 + fi +} + +expect_plan_structure_report() { + local fixture_name="$1" + shift + local report + local actual_diagnostics + local expected_diagnostics + local report_stderr="$tmp_dir/plan-structure-report.stderr" + + if ! report="$(pkl eval -p "planPath=$repo_root/$plan_structure_fixtures/$fixture_name" \ + "$plan_structure_report" 2>"$report_stderr")"; then + printf 'Plan structure report failed to evaluate: %s\n%s\n%s\n' \ + "$fixture_name" "$report" "$(cat "$report_stderr")" >&2 + exit 1 + fi + + actual_diagnostics="$(LC_ALL=C sort <<<"$report")" + expected_diagnostics="$(if [[ "$#" -gt 0 ]]; then printf '%s\n' "$@"; fi | LC_ALL=C sort)" + + if [[ "$actual_diagnostics" != "$expected_diagnostics" ]]; then + printf 'Plan structure fixture diagnostics differ from the manifest: %s\nexpected (sorted):\n%s\nactual (sorted):\n%s\n' \ + "$fixture_name" "$expected_diagnostics" "$actual_diagnostics" >&2 + exit 1 + fi +} + +structured_plan_result="structured plan: reference integrity holds" + +plan_structure_manifest="$plan_structure_fixtures/expectations.tsv" +listed_plan_structure_fixtures=() + +while IFS=$'\t' read -r -u 3 -a manifest_columns; do + if [[ "${#manifest_columns[@]}" -eq 0 || "${manifest_columns[0]}" == \#* ]]; then + continue + fi + + fixture_name="${manifest_columns[0]}" + fixture_verdict="${manifest_columns[1]:-}" + expected_diagnostics=("${manifest_columns[@]:2}") + + if [[ ! -f "$plan_structure_fixtures/$fixture_name" ]]; then + printf 'Plan structure manifest lists a missing fixture: %s\n' "$fixture_name" >&2 + exit 1 + fi + listed_plan_structure_fixtures+=("$fixture_name") + + case "$fixture_verdict" in + pass) + if [[ "${#expected_diagnostics[@]}" -ne 0 ]]; then + printf 'Plan structure manifest pass row must not list diagnostics: %s\n' "$fixture_name" >&2 + exit 1 + fi + expect_plan_structure_pass "$fixture_name" + expect_plan_structure_report "$fixture_name" + ;; + fail) + if [[ "${#expected_diagnostics[@]}" -eq 0 ]]; then + printf 'Plan structure manifest fail row must list diagnostics: %s\n' "$fixture_name" >&2 + exit 1 + fi + expect_plan_structure_failure "$fixture_name" + expect_plan_structure_report "$fixture_name" "${expected_diagnostics[@]}" + ;; + *) + printf 'Plan structure manifest has an unknown verdict for %s: %s\n' \ + "$fixture_name" "$fixture_verdict" >&2 + exit 1 + ;; + esac +done 3<"$plan_structure_manifest" + +for fixture_path in "$plan_structure_fixtures"/*.md; do + fixture_name="$(basename "$fixture_path")" + listed_count=0 + for listed_fixture in "${listed_plan_structure_fixtures[@]}"; do + if [[ "$listed_fixture" == "$fixture_name" ]]; then + listed_count=$((listed_count + 1)) + fi + done + if [[ "$listed_count" -ne 1 ]]; then + printf 'Plan structure fixture must be listed exactly once in %s: %s\n' \ + "$plan_structure_manifest" "$fixture_name" >&2 + exit 1 + fi +done "$producer" "$repo_root" "$generated_input_root" generated_root="$generated_input_root/pkl-generated" @@ -196,6 +340,7 @@ required_paths=( "config/.pi/skills" "config/.pi/extensions/sce/index.ts" "config/schema/sce-config.schema.json" + "config/plan-init-scaffold.md" ) for path in "${required_paths[@]}"; do diff --git a/config/pkl/generate.pkl b/config/pkl/generate.pkl index 84ae2ac8b..91df38dfe 100644 --- a/config/pkl/generate.pkl +++ b/config/pkl/generate.pkl @@ -6,6 +6,7 @@ import "renderers/common.pkl" as common import "base/sce-config-schema.pkl" as sce_config_schema import "base/optional-workflow-manifest.pkl" as optional_workflow_manifest import "base/bash-policy-presets.pkl" as bash_policy_presets +import "base/plan-init-scaffold.pkl" as plan_init_scaffold local bashPolicyPresetCatalogSource = bash_policy_presets.output.text local opencodeBashPolicyPluginSource = read("../lib/bash-policy-plugin/opencode-bash-policy-plugin.ts").text @@ -96,5 +97,8 @@ output { ["config/optional-workflows.json"] { text = optional_workflow_manifest.rendered } + ["config/plan-init-scaffold.md"] { + text = "\(plan_init_scaffold.text)\n" + } } } diff --git a/config/pkl/renderers/fixtures/next-task-structured-references-check.pkl b/config/pkl/renderers/fixtures/next-task-structured-references-check.pkl new file mode 100644 index 000000000..e58e8b213 --- /dev/null +++ b/config/pkl/renderers/fixtures/next-task-structured-references-check.pkl @@ -0,0 +1,15 @@ +import "../generation-contract-check.pkl" as contract + +local documents = new Mapping { + ["config/.opencode/skills/sce-next-task/references/plan-review.md"] = + "## 1.5 Return the result\n\nA `ready` result must identify:\n\n- One resolved plan." + ["config/.opencode/skills/sce-next-task/references/output.md"] = + "## Risks or trade-offs\n\n## Verification" + ["config/.opencode/skills/sce-next-task/references/task-execution.md"] = + "## 2.6 Verify the task\n\n## 2.7 Update the plan" +} + +result = if ( + contract.assertNextTaskStructuredReferences.apply(documents) +) "negative fixture unexpectedly accepted next-task references without acceptance coverage" +else throw("generated sce-next-task plan-review.md, output.md, and task-execution.md must carry covered acceptance criteria, scenarios, and decisions, treat dangling references as review risks, and never check an acceptance criterion or record a scenario status during task execution") diff --git a/config/pkl/renderers/fixtures/plan-authoring-structured-references-check.pkl b/config/pkl/renderers/fixtures/plan-authoring-structured-references-check.pkl new file mode 100644 index 000000000..84da9c5be --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-authoring-structured-references-check.pkl @@ -0,0 +1,11 @@ +import "../generation-contract-check.pkl" as contract + +local documents = new Mapping { + ["config/.opencode/skills/sce-change-to-plan/references/plan-authoring.md"] = + "## 2.5 Author the acceptance criteria\n\n## 2.6 Author the task stack\n\n## 2.7 Write the plan" +} + +result = if ( + contract.assertPlanAuthoringStructuredReferences.apply(documents) +) "negative fixture unexpectedly accepted a plan-authoring phase without the structured authoring order or reference-consistency pass" +else throw("generated plan-authoring.md must author design decisions, acceptance criteria, verification scenarios, and tasks in that order, forbid manufactured decisions and invented test names, add Covers:, and run a reference-consistency pass before writing, run sce plan check after writing the plan, and keep sce plan check out of every sce-next-task document") diff --git a/config/pkl/renderers/fixtures/plan-structure-report.pkl b/config/pkl/renderers/fixtures/plan-structure-report.pkl new file mode 100644 index 000000000..bf32cd3ee --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure-report.pkl @@ -0,0 +1,7 @@ +import "../plan-structure.pkl" as planStructure + +local report = planStructure.check(read("file:\(read("prop:planPath"))").text) + +output { + text = report.defects.map((defect) -> defect + "\n").join("") +} diff --git a/config/pkl/renderers/fixtures/plan-structure/duplicate-id.md b/config/pkl/renderers/fixtures/plan-structure/duplicate-id.md new file mode 100644 index 000000000..e645e1907 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/duplicate-id.md @@ -0,0 +1,52 @@ +# Plan: duplicate-id + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` +- S1: Expired refresh token is rejected + - Given: An expired refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects an expired token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/expectations.tsv b/config/pkl/renderers/fixtures/plan-structure/expectations.tsv new file mode 100644 index 000000000..0e1092a57 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/expectations.tsv @@ -0,0 +1,23 @@ +# fixture verdict (pass | fail) complete expected diagnostic messages (fail only, one per column, order-insensitive, duplicates counted) +duplicate-id.md fail `S1` is defined more than once +fenced-example-plan.md pass +inspection-only-plan.md pass +keywords-only-in-examples.md fail `Scenarios:` on `AC1` references undefined `S1`; it appears only inside a fenced code block +misplaced-definition.md fail `S1` is defined under `## Acceptance criteria`; it belongs under `### Verification scenarios` `Scenarios:` on `AC1` references undefined `S1`; it appears only outside `### Verification scenarios` +misplaced-field.md fail `Check:` on `D1` is misplaced; it belongs to a verification scenario `Covers:` on `AC1` is misplaced; it belongs to a task `S1` is missing `Check:` `T01` is missing `Covers:` `AC1` is not covered by any task's `Covers:` +missing-scenarios-field.md fail `AC1` is missing `Scenarios:` `S1` is not referenced by any acceptance criterion's `Scenarios:` +review-fields-duplicate.md fail `Review:` appears more than once on `D1` `Review question:` appears more than once on `D2` +review-fields-malformed.md fail `Review:` on `D1` must be `required` `Review question:` on `D2` is empty `Review question:` on `D3` must be a single line `Review:` on `D4` must be `required` +review-fields-misplaced.md fail `Review:` on `AC1` is misplaced; it belongs to a design decision `Review question:` on `AC1` is misplaced; it belongs to a design decision `Review:` on `T01` is misplaced; it belongs to a design decision `Review question:` on `T01` is misplaced; it belongs to a design decision +review-fields-missing-pair.md fail `D1` has `Review:` without `Review question:` `D2` has `Review question:` without `Review:` +review-fields-valid.md pass +task-empty-covers.md fail `Covers:` on `T02` is not a comma-separated ID list +task-missing-covers.md fail `T02` is missing `Covers:` +uncovered-ac.md fail `AC2` is not covered by any task's `Covers:` +undefined-references.md fail `Decisions:` on `AC1` references undefined `D2` `Scenarios:` on `AC1` references undefined `S9` `Covers:` on `T01` references undefined `AC3` +unreferenced-scenario.md fail `S2` is not referenced by any acceptance criterion's `Scenarios:` +unstructured-plan-with-fenced-structured-example.md fail `AC1` is missing `Scenarios:` `T01` is missing `Covers:` `AC1` is not covered by any task's `Covers:` +unstructured-plan.md fail `AC1` is missing `Scenarios:` `AC2` is missing `Scenarios:` `AC3` is missing `Scenarios:` `T01` is missing `Covers:` `T02` is missing `Covers:` `AC1` is not covered by any task's `Covers:` `AC2` is not covered by any task's `Covers:` `AC3` is not covered by any task's `Covers:` +valid-structured-plan.md pass +wrong-heading-context.md fail heading `## Task stack` must come after `### Verification scenarios` heading `### Verification scenarios` is not inside `## Acceptance criteria` +wrong-reference-kind.md fail `Covers:` on `T01` names `S1`, which is not an acceptance criterion `AC1` is not covered by any task's `Covers:` diff --git a/config/pkl/renderers/fixtures/plan-structure/fenced-example-plan.md b/config/pkl/renderers/fixtures/plan-structure/fenced-example-plan.md new file mode 100644 index 000000000..d10aeb1c1 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/fenced-example-plan.md @@ -0,0 +1,71 @@ +# Plan: fenced-example-plan + +## Change summary + +Add a refresh endpoint. The plan format looks like this: + +### Format at a glance + +```markdown +## Design decisions + +- D1: {decision} + - Boundary: {boundary} + +## Acceptance criteria + +- [ ] AC1: {observable outcome} + - Validate: `{check}` + - Decisions: D1 + - Scenarios: S1, S2 + +### Verification scenarios + +- S1: {short title} + - Given: {preconditions} + - Check: {planned check} + +## Task stack + +- [ ] T01: `{title}` (status:todo) + - Task ID: T01 + - Covers: AC1, AC2 +``` + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/inspection-only-plan.md b/config/pkl/renderers/fixtures/plan-structure/inspection-only-plan.md new file mode 100644 index 000000000..6cfcf1e29 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/inspection-only-plan.md @@ -0,0 +1,30 @@ +# Plan: inspection-only-plan + +## Change summary + +Document the refresh endpoint. + +## Acceptance criteria + +- [ ] AC1: The OpenAPI spec documents `POST /auth/refresh`. + - Validate: inspection of `openapi.yaml` under `paths./auth/refresh`. + - Scenarios: none (inspection) + +## Task stack + +- [x] T01: `Document /auth/refresh` (status:done) + - Task ID: T01 + - Covers: AC1 + - Scope: In — `openapi.yaml`. Out — code. + - Dependencies: none + - Done when: The entry exists. + - Verify: inspection of `openapi.yaml` — passed. + - Completed: 2026-10-23 + - Files changed: openapi.yaml + - Result: Added the entry. + - Context impact: none + - Context synchronization: synced + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/keywords-only-in-examples.md b/config/pkl/renderers/fixtures/plan-structure/keywords-only-in-examples.md new file mode 100644 index 000000000..31ffaa206 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/keywords-only-in-examples.md @@ -0,0 +1,50 @@ +# Plan: keywords-only-in-examples + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +```markdown +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +``` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/misplaced-definition.md b/config/pkl/renderers/fixtures/plan-structure/misplaced-definition.md new file mode 100644 index 000000000..f87184e04 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/misplaced-definition.md @@ -0,0 +1,46 @@ +# Plan: misplaced-definition + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Verification scenarios + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/misplaced-field.md b/config/pkl/renderers/fixtures/plan-structure/misplaced-field.md new file mode 100644 index 000000000..b6b14ecfe --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/misplaced-field.md @@ -0,0 +1,47 @@ +# Plan: misplaced-field + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Check: `pnpm test src/auth/refresh.test.ts` + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + - Covers: AC1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/missing-scenarios-field.md b/config/pkl/renderers/fixtures/plan-structure/missing-scenarios-field.md new file mode 100644 index 000000000..a8378fece --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/missing-scenarios-field.md @@ -0,0 +1,46 @@ +# Plan: missing-scenarios-field + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/review-fields-duplicate.md b/config/pkl/renderers/fixtures/plan-structure/review-fields-duplicate.md new file mode 100644 index 000000000..507247078 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/review-fields-duplicate.md @@ -0,0 +1,58 @@ +# Plan: review-fields-duplicate + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: Is one store read per refresh acceptable? + - Review: required +- D2: Revoked tokens return 401. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: Is a 401 status acceptable for a revoked token? + - Review question: Is a 401 status acceptable for every client? + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1, D2 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/review-fields-malformed.md b/config/pkl/renderers/fixtures/plan-structure/review-fields-malformed.md new file mode 100644 index 000000000..89bb316de --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/review-fields-malformed.md @@ -0,0 +1,72 @@ +# Plan: review-fields-malformed + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: optional + - Review question: Is one store read per refresh acceptable? +- D2: Revoked tokens return 401. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: +- D3: Refresh responses are not cached. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: Is disabling the cache acceptable + for every client? +- D4: Refresh tokens are opaque. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + and urgent + - Review question: Are opaque tokens acceptable? + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1, D2, D3, D4 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/review-fields-misplaced.md b/config/pkl/renderers/fixtures/plan-structure/review-fields-misplaced.md new file mode 100644 index 000000000..538a1d1ec --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/review-fields-misplaced.md @@ -0,0 +1,51 @@ +# Plan: review-fields-misplaced + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + - Review: required + - Review question: Is a 401 status acceptable for a revoked token? + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Review: required + - Review question: Is the endpoint scope acceptable? + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/review-fields-missing-pair.md b/config/pkl/renderers/fixtures/plan-structure/review-fields-missing-pair.md new file mode 100644 index 000000000..ea3c3f791 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/review-fields-missing-pair.md @@ -0,0 +1,54 @@ +# Plan: review-fields-missing-pair + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required +- D2: Revoked tokens return 401. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review question: Is a 401 status acceptable for a revoked token? + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1, D2 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/review-fields-valid.md b/config/pkl/renderers/fixtures/plan-structure/review-fields-valid.md new file mode 100644 index 000000000..5abb0fcba --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/review-fields-valid.md @@ -0,0 +1,55 @@ +# Plan: review-fields-valid + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + - Review: required + - Review question: Is one store read per refresh acceptable? + - Note that the store is shared with login. +- D2: Revoked tokens return 401. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1, D2 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/task-empty-covers.md b/config/pkl/renderers/fixtures/plan-structure/task-empty-covers.md new file mode 100644 index 000000000..b56f9dc0e --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/task-empty-covers.md @@ -0,0 +1,56 @@ +# Plan: task-empty-covers + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +- [ ] T02: `Document /auth/refresh in the changelog` (status:todo) + - Task ID: T02 + - Covers: + - Scope: In — changelog entry. Out — API docs. + - Dependencies: T01 + - Done when: `CHANGELOG.md` names the refresh endpoint. + - Verify: inspection of `CHANGELOG.md`. + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/task-missing-covers.md b/config/pkl/renderers/fixtures/plan-structure/task-missing-covers.md new file mode 100644 index 000000000..4aae1446a --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/task-missing-covers.md @@ -0,0 +1,55 @@ +# Plan: task-missing-covers + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +- [ ] T02: `Document /auth/refresh in the changelog` (status:todo) + - Task ID: T02 + - Scope: In — changelog entry. Out — API docs. + - Dependencies: T01 + - Done when: `CHANGELOG.md` names the refresh endpoint. + - Verify: inspection of `CHANGELOG.md`. + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/uncovered-ac.md b/config/pkl/renderers/fixtures/plan-structure/uncovered-ac.md new file mode 100644 index 000000000..b81359b12 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/uncovered-ac.md @@ -0,0 +1,50 @@ +# Plan: uncovered-ac + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 +- [ ] AC2: The OpenAPI spec documents `POST /auth/refresh`. + - Validate: inspection of `openapi.yaml`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/undefined-references.md b/config/pkl/renderers/fixtures/plan-structure/undefined-references.md new file mode 100644 index 000000000..94bf1978b --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/undefined-references.md @@ -0,0 +1,47 @@ +# Plan: undefined-references + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1, D2 + - Scenarios: S1, S9 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1, AC3 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/unreferenced-scenario.md b/config/pkl/renderers/fixtures/plan-structure/unreferenced-scenario.md new file mode 100644 index 000000000..128d33734 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/unreferenced-scenario.md @@ -0,0 +1,52 @@ +# Plan: unreferenced-scenario + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` +- S2: Expired refresh token is rejected + - Given: An expired refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects an expired token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/unstructured-plan-with-fenced-structured-example.md b/config/pkl/renderers/fixtures/plan-structure/unstructured-plan-with-fenced-structured-example.md new file mode 100644 index 000000000..cc2cee670 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/unstructured-plan-with-fenced-structured-example.md @@ -0,0 +1,36 @@ +# Plan: unstructured-plan-with-fenced-structured-example + +## Change summary + +Describe the structured plan format without adopting it: + +~~~markdown +### Verification scenarios + +- S1: {short title} + - Then: {result} + +## Task stack + +- [ ] T01: `{title}` (status:todo) + - Covers: AC1 +~~~ + +## Acceptance criteria + +- [ ] AC1: The format description exists. + - Validate: inspection of this plan. + +## Task stack + +- [ ] T01: `Describe the format` (status:todo) + - Task ID: T01 + - Scope: In — this plan. Out — everything else. + - Dependencies: none + - Done when: The description exists. + - Verify: inspection. + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/unstructured-plan.md b/config/pkl/renderers/fixtures/plan-structure/unstructured-plan.md new file mode 100644 index 000000000..6dbc24531 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/unstructured-plan.md @@ -0,0 +1,113 @@ +# Plan: remove-checkout-id + +## Change summary + +Remove the checkout identity feature: the `cli/src/services/checkout/` module (`resolve_git_dir`, `read_checkout_id`, `get_or_create_checkout_id`), the `/sce/checkout-id` file it creates/reads, and every diagnostic-only consumer of it in `agent_trace_storage`, `agent_trace_db` setup lifecycle, and `sce doctor`. Checkout ID is currently never persisted on Agent Trace rows and does not select the active database; it exists solely as a diagnostic label in setup output and `sce doctor --format json`. This replaces existing behavior by dropping that diagnostic surface, mirroring the repository's completed `remove-checkout-registry` removal of the related checkout registry feature. + +## Acceptance criteria + +- [x] AC1: `sce` no longer creates or reads `/sce/checkout-id`, and no code references the removed `cli/src/services/checkout/` module. + - Validate: `rg -n "services::checkout|get_or_create_checkout_id|resolve_git_dir|checkout_id" cli/src` returns no matches. +- [x] AC2: `sce setup` and `sce doctor` (text and JSON) no longer mention checkout identity. + - Validate: run `sce setup` in a scratch repo and confirm the output has no "Agent Trace checkout identity" line; run `sce doctor --format json` and confirm the payload has no `checkout_identity` key. +- [x] AC3: Durable context describes checkout identity as removed, with no remaining claim that current runtime creates, reads, or reports it. + - Validate: `rg -in "checkout identity|checkout_id|checkout-id" context/` shows only past-tense/removed framing, consistent with the existing `checkout registry` (removed) glossary entry. + +### Full validation + +- `nix flake check` +- `nix run .#pkl-check-generated` + +### Context sync + +- `context/cli/checkout-identity.md` +- `context/cli/agent-trace-storage.md` +- `context/sce/agent-trace-db.md` +- `context/sce/agent-trace-hook-doctor.md` +- `context/cli/cli-command-surface.md` +- `context/cli/service-lifecycle.md` +- `context/context-map.md` +- `context/glossary.md` +- `context/architecture.md` +- `context/overview.md` + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/checkout/` (deletion) and its module declaration in `cli/src/services/mod.rs`; `checkout_id` field and usage in `cli/src/services/agent_trace_storage/mod.rs` (including affected tests); `checkout_id` field, `setup.checkout_id` usage, and the "Agent Trace checkout identity" message line in `cli/src/services/agent_trace_db/lifecycle.rs` (including affected tests); `CheckoutIdentityHealth`, `checkout_identity` field, `collect_checkout_identity_health`, and JSON rendering in `cli/src/services/doctor/{types,inspect,render}.rs`; the ten context files listed under Context sync. +- **Out of scope:** repository identity resolution (`repository_identity`), Agent Trace DB schema/migrations, `sce sync`, and how the repository-scoped Agent Trace DB path is selected or opened. The already-completed `checkout registry` removal (`checkout-registry.json`, `sce doctor dbs`) is unrelated prior work and stays untouched. +- **Constraints:** `nix flake check` (cli-tests, cli-clippy, cli-fmt) and `nix run .#pkl-check-generated` must keep passing; no replacement identity mechanism is introduced. +- **Non-goal:** deleting or migrating any `/sce/checkout-id` file already on disk. SCE never touches pre-existing on-disk artifacts (the same convention already applied to legacy `agent-trace-.db` files); a stray file is simply left inert. + +## Assumptions + +- "Remove checkout id" refers to the checkout identity infrastructure in `context/cli/checkout-identity.md` / `cli/src/services/checkout/`, not the separate, already-removed `checkout registry` feature (`remove-checkout-registry` plan). +- Removing `checkout_identity` from `sce doctor --format json` output is an acceptable diagnostic-surface change; no consumer depends on that field for correctness, only for human inspection. + +## Task stack + +- [x] T01: `Remove checkout identity code and its call sites` (status:done) + - Task ID: T01 + - Scope: In — delete `cli/src/services/checkout/mod.rs` and its `pub mod checkout;` declaration in `cli/src/services/mod.rs`; remove the `checkout_id` field and the `get_or_create_checkout_id`/`resolve_git_dir` calls (plus the now-invalid `use` import) from `cli/src/services/agent_trace_storage/mod.rs`, updating or removing tests that assert on `checkout_id`; remove the `checkout_id` field from `RepositoryDatabaseSetup`, the `setup.checkout_id` usage, and the "Agent Trace checkout identity: {}" segment of the setup message in `cli/src/services/agent_trace_db/lifecycle.rs`, updating its tests; remove `CheckoutIdentityHealth`, the `checkout_identity` field on `HookDoctorReport`, `collect_checkout_identity_health`, its two call sites, and the `checkout_identity` JSON key from `cli/src/services/doctor/{types,inspect,render}.rs`. Out — repository identity resolution, Agent Trace DB schema/migrations, `sce sync`, durable context files. + - Dependencies: none + - Done when: `cli/src/services/checkout/` no longer exists; `rg -n "services::checkout|get_or_create_checkout_id|resolve_git_dir|checkout_id|CheckoutIdentityHealth" cli/src` returns no matches; `nix flake check` passes. + - Verify: `nix flake check` (cli-tests, cli-clippy, cli-fmt); `rg -n "services::checkout|get_or_create_checkout_id|resolve_git_dir|checkout_id|CheckoutIdentityHealth" cli/src` returns nothing. + - Completed: 2026-09-13 + - Files changed: `cli/src/services/mod.rs`, `cli/src/services/checkout/mod.rs` (deleted), `cli/src/services/agent_trace_storage/mod.rs`, `cli/src/services/agent_trace_db/lifecycle.rs`, `cli/src/services/doctor/types.rs`, `cli/src/services/doctor/inspect.rs`, `cli/src/services/doctor/render.rs` + - Result: Deleted `cli/src/services/checkout/` and its module declaration; removed `checkout_id`/`resolve_git_dir`/`get_or_create_checkout_id` from `agent_trace_storage::open_storage_with` (and the now-unused `context` parameter threaded through `open_storage`/`open_storage_for_hook_runtime`/`open_storage_with`, plus the resulting unused `anyhow::Context` import), the `checkout_id` field and setup-message segment in `agent_trace_db::lifecycle::RepositoryDatabaseSetup`, and `CheckoutIdentityHealth`/`collect_checkout_identity_health`/its two call sites/the `checkout_identity` JSON key in the doctor module. Updated four `agent_trace_storage` tests that asserted on `checkout_id`, renaming two whose names referenced "checkout_ids"/"checkout_id" since that behavior no longer exists. + - Verify (actual): `nix build .#checks.x86_64-linux.cli-tests .#checks.x86_64-linux.cli-clippy .#checks.x86_64-linux.cli-fmt` — all three passed (665 tests passed, 0 failed; clippy and fmt clean). `rg -n "services::checkout|get_or_create_checkout_id|resolve_git_dir|checkout_id|CheckoutIdentityHealth" cli/src` returns only `cli/src/services/agent_trace_db/repository.rs` (a pre-existing, out-of-scope schema-invariant test/comment documenting the unrelated absence of a `checkout_id` DB column — not the checkout identity service removed by this task). + - Context impact: `domain` (diagnostic-only feature removal, no architecture/ownership/terminology-boundary change). During this task's own context-synchronization pass, all ten durable context files listed under "Context sync", plus `context/sce/shared-turso-db.md` (found by sweep, not originally listed), were updated to stop describing checkout identity as active runtime behavior; see the Task context synchronization phase report for this task. T02 remains to do a final targeted review/verification pass against its own Done-when/Verify criteria. + - Assumptions/deviations: Per the reviewed assumption, the bare `rg ... cli/src` done-check text is read as excluding the noted out-of-scope `repository.rs` residual match, which is unrelated prior work (DB schema invariant) rather than a remnant of the removed checkout identity service. + - Context synchronization: synced + +- [x] T02: `Update durable context to reflect checkout identity removal` (status:done) + - Task ID: T02 + - Scope: In — `context/cli/checkout-identity.md`, `context/cli/agent-trace-storage.md`, `context/sce/agent-trace-db.md`, `context/sce/agent-trace-hook-doctor.md`, `context/cli/cli-command-surface.md`, `context/cli/service-lifecycle.md`, `context/context-map.md`, `context/glossary.md`, `context/architecture.md`, `context/overview.md`: rewrite or remove every claim that checkout identity is created, read, resolved, or reported by current runtime behavior, following the past-tense "removed" framing the `checkout registry` glossary entry already uses for the related prior removal. Out — code changes, plan files, decision records. + - Dependencies: T01 + - Done when: none of the ten files claim checkout identity is created, stored, resolved, or surfaced by current behavior; `rg -in "checkout identity|checkout_id|checkout-id" context/` shows only past-tense/removed framing. + - Verify: `rg -in "checkout identity|checkout_id|checkout-id" context/cli context/sce context/context-map.md context/glossary.md context/architecture.md context/overview.md`, reviewed by hand against the T01 code state. + - Completed: 2026-09-13 + - Files changed: `context/plans/remove-checkout-id.md` (this completion record only — no further edits to the ten scoped files were required) + - Result: Confirmed all ten scoped files (plus `context/sce/shared-turso-db.md`, updated by T01's own sweep) already carry only past-tense "removed"/"no longer" framing for checkout identity, written during T01's context-synchronization pass and committed in `b525f5e84`. Read every match returned by the scoped verify command by hand, including `context/cli/service-lifecycle.md` (one hit, already past-tense: "no global/checkout fallback path (removed by the `retire-legacy-agent-trace-db` plan)"). No residual live-behavior claim was found, so no rewrite was needed beyond T01's existing work. + - Verify (actual): `rg -in "checkout identity|checkout_id|checkout-id" context/cli context/sce context/context-map.md context/glossary.md context/architecture.md context/overview.md` — 20 matches across 8 files (`context/overview.md`, `context/architecture.md` x3, `context/glossary.md` x5, `context/context-map.md` x2, `context/cli/cli-command-surface.md`, `context/cli/checkout-identity.md` x3, `context/cli/repository-identity.md`, `context/cli/agent-trace-storage.md` x2, `context/sce/agent-trace-db.md` x3, `context/sce/agent-trace-hook-doctor.md`); every match reviewed by hand and confirmed past-tense/removed framing (e.g. "was removed by the `remove-checkout-id` plan", "no longer mention", "no longer reported"). + - Context impact: `domain` (durable-context-only verification pass; no code or architecture change). No new context impact beyond T01's own sync, which already covered these files. + - Assumptions/deviations: Per the reviewed assumption, T01's context-synchronization pass already satisfied this task's Done-when/AC3 criteria; T02 served as the planned final targeted review/verification pass rather than a fresh rewrite, and found no gap to close. + - Context synchronization: synced + +## Open questions + +None. The removal is fully precedented by the completed `remove-checkout-registry` plan and scoped to a diagnostic-only surface with no persisted-data implications. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-13 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed; includes cli-tests, cli-clippy, cli-fmt, pkl-generated, and every other repository check) +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 141 files, inventory sha256 `39fe28cef0c034871a003e31f4342e405afd75263d692bf4e3bacbd1dc970fc8`) + +### Success-criteria verification + +- [x] AC1: `sce` no longer creates or reads `/sce/checkout-id`, and no code references the removed `cli/src/services/checkout/` module -> `cli/src/services/checkout/` does not exist; `rg -n "services::checkout|get_or_create_checkout_id|resolve_git_dir|checkout_id" cli/src` returns only pre-existing, out-of-scope matches in `cli/src/services/agent_trace_db/repository.rs` (a doc-comment and test asserting the schema has *no* `checkout_id` column — documented in T01's completion record as unrelated to the removed checkout identity service). +- [x] AC2: `sce setup` and `sce doctor` (text and JSON) no longer mention checkout identity -> built `.#sce` and ran it in a scratch Git repo with a fake `origin` remote: `sce setup --non-interactive --claude` output contains no "Agent Trace checkout identity" line; `sce doctor --format json` payload contains no `checkout_identity` key. +- [x] AC3: Durable context describes checkout identity as removed, with no remaining claim that current runtime creates, reads, or reports it -> `rg -in "checkout identity|checkout_id|checkout-id" context/` matches only past-tense/removed framing across every context file (root, `context/cli/`, `context/sce/`); the `context/plans/` and `context/decisions/` matches are historical plan/decision records, not current-state claims. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. diff --git a/config/pkl/renderers/fixtures/plan-structure/valid-structured-plan.md b/config/pkl/renderers/fixtures/plan-structure/valid-structured-plan.md new file mode 100644 index 000000000..6b76a8c07 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/valid-structured-plan.md @@ -0,0 +1,47 @@ +# Plan: valid-structured-plan + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/wrong-heading-context.md b/config/pkl/renderers/fixtures/plan-structure/wrong-heading-context.md new file mode 100644 index 000000000..38f6a1d88 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/wrong-heading-context.md @@ -0,0 +1,47 @@ +# Plan: wrong-heading-context + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: AC1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-structure/wrong-reference-kind.md b/config/pkl/renderers/fixtures/plan-structure/wrong-reference-kind.md new file mode 100644 index 000000000..53ed3b84a --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-structure/wrong-reference-kind.md @@ -0,0 +1,47 @@ +# Plan: wrong-reference-kind + +## Change summary + +Add a refresh endpoint. + +## Design decisions + +- D1: The session store validates refresh tokens. + - Boundary: `src/auth/` owns token validation. + - Rationale: The store already tracks revocation. + - Rejected: Stateless tokens — they cannot be revoked. + - Trade-offs: One store read per refresh. + +## Acceptance criteria + +- [ ] AC1: `POST /auth/refresh` rejects a revoked token with 401. + - Validate: `pnpm test src/auth/refresh.test.ts` + - Decisions: D1 + - Scenarios: S1 + +### Verification scenarios + +- S1: Revoked refresh token is rejected + - Given: A revoked refresh token. + - When: The client calls `POST /auth/refresh`. + - Then: The response is 401. + - Check: `pnpm test src/auth/refresh.test.ts -t "rejects a revoked token"` + +### Full validation + +- `pnpm test` + +## Task stack + +- [ ] T01: `Add /auth/refresh endpoint` (status:todo) + - Task ID: T01 + - Covers: S1 + - Scope: In — route handler. Out — rotation policy. + - Dependencies: none + - Done when: The endpoint returns 401 for a revoked token. + - Verify: `pnpm test src/auth/refresh.test.ts` + - Context synchronization: pending + +## Open questions + +None. diff --git a/config/pkl/renderers/fixtures/plan-template-dangling-reference-check.pkl b/config/pkl/renderers/fixtures/plan-template-dangling-reference-check.pkl new file mode 100644 index 000000000..e10bab85c --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-template-dangling-reference-check.pkl @@ -0,0 +1,14 @@ +import "../generation-contract-check.pkl" as contract +import "../../base/workflow-change-to-plan.pkl" as changeToPlan + +local template = changeToPlan.changeToPlanPlanTemplate + .replaceFirst(" - Decisions: D1\n - Scenarios: S1\n", " - Decisions: D1\n - Scenarios: S2\n") + +local documents = new Mapping { + ["config/.opencode/skills/sce-change-to-plan/references/plan-template.md"] = template +} + +result = + contract.assertStructuredPlanTemplateSchema.apply(documents) + + "; " + + contract.assertPlanTemplateStructure.apply(documents) diff --git a/config/pkl/renderers/fixtures/plan-template-keywords-in-examples-check.pkl b/config/pkl/renderers/fixtures/plan-template-keywords-in-examples-check.pkl new file mode 100644 index 000000000..0b8ffa0fb --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-template-keywords-in-examples-check.pkl @@ -0,0 +1,14 @@ +import "../generation-contract-check.pkl" as contract +import "../../base/workflow-change-to-plan.pkl" as changeToPlan + +local template = changeToPlan.changeToPlanPlanTemplate + .replaceFirst("\n## Reference rules\n", "\n```markdown\n## Reference rules\n```\n") + +local documents = new Mapping { + ["config/.opencode/skills/sce-change-to-plan/references/plan-template.md"] = template +} + +result = + contract.assertStructuredPlanTemplateSchema.apply(documents) + + "; " + + contract.assertPlanTemplateStructure.apply(documents) diff --git a/config/pkl/renderers/fixtures/plan-template-misplaced-field-check.pkl b/config/pkl/renderers/fixtures/plan-template-misplaced-field-check.pkl new file mode 100644 index 000000000..c49e353f9 --- /dev/null +++ b/config/pkl/renderers/fixtures/plan-template-misplaced-field-check.pkl @@ -0,0 +1,15 @@ +import "../generation-contract-check.pkl" as contract +import "../../base/workflow-change-to-plan.pkl" as changeToPlan + +local template = changeToPlan.changeToPlanPlanTemplate + .replaceFirst(" - Task ID: T01\n - Covers: AC1, AC2\n", " - Task ID: T01\n") + .replaceLast(" - Scenarios: none (inspection)\n", " - Scenarios: none (inspection)\n - Covers: AC1, AC2\n") + +local documents = new Mapping { + ["config/.opencode/skills/sce-change-to-plan/references/plan-template.md"] = template +} + +result = + contract.assertStructuredPlanTemplateSchema.apply(documents) + + "; " + + contract.assertPlanTemplateStructure.apply(documents) diff --git a/config/pkl/renderers/fixtures/structured-plan-template-schema-check.pkl b/config/pkl/renderers/fixtures/structured-plan-template-schema-check.pkl new file mode 100644 index 000000000..0fd0aae44 --- /dev/null +++ b/config/pkl/renderers/fixtures/structured-plan-template-schema-check.pkl @@ -0,0 +1,11 @@ +import "../generation-contract-check.pkl" as contract + +local documents = new Mapping { + ["config/.opencode/skills/sce-change-to-plan/references/plan-template.md"] = + "## Design decisions\n\n## Acceptance criteria\n\n - Validate: `check`\n\n## Task stack\n\n - Task ID: T01" +} + +result = if ( + contract.assertStructuredPlanTemplateSchema.apply(documents) +) "negative fixture unexpectedly accepted a plan template without verification scenarios or coverage fields" +else throw("generated plan-template.md must define the Design decisions section, the Decisions:, Scenarios:, and Covers: reference fields, the Verification scenarios section, and the Design decision, Verification scenario, and Reference rules") diff --git a/config/pkl/renderers/fixtures/validate-scenario-evidence-check.pkl b/config/pkl/renderers/fixtures/validate-scenario-evidence-check.pkl new file mode 100644 index 000000000..434292d9b --- /dev/null +++ b/config/pkl/renderers/fixtures/validate-scenario-evidence-check.pkl @@ -0,0 +1,11 @@ +import "../generation-contract-check.pkl" as contract + +local documents = new Mapping { + ["config/.opencode/skills/sce-validate/references/validation.md"] = + "## 1.4 Run full validation and acceptance checks\n\nRun the plan's `Full validation` commands." +} + +result = if ( + contract.assertValidateScenarioEvidence.apply(documents) +) "negative fixture unexpectedly accepted validation.md without relationship integrity or scenario evidence rules" +else throw("generated sce-validate validation.md must fail on relationship-integrity defects, run every scenario check itself with exactly one of Verified, Partially verified, or Not verified, check an acceptance criterion only when its Validate: check passes and all its scenarios are Verified, exclude task completion and plan claims as evidence, run sce plan check before scenario verification with the checker trust rule and manual Reference rules fallback, and keep sce plan check out of every sce-next-task document") diff --git a/config/pkl/renderers/fixtures/validation-report-scenario-section-check.pkl b/config/pkl/renderers/fixtures/validation-report-scenario-section-check.pkl new file mode 100644 index 000000000..adefb9dce --- /dev/null +++ b/config/pkl/renderers/fixtures/validation-report-scenario-section-check.pkl @@ -0,0 +1,13 @@ +import "../generation-contract-check.pkl" as contract + +local documents = new Mapping { + ["config/.opencode/skills/sce-validate/references/validation-report.md"] = + "## Validation Report\n\n### Success-criteria verification\n\n- [x] AC1: {criterion statement} -> {evidence}" + ["config/.opencode/skills/sce-validate/references/validation.md"] = + "# Validation report\n\n## Acceptance criteria\n\n- [x] AC1: {criterion statement} — {evidence}" +} + +result = if ( + contract.assertValidationReportScenarioSection.apply(documents) +) "negative fixture unexpectedly accepted validation reports without a scenario verification section" +else throw("generated sce-validate validation-report.md and validation.md must carry a scenario verification section mapping each scenario to its acceptance criteria, evidence kind, exact evidence, actual result, and status, cite scenario IDs on acceptance-criterion lines, and list unverified scenarios under What failed") diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 1e8edebf9..fc85c132b 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -5,6 +5,7 @@ import "claude-content.pkl" as claude import "pi-content.pkl" as pi import "codex-content.pkl" as codex import "codex-metadata.pkl" as codexMetadata +import "plan-structure.pkl" as planStructure /// Build the expected inventory from each target's independently checked /// document inventory plus the retained non-workflow assets. This deliberately @@ -53,6 +54,7 @@ hidden expectedArtifactPaths = new Mapping { ["config/schema/sce-config.schema.json"] = true ["config/optional-workflows.json"] = true + ["config/plan-init-scaffold.md"] = true } hidden generatedArtifacts = new Mapping { @@ -520,10 +522,10 @@ local forbiddenWorkflowReferenceTokens = new Listing { /// package and retained non-workflow assets, across OpenCode, Claude, Pi, and /// Codex, plus Codex's `.codex/hooks.json`, its install-guidance hook script, /// and its six per-workflow `agents/openai.yaml` files (one per catalog -/// workflow, excluding `sce-decision`). Stating the total as a literal makes -/// an unintended inventory change fail here instead of silently becoming the -/// new expectation. -local expectedArtifactPathCount = 145 +/// workflow, excluding `sce-decision`), plus the plan init scaffold. Stating +/// the total as a literal makes an unintended inventory change fail here +/// instead of silently becoming the new expectation. +local expectedArtifactPathCount = 146 local assertExactArtifactPaths = (actual: Mapping) -> if ( @@ -1001,6 +1003,73 @@ hidden assertCompactPlanTemplateSchema = (documents: Mapping) -> ) "generated plan template: compact task/completion schema present, legacy fields absent" else throw("generated plan-template.md must use the compact task and completion schema and must not reference the removed Goal, Boundaries (in/out of scope), Verification notes, Implementation evidence, Verification evidence, or Context synchronization handoff fields") +local requiredStructuredPlanTemplateTokens = new Listing { + "## Design decisions" + " - Boundary: " + " - Rationale: " + " - Rejected: " + " - Trade-offs: " + " - Decisions: " + " - Scenarios: " + "### Verification scenarios" + " - Given: " + " - When: " + " - Then: " + " - Check: " + " - Covers: " + "## Design decision rules" + "## Verification scenario rules" + "## Reference rules" +} + +hidden assertStructuredPlanTemplateSchema = (documents: Mapping) -> + if ( + documents.every((path, text) -> + !path.endsWith("/skills/sce-change-to-plan/references/plan-template.md") + || requiredStructuredPlanTemplateTokens.every((token) -> text.contains(token)) + ) + ) "generated plan template: design decisions, verification scenarios, coverage fields, and reference rules present" + else throw("generated plan-template.md must define the Design decisions section, the Decisions:, Scenarios:, and Covers: reference fields, the Verification scenarios section, and the Design decision, Verification scenario, and Reference rules") + +hidden assertPlanTemplateStructure = (documents: Mapping) -> + let (defects = documents + .fold(List(), (acc, path, text) -> + if (!path.endsWith("/skills/sce-change-to-plan/references/plan-template.md")) acc + else acc + planStructure.templateDefects(text).map((defect) -> "\(path): \(defect)"))) + if ( + defects.isEmpty + && documents.any((path, _) -> path.endsWith("/skills/sce-change-to-plan/references/plan-template.md")) + ) "generated plan template: headings, definitions, fields, and references are structurally valid outside fenced examples" + else throw("generated plan-template.md failed the structural reference-integrity check: " + defects.join("; ")) + +local planInitScaffoldPath = "config/plan-init-scaffold.md" + +local planInitScaffoldCheckDefects = List( + "`Scenarios:` on `AC1` is not a comma-separated ID list", + "`Covers:` on `T01` is not a comma-separated ID list", + "`AC1` is not covered by any task's `Covers:`" +) + +hidden assertPlanInitScaffold = (artifacts: Mapping) -> + let (text = artifacts.getOrNull(planInitScaffoldPath) ?? "") + let (checkDefects = planStructure.check(text).defects) + let (definedKinds = planStructure.entries(planStructure.annotate(text)).map((entry) -> entry.kind)) + if ( + artifacts.containsKey(planInitScaffoldPath) + && text.startsWith("# Plan: {plan-name}\n") + && text.split("{plan-name}").length == 2 + && planStructure.skeletonDefects(text).isEmpty + && checkDefects.length == planInitScaffoldCheckDefects.length + && checkDefects.toSet() == planInitScaffoldCheckDefects.toSet() + && !definedKinds.contains("D") + && !definedKinds.contains("S") + && !text.contains("```") + && !text.contains("## Template") + && !text.contains("Filled-in") + && !text.contains(" rules") + ) "plan init scaffold: skeleton headings, single plan-name token, and exact untouched check defects pinned" + else throw("generated \(planInitScaffoldPath) must pass plan-template skeleton mode, carry `{plan-name}` exactly once, define no D or S entry, omit authoring rules and examples, and fail check with exactly: " + planInitScaffoldCheckDefects.join("; ") + "; skeleton defects: " + planStructure.skeletonDefects(text).join("; ") + "; check defects: " + checkDefects.join("; ")) + local planTemplateOwnedPolicyTokens = new Listing { "Acceptance criteria describe the finished system, not the work." "Every criterion carries a `Validate:` line." @@ -1010,11 +1079,23 @@ local planTemplateOwnedPolicyTokens = new Listing { "Split any candidate task that would require multiple independent commits" "The last task in the stack is an ordinary implementation task." "Preserve completed tasks, their `(status:done)` markers" + "Record a design decision only for a real architectural choice the plan" + "`Then:` states an exact observable result or invariant, not an intention." + "Read plan structure only from Markdown outside fenced code blocks." + "A section runs from its heading to the next heading of any level outside" + "To check a fenced example itself, extract the block's content and apply these" + "Never renumber or reuse a decision, criterion, or scenario ID" + "Every task carries `Covers:` naming at least one" + "Every acceptance criterion is named by at least one task's `Covers:`." + "A planned `Check:` is not execution evidence." } local requiredPlanAuthoringTemplateReferences = new Listing { "`references/plan-template.md` is the sole owner of the persisted plan schema" "`Acceptance criteria rules`" + "`Design decision rules`" + "`Verification scenario rules`" + "`Reference rules`" "`Task rules` and `No validation task` rules" "`Updating an existing plan` rules" } @@ -1032,6 +1113,132 @@ hidden assertPlanTemplatePolicyOwnership = (documents: Mapping) -> ) "sce-change-to-plan: plan-template owns persisted schema and generic authoring policy" else throw("sce-change-to-plan plan-template.md must own the persisted plan schema and generic acceptance/task/update rules; plan-authoring.md may reference those rules but must not restate them") +local requiredPlanAuthoringStructuredTokens = new Listing { + "Author plan content in this order: design decisions, then acceptance criteria,\nthen verification scenarios, then tasks." + "## 2.5 Record design decisions" + "Do not\nmanufacture a decision to fill the section." + "## 2.7 Author the verification scenarios" + "Do not invent test names" + "Give each task its `Covers:` field" + "## 2.9 Check references" + "run a reference-consistency pass over the\ncomplete draft that applies every `Reference rules` entry" + "run `sce plan check {plan-path}`." + "`structured plan: reference integrity holds`" +} + +local noNextTaskPlanCheckReference = (documents: Mapping) -> + documents.every((path, text) -> !path.contains("/skills/sce-next-task/") || !text.contains("sce plan check")) + +hidden assertPlanAuthoringStructuredReferences = (documents: Mapping) -> + if ( + documents.every((path, text) -> + !path.endsWith("/skills/sce-change-to-plan/references/plan-authoring.md") + || requiredPlanAuthoringStructuredTokens.every((token) -> text.contains(token)) + ) + && noNextTaskPlanCheckReference.apply(documents) + ) "sce-change-to-plan plan-authoring: decisions, criteria, scenarios, and tasks authored in order with a reference-consistency pass" + else throw("generated plan-authoring.md must author design decisions, acceptance criteria, verification scenarios, and tasks in that order, forbid manufactured decisions and invented test names, add Covers:, and run a reference-consistency pass before writing, run sce plan check after writing the plan, and keep sce plan check out of every sce-next-task document") + +local planInitSuccessCondition = "Initialization succeeds only when all three\nhold: exit `0`, stdout contains `Created plan: context/plans/{plan_name}.md`,\nand that file exists." + +local planCheckSuccessCondition = "It passes\nonly on exit `0` with `structured plan: reference integrity holds` on stdout." + +local planCompletenessGate = "### Completeness gate" + +local planReadyPermission = "Set internal status `plan_ready` only after both the completeness gate and\n`sce plan check` pass." + +local requiredPlanAuthoringPlanInitTokens = new Listing { + "Structured plans are the only supported format. This step has no fallback for\n`sce plan init` or `sce plan check`." + "### New plan\n\nRun `sce plan init {plan_name}`." + planInitSuccessCondition + "Every other result returns `blocked` with the exit code\nand stderr." + "a missing `sce` executable, a binary without `init`,\na nonzero exit, an exit `0` without the expected line, and an expected line\nwith no created file." + "never a substitute for `sce plan init`. Never write a new\nplan without it." + "### Existing plan\n\nDo not run `sce plan init`." + "`Updating an existing plan` rules in `references/plan-template.md`, which\npreserve completed tasks and their evidence." + planCompletenessGate + "the plan contains no scaffold `TODO`\nplaceholder, and optional sections without real entries (`Design decisions`,\n`Assumptions`) are removed." + "The change summary, acceptance criteria, design\ndecisions, verification scenarios, task definitions, and verify instructions\nare authored for this change." + "While any placeholder remains, keep authoring or return `blocked`." + "`sce plan check` is required but not sufficient" + "### Structural check\n\nFor new plans and revisions alike, run `sce plan check {plan-path}`." + planCheckSuccessCondition + "On exit `3` with structural diagnostics, fix each reported defect in the plan\nand run it again, or return `blocked` with the remaining diagnostics" + "a binary without `check`, any\nother exit code, and an exit `0` without the success marker." + planReadyPermission + "It never substitutes for a\nsuccessful `sce plan check` in step 2.10." +} + +local forbiddenPlanAuthoringFallbackTokens = new Listing { + "manual" + "Manual" + "checker is unavailable" + "older `sce`" + "`plan` help" + "plan --help" + "Usage: plan" +} + +local hasPlanAuthoringPlanInitContract = (text: String) -> + requiredPlanAuthoringPlanInitTokens.every((token) -> text.contains(token)) + && forbiddenPlanAuthoringFallbackTokens.every((token) -> !text.contains(token)) + && text.indexOf("## 2.3 Run the clarification gate") < text.indexOf("`sce plan init") + && text.indexOf(planCompletenessGate) < text.indexOf(planReadyPermission) + && text.indexOf(planCheckSuccessCondition) < text.indexOf(planReadyPermission) + +local noPlanInitOutsideChangeToPlan = (documents: Mapping) -> + documents.every((path, text) -> + !(path.contains("/skills/sce-next-task/") || path.contains("/skills/sce-validate/")) + || !text.contains("sce plan init") + ) + +local completenessGateSection = (text: String) -> + let (tail = text.drop(text.indexOf(planCompletenessGate))) + tail.take(tail.indexOf("### Structural check")) + +local planAuthoringPlanInitFixtures = (text: String) -> + let (gate = completenessGateSection.apply(text)) + List( + text.replaceFirst(gate, ""), + text.replaceFirst(gate, "").replaceFirst(planReadyPermission, planReadyPermission + "\n\n" + gate), + text + "\nWhen `sce plan init` is unavailable or fails, author the new plan manually from\n`references/plan-template.md`.\n", + text.replaceFirst(planInitSuccessCondition, "Initialization succeeds on exit `0`."), + text + "\nAny other outcome means the checker is unavailable; apply the\nmanual `Reference rules` pass from step 2.9 to the written plan instead.\n", + text.replaceFirst(planCheckSuccessCondition, "It passes\non exit `0`.") + ) + +hidden assertPlanAuthoringPlanInit = (documents: Mapping) -> + let (authoring = documents.toMap().filter((path, _) -> path.endsWith("/skills/sce-change-to-plan/references/plan-authoring.md"))) + if ( + authoring.length == generatedTargetRoots.length + && authoring.every((_, text) -> + hasPlanAuthoringPlanInitContract.apply(text) + && planAuthoringPlanInitFixtures.apply(text).every((fixture) -> !hasPlanAuthoringPlanInitContract.apply(fixture)) + ) + && noPlanInitOutsideChangeToPlan.apply(documents) + ) "sce-change-to-plan plan-authoring: sce plan init for new plans only, completeness gate and passing sce plan check before plan_ready, no fallback; negative fixtures rejected" + else throw("generated plan-authoring.md must run sce plan init for new plans after the clarification gate with the exact success condition, skip init for revisions, block on every other init or check result, gate plan_ready on the completeness gate and a check exit 0 with the success marker, and contain no legacy-binary detection or manual initialization or validation fallback; every negative fixture must be rejected; no sce-next-task or sce-validate document may mention sce plan init") + +local requiredNextTaskStructuredReferenceTokens = new Listing { + Pair("/skills/sce-next-task/references/plan-review.md", "Read plan structure only outside fenced code blocks;") + Pair("/skills/sce-next-task/references/plan-review.md", "A definition counts only\nin its own section:") + Pair("/skills/sce-next-task/references/plan-review.md", "Record it as a review risk;\nit does not make the task `blocked`.") + Pair("/skills/sce-next-task/references/plan-review.md", "A `ready` result also identifies:") + Pair("/skills/sce-next-task/references/plan-review.md", "each scenario ID with its `Then:` and `Check:`.") + Pair("/skills/sce-next-task/references/output.md", "## Acceptance coverage\n\n- List each covered acceptance criterion") + Pair("/skills/sce-next-task/references/task-execution.md", "Task execution never checks an acceptance-criterion\ncheckbox and never records a scenario status") + Pair("/skills/sce-next-task/references/task-execution.md", "- Keep the task's `Covers:` field as authored.") +} + +hidden assertNextTaskStructuredReferences = (documents: Mapping) -> + if ( + requiredNextTaskStructuredReferenceTokens.every((entry) -> + documents.any((path, _) -> path.endsWith(entry.first)) + && documents.every((path, text) -> !path.endsWith(entry.first) || text.contains(entry.second)) + ) + ) "sce-next-task structured references: review, gate, and execution consume coverage" + else throw("generated sce-next-task plan-review.md, output.md, and task-execution.md must carry covered acceptance criteria, scenarios, and decisions, treat dangling references as review risks, and never check an acceptance criterion or record a scenario status during task execution") + local requiredNextTaskCompletionWritingTokens = new Listing { "Record directly on the completed task:" "`Files changed`" @@ -1243,7 +1450,70 @@ hidden assertValidateExcludesDecisionAndPlanSync = (documents: Mapping) -> else throw("generated sce-validate document must not contain a sce-decision reference or plan-context-sync wording") -hidden assertWorkflowLanguageConsistency = (artifacts: Mapping) -> +local requiredValidateScenarioEvidenceTokens = new Listing { + "Read plan structure only outside fenced code blocks;" + "A definition counts only\nin its own section:" + "`Reference rules` to the plan." + "- An ID defined more than once." + "- A dangling reference:" + "- An acceptance criterion without `Scenarios:`." + "- An acceptance criterion that no task's `Covers:` names." + "- A scenario that no acceptance criterion's `Scenarios:` references." + "A relationship-integrity defect makes the result\n`failed`; never tolerate one silently." + "Run or perform every scenario's `Check:` yourself" + "- `Verified` when" + "- `Partially verified` when" + "- `Not verified` when" + "Only evidence gathered by this run counts. Task completion, a task's recorded\n`Verify` outcomes, a planned `Check:` line, and the plan's own claims are never\nevidence." + "An acceptance criterion is met only when its `Validate:`\ncheck passes and every scenario its `Scenarios:` names is `Verified`." + "Run `sce plan check {plan-path}` before scenario\nverification." + "Record each reported diagnostic as a failed check" + "Trust the checker only when it actually ran:" + "`structured plan: reference integrity holds`" + "Any other outcome means the checker is unavailable;" + "apply the manual pass below instead." +} + +hidden assertValidateScenarioEvidence = (documents: Mapping) -> + if ( + documents.any((path, _) -> path.endsWith("/skills/sce-validate/references/validation.md")) + && documents.every((path, text) -> + !path.endsWith("/skills/sce-validate/references/validation.md") + || requiredValidateScenarioEvidenceTokens.every((token) -> text.contains(token)) + ) + && noNextTaskPlanCheckReference.apply(documents) + ) "sce-validate validation: plans get relationship integrity and scenario evidence" + else throw("generated sce-validate validation.md must fail on relationship-integrity defects, run every scenario check itself with exactly one of Verified, Partially verified, or Not verified, check an acceptance criterion only when its Validate: check passes and all its scenarios are Verified, exclude task completion and plan claims as evidence, run sce plan check before scenario verification with the checker trust rule and manual Reference rules fallback, and keep sce plan check out of every sce-next-task document") + +local requiredValidationReportScenarioTokens = new Listing { + "### Scenario verification" + "- S1 (AC1): {test | command | inspection} `{exact test, command, or inspected location}` -> {actual result}; status: {Verified | Partially verified | Not verified}" + "; scenarios: S1" + "- Include **Scenario verification** with exactly one entry per scenario in\n plan order." +} + +local requiredValidationResultScenarioTokens = new Listing { + "## Scenario verification" + "; status: Verified" + "- `{scenario id}` ({AC ids}): {Partially verified | Not verified}" + "In the failed variant, list\n every `Partially verified` or `Not verified` scenario under **What failed**." +} + +hidden assertValidationReportScenarioSection = (documents: Mapping) -> + if ( + documents.any((path, _) -> path.endsWith("/skills/sce-validate/references/validation-report.md")) + && documents.any((path, _) -> path.endsWith("/skills/sce-validate/references/validation.md")) + && documents.every((path, text) -> + if (path.endsWith("/skills/sce-validate/references/validation-report.md")) + requiredValidationReportScenarioTokens.every((token) -> text.contains(token)) + else if (path.endsWith("/skills/sce-validate/references/validation.md")) + requiredValidationResultScenarioTokens.every((token) -> text.contains(token)) + else true + ) + ) "sce-validate reports: scenario verification section present" + else throw("generated sce-validate validation-report.md and validation.md must carry a scenario verification section mapping each scenario to its acceptance criteria, evidence kind, exact evidence, actual result, and status, cite scenario IDs on acceptance-criterion lines, and list unverified scenarios under What failed") + +hidden assertWorkflowLanguageConsistency =(artifacts: Mapping) -> let (targets = generatedTargetRoots) if ( targets.every((target) -> @@ -1331,7 +1601,13 @@ contractChecks { ["no-stale-sync-debt-text"] = assertNoStaleSyncDebtText.apply(generatedArtifacts) ["plan-review-sync-debt-recovery"] = assertPlanReviewSyncDebtRecovery.apply(workflowDocuments) ["compact-plan-template-schema"] = assertCompactPlanTemplateSchema.apply(workflowDocuments) + ["structured-plan-template-schema"] = assertStructuredPlanTemplateSchema.apply(workflowDocuments) + ["plan-template-structure"] = assertPlanTemplateStructure.apply(workflowDocuments) + ["plan-init-scaffold"] = assertPlanInitScaffold.apply(generatedArtifacts) ["plan-template-policy-ownership"] = assertPlanTemplatePolicyOwnership.apply(workflowDocuments) + ["plan-authoring-structured-references"] = assertPlanAuthoringStructuredReferences.apply(workflowDocuments) + ["plan-authoring-plan-init"] = assertPlanAuthoringPlanInit.apply(workflowDocuments) + ["next-task-structured-references"] = assertNextTaskStructuredReferences.apply(workflowDocuments) ["next-task-compact-completion-writing"] = assertNextTaskCompactCompletionWriting.apply(workflowDocuments) ["plan-review-reads-completed-record"] = assertPlanReviewReadsCompletedRecord.apply(workflowDocuments) ["context-sync-validates-task-record"] = assertContextSyncValidatesTaskRecord.apply(workflowDocuments) @@ -1342,4 +1618,6 @@ contractChecks { ["validation-report-policy-ownership"] = assertValidationReportPolicyOwnership.apply(workflowDocuments) ["validation-observational"] = assertValidationIsObservational.apply(workflowDocuments) ["validate-decision-sync-boundary"] = assertValidateExcludesDecisionAndPlanSync.apply(workflowDocuments) + ["validate-scenario-evidence"] = assertValidateScenarioEvidence.apply(workflowDocuments) + ["validation-report-scenario-section"] = assertValidationReportScenarioSection.apply(workflowDocuments) } diff --git a/config/pkl/renderers/plan-structure-check.pkl b/config/pkl/renderers/plan-structure-check.pkl new file mode 100644 index 000000000..c67831542 --- /dev/null +++ b/config/pkl/renderers/plan-structure-check.pkl @@ -0,0 +1,18 @@ +import "plan-structure.pkl" as planStructure + +local planPath = read("prop:planPath") +local reports = read*("file:\(planPath)").toMap().mapValues((_, plan) -> planStructure.check(plan.text)) +local failures = reports.filter((_, report) -> !report.defects.isEmpty) + +result = + if (reports.isEmpty) throw("no plan matched `\(planPath)`") + else if (!failures.isEmpty) + throw( + "plan failed the structural reference-integrity check: " + + failures + .map((uri, report) -> Pair(uri, "\(uri.drop("file:".length)): \(report.defects.join("; "))")) + .values + .join(" | ") + ) + else if (reports.length > 1) "\(reports.length) plans checked: reference integrity holds" + else "structured plan: reference integrity holds" diff --git a/config/pkl/renderers/plan-structure.pkl b/config/pkl/renderers/plan-structure.pkl new file mode 100644 index 000000000..080c37f12 --- /dev/null +++ b/config/pkl/renderers/plan-structure.pkl @@ -0,0 +1,388 @@ +class PlanLine { + text: String + fenced: Boolean + openFence: String + section: String +} + +class PlanField { + key: String + value: String + continued: List +} + +class PlanEntry { + id: String + kind: String + fields: List +} + +class PlanReport { + defects: List +} + +local idPattern = #"(?:D[1-9][0-9]*|AC[1-9][0-9]*|S[1-9][0-9]*|T[0-9]{2,})"# +local fencePattern = Regex(#"\s*(`{3,}|~{3,})(.*)"#) +local headingPattern = Regex(#"#{1,6}\s+\S.*"#) +local definitionPattern = Regex(#"( *)-\s+(?:\[[ x]\]\s+)?("# + idPattern + #"):(?:\s.*)?"#) +local fieldPattern = Regex(#"( *)-\s+([A-Z][A-Za-z -]*):(?:\s+(.*))?"#) +local idListPattern = Regex(idPattern + "(?:, " + idPattern + ")*") +local blockStartPattern = Regex(#"\s*(?:[-*+](?:\s.*)?|[0-9]+[.)](?:\s.*)?|>.*|#{1,6}(?:\s.*)?)"#) + +local inspectionOnly = "none (inspection)" + +local reviewKey = "Review" +local reviewQuestionKey = "Review question" +local reviewRequired = "required" + +local authoritativeSections = Map( + "D", "## Design decisions", + "AC", "## Acceptance criteria", + "S", "### Verification scenarios", + "T", "## Task stack" +) + +local kindNames = Map( + "D", "a design decision", + "AC", "an acceptance criterion", + "S", "a verification scenario", + "T", "a task" +) + +local fieldOwners = Map( + "Boundary", "D", + "Rationale", "D", + "Rejected", "D", + "Trade-offs", "D", + "Review", "D", + "Review question", "D", + "Validate", "AC", + "Decisions", "AC", + "Scenarios", "AC", + "Given", "S", + "When", "S", + "Then", "S", + "Check", "S", + "Covers", "T" +) + +local requiredFields = Map( + "D", List("Boundary", "Rationale", "Rejected", "Trade-offs"), + "AC", List("Validate", "Scenarios"), + "S", List("Given", "When", "Then", "Check"), + "T", List("Covers") +) + +local referenceKinds = Map( + "Decisions", "D", + "Scenarios", "S", + "Covers", "AC" +) + +local requiredPlanHeadings = List("## Acceptance criteria", "## Task stack") + +local orderedPlanHeadings = List( + "## Design decisions", + "## Acceptance criteria", + "### Verification scenarios", + "## Task stack" +) + +local templateSkeletonHeadings = List( + "## Change summary", + "## Design decisions", + "## Acceptance criteria", + "### Verification scenarios", + "### Full validation", + "### Context sync", + "## Task context synchronization lifecycle", + "## Constraints and non-goals", + "## Assumptions", + "## Task stack", + "## Open questions" +) + +local templateDocumentHeadings = List( + "## Template", + "## Filled-in structured plan example", + "## Design decision rules", + "## Verification scenario rules", + "## Reference rules" +) + +local function kindOf(id: String): String = if (id.startsWith("AC")) "AC" else id.take(1) + +local function indentOf(line: String): Int = line.length - line.trimStart().length + +local function closesFence(opener: String, marker: String, rest: String): Boolean = + marker.take(1) == opener.take(1) && marker.length >= opener.length && rest.isBlank + +function annotate(text: String): List = + text.split("\n").fold(List(), (acc: List, raw: String) -> + let (openMarker = acc.lastOrNull?.openFence ?? "") + let (currentSection = acc.lastOrNull?.section ?? "") + let (fence = fencePattern.matchEntire(raw)) + acc.add( + if (openMarker != "") + new PlanLine { + text = raw + fenced = true + openFence = + if (fence != null && closesFence(openMarker, fence.groups[1]!!.value, fence.groups[2]!!.value)) "" + else openMarker + section = currentSection + } + else if (fence != null) + new PlanLine { + text = raw + fenced = true + openFence = fence.groups[1]!!.value + section = currentSection + } + else + new PlanLine { + text = raw + fenced = false + openFence = "" + section = if (headingPattern.matchEntire(raw) != null) raw.trim() else currentSection + } + ) + ) + +function headingsOf(planLines: List): List = + planLines + .filter((line) -> !line.fenced && headingPattern.matchEntire(line.text) != null) + .map((line) -> line.text.trim()) + +local function endsEntry(line: PlanLine, indent: Int): Boolean = + !line.fenced && !line.text.isBlank && indentOf(line.text) <= indent + +local function continuationOf(following: List): List = + following + .takeWhile((next) -> + !next.fenced && !next.text.isBlank && blockStartPattern.matchEntire(next.text) == null) + .map((next) -> next.text.trim()) + +function entries(planLines: List): List = + planLines + .mapIndexed((index, line) -> + let (match = if (line.fenced) null else definitionPattern.matchEntire(line.text)) + if (match == null) null + else + let (entryId = match.groups[2]!!.value) + let (entryKind = kindOf(entryId)) + let (indent = match.groups[1]!!.value.length) + if (line.section != authoritativeSections[entryKind]) null + else + new PlanEntry { + id = entryId + kind = entryKind + fields = + let (body = planLines.drop(index + 1)) + body + .takeWhile((next) -> !endsEntry(next, indent)) + .mapIndexed((offset, next) -> + let (field = if (next.fenced) null else fieldPattern.matchEntire(next.text)) + if (field == null || field.groups[1]!!.value.length != indent + 2) null + else + new PlanField { + key = field.groups[2]!!.value + value = field.groups[3]?.value?.trim() ?? "" + continued = continuationOf(body.drop(offset + 1)) + } + ) + .filterNonNull() + } + ) + .filterNonNull() + +local function fieldOf(entry: PlanEntry, key: String): PlanField? = + entry.fields.findOrNull((field) -> field.key == key) + +local function fieldText(field: PlanField): String = + (List(field.value) + field.continued).join("\n").trim() + +local function reviewDefects(entry: PlanEntry): List = + let (reviews = entry.fields.filter((field) -> field.key == reviewKey)) + let (questions = entry.fields.filter((field) -> field.key == reviewQuestionKey)) + List(Pair(reviewKey, reviews), Pair(reviewQuestionKey, questions)) + .filter((pair) -> pair.second.length > 1) + .map((pair) -> "`\(pair.first):` appears more than once on `\(entry.id)`") + + ( + if (!reviews.isEmpty && questions.isEmpty) + List("`\(entry.id)` has `\(reviewKey):` without `\(reviewQuestionKey):`") + else List() + ) + + ( + if (!questions.isEmpty && reviews.isEmpty) + List("`\(entry.id)` has `\(reviewQuestionKey):` without `\(reviewKey):`") + else List() + ) + + reviews + .filter((field) -> fieldText(field) != reviewRequired) + .map((field) -> "`\(reviewKey):` on `\(entry.id)` must be `\(reviewRequired)`") + + questions + .map((field) -> fieldText(field)) + .filter((text) -> text.isEmpty || text.contains("\n")) + .map((text) -> + if (text.isEmpty) "`\(reviewQuestionKey):` on `\(entry.id)` is empty" + else "`\(reviewQuestionKey):` on `\(entry.id)` must be a single line") + +local function listedIds(value: String): List = + if (idListPattern.matchEntire(value) == null) List() else value.split(", ") + +local function orderDefects(headings: List, ordered: List): List = + let (present = ordered.filter((heading) -> headings.contains(heading))) + present + .drop(1) + .mapIndexed((index, heading) -> Pair(present[index], heading)) + .filter((pair) -> headings.indexOf(pair.first) > headings.indexOf(pair.second)) + .map((pair) -> "heading `\(pair.second)` must come after `\(pair.first)`") + +local function headingDefects(headings: List, required: List, ordered: List): List = + required.filter((heading) -> !headings.contains(heading)).map((heading) -> "heading `\(heading)` is missing") + + ordered + .filter((heading) -> headings.count((other) -> other == heading) > 1) + .map((heading) -> "heading `\(heading)` appears more than once") + + orderDefects(headings, ordered) + + ( + if (!headings.contains("### Verification scenarios")) List() + else + let (parent = headings + .take(headings.indexOf("### Verification scenarios")) + .findLastOrNull((heading) -> heading.startsWith("# ") || heading.startsWith("## "))) + if (parent == "## Acceptance criteria") List() + else List("heading `### Verification scenarios` is not inside `## Acceptance criteria`") + ) + +local function definitionDefects(defined: List): List = + let (ids = defined.map((entry) -> entry.id)) + ids + .filter((id) -> ids.count((other) -> other == id) > 1) + .distinct + .map((id) -> "`\(id)` is defined more than once") + + defined.flatMap((entry) -> + entry.fields + .filter((field) -> fieldOwners.containsKey(field.key) && fieldOwners[field.key] != entry.kind) + .map((field) -> "`\(field.key):` on `\(entry.id)` is misplaced; it belongs to \(kindNames[fieldOwners[field.key]])") + + requiredFields[entry.kind] + .filter((key) -> fieldOf(entry, key) == null) + .map((key) -> "`\(entry.id)` is missing `\(key):`") + + (if (entry.kind == "D") reviewDefects(entry) else List()) + ) + +local function misplacedDefinitionDefects(planLines: List): List = + planLines + .map((line) -> if (line.fenced) null else definitionPattern.matchEntire(line.text)) + .zip(planLines) + .filter((pair) -> + pair.first != null + && pair.first.groups[1]!!.value.isEmpty + && authoritativeSections.values.contains(pair.second.section) + && pair.second.section != authoritativeSections[kindOf(pair.first.groups[2]!!.value)]) + .map((pair) -> + let (id = pair.first!!.groups[2]!!.value) + "`\(id)` is defined under `\(pair.second.section)`; it belongs under `\(authoritativeSections[kindOf(id)])`") + +local function undefinedHint(planLines: List, id: String): String = + let (candidates = planLines.filter((line) -> + let (match = definitionPattern.matchEntire(line.text)) + match != null && match.groups[2]!!.value == id)) + if (candidates.any((line) -> !line.fenced)) "; it appears only outside `\(authoritativeSections[kindOf(id)])`" + else if (candidates.any((line) -> line.fenced)) "; it appears only inside a fenced code block" + else "" + +local function referenceDefects(planLines: List, defined: List): List = + let (ids = defined.map((entry) -> entry.id)) + let (references = defined.flatMap((entry) -> + entry.fields + .filter((field) -> referenceKinds.containsKey(field.key) && fieldOwners[field.key] == entry.kind) + .map((field) -> Pair(entry.id, field)))) + let (coveredIds = references + .filter((reference) -> reference.second.key == "Covers") + .flatMap((reference) -> listedIds(reference.second.value))) + let (scenarioIds = references + .filter((reference) -> reference.second.key == "Scenarios") + .flatMap((reference) -> listedIds(reference.second.value))) + references.flatMap((reference) -> + let (owner = reference.first) + let (field = reference.second) + if (field.key == "Scenarios" && field.value == inspectionOnly) List() + else if (idListPattern.matchEntire(field.value) == null) + List("`\(field.key):` on `\(owner)` is not a comma-separated ID list") + else + field.value.split(", ").flatMap((id) -> + if (kindOf(id) != referenceKinds[field.key]) + List("`\(field.key):` on `\(owner)` names `\(id)`, which is not \(kindNames[referenceKinds[field.key]])") + else if (!ids.contains(id)) + List("`\(field.key):` on `\(owner)` references undefined `\(id)`\(undefinedHint(planLines, id))") + else List() + ) + ) + + defined + .filter((entry) -> entry.kind == "AC" && !coveredIds.contains(entry.id)) + .map((entry) -> "`\(entry.id)` is not covered by any task's `Covers:`") + + defined + .filter((entry) -> entry.kind == "S" && !scenarioIds.contains(entry.id)) + .map((entry) -> "`\(entry.id)` is not referenced by any acceptance criterion's `Scenarios:`") + +local function inspect(text: String, skeleton: Boolean): PlanReport = + let (planLines = annotate(text)) + let (headings = headingsOf(planLines)) + let (defined = entries(planLines)) + new PlanReport { + defects = + headingDefects( + headings, + if (skeleton) templateSkeletonHeadings else requiredPlanHeadings, + if (skeleton) templateSkeletonHeadings else orderedPlanHeadings + ) + + definitionDefects(defined) + + (if (skeleton) List() else misplacedDefinitionDefects(planLines) + referenceDefects(planLines, defined)) + } + +function check(text: String): PlanReport = inspect(text, false) + +function skeletonDefects(text: String): List = inspect(text, true).defects + +function fencedBlockUnder(text: String, heading: String): String? = + let (planLines = annotate(text)) + let (start = planLines.findIndexOrNull((line) -> line.fenced && line.section == heading)) + if (start == null) null + else + planLines + .drop(start + 1) + .takeWhile((line) -> line.openFence != "") + .map((line) -> line.text) + .join("\n") + +local function exampleShapeDefects(sample: String): List = + let (defined = entries(annotate(sample))) + List("D", "AC", "S", "T") + .filter((kind) -> !defined.any((entry) -> entry.kind == kind)) + .map((kind) -> "defines no \(authoritativeSections[kind]) entry") + + ( + if (defined.any((entry) -> entry.kind == "AC" && fieldOf(entry, "Decisions") != null)) List() + else List("no acceptance criterion carries `Decisions:`") + ) + + ( + if (defined.any((entry) -> entry.kind == "AC" && fieldOf(entry, "Scenarios")?.value == inspectionOnly)) List() + else List("no acceptance criterion carries `Scenarios: \(inspectionOnly)`") + ) + +local function sampleDefects(label: String, sample: String?, skeleton: Boolean): List = + ( + if (sample == null) List("no fenced example found") + else + inspect(sample, skeleton).defects + + exampleShapeDefects(sample) + ).map((defect) -> "\(label): \(defect)") + +function templateDefects(text: String): List = + let (headings = headingsOf(annotate(text))) + templateDocumentHeadings + .filter((heading) -> !headings.contains(heading)) + .map((heading) -> "heading `\(heading)` is missing outside fenced code blocks") + + orderDefects(headings, templateDocumentHeadings) + + sampleDefects("template", fencedBlockUnder(text, "## Template"), true) + + sampleDefects("structured example", fencedBlockUnder(text, "## Filled-in structured plan example"), false) diff --git a/context/architecture.md b/context/architecture.md index c13ca8749..ac9dde24f 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -50,12 +50,12 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, successful unknown-command fallback to top-level or closest-parent help, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - The app runtime now moves through explicit startup phases in `cli/src/app.rs`: dependency bootstrapping (`perform_dependency_check`), startup context construction (`build_startup_context`), runtime initialization (`initialize_runtime`), directly awaited command parse/execute inside the telemetry polling scope (`run_command_lifecycle`, `parse_command_phase` plus `services::app_support::execute_command_phase`), and final output rendering through `services::app_support::render_run_outcome`. `AppRuntime` owns the concrete production logger, no-op telemetry runtime, filesystem ops, git ops, static `CommandRegistry`, and startup-diagnostic state across those phases; `RunOutcome` carries final render data with an optional generic logger implementing `services::observability::traits::Logger`, so render support can log classified errors without production-logger type coupling. If a telemetry implementation attempts to invoke the command action more than once, dispatch returns a runtime-classified error instead of panicking or reusing consumed arguments. - `AppContext` is the CLI's borrowed dependency view in `cli/src/app.rs`: it is generic over logger, telemetry, filesystem, and git capability implementations and stores references plus an optional `repo_root: Option` instead of owning `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is command-scoped: borrowing across directly awaited execution is safe while the owner lives, but detached work cannot retain that borrowed view. No new `Send`, `Sync`, or `'static` command bounds are required. Startup creates a context view over `AppRuntime`'s concrete production dependencies with `repo_root` set to `None`; command paths can derive repo-root-scoped context views through the `ContextWithRepoRoot` accessor trait / `AppContext::with_repo_root(...)`, which reuses the same borrowed dependencies while attaching the resolved root. Narrow accessor traits expose associated concrete capability types for logger, telemetry, fs, and git (`&Self::...`) plus repo-root access, so call sites can express capability requirements without erasing the borrowed dependencies back to trait objects; lifecycle providers consume the repo-root accessor rather than the full context type. -- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, longest-valid-parent traversal for unknown command paths, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. +- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, sync, and plan, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, longest-valid-parent traversal for unknown command paths, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. - Startup observability bootstrapping in `cli/src/app.rs` still tolerates invalid default-discovered config files by continuing with degraded defaults plus `sce.config.invalid_config` warn-level logs, but the warning/logging work is now isolated behind the startup-context and runtime-initialization phases rather than one inline startup function. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format, explicit config-file/default `log_to_file`, and `log_dir` inputs with precedence `env > config file > defaults` for non-flag observability keys, stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, error-specific stderr suppression when file logging is enabled while non-error records and file-write diagnostics remain on stderr, redaction-safe emission through the shared security helper, and log-directory writes with bounded retention. Config resolution also carries a positive config-file/default-only `log_file_retention_limit` (`10` by default) into startup observability config and `sce config show`; the concrete logger stores that resolved value and threads it through primary and v2 cleanup. When `log_dir` resolves from `SCE_LOG_DIR`, config, or the `/sce/logs` default, each enabled or forced log operation selects `/sce-.log` or `/sce--.log` using the machine-local date and optional logger session context, with deterministic percent-encoding for unsafe session filename bytes; after successfully writing a newly created selected file, retention keeps the configured number of newest direct regular `*.log` files by mtime plus path/name tie-break and fails open on cleanup errors. Its `observability::traits` submodule exposes the current `Logger` API with `Option<&str>` session context plus the generic async, deliberately non-object-safe `Telemetry` capability and `NoopLogger`; the concrete observability `Logger` and `NoopTelemetry` implement those capability traits; `NoopTelemetry` installs no tracing subscriber. `services::app_support::render_run_outcome` consumes the logger through that trait boundary when logging classified errors and stdout-write failures. - `cli/src/services/managed_telemetry.rs` (+ `managed_telemetry/trace_export.rs`, `managed_telemetry/log_export.rs`) owns hardcoded managed OTLP defaults, credential-backed runtime telemetry selection from the encrypted auth DB, and bounded fail-open OTLP/HTTP protobuf export of `sce.command` spans and allowlisted `Logger` events to the pinned canonical collector (metrics endpoint reserved, not exported); see `context/sce/cli-observability-contract.md`. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on concrete `Logger` behavior plus the `NoopTelemetry`-backed telemetry capability boundary. -- `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, `completion`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. +- `cli/src/cli_schema.rs` is now the canonical owner for top-level command metadata for the real clap-backed command set (`auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `version`, `completion`, `sync`, `plan`), including the slim top-level help purpose text and per-command visibility on `sce`, `sce help`, and `sce --help`; `cli/src/command_surface.rs` remains the custom top-level help renderer and known-command classifier, adding the synthetic `help` row plus the ASCII banner while consuming that shared metadata instead of maintaining a parallel command catalog. - `cli/src/services/default_paths.rs` is the canonical production path catalog for the CLI: it resolves config/state/cache roots with platform-aware XDG or `dirs` fallbacks through an internal `roots` seam, exposes named default paths for current persisted artifacts and database/log files (global config, auth tokens, auth DB, local DB, default observability log directory, and the sole Agent Trace DB path helper `agent_trace_db_path_for_repository` under `repos//agent-trace.db`; the former global-sentinel and per-checkout Agent Trace path helpers were removed by the `retire-legacy-agent-trace-db` plan), and owns canonical repo-relative, embedded-asset, install, hook, and context-path accessors so non-test production path definitions have one shared owner. Compile-time generated payload paths are owned by `build.rs` under `OUT_DIR`, not by the default-path catalog. Current production consumers such as config discovery, observability config resolution, doctor reporting, setup/install flows, database adapters, checkout identity, Agent Trace storage resolution, and local hook runtime path resolution consume this shared catalog rather than defining owned path literals in their own modules. - `cli/src/services/agent_trace.rs` is the Rust CLI owner for the SCE web base URL (`SCE_WEB_BASE_URL`) and exposes helpers for SCE-owned URL construction: Agent Trace conversation lookup URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created config schema URLs. Production Rust code should consume those helpers instead of repeating `sce.crocoder.dev` literals. The config resolver separately owns the `control_plane_base_url` runtime seam, whose baked `sce sync` default is `https://sce.crocoderlab.dev`; this control-plane host is not a web URL or schema owner. - `cli/src/services/config/mod.rs` is the config service facade and `sce config` orchestration surface (`show`, `validate`, `--help`), with bare `sce config` routed by `cli/src/app.rs` to the same help payload as `sce config --help`. Focused submodules own the implementation slices: `types.rs` owns shared config/runtime primitives, `schema.rs` owns generated schema embedding plus typed file parsing, `policy.rs` owns bash-policy semantic validation plus policy-specific formatting and runtime preset-catalog access for the Rust evaluator, `resolver.rs` owns deterministic config-file discovery, file-layer merging, explicit value precedence (`flags > env > config file > defaults` where flag-backed), shared auth-key resolution, observability-runtime resolution, attribution-hooks runtime gate and `agent_trace.auto_sync` resolution, database-retry config resolution and `DATABASE_RETRY_CONFIG` `OnceLock` initialization, default-discovered invalid-file degradation, strict invalid-discovered-layer errors for Agent Trace storage, and explicit-path fatal errors for `--config` / `SCE_CONFIG_FILE`, and private `render.rs` owns `sce config show` / `sce config validate` text and JSON output construction plus rendering-specific display-value helpers. The facade preserves existing `services::config` imports for startup/auth/hooks callers while delegating command execution to resolution plus rendering submodules. @@ -82,8 +82,9 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/sync/progress.rs` owns the sync-local, consumer-typed progress seam: generic `ProgressReporter` supports event delivery plus explicit successful finalization, closure-based collectors, and a no-op implementation alongside the fixed `indicatif` stderr presentation adapter. `cli/src/services/sync/sync.rs` owns `SyncProgressEvent` and its payload semantics for the three remotely synchronized streams (`messages`, `parts`, `agent_traces`; `diff_traces` is captured locally but not synchronized), while `sync/command.rs` selects the terminal adapter for text and the no-op reporter for JSON. There is no top-level `cli/src/services/progress/` module; sync orchestration depends only on its sync-owned contract, so terminal-library details stay at the sync presentation boundary. - `sce sync [--format text|json]` is implemented: `cli/src/services/sync/sync.rs` resolves repository-scoped Agent Trace storage, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the three remote `messages`/`parts`/`agent_traces` stream state machines concurrently (`diff_traces` remains a local-only compatibility report) via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/sync/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON without a nested subcommand field (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. The former trace database inspection and nested sync surfaces are unavailable. - `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helper (`load_patch_from_json` for string input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. +- `cli/src/services/plan/` owns read-only SCE plan structure analysis; `extract.rs` is its only `markdown::mdast` consumer and turns `markdown-rs` GFM output into an owned plan model, and `validate.rs` applies the Pkl-parity structural rules to that model as a pure function (see `context/cli/plan-service.md`). `command.rs` exposes it as the synchronous, database-free `sce plan check ` command, and `init.rs` backs `sce plan init `, which publishes the embedded scaffold to `context/plans/.md` at the Git repository root through a no-clobber hard link. `pr_description.rs` is the pure renderer behind `sce plan pr-description`, which validates plans read from the working tree and prints an offline intent review brief. Its `BriefFormat` enum also selects the Decision Brief layout from the same selection pass. The `sce plan review` command uses that layout and reads each plan from the `--head` commit through `git ls-tree` and `git cat-file`. With `--claims`, it passes the validated plans to the pure `claims.rs` module, which serializes the intent-only `sce.plan.claims.v1` JSON claims manifest instead (see `context/cli/plan-service.md`). - `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` structured-update hunks, `Write` `tool_input.content` create fallback, and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). -- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. The former `cli/src/services/checkout/` checkout-identity module was removed by the `remove-checkout-id` plan (see `context/cli/checkout-identity.md`); there is no per-clone identity, worktrees are identified by Git topology (`resolve_worktree_id`), active setup/hooks use `agent_trace_storage` to initialize/open the repository-scoped DB, and `sce doctor` surfaces credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers; Codex delegates in-process to the same evaluator for its native `PreToolUse(Bash)` response. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. +- `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, bash_policy, version, completion, help, patch, plan, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. The former `cli/src/services/checkout/` checkout-identity module was removed by the `remove-checkout-id` plan (see `context/cli/checkout-identity.md`); there is no per-clone identity, worktrees are identified by Git topology (`resolve_worktree_id`), active setup/hooks use `agent_trace_storage` to initialize/open the repository-scoped DB, and `sce doctor` surfaces credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers; Codex delegates in-process to the same evaluator for its native `PreToolUse(Bash)` response. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. - `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.95.0.default` with `rustfmt` + `clippy`, and reads package/check version from repo-root `.version`. Its `cliGeneratedInput` derivation invokes the shared generated-input producer from a declarative source containing the producer and canonical inputs, then publishes the producer-validated handoff as its store output. Native, release, test, and Clippy Cargo derivations receive that same store path through `SCE_CLI_GENERATED_INPUT_DIR`; their Cargo environments exclude Pkl and assert that it is unavailable. Repository-mode `cli/build.rs` validates the handoff before copying it into `OUT_DIR`; published crates use the validated packaging-only fallback. The build script stages SQL under `OUT_DIR/static/migrations` and writes `OUT_DIR/generated_migrations.rs` with deterministic migration constants sorted by numeric filename prefix. - Crane dependency-only derivations and `cli-fmt` intentionally do not receive `SCE_CLI_GENERATED_INPUT_DIR`, so canonical generation changes invalidate the producer and compiling derivations while preserving host/musl dependency artifacts and formatting. The root flake runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed paths and exposes `cli-generated-input` as the focused payload/inventory integrity check. It also exposes directory-scoped JS validation derivations for `npm/` and `config/lib/`, while `pkl-generated` uses a narrow canonical-input source set plus maybe-missing forbidden paths so reintroduced generated repository artifacts fail the check. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 403828c63..c11a8b638 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -11,7 +11,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - Top-level command metadata catalog: `cli/src/cli_schema.rs` - Custom top-level help renderer and known-command classifier: `cli/src/command_surface.rs` - Turso adapters: `cli/src/services/auth_db/mod.rs`, `cli/src/services/local_db/mod.rs`, `cli/src/services/agent_trace_db/mod.rs`, and shared infrastructure in `cli/src/services/db/mod.rs` -- Service domains: `cli/src/services/{agent_trace,agent_trace_db,auth,auth_command,auth_db,completion,config,db,default_paths,hooks,local_db,observability,output_format,resilience,security,setup,style,sync,token_storage,version}` plus the split doctor module at `cli/src/services/doctor/{mod,command,inspect,render,fixes,types}.rs`; service-owned `command.rs` files own command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync, and `cli/src/services/command_registry.rs` owns the static `RuntimeCommand` enum that dispatches them +- Service domains: `cli/src/services/{agent_trace,agent_trace_db,auth,auth_command,auth_db,completion,config,db,default_paths,hooks,local_db,observability,output_format,resilience,plan,security,setup,style,sync,token_storage,version}` plus the split doctor module at `cli/src/services/doctor/{mod,command,inspect,render,fixes,types}.rs`; service-owned `command.rs` files own command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync/plan, and `cli/src/services/command_registry.rs` owns the static `RuntimeCommand` enum that dispatches them - Service lifecycle: `cli/src/services/lifecycle.rs` defines lifecycle-owned health/setup result types, the `ServiceLifecycle` trait with `diagnose`, `fix`, and `setup` methods for concrete providers, and the static `LifecycleProvider` enum used by `doctor`/`setup` to dispatch across `config`, `local_db`, `auth_db`, `agent_trace_db`, and `hooks` without boxed provider aggregation ## Application runtime dispatch @@ -28,7 +28,7 @@ awaited Turso persistence decision](../decisions/2026-10-07-directly-awaited-tur | Path | Execution and lifetime reason | | --- | --- | -| Help, HelpText, Version, Completion, Policy, Config Show/Validate | Direct synchronous execution; no database operations. | +| Help, HelpText, Version, Completion, Policy, Config Show/Validate, Plan | Direct synchronous execution; no database operations. | | Setup and Doctor | Async lifecycle-provider dispatch awaits local, auth, and repository DB operations; pure Config/Hooks work stays synchronous within async lifecycle methods. | | DB-backed Hooks and MutationScope | Static handler dispatch awaits required foreground persistence before returning. | | Sync | Async repository storage, export reads, credential persistence, and HTTP remain awaited on the application runtime; ordinary ownership retains storage across HTTP calls. | @@ -87,7 +87,7 @@ Non-interactive target setup never prompts. A missing config receives explicit ` - the banner uses a per-column right-to-left color gradient (cyan on the right, magenta on the left) when stdout color is enabled, and renders as plain ASCII when color is disabled (non-TTY or `NO_COLOR`) - the banner is the box-drawing "SCE" art from `SCE_BANNER_LINES`, rendered by `command_surface::help_text()` via `sce_banner()`, which applies `style::banner_with_gradient` and adds a `Visit sce.crocoder.dev to learn more.` line where `sce.crocoder.dev` is cyan, underlined, and an OSC 8 hyperlink to `https://sce.crocoder.dev` (`style::banner_link`); all of these render as plain text when color is disabled - the visible real-command rows are sourced from `cli_schema::TOP_LEVEL_COMMANDS`, so top-level purpose text and help visibility are defined once for both help rendering and known-command classification -- the visible command list is `help`, `auth`, `config`, `setup`, `doctor`, `sync`, `version`, and `completion` +- the visible command list is `help`, `auth`, `config`, `setup`, `doctor`, `sync`, `plan`, `version`, and `completion` - top-level help omits implemented/placeholder labels - top-level examples cover setup plus doctor/version machine-readable or repair-intent flows (`doctor --format json`, `doctor --fix`, `version --format json`) and use the shared example-command styling when stdout color is enabled - `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden @@ -97,6 +97,8 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, first-class `policies.bash` reporting for preset/custom blocked-command rules, and deterministic text/JSON output modes where `show` reports resolved values with provenance while `validate` reports pass/fail plus validation issues and warnings only. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. + +`plan` exposes `sce plan check `, a read-only structural check of one SCE plan file, `sce plan init `, which creates `context/plans/.md` at the Git repository root from the embedded plan scaffold without overwriting an existing entry, `sce plan pr-description --repo --head ...`, which prints an offline intent review brief for pull requests, and `sce plan review [--claims] --repo --head ...`, which prints a Decision Brief from plans read at the `--head` commit, or with `--claims` the `sce.plan.claims.v1` JSON claims manifest of authored acceptance-criterion claims, which Jev and other separate assessors consume; `claims.json` names that stdout document, and the command creates no file. The former `sce plan decision-brief` name has no alias and routes to the `sce plan --help` text with exit `0`. All four output and exit-code contracts are owned by `context/cli/plan-service.md`. `setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, Codex, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--codex`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi+codex); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. Every setup mode first validates the Git repository and effective `agent_trace.repository_remote` (default `origin`) before prompts or writes. If that configured remote is missing, the typed `NotGitRemote` diagnostic uses generic `git remote add ` guidance; the preserved technical source identifies the effective name without exposing the URL. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path ensures that baseline only after both preflights. `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. It then asks `Automatically sync Agent Traces?` followed by `(Requires an SCE account. Sends Agent Traces from supported AI coding tools` and `to SCE servers so they can be stored and viewed in your account.)`, then `Record SCE involvement in Git commits?` followed by `(Adds SCE metadata to commits created or assisted by SCE.)`, as independent confirmations, both defaulting to Yes; the two answers are carried independently through setup dispatch. Cancelling either interactive prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the optional-workflow prompt is skipped when the catalog has no optional workflow. @@ -133,7 +135,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/config/mod.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, repo-configured bash-policy preset/custom validation and reporting under `policies.bash`, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key provenance/preference metadata stays on `show`, while `validate` stays trimmed to validation status plus issues/warnings. `cli/src/services/config/lifecycle.rs` implements `ServiceLifecycle` for config health checks and setup (global/local config validation and repo-local config bootstrap). - `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorMode`, `run_doctor_with_context`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, repository-scoped Agent Trace DB health, stable JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, canonical non-launching post-commit auto-sync readiness reporting from managed-block currency plus resolved config, repo-root installed OpenCode, Claude, Pi, and Codex integration inventory derived from embedded setup asset catalogs, typed target/area grouping with compact human status tokens (`[PASS]`, `[WARN]`, `[FAIL]`, `[MISS]`), and repair-mode delegation to service-owned fix implementations. The human renderer emits the compact `SCE doctor` / `SCE doctor fix` hierarchy, suppresses healthy paths and identity metadata, and expands only unhealthy branches; JSON retains the complete path, identity, problem, fix-result, and `post_commit_auto_sync` detail. Claude grouping is typed and path-based: `settings.json`/`hooks/**` as the `Plugins` area (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `Commands` and `Skills`; OpenCode retains `Plugins`, `Agents`, `Commands`, and `Skills`; Pi grouping includes `prompts/**`, `skills/**`, and `extensions/**` areas; Codex grouping includes `.agents/skills/**` as `Skills` and `.codex/hooks.json`/`.codex/hooks/**` as `Hooks` (the latter also carrying a Codex hook trust/review reminder when unhealthy). - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. -- `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. +- `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish. Before generation it rebuilds the `plan` subcommand in place with only its visible subcommands, because `clap_complete` emits hidden subcommands. Hidden subcommands of other commands, such as the `hooks` adapters, stay in the scripts; `cli/src/services/completion/command.rs` owns the completion runtime command handler. - `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `codex`; `cli/src/services/hooks/codex/` owns the Codex dispatcher (typed `CodexHookEvent` parsing plus `classify_codex_event`; `UserPromptSubmit`/`Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence; `PreToolUse(apply_patch)` is unregistered and falls open as a no-op like every other unsupported combination or malformed STDIN); `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). @@ -141,7 +143,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/agent_trace.rs` defines the canonical Rust SCE web base URL and helpers for Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. - `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. - `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `whoami`, including shared text/JSON rendering, resolved Control Plane telemetry-credential provisioning after WorkOS persistence on fresh/valid-stored/renewed login paths, best-effort failure handling, deterministic `telemetry_provisioning` issued/unavailable status and message rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, source-level typed `CliError` propagation through `run_login_with_stored_credentials` for renewal, token-save, and device-login failures, token-storage-backed logout deletion with idempotent absent-credential success, unauthenticated whoami state reporting, Control Plane `/me` profile retrieval for authenticated whoami, flat safe-field rendering with optional-name/null-value handling, exact logged-out text guidance, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and directly awaited WorkOS/Control Plane HTTP on the application runtime; credential load/save/delete are async APIs directly awaited on the application runtime; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. +- `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `plan`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. ## Local and Agent Trace Turso adapter behavior diff --git a/context/cli/plan-service.md b/context/cli/plan-service.md new file mode 100644 index 000000000..5988c3728 --- /dev/null +++ b/context/cli/plan-service.md @@ -0,0 +1,208 @@ +# Plan Service + +`cli/src/services/plan/` owns deterministic, read-only structural analysis of one SCE plan Markdown file, the `sce plan pr-description` intent review brief, and the `sce plan init` scaffold generator, its only writing operation. The analysis is the Rust counterpart of the test-only Pkl checker `config/pkl/renderers/plan-structure.pkl`. + +## Current scope + +- `extract.rs` is the only plan-service code that touches `markdown::mdast::Node`. It parses the source with `markdown-rs` (`markdown` crate, `to_mdast(source, &ParseOptions::gfm())`) and returns an owned `Plan` model. It never scans lines to find structure. +- `validate.rs` holds the pure `validate(&Plan) -> PlanReport` rules. It has no I/O and never touches `mdast` types. +- `command.rs` owns the `plan` command. `PlanCommand` holds one `PlanRequest::{Check(PlanCheckRequest), Init(PlanInitRequest), PrDescription(PlanPrDescriptionRequest), Review(PlanReviewRequest)}`, mapped from `PlanSubcommand` in `services/parse/command_runtime.rs` and dispatched by a static `match`. `execute_with_stderr(stderr)`, called from `RuntimeCommand::execute_with_stderr`, passes `std::env::current_dir` and the command stderr writer to `execute_with`, which tests call with an explicit start directory and a captured stderr buffer instead of changing process state (see [Command contract](#command-contract), [Init command contract](#init-command-contract), and [PR description command contract](#pr-description-command-contract), and [Review command contract](#review-command-contract)). `PlanReviewRequest` carries `repo`, `head`, `paths`, and a `ReviewOutput::{Brief, Claims}` enum. `command_runtime.rs` maps the boolean `--claims` flag to `Claims`, and `command.rs` dispatches on the enum with a static `match`. All three read commands share `parse_valid_plan`: `extract`, `validate`, and the same diagnostic rendering. `check` and `pr-description` read the working tree, and `review` reads Git objects. +- `init.rs` owns `PlanName` validation and `create(&PlanName, start)`, the repository-root scaffold publication. Its `resolve_repository_root(start, command)` is shared with `pr-description` and `review` and names the calling command in its remediation text. +- `pr_description.rs` is a pure module with no I/O. It owns the argument grammar (`parse_arguments`), the collection of marked decisions, the shared review-scope warning, Markdown escaping (`escape_markdown`), and `render_format(&PrDescriptionArguments, &[PlanInput], BriefFormat)`. `BriefFormat::{IntentReview, DecisionBrief}` selects only the layout (see [Brief formats](#brief-formats)). The test-only `render` wrapper calls `render_format` with `IntentReview`. +- `claims.rs` is a pure module with no I/O. `claims::render(&PrDescriptionArguments, &[PlanInput])` maps validated plans to one `sce.plan.claims.v1` JSON document, with one claim per acceptance criterion. Typed `#[derive(Serialize)]` structs fix the property order, and `serde_json::to_string_pretty` serializes them. The module does not use `render_format` or `escape_markdown`, so the review-scope warning and Markdown escaping never apply to claims. A repeated consumed field on one entry gives a `Plan claims check failed` validation error (exit `3`) and no JSON. The [Claims manifest contract](#claims-manifest-contract) owns the field list. `sce plan review --claims` is its only caller. The [claims manifest decision](../decisions/2026-10-25-plan-claims-manifest-v1-contract.md) records the intent-only v1 contract with status `Proposed`. +- `mod.rs` embeds the generated plan init scaffold as the crate-visible `SCAFFOLD` constant (`include_str!` of `OUT_DIR/pkl-generated/config/plan-init-scaffold.md`) with its `SCAFFOLD_PLAN_NAME_TOKEN` (`{plan-name}`). `cli/build.rs` requires that staged path, so a payload or package fallback without the scaffold fails the build. The binary never renders the scaffold itself. + +## Plan model + +- `Plan.headings`: one `PlanHeading { depth, text, line }` per root-level heading, where `text` is the heading's plain text. +- `Plan.entries`: one `PlanEntry { id, kind, checked, line, statement, fields }` per definition, in source order. Use `Plan::entries_of(kind)` to select one kind. `PlanEntryKind` is one of `Decision`, `AcceptanceCriterion`, `Scenario`, or `Task`. +- `PlanEntry.statement` is the source text after `ID:` in the first paragraph of the definition. Each whitespace run, including a line break, becomes one space, and the ends are trimmed. An empty definition such as `- D2:` gives an empty string. Renderers read `statement` and do not parse Markdown again. +- `PlanField { key, value, line }`: one per directly nested list item whose first paragraph starts with `Key:`. The key is `[A-Z][A-Za-z -]*`, followed by whitespace or the end of the text. `value` is the trimmed source slice after the colon, so inline code and punctuation are kept verbatim. +- `Plan.outside_definitions` and `Plan.fenced_ids` hold definition-shaped list items that are not definitions. The first holds `OutsideDefinition { id, line, section }` for items found outside an authoritative position. `section` is set only for a top-level list item under an authoritative section of another kind, which marks a definition in the wrong section rather than nested or unrelated prose. The second lists items found inside `Code` blocks, which are re-parsed with `to_mdast` rather than scanned by line. They support undefined-reference hints and never create entries. +- Lines are 1-based and come from `mdast` positions. + +## Extraction rules + +- A section is the run of root children after a heading, up to the next heading of any depth. The authoritative sections are `## Design decisions` (D), `## Acceptance criteria` (AC), `### Verification scenarios` (S), and `## Task stack` (T). +- A definition is a `ListItem` of a root-level `List` in the section that matches its kind. Its first paragraph must start with an ID token (`D`, `AC`, `S` with `n` ≥ 1 and no leading zero, or `T` plus two or more digits), then `:`, then whitespace or the end of the text. The GFM `checked` value gives `checked`. +- A definition-shaped item of the wrong kind, in a non-authoritative section, or nested inside another list item is not an entry. +- `Code` nodes, both fenced and indented, never contribute headings, entries, or fields. + +## Validation rules + +`validate` mirrors the `check` path of `config/pkl/renderers/plan-structure.pkl`. Template-skeleton mode stays Pkl-only. Defect messages are copied verbatim from the Pkl checker, so changing their wording means changing both checkers. + +- Every plan is checked as a structured plan. There is no legacy classification: a plan in the pre-structured format fails with its missing `Scenarios:`, `Covers:`, and coverage diagnostics. +- Heading rules, with headings compared as `#`×depth plus text: + - `## Acceptance criteria` and `## Task stack` are required. + - `## Design decisions`, `## Acceptance criteria`, `### Verification scenarios`, and `## Task stack` may each appear only once, in that order. + - The nearest preceding depth-1 or depth-2 heading of `### Verification scenarios` must be `## Acceptance criteria`. +- Definition rules: + - IDs are unique. + - Owned fields appear only on their owning kind: `Boundary`/`Rationale`/`Rejected`/`Trade-offs`/`Review`/`Review question` → D, `Validate`/`Decisions`/`Scenarios` → AC, `Given`/`When`/`Then`/`Check` → S, `Covers` → T. + - Every definition carries its kind's required fields. +- Review pair rules, per decision: + - `Review:` and `Review question:` are optional, but each requires the other. + - Every `Review:` value must be `required`. + - Every `Review question:` value must be nonempty and must not contain a newline. The source-sliced value keeps lazy continuation lines, so a wrapped question fails. + - A repeated `Review:` or `Review question:` key is reported once. +- Reference rules: + - `Decisions:`, `Scenarios:`, and `Covers:` on their owning kind must be an exact `ID, ID` list. `Scenarios: none (inspection)` is exempt. + - Each listed ID must name the right kind and resolve to a definition. + - An unresolved ID gets an `outside_definitions` or `fenced_ids` hint. + - An `OutsideDefinition` with a `section` is reported at its own line as defined under the wrong section. + - Every AC must be covered by some task's `Covers:`, and every S must be referenced by some AC's `Scenarios:`. +- `PlanReport.diagnostics` holds `PlanDiagnostic { line, message }` values, sorted by `(line, message)`. Line-less diagnostics come first, and duplicates are kept. +- Where a diagnostic's line comes from: + - Missing required headings have no line. + - Duplicate headings and duplicate IDs point at the first repeated occurrence. + - A misordered heading points at the later heading. + - The parent-rule defect points at `### Verification scenarios`. + - Misplaced and reference defects point at their field. + - Missing-field, uncovered-AC, and unreferenced-S defects point at their definition. + - A missing review-pair half points at its decision. A review value, empty, or single-line defect points at its field. A repeated review key points at its second occurrence. + +## Command contract + +`sce plan check ` checks exactly the file at ``, relative or absolute. It is synchronous, uses no database or repository root, and never runs `Check:`, `Validate:`, or `Verify:` content. + +- Valid structured plan: stdout `structured plan: reference integrity holds`, exit `0`. +- Structurally invalid plan: `CliError::validation` (exit `3`, `SCE-ERR-VALIDATION`). The stderr message is a `Plan structure check failed for '{path}':` header, one `{path}:{line}: {message}` line per diagnostic (`{path}: {message}` when the diagnostic has no line) in `validate` order, and a closing `Try:` line. `{path}` is printed as given. +- Missing or unreadable file: `CliError::runtime` (exit `4`, `SCE-ERR-RUNTIME`) with `Failed to read plan file '{path}'` context, and no diagnostics. + +## Init command contract + +`sce plan init ` creates `context/plans/.md` at the Git repository root from the embedded scaffold, with its single `{plan-name}` token replaced by ``. It is synchronous, offline, uses no database, and invokes only `git`. + +- Name: one required positional `String`. `PlanName::parse` runs before any filesystem access, including reading the current directory: the value must match `[a-z0-9]+(-[a-z0-9]+)*`, be at most 64 bytes (`PLAN_NAME_MAX_BYTES`), and not end in `.md`. Clap errors keep the existing `classify_clap_error` mapping: hyphen-prefixed values, unknown options, and extra positionals are parse errors (exit `2`); a missing `` is exit `3`. A value after `--` reaches name validation. +- Root: `git rev-parse --show-toplevel` run with the start directory as its working directory. A root that is not UTF-8 or contains a newline is refused before any write. +- Directories: `context` and `context/plans` must be real directories (`symlink_metadata`, so symlinks are refused) and are created when absent. Symlinked directories are refused even when a repository links `context/` elsewhere on purpose, because following the link would allow writes outside the repository root. Directories created by a failed run are not rolled back. +- Publication: the content is written to a hidden `..md..tmp` file opened with `create_new` inside `context/plans/`, then published with `std::fs::hard_link`. A successful hard link is the commit point: from then on the run succeeds. The temporary file is removed after publication and on every handled error after its creation. `init::create` delegates to `init::create_with`, which takes the link and remove operations as generic closures so tests inject failures deterministically. The destination either does not exist or holds the complete scaffold; an existing entry, including a dangling symlink, is never replaced. There is no non-atomic fallback, no `fsync`, and no crash-recovery cleanup, so a killed run can leave an orphaned temporary file. +- Success: exit `0` and exactly three stdout lines: `Created plan: context/plans/.md`, `Next: complete the plan and run`, and `sce plan check '/context/plans/.md'`. The path is always wrapped in POSIX single quotes, each `'` written as `'\''`, so the printed command works from any directory. +- Cleanup warning: when the hard link succeeds but removing the temporary file fails, the run still exits `0` with the same three stdout lines, and writes one stderr line before them: `Warning: Created plan context/plans/.md, but failed to remove temporary file '': . Try: remove '' manually; the plan was created and does not need to be initialized again.` The published destination is never removed. +- Failures: an invalid name or an existing destination (`Plan already exists: context/plans/.md`) is `CliError::validation` (exit `3`). A missing repository, a failed or missing `git`, an unprintable root, a non-directory `context` path, and any other filesystem failure are `CliError::runtime` (exit `4`). When writing or publishing fails, the temporary file is removed and the original error and class are returned. When that removal also fails, the class is kept and the rendered message is the original error followed by a line `Cleanup also failed: could not remove temporary file '': . Try: remove it manually.` + +## PR description command contract + +`sce plan pr-description --repo --head ...` prints an intent review brief for one or more plans. It is synchronous and offline. It uses no database, no network, no AI, and no heuristics, and it never writes files. + +- Arguments are checked before any file access. `--repo` is `/`, with owner `[A-Za-z0-9-]+` and name `[A-Za-z0-9._-]+` that is not `.` or `..`. `--head` is exactly 40 lowercase hexadecimal characters. Each path is `context/plans/.md`, with `` in `[A-Za-z0-9._-]+` and not `.` or `..`. A path given twice is rejected. Because of this grammar, URLs need no percent-encoding. +- Plans are read from the working tree at `{root}/{path}`, where the root comes from `git rev-parse --show-toplevel`. The command never reads Git objects and never checks that the working tree matches `--head`. The caller must pass the SHA that contains the plan files as read. +- Every plan must pass the same `extract` and `validate` checks as `sce plan check`. A failing plan reports the same diagnostic lines, labeled with its relative path. +- A marked decision carries `Review: required` and its `Review question:`. There is no maximum number of marked decisions, and the brief never truncates, ranks, or merges them. Three or more decisions marked for senior review produce a non-blocking scope warning. Every marked decision remains visible. The warning encourages splitting independent work into separate PRs but does not reject the review brief. +- Output: the `` marker, `## Intent review`, and a `**Plans:**` line with one link per plan in sorted path order, joined by `, `. With three or more marked decisions, a blank line and the review-scope warning follow the plans line. Then `**Decisions requiring review**` and numbered `{n}. {question} — [{label} {D-id}]({url}#L{line})` items, separated by blank lines, in sorted plan order and source decision order. With no marked decision, the line `No decisions explicitly flagged for review.` replaces the list. A blank line and the `` marker close the brief, and stdout ends with one newline. +- A plan label is the file name without `.md`. A link is `https://github.com/{repo}/blob/{sha}/{path}`, and a decision link adds `#L{line}` for the decision entry line. +- Escaping: `escape_markdown` puts one backslash before each of `` \ ` * _ [ ] < > ! | ~ & `` in labels. Questions and statements are authored Markdown and use `escape_authored_markdown`, which parses the text with `markdown-rs` (GFM), copies each inline code span byte for byte, and applies `escape_markdown` to the text around them. Code spans therefore keep their backticks, underscores, angle brackets, and backslashes, while plain text, including literal backslashes, stays escaped. URLs are never escaped. +- Failures: invalid arguments, a duplicate path, and an invalid plan are `CliError::validation` (exit `3`). A missing or unreadable plan, a failed `git`, or a start directory outside a repository is `CliError::runtime` (exit `4`). Stdout is empty on every failure. + +## Review command contract + +`sce plan review [--claims] --repo --head ...` prints the [Decision Brief](#brief-formats) for one or more plans. With `--claims`, it prints one `sce.plan.claims.v1` JSON claims manifest instead. It is visible, synchronous, and offline. It uses no database, network, or AI, and it never writes files. + +- Audience: a senior reviewer who judges the marked architectural choices of a pull request in two to three minutes, without reading the diff. The exact output is the `DecisionBrief` layout in [Brief formats](#brief-formats). +- Relation to `pr-description`: both commands share the argument grammar, the selection pass, the review-scope warning, the URL builder, and Markdown escaping, so they select the same decisions. They differ only in the layout and the source of plan text: `pr-description` reads the working tree and keeps its intent review output, while `review` reads the `--head` commit. +- Arguments use the same `parse_arguments` grammar as `pr-description`, and they are checked before any Git call. +- Three or more decisions marked for senior review produce a non-blocking scope warning. Every marked decision remains visible. The warning encourages splitting independent work into separate PRs but does not reject the review brief. The threshold counts marked decisions across all selected plans, not per plan. The warning appears only in the Markdown; stderr stays empty. The command never truncates. +- Every plan comes from the `--head` commit, never from the working tree. Git runs as a direct process at the repository root, with no shell. +- `git rev-parse --verify --quiet ^{commit}` must succeed. For each path, `git ls-tree -z -- ` must give an entry with that exact name, type `blob`, and mode `100644` or `100755`. `git cat-file blob ` gives the bytes, which must be UTF-8. +- Each plan then passes the same `extract` and `validate` checks as `sce plan check`, labeled with its relative path. +- Link limitation: `--repo` is a caller-supplied GitHub repository identifier, and `--head` must be a commit available in the local repository. The command guarantees local source integrity: the parsed text and the `#L{line}` anchors match the local `--head` commit. It does not guarantee remote link availability: it does not check that the commit was pushed to GitHub or exists in `--repo`. Published links work only when that commit is available in the named repository, so a wrong `--repo` or an unpushed commit gives a well-formed brief whose links do not resolve. +- Review intent is not evidence: the brief shows what the plan author asks reviewers to judge. It does not show that the implementation follows those decisions, and it is not a substitute for verification. +- Failures: invalid arguments, a duplicate path, an invalid plan, and an empty marked statement are `CliError::validation` (exit `3`). A missing commit, a missing path, a symlink or other non-regular entry, non-UTF-8 content, and a failed `git` are `CliError::runtime` (exit `4`). Stdout is empty on every failure. +- Claims output: `--claims` reads and validates every plan from `--head` through the same Git path. Then `claims::render` builds the complete document, and the shared stdout writer appends one newline. The review-scope warning and the empty-statement check do not apply, and the JSON never contains warning text. A repeated consumed field fails with the `Plan claims check failed` validation error (exit `3`), although `sce plan check` and the Decision Brief accept the plan. Stdout stays empty on every failure, and the command creates no file. The [Claims manifest contract](#claims-manifest-contract) owns the document. +- `PlanSubcommand::Review` is visible. `sce --help` lists it under `Plan Usage:`, and `sce plan --help` and the Bash, Zsh, and Fish completion scripts list `review` and its `--claims` flag. +- The former `sce plan decision-brief` name has no alias. The unknown-subcommand routing in `command_runtime.rs` handles it: the command exits `0`, prints the `sce plan --help` text on stdout, and prints no Decision Brief. A script cannot detect the removal from the exit code. + +## Claims manifest contract + +`sce plan review --claims --repo --head ...` prints exactly one `sce.plan.claims.v1` JSON document on stdout, followed by exactly one newline. The [claims manifest decision](../decisions/2026-10-25-plan-claims-manifest-v1-contract.md) records this contract as an external compatibility surface. + +- Claim identity: one claim per acceptance criterion, with `id` `#`, for example `context/plans/token-refresh.md#AC1`. The ID contains no SHA and no line number, so it stays stable across text edits and commits because plan rules never renumber or reuse AC IDs. Renaming a plan file or moving a criterion to another plan changes it. +- Provenance: every plan is read from the `--head` commit through the [review command contract](#review-command-contract) Git path, never from the working tree. Every `line` is the 1-based `PlanEntry.line` in the committed content, and every `url` is `https://github.com/{slug}/blob/{head_sha}/{path}` with `#L{line}` for sources. Uncommitted edits never change the output, and input path order never changes it. +- Intended claims, declared checks, and observed evidence are three different things. Acceptance criteria and design decisions are authored intent: every claim has `kind` `acceptance_criterion` and `modality` `intended`. `Validate:`, scenario, and `Check:` values are declared verification procedures, copied as text. The command never runs them, and their presence never means that they passed. Observed evidence (exit codes, logs, test results, model judgments) is never part of v1. `author_checked` copies the checkbox (`true`, `false`, or `null` when absent) and never counts as evidence or changes `modality`. +- Consumers: Jev and other semantic evaluators or assessors are separate consumers outside `sce`. They attach their own evidence and judgments by joining on `repository.slug`, `repository.head_sha`, and `claims[].id`. The document has no evidence, assessment, verdict, confidence, risk, score, or priority property. +- `claims.json` names the stdout document only, for example after `> claims.json`. The command creates no file. + +Every object has exactly these properties, in this order, with none optional or omitted when empty: + +- Root: `schema_version` (`"sce.plan.claims.v1"`), `repository` (Repository), `plans` (Plan[]), `claims` (Claim[]). +- Repository: `slug` (the validated `--repo`), `head_sha` (the validated `--head`). +- Plan: `path`, `url`. +- Claim: `id`, `kind` (`"acceptance_criterion"`), `statement`, `modality` (`"intended"`), `author_checked` (boolean or null), `source` (Source), `decisions` (Decision[]), `verification` (Verification), `tasks` (Task[]). +- Source: `path`, `entry_id`, `line` (positive integer), `url`. +- Decision: `id`, `statement`, `boundary`, `rationale`, `rejected`, `trade_offs`, `review` (Review), `source` (Source). +- Review: `required` (boolean, `true` for `Review: required`), `question` (the `Review question:` value when required, otherwise `null`). +- Verification: `mode` (`"scenarios"` or `"inspection"`), `validate` (the `Validate:` value), `scenarios` (Scenario[]). +- Scenario: `id`, `statement`, `given`, `when`, `then`, `check`, `source` (Source). +- Task: `id`, `statement`, `author_checked` (boolean or null), `source` (Source). + +Mapping rules: + +- Statements are the whitespace-folded `PlanEntry.statement`. Other strings are the authored `PlanField.value`. Neither uses Markdown escaping, so inline backticks and a task's `(status:...)` suffix stay as authored, and a wrapped field value keeps its newline as `\n`. A marked decision with an empty statement gives `"statement": ""`. +- `decisions` and `scenarios` follow the reference order of `Decisions:` and `Scenarios:`, resolved only inside the claim's own plan. A claim without `Decisions:` gives `[]`. `Scenarios: none (inspection)` gives `"mode": "inspection"` and `"scenarios": []`. +- `tasks` lists every task whose `Covers:` names the criterion, in task source order, or `[]`. +- `plans` is sorted by path in byte order, and `claims` by plan path, then criterion source order. +- The review-scope warning and the empty-statement check of the Decision Brief do not apply. Every criterion of every plan gives a claim, and valid plans without criteria give `"claims": []`. +- `serde_json::to_string_pretty` serializes typed `#[derive(Serialize)]` structs with two-space indentation, without `serde_json::Value` maps or `skip_serializing_if`. + +Repeated consumed fields: a plan that `validate` accepts can repeat a field on one entry. `claims.rs` rejects a repeated `Boundary`, `Rationale`, `Rejected`, `Trade-offs`, `Review`, or `Review question` on a decision, `Validate`, `Decisions`, or `Scenarios` on an acceptance criterion, `Given`, `When`, `Then`, or `Check` on a scenario, and `Covers` on a task, instead of choosing one occurrence. It checks plans in sorted path order and fails on the first plan with a repetition with `CliError::validation` (exit `3`): + +```text +Plan claims check failed for '': +:: `:` appears more than once on `` +Try: merge each repeated field into one line, then rerun 'sce plan review --claims'. +``` + +Each diagnostic names the line of the second occurrence, in line order. `validate` still reports a repeated `Review:` or `Review question:` first with its own diagnostic. `sce plan check` and the Decision Brief keep accepting the other repetitions. + +Failures follow the review command: argument, duplicate-path, plan, and repeated-field defects exit `3`, and Git and content defects exit `4`. Stdout is empty on every failure, with no partial JSON, and no file is written. + +## Brief formats + +`render_format` runs one selection pass for both layouts. The pass collects marked decisions with their question, `statement`, and line, and sorts plans by path. It never limits the number of marked decisions. `sce plan pr-description` passes `BriefFormat::IntentReview`, which gives the output in the [PR description command contract](#pr-description-command-contract). The `sce plan review` command passes `BriefFormat::DecisionBrief`. + +- Review-scope warning: with `n` of 3 or more marked decisions across all selected plans, both layouts add exactly one warning with the shared text `{n} architectural decisions require senior review. Consider moving independent changes into a separate PR to keep this review focused.` from `review_scope_text`. `IntentReview` renders it as `**Review scope:** {text}` (`review_scope_warning`) after the plans line and before `**Decisions requiring review**`. `DecisionBrief` renders it as the blockquote `> {text}` (`review_scope_advisory`) right after its heading and before the first decision section. With fewer than 3 marked decisions, neither layout changes. +- Both layouts use the same section markers, `START_MARKER` (``) and `END_MARKER` (``). Each layout keeps its own heading. +- The `DecisionBrief` layout puts `## Decisions requiring review ({n})` on the line right after the start marker, where `n` is the number of rendered decision sections. It then joins these blocks with one blank line: the heading, the review-scope advisory when it applies, the decision sections, the source-plan index, and the end marker. The output never contains three newlines in a row and ends with exactly one newline. +- `decision_brief_stdout_payload` removes exactly one trailing newline from a rendered Decision Brief. `write_stdout_payload` appends one newline with `writeln!`, so command stdout ends with exactly one newline. The `review` command passes its rendered brief through this helper. The intent review brief does not use it. +- Each marked decision gives four blocks: `### {n}. {label} · {D-id}`, the bold question `**{question}**`, `**Proposed:** {statement}`, and `[Rationale, alternatives and trade-offs]({url}#L{line})`. The question is whitespace-folded like the statement so the bold paragraph stays on one line. Labels use `escape_markdown`, and questions and statements use `escape_authored_markdown`. +- With no marked decision, the line `No decisions explicitly flagged for review.` replaces the sections. +- The source-plan index closes the layout: `
`, `Source plans ({count})`, a blank line, one `- [{label}]({url})` bullet per plan in sorted path order, a blank line, and `
`. With no plans, the index is omitted. +- A marked decision with an empty `statement` is a `CliError::validation` (exit `3`) that names the decision and the plan. Only the `DecisionBrief` layout applies this check, so `validate` and the intent review brief still accept the plan. + +`plan` is a visible top-level command and appears in `sce --help` (with `sce plan check `, `sce plan init `, `sce plan pr-description --repo --head ...`, and `sce plan review [--claims] --repo --head ...` lines under `Plan Usage:`) and in generated Bash/Zsh/Fish completions. The completions list only the visible `plan` subcommands. + +## Terms + +- `plan-structure expectations manifest`: `config/pkl/renderers/fixtures/plan-structure/expectations.tsv`, the single source of fixture verdicts for both the Pkl checker (driven by `config/pkl/check-generated.sh`) and the Rust validator. Each row is tab-separated: fixture file, `pass` or `fail`, then the complete expected defect messages for `fail` rows, compared as an exact order-insensitive multiset. Every `*.md` fixture in the directory must be listed exactly once. +- `sce plan init`: The CLI command that publishes the plan init scaffold as `context/plans/.md` at the repository root through the [init command contract](#init-command-contract). It never overwrites an existing plan. +- `sce plan check`: The read-only CLI command that runs `extract` then `validate` on one plan file and reports through the exit `0`/`3`/`4` contract above. +- `sce plan pr-description`: The offline CLI command that validates plans and prints their intent review brief through the [PR description command contract](#pr-description-command-contract). +- `sce plan review`: The offline CLI command that reads plans from a commit and prints their Decision Brief through the [review command contract](#review-command-contract). It replaced the former `sce plan decision-brief` name without an alias. +- `claims manifest`: The `sce.plan.claims.v1` JSON document that `sce plan review --claims` prints on stdout through the [claims manifest contract](#claims-manifest-contract). It holds one claim per acceptance criterion, identified as `#`, and records authored intent only, with no evidence or verdict. `claims.json` names this stdout document, not a file the command creates. +- `Decision Brief`: The marker-delimited Markdown layout that `BriefFormat::DecisionBrief` renders for a senior reviewer. Each marked decision shows its review question, its proposed decision statement, and one link to the decision line (see [Brief formats](#brief-formats)). +- `intent review brief`: The marker-delimited Markdown block that `sce plan pr-description` prints for a pull request. It lists the input plans and their review questions with links to exact decision lines. +- `review pair`: The optional `Review: required` and one-line `Review question:` fields on a design decision. They mark a decision that needs senior engineering judgment. +- `review question`: The `Review question:` value of a marked decision. Both briefs show it verbatim, with inline code spans kept and Markdown punctuation outside them escaped. +- `plan diagnostic`: One `PlanDiagnostic` from `validate`, with Pkl defect wording and an optional 1-based source line. +- `plan init scaffold`: The generated `config/plan-init-scaffold.md` plan skeleton rendered by `config/pkl/base/plan-init-scaffold.pkl` and embedded as `SCAFFOLD`. Untouched, it fails `sce plan check` with exactly its `Scenarios: TODO` and `Covers: TODO` reference defects (see the [plan structure contract](../sce/plan-structure-contract.md)). + +## Divergences from the Pkl line scanner + +The extractor follows CommonMark/GFM nesting. The Pkl checker requires fields at exactly `entry indent + 2` and accepts definitions at any indentation. Indentation-only nesting differences, `[X]` checkboxes, and multi-line field values can therefore extract differently. Agreement is pinned by the shared fixtures under `config/pkl/renderers/fixtures/plan-structure/`. The full divergence list and the shared rules live in the [plan structure contract](../sce/plan-structure-contract.md). + +## Tests + +`cli/src/services/plan/extract.rs` unit tests (`plan::extract`) read the shared plan-structure fixtures through `CARGO_MANIFEST_DIR/../config/pkl/renderers/fixtures/plan-structure`. They cover fenced examples, misplaced definitions, unstructured plans with fenced structured examples, source-sliced field values with lines, folded definition statements (`definition_statement_is_folded_source_text`), and the ID grammar. + +Module-level tests in `cli/src/services/plan/mod.rs` make the shared contract mechanical. `plan::fixture_expectations_match_manifest` runs every manifest row through `extract` and `validate` and requires that every fixture is listed. `plan::diagnostic_lines_match_source_positions` asserts exact diagnostic lines, and checks that a missing heading has no line. The `plan::command_*` tests drive `sce plan check` through the parser and stdout/stderr renderer for valid plans (by relative and absolute path), an invalid plan, an unstructured plan, a missing file, and a plan whose commands would create a marker file. `plan::plan_check_appears_in_help`, `plan::completion_scripts_include_plan_check`, `plan::plan_init_appears_in_help`, `plan::completion_scripts_include_plan_init`, `plan::plan_pr_description_appears_in_help`, and `plan::completion_scripts_include_plan_pr_description` cover help and completion. `plan::scaffold_untouched_fails_check` asserts the embedded scaffold has one `{plan-name}` token, defines no decision or scenario entries, and validates to exactly the three untouched-scaffold diagnostics. + +The `plan::init_*` tests run `sce plan init` in temporary `git init` repositories through the parser, `execute_with`, and the renderer: creation at the root with exact stdout, file content, and `git status` (`init_creates_scaffold_at_repository_root`); a nested start directory under a root containing a space and an apostrophe, whose third stdout line is executed by `sh` and dispatched back to `plan check` (`init_from_nested_directory_targets_repository_root`); a regular file and a dangling symlink left untouched with no temporary file (`init_never_overwrites_existing_entry`); application-validation, Clap-parse, and missing-argument rejections with an unchanged tree (`init_rejects_unsafe_names_without_writing`); and exit `4` outside a repository (`init_outside_repository_is_runtime_failure`). Three tests call `init::create_with` with injected operations: a cleanup failure after a real hard link exits `0` with the exact stdout, the exact warning line, the complete destination, and the temporary file left in place (`init_cleanup_failure_after_publication_warns_and_succeeds`); an injected link failure exits `4` with the original publish error and an empty `context/plans/` (`init_publication_failure_removes_temporary_and_keeps_error`); and a real conflict on an existing file with an injected cleanup failure exits `3` with `Plan already exists` plus the `Cleanup also failed` line, leaving the existing bytes unchanged (`init_publication_and_cleanup_failure_keeps_original_error_and_destination`). `plan::scaffold_authored_passes_check` shows that filling only `Scenarios:` and `Covers:` makes `check` pass while `TODO` placeholders remain. + +The `plan::pr_description_*` tests cover the brief. Four tests call `pr_description::render` on in-memory plans: an exact golden brief with three marked decisions and the scope warning for two plans given in reverse order (`pr_description_golden_multiple_plans`), an exact golden brief with two marked decisions and no warning (`pr_description_golden_two_decisions`), the no-marked-decision output (`pr_description_no_flagged_decisions`), and escaping that round-trips to the original question (`pr_description_escapes_markdown`). Four tests run the command in temporary `git init` repositories through the parser, `execute_with`, and the renderer. They cover byte-identical output for both path orders, invalid argument rejection before file access with exit `3` and empty stdout, four marked decisions across two plans that pass `sce plan check` and render with exit `0`, every question in order, and one scope warning, and the invalid-plan and missing-plan cases with exit `3` and `4`. + +The `plan::decision_brief_*` tests call `pr_description::render_format` with `BriefFormat::DecisionBrief` on in-memory plans. They cover an exact golden brief for one decision (`decision_brief_golden_one_decision`), an exact golden brief with a wrapped statement and an unmarked decision (`decision_brief_golden_two_decisions`), the zero-decision output (`decision_brief_no_marked_decisions`), an exact golden for inline code with flags, underscores, `#`, and backslashes next to escaped Markdown-sensitive plain text, literal backslashes, and an unmatched backtick (`decision_brief_escapes_markdown`), and byte-identical output for both plan orders with sorted plan and source decision order and one scope warning (`decision_brief_orders_plans_deterministically`). `decision_brief_scope_warning_spans_all_plans` checks exact goldens for both layouts, including the Decision Brief advisory and three-plan source index, with four marked decisions spread over three plans, none with more than two, for both input orders. `decision_brief_scope_warning_threshold_keeps_every_decision` renders both layouts for zero to eight marked decisions over four plans. It checks that the warning appears exactly once with the right count from three on and never below, and that every decision appears once, in order, with sequential numbers and correct line links. `decision_brief_rejects_empty_statement` shows that an empty marked statement fails the Decision Brief with exit `3`, while `sce plan check` and the intent review brief accept the plan. `decision_brief_stdout_ends_with_one_newline` passes `decision_brief_stdout_payload` output through the shared stdout writer. It checks that stdout equals the rendered brief and ends in `\n` but not `\n\n`. + +The `plan::claims_*` tests call `claims::render` on in-memory plans. `claims_golden_one_of_each` checks a byte-exact golden document. The other tests cover reference order, inspection-only criteria, unmarked and absent decisions, and `author_checked` values with fixed `"modality": "intended"`. They also cover every criterion regardless of the number of marked decisions, `"claims": []`, and rejection of repeated `Covers:`, `Scenarios:`, `Decisions:`, and `Rationale:` fields. + +Nine `plan::review_*` tests run `sce plan` against commits in temporary Git repositories. `review_reads_committed_plan` checks exact `sce plan review` stdout with one trailing newline, and byte-identical output after a working-tree edit. `review_renders_every_marked_decision` renders three and four marked decisions with exit `0` and every heading, and checks one scope warning for four. `review_claims_ignore_review_scope` checks that `--claims` on a commit with four marked decisions succeeds with every claim and no warning text. `review_rejects_invalid_git_inputs` covers ten argument, Git, and plan failures with their exit codes and empty stdout. `review_removed_name_shows_plan_help` checks that `sce plan decision-brief` exits `0` with stdout byte-identical to `sce plan --help`, empty stderr, and no Decision Brief. It also checks that `review` renders four marked decisions with the scope warning. `review_appears_in_help_and_completions` checks the `review [--claims]` `Plan Usage:` line, the `review` help, and `review` and `--claims` in `plan` help and the Bash, Zsh, and Fish completion scripts. No output contains `decision-brief`. `review_claims_reads_committed_plans` checks byte-identical claims stdout for both path orders and after a working-tree edit, sorted plans, and committed source lines. `review_claims_output_is_pure` checks one JSON value with one trailing newline, empty stderr, and an unchanged `git status --porcelain`. `review_claims_rejects_invalid_inputs` covers twelve argument, plan, repeated-field, and Git failures with exit `3` or `4`, empty stdout, and the exact repeated-`Covers:` stderr. diff --git a/context/context-map.md b/context/context-map.md index 40476a1da..9add32719 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -18,6 +18,7 @@ Feature/domain context: - `context/cli/agent-trace-storage.md` (async repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, shared degradation of invalid default-discovered config before identity fallback while explicit selections remain fatal, `ResolvedAgentTraceStorage` carrying repository identity + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, `resolve_agent_trace_storage{,_at_state_root}` setup/lifecycle entrypoints with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, a separate no-migration `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` pair that high-frequency hook callers use exclusively and that never runs migration `002` or any migration, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, strict never-touch boundary for any pre-migration checkout-scoped/global DB files, and active hook/runtime plus Agent Trace lifecycle setup call-site consumption after T08) - `context/cli/checkout-identity.md` (checkout identity infrastructure, `cli/src/services/checkout/` and its `/sce/checkout-id` UUIDv7 storage, was removed by the `remove-checkout-id` plan; documents the removal plus the never-touch on-disk handling of any pre-existing checkout-id/pre-migration checkout-scoped DB files left behind) - `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-line `session_id` and matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; repository structured-row reconstruction supplies persisted hunk-model and canonical touched-line-session provenance before these operations; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) +- `context/cli/plan-service.md` (read-only SCE plan structure extraction in `cli/src/services/plan/`: `markdown-rs` GFM `mdast` parsing into the owned `Plan`/`PlanEntry`/`PlanField` model with authoritative sections, source-sliced field values, 1-based lines, ignored `Code` nodes, and known divergences from the Pkl line scanner; pure `validate` rules with Pkl defect wording, line-anchored sorted diagnostics, the shared `expectations.tsv` fixture manifest, the `sce plan check ` command with its exit `0`/`3`/`4` output contract, the embedded plan init scaffold constant, the `sce plan init ` repository-root, no-clobber hard-link publication contract, and the `sce plan pr-description` intent review brief contract with its strict argument grammar, working-tree reads, non-blocking three-or-more review-scope warning, and escaping, and the shared `BriefFormat` selection pass with the `DecisionBrief` layout and its empty-statement check, and the commit-backed `sce plan review` command contract with its `--claims` `sce.plan.claims.v1` claims manifest contract: stable `#` identity, commit-backed provenance, intent-only claims with declared checks, and repeated-field rejection) - `context/cli/structured-patch-service.md` (Claude structured editor-hook derivation in `cli/src/services/structured_patch.rs`, including `Write` structured-update hunks, `Write` `tool_input.content` create fallback, `Edit` structured patches, deterministic skip reasons, `ParsedPatch` output semantics, Rust golden fixture coverage, and repository read-time enrichment that assigns persisted row `model_id` to each hunk and canonical row `session_id` to each touched line) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces including sync completion markers, and per-column right-to-left RGB gradient banner rendering) - `context/cli/agent-trace-auto-sync.md` (setup-enabled post-commit Agent Trace synchronization with explicit-false opt-out plus doctor readiness reporting: a newly created repo-local config supplies the explicit `true` opt-in while omitted runtime values resolve to `false`; the existing `sce sync` command is launched once through the current executable after local persistence, with detached null-standard-stream behavior, fail-open startup, canonical managed-block proof of hook readiness, stable text/JSON enabled/disabled/not-ready/not-applicable states, no daemon/queue/high-frequency trigger, and retryability through manual sync and control-plane cursor authority) @@ -58,7 +59,8 @@ Feature/domain context: - `context/sce/claude-raw-hook-capture.md` (removed feature: the former `sce hooks claude-capture` raw-capture route and its supporting types, replaced by the active `diff-trace` and `conversation-trace` intakes) - `context/sce/cli-observability-contract.md` (implemented config-backed runtime observability contract for the flat logging config-file shape with explicit config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` env-over-config-over-`/sce/logs` fallback, append-only local-date/session log file routing with a one-time complete-record `-v2.log` fallback on primary open/append/flush failure, creation-triggered retention of direct regular `*.log` files to 10 entries, reliable producer-native diff-trace/conversation-trace session routing and hook-specific non-duplicated Agent Trace DB-open error events, deterministic session filename sanitization, concrete `Logger` behavior plus the `NoopTelemetry`-backed telemetry capability boundary, logger and deliberately non-object-safe generic async telemetry boundaries with static capability dispatch and future-aware polling scope, AppContext observability wiring, generic `RunOutcome` final rendering, runtime-classified repeated telemetry action protection, operator-facing `sce config show` observability reporting, managed OTLP trace/log export (credential-gated, hardcoded canonical collector, allowlisted fields, bounded fail-open lifecycle, reserved metrics endpoint), and the trimmed `sce config validate` status-only validation surface) - `context/sce/shared-context-code-workflow.md` (canonical `/next-task` task-synchronization lifecycle and validation-only `/validate` lifecycle, package-local phase references with single-skill control flow, and the task-synchronization-scoped `sce-decision` sibling invocation with ADR reuse/blocker propagation) -- `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, package-local context-load/plan-authoring/template references, clarification/readiness gate contract, and one-task/one-atomic-commit task slicing) +- `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, package-local context-load/plan-authoring/template references, the structured plan format (design decisions with the optional review pair, verification scenarios, coverage reference fields, no legacy plan format), `sce plan init` new-plan creation with the completeness and mandatory `sce plan check` gates and no fallback, clarification/readiness gate contract, and one-task/one-atomic-commit task slicing) +- [Plan structure contract](sce/plan-structure-contract.md) (shared Pkl/Rust plan-structure checker contract: shared rule set, `config/pkl/renderers/fixtures/plan-structure/expectations.tsv` as the single fixture source of truth and how to add a fixture, `sce plan check` result and diagnostic wording shared with the Pkl defect strings, the both-checkers rewording rule, known AST-versus-line-scanner divergences, and Pkl staying generation-only while the CLI never invokes Pkl) - [Context workflow rules](sce/context-workflow-rules.md) (canonical bootstrap, ongoing context maintenance, task synchronization, hygiene, discoverability, and feature-existence rules) - `context/sce/plan-code-overlap-map.md` (overlap matrix for thin OpenCode Plan/Code routing agents and six workflow packages, including task-only context synchronization ownership) - `context/sce/dedup-ownership-table.md` (canonical owner-vs-consumer boundaries for six workflow packages, canonical phase modules, the shared synchronization skeleton, and thin OpenCode agents) @@ -189,3 +191,4 @@ Recent decision records: - `context/decisions/2026-08-18-sync-owned-progress-reporter-contract.md` (makes `services::sync::progress` the sole owner of the generic progress contract, no-op reporter, sync adapter, and focused tests; no top-level progress service remains) - `context/decisions/2026-08-20-general-unexpected-user-error-catalog.md` (records the closed `UserError` catalog entry with one static log-files guidance sentence, no dynamic path interpolation or arbitrary message variant; current `sce sync` adoption is documented in `context/sce/cli-error-code-taxonomy.md`) - `context/decisions/2026-08-07-git-hook-managed-block-cooperation.md` (SCE-installed git hooks are a bounded in-place editor, not an exclusive owner: hook ownership is decided structurally by the SCE managed-block marker pair or a legacy guidance-URL marker, a foreign hook's bytes are preserved as an exact prefix with the block appended after them, and coexistence with third-party hook managers is cooperative, not authoritative) +- `context/decisions/2026-10-25-plan-claims-manifest-v1-contract.md` (proposed: `sce plan review --claims` publishes the intent-only `sce.plan.claims.v1` JSON claims manifest with `#` claim identity, commit-backed provenance, and no evidence or verdict fields; the claim-identity review question in the `plan-review-claims` plan D4 stays open) diff --git a/context/decisions/2026-10-25-plan-claims-manifest-v1-contract.md b/context/decisions/2026-10-25-plan-claims-manifest-v1-contract.md new file mode 100644 index 000000000..b1bca2e9a --- /dev/null +++ b/context/decisions/2026-10-25-plan-claims-manifest-v1-contract.md @@ -0,0 +1,57 @@ +# Decision: Publish the intent-only sce.plan.claims.v1 claims manifest + +Date: 2026-10-25 +Status: Proposed +Plan: `context/plans/plan-review-claims.md` +Task: `T02, T03` + +## Context + +External assessors such as Jev need a machine-readable view of what a plan author intends a change to prove. The plan service already extracts and validates plans, and `sce plan review` already reads plans from a commit. The output must stay stable enough that assessors can join their own judgments to it across commits. Plan design decisions D2, D3, and D4 and the `## Claims JSON v1 contract` section define the shape. + +## Decision + +`sce plan review --claims` prints the versioned `sce.plan.claims.v1` JSON claims manifest on stdout as an external compatibility contract. The manifest records authored intent only. Each acceptance criterion gives one claim with the identity `#`, `kind` `acceptance_criterion`, and `modality` `intended`. The manifest has no evidence, verdict, confidence, risk, score, or priority field. Plans come only from the `--head` commit. External assessors join their judgments on `repository.slug`, `repository.head_sha`, and `claims[].id`. An incompatible change requires a new schema version. + +## Rationale + +The plan path and the AC ID stay stable across text edits and commits, because plan rules never renumber or reuse AC IDs. Judgments stay outside the manifest, so the authored source stays authoritative and an assessor can change without a schema change. Commit-backed reads make every line anchor and URL match `--head`. + +## Alternatives considered + +- **An identity that includes the commit SHA or line number** — every commit or edit changes it. +- **A content-hash identity** — any wording fix changes it. +- **A `verified` modality from checked boxes** — a checkbox is not execution evidence. +- **A `--format json` option or a separate `sce plan claims` command** — the change request requires one review command with a boolean selector. + +## Compatibility and risks + +- A plan rename or a criterion moved to another plan changes the claim identity, so assessor joins on the old identity break. +- A plan that `sce plan check` accepts can still fail `--claims` when an entry repeats a consumed field. +- D4 carries an open review question about whether `#` is an acceptable stable identity. This record stays `Proposed` until that review ends. + +## Guardrails + +- The command never runs `Validate:` or `Check:` content, never writes files, and never calls a model or the network. +- Typed `#[derive(Serialize)]` structs in `cli/src/services/plan/claims.rs` fix the property order. The module uses no `serde_json::Value` map and no `skip_serializing_if`. +- The Decision Brief limits and Markdown escaping never apply to the manifest. + +## Consequences + +- Assessors can attach evidence and verdicts without changes to the plan service. +- An acceptance criterion with several assertions stays one claim until a future atomization step exists. +- Each new field that claims need requires a typed-structure change, and an incompatible change requires a new schema version. + +## Follow-up + +- Resolve the D4 review question on claim identity. +- T04 documents the manifest contract in `context/cli/plan-service.md`. + +## References + +- Plan: [`plan-review-claims`](../plans/plan-review-claims.md) +- Task: `T02`, `T03` +- Current-state context: [`plan-service.md`](../cli/plan-service.md) +- Current-state context: [`cli-command-surface.md`](../cli/cli-command-surface.md) +- Evidence: [`claims.rs`](../../cli/src/services/plan/claims.rs) +- Evidence: [`command.rs`](../../cli/src/services/plan/command.rs) diff --git a/context/glossary.md b/context/glossary.md index 1bd61e54c..fd361ee22 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -1,5 +1,5 @@ # Glossary -- CLI execution terminology: [command dispatch, runtime dependencies and telemetry](cli/execution-glossary.md). +- CLI execution terminology: [command dispatch, runtime dependencies and telemetry](cli/execution-glossary.md). Plan-structure terminology (`sce plan check`, `sce plan init`, `sce plan pr-description`, `sce plan review`, `claims manifest`, `intent review brief`, `Decision Brief`, `review question`, `plan-structure expectations manifest`, `plan diagnostic`, `plan init scaffold`): [plan service terms](cli/plan-service.md#terms). - `pkl-check-generated`: Flake app exposed as `nix run .#pkl-check-generated`; canonical ephemeral-generation check that rejects committed target/schema/mirror outputs, evaluates exact workflow metadata, the generated artifact contract, semantic layout/path/inventory/content/parity/observational checks, and the optional-workflow manifest's content against the catalog, requires the shared helper-composition rule and SCE-scoped workflow prohibitions, enforces ordered catalog-derived OpenCode skill permissions plus explicit-permission artifact integrity, rejects stale sibling-package references or unresolved internalization tokens in workflow entrypoint `SKILL.md` documents, proves contract failures through checked-in negative fixtures, and delegates deterministic generation plus payload/input inventories to the generated-input producer while preserving its established inventory report. - `repo-level verification preference`: Current repository guidance that contributor-facing validation/check flows should prefer `nix flake check`; direct Cargo verification commands are secondary and used only when explicitly requested or for narrow targeted debugging, while `cargo fmt` remains the explicit autofix path. Required quick checks after each completed task in this repo are `nix run .#pkl-check-generated` and `nix flake check`. - disposable plan lifecycle: Policy where `context/plans/` holds active execution artifacts only; completed plans are disposable and durable outcomes must be reflected in current-state context files and/or `context/decisions/`. @@ -71,7 +71,7 @@ - `log_to_file`: Flat SCE config-file boolean controlling file-log emission and normal logger `stderr` routing. It defaults to `true`, is surfaced with source metadata by `sce config show`, and resolves independently from `log_dir`; enabled file logging routes normal logger records only to the file, while disabled file logging routes only error-level logger records to `stderr`. An omitted `log_dir` uses the default location, while an explicitly empty config value remains invalid. See [CLI observability contract](sce/cli-observability-contract.md). - `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|whoami`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), Control Plane `/me`-backed whoami profile output using flat email/name/role/permissions/organization labels, exact logged-out login guidance, implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). - `auth login stored-credential renewal`: The `sce auth login` behavior that first validates every stored credential through the existing non-forced token path, preserves valid credentials, refreshes expired credentials, and falls back to device authorization when credentials are absent or renewal fails. After WorkOS persistence, every successful fresh, valid-stored, or renewed login path issues and saves one replacement telemetry credential through the resolved Control Plane client; provisioning is best effort and unavailable status retains successful WorkOS authentication. Renewal reports retain `login` labels in text and JSON; credential renewal is not exposed as a public subcommand. The auth command boundary treats missing-credential logout/whoami calls as successful state queries, classifies authenticated Control Plane failures as `UserError::NotAuthenticated`, token-storage plus `AuthError::Io`/`Storage` failures as `UserError::AuthStorageUnavailable` (`auth.storage_unavailable`), and approved user-facing rendering/prompt fallbacks as `UserError::UnexpectedFailure` (`general.unexpected_failure`); fixed catalog messages expose no storage or implementation details while technical sources remain available for structured observability, and remaining auth-command failures use `UnexpectedFailure` with preserved technical sources. -- `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. +- `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `plan`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. - `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `hooks` and `policy`, while `auth` is visible, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. - `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends), validated through normal compile/test coverage. `[dev-dependencies]` now carries `quint-connect` (dev/test-only, for the in-progress mutation-cursor Quint Connect model-based-testing harness; see `context/plans/mutation-cursor-quint-connect.md`). - `progress reporter contract`: Library-independent, consumer-typed sync seam in `cli/src/services/sync/progress.rs` where `ProgressReporter` carries an arbitrary consumer event type, supports explicit successful finalization and closure-based collectors, and provides `NoopProgressReporter` for callers without human progress output. The sync-owned `SyncProgressEvent` in `cli/src/services/sync/sync.rs` carries lifecycle, accepted-batch, and stream-completion payloads for the three concurrent remote Agent Trace streams (`messages`, `parts`, `agent_traces`); `services::sync::progress` also owns the fixed `indicatif` terminal adapter and focused contract tests, while `sync/command.rs` selects text versus JSON behavior. No top-level `services::progress` module exists. @@ -135,7 +135,7 @@ - `SCE_LOG_DIR`: Optional runtime env key for `sce` observability log-directory configuration; when set, it overrides config-file `log_dir` and the `/sce/logs` default and must be non-empty. - `app startup phases`: Current `cli/src/app.rs` execution model that separates dependency checking, startup-context construction, runtime initialization, command parse/execute, and output rendering into named helpers while preserving the CLI's existing exit-code, stderr-diagnostic, and degraded-startup behavior; output rendering and execution-phase logging helpers live in `cli/src/services/app_support.rs`. - `RunOutcome`: Generic final render payload in `cli/src/services/app_support.rs` (`RunOutcome`) carrying a command result, optional startup diagnostic, and optional logger implementing the logger trait boundary. Production construction in `cli/src/app.rs` uses the concrete observability logger, while rendering is not hardcoded to that production type. -- `CommandRegistry`: Static command-name catalog in `cli/src/services/command_registry.rs`; populated by `build_default_registry()` and carried by `AppRuntime` during command dispatch. It exposes deterministic command-name membership for the current top-level command catalog (`help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) while actual parsed command payloads are represented by the `RuntimeCommand` enum rather than zero-arg boxed constructors. +- `CommandRegistry`: Static command-name catalog in `cli/src/services/command_registry.rs`; populated by `build_default_registry()` and carried by `AppRuntime` during command dispatch. It exposes deterministic command-name membership for the current top-level command catalog (`help`, `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `plan`, `version`, and `completion`) while actual parsed command payloads are represented by the `RuntimeCommand` enum rather than zero-arg boxed constructors. - `ServiceLifecycle`: Compile-safe lifecycle trait seam in `cli/src/services/lifecycle.rs` with default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`; it exposes lifecycle-owned health, fix, and setup result types, while doctor/setup adapt those records at orchestration boundaries before rendering command-owned output. The hooks service has `HooksLifecycle` for hook rollout diagnosis/fix/setup, the config service has `ConfigLifecycle` for global/repo-local config validation plus repo-local config bootstrap, local_db has `LocalDbLifecycle` for canonical local DB path health/bootstrap/setup, auth_db has `AuthDbLifecycle` for canonical auth DB path health/bootstrap/setup, and agent_trace_db has `AgentTraceDbLifecycle` for repository identity resolution, setup-time repository-scoped Agent Trace DB initialization, and repository Agent Trace DB path health/bootstrap, returning an actionable "requires a Git repository" diagnostic outside repository context (the former global parent fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the static provider catalog for `diagnose` and `fix`; setup command aggregates providers for `setup` in order (config → local_db → auth_db → agent_trace_db → hooks when requested). - `lifecycle provider catalog`: Shared factory in `cli/src/services/lifecycle.rs` (`lifecycle_providers(include_hooks)`) that returns static `LifecycleProvider` enum values in deterministic config → local_db → auth_db → agent_trace_db → hooks order, used by doctor with hooks included and by setup with hooks included only when requested. The enum owns concrete-provider dispatch for `id`, `diagnose`, `fix`, and `setup` without boxed provider trait objects or `&dyn HasRepoRoot` lifecycle context erasure. - `sce config command surface`: Implemented top-level CLI command routed by `cli/src/app.rs` to `cli/src/services/config/mod.rs` (with schema/file-parsing delegated to `cli/src/services/config/schema.rs`, runtime precedence resolution delegated to `cli/src/services/config/resolver.rs`, and text/JSON output construction delegated to `cli/src/services/config/render.rs`), exposing `show`, `validate`, and `--help` for deterministic runtime config inspection and validation; `show` reports resolved flat logging observability values with provenance, while `validate` reports pass/fail plus validation issues and warnings only. diff --git a/context/overview.md b/context/overview.md index ddc104775..ba1e699cd 100644 --- a/context/overview.md +++ b/context/overview.md @@ -45,7 +45,7 @@ for `/validate`. See This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated working-tree SCE config schema are not committed; versioned SCE config schema snapshots live under `schema/v/`. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 145-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its install-guidance helper (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`). The existing conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex` and retain their fail-open behavior where designed. The separate mutation-scope registrations route through `sce hooks codex-mutation-scope`: `PreToolUse` and `PostToolUse` use matcher `^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` omit matcher and are unmatched. The tracked mutation `PreToolUse` bootstrap fails closed if Git-root resolution, helper or `sce` discovery, adapter startup, runtime `Start`, or Bash-policy evaluation cannot establish attribution; mutation-scope `PostToolUse` and cleanup hooks do not use this bootstrap behavior. Delegation, MCP, and unknown tools do not match these generated mutation Pre/Post registrations. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash filesystem effects do not become `diff_traces` in this existing conversation/diff pipeline, and its apply_patch evidence has separate operation limitations. Separately, the Codex mutation-scope adapter tracks `Bash` and `apply_patch` executions as `TrackedMutation` scopes; `AiExclusive` means tracked-scope exclusivity, not that Bash authored every mutation in the interval. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). +It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, deterministic plan structure checking (`sce plan check `) and plan scaffold creation (`sce plan init `), offline pull request intent review briefs (`sce plan pr-description`), commit-backed Decision Briefs and `sce.plan.claims.v1` JSON claims manifests (`sce plan review [--claims]`), and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. @@ -61,7 +61,7 @@ The generated `/next-task` workflow persists task-level context-synchronization - **Install channels:** repo-flake Nix, Cargo, npm, and source-built Flatpak (`dev.crocoder.sce`); Homebrew deferred (see `context/sce/cli-first-install-channels-contract.md`). The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. `quint-connect` is the CLI's first dev-only dependency, added for the in-progress `mutation-cursor-quint-connect` model-based-testing harness. -Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|whoami`), with successful login paths provisioning and replacing the cached telemetry credential after WorkOS persistence on fresh, valid-stored, and renewed credentials while keeping provisioning best effort; authenticated whoami reads the Control Plane `GET /me` profile and renders flat email/name/role/permissions/organization labels, optional names and missing values handled deterministically, and exact login guidance returned when logged out, alongside config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Unknown command paths are successful help requests: top-level unknown tokens use the normal top-level help payload and nested unknown tokens use the closest valid parent's help surface, while unknown options and other parse/validation failures retain their existing errors. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. +Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|whoami`), with successful login paths provisioning and replacing the cached telemetry credential after WorkOS persistence on fresh, valid-stored, and renewed credentials while keeping provisioning best effort; authenticated whoami reads the Control Plane `GET /me` profile and renders flat email/name/role/permissions/organization labels, optional names and missing values handled deterministically, and exact login guidance returned when logged out, alongside config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, read-only plan structure checking (`sce plan check`), and the top-level `sync` command. Unknown command paths are successful help requests: top-level unknown tokens use the normal top-level help payload and nested unknown tokens use the closest valid parent's help surface, while unknown options and other parse/validation failures retain their existing errors. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. The current doctor presentation contract supersedes the earlier output-shape scaffolding wording above: human text uses the compact Environment/Repository/Integrations hierarchy with healthy rows collapsed and unhealthy branches expanded, while JSON retains complete path, identity, problem, and fix-result detail. See `context/sce/doctor-human-text-contract.md`. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. Auth command orchestration routes authenticated Control Plane authentication failures, token-storage failures, and approved user-facing fallback failures through typed `CliError::User` catalog entries (`NotAuthenticated`, `AuthStorageUnavailable`, `NotGitRepository`, and `UnexpectedFailure`) while retaining technical sources for observability; missing-credential logout/whoami state queries succeed with their documented text/JSON reports, and internal auth failures remain runtime errors. The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: `CliError::User` carries a closed catalog (`NotAuthenticated`, `NotGitRepository`, `NotGitRemote`, authentication-storage `AuthStorageUnavailable`, and general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. Setup preflight errors preserve technical sources, including the configured remote name, while raw remote URLs remain out of user-facing diagnostics. See `context/sce/cli-error-code-taxonomy.md` for the full contract. diff --git a/context/patterns.md b/context/patterns.md index eb5e1a770..3660bd68b 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -102,7 +102,7 @@ - Use `config/pkl/generate.pkl` as the single generation module for authored config outputs. Flatten self-contained workflow skill documents as `{skill slug}/{package-relative path}` so nested references are emitted deterministically without sibling-package dependencies. - Use `config/pkl/README.md` as the contributor-facing runbook for prerequisites, ownership boundaries, regeneration steps, and troubleshooting. - Run multi-file generation only into an explicit temporary output root, for example `nix run .#pkl-generate -- "$(mktemp -d)"`; never evaluate with `-m .`. -- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 145-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. +- Run ephemeral generation validation through `nix run .#pkl-check-generated`; it wraps the dev-shell script, rejects committed target/schema/mirror outputs, evaluates exact metadata plus the complete 146-path artifact/reference contract, the optional-workflow manifest assertion, and its negative fixtures, requires all supported target roots, and delegates canonical input discovery, two-pass generation, and inventories to `scripts/produce-cli-generated-input.sh`. - Keep this contract anchored to the root `nix flake check` `pkl-generated` derivation. The separate `codex-hook-command` check executes the generated Codex command from root and nested cwd, including spaced repository paths, and verifies unchanged STDIN plus silent fail-open behavior. Removed target paths are forbidden repository artifacts even though the same path names remain valid inside temporary payload roots. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - Keep `output.files` limited to payload-relative paths (`config/.opencode/{agent,command,skills,lib,plugins,opencode.json}`, `config/.claude/{commands,skills,hooks,settings.json}` with no Claude agents, `config/.pi/{prompts,skills,extensions}`, and the generated schema). Do not emit `config/automated/.opencode`. diff --git a/context/plans/plan-decision-brief.md b/context/plans/plan-decision-brief.md new file mode 100644 index 000000000..61032dce6 --- /dev/null +++ b/context/plans/plan-decision-brief.md @@ -0,0 +1,332 @@ +# Plan: plan-decision-brief + +## Change summary + +This plan adds a hidden `sce plan decision-brief --repo --head ...` command. It prints a short Markdown Decision Brief for a GitHub pull request. Each marked design decision shows its review question, its authored proposed decision, and one link to the decision line at the `--head` commit. The brief helps a senior reviewer judge the marked architectural choices in two to three minutes, without the PR diff. + +The change extends the plan service from PR #342. It reuses the `markdown-rs` extractor, the `validate` rules, the review-pair selection, the three-question limit, the URL builder, and the Markdown escaping in `pr_description.rs`. The extractor gains the decision statement. A format enum selects between the existing intent-review layout and the new Decision Brief layout. Unlike `sce plan pr-description`, the new command reads every plan from the Git object at `--head`, so each link matches the parsed content. `sce plan pr-description` keeps its working-tree reads and its byte-identical output. The plan also adds review-question authoring guidance to the canonical `/change-to-plan` template rules. + +## Design decisions + +- D1: The extractor owns the definition statement as `PlanEntry.statement`. It is the source slice after `ID:` in the definition's first paragraph, with whitespace runs folded to one space and trimmed. + - Boundary: `cli/src/services/plan/extract.rs` produces `statement` in the same `definition` path that parses the ID. Renderers read `statement` and never parse Markdown or scan source lines. + - Rationale: `parse_id_token` already returns the text after `ID:`. Keeping it removes a second parse and keeps the rules for authoritative sections, fenced examples, lines, and order unchanged. + - Rejected: A regex or line scan for `- Dn:` in the renderer — this duplicates the extractor and ignores fenced examples. An `Option` only on decisions — every kind has the same opening paragraph, and one shape is simpler. + - Trade-offs: Every entry carries a string that only the Decision Brief uses. Folding whitespace changes the display of authored line breaks, but it keeps every word. +- D2: `pr_description.rs` keeps one selection pass and one argument grammar for both commands. A `BriefFormat::{IntentReview, DecisionBrief}` enum selects only the layout. A marked decision with an empty statement fails only in the `DecisionBrief` layout. + - Boundary: `pr_description.rs` owns selection, plan ordering, the limit, URLs, escaping, and both layouts. `command.rs` owns I/O and passes the format. `validate.rs` does not change. + - Rationale: One selection pass makes both commands agree on which decisions are marked. A static enum follows the repository's static dispatch rule. The empty-statement check stays out of `validate`, so it cannot invalidate unrelated plans. + - Rejected: A second renderer module — it duplicates selection and escaping. A renderer trait or `dyn` dispatch — the repository uses enums for static dispatch. + - Trade-offs: One `render` function carries two layouts. The `pr-description` call sites change to pass `BriefFormat::IntentReview`. +- D3: `decision-brief` reads each plan only from the `--head` commit through Git plumbing at the repository root. It runs `git rev-parse --verify --quiet ^{commit}`, then `git ls-tree -z -- `, and accepts only a regular blob with mode `100644` or `100755`. It then runs `git cat-file blob ` and decodes the bytes as UTF-8. `pr-description` keeps its working-tree reads. + - Boundary: `cli/src/services/plan/command.rs` owns the Git process calls, with arguments passed directly and no shell. Extraction, validation, and diagnostics reuse the shared load path after the source text is read. `--repo` is a caller-supplied GitHub repository identifier, and `--head` must identify a commit that is available in the local repository. The generated source links refer to the supplied repository and commit. The command guarantees local source integrity: the parsed plan content and the source line numbers match the local `--head` commit. It does not guarantee remote link availability: it does not check that the commit was pushed to GitHub or exists in the `--repo` repository. + - Rationale: Links are built from `--head`, so the parsed text must come from `--head`. A tree entry check rejects symlinks, and Git resolves paths only inside the commit tree, so no local filesystem path is followed. + - Rejected: `git show :` — it returns a symlink blob's target text as content without an error. A working-tree read with a cleanliness check — it fails on unrelated edits and still reads the wrong source. The GitHub API — the command stays offline. + - Trade-offs: The commit must exist locally, and uncommitted plan edits are invisible to the brief. Each plan costs Git process starts. `pr-description` keeps the stale-link risk that its own D5 accepted. Because the command stays offline, published links work only when the `--head` commit is available in the named `--repo` repository. A wrong `--repo` or an unpushed commit yields a well-formed brief whose links do not resolve on GitHub. The command adds no GitHub API call, network check, or remote-discovery heuristic to close this gap. +- D4: The command is hidden with Clap `hide = true` and has no `Plan Usage:` line. Completion rendering rebuilds the `plan` command without its hidden subcommands, because `clap_complete` 4.6.7 emits hidden subcommands. + - Boundary: `cli/src/cli_schema.rs` marks `PlanSubcommand::DecisionBrief` hidden. `cli/src/services/completion/mod.rs` removes hidden subcommands under `plan` only. Hidden `hooks` subcommands keep their current completion output. + - Rationale: `hide = true` already removes the command from Clap help. `clap_complete::aot::generator::utils::subcommands` iterates every subcommand, so the completion command needs explicit pruning. + - Rejected: Post-processing the generated script text — it depends on each shell's output shape. Pruning every hidden subcommand in the tree — it changes the completion output of the hidden `hooks` adapters, which this change does not own. + - Trade-offs: The rebuilt `plan` command must copy the settings that completion uses. Hiding is discoverability only, so any user who knows the name can run the command. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: `pr_description::render` with `BriefFormat::DecisionBrief` prints the shared `` marker, followed on the next line by `## Decision Brief`, then `**Review requested:** N decisions`, the `**Plans:**` link line, one `### N. {question}` subsection per marked decision with its `**Proposed:** {statement}` line and `[Review {label} {Dn} — rationale, alternatives and trade-offs]({url}#L{line})` link, and the shared `` marker. Every element after the heading is separated from the one before it by one blank line. No three newlines appear in a row, and the output ends with exactly one newline. `sce plan decision-brief` stdout also ends with exactly one newline. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::decision_brief_` + - Decisions: D1, D2 + - Scenarios: S1, S2, S3 +- [x] AC2: Both commands select the same decisions. A decision is selected only when it carries `Review: required` and `Review question:`. Plans are sorted by path, decisions keep source order, and numbering starts at 1. Four or more marked decisions fail with exit `3`, name every marked decision, and print nothing on stdout. A marked decision with an empty statement fails the Decision Brief with exit `3`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::decision_brief_` + - Decisions: D2 + - Scenarios: S4, S5, S6 +- [x] AC3: `sce plan decision-brief` parses and links the plan content at the `--head` commit, never the working tree. A missing commit, a missing blob, a non-regular tree entry, and non-UTF-8 content fail with exit `4`. Invalid arguments, duplicate paths, and structurally invalid plans fail with exit `3`. Stdout is empty on every failure. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::decision_brief_` + - Decisions: D3 + - Scenarios: S7, S8 +- [x] AC4: `sce plan decision-brief` and `sce plan decision-brief --help` run directly. The command is absent from `sce --help`, from `sce plan --help`, from the custom `Plan Usage:` lines, and from the Bash, Zsh, and Fish completion scripts. The existing hidden `hooks` adapters keep their completion output. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::decision_brief_hidden` + - Decisions: D4 + - Scenarios: S9, S10 +- [x] AC5: `sce plan pr-description` keeps its working-tree reads and its exact output, identical to the baseline implementation at `7cb9552f99e8d815850bdca8c89d0c68e8586004`. Every `plan::pr_description_` test that exists at that baseline passes with its original golden expectations unchanged. New Decision Brief tests may be added to `cli/src/services/plan/mod.rs`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::pr_description_`, and `git diff 7cb9552f99e8d815850bdca8c89d0c68e8586004 -- cli/src/services/plan/mod.rs` shows no removed or changed line inside an existing `pr_description_` test, including its expected strings. Added `decision_brief_` tests are allowed. + - Decisions: D2 + - Scenarios: S11 +- [x] AC6: Every extracted entry carries a `statement` equal to its whitespace-folded opening paragraph after `ID:`. Entry order, lines, authoritative sections, and fenced-example handling are unchanged. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml plan::extract` + - Decisions: D1 + - Scenarios: S12 +- [x] AC7: The canonical `Design decision rules` tell plan authors to ask one specific accept-or-reject question about an architectural choice and its consequence, to mark only decisions that need senior judgment, and to not repeat a concern. The structural review-pair rules do not change. + - Validate: inspection of `config/pkl/base/workflow-change-to-plan.pkl` under `## Design decision rules`, and `nix run .#pkl-check-generated`. + - Scenarios: none (inspection) +- [x] AC8: The dogfood brief for all five PR #342 plans, `plan-decisions-and-verification-scenarios.md`, `sce-plan-check.md`, `sce-plan-init.md`, `plan-pr-description.md`, and `plan-decision-brief.md`, at `git rev-parse HEAD` links all five plans and shows `**Review requested:** 3 decisions` with exactly three subsections in sorted plan order and source decision order: `plan-pr-description` D5 (line 31), `sce-plan-init` D4 (line 50), and `sce-plan-init` D6 (line 72). `plan-decision-brief.md` contributes no marked decision. Each decision link line is the line of that `- Dn:` definition in `git show :`. `HEAD` must be a commit that contains the removal of the D3 and D4 review markers from `plan-decision-brief.md`; at `7ca0a187` the brief would have five marked decisions and fail the limit. + - Validate: `nix run .#sce -- plan decision-brief --repo crocoder-dev/shared-context-engineering --head "$(git rev-parse HEAD)" context/plans/plan-decisions-and-verification-scenarios.md context/plans/sce-plan-check.md context/plans/sce-plan-init.md context/plans/plan-pr-description.md context/plans/plan-decision-brief.md` + - Decisions: D3 + - Scenarios: S13 +- [x] AC9: `context/cli/plan-service.md` documents the hidden command, its reviewer audience, its exact output, its relation to `pr-description`, the three-question limit, commit-backed reads, its offline read-only behavior, that hiding is not access control, and that review intent is not verified implementation evidence. It also states the D3 link limitation: `--repo` is caller-supplied and `--head` must be a local commit; the command guarantees that parsed content and line numbers match the local commit, but it does not verify that the commit was pushed to GitHub or exists in `--repo`, so published links work only when that commit is available in the named repository. + - Validate: inspection of the `Decision brief command contract` section in `context/cli/plan-service.md`. + - Scenarios: none (inspection) + +### Verification scenarios + +- S1: Golden Decision Brief for two marked decisions + - Given: One in-memory structured plan with two marked decisions, one of which has a wrapped opening paragraph. + - When: `render` runs with `BriefFormat::DecisionBrief`. + - Then: The output equals the expected string byte for byte, including both questions, both folded statements, both decision IDs, both `#L{line}` anchors, the blank lines, the shared `sce:intent-review` markers with the heading on the line after the start marker, and one trailing newline. + - Check: `plan::decision_brief_golden_two_decisions` and the one-decision variant `plan::decision_brief_golden_one_decision`, created by T02. +- S2: No marked decisions + - Given: Valid plans with no `Review: required` decision. + - When: `render` runs with `BriefFormat::DecisionBrief`. + - Then: The output has `**Review requested:** 0 decisions`, the plans line, and `No decisions explicitly flagged for review.`, and it has no `###` subsection and no approval or safety claim. + - Check: `plan::decision_brief_no_marked_decisions`, created by T02. +- S3: Markdown-sensitive text stays literal + - Given: A marked decision whose question and statement contain `` ` * _ [ ] < > | \ `` characters. + - When: `render` runs with `BriefFormat::DecisionBrief`. + - Then: Each listed character has one backslash before it, and removing those backslashes gives the authored words. + - Check: `plan::decision_brief_escapes_markdown`, created by T02. +- S4: Ordering and determinism + - Given: Two plans given in reverse lexicographic order, each with marked decisions. + - When: The brief is rendered twice with that order and once with the sorted order. + - Then: The three outputs are byte-identical, plans are linked in sorted order, and questions follow sorted plan order and source decision order. + - Check: `plan::decision_brief_orders_plans_deterministically`, created by T02. +- S5: Three-question limit + - Given: Plans with exactly three marked decisions, and plans with four marked decisions. + - When: The command runs on each set. + - Then: Three succeed with three subsections. Four fail with exit `3`, the message states `4` and names all four `