@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.
Files changed (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. 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
- const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
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
- return raw ? JSON.parse(raw) : null;
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: readRuntime(PROJECT_ROOT)
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 dir = path.join(PROJECT_ROOT, '.codex', 'hooks', '.logs');
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
  };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/privacy-command-analysis.cjs');
@@ -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
- const dir = path.dirname(file);
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 = stateDir(projectRoot);
65
- if (!fs.existsSync(dir)) return;
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
- if (!fs.existsSync(dir)) return;
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
- atomicWrite(path.join(stateDir(projectRoot), `pending-${requestId}.json`), record);
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 pendingFile = path.join(stateDir(projectRoot), `pending-${requestId}.json`);
126
- const record = readJson(pendingFile);
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(stateDir(projectRoot), `token-${requestId}.json`), {
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 = stateDir(projectRoot);
159
- if (!fs.existsSync(dir)) return false;
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 || '') ||
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/runtime-path-safety.cjs');
@@ -3,129 +3,100 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const EVIDENCE_NAMES = [
7
- 'Evidence',
8
- 'Task Test Plan & Verification Evidence',
9
- 'Verification & Evidence'
10
- ];
11
- const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
12
- const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
13
- const EXPLICIT_FAILURE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
14
-
15
- function evidenceBody(text) {
16
- const lines = text.split('\n');
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 lines.slice(start, end).join('\n');
18
+ return { value: null, error: lastError, path: candidates[0] };
37
19
  }
38
20
 
39
- function safeTaskFile(featureDir, taskPath) {
40
- const target = path.resolve(featureDir, taskPath);
41
- const relative = path.relative(featureDir, target);
42
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
43
- return target;
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 validateCanonicalReceipt(body) {
47
- const failures = [];
48
- if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) {
49
- failures.push('verification_state');
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;
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 checkReceipt(featureDir, taskPath, task) {
71
- const failures = [];
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
- const status = text.split('\n')
78
- .find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
79
- if (!status || !/\bdone\b/i.test(status)) failures.push('a');
80
-
81
- const body = evidenceBody(text);
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
- const completedAt = task?.completed_at;
123
- if (
124
- typeof completedAt !== 'string'
125
- || !/^\d{4}-\d{2}-\d{2}T/.test(completedAt)
126
- || Number.isNaN(Date.parse(completedAt))
127
- ) failures.push('d');
128
- return failures;
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 = { checkReceipt, evidenceBody, safeTaskFile, validateCanonicalReceipt };
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 { resolveProjectPath } = require('./hook-context.cjs');
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 resolveProjectPath(
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.paths?.specs,
11
- 'specs'
12
- );
97
+ runtime,
98
+ explicitFeature,
99
+ explicitPath,
100
+ });
13
101
  }
14
102
 
15
103
  function findActiveSpec(projectRoot, runtime) {
16
- const specsDir = specsDirectory(projectRoot, runtime);
17
- if (!fs.existsSync(specsDir)) return null;
18
- const entries = fs.readdirSync(specsDir, { withFileTypes: true })
19
- .filter((entry) => entry.isDirectory())
20
- .sort((left, right) => left.name.localeCompare(right.name));
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 || '').trim();
27
+ const prompt = String(data.prompt || '');
28
28
  cleanup(projectRoot);
29
29
 
30
30
  if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);