@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/code-auditor.md +8 -1
  10. package/src/claude/agents/spec-maker.md +107 -200
  11. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  12. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  13. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  14. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  15. package/src/claude/hooks/completion-authority.cjs +148 -0
  16. package/src/claude/hooks/session.cjs +33 -19
  17. package/src/claude/hooks/spec-gate.cjs +173 -61
  18. package/src/claude/hooks/spec-state.cjs +104 -45
  19. package/src/claude/migration-manifest.json +6 -1
  20. package/src/claude/scripts/provenance.cjs +315 -0
  21. package/src/claude/scripts/spec-resolver.cjs +450 -0
  22. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  23. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  24. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  25. package/src/claude/settings/settings.json +8 -0
  26. package/src/claude/skills/backend-development/references/backend-security.md +52 -0
  27. package/src/claude/skills/code-review/SKILL.md +85 -66
  28. package/src/claude/skills/develop/SKILL.md +153 -285
  29. package/src/claude/skills/develop/references/quality-gate.md +112 -124
  30. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  31. package/src/claude/skills/specs/SKILL.md +170 -381
  32. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  33. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  34. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  35. package/src/claude/skills/specs/templates/task.md +20 -0
  36. package/src/claude/skills/sync/SKILL.md +15 -8
  37. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  38. package/src/claude/skills/test/SKILL.md +141 -167
  39. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  40. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  41. package/src/codex/hooks/completion-authority.cjs +100 -0
  42. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  43. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  44. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  45. package/src/codex/hooks/session.cjs +2 -0
  46. package/src/codex/hooks/spec-gate.cjs +132 -20
  47. package/src/codex/hooks/spec-state.cjs +56 -10
  48. package/src/codex/hooks.json +10 -0
  49. package/src/opencode/AGENTS.md +4 -2
  50. package/src/opencode/plugins/spec-gate.ts +296 -122
  51. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -1,201 +1,175 @@
1
1
  ---
2
2
  name: hapo:test
3
- description: "Run and verify project tests across all scopes: unit, integration, e2e, and UI. Blast-radius scoping for speed, chrome-devtools for UI verification, structured verdicts for downstream automation."
3
+ description: "Execute the smallest adequate verification scope and own the canonical execution proof."
4
4
  user-invocable: true
5
- when_to_use: "Invoke to run and verify unit, integration, e2e, and UI tests."
5
+ when_to_use: "Use after implementation, for a feature scope, or for an explicit test request."
6
6
  category: testing
7
- keywords: [test, unit, integration, e2e]
7
+ keywords: [test, unit, integration, e2e, proof]
8
8
  argument-hint: "[scope|--full|--ui <url>|--ui-auth <url>|--ui-flow <url>]"
9
9
  metadata:
10
10
  author: haposoft
11
11
  version: "2.0.0"
12
12
  ---
13
- # Test — Verify Implementation Quality
13
+ # Test — execution proof owner
14
14
 
15
- Run the project's test suite, analyze results, and return a structured verdict.
16
- Designed to work **after `hapo:develop`**. Standalone `/hapo:test` uses the same `test-runner` contract that `hapo:develop` relies on during its Quality Gate, and may run **in parallel with `hapo:code-review`**.
17
-
18
- **Principles:** Fail-fast | Blast-radius scoping | Zero hidden failures | No mocking to pass
15
+ `hapo:test` is the sole owner of canonical execution proof. It runs real
16
+ commands, records what ran, and emits the receipt consumed by closeout and
17
+ review. Review may consume that receipt but must not run tests, create a second
18
+ receipt, or claim execution proof.
19
19
 
20
20
  ## Usage
21
21
 
