@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
|
@@ -10,7 +10,40 @@ const EVIDENCE_NAMES = [
|
|
|
10
10
|
];
|
|
11
11
|
const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
|
|
12
12
|
const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
// Single authority: delegate to shared workflow-policy validator. If an
|
|
15
|
+
// installed policy exists but is broken, do not fall back to another copy.
|
|
16
|
+
function loadSharedPolicy() {
|
|
17
|
+
const installed = path.join(__dirname, '../../scripts/workflow-policy.cjs');
|
|
18
|
+
const repo = path.join(__dirname, '../../../claude/scripts/workflow-policy.cjs');
|
|
19
|
+
const candidates = fs.existsSync(installed) ? [installed] : [repo];
|
|
20
|
+
let lastError = new Error('shared workflow policy is missing');
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
const policy = require(candidate);
|
|
24
|
+
if (typeof policy?.validateCanonicalReceipt !== 'function') {
|
|
25
|
+
throw new Error('shared workflow policy has no validateCanonicalReceipt function');
|
|
26
|
+
}
|
|
27
|
+
return { policy, error: null, path: candidate };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
lastError = error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { policy: null, error: lastError, path: candidates[0] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getSharedPolicy() {
|
|
36
|
+
return loadSharedPolicy().policy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getSharedValidate() {
|
|
40
|
+
return loadSharedPolicy().policy?.validateCanonicalReceipt || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isTapMetadataHeading(line) {
|
|
44
|
+
const policy = getSharedPolicy();
|
|
45
|
+
return typeof policy?.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(line);
|
|
46
|
+
}
|
|
14
47
|
|
|
15
48
|
function evidenceBody(text) {
|
|
16
49
|
const lines = text.split('\n');
|
|
@@ -28,7 +61,7 @@ function evidenceBody(text) {
|
|
|
28
61
|
let end = lines.length;
|
|
29
62
|
for (let index = start; index < lines.length; index += 1) {
|
|
30
63
|
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
31
|
-
if (heading && heading[1].length <= level) {
|
|
64
|
+
if (heading && heading[1].length <= level && !isTapMetadataHeading(lines[index])) {
|
|
32
65
|
end = index;
|
|
33
66
|
break;
|
|
34
67
|
}
|
|
@@ -37,37 +70,29 @@ function evidenceBody(text) {
|
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
function safeTaskFile(featureDir, taskPath) {
|
|
73
|
+
const resolvedFeature = path.resolve(featureDir);
|
|
40
74
|
const target = path.resolve(featureDir, taskPath);
|
|
41
|
-
const relative = path.relative(
|
|
75
|
+
const relative = path.relative(resolvedFeature, target);
|
|
42
76
|
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
77
|
+
if (!fs.existsSync(target)) return null;
|
|
78
|
+
try {
|
|
79
|
+
const canonicalFeature = fs.realpathSync(resolvedFeature);
|
|
80
|
+
const canonicalTarget = fs.realpathSync(target);
|
|
81
|
+
const rel = path.relative(canonicalFeature, canonicalTarget);
|
|
82
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
43
86
|
return target;
|
|
44
87
|
}
|
|
45
88
|
|
|
46
|
-
function validateCanonicalReceipt(body) {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) {
|
|
52
|
-
failures.push('command');
|
|
53
|
-
}
|
|
54
|
-
if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) {
|
|
55
|
-
failures.push('exit_result');
|
|
56
|
-
}
|
|
57
|
-
const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
|
|
58
|
-
const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
|
|
59
|
-
const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
|
|
60
|
-
const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
|
|
61
|
-
if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) {
|
|
62
|
-
failures.push('provenance');
|
|
63
|
-
}
|
|
64
|
-
if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) {
|
|
65
|
-
failures.push('artifact_hash');
|
|
66
|
-
}
|
|
67
|
-
return failures;
|
|
89
|
+
function validateCanonicalReceipt(body, options = {}) {
|
|
90
|
+
const fn = getSharedValidate();
|
|
91
|
+
if (!fn) return ['shared_validator'];
|
|
92
|
+
return fn(body, options);
|
|
68
93
|
}
|
|
69
94
|
|
|
70
|
-
function checkReceipt(featureDir, taskPath, task) {
|
|
95
|
+
function checkReceipt(featureDir, taskPath, task, runtimeContext) {
|
|
71
96
|
const failures = [];
|
|
72
97
|
const file = safeTaskFile(featureDir, taskPath);
|
|
73
98
|
if (!file || !fs.existsSync(file)) return ['a'];
|
|
@@ -83,37 +108,38 @@ function checkReceipt(featureDir, taskPath, task) {
|
|
|
83
108
|
failures.push('b');
|
|
84
109
|
} else if (
|
|
85
110
|
/\{\{[^}]+\}\}/.test(body)
|
|
86
|
-
|| EXPLICIT_FAILURE.test(body)
|
|
87
111
|
|| !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))
|
|
88
112
|
) {
|
|
89
113
|
failures.push('c');
|
|
90
114
|
} else {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
const policy = loadSharedPolicy().policy;
|
|
116
|
+
const options = typeof policy?.receiptValidatorOptions === 'function'
|
|
117
|
+
? policy.receiptValidatorOptions(task, { runtimeContext, requireProvenanceBinding: true })
|
|
118
|
+
: { requireProvenanceBinding: true };
|
|
119
|
+
const canonical = validateCanonicalReceipt(body, options);
|
|
96
120
|
const map = {
|
|
97
|
-
verification_state: 'c',
|
|
121
|
+
verification_state: 'c',
|
|
98
122
|
command: 'e',
|
|
99
123
|
exit_result: 'f',
|
|
100
124
|
provenance: 'g',
|
|
101
125
|
artifact_hash: 'h',
|
|
126
|
+
artifact_declaration: 'h',
|
|
127
|
+
placeholder: 'c',
|
|
128
|
+
shared_validator: 'c',
|
|
129
|
+
validator_unavailable: 'c',
|
|
102
130
|
};
|
|
103
131
|
for (const fail of canonical) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (fail === 'verification_state' && failures.includes('c')) continue;
|
|
107
|
-
if (fail === 'verification_state') {
|
|
108
|
-
// This should have been caught by c, but if PASS present via legacy, still need strict PASS
|
|
109
|
-
if (!PASS_MARKER.test(body)) failures.push('c');
|
|
132
|
+
if (fail === 'verification_state' || fail === 'shared_validator' || fail === 'validator_unavailable') {
|
|
133
|
+
if (!failures.includes('c')) failures.push('c');
|
|
110
134
|
continue;
|
|
111
135
|
}
|
|
136
|
+
if (fail === 'placeholder') {
|
|
137
|
+
if (!failures.includes('c')) failures.push('c');
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const letter = map[fail];
|
|
112
141
|
if (letter && !failures.includes(letter)) failures.push(letter);
|
|
113
142
|
}
|
|
114
|
-
// Additional strict check: legacy success alone is not sufficient for canonical
|
|
115
|
-
// If body contains only legacy PASS but not canonical Verification: PASS, c already would have caught if no PASS_MARKER?
|
|
116
|
-
// But we allow legacy for backward compat in c, but canonical requires PASS_MARKER, so we must enforce.
|
|
117
143
|
if (!PASS_MARKER.test(body)) {
|
|
118
144
|
if (!failures.includes('c')) failures.push('c');
|
|
119
145
|
}
|
|
@@ -128,4 +154,11 @@ function checkReceipt(featureDir, taskPath, task) {
|
|
|
128
154
|
return failures;
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
module.exports = {
|
|
157
|
+
module.exports = {
|
|
158
|
+
checkReceipt,
|
|
159
|
+
evidenceBody,
|
|
160
|
+
safeTaskFile,
|
|
161
|
+
validateCanonicalReceipt,
|
|
162
|
+
loadSharedPolicy,
|
|
163
|
+
getSharedPolicy,
|
|
164
|
+
};
|
|
@@ -2,49 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
// Installed Codex hooks use .codex/scripts; source tests use the Claude-side
|
|
7
|
+
// source path. If an installed resolver exists but is malformed, do not fall
|
|
8
|
+
// back to another copy: the shared authority is unavailable.
|
|
9
|
+
const RESOLVER_CANDIDATES = [
|
|
10
|
+
path.join(__dirname, '../../scripts/spec-resolver.cjs'),
|
|
11
|
+
path.join(__dirname, '../../../claude/scripts/spec-resolver.cjs'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function sharedResolver() {
|
|
15
|
+
const candidate = RESOLVER_CANDIDATES.find((file) => fs.existsSync(file)) || RESOLVER_CANDIDATES[0];
|
|
16
|
+
try {
|
|
17
|
+
const resolver = require(candidate);
|
|
18
|
+
if (typeof resolver?.resolveActiveSpec !== 'function') {
|
|
19
|
+
throw new Error('shared spec resolver has no resolveActiveSpec function');
|
|
20
|
+
}
|
|
21
|
+
return resolver;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
throw new Error(`${message} (${candidate})`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
6
27
|
|
|
7
28
|
function specsDirectory(projectRoot, runtime) {
|
|
8
|
-
return
|
|
29
|
+
return sharedResolver().specsDirectory(projectRoot, runtime);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findAllActiveSpecs(projectRoot, runtime) {
|
|
33
|
+
return sharedResolver().findAllActiveSpecs(projectRoot, runtime);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findAllSpecCandidates(projectRoot, runtime) {
|
|
37
|
+
return sharedResolver().findAllSpecCandidates(projectRoot, runtime);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath) {
|
|
41
|
+
if (projectRoot && typeof projectRoot === 'object') {
|
|
42
|
+
return sharedResolver().resolveActiveSpec(projectRoot);
|
|
43
|
+
}
|
|
44
|
+
return sharedResolver().resolveActiveSpec({
|
|
9
45
|
projectRoot,
|
|
10
|
-
runtime
|
|
11
|
-
|
|
12
|
-
|
|
46
|
+
runtime,
|
|
47
|
+
explicitFeature,
|
|
48
|
+
explicitPath,
|
|
49
|
+
});
|
|
13
50
|
}
|
|
14
51
|
|
|
15
52
|
function findActiveSpec(projectRoot, runtime) {
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
for (const entry of entries) {
|
|
23
|
-
const file = path.join(specsDir, entry.name, 'spec.json');
|
|
24
|
-
if (!fs.existsSync(file)) continue;
|
|
25
|
-
try {
|
|
26
|
-
const spec = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
-
if (spec.status === 'in_progress' || spec.status === 'in-progress') {
|
|
28
|
-
return { featureName: entry.name, spec, specsDir };
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
// Ignore an unrelated malformed spec and continue discovery.
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
53
|
+
const resolved = resolveActiveSpec(projectRoot, runtime);
|
|
54
|
+
if (!resolved) return null;
|
|
55
|
+
if (resolved.error === 'multiple_active' || resolved.error === 'invalid_specs') return resolved;
|
|
56
|
+
if (resolved.error) return null;
|
|
57
|
+
return resolved;
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
function taskStatusMap(spec) {
|
|
38
61
|
return Object.fromEntries(
|
|
39
62
|
Object.entries(spec.task_registry || {}).map(([taskPath, task]) => [
|
|
40
63
|
taskPath,
|
|
41
|
-
task?.status || 'pending'
|
|
42
|
-
])
|
|
64
|
+
task?.status || 'pending',
|
|
65
|
+
]),
|
|
43
66
|
);
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
module.exports = {
|
|
47
70
|
findActiveSpec,
|
|
71
|
+
findAllActiveSpecs,
|
|
72
|
+
findAllSpecCandidates,
|
|
73
|
+
resolveActiveSpec,
|
|
48
74
|
specsDirectory,
|
|
49
|
-
taskStatusMap
|
|
75
|
+
taskStatusMap,
|
|
50
76
|
};
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
readPayload
|
|
11
11
|
} = require('./lib/hook-context.cjs');
|
|
12
12
|
const { clearState } = require('./lib/privacy-state.cjs');
|
|
13
|
+
const { clearState: clearCompletionState } = require('./completion-authority-state.cjs');
|
|
13
14
|
|
|
14
15
|
function readPackage(projectRoot) {
|
|
15
16
|
try {
|
|
@@ -88,6 +89,7 @@ try {
|
|
|
88
89
|
|
|
89
90
|
// Approval tokens never survive a session boundary or compaction.
|
|
90
91
|
clearState(projectRoot);
|
|
92
|
+
clearCompletionState(projectRoot, payload.session_id);
|
|
91
93
|
|
|
92
94
|
const parts = [
|
|
93
95
|
projectType && `Type: ${projectType}`,
|
|
@@ -9,21 +9,93 @@ const {
|
|
|
9
9
|
logCrash,
|
|
10
10
|
readPayload
|
|
11
11
|
} = require('./lib/hook-context.cjs');
|
|
12
|
-
const { checkReceipt } = require('./lib/spec-receipt.cjs');
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
const { checkReceipt, evidenceBody, loadSharedPolicy } = require('./lib/spec-receipt.cjs');
|
|
13
|
+
const { taskStatusMap } = require('./lib/spec-utils.cjs');
|
|
14
|
+
|
|
15
|
+
function emitBlock(reason) {
|
|
16
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionIdentity(payload) {
|
|
20
|
+
const candidates = [
|
|
21
|
+
payload && payload.session_id,
|
|
22
|
+
payload && payload.sessionId,
|
|
23
|
+
payload && payload.sessionID,
|
|
24
|
+
payload && payload.session && payload.session.id,
|
|
25
|
+
];
|
|
26
|
+
return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readReceiptBody(featureDir, taskPath) {
|
|
30
|
+
const featureRoot = path.resolve(featureDir);
|
|
31
|
+
const target = path.resolve(featureRoot, taskPath);
|
|
32
|
+
const relative = path.relative(featureRoot, target);
|
|
33
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
34
|
+
try {
|
|
35
|
+
const canonicalFeature = fs.realpathSync(featureRoot);
|
|
36
|
+
const canonicalTarget = fs.realpathSync(target);
|
|
37
|
+
const canonicalRelative = path.relative(canonicalFeature, canonicalTarget);
|
|
38
|
+
if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) return null;
|
|
39
|
+
return evidenceBody(fs.readFileSync(canonicalTarget, 'utf8'));
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
18
44
|
|
|
19
45
|
try {
|
|
20
46
|
const payload = readPayload();
|
|
21
|
-
if (
|
|
47
|
+
if (payload.stop_hook_active === true) process.exit(0);
|
|
48
|
+
const loaded = loadSharedPolicy();
|
|
49
|
+
if (!loaded.policy) {
|
|
50
|
+
logCrash('spec-gate', loaded.error);
|
|
51
|
+
emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${loaded.error.message}). Repair ${loaded.path} before completing tasks.`);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
const POLICY = loaded.policy;
|
|
22
55
|
const { projectRoot, runtime } = getHookContext(payload);
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
if (typeof POLICY.completionDecisionForSpec !== 'function') {
|
|
57
|
+
throw new Error('shared workflow policy lacks completion authority functions');
|
|
58
|
+
}
|
|
59
|
+
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
60
|
+
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
61
|
+
const { resolveActiveSpec } = require('./lib/spec-utils.cjs');
|
|
62
|
+
const resolved = resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
|
|
63
|
+
if (!resolved) process.exit(0);
|
|
64
|
+
if (resolved.error === 'multiple_active') {
|
|
65
|
+
process.stdout.write(`${JSON.stringify({
|
|
66
|
+
decision: 'block',
|
|
67
|
+
reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks.`
|
|
68
|
+
})}\n`);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
if (resolved.error === 'invalid_specs') {
|
|
72
|
+
process.stdout.write(`${JSON.stringify({
|
|
73
|
+
decision: 'block',
|
|
74
|
+
reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
|
|
75
|
+
})}\n`);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
79
|
+
process.stdout.write(`${JSON.stringify({
|
|
80
|
+
decision: 'block',
|
|
81
|
+
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.`
|
|
82
|
+
})}\n`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
if (resolved.error) process.exit(0);
|
|
86
|
+
const active = resolved;
|
|
87
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
88
|
+
projectRoot,
|
|
89
|
+
specsRoot: active.specsDir,
|
|
90
|
+
specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
|
|
91
|
+
featureName: active.featureName,
|
|
92
|
+
runtimeSession: sessionIdentity(payload),
|
|
93
|
+
});
|
|
94
|
+
const configuredGate = runtime.spec?.completion_gate;
|
|
95
|
+
if (configuredGate !== undefined && configuredGate !== true) {
|
|
96
|
+
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.');
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
27
99
|
const registry = active.spec.task_registry || {};
|
|
28
100
|
const currentStatuses = taskStatusMap(active.spec);
|
|
29
101
|
const cacheFile = path.join(
|
|
@@ -42,7 +114,21 @@ try {
|
|
|
42
114
|
// Cache hardening: revalidate every done task on every Stop so a
|
|
43
115
|
// cached PASS cannot hide later receipt/provenance mutations.
|
|
44
116
|
// Cache is optimization only - validate every done task even on first run.
|
|
45
|
-
const
|
|
117
|
+
const cacheIdentity = {
|
|
118
|
+
project_root: runtimeContext.project_root,
|
|
119
|
+
specs_root: runtimeContext.specs_root,
|
|
120
|
+
spec_file: runtimeContext.spec_file,
|
|
121
|
+
feature_name: runtimeContext.feature_name,
|
|
122
|
+
runtime_session: runtimeContext.runtime_session,
|
|
123
|
+
provenance_mode: runtimeContext.provenance_mode,
|
|
124
|
+
Base: runtimeContext.base,
|
|
125
|
+
Head: runtimeContext.head,
|
|
126
|
+
context_id: runtimeContext.context_id,
|
|
127
|
+
};
|
|
128
|
+
const cacheEntries = cache && cache.entries && typeof cache.entries === 'object' ? cache.entries : {};
|
|
129
|
+
const previous = cacheExists && cacheEntries[runtimeContext.context_id]
|
|
130
|
+
? cacheEntries[runtimeContext.context_id].tasks || {}
|
|
131
|
+
: {};
|
|
46
132
|
const staleFlashTasks = Object.entries(registry)
|
|
47
133
|
.filter(([, task]) => POLICY.isStaleFlashDone(task))
|
|
48
134
|
.map(([taskPath]) => taskPath);
|
|
@@ -57,24 +143,43 @@ try {
|
|
|
57
143
|
currentStatuses[taskPath] === 'done'
|
|
58
144
|
));
|
|
59
145
|
const featureDir = path.join(active.specsDir, active.featureName);
|
|
146
|
+
const receiptBodies = new Map();
|
|
60
147
|
const failures = allDoneTasks
|
|
61
|
-
.map((taskPath) =>
|
|
62
|
-
taskPath
|
|
63
|
-
|
|
64
|
-
|
|
148
|
+
.map((taskPath) => {
|
|
149
|
+
const body = readReceiptBody(featureDir, taskPath);
|
|
150
|
+
if (body !== null) receiptBodies.set(taskPath, body);
|
|
151
|
+
return {
|
|
152
|
+
taskPath,
|
|
153
|
+
failures: checkReceipt(featureDir, taskPath, registry[taskPath], runtimeContext)
|
|
154
|
+
};
|
|
155
|
+
})
|
|
65
156
|
.filter((result) => result.failures.length);
|
|
66
157
|
|
|
158
|
+
const firstReceiptTask = allDoneTasks.find((taskPath) => receiptBodies.has(taskPath));
|
|
159
|
+
const completion = allDoneTasks.length > 0 && Object.prototype.hasOwnProperty.call(active.spec, 'workflow_policy')
|
|
160
|
+
? POLICY.completionDecisionForSpec(active.spec, {
|
|
161
|
+
runtimeContext,
|
|
162
|
+
executionReceipt: firstReceiptTask ? receiptBodies.get(firstReceiptTask) : null,
|
|
163
|
+
taskContext: firstReceiptTask ? registry[firstReceiptTask] : null,
|
|
164
|
+
})
|
|
165
|
+
: null;
|
|
166
|
+
|
|
67
167
|
const next = { ...previous, ...currentStatuses };
|
|
68
168
|
for (const result of failures) {
|
|
69
169
|
if (previous[result.taskPath] === undefined) delete next[result.taskPath];
|
|
70
170
|
else next[result.taskPath] = previous[result.taskPath];
|
|
71
171
|
}
|
|
72
|
-
|
|
73
|
-
|
|
172
|
+
atomicWrite(cacheFile, `${JSON.stringify({
|
|
173
|
+
schema_version: '2',
|
|
174
|
+
entries: { ...cacheEntries, [runtimeContext.context_id]: { identity: cacheIdentity, tasks: next } },
|
|
175
|
+
})}\n`);
|
|
74
176
|
|
|
75
|
-
|
|
177
|
+
const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
|
|
178
|
+
if (!failures.length && !completionBlocked) process.exit(0);
|
|
76
179
|
const lines = [
|
|
77
|
-
|
|
180
|
+
failures.length > 0
|
|
181
|
+
? `Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
|
|
182
|
+
: 'Completion gate: workflow completion proof is incomplete.'
|
|
78
183
|
];
|
|
79
184
|
for (const result of failures) {
|
|
80
185
|
lines.push(
|
|
@@ -82,10 +187,17 @@ try {
|
|
|
82
187
|
` Add \`Verification: PASS\`, commands, and successful outcomes under \`## Evidence\` in \`specs/${active.featureName}/${result.taskPath}\`, then re-sync spec.json.`
|
|
83
188
|
);
|
|
84
189
|
}
|
|
190
|
+
if (completionBlocked) {
|
|
191
|
+
lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
|
|
192
|
+
if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
|
|
193
|
+
lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
85
196
|
process.stdout.write(`${JSON.stringify({
|
|
86
197
|
decision: 'block',
|
|
87
198
|
reason: lines.slice(0, 8).join('\n')
|
|
88
199
|
})}\n`);
|
|
89
200
|
} catch (error) {
|
|
90
201
|
logCrash('spec-gate', error);
|
|
202
|
+
emitBlock(`Completion gate controlled failure: ${error.message}. Completion is blocked until the hook is repaired.`);
|
|
91
203
|
}
|
|
@@ -10,11 +10,11 @@ const {
|
|
|
10
10
|
logCrash,
|
|
11
11
|
readPayload
|
|
12
12
|
} = require('./lib/hook-context.cjs');
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const { loadSharedPolicy } = require('./lib/spec-receipt.cjs');
|
|
14
|
+
|
|
15
|
+
function emitControlledFailure(reason) {
|
|
16
|
+
process.stdout.write(`> ⚠️ Spec tollgate unavailable: ${reason}. Repair the installed workflow policy before continuing.\n`);
|
|
17
|
+
}
|
|
18
18
|
|
|
19
19
|
function cacheFile(projectRoot, sessionId) {
|
|
20
20
|
const key = crypto.createHash('sha256')
|
|
@@ -27,10 +27,42 @@ function cacheFile(projectRoot, sessionId) {
|
|
|
27
27
|
try {
|
|
28
28
|
const payload = readPayload();
|
|
29
29
|
if (!payload) process.exit(0);
|
|
30
|
+
const loaded = loadSharedPolicy();
|
|
31
|
+
if (!loaded.policy) {
|
|
32
|
+
logCrash('spec-state', loaded.error);
|
|
33
|
+
emitControlledFailure(loaded.error.message);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
const POLICY = loaded.policy;
|
|
30
37
|
const { projectRoot, runtime } = getHookContext(payload);
|
|
31
38
|
if (runtime.spec?.tollgate === false) process.exit(0);
|
|
32
|
-
const
|
|
33
|
-
|
|
39
|
+
const { resolveActiveSpec } = require('./lib/spec-utils.cjs');
|
|
40
|
+
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
41
|
+
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
42
|
+
const resolved = resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
|
|
43
|
+
if (!resolved) process.exit(0);
|
|
44
|
+
if (resolved.error === 'multiple_active') {
|
|
45
|
+
process.stdout.write(`> ⚠️ Multiple active specs detected: ${resolved.candidates.join(', ')}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (resolved.error === 'invalid_specs') {
|
|
49
|
+
process.stdout.write(`> ⚠️ Invalid spec JSON detected: ${resolved.candidates.join(', ')}. ${resolved.reason}. Fix or remove malformed spec.json. Tollgate paused.\n`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
|
|
53
|
+
const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
|
|
54
|
+
process.stdout.write(`> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
if (resolved.error) process.exit(0);
|
|
58
|
+
const active = resolved;
|
|
59
|
+
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
60
|
+
projectRoot,
|
|
61
|
+
specsRoot: active.specsDir,
|
|
62
|
+
specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
|
|
63
|
+
featureName: active.featureName,
|
|
64
|
+
runtimeSession: payload.session_id || payload.sessionId || payload.sessionID || payload.session?.id,
|
|
65
|
+
});
|
|
34
66
|
|
|
35
67
|
const phase = active.spec.current_phase || active.spec.phase || 'unknown';
|
|
36
68
|
const taskRegistry = active.spec.task_registry || {};
|
|
@@ -49,8 +81,21 @@ try {
|
|
|
49
81
|
(task?.status || 'pending') === 'pending'
|
|
50
82
|
&& (task?.dependencies || []).every((dependency) => statuses.get(dependency) === 'done')
|
|
51
83
|
));
|
|
52
|
-
const stateKey =
|
|
53
|
-
|
|
84
|
+
const stateKey = JSON.stringify({
|
|
85
|
+
project_root: runtimeContext.project_root,
|
|
86
|
+
specs_root: runtimeContext.specs_root,
|
|
87
|
+
spec_file: runtimeContext.spec_file,
|
|
88
|
+
feature_name: runtimeContext.feature_name,
|
|
89
|
+
runtime_session: runtimeContext.runtime_session,
|
|
90
|
+
provenance_mode: runtimeContext.provenance_mode,
|
|
91
|
+
Base: runtimeContext.base,
|
|
92
|
+
Head: runtimeContext.head,
|
|
93
|
+
context_id: runtimeContext.context_id,
|
|
94
|
+
phase,
|
|
95
|
+
done: counts.done || 0,
|
|
96
|
+
total: tasks.length,
|
|
97
|
+
});
|
|
98
|
+
const cache = cacheFile(projectRoot, runtimeContext.runtime_session);
|
|
54
99
|
let previous = '';
|
|
55
100
|
try { previous = fs.readFileSync(cache, 'utf8').trim(); } catch { /* first run */ }
|
|
56
101
|
|
|
@@ -69,7 +114,7 @@ try {
|
|
|
69
114
|
];
|
|
70
115
|
if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
|
|
71
116
|
if (flashTasks.length > 0) {
|
|
72
|
-
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS
|
|
117
|
+
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
|
|
73
118
|
}
|
|
74
119
|
lines.push(
|
|
75
120
|
'- Sync `spec.json` and the task file only after verified work.',
|
|
@@ -79,4 +124,5 @@ try {
|
|
|
79
124
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
80
125
|
} catch (error) {
|
|
81
126
|
logCrash('spec-state', error);
|
|
127
|
+
emitControlledFailure(error.message);
|
|
82
128
|
}
|
package/src/codex/hooks.json
CHANGED
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/privacy-approval.cjs\"",
|
|
44
44
|
"commandWindows": "node \".codex/hooks/privacy-approval.cjs\""
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/completion-authority.cjs\"",
|
|
49
|
+
"commandWindows": "node \".codex/hooks/completion-authority.cjs\""
|
|
50
|
+
},
|
|
46
51
|
{
|
|
47
52
|
"type": "command",
|
|
48
53
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/rules.cjs\"",
|
|
@@ -98,6 +103,11 @@
|
|
|
98
103
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/spec-gate.cjs\"",
|
|
99
104
|
"commandWindows": "node \".codex/hooks/spec-gate.cjs\""
|
|
100
105
|
},
|
|
106
|
+
{
|
|
107
|
+
"type": "command",
|
|
108
|
+
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/completion-authority.cjs\"",
|
|
109
|
+
"commandWindows": "node \".codex/hooks/completion-authority.cjs\""
|
|
110
|
+
},
|
|
101
111
|
{
|
|
102
112
|
"type": "command",
|
|
103
113
|
"command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/state.cjs\"",
|
package/src/opencode/AGENTS.md
CHANGED
|
@@ -28,9 +28,11 @@ The underlying CafeKit skills are still named `hapo:*` in their documentation.
|
|
|
28
28
|
|
|
29
29
|
## OpenCode limits
|
|
30
30
|
|
|
31
|
-
Claude Code hooks, statusline, and settings do not run in OpenCode. Map Claude-only tools to OpenCode built-ins: `TodoWrite` → `todowrite`, `AskUserQuestion` → `question`, `Task` → the agent/subtask flow. The installed plugins under `.opencode/plugins/` provide the privacy, inspect-scope, spec-state, scaffold-guard, session-state, and docs-sync gates; other Claude runtime behavior has no OpenCode equivalent.
|
|
31
|
+
Claude Code hooks, statusline, and settings do not run in OpenCode. Map Claude-only tools to OpenCode built-ins: `TodoWrite` → `todowrite`, `AskUserQuestion` → `question`, `Task` → the agent/subtask flow. The installed plugins under `.opencode/plugins/` provide the privacy, inspect-scope, spec-state, scaffold-guard, session-state, and docs-sync gates; other Claude runtime behavior has no OpenCode equivalent.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
The installed `spec-gate` plugin uses the cancellable `tool.execute.before` hook to hard-block supported completion/state tools (`task`, `taskupdate`, and `todowrite`) with a controlled `CAFEKIT_SPEC_GATE_BLOCKED` result when shared policy/resolver or receipt evidence is invalid or unavailable. Receipt-repair tools remain available where the host boundary permits correction.
|
|
34
|
+
|
|
35
|
+
OpenCode does not expose a cancellable `session.idle`/Stop hook or final assistant-turn cancellation boundary. Those event and prompt-injection paths are observational only; do not claim full-turn parity with Claude/Codex.
|
|
34
36
|
|
|
35
37
|
## Combined-install boundary
|
|
36
38
|
|