@haposoft/cafekit 0.18.0 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -20,7 +20,7 @@ const CACHE = path.join(hookStateDir(), 'spec-gate-last.json');
|
|
|
20
20
|
const PROVENANCE = require(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'));
|
|
21
21
|
const POLICY = require(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'));
|
|
22
22
|
const FEATURE = 'demo';
|
|
23
|
-
const TASK_REL = '
|
|
23
|
+
const TASK_REL = 'task-01-x.md';
|
|
24
24
|
const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
|
|
25
25
|
const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
|
|
26
26
|
const FIXTURE_ARTIFACT = Buffer.from('cafekit claude fixture artifact\n');
|
|
@@ -29,14 +29,10 @@ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIF
|
|
|
29
29
|
function runHook(payload, cwd) {
|
|
30
30
|
const projectRoot = cwd || payload.cwd;
|
|
31
31
|
const specsRoot = path.join(projectRoot, 'specs');
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
// PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
|
|
38
|
-
// monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
|
|
39
|
-
const env = { ...process.env, PROJECT_ROOT: projectRoot };
|
|
32
|
+
// Source hooks use payload.cwd without inherited root overrides.
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
35
|
+
delete env.PROJECT_ROOT;
|
|
40
36
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
41
37
|
input: JSON.stringify({
|
|
42
38
|
hook_event_name: 'Stop',
|
|
@@ -69,7 +65,7 @@ function runtimeContext(dir) {
|
|
|
69
65
|
return PROVENANCE.deriveRuntimeContext({
|
|
70
66
|
projectRoot: dir,
|
|
71
67
|
specsRoot: path.join(dir, 'specs'),
|
|
72
|
-
specFile: path.join(dir, 'specs', FEATURE, '
|
|
68
|
+
specFile: path.join(dir, 'specs', FEATURE, 'plan.md'),
|
|
73
69
|
featureName: FEATURE,
|
|
74
70
|
runtimeSession: 'test',
|
|
75
71
|
});
|
|
@@ -118,38 +114,6 @@ function makeWorkflowFixture(receiptLines = [], plannedCommand = 'node --test')
|
|
|
118
114
|
return dir;
|
|
119
115
|
}
|
|
120
116
|
|
|
121
|
-
function installFeatureReceipt(dir, featureName = FEATURE, session = 'test') {
|
|
122
|
-
const featureDir = path.join(dir, 'specs', featureName);
|
|
123
|
-
const specFile = path.join(featureDir, 'spec.json');
|
|
124
|
-
if (!fs.existsSync(specFile)) return;
|
|
125
|
-
let spec;
|
|
126
|
-
try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { return; }
|
|
127
|
-
const lifecyclePhase = spec.current_phase || spec.phase;
|
|
128
|
-
const explicitCloseout = ['completed', 'complete'].includes(spec.status)
|
|
129
|
-
|| ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
|
|
130
|
-
if (!explicitCloseout) return;
|
|
131
|
-
const tasks = Object.values(spec.task_registry || {});
|
|
132
|
-
if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) return;
|
|
133
|
-
const context = PROVENANCE.deriveRuntimeContext({
|
|
134
|
-
projectRoot: dir,
|
|
135
|
-
specsRoot: path.join(dir, 'specs'),
|
|
136
|
-
specFile,
|
|
137
|
-
featureName,
|
|
138
|
-
runtimeSession: session,
|
|
139
|
-
});
|
|
140
|
-
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
141
|
-
`Feature: ${featureName}`,
|
|
142
|
-
'Expected: final integration verification passes',
|
|
143
|
-
'Observed: final integration verification passed',
|
|
144
|
-
'Verification: PASS',
|
|
145
|
-
'Command: node --test',
|
|
146
|
-
'Exit: 0',
|
|
147
|
-
`Base: ${context.base}`,
|
|
148
|
-
`Head: ${context.head}`,
|
|
149
|
-
'',
|
|
150
|
-
].join('\n'));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
117
|
function clearCache() {
|
|
154
118
|
try { fs.unlinkSync(CACHE); } catch { /* absent */ }
|
|
155
119
|
}
|
|
@@ -164,52 +128,32 @@ function readCache() {
|
|
|
164
128
|
}
|
|
165
129
|
|
|
166
130
|
/**
|
|
167
|
-
* Build a minimal
|
|
131
|
+
* Build a minimal process-first packet under dir: plan.md plus one flat task
|
|
132
|
+
* whose Verification Plan runs `npm test` and whose inline Receipt varies.
|
|
168
133
|
* @param {object} opts
|
|
169
134
|
* @param {string} [opts.taskStatus='done']
|
|
170
|
-
* @param {
|
|
171
|
-
* @param {string} [opts.mdStatus='done']
|
|
172
|
-
* @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
135
|
+
* @param {'valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
173
136
|
* @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
|
|
174
|
-
* @param {object|null} [opts.workflowPolicy] — if set, persist completion obligations
|
|
175
|
-
* @param {string} [opts.phase='closeout'] — persisted lifecycle boundary
|
|
176
137
|
*/
|
|
177
138
|
function makeFixture(opts = {}) {
|
|
178
139
|
const {
|
|
179
140
|
taskStatus = 'done',
|
|
180
|
-
completed_at = '2026-07-01T00:00:00Z',
|
|
181
|
-
mdStatus = 'done',
|
|
182
141
|
evidence = 'valid',
|
|
183
142
|
runtime = null,
|
|
184
|
-
workflowPolicy = null,
|
|
185
|
-
phase = 'closeout',
|
|
186
143
|
} = opts;
|
|
187
144
|
const dir = tmpDir();
|
|
188
145
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
189
|
-
|
|
190
|
-
fs.
|
|
191
|
-
|
|
192
|
-
const entry = { status: taskStatus };
|
|
193
|
-
if (completed_at !== null) entry.completed_at = completed_at;
|
|
194
|
-
|
|
195
|
-
const spec = {
|
|
196
|
-
status: 'in_progress',
|
|
197
|
-
feature_name: FEATURE,
|
|
198
|
-
current_phase: phase,
|
|
199
|
-
task_registry: { [TASK_REL]: entry },
|
|
200
|
-
};
|
|
201
|
-
if (workflowPolicy) spec.workflow_policy = workflowPolicy;
|
|
202
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
146
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
147
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
203
148
|
|
|
204
|
-
let
|
|
149
|
+
let receiptBlock = '';
|
|
205
150
|
if (evidence === 'valid') {
|
|
206
|
-
|
|
207
|
-
'##
|
|
151
|
+
receiptBlock = [
|
|
152
|
+
'## Receipt',
|
|
208
153
|
'',
|
|
209
154
|
'Verification: PASS',
|
|
210
155
|
'Command: npm test',
|
|
211
156
|
'Exit: 0',
|
|
212
|
-
'Result: PASS',
|
|
213
157
|
`Base: ${VALID_BASE}`,
|
|
214
158
|
`Head: ${VALID_HEAD}`,
|
|
215
159
|
`Artifact: output/bundle.js (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
|
|
@@ -219,34 +163,38 @@ function makeFixture(opts = {}) {
|
|
|
219
163
|
'```',
|
|
220
164
|
'',
|
|
221
165
|
].join('\n');
|
|
222
|
-
} else if (evidence === 'legacy-valid') {
|
|
223
|
-
evidenceBlock = `## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nnpm test — passed, exit code 0\n`;
|
|
224
166
|
} else if (evidence === 'failed') {
|
|
225
|
-
|
|
167
|
+
receiptBlock = '## Receipt\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
|
|
226
168
|
} else if (evidence === 'fence-only') {
|
|
227
|
-
|
|
169
|
+
receiptBlock = '## Receipt\n\n```\nnpm test\n```\n';
|
|
228
170
|
} else if (evidence === 'placeholder') {
|
|
229
|
-
|
|
230
|
-
'##
|
|
171
|
+
receiptBlock = [
|
|
172
|
+
'## Receipt',
|
|
231
173
|
'',
|
|
232
174
|
'Command: `{{TYPECHECK / TEST COMMAND}}`',
|
|
233
175
|
'Expected: {{What proves success}}',
|
|
234
176
|
'',
|
|
235
177
|
].join('\n');
|
|
236
178
|
} else if (evidence === 'missing-evidence') {
|
|
237
|
-
|
|
238
|
-
} else {
|
|
239
|
-
evidenceBlock = '';
|
|
179
|
+
receiptBlock = '## Risk Assessment\n\nNone.\n';
|
|
240
180
|
}
|
|
241
181
|
|
|
242
182
|
fs.writeFileSync(
|
|
243
|
-
path.join(
|
|
183
|
+
path.join(featureDir, TASK_REL),
|
|
244
184
|
[
|
|
245
|
-
|
|
185
|
+
'# Task 01: example',
|
|
186
|
+
'',
|
|
187
|
+
`Status: ${taskStatus}`,
|
|
246
188
|
'',
|
|
247
|
-
|
|
189
|
+
'## Dependencies',
|
|
248
190
|
'',
|
|
249
|
-
|
|
191
|
+
'- none',
|
|
192
|
+
'',
|
|
193
|
+
'## Verification Plan',
|
|
194
|
+
'',
|
|
195
|
+
'- Command: npm test',
|
|
196
|
+
'',
|
|
197
|
+
receiptBlock,
|
|
250
198
|
].join('\n'),
|
|
251
199
|
);
|
|
252
200
|
|
|
@@ -261,9 +209,7 @@ function makeFixture(opts = {}) {
|
|
|
261
209
|
if (evidence === 'valid') {
|
|
262
210
|
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
263
211
|
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
264
|
-
|
|
265
|
-
if (evidence === 'valid' || evidence === 'legacy-valid') {
|
|
266
|
-
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
212
|
+
const taskFile = path.join(featureDir, TASK_REL);
|
|
267
213
|
fs.writeFileSync(taskFile, bindFixtureReceipt(dir, fs.readFileSync(taskFile, 'utf8')));
|
|
268
214
|
}
|
|
269
215
|
|
|
@@ -290,12 +236,14 @@ function installClaudeGate(root, policyMode = 'valid') {
|
|
|
290
236
|
fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
|
|
291
237
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
292
238
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
|
|
239
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'project-root.cjs'), path.join(hooks, 'lib', 'project-root.cjs'));
|
|
240
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'spec-touch.cjs'), path.join(hooks, 'lib', 'spec-touch.cjs'));
|
|
293
241
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
|
|
294
242
|
fs.copyFileSync(
|
|
295
243
|
path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
|
|
296
244
|
path.join(hooks, 'lib', 'hook-state-dir.cjs'),
|
|
297
245
|
);
|
|
298
|
-
for (const name of ['spec-resolver.cjs', 'spec-receipt.cjs'
|
|
246
|
+
for (const name of ['spec-resolver.cjs', 'spec-receipt.cjs']) {
|
|
299
247
|
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', name), path.join(scripts, name));
|
|
300
248
|
}
|
|
301
249
|
if (policyMode === 'valid') {
|
|
@@ -401,31 +349,21 @@ test('Claude adapter requires canonical SHA-256 only for a declared task artifac
|
|
|
401
349
|
const dir = tmpDir();
|
|
402
350
|
const gate = installClaudeGate(dir);
|
|
403
351
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
352
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
353
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
404
354
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
405
355
|
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
406
356
|
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
407
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
408
|
-
status: 'in_progress',
|
|
409
|
-
current_phase: 'closeout',
|
|
410
|
-
feature_name: FEATURE,
|
|
411
|
-
task_registry: {
|
|
412
|
-
[TASK_REL]: {
|
|
413
|
-
status: 'done',
|
|
414
|
-
completed_at: '2026-08-11T00:00:00.000Z',
|
|
415
|
-
artifacts: ['output/bundle.js'],
|
|
416
|
-
},
|
|
417
|
-
},
|
|
418
|
-
}));
|
|
419
357
|
fs.writeFileSync(path.join(featureDir, TASK_REL), [
|
|
420
|
-
'# Task', '', '
|
|
358
|
+
'# Task', '', 'Status: done', '', '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', '', '## Receipt', '',
|
|
421
359
|
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
422
360
|
'Artifact: output/bundle.js', suffix,
|
|
361
|
+
'```', 'node --test', 'ok 1', '```',
|
|
423
362
|
].join('\n'));
|
|
424
363
|
fs.writeFileSync(
|
|
425
364
|
path.join(featureDir, TASK_REL),
|
|
426
365
|
bindFixtureReceipt(dir, fs.readFileSync(path.join(featureDir, TASK_REL), 'utf8')),
|
|
427
366
|
);
|
|
428
|
-
installFeatureReceipt(dir);
|
|
429
367
|
try {
|
|
430
368
|
const result = spawnSync(process.execPath, [gate], {
|
|
431
369
|
cwd: dir,
|
|
@@ -455,6 +393,8 @@ test('Claude artifact verification rejects traversal and symlink paths', () => {
|
|
|
455
393
|
for (const { artifactPath, symlink } of cases) {
|
|
456
394
|
const dir = tmpDir();
|
|
457
395
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
396
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
397
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
458
398
|
const artifactBytes = Buffer.from(`artifact-${artifactPath}\n`);
|
|
459
399
|
const digest = crypto.createHash('sha256').update(artifactBytes).digest('hex');
|
|
460
400
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
@@ -466,20 +406,8 @@ test('Claude artifact verification rejects traversal and symlink paths', () => {
|
|
|
466
406
|
} else {
|
|
467
407
|
fs.writeFileSync(path.join(dir, 'outside.js'), artifactBytes);
|
|
468
408
|
}
|
|
469
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
470
|
-
status: 'in_progress',
|
|
471
|
-
current_phase: 'closeout',
|
|
472
|
-
feature_name: FEATURE,
|
|
473
|
-
task_registry: {
|
|
474
|
-
[TASK_REL]: {
|
|
475
|
-
status: 'done',
|
|
476
|
-
completed_at: '2026-08-11T00:00:00.000Z',
|
|
477
|
-
artifacts: [artifactPath],
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
}));
|
|
481
409
|
fs.writeFileSync(path.join(featureDir, TASK_REL), bindFixtureReceipt(dir, [
|
|
482
|
-
'# Task', '', '
|
|
410
|
+
'# Task', '', 'Status: done', '', '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', '', '## Receipt', '',
|
|
483
411
|
'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
484
412
|
`Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
485
413
|
`Artifact: ${artifactPath} (sha256:${digest})`,
|
|
@@ -625,27 +553,6 @@ test('empty or malformed Claude hook payload fails closed', () => {
|
|
|
625
553
|
}
|
|
626
554
|
});
|
|
627
555
|
|
|
628
|
-
test('explicit Strict completion ignores worker-writable proof strings and remains blocked', () => {
|
|
629
|
-
const dir = makeFixture({
|
|
630
|
-
workflowPolicy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
|
|
631
|
-
});
|
|
632
|
-
try {
|
|
633
|
-
const specPath = path.join(dir, 'specs', FEATURE, 'spec.json');
|
|
634
|
-
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
635
|
-
spec.proofs = {
|
|
636
|
-
needsInspection: 'inspection completed',
|
|
637
|
-
needsIndependentAudit: 'PASS',
|
|
638
|
-
needsResearchGrounding: 'research completed',
|
|
639
|
-
};
|
|
640
|
-
fs.writeFileSync(specPath, JSON.stringify(spec));
|
|
641
|
-
const body = parseBlock(runHook({}, dir).stdout);
|
|
642
|
-
assert.equal(body?.decision, 'block');
|
|
643
|
-
assert.match(body.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
|
|
644
|
-
} finally {
|
|
645
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
646
|
-
}
|
|
647
|
-
});
|
|
648
|
-
|
|
649
556
|
test('7. Evidence with {{...}} placeholder → blocked (placeholder)', () => {
|
|
650
557
|
const dir = makeFixture({ evidence: 'placeholder' });
|
|
651
558
|
try {
|
|
@@ -686,70 +593,20 @@ test('9. a code fence alone is not verification proof', () => {
|
|
|
686
593
|
}
|
|
687
594
|
});
|
|
688
595
|
|
|
689
|
-
test('10. completed_at must be a valid ISO timestamp', () => {
|
|
690
|
-
const dir = makeFixture({ completed_at: 'yesterday' });
|
|
691
|
-
try {
|
|
692
|
-
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
693
|
-
const body = parseBlock(runHook({}, dir).stdout);
|
|
694
|
-
assert.ok(body);
|
|
695
|
-
assert.match(body.reason, /\bcompleted_at\b/);
|
|
696
|
-
} finally {
|
|
697
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
698
|
-
}
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
test('11. done → pending cache transition is persisted and explicit closeout gates re-completion', () => {
|
|
702
|
-
const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending', phase: 'implementation' });
|
|
703
|
-
try {
|
|
704
|
-
seedCache({ [FEATURE]: { [TASK_REL]: 'done' } });
|
|
705
|
-
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
706
|
-
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'pending');
|
|
707
|
-
|
|
708
|
-
const specFile = path.join(dir, 'specs', FEATURE, 'spec.json');
|
|
709
|
-
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
710
|
-
spec.task_registry[TASK_REL] = {
|
|
711
|
-
status: 'done',
|
|
712
|
-
completed_at: '2026-07-01T00:00:00Z',
|
|
713
|
-
};
|
|
714
|
-
spec.current_phase = 'closeout';
|
|
715
|
-
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
716
|
-
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
717
|
-
fs.writeFileSync(taskFile, '# Task\n\n**Status:** done\n\n## Evidence\n\nFAIL: tests failed\n');
|
|
718
|
-
|
|
719
|
-
assert.ok(parseBlock(runHook({}, dir).stdout), 're-completed failing task must be gated');
|
|
720
|
-
} finally {
|
|
721
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
test('12. legacy successful receipt remains read-compatible', () => {
|
|
726
|
-
const dir = makeFixture({ evidence: 'legacy-valid' });
|
|
727
|
-
try {
|
|
728
|
-
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
729
|
-
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
730
|
-
} finally {
|
|
731
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
|
|
735
596
|
test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
736
597
|
const dir = tmpDir();
|
|
737
598
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
599
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
600
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
738
601
|
const tasksDir = path.join(featureDir, 'tasks');
|
|
739
602
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
740
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
741
|
-
status: 'in_progress',
|
|
742
|
-
feature_name: FEATURE,
|
|
743
|
-
current_phase: 'closeout',
|
|
744
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
745
|
-
}));
|
|
746
603
|
// Only Base, missing Head
|
|
747
604
|
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
748
605
|
'# Task',
|
|
749
606
|
'',
|
|
750
|
-
'
|
|
607
|
+
'Status: done',
|
|
751
608
|
'',
|
|
752
|
-
'##
|
|
609
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt',
|
|
753
610
|
'',
|
|
754
611
|
'Verification: PASS',
|
|
755
612
|
'Command: npm test',
|
|
@@ -773,21 +630,17 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
|
773
630
|
test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
774
631
|
const dir = tmpDir();
|
|
775
632
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
633
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
634
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
776
635
|
const tasksDir = path.join(featureDir, 'tasks');
|
|
777
636
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
778
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
779
|
-
status: 'in_progress',
|
|
780
|
-
feature_name: FEATURE,
|
|
781
|
-
current_phase: 'closeout',
|
|
782
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
783
|
-
}));
|
|
784
637
|
// Only Head, missing Base
|
|
785
638
|
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
786
639
|
'# Task',
|
|
787
640
|
'',
|
|
788
|
-
'
|
|
641
|
+
'Status: done',
|
|
789
642
|
'',
|
|
790
|
-
'##
|
|
643
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt',
|
|
791
644
|
'',
|
|
792
645
|
'Verification: PASS',
|
|
793
646
|
'Command: npm test',
|
|
@@ -811,20 +664,16 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
811
664
|
test('15. provenance with both Base and Head passes', () => {
|
|
812
665
|
const dir = tmpDir();
|
|
813
666
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
667
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
668
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
814
669
|
const tasksDir = path.join(featureDir, 'tasks');
|
|
815
670
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
816
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
817
|
-
status: 'in_progress',
|
|
818
|
-
feature_name: FEATURE,
|
|
819
|
-
current_phase: 'closeout',
|
|
820
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
821
|
-
}));
|
|
822
671
|
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
823
672
|
'# Task',
|
|
824
673
|
'',
|
|
825
|
-
'
|
|
674
|
+
'Status: done',
|
|
826
675
|
'',
|
|
827
|
-
'##
|
|
676
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt',
|
|
828
677
|
'',
|
|
829
678
|
'Verification: PASS',
|
|
830
679
|
'Command: npm test',
|
|
@@ -844,57 +693,6 @@ test('15. provenance with both Base and Head passes', () => {
|
|
|
844
693
|
}
|
|
845
694
|
});
|
|
846
695
|
|
|
847
|
-
test('16. registry task path with ../ escape or sibling-prefix is rejected (check a)', () => {
|
|
848
|
-
const dir = tmpDir();
|
|
849
|
-
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
850
|
-
const evilFeature = path.join(dir, 'specs', `${FEATURE}-evil`);
|
|
851
|
-
const maliciousRel = `../${FEATURE}-evil/tasks/task.md`;
|
|
852
|
-
const maliciousAbs = path.join(evilFeature, 'tasks/task.md');
|
|
853
|
-
// Prepare evil file with valid receipt outside feature (should be ignored due to path traversal)
|
|
854
|
-
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
855
|
-
fs.mkdirSync(path.join(evilFeature, 'tasks'), { recursive: true });
|
|
856
|
-
fs.writeFileSync(maliciousAbs, [
|
|
857
|
-
'# Task',
|
|
858
|
-
'',
|
|
859
|
-
'**Status:** done',
|
|
860
|
-
'',
|
|
861
|
-
'## Evidence',
|
|
862
|
-
'',
|
|
863
|
-
'Verification: PASS',
|
|
864
|
-
'Command: npm test',
|
|
865
|
-
'Exit: 0',
|
|
866
|
-
`Base: ${VALID_BASE}`,
|
|
867
|
-
`Head: ${VALID_HEAD}`,
|
|
868
|
-
'```',
|
|
869
|
-
'pass',
|
|
870
|
-
'```',
|
|
871
|
-
].join('\n'));
|
|
872
|
-
// Also create sibling-prefix style path: tasks/../../demo-evil/tasks/task.md
|
|
873
|
-
const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
|
|
874
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
875
|
-
status: 'in_progress',
|
|
876
|
-
feature_name: FEATURE,
|
|
877
|
-
current_phase: 'closeout',
|
|
878
|
-
task_registry: {
|
|
879
|
-
[maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
880
|
-
[siblingPrefixRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
881
|
-
[path.resolve(featureDir, maliciousRel)]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
882
|
-
},
|
|
883
|
-
}));
|
|
884
|
-
try {
|
|
885
|
-
seedCache({ [FEATURE]: { [maliciousRel]: 'pending', [siblingPrefixRel]: 'pending', [path.resolve(featureDir, maliciousRel)]: 'pending' } });
|
|
886
|
-
const body = parseBlock(runHook({}, dir).stdout);
|
|
887
|
-
assert.ok(body, 'traversal path should block');
|
|
888
|
-
assert.strictEqual(body.decision, 'block');
|
|
889
|
-
// Must fail check a (file + Status)
|
|
890
|
-
assert.match(body.reason, /\ba\b/);
|
|
891
|
-
// Ensure malicious path is named in reason
|
|
892
|
-
assert.ok(body.reason.includes(maliciousRel) || body.reason.includes(siblingPrefixRel));
|
|
893
|
-
} finally {
|
|
894
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
895
|
-
}
|
|
896
|
-
});
|
|
897
|
-
|
|
898
696
|
test('17. empty or bare provenance fails — Base:/Head: require non-empty same-line values', () => {
|
|
899
697
|
const cases = [
|
|
900
698
|
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','Base:','Head: def456'].join('\n'), desc: 'empty Base:' },
|
|
@@ -908,14 +706,10 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
|
|
|
908
706
|
for (const { evidence, desc } of cases) {
|
|
909
707
|
const dir = tmpDir();
|
|
910
708
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
709
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
710
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
911
711
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
912
|
-
fs.writeFileSync(path.join(
|
|
913
|
-
status: 'in_progress',
|
|
914
|
-
feature_name: FEATURE,
|
|
915
|
-
current_phase: 'closeout',
|
|
916
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
917
|
-
}));
|
|
918
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
712
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
919
713
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
920
714
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
921
715
|
assert.ok(body, `empty/bare provenance should block for ${desc}`);
|
|
@@ -934,15 +728,17 @@ test('18. valid non-empty Base: value and base_sha: value pass', () => {
|
|
|
934
728
|
for (const { evidence, shouldPass } of validCases) {
|
|
935
729
|
const dir = tmpDir();
|
|
936
730
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
731
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
732
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
937
733
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
fs.writeFileSync(
|
|
734
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
735
|
+
const taskText = (receipt) => ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: ' + ((evidence.match(/^Command: (.*)$/m) || [])[1] || 'npm test'), '', '## Receipt','','' + receipt,'```','pass','```'].join('\n');
|
|
736
|
+
// Base and Head bind to the live tree only for a committed task file that has since
|
|
737
|
+
// changed, so every case commits the receipt and then edits it: a pass case rebinds
|
|
738
|
+
// to the runtime-derived pair, a fail case keeps its arbitrary values.
|
|
739
|
+
fs.writeFileSync(taskFile, taskText(evidence));
|
|
740
|
+
commitAll(dir, 'receipt');
|
|
741
|
+
fs.writeFileSync(taskFile, shouldPass ? taskText(bindFixtureReceipt(dir, evidence)) : `${taskText(evidence)}\n`);
|
|
946
742
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
947
743
|
const result = runHook({}, dir);
|
|
948
744
|
if (shouldPass) assert.strictEqual(result.stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
@@ -979,8 +775,8 @@ test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even
|
|
|
979
775
|
// mutate: strip Verification: PASS and Command — keep file as done
|
|
980
776
|
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
981
777
|
fs.writeFileSync(taskFile, [
|
|
982
|
-
'# Task', '', '
|
|
983
|
-
'##
|
|
778
|
+
'# Task', '', 'Status: done', '',
|
|
779
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt', '',
|
|
984
780
|
'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
985
781
|
'```', 'pass', '```',
|
|
986
782
|
].join('\n'));
|
|
@@ -1004,8 +800,8 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
1004
800
|
// mutate: remove Head, leave only Base
|
|
1005
801
|
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
1006
802
|
fs.writeFileSync(taskFile, [
|
|
1007
|
-
'# Task', '', '
|
|
1008
|
-
'##
|
|
803
|
+
'# Task', '', 'Status: done', '',
|
|
804
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`,
|
|
1009
805
|
'```', 'pass', '```',
|
|
1010
806
|
].join('\n'));
|
|
1011
807
|
let body = parseBlock(runHook({}, dir).stdout);
|
|
@@ -1013,8 +809,8 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
1013
809
|
assert.match(body.reason, /\bprovenance\b/);
|
|
1014
810
|
// mutate back to include both but with empty Head value (stale)
|
|
1015
811
|
fs.writeFileSync(taskFile, [
|
|
1016
|
-
'# Task', '', '
|
|
1017
|
-
'##
|
|
812
|
+
'# Task', '', 'Status: done', '',
|
|
813
|
+
'## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`, 'Head:',
|
|
1018
814
|
'```', 'pass', '```',
|
|
1019
815
|
].join('\n'));
|
|
1020
816
|
body = parseBlock(runHook({}, dir).stdout);
|
|
@@ -1025,22 +821,6 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
1025
821
|
}
|
|
1026
822
|
});
|
|
1027
823
|
|
|
1028
|
-
test('22. cache-hit: deleted task file blocks even though spec still says done', () => {
|
|
1029
|
-
const dir = makeFixture({ evidence: 'valid' });
|
|
1030
|
-
try {
|
|
1031
|
-
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1032
|
-
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
1033
|
-
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
1034
|
-
fs.unlinkSync(path.join(dir, 'specs', FEATURE, TASK_REL));
|
|
1035
|
-
const body = parseBlock(runHook({}, dir).stdout);
|
|
1036
|
-
assert.ok(body, 'deleted receipt file should block on cache-hit');
|
|
1037
|
-
assert.strictEqual(body.decision, 'block');
|
|
1038
|
-
assert.match(body.reason, /\ba\b/);
|
|
1039
|
-
} finally {
|
|
1040
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
824
|
test('23. malformed cache JSON does not bypass validation', () => {
|
|
1045
825
|
// valid receipt with malformed cache → must still PASS (fail-open on cache parse, not on receipt)
|
|
1046
826
|
const dirValid = makeFixture({ evidence: 'valid' });
|
|
@@ -1093,9 +873,10 @@ test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty m
|
|
|
1093
873
|
for (const { evidence, check, desc } of cases) {
|
|
1094
874
|
const dir = tmpDir();
|
|
1095
875
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
876
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
877
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
1096
878
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1097
|
-
fs.writeFileSync(path.join(
|
|
1098
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
879
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: ' + ((evidence.match(/^Command: (.*)$/m) || [])[1] || 'npm test'), '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
1099
880
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1100
881
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1101
882
|
assert.ok(body, `placeholder case ${desc} should block`);
|
|
@@ -1107,8 +888,8 @@ test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty m
|
|
|
1107
888
|
const dir2 = tmpDir();
|
|
1108
889
|
const featureDir2 = path.join(dir2, 'specs', FEATURE);
|
|
1109
890
|
fs.mkdirSync(path.join(featureDir2, 'tasks'), { recursive: true });
|
|
1110
|
-
fs.writeFileSync(path.join(featureDir2, '
|
|
1111
|
-
fs.writeFileSync(path.join(dir2, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir2, ['# Task','','
|
|
891
|
+
fs.writeFileSync(path.join(featureDir2, 'plan.md'), '# Demo plan\n');
|
|
892
|
+
fs.writeFileSync(path.join(dir2, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir2, ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm run todo:test', '', '## Receipt','','Verification: PASS','Command: npm run todo:test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`,'```','pass','```'].join('\n')));
|
|
1112
893
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1113
894
|
assert.strictEqual(runHook({}, dir2).stdout, '', 'command containing todo substring should not block');
|
|
1114
895
|
fs.rmSync(dir2, { recursive: true, force: true });
|
|
@@ -1123,9 +904,10 @@ test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Resu
|
|
|
1123
904
|
for (const { evidence, desc } of cases) {
|
|
1124
905
|
const dir = tmpDir();
|
|
1125
906
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
907
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
908
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
1126
909
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1127
|
-
fs.writeFileSync(path.join(
|
|
1128
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
910
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: pnpm test', '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
1129
911
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1130
912
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1131
913
|
assert.ok(body, `explicit failure ${desc} should block`);
|
|
@@ -1137,8 +919,9 @@ test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Resu
|
|
|
1137
919
|
test('27. task markdown symlink outside feature must be rejected (check a)', () => {
|
|
1138
920
|
const dir = tmpDir();
|
|
1139
921
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
922
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
923
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
1140
924
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1141
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1142
925
|
const outside = path.join(dir, 'outside.md');
|
|
1143
926
|
fs.writeFileSync(outside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
1144
927
|
const taskPath = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
@@ -1148,23 +931,7 @@ test('27. task markdown symlink outside feature must be rejected (check a)', ()
|
|
|
1148
931
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1149
932
|
assert.ok(body, 'symlink task outside should block');
|
|
1150
933
|
assert.strictEqual(body.decision, 'block');
|
|
1151
|
-
assert.match(body.reason,
|
|
1152
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
test('28. malformed spec.json must block with invalid_specs', () => {
|
|
1156
|
-
const dir = tmpDir();
|
|
1157
|
-
fs.mkdirSync(path.join(dir, 'specs', 'good'), { recursive: true });
|
|
1158
|
-
fs.mkdirSync(path.join(dir, 'specs', 'bad'), { recursive: true });
|
|
1159
|
-
fs.writeFileSync(path.join(dir, 'specs', 'good', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'good', task_registry: {} }));
|
|
1160
|
-
fs.writeFileSync(path.join(dir, 'specs', 'bad', 'spec.json'), '{ malformed');
|
|
1161
|
-
seedCache({});
|
|
1162
|
-
const { stdout } = runHook({}, dir);
|
|
1163
|
-
const body = parseBlock(stdout);
|
|
1164
|
-
assert.ok(body, 'malformed spec should block');
|
|
1165
|
-
assert.strictEqual(body.decision, 'block');
|
|
1166
|
-
assert.match(body.reason, /invalid spec/i);
|
|
1167
|
-
assert.match(body.reason, /bad/);
|
|
934
|
+
assert.match(body.reason, /invalid workflow packet/);
|
|
1168
935
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1169
936
|
});
|
|
1170
937
|
|
|
@@ -1174,7 +941,6 @@ test('29. specs/linked symlink outside must be explicit_malformed and non-explic
|
|
|
1174
941
|
fs.mkdirSync(specsDir, { recursive: true });
|
|
1175
942
|
const outside = path.join(dir, 'outside-feat');
|
|
1176
943
|
fs.mkdirSync(outside, { recursive: true });
|
|
1177
|
-
fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
1178
944
|
const link = path.join(specsDir, 'linked');
|
|
1179
945
|
fs.symlinkSync(outside, link);
|
|
1180
946
|
// non-explicit should block with invalid_specs (fail-closed, not phantom no-active)
|
|
@@ -1182,7 +948,7 @@ test('29. specs/linked symlink outside must be explicit_malformed and non-explic
|
|
|
1182
948
|
const nonExplicit = parseBlock(runHook({}, dir).stdout);
|
|
1183
949
|
assert.ok(nonExplicit, 'non-explicit symlink outside should block with invalid_specs');
|
|
1184
950
|
assert.strictEqual(nonExplicit.decision, 'block');
|
|
1185
|
-
assert.match(nonExplicit.reason, /invalid
|
|
951
|
+
assert.match(nonExplicit.reason, /invalid workflow packet/i);
|
|
1186
952
|
assert.match(nonExplicit.reason, /linked/);
|
|
1187
953
|
// explicit should block explicit_malformed
|
|
1188
954
|
const body = parseBlock(runHook({ featureName: 'linked' }, dir).stdout);
|
|
@@ -1224,15 +990,11 @@ test('30. phantom vectors via gate evidence must block with verification_state (
|
|
|
1224
990
|
for (const v of vectors) {
|
|
1225
991
|
const dir = tmpDir();
|
|
1226
992
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
993
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
994
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
1227
995
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1228
|
-
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
1229
|
-
status: 'in_progress',
|
|
1230
|
-
feature_name: FEATURE,
|
|
1231
|
-
current_phase: 'closeout',
|
|
1232
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1233
|
-
}));
|
|
1234
996
|
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1235
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','
|
|
997
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
1236
998
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1237
999
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1238
1000
|
assert.ok(body, `vector should block: ${v}`);
|
|
@@ -1271,14 +1033,9 @@ test('30. phantom vectors via gate evidence must block with verification_state (
|
|
|
1271
1033
|
const dir = tmpDir();
|
|
1272
1034
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1273
1035
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1274
|
-
fs.writeFileSync(path.join(featureDir, '
|
|
1275
|
-
status: 'in_progress',
|
|
1276
|
-
feature_name: FEATURE,
|
|
1277
|
-
current_phase: 'closeout',
|
|
1278
|
-
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1279
|
-
}));
|
|
1036
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
|
|
1280
1037
|
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1281
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','
|
|
1038
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','Status: done','','## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: npm test', '', '## Receipt','','' + evidence,'```','pass','```'].join('\n'));
|
|
1282
1039
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1283
1040
|
const { stdout } = runHook({}, dir);
|
|
1284
1041
|
assert.strictEqual(stdout, '', `positive control should not block: ${v}`);
|
|
@@ -1510,11 +1267,14 @@ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
|
|
|
1510
1267
|
}
|
|
1511
1268
|
|
|
1512
1269
|
function runHookFrom(dir, cwd) {
|
|
1270
|
+
const env = { ...process.env };
|
|
1271
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
1272
|
+
delete env.PROJECT_ROOT;
|
|
1513
1273
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
1514
1274
|
cwd,
|
|
1515
1275
|
input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
|
|
1516
1276
|
encoding: 'utf8',
|
|
1517
|
-
env
|
|
1277
|
+
env,
|
|
1518
1278
|
});
|
|
1519
1279
|
return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
|
|
1520
1280
|
}
|
|
@@ -1617,17 +1377,142 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
|
|
|
1617
1377
|
clearCache();
|
|
1618
1378
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1619
1379
|
assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
|
|
1623
|
-
// not the binding-mode selector. The selector's own HEAD-unreadable branch
|
|
1624
|
-
// is guarded by cases 38 and 39.
|
|
1625
|
-
assert.match(body.reason, /controlled failure/);
|
|
1380
|
+
// A done task still blocks before receipt validation, with guidance to
|
|
1381
|
+
// create the first commit before recording its Base/Head.
|
|
1382
|
+
assert.match(body.reason, /first commit/);
|
|
1626
1383
|
} finally {
|
|
1627
1384
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1628
1385
|
}
|
|
1629
1386
|
});
|
|
1630
1387
|
|
|
1388
|
+
function noHistoryFixture() {
|
|
1389
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-no-history-'));
|
|
1390
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
|
|
1391
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
1392
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1393
|
+
}
|
|
1394
|
+
installClaudeGate(dir);
|
|
1395
|
+
fs.copyFileSync(path.join(__dirname, '..', 'spec-state.cjs'), path.join(dir, '.claude/hooks/spec-state.cjs'));
|
|
1396
|
+
return dir;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
test('Stop still blocks multiple active specs for an untouched session', () => {
|
|
1400
|
+
const dir = tmpDir();
|
|
1401
|
+
try {
|
|
1402
|
+
const gate = installClaudeGate(dir);
|
|
1403
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'in_progress');
|
|
1404
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
1405
|
+
cwd: dir, encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir },
|
|
1406
|
+
input: JSON.stringify({ cwd: dir, session_id: 'untouched-session', hook_event_name: 'Stop', prompt: 'Continue' }),
|
|
1407
|
+
});
|
|
1408
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1409
|
+
const body = parseBlock(result.stdout);
|
|
1410
|
+
assert.strictEqual(body.decision, 'block');
|
|
1411
|
+
assert.match(body.reason, /multiple active specs/);
|
|
1412
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
function writeNoHistoryPacket(dir, feature, status) {
|
|
1416
|
+
const packet = path.join(dir, 'specs', feature);
|
|
1417
|
+
fs.mkdirSync(packet, { recursive: true });
|
|
1418
|
+
fs.writeFileSync(path.join(packet, 'plan.md'), `# ${feature}\nSpecs-Contract: process-first-ready-v1\n`);
|
|
1419
|
+
fs.writeFileSync(path.join(packet, 'task-01-work.md'), [
|
|
1420
|
+
'# Task 01: work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
|
|
1421
|
+
'## Verification Plan', '', '- Command: node --test', '',
|
|
1422
|
+
...(status === 'done' ? ['## Receipt', '', 'Verification: PASS', ''] : []),
|
|
1423
|
+
].join('\n'));
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function runInstalledHistoryHook(dir, hook = 'spec-gate', extraEnv = {}) {
|
|
1427
|
+
return spawnSync(process.execPath, [path.join(dir, '.claude/hooks', `${hook}.cjs`)], {
|
|
1428
|
+
cwd: dir, encoding: 'utf8',
|
|
1429
|
+
env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir, ...extraEnv },
|
|
1430
|
+
input: JSON.stringify({ cwd: dir, session_id: 'no-history',
|
|
1431
|
+
hook_event_name: hook === 'spec-state' ? 'UserPromptSubmit' : 'Stop', prompt: 'Continue specs/demo' }),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
test('no commits allows Stop without done tasks and prints one reminder', () => {
|
|
1436
|
+
const dir = noHistoryFixture();
|
|
1437
|
+
try {
|
|
1438
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1439
|
+
const gate = runInstalledHistoryHook(dir);
|
|
1440
|
+
assert.strictEqual(gate.status, 0, gate.stderr);
|
|
1441
|
+
assert.strictEqual(gate.stdout.trim(), '');
|
|
1442
|
+
const state = runInstalledHistoryHook(dir, 'spec-state');
|
|
1443
|
+
assert.strictEqual(state.status, 0, state.stderr);
|
|
1444
|
+
assert.match(state.stdout, /no commits/);
|
|
1445
|
+
assert.strictEqual(state.stdout.trim().split('\n').length, 1);
|
|
1446
|
+
assert.doesNotMatch(state.stdout, /Spec tollgate unavailable/);
|
|
1447
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test('no commits blocks a done task with first commit guidance', () => {
|
|
1451
|
+
const dir = noHistoryFixture();
|
|
1452
|
+
try {
|
|
1453
|
+
writeNoHistoryPacket(dir, 'demo', 'done');
|
|
1454
|
+
const result = runInstalledHistoryHook(dir);
|
|
1455
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1456
|
+
const body = parseBlock(result.stdout);
|
|
1457
|
+
assert.strictEqual(body.decision, 'block');
|
|
1458
|
+
assert.match(body.reason, /first commit/);
|
|
1459
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1460
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test('no commits blocks two completed packets with first commit guidance', () => {
|
|
1464
|
+
const dir = noHistoryFixture();
|
|
1465
|
+
try {
|
|
1466
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'done');
|
|
1467
|
+
const result = runInstalledHistoryHook(dir);
|
|
1468
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1469
|
+
const body = parseBlock(result.stdout);
|
|
1470
|
+
assert.strictEqual(body.decision, 'block');
|
|
1471
|
+
assert.match(body.reason, /first commit/);
|
|
1472
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1473
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test('a corrupt current branch ref remains a controlled failure', () => {
|
|
1477
|
+
const dir = tmpDir();
|
|
1478
|
+
try {
|
|
1479
|
+
installClaudeGate(dir);
|
|
1480
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1481
|
+
const ref = spawnSync('git', ['-C', dir, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
|
|
1482
|
+
assert.strictEqual(ref.status, 0, ref.stderr);
|
|
1483
|
+
fs.writeFileSync(path.join(dir, '.git', ref.stdout.trim()), 'corrupt-ref\n');
|
|
1484
|
+
const result = runInstalledHistoryHook(dir);
|
|
1485
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1486
|
+
const body = parseBlock(result.stdout);
|
|
1487
|
+
assert.strictEqual(body.decision, 'block');
|
|
1488
|
+
assert.match(body.reason, /controlled failure/);
|
|
1489
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test('unavailable git remains a controlled failure in a repo without commits', () => {
|
|
1493
|
+
const dir = noHistoryFixture();
|
|
1494
|
+
try {
|
|
1495
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1496
|
+
const emptyPath = path.join(dir, 'empty-path');
|
|
1497
|
+
fs.mkdirSync(emptyPath);
|
|
1498
|
+
const result = runInstalledHistoryHook(dir, 'spec-gate', { PATH: emptyPath });
|
|
1499
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1500
|
+
const body = parseBlock(result.stdout);
|
|
1501
|
+
assert.strictEqual(body.decision, 'block');
|
|
1502
|
+
assert.match(body.reason, /controlled failure/);
|
|
1503
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
test('a committed repo still accepts a valid done task receipt', () => {
|
|
1507
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
1508
|
+
try {
|
|
1509
|
+
installClaudeGate(dir);
|
|
1510
|
+
const result = runInstalledHistoryHook(dir);
|
|
1511
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1512
|
+
assert.strictEqual(result.stdout.trim(), '');
|
|
1513
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1631
1516
|
test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
|
|
1632
1517
|
// Contract change. This used to require a block. `Base` is the last commit
|
|
1633
1518
|
// touching anything outside the specs root, so a receipt written before any
|
|
@@ -2026,3 +1911,89 @@ test('53. an indented or sub-heading Command never becomes the canonical one', (
|
|
|
2026
1911
|
}
|
|
2027
1912
|
}
|
|
2028
1913
|
});
|
|
1914
|
+
|
|
1915
|
+
function plantLegacyPacket(dir, name = 'old') {
|
|
1916
|
+
const legacyDir = path.join(dir, 'specs', name);
|
|
1917
|
+
fs.mkdirSync(path.join(legacyDir, 'tasks'), { recursive: true });
|
|
1918
|
+
fs.writeFileSync(path.join(legacyDir, 'spec.json'), JSON.stringify({
|
|
1919
|
+
feature_name: name,
|
|
1920
|
+
status: 'in_progress',
|
|
1921
|
+
task_registry: { 'tasks/task-R1-01-x.md': { status: 'done' } },
|
|
1922
|
+
}));
|
|
1923
|
+
fs.writeFileSync(path.join(legacyDir, 'tasks', 'task-R1-01-x.md'), '# Task\n\n**Status:** done\n');
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function writeActiveFeature(dir, featureName) {
|
|
1927
|
+
fs.mkdirSync(path.join(dir, 'specs', '_shared'), { recursive: true });
|
|
1928
|
+
fs.writeFileSync(path.join(dir, 'specs', '_shared', 'active-feature.json'), JSON.stringify({ featureName }));
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
test('a leftover legacy packet alone never blocks Stop', () => {
|
|
1932
|
+
const dir = tmpDir();
|
|
1933
|
+
try {
|
|
1934
|
+
plantLegacyPacket(dir);
|
|
1935
|
+
clearCache();
|
|
1936
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
1937
|
+
} finally {
|
|
1938
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
test('a recorded active feature naming a legacy packet does not block Stop', () => {
|
|
1943
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
1944
|
+
try {
|
|
1945
|
+
plantLegacyPacket(dir);
|
|
1946
|
+
writeActiveFeature(dir, 'old');
|
|
1947
|
+
clearCache();
|
|
1948
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
1949
|
+
} finally {
|
|
1950
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
test('a recorded active feature naming a missing packet still blocks as explicit_not_found', () => {
|
|
1955
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
1956
|
+
try {
|
|
1957
|
+
writeActiveFeature(dir, 'nope');
|
|
1958
|
+
clearCache();
|
|
1959
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1960
|
+
assert.strictEqual(body?.decision, 'block');
|
|
1961
|
+
assert.match(body.reason, /explicit_not_found/);
|
|
1962
|
+
} finally {
|
|
1963
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
test('a process-first packet with a dependency cycle and a done task blocks Stop', () => {
|
|
1968
|
+
const dir = tmpDir();
|
|
1969
|
+
try {
|
|
1970
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1971
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
1972
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
1973
|
+
const task = (status, dependency) => [
|
|
1974
|
+
'# Task', '', `Status: ${status}`, '', '## Dependencies', '', `- ${dependency}`, '',
|
|
1975
|
+
'## Verification Plan', '', '- Command: npm test', '',
|
|
1976
|
+
].join('\n');
|
|
1977
|
+
fs.writeFileSync(path.join(featureDir, 'task-01-a.md'), task('done', 'task-02-b.md'));
|
|
1978
|
+
fs.writeFileSync(path.join(featureDir, 'task-02-b.md'), task('pending', 'task-01-a.md'));
|
|
1979
|
+
clearCache();
|
|
1980
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1981
|
+
assert.strictEqual(body?.decision, 'block');
|
|
1982
|
+
assert.match(body.reason, /invalid workflow packet/);
|
|
1983
|
+
} finally {
|
|
1984
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
test('a packet holding both plan.md and spec.json is read as process-first and its unreceipted done task blocks', () => {
|
|
1989
|
+
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
1990
|
+
try {
|
|
1991
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, 'spec.json'), JSON.stringify({ feature_name: FEATURE }));
|
|
1992
|
+
clearCache();
|
|
1993
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1994
|
+
assert.strictEqual(body?.decision, 'block');
|
|
1995
|
+
assert.match(body.reason, new RegExp(TASK_REL));
|
|
1996
|
+
} finally {
|
|
1997
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1998
|
+
}
|
|
1999
|
+
});
|