22
- ```bash
23
- /hapo:test # Blast-radius mode: only tests affected by recent changes
24
- /hapo:test --full # Run full test suite regardless of changes
25
- /hapo:test <scope> # Test a specific module or path
26
- /hapo:test <feature-name> # Spec-aware test: load specs/<feature-name> and verify scope/task evidence
27
- /hapo:test specs/<feature> # Spec-aware test by spec directory
28
- /hapo:test --ui <url> # UI verification via chrome-devtools (public pages)
29
- /hapo:test --ui-auth <url> # UI verification with auth injection (protected pages)
30
- /hapo:test --ui-flow <url> # UI testing with User Journey (form fill/submit simulation)
22
+ ```text
23
+ /hapo:test
24
+ /hapo:test --full
25
+ /hapo:test <scope-or-path>
26
+ /hapo:test <feature-name>
27
+ /hapo:test specs/<feature>
28
+ /hapo:test --ui <url>
29
+ /hapo:test --ui-auth <url>
30
+ /hapo:test --ui-flow <url>
31
31
  ```
32
32
 
33
- <HARD-GATE>
34
- NEVER claim tests pass when they were NOT actually executed.
35
- NEVER mock, stub, or skip a failing test to produce a green result.
36
- If no test command is detected, report NO_TESTS — do not fabricate results.
37
- If a test command exits 0 but runs 0 tests, report NO_TESTS — this is a green lie, not a PASS.
38
- If tests fail, list every failure explicitly — do not summarize failures away.
39
- </HARD-GATE>
33
+ ## Hard gates
40
34
 
41
- <SCOPE-GATE>
42
- When a feature name or `specs/<feature>` path is supplied, testing is spec-aware.
43
- Load `spec.json`, `requirements.md`, `design.md`, active/recent task files, and task `Evidence` / test-plan proof.
44
- The verdict MUST compare executed/reachable behavior against `scope_lock`, requirements, design contracts, task Completion Criteria, and runtime reachability obligations.
45
- Build/typecheck success without scoped runtime proof is not PASS.
46
- ## Flash promotion contract
47
-
48
- Executable policy source: `.claude/scripts/workflow-policy.cjs` (source: `src/claude/scripts/workflow-policy.cjs`). Its `consumeReviewVerdict` and `promoteFlashTask` functions are sole source for PASS/FAIL/BLOCKED handling and task-scoped promotion.
49
-
50
- When `/hapo:test <feature>` finds a task with `status: "in_progress"` and receipt `FLASH_UNVERIFIED`, evaluate that task's exact Evidence and runtime reachability only:
51
-
52
- - PASS → replace receipt with concrete proof while keeping `status: "in_progress"`, `dependencyBlocked: true`, `unblocks: false`, and `readyForSync: true`; only explicit `/hapo:sync ... sync-finalize` may set `done` and unblock dependents.
53
- - FAIL, BLOCKED, or NO_TESTS → keep `status: "in_progress"`, keep or update blocker, and do not promote any other flash task.
54
- - Never blanket-promote every flash task in a feature because one task passed.
55
-
56
-
57
-
58
- ```mermaid
59
- flowchart TD
60
- A["/hapo:test"] --> B[Phase 1: Detect]
61
- B --> C{Test runner found?}
62
- C -->|No| Z[STOP: Verdict = NO_TESTS]
63
- C -->|Yes| D[Phase 2: Execute]
64
- D --> E{--ui / auth / flow?}
65
- E -->|Yes| F["UI Verification (Parallel Subagents)"]
66
- E -->|No| G[Code Tests (Blast Radius)]
67
- F --> H[Phase 3: Verdict]
68
- G --> H
69
- H --> I[Phase 4: Sync Memory]
70
- I -->|PASS| J[Hand off to hapo:code-review]
71
- I -->|FAIL| K[Load failure-triage.md → classify → escalate]
72
- ```
35
+ - Never claim a pass without executing the relevant command.
36
+ - Never mock, stub, delete, weaken, or skip a failing assertion to obtain green.
37
+ - A missing runner, missing required command, or zero executed tests is not
38
+ `PASS`; report the blocker and keep closeout unfinished.
39
+ - A compile/typecheck failure is a precheck failure even if no test command is
40
+ available.
41
+ - Preserve the exact command, exit result, provenance, and artifact hashes
42
+ required by the canonical receipt schema.
73
43
 
74
- ### Phase 1 — Detect
75
-
76
- Auto-detect the test runner from project files:
77
- - `package.json` → npm/yarn/pnpm/bun test, jest, vitest, mocha
78
- - `pyproject.toml` / `setup.cfg` → pytest
79
- - `go.mod` → go test
80
- - `Cargo.toml` → cargo test
81
- - `pubspec.yaml` → flutter test
82
-
83
- Unless `--full` is specified: apply **Blast Radius scoping** to run only tests
84
- affected by recent file changes. See `references/execution-strategy.md` Phase A.
85
-
86
- If the argument resolves to `specs/<feature>` or a feature directory under `specs/`, enter **Spec-Aware Mode**:
87
- - Load `spec.json`, `requirements.md`, `design.md`, and task files referenced by `task_registry`
88
- - Identify tasks marked `done`, `in_progress`, or recently changed
89
- - Extract exact commands, runtime/artifact proof, runtime reachability proof, and negative-path checks
90
- - Scope test selection by affected task files, but do not skip any mandatory task evidence
91
-
92
- ### Phase 2 — Execute
93
-
94
- **Code testing (default):**
95
- 1. Pre-flight: run typecheck/lint to catch compile errors first
96
- 2. Execute test command with coverage flags
97
- 3. Collect test counts, coverage percentages, and fail stack traces
98
- 4. Treat 0 executed tests as `NO_TESTS`, even if the command exits 0
99
- 5. In Spec-Aware Mode, inspect runtime reachability from declared entrypoints/callers and fail if scoped surfaces are missing or orphaned
100
-
101
- **Spec-aware test type escalation:**
102
- - Unit tests are mandatory when task evidence covers pure logic, transforms, validators, sorting/filtering, or regressions.
103
- - Component/integration tests are expected when task evidence covers stateful UI, context/store wiring, API/service boundaries, or persistence.
104
- - E2E/UI flow tests are expected once a complete user-facing workflow exists, not for isolated foundation tasks.
105
- - Visual/responsive checks are expected for layout, theme, dashboard, and style tasks.
106
- - Accessibility checks are expected for interactive UI surfaces where focus, roles, labels, keyboard navigation, or ARIA can regress.
107
- - Smoke checks are enough for scaffold/config tasks unless the task requires deeper proof.
108
- - Performance/security checks are only mandatory when the requirement, design risk, or touched runtime boundary calls for them.
109
-
110
- **UI verification (`--ui` / `--ui-auth` / `--ui-flow`):**
111
- Execute multi-page discovery, then spawn **Parallel UI Subagents** (test-runner instances) to handle Smoke, Core-Vitals, Accessibility, SEO, Security, and User Flows simultaneously.
112
- See `references/execution-strategy.md` Phase C for full phase breakdown.
113
-
114
- Delegate execution to `test-runner` agent:
115
- ```
116
- Agent(subagent_type="test-runner",
117
- prompt="Run tests. Scope: [blast-radius|full|ui|spec-aware]. Target: [path|url|feature]. Load specs when target is a feature. Return structured verdict with scope/spec coverage and runtime reachability.",
118
- description="Test [feature]")
119
- ```
44
+ ## Scope and lane
45
+
46
+ For a feature target, load `spec.json`, `requirements.md`, `design.md`, and
47
+ task evidence only when that target exists. Compare actual execution against
48
+ `scope_lock`, contracts, completion criteria, runtime reachability, and
49
+ negative-path obligations.
50
+
51
+ Choose depth from the persisted lane, risk, and blast radius:
120
52
 
121
- ### Phase 3 — Verdict
53
+ - Direct: targeted commands and diff/runtime self-check;
54
+ - Standard: bounded affected suite and feature receipt;
55
+ - Critical: strict commands plus the independent evidence required by the
56
+ persisted obligations.
122
57
 
123
- Return a **structured verdict** (required format — not free-form prose):
58
+ Do not infer depth from file count or from the legacy `execution_tier`. That
59
+ field is a read-only compatibility adapter. A task bundle is not required for a
60
+ small Standard feature.
61
+
62
+ ## Spec-Aware Mode
63
+
64
+ <SCOPE-GATE>
65
+ For a feature target, test only the active scope, requirements, completion
66
+ criteria, and reachable runtime surfaces. A missing or orphaned runtime surface
67
+ is a failure even when the command exits successfully.
68
+ </SCOPE-GATE>
69
+
70
+ Use blast-radius selection by default; `--full` overrides selection, not proof
71
+ requirements. Use UI checks only when a reachable UI surface is in scope.
72
+
73
+ ## Execution
74
+
75
+ 1. Detect the project runner and exact commands from repository files and task
76
+ evidence. Do not invent commands.
77
+ 2. Run a cheap compile/typecheck precheck where the project provides one.
78
+ 3. Execute the smallest adequate unit, integration, UI, E2E, accessibility,
79
+ performance, or security proof for the changed surface.
80
+ 4. Inspect runtime reachability and declared artifacts when the task creates
81
+ runtime-facing or generated output.
82
+ 5. Preserve raw outcomes and write one canonical receipt. A receipt must not
83
+ contain secrets or placeholder provenance.
84
+
85
+ Required proof type follows the behavior, not ceremony:
86
+
87
+ | Surface | Adequate proof |
88
+ |---|---|
89
+ | pure logic/parser/validator | unit plus negative path |
90
+ | stateful UI or module wiring | component/integration and mounted path |
91
+ | API, persistence, provider, or process boundary | real contract/state handoff |
92
+ | complete user workflow | E2E or UI flow |
93
+ | layout or responsive behavior | viewport/visual check |
94
+ | interactive focus/labels/keyboard | accessibility check |
95
+ | regression | reproduction before fix plus passing regression |
96
+ | security/performance | only when requirement, risk, or boundary requires it |
97
+
98
+ ## Canonical receipt
99
+
100
+ The receipt is execution evidence, not a status marker. It must contain the
101
+ actual command, a successful exit result, both provenance anchors (`Base` and
102
+ `Head`, or their canonical aliases), and any declared artifact SHA-256. Reject
103
+ `Exit: 1`, conflicting outcomes, empty commands, placeholders, missing
104
+ provenance, zero execution, and failure summaries. No-artifact tasks remain
105
+ compatible; declared artifacts require hashes.
106
+
107
+ Closeout and flash callers must also provide an explicit runtime binding; a
108
+ valid-length `Base`/`Head` pair alone is not identity proof. The minimal adapter
109
+ contract is `const binding = policy.createReceiptBinding({ base, head })`, then
110
+ pass `receipt_binding: binding` to `completionDecision` or include the binding's
111
+ `expectedProvenance` in the flash task. The policy requires that binding and
112
+ compares both receipt anchors before completion or flash promotion.
113
+
114
+ For a normal closeout, emit a receipt that the shared policy validator accepts.
115
+ For `--flash`, emit proof for the current `FLASH_UNVERIFIED` task only. Only explicit trusted sync-finalize may promote it. The
116
+ receipt may make the task eligible for trusted sync-finalize, but it must not
117
+ promote the task, unblock dependents, or fabricate a done state.
118
+
119
+ ## Verdict and handoff
120
+
121
+ The shared workflow surface is:
122
+
123
+ `PASS | PASS_WITH_WARNINGS | FAIL | BLOCKED`
124
+
125
+ Warnings describe residual, non-blocking concerns; they never replace missing
126
+ execution proof. A legacy diagnostic result such as `NO_TESTS` is normalized by
127
+ the shared adapter to an unfinished outcome and is never treated as `PASS`.
124
128
 
125
129
  ```markdown
