diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-build.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-build.yaml index 6c13bc19..40ec6551 100644 --- a/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-build.yaml +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-build.yaml @@ -23,7 +23,8 @@ nodes: # --------------------------------------------------------------------------------------------- # bootstrap — parse the slug from $ARGUMENTS, read config, confirm issues.json exists. # A prompt node (NOT a script) so it needs no plugin-lib import and no $CLAUDE_PLUGIN_ROOT - # (ADR-0023 §5): the agent reads .archon/unic-dlc.config.yaml with its own tools. + # (ADR-0023 §5): the agent reads .archon/unic-dlc.config.yaml with its own tools. It also passes the + # whole `sdlc_needs` block downstream and runs the declared install once for the run (#430). # --------------------------------------------------------------------------------------------- - id: bootstrap depends_on: [] @@ -37,8 +38,8 @@ nodes: 2. Read `.archon/unic-dlc.config.yaml` at the repo root. - If it is absent or unreadable → status "no-config". - Otherwise read: `artifacts_dir` (default "workflows"), `gates.build` (default "hitl"), - `build.e2e_command` (may be null), `build.coverage_threshold` (may be null), - `project.branching` (default "gitflow"). + `build.coverage_threshold` (may be null), `project.branching` (default "gitflow"), and the + whole `sdlc_needs` block. 3. Confirm the build baton exists: `//issues.json`. - If it is absent → status "no-issues". 4. If slug, config, and issues.json are all present → status "ready". @@ -47,9 +48,37 @@ nodes: Emit it in EVERY branch (fall back to "develop" when config cannot be read) — the implement-review-precheck node pins its code-review fixed point to it. - Emit ONLY the structured object. `test_command` is the repo default test command you can infer - (e.g. "pnpm test") for the verification node; leave it null if you cannot infer one. Emit every - required field in every branch, so the object validates before a guard can cancel. + ## The `sdlc_needs` block — a key names a need, never a tool + Every key in it names a NEED of this project's development process. `test` names the need; + whichever runner this project uses is the tool that serves it, and this Box names none. Pass the + block downstream as ONE object carrying ALL NINE keys — `install`, `build`, `test`, `e2e`, `lint`, + `format`, `typecheck`, `dev`, `coverage` — every time. Copy each value the config sets verbatim: + rename nothing, rewrite nothing, infer nothing. Emit `null` for every key the config omits. A + config may carry a partial block, or none at all, and both are normal — a tenant hand-edits this + file and a key added by a later release reaches them only at their next `reconfigure`. What is + never allowed is emitting fewer than nine keys: this node's schema requires all of them, so a + short object fails validation and aborts the run before a guard can cancel it cleanly. A key that + arrives null stays null. What happens next depends on the node: one that wants that need reports + an **unresolved** check, one that does not want it reports nothing at all, and neither fabricates + a command — a guessed command reports a result that means nothing. + + ## Install once, and say what you did + One Archon worktree serves this whole run, including the RED and GREEN fresh contexts inside it, + and it may or may not already carry this project's dependencies. So install here, once, and + nowhere else. When status is "ready", run `sdlc_needs.install` from the repo root if it is + declared, and set `install_report` to: + - declared, exit 0 → "ran the declared install command: ok" + - declared, non-zero → "ran the declared install command: FAILED — " + - not declared → "sdlc_needs.install is not declared, so nothing was installed. + Dependencies may still be present in this worktree by chance, and a + later green check here is not proof of a deliberately built + environment." + A failed install does not fail this node — report it and carry on, so the failure reaches a human + through the report rather than killing the run. On any status other than "ready", set + `install_report` to "not attempted ()". + + Emit ONLY the structured object. Emit every required field in every branch, so the object + validates before a guard can cancel. output_format: type: object properties: @@ -61,12 +90,24 @@ nodes: gate: type: string enum: [hitl, afk] - e2e_command: { type: [string, 'null'] } coverage_threshold: { type: [number, 'null'] } - test_command: { type: [string, 'null'] } + sdlc_needs: + type: object + properties: + install: { type: [string, 'null'] } + build: { type: [string, 'null'] } + test: { type: [string, 'null'] } + e2e: { type: [string, 'null'] } + lint: { type: [string, 'null'] } + format: { type: [string, 'null'] } + typecheck: { type: [string, 'null'] } + dev: { type: [string, 'null'] } + coverage: { type: [string, 'null'] } + required: [install, build, test, e2e, lint, format, typecheck, dev, coverage] + install_report: { type: string } expected_base: { type: string } message: { type: string } - required: [status, slug, artifacts_dir, gate, expected_base, message] + required: [status, slug, artifacts_dir, gate, sdlc_needs, install_report, expected_base, message] # --------------------------------------------------------------------------------------------- # guard — expected precondition failures cancel cleanly (ADR-0011 / schema: cancel vs fail). One @@ -291,8 +332,14 @@ nodes: the committed test + the slice INTENT. 1. Write the MINIMUM implementation to make the committed test pass, per `implement` and `tdd`'s "red before green / one slice at a time" rules: no speculative features, no - extra abstractions, no unrelated files. Run typechecking and the single test file as you - go, as `implement` prescribes. + extra abstractions, no unrelated files. Type-check and run the single test file as you + go, as `implement` prescribes. Type-check with the `typecheck` key of + `$bootstrap.output.sdlc_needs`. **A key in that block names a NEED of this project's + development process, never a tool** — so run exactly the command it holds, and where it + is null, or its command will not run, record the type-check as **unresolved** in this + iteration's `notes` and carry on. Never fabricate a command to fill a null key: this loop + is not a gate, an unresolved check here stops nothing, and a guessed command reports a + result that means nothing. 2. Run `test_command`. It MUST now pass (exit 0). If it does not, iterate on the impl only (never weaken the test) until green, within this iteration. 3. Stage the implementation file(s) BY NAME per Step 2.5 and commit: @@ -399,26 +446,82 @@ nodes: prompt: | You are the verification node of /build for slug "$bootstrap.output.slug". - 1. Run the full test suite: use `$bootstrap.output.e2e_command` if non-null, else - `$bootstrap.output.test_command`, else `pnpm test`. Report pass/fail counts. + ## A key names a need, never a tool + `$bootstrap.output.sdlc_needs` carries the commands this project declared. Read the key you want + and run exactly that command. Fabricate nothing for a key that is null, append no flag of your own + to a key that is set, and name no tool anywhere in this node: a need this project does not declare + is an **unresolved** check, and unresolved is never a pass. + + 1. Run the test suite: the `test` key of `$bootstrap.output.sdlc_needs`, from the repo root. + Report pass/fail counts and set `test_outcome`: + · the key is null, or the command will not run → "unresolved" + · exit 0 → "pass" + · non-zero → "fail" + A "pass" that reports ZERO tests goes in the verdict as **suspicious**, never as a green + result — that is the shape a command declared against the wrong target takes. + 1b. Run the end-to-end suite as well when the `e2e` key is declared. This is IN ADDITION to step + 1, never instead of it. Report its pass/fail counts in the verdict prose + whatever the result, as step 1 does — a green run carries the counts it reported, and one + reporting ZERO tests goes in the prose as **suspicious** rather than as a pass. A red e2e is a + blocker, so it goes in `failures`; one that will not run + goes in `unresolved_needs`; either way it belongs in the verdict prose with its counts. When + `e2e` is null this node wants no e2e run at all and reports nothing about it — silence here + means "not wanted", which is not the same fact as "could not run". 2. Stub scan — inspect the diff against the base branch (`git diff --name-only $BASE_BRANCH...HEAD`, source files only: .mjs/.js/.ts/.py/.rb/.go/.rs). Flag any of these left in changed source: - `TODO` / `FIXME` comments - Python `pass` as a whole statement - bare `return;` / `return null` / `return undefined` / `return None` used as a stub - 3. If `$bootstrap.output.coverage_threshold` is non-null, run coverage and compare. - 4. Print a verdict block: - verification: PASSED ✓ (tests green, no stubs, coverage ≥ threshold) + 3. Coverage. When `$bootstrap.output.coverage_threshold` is non-null, run the `coverage` key of + `$bootstrap.output.sdlc_needs` and compare the total against that threshold. A figure under it + is a blocker for `failures`; a null `coverage` key, or one whose command will not run, goes in + `unresolved_needs`; the figure itself belongs in the verdict prose. A run that reports 0%, or + no covered files at all, goes in the prose as **suspicious** rather than as a figure — that is + the shape a coverage command pointed at the wrong target takes. When no threshold is + configured this node wants no coverage figure and reports nothing about it. + 4. Print a verdict block. PASSED means exactly what step 5's rule means, so cite that rule rather + than paraphrasing it — a gloss in other words drifts from the rule it glosses: + verification: PASSED ✓ (none of step 5's (a), (b) or (c) is true) or verification: FAILED ✗ — + or + verification: UNRESOLVED — + Three labels, because there are three states and the human at build-pr-gate is the reader who + most needs them apart: print UNRESOLVED when nothing failed and the test simply could not run. + Printing FAILED there puts a red word beside an empty list of failures. Name each need that + came back "unresolved" in the block whichever label it carries, so this prose says which + checks did not run rather than leaving that to the report node. 5. Return the verdict block from step 4, plus everything you found worth reporting (counts, coverage figure, the stub list), as the `verdict` string — that field is the prose the - report node reads, so it must stand alone. Then set a `passed` boolean (true only if the - suite is green AND no stubs were found AND coverage, if configured, meets the threshold) - and a `failures` array of short strings, one per distinct failure. List blockers ONLY in - `failures` — never a non-blocking note. The prose is for the human at build-pr-gate; the - `evidence` node reads `passed` and `failures` and withholds on either signal, and + report node reads, so it must stand alone. Then set a `passed` boolean and a + `failures` array of short strings, one per distinct failure. + + **`passed` is false when any ONE of these is true, and true otherwise. This is the whole + rule — nothing else lowers it.** + a. `test_outcome` is not "pass". The floor covers both a red suite and one that could not + run: an unresolved suite is not an absence of failures. + b. You found a stub in step 2. + c. An optional check you actually ran — the e2e suite, or coverage against a threshold — + came back red. + An optional outcome that is **"unresolved" leaves `passed` alone**. Outside the `test` floor + no need is mandatory, so a check this project does not declare must never hold the evidence + gate. This node runs up to three separate commands, which is why (c) exists at all: "the suite + is green" is not one fact here, and a failing e2e beside a passing test suite is a red build + that `evidence` would otherwise certify. + + `failures` carries blockers ONLY — never a non-blocking note — so it holds one entry per + condition (a) with a red suite, (b), or (c). **A need that came back "unresolved" goes in + `unresolved_needs` and NEVER in `failures`**, however blocking it feels. `failures` means + something ran and was red; an empty `failures` beside a non-"pass" `test_outcome` is exactly + how `evidence` tells a check that could not run apart from one that failed, and putting the + need in both collapses its three states back into two. **Emit + `unresolved_needs` in every case**, as an empty array when nothing was unresolved — the schema + requires the field, so omitting it aborts the run instead of reporting a clean result. The + prose is for the human at build-pr-gate. The `evidence` node reads `passed` and `failures` from both + of us, plus `verification`'s three-state `test_outcome`, and withholds unless both verdicts + are green AND that test outcome is "pass"; its own verdict keeps `unresolved` and `fail` + apart rather than calling both red, and `evidence_policy` then gates on the file that node writes. Do not fix anything here; just report. The report and gate nodes consume this verdict. output_format: @@ -429,7 +532,13 @@ nodes: failures: type: array items: { type: string } - required: [verdict, passed, failures] + test_outcome: + type: string + enum: [pass, fail, unresolved] + unresolved_needs: + type: array + items: { type: string } + required: [verdict, passed, failures, test_outcome, unresolved_needs] # --------------------------------------------------------------------------------------------- - id: goals-check @@ -456,8 +565,11 @@ nodes: field is what the report node pastes verbatim, so the matrix must be inside it. Then set a `passed` boolean (true only when every criterion is COVERED) and a `failures` array, one short string per MISSING criterion — blockers ONLY, never a non-blocking note. The prose is - for the human at build-pr-gate; the `evidence` node reads `passed` and `failures` and - withholds on either signal, and `evidence_policy` then gates on the file that node writes. + for the human at build-pr-gate. The `evidence` node reads `passed` and `failures` from both + of us, plus `verification`'s three-state `test_outcome`, and withholds unless both verdicts + are green AND that test outcome is "pass"; its own verdict keeps `unresolved` and `fail` + apart rather than calling both red, and + `evidence_policy` then gates on the file that node writes. output_format: type: object properties: @@ -535,8 +647,38 @@ nodes: const verificationGreen = verificationPassed === true && verificationFailures.length === 0 const goalsCheckGreen = goalsCheckPassed === true && goalsCheckFailures.length === 0 - if (!verificationGreen || !goalsCheckGreen) { - console.log(`evidence: withholding — verification(passed=${verificationPassed}, failures=${verificationFailures.length}) goals-check(passed=${goalsCheckPassed}, failures=${goalsCheckFailures.length})`) + // A check that could not run is `unresolved`, and unresolved is never a pass: an absent result + // and a green result are not the same fact (#430). `test` is the only need whose outcome is + // mandatory here — every other need reports and none of them holds this gate. + const testOutcome = '$verification.output.test_outcome' + const unresolvedNeeds = $verification.output.unresolved_needs + + // Three states in precedence order: a real failure is `fail`; a floor check that could not run + // is `unresolved`; only two green verdicts with a passing test are `pass`. The unresolved branch + // is tested FIRST because `verificationGreen` is already false whenever the test is unresolved — + // verification sets `passed` false for it — so testing green first would report an absent result + // as a failure. + let verdictState + if (testOutcome === 'unresolved' && verificationFailures.length === 0 && goalsCheckGreen) { + verdictState = 'unresolved' + } else if (verificationGreen && goalsCheckGreen && testOutcome === 'pass') { + verdictState = 'pass' + } else { + verdictState = 'fail' + } + + // PRINT the verdict as JSON: a `script:` node has no `output_format`, so what this node writes + // to STDOUT is its whole output. That makes stdout the machine channel and nothing else may go + // there — every human line below uses console.error, which the run log still shows. Mixing one + // progress line into stdout leaves a reader a string that is not JSON, and the reader is a + // `when:` expression with nowhere to put a parse failure. Nothing reads it yet: the `when:` that + // will consume it belongs to the ticket that makes this file unconditional, and a field with no + // reader here is that hand-off, not an oversight (#430 criterion 8). + console.log(JSON.stringify({ verdict: verdictState, test_outcome: testOutcome, unresolved_needs: unresolvedNeeds })) + + if (verdictState !== 'pass') { + console.error(`evidence: withholding — verdict=${verdictState} verification(passed=${verificationPassed}, failures=${verificationFailures.length}, test=${testOutcome}) goals-check(passed=${goalsCheckPassed}, failures=${goalsCheckFailures.length})`) + if (unresolvedNeeds.length > 0) console.error(`evidence: unresolved needs — ${unresolvedNeeds.join(', ')}`) process.exit(0) } @@ -545,17 +687,20 @@ nodes: // fields the gate checked rather than having to know which fields it consulted. const evidence = { slug: '$bootstrap.output.slug', + verdict: verdictState, + test_outcome: testOutcome, + unresolved_needs: unresolvedNeeds, verification_failures: verificationFailures, goals_check_failures: goalsCheckFailures, } writeFileSync(evidencePath, JSON.stringify(evidence, null, 2)) - console.log(`evidence: wrote ${evidencePath} ✓`) + console.error(`evidence: wrote ${evidencePath} ✓`) // Mirror into the repo-relative Session dir so a reviewer (and open-pr) can see it — // $ARTIFACTS_DIR resolves OUTSIDE the repo tree and is gone once /cleanup prunes the worktree. mkdirSync(sessionDir, { recursive: true }) copyFileSync(evidencePath, mirrorPath) - console.log(`evidence: mirrored to ${mirrorPath} ✓`) + console.error(`evidence: mirrored to ${mirrorPath} ✓`) # --------------------------------------------------------------------------------------------- - id: report @@ -565,15 +710,23 @@ nodes: prompt: | You are the report node of /build for slug "$bootstrap.output.slug". - Write `$bootstrap.output.artifacts_dir/$bootstrap.output.slug/report.md` with exactly five + Write `$bootstrap.output.artifacts_dir/$bootstrap.output.slug/report.md` with exactly six sections: - 1. What Was Built — 3-5 bullets referencing each issue id + title implemented this cycle. + 1. Checks That Could Not Run — its own section, first, and present even when it is empty. As a + list, never as prose in another section: + - one line per need in `$verification.output.unresolved_needs`, naming the need; + - `$bootstrap.output.install_report`, verbatim. + Write "every declared check ran" when `unresolved_needs` is empty. A check that could not + run is the fact a reader skims past when it is a sentence inside a paragraph, which is why + it is a block of its own and why it goes above what was built. + 2. What Was Built — 3-5 bullets referencing each issue id + title implemented this cycle. If goals-check reported MISSING criteria, lead with "⚠ goals-check found N uncovered criteria — see matrix below." - 2. Goals-Check Coverage Matrix — paste $goals-check.output.verdict verbatim. - 3. Test Outcomes — pass/fail counts + coverage from $verification.output.verdict; note - skipped/flaky. - 4. Decisions Made — technical decisions worth recording; for each significant one, draft an + 3. Goals-Check Coverage Matrix — paste $goals-check.output.verdict verbatim. + 4. Test Outcomes — open with `test: $verification.output.test_outcome`, then the counts, the + coverage figure and any e2e result from $verification.output.verdict; note skipped/flaky. A + need reported "unresolved" is named in section 1; this section carries what ran. + 5. Decisions Made — technical decisions worth recording; for each significant one, draft an ADR inline (docs/adr/NNNN-.md) for review. Also fold in, verbatim: - `$implement-review-precheck.output.findings` — the local code-review pre-check's `## Standards` / `## Spec` axes. Label the subsection "Local code-review pre-check (not @@ -581,7 +734,7 @@ nodes: - every `seam chosen: ` line in build-state.json's per-slice `notes`. These are the seams an agent picked for `test_command_planned` slices, with no human approval on record — list them so /pr-review and /improve-architecture can audit them. - 5. Tech Debt Flagged — accepted stubs, known limits, shortcuts (feed into /cleanup). + 6. Tech Debt Flagged — accepted stubs, known limits, shortcuts (feed into /cleanup). Print: "report written to $bootstrap.output.artifacts_dir/$bootstrap.output.slug/report.md". # --------------------------------------------------------------------------------------------- diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-pr-review.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-pr-review.yaml index dadd70bc..b3868cca 100644 --- a/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-pr-review.yaml +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-pr-review.yaml @@ -41,10 +41,47 @@ nodes: `pr-review.inline_comments` (default true) `docs.type` / `docs.access` (for intent composition from docs) `project.branching` (default "gitflow") + the whole `sdlc_needs` block 3. If slug and config are both present → status "ready". (There is no PRD precondition: intent is composed from whatever sources resolve — see prep.) Compute `expected_base` from branching: "develop" when branching == "gitflow", else "main". + + ## The `sdlc_needs` block — a key names a need, never a tool + Every key in it names a NEED of this project's development process. `test` names the need; + whichever runner this project uses is the tool that serves it, and this Box names none. Pass the + block downstream as ONE object carrying ALL NINE keys — `install`, `build`, `test`, `e2e`, `lint`, + `format`, `typecheck`, `dev`, `coverage` — every time. Copy each value the config sets verbatim: + rename nothing, rewrite nothing, infer nothing. Emit `null` for every key the config omits. A + config may carry a partial block, or none at all, and both are normal — a tenant hand-edits this + file, and a key added by a later release reaches them only at their next `reconfigure`. What is + never allowed is emitting fewer than nine keys: this node's schema requires all of them, so a + short object fails validation and aborts the run before a guard can cancel it cleanly. A key that + arrives null stays null. What happens next depends on the node: one that wants that need reports + an **unresolved** check, one that does not want it reports nothing at all, and neither fabricates + a command — a guessed command reports a result that means nothing. + **This Box runs no build, lint, type-check or test command by instruction** (ADR-0026): it + carries the block so that `reconcile` can tell the reader which checks the project has and this + review did not run. `install` is the exception and is not a check — you run it below, once, + because a sub-agent that decides on its own that it needs to run something should fail on that + decision rather than on which worktree the run drew (ADR-0037 §6). + + ## Install once, and say what you did + One Archon worktree serves this whole run and may or may not already carry this project's + dependencies. So install here, once, and nowhere else. No node here is instructed to run a + check, and this Box installs anyway with no exception: a review sub-agent that decides on its own + that it needs to run something should fail on the merits of that decision, not on which worktree + the run happened to draw. When status is "ready", run `sdlc_needs.install` from the repo root if + it is declared, and set `install_report` to: + - declared, exit 0 → "ran the declared install command: ok" + - declared, non-zero → "ran the declared install command: FAILED — " + - not declared → "sdlc_needs.install is not declared, so nothing was installed. + Dependencies may still be present in this worktree by chance, and a + later green check here is not proof of a deliberately built + environment." + A failed install does not fail this node — report it and carry on. On any status other than + "ready", set `install_report` to "not attempted ()". + ALWAYS emit EVERY required field, including in the no-slug and no-config branches, so the object validates before a guard can cancel. When a value cannot be read, fall back to: slug "" (if no-slug), artifacts_dir "workflows", gate "hitl", confidence_threshold 60, inline_comments @@ -67,9 +104,23 @@ nodes: type: string enum: ['true', 'false'] branching: { type: string } + sdlc_needs: + type: object + properties: + install: { type: [string, 'null'] } + build: { type: [string, 'null'] } + test: { type: [string, 'null'] } + e2e: { type: [string, 'null'] } + lint: { type: [string, 'null'] } + format: { type: [string, 'null'] } + typecheck: { type: [string, 'null'] } + dev: { type: [string, 'null'] } + coverage: { type: [string, 'null'] } + required: [install, build, test, e2e, lint, format, typecheck, dev, coverage] + install_report: { type: string } expected_base: { type: string } message: { type: string } - required: [status, slug, artifacts_dir, gate, confidence_threshold, inline_comments, branching, expected_base, message] + required: [status, slug, artifacts_dir, gate, confidence_threshold, inline_comments, branching, sdlc_needs, install_report, expected_base, message] # --------------------------------------------------------------------------------------------- # guard — expected precondition failures cancel cleanly (ADR-0011: cancel vs fail). One guard, @@ -467,6 +518,8 @@ nodes: ## unic-dlc /pr-review — $prep.output.pr_title · Iteration + ### Checks this review did not run + ### Since iteration (only when ITERATION > 1)