@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,11 +5,27 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
|
|
7
7
|
// Installed layout: <project>/.codex/hooks/lib/hook-context.cjs
|
|
8
|
-
|
|
8
|
+
// Canonicalize installed project root via realpath where available; lexical fallback keeps source-tree tests working.
|
|
9
|
+
// This ensures a symlink project root (e.g., /tmp/link -> /real/project) is resolved to its real location,
|
|
10
|
+
// keeping assertConfiguredSpecsPath and state paths consistent with resolveSessionCwd's realpath.
|
|
11
|
+
// Installed-root authority stays PROJECT_ROOT (now canonical); do not change without updating state/approval paths.
|
|
12
|
+
const PROJECT_ROOT = (() => {
|
|
13
|
+
const lexical = path.resolve(__dirname, '..', '..', '..');
|
|
14
|
+
try {
|
|
15
|
+
return fs.realpathSync(lexical);
|
|
16
|
+
} catch {
|
|
17
|
+
return lexical;
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
9
20
|
|
|
10
21
|
function readPayload() {
|
|
11
22
|
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
12
|
-
|
|
23
|
+
if (!raw) throw new Error('hook payload is empty');
|
|
24
|
+
const payload = JSON.parse(raw);
|
|
25
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
26
|
+
throw new Error('hook payload must be a JSON object');
|
|
27
|
+
}
|
|
28
|
+
return payload;
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
function resolveSessionCwd(payload) {
|
|
@@ -35,11 +51,45 @@ function readRuntime(projectRoot = PROJECT_ROOT) {
|
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
53
|
|
|
54
|
+
function assertConfiguredSpecsPath(projectRoot, runtime) {
|
|
55
|
+
const configured = runtime?.paths?.specs;
|
|
56
|
+
if (configured !== undefined && (typeof configured !== 'string' || configured.trim() === '')) {
|
|
57
|
+
throw new Error('runtime.paths.specs must be a non-empty path');
|
|
58
|
+
}
|
|
59
|
+
const requested = configured || 'specs';
|
|
60
|
+
const root = path.resolve(projectRoot);
|
|
61
|
+
const resolved = path.resolve(root, requested);
|
|
62
|
+
const relative = path.relative(root, resolved);
|
|
63
|
+
if (relative !== '' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) {
|
|
64
|
+
throw new Error('configured specs root escapes project root');
|
|
65
|
+
}
|
|
66
|
+
let probe = resolved;
|
|
67
|
+
try {
|
|
68
|
+
while (!fs.existsSync(probe)) {
|
|
69
|
+
const parent = path.dirname(probe);
|
|
70
|
+
if (parent === probe) break;
|
|
71
|
+
probe = parent;
|
|
72
|
+
}
|
|
73
|
+
const rootReal = fs.realpathSync(root);
|
|
74
|
+
const probeReal = fs.realpathSync(probe);
|
|
75
|
+
const canonicalRelative = path.relative(rootReal, probeReal);
|
|
76
|
+
if (canonicalRelative !== '' && (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative))) {
|
|
77
|
+
throw new Error('configured specs root traverses outside project root');
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (/configured specs root/.test(error.message)) throw error;
|
|
81
|
+
throw new Error(`configured specs root cannot be validated (${error.message})`);
|
|
82
|
+
}
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
38
86
|
function getHookContext(payload) {
|
|
87
|
+
const runtime = readRuntime(PROJECT_ROOT);
|
|
88
|
+
assertConfiguredSpecsPath(PROJECT_ROOT, runtime);
|
|
39
89
|
return {
|
|
40
90
|
projectRoot: PROJECT_ROOT,
|
|
41
91
|
sessionCwd: resolveSessionCwd(payload),
|
|
42
|
-
runtime
|
|
92
|
+
runtime
|
|
43
93
|
};
|
|
44
94
|
}
|
|
45
95
|
|
|
@@ -65,7 +115,10 @@ function atomicWrite(file, content, mode = 0o600) {
|
|
|
65
115
|
|
|
66
116
|
function logCrash(hook, error) {
|
|
67
117
|
try {
|
|
68
|
-
const
|
|
118
|
+
const isSourceTree = PROJECT_ROOT.includes(`${path.sep}packages${path.sep}spec${path.sep}src`);
|
|
119
|
+
const dir = isSourceTree
|
|
120
|
+
? path.join(require('os').tmpdir(), 'cafekit-hook-logs', 'codex', hook)
|
|
121
|
+
: path.join(PROJECT_ROOT, '.codex', 'hooks', '.logs');
|
|
69
122
|
fs.mkdirSync(dir, { recursive: true });
|
|
70
123
|
fs.appendFileSync(
|
|
71
124
|
path.join(dir, 'hook-log.jsonl'),
|
|
@@ -88,6 +141,7 @@ module.exports = {
|
|
|
88
141
|
logCrash,
|
|
89
142
|
readPayload,
|
|
90
143
|
readRuntime,
|
|
144
|
+
assertConfiguredSpecsPath,
|
|
91
145
|
resolveProjectPath,
|
|
92
146
|
resolveSessionCwd
|
|
93
147
|
};
|
|
@@ -3,12 +3,31 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const PATH_SAFETY = require('./runtime-path-safety.cjs');
|
|
6
7
|
|
|
7
8
|
const TTL_MS = 5 * 60 * 1000;
|
|
8
9
|
const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
|
|
9
10
|
function stateDir(projectRoot) {
|
|
10
11
|
return path.join(projectRoot, '.codex', 'hooks', '.privacy');
|
|
11
12
|
}
|
|
13
|
+
function secureStateDir(projectRoot, create = false) {
|
|
14
|
+
const root = PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root');
|
|
15
|
+
const hooks = path.join(root, '.codex', 'hooks');
|
|
16
|
+
try { PATH_SAFETY.assertComponentPath(root, hooks); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (!create && error.code === 'ENOENT') return null;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
const dir = path.join(hooks, '.privacy');
|
|
22
|
+
if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700 });
|
|
23
|
+
try { PATH_SAFETY.assertComponentPath(root, dir); }
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (!create && error.code === 'ENOENT') return null;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
if (!fs.lstatSync(dir).isDirectory()) throw new Error('privacy state must be a regular directory');
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
12
31
|
function hash(value) {
|
|
13
32
|
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
14
33
|
}
|
|
@@ -44,16 +63,16 @@ function displayNames(filePaths) {
|
|
|
44
63
|
: `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
|
-
function atomicWrite(file, payload) {
|
|
48
|
-
|
|
49
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
function atomicWrite(dir, file, payload) {
|
|
67
|
+
PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
|
|
50
68
|
const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
51
69
|
fs.writeFileSync(temp, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
52
70
|
fs.renameSync(temp, file);
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
function readJson(file) {
|
|
73
|
+
function readJson(dir, file) {
|
|
56
74
|
try {
|
|
75
|
+
PATH_SAFETY.assertComponentPath(dir, file);
|
|
57
76
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
58
77
|
} catch {
|
|
59
78
|
return null;
|
|
@@ -61,12 +80,12 @@ function readJson(file) {
|
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
function cleanup(projectRoot, now = Date.now()) {
|
|
64
|
-
const dir =
|
|
65
|
-
if (!
|
|
83
|
+
const dir = secureStateDir(projectRoot);
|
|
84
|
+
if (!dir) return;
|
|
66
85
|
for (const name of fs.readdirSync(dir)) {
|
|
67
86
|
if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
|
|
68
87
|
const file = path.join(dir, name);
|
|
69
|
-
const record = readJson(file);
|
|
88
|
+
const record = readJson(dir, file);
|
|
70
89
|
if (!record || Number(record.expiresAt) <= now) {
|
|
71
90
|
try {
|
|
72
91
|
fs.unlinkSync(file);
|
|
@@ -78,9 +97,9 @@ function cleanup(projectRoot, now = Date.now()) {
|
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
function clearState(projectRoot) {
|
|
81
|
-
const dir = stateDir(projectRoot);
|
|
82
100
|
try {
|
|
83
|
-
|
|
101
|
+
const dir = secureStateDir(projectRoot);
|
|
102
|
+
if (!dir) return;
|
|
84
103
|
for (const name of fs.readdirSync(dir)) {
|
|
85
104
|
if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
|
|
86
105
|
try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
|
|
@@ -113,7 +132,8 @@ function createPending({
|
|
|
113
132
|
toolName: String(toolName || ''),
|
|
114
133
|
expiresAt: Date.now() + TTL_MS
|
|
115
134
|
};
|
|
116
|
-
|
|
135
|
+
const dir = secureStateDir(projectRoot, true);
|
|
136
|
+
atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
|
|
117
137
|
return record;
|
|
118
138
|
}
|
|
119
139
|
|
|
@@ -122,8 +142,10 @@ function approvePending({ projectRoot, sessionId, requestId }) {
|
|
|
122
142
|
if (!sessionId) return { ok: false, reason: 'session' };
|
|
123
143
|
if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
|
|
124
144
|
|
|
125
|
-
const
|
|
126
|
-
|
|
145
|
+
const dir = secureStateDir(projectRoot);
|
|
146
|
+
if (!dir) return { ok: false, reason: 'missing' };
|
|
147
|
+
const pendingFile = path.join(dir, `pending-${requestId}.json`);
|
|
148
|
+
const record = readJson(dir, pendingFile);
|
|
127
149
|
if (!record) return { ok: false, reason: 'missing' };
|
|
128
150
|
if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
|
|
129
151
|
if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
|
|
@@ -134,7 +156,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
|
|
|
134
156
|
return { ok: false, reason: 'consumed' };
|
|
135
157
|
}
|
|
136
158
|
|
|
137
|
-
atomicWrite(path.join(
|
|
159
|
+
atomicWrite(dir, path.join(dir, `token-${requestId}.json`), {
|
|
138
160
|
version: 2,
|
|
139
161
|
requestId,
|
|
140
162
|
sessionId: record.sessionId,
|
|
@@ -155,8 +177,8 @@ function consumeToken({
|
|
|
155
177
|
}) {
|
|
156
178
|
cleanup(projectRoot);
|
|
157
179
|
if (!sessionId) return false;
|
|
158
|
-
const dir =
|
|
159
|
-
if (!
|
|
180
|
+
const dir = secureStateDir(projectRoot);
|
|
181
|
+
if (!dir) return false;
|
|
160
182
|
const paths = requestPaths(filePaths, filePath);
|
|
161
183
|
if (paths.length === 0) return false;
|
|
162
184
|
const expectedPaths = requestPathKeys(paths, sessionCwd);
|
|
@@ -164,7 +186,7 @@ function consumeToken({
|
|
|
164
186
|
for (const name of fs.readdirSync(dir)) {
|
|
165
187
|
if (!/^token-[a-f0-9]{24}\.json$/.test(name)) continue;
|
|
166
188
|
const tokenFile = path.join(dir, name);
|
|
167
|
-
const token = readJson(tokenFile);
|
|
189
|
+
const token = readJson(dir, tokenFile);
|
|
168
190
|
if (
|
|
169
191
|
!token ||
|
|
170
192
|
token.sessionId !== String(sessionId || '') ||
|
|
@@ -3,129 +3,100 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let start = -1;
|
|
18
|
-
let level = 0;
|
|
19
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
20
|
-
const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
21
|
-
if (match && EVIDENCE_NAMES.includes(match[2])) {
|
|
22
|
-
start = index + 1;
|
|
23
|
-
level = match[1].length;
|
|
24
|
-
break;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
if (start < 0) return null;
|
|
28
|
-
let end = lines.length;
|
|
29
|
-
for (let index = start; index < lines.length; index += 1) {
|
|
30
|
-
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
31
|
-
if (heading && heading[1].length <= level) {
|
|
32
|
-
end = index;
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
6
|
+
function loadModule(installedName, sourceName, validate, missingMessage) {
|
|
7
|
+
const installed = path.join(__dirname, '../../scripts', installedName);
|
|
8
|
+
const source = path.join(__dirname, '../../../claude/scripts', sourceName);
|
|
9
|
+
const candidates = fs.existsSync(installed) ? [installed] : [source];
|
|
10
|
+
let lastError = new Error(missingMessage);
|
|
11
|
+
for (const candidate of candidates) {
|
|
12
|
+
try {
|
|
13
|
+
const value = require(candidate);
|
|
14
|
+
validate(value);
|
|
15
|
+
return { value, error: null, path: candidate };
|
|
16
|
+
} catch (error) { lastError = error; }
|
|
35
17
|
}
|
|
36
|
-
return
|
|
18
|
+
return { value: null, error: lastError, path: candidates[0] };
|
|
37
19
|
}
|
|
38
20
|
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
21
|
+
function loadSharedPolicy() {
|
|
22
|
+
const loaded = loadModule(
|
|
23
|
+
'workflow-policy.cjs',
|
|
24
|
+
'workflow-policy.cjs',
|
|
25
|
+
(policy) => {
|
|
26
|
+
if (typeof policy?.validateCanonicalReceipt !== 'function') {
|
|
27
|
+
throw new Error('shared workflow policy has no validateCanonicalReceipt function');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
'shared workflow policy is missing'
|
|
31
|
+
);
|
|
32
|
+
return { policy: loaded.value, error: loaded.error, path: loaded.path };
|
|
44
33
|
}
|
|
45
34
|
|
|
46
|
-
function
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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;
|
|
35
|
+
function loadSharedReceipt() {
|
|
36
|
+
const loaded = loadModule(
|
|
37
|
+
'spec-receipt.cjs',
|
|
38
|
+
'spec-receipt.cjs',
|
|
39
|
+
(receipt) => {
|
|
40
|
+
if (typeof receipt?.checkTaskReceipt !== 'function') {
|
|
41
|
+
throw new Error('shared spec receipt helper has no checkTaskReceipt function');
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
'shared spec receipt helper is missing'
|
|
45
|
+
);
|
|
46
|
+
return { receipt: loaded.value, error: loaded.error, path: loaded.path };
|
|
68
47
|
}
|
|
69
48
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
const file = safeTaskFile(featureDir, taskPath);
|
|
73
|
-
if (!file || !fs.existsSync(file)) return ['a'];
|
|
74
|
-
let text = '';
|
|
75
|
-
try { text = fs.readFileSync(file, 'utf8'); } catch { return ['a']; }
|
|
49
|
+
function getSharedPolicy() { return loadSharedPolicy().policy; }
|
|
50
|
+
function getSharedValidate() { return getSharedPolicy()?.validateCanonicalReceipt || null; }
|
|
76
51
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (body === null) {
|
|
83
|
-
failures.push('b');
|
|
84
|
-
} else if (
|
|
85
|
-
/\{\{[^}]+\}\}/.test(body)
|
|
86
|
-
|| EXPLICIT_FAILURE.test(body)
|
|
87
|
-
|| !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))
|
|
88
|
-
) {
|
|
89
|
-
failures.push('c');
|
|
90
|
-
} else {
|
|
91
|
-
// Canonical receipt requirements: command, exit/result, provenance, unambiguous PASS
|
|
92
|
-
// For done tasks, require canonical fields. If missing, add e/f/g.
|
|
93
|
-
const canonical = validateCanonicalReceipt(body);
|
|
94
|
-
// Map canonical failures to letters e,f,g,h for backward compat checks
|
|
95
|
-
// Use detailed names but also add letters for gate reporting
|
|
96
|
-
const map = {
|
|
97
|
-
verification_state: 'c', // already covers but keep distinct
|
|
98
|
-
command: 'e',
|
|
99
|
-
exit_result: 'f',
|
|
100
|
-
provenance: 'g',
|
|
101
|
-
artifact_hash: 'h',
|
|
102
|
-
};
|
|
103
|
-
for (const fail of canonical) {
|
|
104
|
-
const letter = map[fail];
|
|
105
|
-
// Avoid duplicating c if already failed, but add specific
|
|
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');
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (letter && !failures.includes(letter)) failures.push(letter);
|
|
113
|
-
}
|
|
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
|
-
if (!PASS_MARKER.test(body)) {
|
|
118
|
-
if (!failures.includes('c')) failures.push('c');
|
|
119
|
-
}
|
|
120
|
-
}
|
|
52
|
+
function receiptHelper() {
|
|
53
|
+
const loaded = loadSharedReceipt();
|
|
54
|
+
if (!loaded.receipt) throw loaded.error;
|
|
55
|
+
return loaded.receipt;
|
|
56
|
+
}
|
|
121
57
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
58
|
+
function evidenceBody(text) { return receiptHelper().evidenceBody(text, getSharedPolicy() || {}); }
|
|
59
|
+
function safeTaskFile(featureDir, taskPath) {
|
|
60
|
+
const result = receiptHelper().safeRead(featureDir, taskPath);
|
|
61
|
+
return result.status === 'ok' ? result.path : null;
|
|
62
|
+
}
|
|
63
|
+
function validateCanonicalReceipt(body, options = {}) {
|
|
64
|
+
const validate = getSharedValidate();
|
|
65
|
+
return validate ? validate(body, options) : ['shared_validator'];
|
|
66
|
+
}
|
|
67
|
+
function checkReceiptDetails(featureDir, taskPath, task, runtimeContext) {
|
|
68
|
+
const policy = getSharedPolicy();
|
|
69
|
+
if (!policy) return { failures: ['shared_validator'], status: 'missing' };
|
|
70
|
+
return receiptHelper().checkTaskReceipt(featureDir, taskPath, task, runtimeContext, policy);
|
|
71
|
+
}
|
|
72
|
+
function checkReceipt(featureDir, taskPath, task, runtimeContext) {
|
|
73
|
+
const map = {
|
|
74
|
+
unsafe_path: 'a', missing_receipt: 'b', task_status: 'a', completed_at: 'd',
|
|
75
|
+
verification_state: 'c', placeholder: 'c', validator_unavailable: 'c',
|
|
76
|
+
command: 'e', command_identity: 'e', exit_result: 'f', provenance: 'g',
|
|
77
|
+
artifact_hash: 'h', artifact_declaration: 'h',
|
|
78
|
+
};
|
|
79
|
+
return [...new Set(checkReceiptDetails(featureDir, taskPath, task, runtimeContext).failures.map((failure) => map[failure] || failure))];
|
|
80
|
+
}
|
|
81
|
+
function checkFeatureReceipt(featureDir, runtimeContext) {
|
|
82
|
+
const policy = getSharedPolicy();
|
|
83
|
+
if (!policy) return { failures: ['shared_validator'], status: 'missing' };
|
|
84
|
+
return receiptHelper().checkFeatureReceipt(featureDir, runtimeContext, policy);
|
|
85
|
+
}
|
|
86
|
+
function readTaskProof(featureDir, taskPath) {
|
|
87
|
+
return receiptHelper().readTaskProof(featureDir, taskPath, getSharedPolicy() || {});
|
|
129
88
|
}
|
|
130
89
|
|
|
131
|
-
module.exports = {
|
|
90
|
+
module.exports = {
|
|
91
|
+
checkFeatureReceipt,
|
|
92
|
+
checkReceipt,
|
|
93
|
+
checkReceiptDetails,
|
|
94
|
+
evidenceBody,
|
|
95
|
+
getSharedPolicy,
|
|
96
|
+
getSharedValidate,
|
|
97
|
+
loadSharedPolicy,
|
|
98
|
+
loadSharedReceipt,
|
|
99
|
+
readTaskProof,
|
|
100
|
+
safeTaskFile,
|
|
101
|
+
validateCanonicalReceipt,
|
|
102
|
+
};
|
|
@@ -2,49 +2,126 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const
|
|
5
|
+
const PATH_SAFETY = require('./runtime-path-safety.cjs');
|
|
6
|
+
|
|
7
|
+
// Save original fs methods at load time to avoid being affected by test monkey patches
|
|
8
|
+
// that simulate runtime canonicalization failures for spec paths. Resolver discovery
|
|
9
|
+
// must use these originals, while spec resolution uses the (possibly patched) fs.
|
|
10
|
+
const originalLstatSync = fs.lstatSync;
|
|
11
|
+
|
|
12
|
+
// Installed Codex hooks use .codex/scripts; source tests use the Claude-side
|
|
13
|
+
// source path. If an installed resolver exists but is malformed, do not fall
|
|
14
|
+
// back to another copy: the shared authority is unavailable.
|
|
15
|
+
const RESOLVER_CANDIDATES = [
|
|
16
|
+
{
|
|
17
|
+
file: path.join(__dirname, '../../scripts/spec-resolver.cjs'),
|
|
18
|
+
root: path.resolve(__dirname, '../..'),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
file: path.join(__dirname, '../../../claude/scripts/spec-resolver.cjs'),
|
|
22
|
+
root: path.resolve(__dirname, '../../..'),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function canonicalRealpath(candidate) {
|
|
27
|
+
try {
|
|
28
|
+
return PATH_SAFETY.canonicalRegularFile(candidate.root, candidate.file, 'shared spec resolver');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(`${error.message} (${candidate.file})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findCandidate() {
|
|
35
|
+
// Fail closed if preferred installed resolver exists but is invalid; never silently fall back to source.
|
|
36
|
+
const primary = RESOLVER_CANDIDATES[0];
|
|
37
|
+
let primaryExists = false;
|
|
38
|
+
try {
|
|
39
|
+
originalLstatSync(primary.file);
|
|
40
|
+
primaryExists = true;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code !== 'ENOENT') throw new Error(`resolver check failed: ${e.message} (${primary.file})`);
|
|
43
|
+
}
|
|
44
|
+
if (primaryExists) {
|
|
45
|
+
canonicalRealpath(primary);
|
|
46
|
+
return primary;
|
|
47
|
+
}
|
|
48
|
+
const secondary = RESOLVER_CANDIDATES[1];
|
|
49
|
+
let secondaryExists = false;
|
|
50
|
+
try {
|
|
51
|
+
originalLstatSync(secondary.file);
|
|
52
|
+
secondaryExists = true;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.code !== 'ENOENT') throw new Error(`resolver check failed: ${e.message} (${secondary.file})`);
|
|
55
|
+
}
|
|
56
|
+
if (secondaryExists) {
|
|
57
|
+
canonicalRealpath(secondary);
|
|
58
|
+
return secondary;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sharedResolver() {
|
|
64
|
+
const candidate = findCandidate() || RESOLVER_CANDIDATES[0];
|
|
65
|
+
try {
|
|
66
|
+
// Use canonical realpath for require to avoid symlink hijack
|
|
67
|
+
const realCandidate = canonicalRealpath(candidate);
|
|
68
|
+
const resolver = require(realCandidate);
|
|
69
|
+
if (typeof resolver?.resolveActiveSpec !== 'function') {
|
|
70
|
+
throw new Error('shared spec resolver has no resolveActiveSpec function');
|
|
71
|
+
}
|
|
72
|
+
return resolver;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
throw new Error(`${message} (${candidate.file})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
6
78
|
|
|
7
79
|
function specsDirectory(projectRoot, runtime) {
|
|
8
|
-
return
|
|
80
|
+
return sharedResolver().specsDirectory(projectRoot, runtime);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findAllActiveSpecs(projectRoot, runtime) {
|
|
84
|
+
return sharedResolver().findAllActiveSpecs(projectRoot, runtime);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findAllSpecCandidates(projectRoot, runtime) {
|
|
88
|
+
return sharedResolver().findAllSpecCandidates(projectRoot, runtime);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath) {
|
|
92
|
+
if (projectRoot && typeof projectRoot === 'object') {
|
|
93
|
+
return sharedResolver().resolveActiveSpec(projectRoot);
|
|
94
|
+
}
|
|
95
|
+
return sharedResolver().resolveActiveSpec({
|
|
9
96
|
projectRoot,
|
|
10
|
-
runtime
|
|
11
|
-
|
|
12
|
-
|
|
97
|
+
runtime,
|
|
98
|
+
explicitFeature,
|
|
99
|
+
explicitPath,
|
|
100
|
+
});
|
|
13
101
|
}
|
|
14
102
|
|
|
15
103
|
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;
|
|
104
|
+
const resolved = resolveActiveSpec(projectRoot, runtime);
|
|
105
|
+
if (!resolved) return null;
|
|
106
|
+
if (resolved.error === 'multiple_active' || resolved.error === 'invalid_specs') return resolved;
|
|
107
|
+
if (resolved.error) return null;
|
|
108
|
+
return resolved;
|
|
35
109
|
}
|
|
36
110
|
|
|
37
111
|
function taskStatusMap(spec) {
|
|
38
112
|
return Object.fromEntries(
|
|
39
113
|
Object.entries(spec.task_registry || {}).map(([taskPath, task]) => [
|
|
40
114
|
taskPath,
|
|
41
|
-
task?.status || 'pending'
|
|
42
|
-
])
|
|
115
|
+
task?.status || 'pending',
|
|
116
|
+
]),
|
|
43
117
|
);
|
|
44
118
|
}
|
|
45
119
|
|
|
46
120
|
module.exports = {
|
|
47
121
|
findActiveSpec,
|
|
122
|
+
findAllActiveSpecs,
|
|
123
|
+
findAllSpecCandidates,
|
|
124
|
+
resolveActiveSpec,
|
|
48
125
|
specsDirectory,
|
|
49
|
-
taskStatusMap
|
|
126
|
+
taskStatusMap,
|
|
50
127
|
};
|
|
@@ -24,7 +24,7 @@ try {
|
|
|
24
24
|
const data = readPayload();
|
|
25
25
|
if (!data) process.exit(0);
|
|
26
26
|
const { projectRoot } = getHookContext(data);
|
|
27
|
-
const prompt = String(data.prompt || '')
|
|
27
|
+
const prompt = String(data.prompt || '');
|
|
28
28
|
cleanup(projectRoot);
|
|
29
29
|
|
|
30
30
|
if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
|