126
130
  ## Test Verdict
127
131
 
128
- **Status:** PASS | FAIL | PARTIAL | NO_TESTS
129
- **Scope:** blast-radius (N/M tests) | full-suite (N tests) | ui-check
130
- **Duration:** Xs
132
+ **Status:** PASS | PASS_WITH_WARNINGS | FAIL | BLOCKED
133
+ **Scope:** [target and selection]
134
+ **Commands:** [exact commands]
135
+ **Exit:** [actual result]
136
+ **Duration:** [measured time]
137
+ **Receipt:** [path or inline canonical receipt]
131
138
 
132
139
  ### Results
133
- - Passed: N | Failed: N | Skipped: N
134
-
135
- ### Coverage (if applicable)
136
- | Metric | Result | Threshold | Status |
137
- |-----------|--------|-----------|-----------|
138
- | Lines | X% | 80% | PASS/FAIL |
139
- | Branches | X% | 70% | PASS/FAIL |
140
- | Functions | X% | 80% | PASS/FAIL |
141
-
142
- ### Failures (if any)
143
- 1. `path/to/file.test.ts:42` — AssertionError: expected X but got Y — Category: Logic Error
144
-
145
- ### UI Results (if --ui)
146
- - Console errors: N found | none
147
- - Network errors (4xx/5xx): N found | none
148
- - Performance: LCP Xms | CLS X | FCP Xms
149
- - Accessibility issues: N found | none
150
- - Screenshots: [paths]
151
-
152
- ### Scope / Spec Coverage (if feature scope)
153
- - Requirements covered: N/N
154
- - Task evidence checks: PASS | FAIL | UNVERIFIED
155
- - Runtime reachability: PASS | FAIL | UNVERIFIED
156
- - Out-of-scope behavior detected: none | [list]
157
-
158
- ### Test Regression Check
159
- - **Comparison:** Compare current test count and assertion depth against previous runs.
160
- - **Result:** OK | REGRESSION (tests deleted/weakened)
140
+ - Passed: N | Failed: N | Skipped: N | Executed: N
141
+
142
+ ### Scope / reachability
143
+ - Requirements: [covered/uncovered]
144
+ - Runtime reachability: PASS | FAIL | BLOCKED
145
+ - Artifact/provenance checks: PASS | FAIL | BLOCKED
161
146
 
