@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4
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 +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- 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
|
@@ -5,59 +5,171 @@
|
|
|
5
5
|
* Stop Hook — spec-gate.cjs
|
|
6
6
|
* Implements: https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
7
7
|
*
|
|
8
|
-
* Blocks turn end when
|
|
8
|
+
* Blocks turn end when any done task lacks a verification receipt.
|
|
9
9
|
* Block contract (exit 0 + stdout): {"decision":"block","reason":"..."}.
|
|
10
10
|
*
|
|
11
|
-
* Safety: stop_hook_active loop guard;
|
|
12
|
-
*
|
|
13
|
-
* done transition is validated even on first run / empty cache; crash →
|
|
11
|
+
* Safety: stop_hook_active loop guard; no worker-writable completion-gate
|
|
12
|
+
* bypass exists; cache is optimization only - every
|
|
13
|
+
* done transition is validated even on first run / empty cache; crash → controlled block + hooks/.logs/hook-log.jsonl. Exit: 0 always.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
function projectRoot(payload = {}) {
|
|
20
|
+
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
21
|
+
? process.env.CLAUDE_PROJECT_DIR.trim()
|
|
22
|
+
: '';
|
|
23
|
+
if (configured) {
|
|
24
|
+
try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
28
|
+
const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
|
|
29
|
+
if (fs.existsSync(installedHook)) return installedRoot;
|
|
30
|
+
|
|
31
|
+
const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
|
|
32
|
+
? process.env.PROJECT_ROOT.trim()
|
|
33
|
+
: '';
|
|
34
|
+
if (sourceFixture) {
|
|
35
|
+
try { return fs.realpathSync(path.resolve(sourceFixture)); } catch { /* continue */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const legacy = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
39
|
+
if (legacy) {
|
|
40
|
+
try { return fs.realpathSync(path.resolve(legacy)); } catch { /* continue */ }
|
|
41
|
+
}
|
|
42
|
+
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logCrash(error) {
|
|
46
|
+
try {
|
|
47
|
+
const d = path.join(__dirname, '.logs');
|
|
48
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
49
|
+
fs.appendFileSync(
|
|
50
|
+
path.join(d, 'hook-log.jsonl'),
|
|
51
|
+
JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: error.message }) + '\n'
|
|
52
|
+
);
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emitBlock(reason) {
|
|
57
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionIdentity(payload) {
|
|
61
|
+
const candidates = [
|
|
62
|
+
payload && payload.session_id,
|
|
63
|
+
payload && payload.sessionId,
|
|
64
|
+
payload && payload.sessionID,
|
|
65
|
+
payload && payload.session && payload.session.id,
|
|
66
|
+
];
|
|
67
|
+
return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
16
70
|
try {
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
71
|
const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
|
|
20
|
-
const POLICY = require(policyPath);
|
|
21
72
|
|
|
22
73
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
23
|
-
if (!stdin)
|
|
74
|
+
if (!stdin) throw new Error('hook payload is empty');
|
|
24
75
|
|
|
25
76
|
const payload = JSON.parse(stdin);
|
|
26
|
-
|
|
77
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
78
|
+
throw new Error('hook payload must be a JSON object');
|
|
79
|
+
}
|
|
27
80
|
|
|
28
81
|
// Never re-block a continuation caused by our own block (infinite-loop guard).
|
|
29
82
|
if (payload.stop_hook_active === true) process.exit(0);
|
|
30
83
|
|
|
84
|
+
let POLICY;
|
|
85
|
+
let RESOLVER;
|
|
86
|
+
let RECEIPT;
|
|
87
|
+
let FINAL_STATE;
|
|
88
|
+
try {
|
|
89
|
+
POLICY = require(policyPath);
|
|
90
|
+
RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
|
|
91
|
+
RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
|
|
92
|
+
FINAL_STATE = require('./completion-authority-check.cjs');
|
|
93
|
+
if (typeof POLICY.validateCanonicalReceipt !== 'function'
|
|
94
|
+
|| typeof POLICY.completionDecisionForSpec !== 'function'
|
|
95
|
+
|| typeof RECEIPT.checkTaskReceipt !== 'function'
|
|
96
|
+
|| typeof FINAL_STATE.evaluateCloseout !== 'function') {
|
|
97
|
+
throw new Error('shared workflow policy lacks completion authority functions');
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logCrash(error);
|
|
101
|
+
emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${error.message}). Repair ${policyPath} before completing tasks.`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cwd = projectRoot(payload);
|
|
106
|
+
|
|
31
107
|
// Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
|
|
32
108
|
let runtime = {};
|
|
33
109
|
try {
|
|
34
110
|
const rp = path.join(cwd, '.claude', 'runtime.json');
|
|
35
111
|
if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
|
|
36
112
|
} catch { /* gate stays on */ }
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
113
|
+
// Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
|
|
114
|
+
const baseDir = cwd;
|
|
115
|
+
const resolved = FINAL_STATE.resolveCandidate({ resolver: RESOLVER, projectRoot: baseDir, runtime, payload });
|
|
116
|
+
if (!resolved) process.exit(0);
|
|
117
|
+
if (resolved.error === 'multiple_active' || resolved.error === 'multiple_persisted') {
|
|
118
|
+
process.stdout.write(JSON.stringify({
|
|
119
|
+
decision: 'block',
|
|
120
|
+
reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks. Candidates: ${resolved.candidates.join(', ')}`
|
|
121
|
+
}) + '\n');
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
if (resolved.error === 'invalid_specs') {
|
|
125
|
+
process.stdout.write(JSON.stringify({
|
|
126
|
+
decision: 'block',
|
|
127
|
+
reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
|
|
128
|
+
}) + '\n');
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
132
|
+
process.stdout.write(JSON.stringify({
|
|
133
|
+
decision: 'block',
|
|
134
|
+
reason: `Completion gate: explicit spec target invalid (${resolved.error}): ${resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown'}. Provide a valid feature target inside configured specs root.`
|
|
135
|
+
}) + '\n');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
if (resolved.error) process.exit(0);
|
|
60
139
|
|
|
140
|
+
const activeSpec = resolved.spec;
|
|
141
|
+
const featureName = resolved.featureName;
|
|
142
|
+
const specsPath = resolved.specsDir;
|
|
143
|
+
const lifecyclePhase = activeSpec.current_phase || activeSpec.phase;
|
|
144
|
+
const explicitCloseout = ['done', 'completed', 'complete'].includes(activeSpec.status)
|
|
145
|
+
|| ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
|
|
146
|
+
if (activeSpec.schema_version === '2.1') {
|
|
147
|
+
const finalState = FINAL_STATE.evaluateCloseout({
|
|
148
|
+
resolver: RESOLVER,
|
|
149
|
+
policy: POLICY,
|
|
150
|
+
projectRoot: baseDir,
|
|
151
|
+
runtime,
|
|
152
|
+
payload: { ...payload, session_id: sessionIdentity(payload) },
|
|
153
|
+
});
|
|
154
|
+
if (!finalState.ok) {
|
|
155
|
+
emitBlock(`Completion gate: ${finalState.reason}`);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
if (finalState.active) process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
161
|
+
projectRoot: baseDir,
|
|
162
|
+
specsRoot: specsPath,
|
|
163
|
+
specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
|
|
164
|
+
featureName,
|
|
165
|
+
runtimeSession: sessionIdentity(payload),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const configuredGate = runtime.spec?.completion_gate;
|
|
169
|
+
if (configuredGate !== undefined && configuredGate !== true) {
|
|
170
|
+
emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
61
173
|
const taskRegistry = activeSpec.task_registry || {};
|
|
62
174
|
const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json');
|
|
63
175
|
const cacheExists = fs.existsSync(cacheFile);
|
|
@@ -90,103 +202,30 @@ try {
|
|
|
90
202
|
const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
|
|
91
203
|
(taskRegistry[tp]?.status || 'pending') === 'done'
|
|
92
204
|
);
|
|
93
|
-
|
|
94
|
-
// legacy heading aliases: read-compat only, no longer advertised
|
|
95
|
-
const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
|
|
96
|
-
const PASS_MARKER_RE = /^\s*Verification:\s*PASS\s*$/m;
|
|
97
|
-
const LEGACY_SUCCESS_RE = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
98
|
-
const EXPLICIT_FAILURE_RE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
|
|
99
|
-
|
|
100
|
-
/** Body of first Evidence heading until next same-or-higher heading. */
|
|
101
|
-
function evidenceBody(text) {
|
|
102
|
-
const lines = text.split('\n');
|
|
103
|
-
let start = -1, level = 0;
|
|
104
|
-
for (let i = 0; i < lines.length; i++) {
|
|
105
|
-
const m = lines[i].match(/^(#{2,3})\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/);
|
|
106
|
-
if (m) { start = i + 1; level = m[1].length; break; }
|
|
107
|
-
}
|
|
108
|
-
if (start < 0) return null;
|
|
109
|
-
let end = lines.length;
|
|
110
|
-
for (let i = start; i < lines.length; i++) {
|
|
111
|
-
const hm = lines[i].match(/^(#{1,6})\s+/);
|
|
112
|
-
if (hm && hm[1].length <= level) { end = i; break; }
|
|
113
|
-
}
|
|
114
|
-
return lines.slice(start, end).join('\n');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function validateCanonicalReceipt(body) {
|
|
118
|
-
const fails = [];
|
|
119
|
-
if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) fails.push('verification_state');
|
|
120
|
-
if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) fails.push('command');
|
|
121
|
-
if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) fails.push('exit_result');
|
|
122
|
-
const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
|
|
123
|
-
const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
|
|
124
|
-
const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
|
|
125
|
-
const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
|
|
126
|
-
if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) fails.push('provenance');
|
|
127
|
-
if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) fails.push('artifact_hash');
|
|
128
|
-
return fails;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Receipt checks a–h → failed letter list.
|
|
132
|
-
// a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + unambiguous PASS; d: completed_at; e: command; f: exit/result; g: provenance; h: artifact hash
|
|
133
|
-
function safeTaskFile(featureDir, taskPath) {
|
|
134
|
-
const target = path.resolve(featureDir, taskPath);
|
|
135
|
-
const relative = path.relative(featureDir, target);
|
|
136
|
-
// sibling-prefix safe: must not be .., ../..., or absolute (cross-platform)
|
|
137
|
-
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
138
|
-
return target;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function checkReceipt(taskPath) {
|
|
142
|
-
const fails = [];
|
|
143
|
-
const featureDir = path.join(specsPath, featureName);
|
|
144
|
-
const abs = safeTaskFile(featureDir, taskPath);
|
|
145
|
-
if (!abs || !fs.existsSync(abs)) return ['a'];
|
|
146
|
-
let text = '';
|
|
147
|
-
try { text = fs.readFileSync(abs, 'utf8'); } catch { return ['a']; }
|
|
148
|
-
|
|
149
|
-
const statusLine = text.split('\n').find((l) => /^\s*(\*\*)?Status(\*\*)?\s*:/i.test(l));
|
|
150
|
-
if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a');
|
|
151
|
-
|
|
152
|
-
const body = evidenceBody(text);
|
|
153
|
-
if (body === null || !EVID_RE.test(text)) {
|
|
154
|
-
fails.push('b');
|
|
155
|
-
} else if (
|
|
156
|
-
/\{\{[^}]+\}\}/.test(body) ||
|
|
157
|
-
EXPLICIT_FAILURE_RE.test(body) ||
|
|
158
|
-
!(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
|
|
159
|
-
) {
|
|
160
|
-
fails.push('c');
|
|
161
|
-
} else {
|
|
162
|
-
// Canonical receipt checks for done tasks
|
|
163
|
-
const canonicalFails = validateCanonicalReceipt(body);
|
|
164
|
-
const map = { verification_state: 'c', command: 'e', exit_result: 'f', provenance: 'g', artifact_hash: 'h' };
|
|
165
|
-
for (const cf of canonicalFails) {
|
|
166
|
-
if (cf === 'verification_state') {
|
|
167
|
-
if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const letter = map[cf];
|
|
171
|
-
if (letter && !fails.includes(letter)) fails.push(letter);
|
|
172
|
-
}
|
|
173
|
-
// Enforce strict Verification: PASS, not just legacy success
|
|
174
|
-
if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const at = taskRegistry[taskPath]?.completed_at;
|
|
178
|
-
if (
|
|
179
|
-
typeof at !== 'string' ||
|
|
180
|
-
!/^\d{4}-\d{2}-\d{2}T/.test(at) ||
|
|
181
|
-
Number.isNaN(Date.parse(at))
|
|
182
|
-
) fails.push('d');
|
|
183
|
-
return fails;
|
|
184
|
-
}
|
|
185
|
-
|
|
205
|
+
const featureDir = path.join(specsPath, featureName);
|
|
186
206
|
const failures = allDoneTasks
|
|
187
|
-
.map((
|
|
207
|
+
.map((taskPath) => ({
|
|
208
|
+
taskPath,
|
|
209
|
+
fails: RECEIPT.checkTaskReceipt(featureDir, taskPath, taskRegistry[taskPath], runtimeContext, POLICY).failures,
|
|
210
|
+
}))
|
|
188
211
|
.filter((f) => f.fails.length > 0);
|
|
189
212
|
|
|
213
|
+
const featureCloseoutRequired = explicitCloseout && allDoneTasks.length > 0;
|
|
214
|
+
const featureReceipt = featureCloseoutRequired
|
|
215
|
+
? RECEIPT.checkFeatureReceipt(featureDir, runtimeContext, POLICY)
|
|
216
|
+
: null;
|
|
217
|
+
if (featureReceipt && featureReceipt.failures.length) {
|
|
218
|
+
failures.push({ taskPath: 'feature-receipt.md', fails: featureReceipt.failures });
|
|
219
|
+
}
|
|
220
|
+
const completion = featureCloseoutRequired && featureReceipt?.failures.length === 0
|
|
221
|
+
&& Object.prototype.hasOwnProperty.call(activeSpec, 'workflow_policy')
|
|
222
|
+
? POLICY.completionDecisionForSpec(activeSpec, {
|
|
223
|
+
runtimeContext,
|
|
224
|
+
executionReceipt: featureReceipt.body,
|
|
225
|
+
taskContext: {},
|
|
226
|
+
})
|
|
227
|
+
: null;
|
|
228
|
+
|
|
190
229
|
// Always persist status transitions, including done → pending. Keep
|
|
191
230
|
// failing done tasks at their previous cached status so a stale valid
|
|
192
231
|
// entry cannot mask a mutated receipt on the next Stop; with
|
|
@@ -202,28 +241,31 @@ try {
|
|
|
202
241
|
fs.writeFileSync(cacheFile, JSON.stringify(cache));
|
|
203
242
|
} catch { /* fail-open */ }
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
if (failures.length === 0) process.exit(0);
|
|
244
|
+
const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
|
|
245
|
+
if (failures.length === 0 && !completionBlocked) process.exit(0);
|
|
207
246
|
|
|
208
247
|
const lines = [
|
|
209
|
-
|
|
248
|
+
failures.length > 0
|
|
249
|
+
? `⚠️ Completion gate: ${failures.length} done task(s) lack a verification receipt.`
|
|
250
|
+
: '⚠️ Completion gate: workflow completion proof is incomplete.',
|
|
210
251
|
];
|
|
211
252
|
for (const { taskPath, fails } of failures) {
|
|
212
253
|
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
|
|
213
|
-
lines.push(
|
|
214
|
-
` Fix:
|
|
215
|
-
|
|
254
|
+
lines.push(taskPath === 'feature-receipt.md'
|
|
255
|
+
? ` Fix: run final integration proof, then write \`specs/${featureName}/feature-receipt.md\`.`
|
|
256
|
+
: ` Fix: write canonical proof to \`specs/${featureName}/receipts/${path.posix.basename(taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
|
|
257
|
+
}
|
|
258
|
+
if (completionBlocked) {
|
|
259
|
+
lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
|
|
260
|
+
if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
|
|
261
|
+
lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
|
|
262
|
+
}
|
|
216
263
|
}
|
|
217
264
|
process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
|
|
218
265
|
process.exit(0);
|
|
219
266
|
|
|
220
267
|
} catch (e) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const d = p.join(__dirname, '.logs');
|
|
224
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
225
|
-
fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
|
|
226
|
-
JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: e.message }) + '\n');
|
|
227
|
-
} catch (_) {}
|
|
268
|
+
logCrash(e);
|
|
269
|
+
emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
|
|
228
270
|
process.exit(0);
|
|
229
271
|
}
|
|
@@ -8,14 +8,42 @@
|
|
|
8
8
|
* Scans for an active spec in progress and dynamically injects
|
|
9
9
|
* the State Sync (Tollgate) rule into the agent's context.
|
|
10
10
|
*
|
|
11
|
-
* Exit: 0 always
|
|
11
|
+
* Exit: 0 always; unavailable policy is surfaced as a controlled failure.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
18
16
|
|
|
17
|
+
function logCrash(error) {
|
|
18
|
+
try {
|
|
19
|
+
const d = path.join(__dirname, '.logs');
|
|
20
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
21
|
+
fs.appendFileSync(
|
|
22
|
+
path.join(d, 'hook-log.jsonl'),
|
|
23
|
+
JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-state', status: 'crash', error: error.message }) + '\n'
|
|
24
|
+
);
|
|
25
|
+
} catch (_) {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emitControlledFailure(reason) {
|
|
29
|
+
console.log(`\n> ⚠️ Spec tollgate unavailable: ${reason}. Repair the installed workflow policy before continuing.\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sessionIdentity(payload) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
payload && payload.session_id,
|
|
35
|
+
payload && payload.sessionId,
|
|
36
|
+
payload && payload.sessionID,
|
|
37
|
+
payload && payload.session && payload.session.id,
|
|
38
|
+
];
|
|
39
|
+
return candidates.find((value) => typeof value === 'string' && value.length > 0) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalPath(value) {
|
|
43
|
+
try { return fs.realpathSync(value); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
19
47
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
20
48
|
|
|
21
49
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
@@ -24,6 +52,20 @@ try {
|
|
|
24
52
|
const payload = JSON.parse(stdin);
|
|
25
53
|
const cwd = payload.cwd || process.cwd();
|
|
26
54
|
|
|
55
|
+
let POLICY;
|
|
56
|
+
let RESOLVER;
|
|
57
|
+
let RECEIPT;
|
|
58
|
+
try {
|
|
59
|
+
POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
|
|
60
|
+
RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
|
|
61
|
+
RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
|
|
62
|
+
if (typeof POLICY.flashState !== 'function') throw new Error('shared workflow policy has no flashState function');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logCrash(error);
|
|
65
|
+
emitControlledFailure(error.message);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
27
69
|
// Read runtime configuration if exists
|
|
28
70
|
let runtime = {};
|
|
29
71
|
try {
|
|
@@ -36,37 +78,47 @@ try {
|
|
|
36
78
|
if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
|
|
37
79
|
|
|
38
80
|
const baseDir = process.env.PROJECT_ROOT || cwd;
|
|
39
|
-
const
|
|
81
|
+
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
82
|
+
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
83
|
+
const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
|
|
40
84
|
|
|
41
|
-
if (!
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
process.exit(0); // No active spec, do nothing
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (resolved.error === 'multiple_active') {
|
|
90
|
+
const candidates = resolved.candidates.join(', ');
|
|
91
|
+
console.log(`\n> ⚠️ Multiple active specs detected: ${candidates}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (resolved.error === 'invalid_specs') {
|
|
96
|
+
const cands = resolved.candidates.join(', ');
|
|
97
|
+
console.log(`\n> ⚠️ Invalid spec JSON detected: ${cands}. ${resolved.reason}. Fix or remove malformed spec.json before continuing. Tollgate paused.\n`);
|
|
42
98
|
process.exit(0);
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const entries = fs.readdirSync(specsPath, { withFileTypes: true });
|
|
50
|
-
for (const entry of entries) {
|
|
51
|
-
if (entry.isDirectory()) {
|
|
52
|
-
const specFile = path.join(specsPath, entry.name, 'spec.json');
|
|
53
|
-
if (fs.existsSync(specFile)) {
|
|
54
|
-
try {
|
|
55
|
-
const specData = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
56
|
-
if (specData.status === 'in_progress' || specData.status === 'in-progress') {
|
|
57
|
-
activeSpec = specData;
|
|
58
|
-
featureName = entry.name;
|
|
59
|
-
break; // take the first active one
|
|
60
|
-
}
|
|
61
|
-
} catch { /* skip bad JSON */ }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
101
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
102
|
+
const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
|
|
103
|
+
console.log(`\n> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
|
|
104
|
+
process.exit(0);
|
|
64
105
|
}
|
|
65
106
|
|
|
66
|
-
if (
|
|
67
|
-
process.exit(0);
|
|
107
|
+
if (resolved.error) {
|
|
108
|
+
process.exit(0);
|
|
68
109
|
}
|
|
69
110
|
|
|
111
|
+
const activeSpec = resolved.spec;
|
|
112
|
+
const featureName = resolved.featureName;
|
|
113
|
+
const specsPath = resolved.specsDir;
|
|
114
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
115
|
+
projectRoot: baseDir,
|
|
116
|
+
specsRoot: specsPath,
|
|
117
|
+
specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
|
|
118
|
+
featureName,
|
|
119
|
+
runtimeSession: sessionIdentity(payload),
|
|
120
|
+
});
|
|
121
|
+
|
|
70
122
|
const phase = activeSpec.current_phase || activeSpec.phase || 'unknown';
|
|
71
123
|
const taskRegistry = activeSpec.task_registry || {};
|
|
72
124
|
const flashTasks = POLICY.flashState(taskRegistry);
|
|
@@ -82,27 +134,44 @@ try {
|
|
|
82
134
|
const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
|
|
83
135
|
return status === 'pending' && deps.every((dep) => taskStatusByPath.get(dep) === 'done');
|
|
84
136
|
});
|
|
137
|
+
const featureDir = path.join(specsPath, featureName);
|
|
138
|
+
const featureReceiptPresent = RECEIPT.safeRead(featureDir, 'feature-receipt.md').status === 'ok';
|
|
85
139
|
|
|
86
140
|
// ── State-change gate: only emit the full tollgate when spec state changed ──
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
141
|
+
// The cache is shared by installed hook copies, so its key must carry the
|
|
142
|
+
// canonical project, complete session identity, and canonical spec identity.
|
|
143
|
+
// If any identity is unavailable, skip caching and emit the full block.
|
|
144
|
+
const stateKey = JSON.stringify({
|
|
145
|
+
projectRoot: runtimeContext.project_root,
|
|
146
|
+
specsRoot: runtimeContext.specs_root,
|
|
147
|
+
specFile: runtimeContext.spec_file,
|
|
148
|
+
featureName: runtimeContext.feature_name,
|
|
149
|
+
runtimeSession: runtimeContext.runtime_session,
|
|
150
|
+
provenanceMode: runtimeContext.provenance_mode,
|
|
151
|
+
Base: runtimeContext.base,
|
|
152
|
+
Head: runtimeContext.head,
|
|
153
|
+
contextId: runtimeContext.context_id,
|
|
154
|
+
phase,
|
|
155
|
+
done: taskCounts.done || 0,
|
|
156
|
+
total: taskEntries.length,
|
|
157
|
+
featureReceiptPresent,
|
|
158
|
+
});
|
|
92
159
|
const cacheFile = path.join(__dirname, '.logs', 'tollgate-last.txt');
|
|
93
160
|
|
|
94
161
|
let lastKey = '';
|
|
95
|
-
|
|
162
|
+
if (stateKey) {
|
|
163
|
+
try { lastKey = fs.readFileSync(cacheFile, 'utf8'); } catch { /* first run */ }
|
|
164
|
+
}
|
|
96
165
|
|
|
97
|
-
if (lastKey === stateKey) {
|
|
166
|
+
if (stateKey && lastKey === stateKey) {
|
|
98
167
|
console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync \`spec.json\` when state changes.\n`);
|
|
99
168
|
process.exit(0);
|
|
100
169
|
}
|
|
101
170
|
|
|
102
171
|
try {
|
|
103
172
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
104
|
-
fs.writeFileSync(cacheFile, stateKey);
|
|
105
|
-
} catch { /*
|
|
173
|
+
if (stateKey) fs.writeFileSync(cacheFile, stateKey);
|
|
174
|
+
} catch { /* cache failure only disables reminder compression */ }
|
|
106
175
|
|
|
107
176
|
// Compact state-change block (enforcement lives on Stop via spec-gate.cjs).
|
|
108
177
|
const lines = [];
|
|
@@ -116,22 +185,18 @@ try {
|
|
|
116
185
|
lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
|
|
117
186
|
}
|
|
118
187
|
if (flashTasks.length > 0) {
|
|
119
|
-
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS
|
|
188
|
+
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
|
|
120
189
|
}
|
|
121
|
-
lines.push(`- Sync \`spec.json\` + task
|
|
122
|
-
lines.push(`-
|
|
190
|
+
lines.push(`- Sync \`spec.json\` + task Markdown status after verified work; task proof belongs in \`receipts/<task-basename>.md\`.`);
|
|
191
|
+
lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
|
|
192
|
+
lines.push(`- Validate with \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\`; hooks revalidate receipt bytes but never grant approval.`);
|
|
123
193
|
lines.push('');
|
|
124
194
|
|
|
125
195
|
console.log(lines.join('\n'));
|
|
126
196
|
process.exit(0);
|
|
127
197
|
|
|
128
198
|
} catch (e) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const d = p.join(__dirname, '.logs');
|
|
132
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
133
|
-
fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
|
|
134
|
-
JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-state', status: 'crash', error: e.message }) + '\n');
|
|
135
|
-
} catch (_) {}
|
|
199
|
+
logCrash(e);
|
|
200
|
+
emitControlledFailure(e.message);
|
|
136
201
|
process.exit(0);
|
|
137
202
|
}
|
|
@@ -61,7 +61,14 @@
|
|
|
61
61
|
"generate-skill-catalog.cjs",
|
|
62
62
|
"validate-spec-output.cjs",
|
|
63
63
|
"scan-staged-secrets.cjs",
|
|
64
|
-
"workflow-policy.cjs"
|
|
64
|
+
"workflow-policy.cjs",
|
|
65
|
+
"spec-resolver.cjs",
|
|
66
|
+
"spec-receipt.cjs",
|
|
67
|
+
"spec-ground.cjs",
|
|
68
|
+
"spec-semantic-model.cjs",
|
|
69
|
+
"spec-final-state.cjs",
|
|
70
|
+
"spec-readiness.cjs",
|
|
71
|
+
"provenance.cjs"
|
|
65
72
|
]
|
|
66
73
|
},
|
|
67
74
|
"agentReferences": {
|
|
@@ -102,6 +109,10 @@
|
|
|
102
109
|
"hooks/rules.cjs",
|
|
103
110
|
"hooks/spec-state.cjs",
|
|
104
111
|
"hooks/spec-gate.cjs",
|
|
112
|
+
"hooks/completion-authority.cjs",
|
|
113
|
+
"hooks/completion-authority-check.cjs",
|
|
114
|
+
"hooks/completion-authority-state.cjs",
|
|
115
|
+
"hooks/semantic-review-authority.cjs",
|
|
105
116
|
"hooks/state.cjs",
|
|
106
117
|
"hooks/lib/color.cjs",
|
|
107
118
|
"hooks/lib/parser.cjs",
|
|
@@ -109,7 +120,9 @@
|
|
|
109
120
|
"hooks/lib/git.cjs",
|
|
110
121
|
"hooks/lib/config.cjs",
|
|
111
122
|
"hooks/lib/context.cjs",
|
|
112
|
-
"hooks/lib/detect.cjs"
|
|
123
|
+
"hooks/lib/detect.cjs",
|
|
124
|
+
"hooks/lib/privacy-command-analysis.cjs",
|
|
125
|
+
"hooks/lib/runtime-path-safety.cjs"
|
|
113
126
|
]
|
|
114
127
|
},
|
|
115
128
|
"settings": {
|