@haposoft/cafekit 0.16.0-rc.2 → 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.
- package/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +116 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -1,201 +1,175 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hapo:test
|
|
3
|
-
description: "
|
|
3
|
+
description: "Execute the smallest adequate verification scope and own the canonical execution proof."
|
|
4
4
|
user-invocable: true
|
|
5
|
-
when_to_use: "
|
|
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 —
|
|
13
|
+
# Test — execution proof owner
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
```
|
|
23
|
-
/hapo:test
|
|
24
|
-
/hapo:test --full
|
|
25
|
-
/hapo:test <scope
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
129
|
-
**Scope:**
|
|
130
|
-
**
|
|
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
|
-
###
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 →
|
|
164
|
-
|
|
165
|
-
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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` —
|
|
194
|
-
|
|
195
|
-
- `references/
|
|
196
|
-
|
|
197
|
-
|
|
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 };
|