162
147
  ### Action
163
- - PASS → Proceed. Hand off to hapo:code-review.
164
- - FAIL → [list specific fixes needed] → Return to implementer. (If REGRESSION: label "Test Regression — tests were deleted or weakened to produce green result")
165
- - PARTIAL → [list uncovered areas] → Consider adding tests.
166
- - NO_TESTS → No test runner detected. User must configure tests first.
167
-
168
- <lessons_learned>
169
- {
170
- "flaky_tests_added": []
171
- }
172
- </lessons_learned>
148
+ - PASS → give the receipt to the single closeout owner; only literal `PASS` can
149
+ complete a task.
150
+ - PASS_WITH_WARNINGS → give the receipt to the closeout owner, but keep the
151
+ task unfinished until a literal `PASS` decision is recorded.
152
+ - FAIL → list exact failures; implementation remains unfinished.
153
+ - BLOCKED → state the changed prerequisite; do not blind-retry.
173
154
  ```
174
155
 
175
- ### Phase 4 — Sync Memory
156
+ The closeout owner calls this workflow once for the current task or feature.
157
+ Do not trigger review from inside the test workflow and do not run a duplicate
158
+ test pass merely because review is requested. Review evaluates correctness,
159
+ security, and spec compliance against this proof.
176
160
 
177
- After receiving the verdict from `test-runner`, the orchestrator (`hapo:test`) intercepts the `<lessons_learned>` block.
178
- It merges the JSON data into `.hapo/test-memory.json` per `references/test-memory.md` to ensure that future runs remember flaky tests or environment setup requirements without modifying the codebase.
161
+ ## Flash proof
179
162
 
180
- ## Skill Interconnections
181
-
182
- | Skill / Agent | Direction | Role |
183
- |---|---|---|
184
- | `hapo:code-review` | runs in parallel | Both run at Quality Gate Step 4 |
185
- | `hapo:develop` | orchestrates | Spawns hapo:test at Step 4 |
186
- | `inspector` agent | hapo:test → | Scout test file locations when structure is unfamiliar |
187
- | `implementer` agent | hapo:test → | FAIL verdicts route back here for fixing |
188
- | `test-runner` agent | hapo:test → | Primary executor, spawned via the `Agent` tool |
189
- | chrome-devtools scripts | test-runner → | UI verification (navigate, screenshot, console, network, performance, aria-snapshot, inject-auth) |
163
+ When the target contains an in-progress `FLASH_UNVERIFIED` task, test only its
164
+ exact Evidence and reachability obligations. On pass, keep the task
165
+ `in_progress`, keep `FLASH_UNVERIFIED`, `dependencyBlocked: true`, and
166
+ `unblocks: false`; return canonical proof to trusted sync-finalize. On failure,
167
+ blocker, or no tests, do not promote any task.
190
168
 
191
169
  ## References
192
170
 
193
- - `references/execution-strategy.md` — Blast-radius algorithm, auto-detect logic, UI verification phases (A–E)
194
- - `references/failure-triage.md` — Failure categories, triage decision tree, escalation rules
195
- - `references/test-memory.md` — `.hapo/test-memory.json` schema and merge rules
196
-
197
- ## Related
198
-
199
- - Previous skill: `hapo:develop`
200
- - Parallel skill: `hapo:code-review`
201
- - Parent workflow: `hapo:develop` Step 4 Quality Gate
171
+ - `references/execution-strategy.md` — blast-radius and UI selection, loaded
172
+ only when the default strategy needs detail.
173
+ - `references/failure-triage.md` — failure classification, loaded on failure.
174
+ - `references/test-memory.md` — optional historical context; current receipt
175
+ evidence always wins.
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const STATE = require('./completion-authority-state.cjs');
7
+ const { checkReceipt, evidenceBody } = require('./lib/spec-receipt.cjs');
8
+ const { findAllSpecCandidates, resolveActiveSpec } = require('./lib/spec-utils.cjs');
9
+
10
+ function plain(value) {
11
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
12
+ }
13
+
14
+ function inside(root, target) {
15
+ const relative = path.relative(path.resolve(root), path.resolve(target));
16
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
17
+ }
18
+
19
+ function readBoundFile(root, target) {
20
+ const requested = path.isAbsolute(target) ? target : path.resolve(root, target);
21
+ if (!inside(root, requested)) return null;
22
+ try {
23
+ const canonicalRoot = fs.realpathSync(root);
24
+ const canonical = fs.realpathSync(requested);
25
+ if (!inside(canonicalRoot, canonical) || !fs.statSync(canonical).isFile()) return null;
26
+ return fs.readFileSync(canonical);
27
+ } catch { return null; }
28
+ }
29
+
30
+ function readTask(featureDir, taskPath) {
31
+ const target = path.resolve(featureDir, taskPath);
32
+ if (!inside(featureDir, target)) return null;
33
+ const bytes = readBoundFile(featureDir, target);
34
+ if (!bytes) return null;
35
+ return { bytes, text: bytes.toString('utf8'), path: fs.realpathSync(target) };
36
+ }
37
+
38
+ function artifactReferences(body) {
39
+ if (typeof body !== 'string') return [];
40
+ const references = [];
41
+ for (const line of body.split('\n')) {
42
+ if (!/^\s*Artifacts?\s*:/i.test(line) && !/^\s*Artifact\s+produced\b/i.test(line)) continue;
43
+ const hashes = [...line.matchAll(/\b(?:artifact[_-])?sha-?256\s*:\s*([^\s),;`]+)/gi)].map((match) => match[1]);
44
+ const raw = line.replace(/^\s*Artifacts?\s*:\s*/i, '').replace(/^\s*Artifact\s+produced\s*:?[ \t]*/i, '').replace(/\b(?:artifact[_-])?sha-?256\s*:\s*[^\s),;`]+/gi, '').replace(/[()[\]{}]/g, ' ').trim();
45
+ const paths = raw.split(/\s*(?:\+|,|;|\band\b)\s*/i).map((value) => value.trim()).filter(Boolean);
46
+ paths.forEach((artifactPath, index) => references.push({ path: artifactPath, hash: hashes[index] || null }));
47
+ }
48
+ return references;
49
+ }
50
+
51
+ function resolveCandidate({ projectRoot, runtime, payload }) {
52
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
53
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
54
+ const candidates = findAllSpecCandidates(projectRoot, runtime);
55
+ if (candidates.length > 1) return { error: 'multiple_persisted', candidates: candidates.map((candidate) => candidate.featureName) };
56
+ if (candidates.length === 0) {
57
+ if (explicitFeature || explicitPath) return { error: 'explicit_not_found', explicitFeature, explicitPath, reason: 'no globally persisted spec matches the explicit target' };
58
+ return null;
59
+ }
60
+ if (!explicitFeature && !explicitPath) return candidates[0];
61
+ const targeted = resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath });
62
+ if (!targeted || targeted.error) {
63
+ return {
64
+ error: 'explicit_mismatch',
65
+ explicitFeature,
66
+ explicitPath,
67
+ reason: targeted?.reason || 'explicit target does not resolve to the persisted candidate',
68
+ };
69
+ }
70
+ if (targeted.featureName !== candidates[0].featureName || path.resolve(targeted.specFile) !== path.resolve(candidates[0].specFile)) {
71
+ return {
72
+ error: 'explicit_mismatch',
73
+ explicitFeature,
74
+ explicitPath,
75
+ reason: 'explicit target does not match the sole globally persisted candidate',
76
+ };
77
+ }
78
+ return candidates[0];
79
+ }
80
+
81
+ function policyRank(policy, lane) {
82
+ const lanes = Array.isArray(policy.LANES) ? policy.LANES : ['Direct', 'Standard', 'Critical'];
83
+ return lanes.indexOf(lane);
84
+ }
85
+
86
+ function policyMonotonicFailures(policy, baseline, current) {
87
+ const failures = [];
88
+ if (baseline.version !== current.version) failures.push('workflow_policy.version changed');
89
+ if (policyRank(policy, current.lane) < policyRank(policy, baseline.lane)) failures.push(`workflow_policy.lane downgraded from ${baseline.lane} to ${current.lane}`);
90
+ if (policyRank(policy, current.automatic_lane) < policyRank(policy, baseline.automatic_lane)) failures.push(`workflow_policy.automatic_lane downgraded from ${baseline.automatic_lane} to ${current.automatic_lane}`);
91
+ const currentRisks = new Set(current.risks);
92
+ for (const risk of baseline.risks) if (!currentRisks.has(risk)) failures.push(`workflow_policy risk ${risk} was removed`);
93
+ const currentObligations = new Set(current.proof_obligations);
94
+ for (const obligation of baseline.proof_obligations) if (!currentObligations.has(obligation)) failures.push(`workflow_policy obligation ${obligation} was removed`);
95
+ return failures;
96
+ }
97
+
98
+ function mergePolicyFloor(policy, floor, current) {
99
+ const merged = { ...current };
100
+ if (policyRank(policy, floor.lane) > policyRank(policy, current.lane)) merged.lane = floor.lane;
101
+ if (policyRank(policy, floor.automatic_lane) > policyRank(policy, current.automatic_lane)) merged.automatic_lane = floor.automatic_lane;
102
+ merged.risks = [...new Set([...current.risks, ...floor.risks])];
103
+ const proofObligations = [];
104
+ const actorNeeds = [];
105
+ for (const source of [current, floor]) {
106
+ for (let index = 0; index < source.proof_obligations.length; index += 1) {
107
+ const obligation = source.proof_obligations[index];
108
+ if (proofObligations.includes(obligation)) continue;
109
+ proofObligations.push(obligation);
110
+ actorNeeds.push(source.actor_needs[index]);
111
+ }
112
+ }
113
+ merged.proof_obligations = proofObligations;
114
+ merged.actor_needs = actorNeeds;
115
+ return merged;
116
+ }
117
+
118
+ function observePolicyBaseline({ STATE: state, policy, projectRoot, candidate }) {
119
+ if (!Object.prototype.hasOwnProperty.call(candidate.spec, 'workflow_policy')) {
120
+ return { ok: false, reason: 'workflow_policy is missing for persisted spec' };
121
+ }
122
+ const current = candidate.spec.workflow_policy;
123
+ const validation = policy.validateWorkflowPolicySnapshot(current);
124
+ if (!validation.valid) return { ok: false, reason: `workflow_policy is malformed: ${validation.errors.join('; ')}` };
125
+ let identity;
126
+ try {
127
+ identity = {
128
+ project_root: fs.realpathSync(projectRoot),
129
+ spec_file: fs.realpathSync(candidate.specFile),
130
+ feature_name: candidate.featureName,
131
+ };
132
+ state.ensureKey();
133
+ let authorityState = state.readState(projectRoot);
134
+ if (authorityState.malformed || authorityState.keyError || authorityState.keyMissing) {
135
+ return { ok: false, reason: authorityState.reason || 'completion authority state is unavailable' };
136
+ }
137
+ if (!authorityState.floor) {
138
+ if (authorityState.baselines.length > 0 || authorityState.pending || authorityState.grant) {
139
+ return { ok: false, reason: 'project policy floor is missing for persisted authority state' };
140
+ }
141
+ state.writePolicyFloor(projectRoot, current);
142
+ authorityState = state.readState(projectRoot);
143
+ if (authorityState.malformed || authorityState.keyError || authorityState.keyMissing || !authorityState.floor) {
144
+ return { ok: false, reason: authorityState.reason || 'project policy floor could not be persisted' };
145
+ }
146
+ } else {
147
+ const floorValidation = policy.validateWorkflowPolicySnapshot(authorityState.floor.policy);
148
+ if (!floorValidation.valid) return { ok: false, reason: `persisted project policy floor is malformed: ${floorValidation.errors.join('; ')}` };
149
+ const floorFailures = policyMonotonicFailures(policy, authorityState.floor.policy, current);
150
+ if (floorFailures.length) return { ok: false, reason: `project policy floor is monotonic and cannot be downgraded (${floorFailures.join('; ')})` };
151
+ const mergedFloor = mergePolicyFloor(policy, authorityState.floor.policy, current);
152
+ if (state.digest(authorityState.floor.policy) !== state.digest(mergedFloor)) state.writePolicyFloor(projectRoot, mergedFloor);
153
+ }
154
+ const baseline = state.readBaseline(projectRoot, identity);
155
+ if (!baseline) {
156
+ state.writeBaseline(projectRoot, identity, current);
157
+ return { ok: true };
158
+ }
159
+ const baselineValidation = policy.validateWorkflowPolicySnapshot(baseline.policy);
160
+ if (!baselineValidation.valid) return { ok: false, reason: `persisted workflow policy baseline is malformed: ${baselineValidation.errors.join('; ')}` };
161
+ const failures = policyMonotonicFailures(policy, baseline.policy, current);
162
+ if (failures.length) return { ok: false, reason: `workflow_policy baseline is monotonic and cannot be downgraded (${failures.join('; ')})` };
163
+ if (state.digest(baseline.policy) !== state.digest(current)) state.writeBaseline(projectRoot, identity, current);
164
+ return { ok: true };
165
+ } catch (error) {
166
+ return { ok: false, reason: `workflow_policy baseline unavailable: ${error.message}` };
167
+ }
168
+ }
169
+
170
+ function evaluateCloseout({ policy, projectRoot, runtime, payload }) {
171
+ const candidate = resolveCandidate({ projectRoot, runtime, payload });
172
+ if (!candidate) return { ok: true, candidate: null };
173
+ if (candidate.error) {
174
+ const detail = Array.isArray(candidate.candidates) ? candidate.candidates.join(', ') : (candidate.reason || candidate.error);
175
+ return { ok: false, candidate, reason: `${candidate.error}: ${detail}` };
176
+ }
177
+ const sessionId = payload.session_id;
178
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, candidate, reason: 'trusted host session ID is unavailable' };
179
+ const baseline = observePolicyBaseline({ STATE, policy, projectRoot, candidate });
180
+ if (!baseline.ok) return { ok: false, candidate, reason: baseline.reason };
181
+ if (runtime.spec?.completion_gate !== undefined && runtime.spec.completion_gate !== true) return { ok: false, candidate, reason: 'runtime.spec.completion_gate is worker-writable and cannot authorize completion' };
182
+ const status = candidate.spec.status;
183
+ if (!['in_progress', 'in-progress', 'completed', 'complete'].includes(status)) return { ok: false, candidate, reason: `spec status is invalid: ${String(status)}` };
184
+ const registry = candidate.spec.task_registry;
185
+ if (!plain(registry) || Object.keys(registry).length === 0) return { ok: false, candidate, reason: 'task_registry is missing or empty' };
186
+
187
+ let runtimeContext;
188
+ try {
189
+ runtimeContext = policy.deriveRuntimeContext({
190
+ projectRoot,
191
+ specsRoot: candidate.specsDir,
192
+ specFile: candidate.specFile,
193
+ featureName: candidate.featureName,
194
+ runtimeSession: sessionId,
195
+ });
196
+ } catch (error) {
197
+ return { ok: false, candidate, reason: `runtime provenance unavailable: ${error.message}` };
198
+ }
199
+ if (!policy.isTrustedRuntimeContext(runtimeContext)) return { ok: false, candidate, reason: 'fresh trusted runtime Base/Head/context is unavailable' };
200
+
201
+ const featureDir = path.join(candidate.specsDir, candidate.featureName);
202
+ const receiptBodies = new Map();
203
+ const taskFiles = [];
204
+ const failures = [];
205
+ for (const [taskPath, task] of Object.entries(registry)) {
206
+ if (!plain(task) || task.status !== 'done') {
207
+ failures.push(`${taskPath}: status is not done`);
208
+ continue;
209
+ }
210
+ if (policy.isStaleFlashDone(task)) failures.push(`${taskPath}: FLASH_UNVERIFIED`);
211
+ const file = readTask(featureDir, taskPath);
212
+ const body = file ? evidenceBody(file.text) : null;
213
+ if (body !== null) receiptBodies.set(taskPath, body);
214
+ const receiptFailures = checkReceipt(featureDir, taskPath, task, runtimeContext);
215
+ if (receiptFailures.length) failures.push(`${taskPath}: checks ${receiptFailures.join(',')}`);
216
+ if (file) taskFiles.push({ path: taskPath, digest: crypto.createHash('sha256').update(file.bytes).digest('hex') });
217
+ }
218
+ if (failures.length) return { ok: false, candidate, reason: `technical completion proof is incomplete (${failures.slice(0, 4).join('; ')})` };
219
+
220
+ const firstTask = Object.keys(registry).find((taskPath) => receiptBodies.has(taskPath));
221
+ let completion;
222
+ try {
223
+ completion = policy.completionDecisionForSpec(candidate.spec, {
224
+ runtimeContext,
225
+ executionReceipt: firstTask ? receiptBodies.get(firstTask) : null,
226
+ taskContext: firstTask ? registry[firstTask] : null,
227
+ });
228
+ } catch (error) {
229
+ return { ok: false, candidate, reason: `workflow completion proof could not be evaluated: ${error.message}` };
230
+ }
231
+ if (completion.completion !== 'complete' && completion.completion !== 'not_applicable') {
232
+ return { ok: false, candidate, reason: completion.blocker || 'workflow completion proof is incomplete' };
233
+ }
234
+
235
+ const artifactRefs = [...receiptBodies.values()].flatMap(artifactReferences).sort((left, right) => `${left.path}:${left.hash}`.localeCompare(`${right.path}:${right.hash}`));
236
+ const artifactFiles = artifactRefs.map((reference) => ({
237
+ ...reference,
238
+ digest: crypto.createHash('sha256').update(readBoundFile(projectRoot, reference.path) || Buffer.from('<missing>')).digest('hex'),
239
+ }));
240
+ const specBytes = readBoundFile(projectRoot, candidate.specFile);
241
+ const binding = {
242
+ project_root: runtimeContext.project_root,
243
+ platform: process.platform,
244
+ session_id: sessionId,
245
+ feature_name: candidate.featureName,
246
+ spec_file: candidate.specFile,
247
+ runtime: { base: runtimeContext.base, head: runtimeContext.head, context_id: runtimeContext.context_id },
248
+ spec_digest: STATE.digest(candidate.spec),
249
+ spec_bytes_digest: crypto.createHash('sha256').update(specBytes || Buffer.from('<missing>')).digest('hex'),
250
+ policy_digest: STATE.digest(candidate.spec.workflow_policy ?? null),
251
+ task_registry_digest: STATE.digest(registry),
252
+ task_digest: STATE.digest(taskFiles),
253
+ artifact_digest: STATE.digest(artifactFiles),
254
+ provenance_digest: STATE.digest({ runtime: runtimeContext, receipts: [...receiptBodies.values()].map((body) => body.match(/^\s*(?:Base|Head|base_sha|head_sha)\s*:.*$/gim) || []) }),
255
+ };
256
+ binding.binding_digest = STATE.digest(binding);
257
+ return { ok: true, candidate, runtimeContext, binding };
258
+ }
259
+
260
+ module.exports = { evaluateCloseout, resolveCandidate, observePolicyBaseline };