@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +116 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -18,10 +18,10 @@ try {
|
|
|
18
18
|
const { execSync } = require('child_process');
|
|
19
19
|
|
|
20
20
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
21
|
-
function run(cmd, fallback = '') {
|
|
21
|
+
function run(cmd, fallback = '', cwd = process.cwd()) {
|
|
22
22
|
try {
|
|
23
23
|
return execSync(cmd, {
|
|
24
|
-
encoding: 'utf8', timeout: 3000,
|
|
24
|
+
cwd, encoding: 'utf8', timeout: 3000,
|
|
25
25
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
26
26
|
}).trim();
|
|
27
27
|
} catch { return fallback; }
|
|
@@ -40,6 +40,21 @@ try {
|
|
|
40
40
|
} catch { /* fail-open */ }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function projectRoot() {
|
|
44
|
+
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
45
|
+
? process.env.CLAUDE_PROJECT_DIR.trim()
|
|
46
|
+
: '';
|
|
47
|
+
if (configured) {
|
|
48
|
+
try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
52
|
+
const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
|
|
53
|
+
if (fs.existsSync(installedHook)) return installedRoot;
|
|
54
|
+
|
|
55
|
+
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
/** Read .claude/runtime.json config */
|
|
44
59
|
function readRuntime(cwd) {
|
|
45
60
|
try {
|
|
@@ -50,8 +65,7 @@ try {
|
|
|
50
65
|
|
|
51
66
|
// ── Project Detection ──────────────────────────────────────────────────────
|
|
52
67
|
|
|
53
|
-
function detectProjectType() {
|
|
54
|
-
const cwd = process.cwd();
|
|
68
|
+
function detectProjectType(cwd) {
|
|
55
69
|
if (fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml')) ||
|
|
56
70
|
fs.existsSync(path.join(cwd, 'lerna.json'))) return 'monorepo';
|
|
57
71
|
try {
|
|
@@ -62,8 +76,7 @@ try {
|
|
|
62
76
|
return 'app';
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
function detectPackageManager() {
|
|
66
|
-
const cwd = process.cwd();
|
|
79
|
+
function detectPackageManager(cwd) {
|
|
67
80
|
if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
68
81
|
if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn';
|
|
69
82
|
if (fs.existsSync(path.join(cwd, 'bun.lockb'))) return 'bun';
|
|
@@ -71,10 +84,10 @@ try {
|
|
|
71
84
|
return '';
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
function detectFramework() {
|
|
87
|
+
function detectFramework(cwd) {
|
|
75
88
|
try {
|
|
76
89
|
const pkg = JSON.parse(
|
|
77
|
-
fs.readFileSync(path.join(
|
|
90
|
+
fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')
|
|
78
91
|
);
|
|
79
92
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
80
93
|
if (deps['next']) return 'next';
|
|
@@ -185,31 +198,32 @@ try {
|
|
|
185
198
|
const payload = stdin ? JSON.parse(stdin) : {};
|
|
186
199
|
const source = payload.source || 'unknown';
|
|
187
200
|
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
188
|
-
const cwd =
|
|
201
|
+
const cwd = projectRoot();
|
|
189
202
|
const runtime = readRuntime(cwd);
|
|
203
|
+
try { require('./completion-authority-state.cjs').clearState(cwd, payload.session_id); } catch { /* old installs remain fail-open */ }
|
|
190
204
|
|
|
191
205
|
// Check CafeKit update in parallel with project detection (async, fail-open).
|
|
192
206
|
const updateCheckPromise = checkCafeKitUpdate(cwd);
|
|
193
207
|
|
|
194
208
|
// Project detection
|
|
195
209
|
const projectType = runtime.project?.type !== 'auto'
|
|
196
|
-
? (runtime.project?.type || detectProjectType())
|
|
197
|
-
: detectProjectType();
|
|
210
|
+
? (runtime.project?.type || detectProjectType(cwd))
|
|
211
|
+
: detectProjectType(cwd);
|
|
198
212
|
|
|
199
213
|
const packageManager = runtime.project?.packageManager !== 'auto'
|
|
200
|
-
? (runtime.project?.packageManager || detectPackageManager())
|
|
201
|
-
: detectPackageManager();
|
|
214
|
+
? (runtime.project?.packageManager || detectPackageManager(cwd))
|
|
215
|
+
: detectPackageManager(cwd);
|
|
202
216
|
|
|
203
217
|
const framework = runtime.project?.framework !== 'auto'
|
|
204
|
-
? (runtime.project?.framework || detectFramework())
|
|
205
|
-
: detectFramework();
|
|
218
|
+
? (runtime.project?.framework || detectFramework(cwd))
|
|
219
|
+
: detectFramework(cwd);
|
|
206
220
|
|
|
207
221
|
// Static environment
|
|
208
|
-
const gitBranch = run('git branch --show-current');
|
|
209
|
-
const gitUrl = run('git remote get-url origin');
|
|
210
|
-
const gitRoot = run('git rev-parse --show-toplevel');
|
|
222
|
+
const gitBranch = run('git branch --show-current', '', cwd);
|
|
223
|
+
const gitUrl = run('git remote get-url origin', '', cwd);
|
|
224
|
+
const gitRoot = run('git rev-parse --show-toplevel', '', cwd);
|
|
211
225
|
const nodeVer = process.version;
|
|
212
|
-
const pythonVer = run('python3 --version') || run('python --version');
|
|
226
|
+
const pythonVer = run('python3 --version', '', cwd) || run('python --version', '', cwd);
|
|
213
227
|
const user = process.env.USERNAME || process.env.USER
|
|
214
228
|
|| process.env.LOGNAME || os.userInfo().username;
|
|
215
229
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -8,56 +8,147 @@
|
|
|
8
8
|
* Blocks turn end when *newly-done* tasks lack 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
|
+
try {
|
|
87
|
+
POLICY = require(policyPath);
|
|
88
|
+
RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
|
|
89
|
+
if (typeof POLICY.validateCanonicalReceipt !== 'function'
|
|
90
|
+
|| typeof POLICY.completionDecisionForSpec !== 'function') {
|
|
91
|
+
throw new Error('shared workflow policy lacks completion authority functions');
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logCrash(error);
|
|
95
|
+
emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${error.message}). Repair ${policyPath} before completing tasks.`);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cwd = projectRoot(payload);
|
|
100
|
+
|
|
31
101
|
// Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
|
|
32
102
|
let runtime = {};
|
|
33
103
|
try {
|
|
34
104
|
const rp = path.join(cwd, '.claude', 'runtime.json');
|
|
35
105
|
if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
|
|
36
106
|
} catch { /* gate stays on */ }
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!fs.existsSync(specFile)) continue;
|
|
50
|
-
try {
|
|
51
|
-
const specData = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
52
|
-
if (specData.status === 'in_progress' || specData.status === 'in-progress') {
|
|
53
|
-
activeSpec = specData;
|
|
54
|
-
featureName = entry.name;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
} catch { /* skip bad JSON */ }
|
|
107
|
+
// Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
|
|
108
|
+
const baseDir = cwd;
|
|
109
|
+
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
110
|
+
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
111
|
+
const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
|
|
112
|
+
if (!resolved) process.exit(0);
|
|
113
|
+
if (resolved.error === 'multiple_active') {
|
|
114
|
+
process.stdout.write(JSON.stringify({
|
|
115
|
+
decision: 'block',
|
|
116
|
+
reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks. Candidates: ${resolved.candidates.join(', ')}`
|
|
117
|
+
}) + '\n');
|
|
118
|
+
process.exit(0);
|
|
58
119
|
}
|
|
59
|
-
if (
|
|
120
|
+
if (resolved.error === 'invalid_specs') {
|
|
121
|
+
process.stdout.write(JSON.stringify({
|
|
122
|
+
decision: 'block',
|
|
123
|
+
reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
|
|
124
|
+
}) + '\n');
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
128
|
+
process.stdout.write(JSON.stringify({
|
|
129
|
+
decision: 'block',
|
|
130
|
+
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.`
|
|
131
|
+
}) + '\n');
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
if (resolved.error) process.exit(0);
|
|
135
|
+
|
|
136
|
+
const activeSpec = resolved.spec;
|
|
137
|
+
const featureName = resolved.featureName;
|
|
138
|
+
const specsPath = resolved.specsDir;
|
|
139
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
140
|
+
projectRoot: baseDir,
|
|
141
|
+
specsRoot: specsPath,
|
|
142
|
+
specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
|
|
143
|
+
featureName,
|
|
144
|
+
runtimeSession: sessionIdentity(payload),
|
|
145
|
+
});
|
|
60
146
|
|
|
147
|
+
const configuredGate = runtime.spec?.completion_gate;
|
|
148
|
+
if (configuredGate !== undefined && configuredGate !== true) {
|
|
149
|
+
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.');
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
61
152
|
const taskRegistry = activeSpec.task_registry || {};
|
|
62
153
|
const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json');
|
|
63
154
|
const cacheExists = fs.existsSync(cacheFile);
|
|
@@ -90,12 +181,12 @@ try {
|
|
|
90
181
|
const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
|
|
91
182
|
(taskRegistry[tp]?.status || 'pending') === 'done'
|
|
92
183
|
);
|
|
184
|
+
const receiptBodies = new Map();
|
|
93
185
|
// /m so ^ matches line starts (Evidence is never at byte 0 of the file).
|
|
94
186
|
// legacy heading aliases: read-compat only, no longer advertised
|
|
95
187
|
const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
|
|
96
188
|
const PASS_MARKER_RE = /^\s*Verification:\s*PASS\s*$/m;
|
|
97
189
|
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
190
|
|
|
100
191
|
/** Body of first Evidence heading until next same-or-higher heading. */
|
|
101
192
|
function evidenceBody(text) {
|
|
@@ -109,32 +200,33 @@ try {
|
|
|
109
200
|
let end = lines.length;
|
|
110
201
|
for (let i = start; i < lines.length; i++) {
|
|
111
202
|
const hm = lines[i].match(/^(#{1,6})\s+/);
|
|
112
|
-
|
|
203
|
+
const isTapMetadata = typeof POLICY.isTapMetadataHeading === 'function'
|
|
204
|
+
&& POLICY.isTapMetadataHeading(lines[i]);
|
|
205
|
+
if (hm && hm[1].length <= level && !isTapMetadata) { end = i; break; }
|
|
113
206
|
}
|
|
114
207
|
return lines.slice(start, end).join('\n');
|
|
115
208
|
}
|
|
116
209
|
|
|
117
|
-
function validateCanonicalReceipt(body) {
|
|
118
|
-
|
|
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;
|
|
210
|
+
function validateCanonicalReceipt(body, options) {
|
|
211
|
+
return POLICY.validateCanonicalReceipt(body, options);
|
|
129
212
|
}
|
|
130
213
|
|
|
131
214
|
// Receipt checks a–h → failed letter list.
|
|
132
215
|
// 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
216
|
function safeTaskFile(featureDir, taskPath) {
|
|
217
|
+
const resolvedFeature = path.resolve(featureDir);
|
|
134
218
|
const target = path.resolve(featureDir, taskPath);
|
|
135
|
-
const relative = path.relative(
|
|
136
|
-
// sibling-prefix safe: must not be .., ../..., or absolute (cross-platform)
|
|
219
|
+
const relative = path.relative(resolvedFeature, target);
|
|
137
220
|
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
221
|
+
if (!fs.existsSync(target)) return null;
|
|
222
|
+
try {
|
|
223
|
+
const canonicalFeature = fs.realpathSync(resolvedFeature);
|
|
224
|
+
const canonicalTarget = fs.realpathSync(target);
|
|
225
|
+
const rel = path.relative(canonicalFeature, canonicalTarget);
|
|
226
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
138
230
|
return target;
|
|
139
231
|
}
|
|
140
232
|
|
|
@@ -150,21 +242,28 @@ try {
|
|
|
150
242
|
if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a');
|
|
151
243
|
|
|
152
244
|
const body = evidenceBody(text);
|
|
245
|
+
if (body !== null) receiptBodies.set(taskPath, body);
|
|
153
246
|
if (body === null || !EVID_RE.test(text)) {
|
|
154
247
|
fails.push('b');
|
|
155
248
|
} else if (
|
|
156
249
|
/\{\{[^}]+\}\}/.test(body) ||
|
|
157
|
-
EXPLICIT_FAILURE_RE.test(body) ||
|
|
158
250
|
!(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
|
|
159
251
|
) {
|
|
160
252
|
fails.push('c');
|
|
161
253
|
} else {
|
|
162
|
-
// Canonical receipt checks for done tasks
|
|
163
|
-
const canonicalFails = validateCanonicalReceipt(body
|
|
164
|
-
|
|
254
|
+
// Canonical receipt checks for done tasks — delegated to shared validator (single authority)
|
|
255
|
+
const canonicalFails = validateCanonicalReceipt(body, POLICY.receiptValidatorOptions(taskRegistry[taskPath], {
|
|
256
|
+
runtimeContext,
|
|
257
|
+
requireProvenanceBinding: true,
|
|
258
|
+
}));
|
|
259
|
+
const map = { verification_state: 'c', command: 'e', exit_result: 'f', provenance: 'g', artifact_hash: 'h', artifact_declaration: 'h', placeholder: 'c', shared_validator: 'c', validator_unavailable: 'c' };
|
|
165
260
|
for (const cf of canonicalFails) {
|
|
166
|
-
if (cf === 'verification_state') {
|
|
167
|
-
if (!
|
|
261
|
+
if (cf === 'verification_state' || cf === 'shared_validator' || cf === 'validator_unavailable') {
|
|
262
|
+
if (!fails.includes('c')) fails.push('c');
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (cf === 'placeholder') {
|
|
266
|
+
if (!fails.includes('c')) fails.push('c');
|
|
168
267
|
continue;
|
|
169
268
|
}
|
|
170
269
|
const letter = map[cf];
|
|
@@ -187,6 +286,15 @@ try {
|
|
|
187
286
|
.map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
|
|
188
287
|
.filter((f) => f.fails.length > 0);
|
|
189
288
|
|
|
289
|
+
const firstReceiptTask = allDoneTasks.find((taskPath) => receiptBodies.has(taskPath));
|
|
290
|
+
const completion = allDoneTasks.length > 0 && Object.prototype.hasOwnProperty.call(activeSpec, 'workflow_policy')
|
|
291
|
+
? POLICY.completionDecisionForSpec(activeSpec, {
|
|
292
|
+
runtimeContext,
|
|
293
|
+
executionReceipt: firstReceiptTask ? receiptBodies.get(firstReceiptTask) : null,
|
|
294
|
+
taskContext: firstReceiptTask ? taskRegistry[firstReceiptTask] : null,
|
|
295
|
+
})
|
|
296
|
+
: null;
|
|
297
|
+
|
|
190
298
|
// Always persist status transitions, including done → pending. Keep
|
|
191
299
|
// failing done tasks at their previous cached status so a stale valid
|
|
192
300
|
// entry cannot mask a mutated receipt on the next Stop; with
|
|
@@ -203,10 +311,13 @@ try {
|
|
|
203
311
|
} catch { /* fail-open */ }
|
|
204
312
|
|
|
205
313
|
if (allDoneTasks.length === 0) process.exit(0);
|
|
206
|
-
|
|
314
|
+
const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
|
|
315
|
+
if (failures.length === 0 && !completionBlocked) process.exit(0);
|
|
207
316
|
|
|
208
317
|
const lines = [
|
|
209
|
-
|
|
318
|
+
failures.length > 0
|
|
319
|
+
? `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
|
|
320
|
+
: '⚠️ Completion gate: workflow completion proof is incomplete.',
|
|
210
321
|
];
|
|
211
322
|
for (const { taskPath, fails } of failures) {
|
|
212
323
|
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
|
|
@@ -214,16 +325,17 @@ try {
|
|
|
214
325
|
` Fix: add \`Verification: PASS\` plus commands and successful outcomes to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`, then re-sync spec.json`
|
|
215
326
|
);
|
|
216
327
|
}
|
|
328
|
+
if (completionBlocked) {
|
|
329
|
+
lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
|
|
330
|
+
if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
|
|
331
|
+
lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
217
334
|
process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
|
|
218
335
|
process.exit(0);
|
|
219
336
|
|
|
220
337
|
} 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 (_) {}
|
|
338
|
+
logCrash(e);
|
|
339
|
+
emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
|
|
228
340
|
process.exit(0);
|
|
229
341
|
}
|
|
@@ -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,18 @@ 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
|
+
try {
|
|
58
|
+
POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
|
|
59
|
+
RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
|
|
60
|
+
if (typeof POLICY.flashState !== 'function') throw new Error('shared workflow policy has no flashState function');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logCrash(error);
|
|
63
|
+
emitControlledFailure(error.message);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
// Read runtime configuration if exists
|
|
28
68
|
let runtime = {};
|
|
29
69
|
try {
|
|
@@ -36,37 +76,47 @@ try {
|
|
|
36
76
|
if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
|
|
37
77
|
|
|
38
78
|
const baseDir = process.env.PROJECT_ROOT || cwd;
|
|
39
|
-
const
|
|
79
|
+
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
80
|
+
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
81
|
+
const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
|
|
40
82
|
|
|
41
|
-
if (!
|
|
83
|
+
if (!resolved) {
|
|
84
|
+
process.exit(0); // No active spec, do nothing
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (resolved.error === 'multiple_active') {
|
|
88
|
+
const candidates = resolved.candidates.join(', ');
|
|
89
|
+
console.log(`\n> ⚠️ Multiple active specs detected: ${candidates}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (resolved.error === 'invalid_specs') {
|
|
94
|
+
const cands = resolved.candidates.join(', ');
|
|
95
|
+
console.log(`\n> ⚠️ Invalid spec JSON detected: ${cands}. ${resolved.reason}. Fix or remove malformed spec.json before continuing. Tollgate paused.\n`);
|
|
42
96
|
process.exit(0);
|
|
43
97
|
}
|
|
44
98
|
|
|
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
|
-
}
|
|
99
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
100
|
+
const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
|
|
101
|
+
console.log(`\n> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
|
|
102
|
+
process.exit(0);
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
if (
|
|
67
|
-
process.exit(0);
|
|
105
|
+
if (resolved.error) {
|
|
106
|
+
process.exit(0);
|
|
68
107
|
}
|
|
69
108
|
|
|
109
|
+
const activeSpec = resolved.spec;
|
|
110
|
+
const featureName = resolved.featureName;
|
|
111
|
+
const specsPath = resolved.specsDir;
|
|
112
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
113
|
+
projectRoot: baseDir,
|
|
114
|
+
specsRoot: specsPath,
|
|
115
|
+
specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
|
|
116
|
+
featureName,
|
|
117
|
+
runtimeSession: sessionIdentity(payload),
|
|
118
|
+
});
|
|
119
|
+
|
|
70
120
|
const phase = activeSpec.current_phase || activeSpec.phase || 'unknown';
|
|
71
121
|
const taskRegistry = activeSpec.task_registry || {};
|
|
72
122
|
const flashTasks = POLICY.flashState(taskRegistry);
|
|
@@ -84,25 +134,39 @@ try {
|
|
|
84
134
|
});
|
|
85
135
|
|
|
86
136
|
// ── State-change gate: only emit the full tollgate when spec state changed ──
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
137
|
+
// The cache is shared by installed hook copies, so its key must carry the
|
|
138
|
+
// canonical project, complete session identity, and canonical spec identity.
|
|
139
|
+
// If any identity is unavailable, skip caching and emit the full block.
|
|
140
|
+
const stateKey = JSON.stringify({
|
|
141
|
+
projectRoot: runtimeContext.project_root,
|
|
142
|
+
specsRoot: runtimeContext.specs_root,
|
|
143
|
+
specFile: runtimeContext.spec_file,
|
|
144
|
+
featureName: runtimeContext.feature_name,
|
|
145
|
+
runtimeSession: runtimeContext.runtime_session,
|
|
146
|
+
provenanceMode: runtimeContext.provenance_mode,
|
|
147
|
+
Base: runtimeContext.base,
|
|
148
|
+
Head: runtimeContext.head,
|
|
149
|
+
contextId: runtimeContext.context_id,
|
|
150
|
+
phase,
|
|
151
|
+
done: taskCounts.done || 0,
|
|
152
|
+
total: taskEntries.length,
|
|
153
|
+
});
|
|
92
154
|
const cacheFile = path.join(__dirname, '.logs', 'tollgate-last.txt');
|
|
93
155
|
|
|
94
156
|
let lastKey = '';
|
|
95
|
-
|
|
157
|
+
if (stateKey) {
|
|
158
|
+
try { lastKey = fs.readFileSync(cacheFile, 'utf8'); } catch { /* first run */ }
|
|
159
|
+
}
|
|
96
160
|
|
|
97
|
-
if (lastKey === stateKey) {
|
|
161
|
+
if (stateKey && lastKey === stateKey) {
|
|
98
162
|
console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync \`spec.json\` when state changes.\n`);
|
|
99
163
|
process.exit(0);
|
|
100
164
|
}
|
|
101
165
|
|
|
102
166
|
try {
|
|
103
167
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
104
|
-
fs.writeFileSync(cacheFile, stateKey);
|
|
105
|
-
} catch { /*
|
|
168
|
+
if (stateKey) fs.writeFileSync(cacheFile, stateKey);
|
|
169
|
+
} catch { /* cache failure only disables reminder compression */ }
|
|
106
170
|
|
|
107
171
|
// Compact state-change block (enforcement lives on Stop via spec-gate.cjs).
|
|
108
172
|
const lines = [];
|
|
@@ -116,7 +180,7 @@ try {
|
|
|
116
180
|
lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
|
|
117
181
|
}
|
|
118
182
|
if (flashTasks.length > 0) {
|
|
119
|
-
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS
|
|
183
|
+
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
|
|
120
184
|
}
|
|
121
185
|
lines.push(`- Sync \`spec.json\` + task file after verified work; run \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` before \`ready_for_implementation=true\`.`);
|
|
122
186
|
lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`);
|
|
@@ -126,12 +190,7 @@ try {
|
|
|
126
190
|
process.exit(0);
|
|
127
191
|
|
|
128
192
|
} 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 (_) {}
|
|
193
|
+
logCrash(e);
|
|
194
|
+
emitControlledFailure(e.message);
|
|
136
195
|
process.exit(0);
|
|
137
196
|
}
|