@haposoft/cafekit 0.18.0 → 0.18.2

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 (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -4,6 +4,7 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const PATH_SAFETY = require('./runtime-path-safety.cjs');
7
+ const { displayNames } = require('./privacy-paths.cjs');
7
8
 
8
9
  // Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
9
10
  // "ask" decision -- so approval costs a full round trip: the agent has to relay
@@ -61,13 +62,6 @@ function samePathKeys(left, right) {
61
62
  && left.every((value, index) => value === right[index]);
62
63
  }
63
64
 
64
- function displayNames(filePaths) {
65
- const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
66
- return names.length <= 3
67
- ? names.join(', ')
68
- : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
69
- }
70
-
71
65
  function atomicWrite(dir, file, payload) {
72
66
  PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
73
67
  const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
@@ -84,14 +78,81 @@ function readJson(dir, file) {
84
78
  }
85
79
  }
86
80
 
81
+ function validPending(record, sessionId, requestId, now = Date.now()) {
82
+ return record?.requestId === requestId && record.sessionId === sessionId
83
+ && Number.isFinite(record.expiresAt) && record.expiresAt > now
84
+ && typeof record.displayName === 'string' && record.displayName.length > 0
85
+ && Array.isArray(record.pathKeys) && record.pathKeys.length > 0
86
+ && record.pathKeys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/.test(key));
87
+ }
88
+
89
+ function writeShortMarker(dir, record) {
90
+ const { requestId, sessionId, expiresAt } = record;
91
+ fs.writeFileSync(path.join(dir, `short-${hash(sessionId)}-${requestId}.json`),
92
+ `${JSON.stringify({ requestId, sessionId, expiresAt })}\n`,
93
+ { encoding: 'utf8', mode: 0o600, flag: 'wx' });
94
+ }
95
+
96
+ /** Each prompt owns only markers it successfully renamed, never a shared mutable flag. */
97
+ function claimShortMarkers(projectRoot, sessionId) {
98
+ const requestIds = [];
99
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: true, requestIds };
100
+ const dir = secureStateDir(projectRoot);
101
+ if (!dir) return { ok: true, requestIds };
102
+ const prefix = `short-${hash(sessionId)}-`;
103
+ let ok = true;
104
+ for (const name of fs.readdirSync(dir)) {
105
+ if (!name.startsWith(prefix) || !name.endsWith('.json')) continue;
106
+ const file = path.join(dir, name);
107
+ const claimed = path.join(dir, `claim-${crypto.randomBytes(12).toString('hex')}.tmp`);
108
+ const requestId = name.slice(prefix.length, -'.json'.length);
109
+ let moved = false;
110
+ try {
111
+ PATH_SAFETY.assertComponentPath(dir, file);
112
+ fs.renameSync(file, claimed);
113
+ moved = true;
114
+ requestIds.push(requestId);
115
+ PATH_SAFETY.assertComponentPath(dir, claimed);
116
+ const marker = JSON.parse(fs.readFileSync(claimed, 'utf8'));
117
+ if (!/^[a-f0-9]{24}$/.test(requestId) || marker.requestId !== requestId
118
+ || marker.sessionId !== sessionId || !Number.isFinite(marker.expiresAt)
119
+ || marker.expiresAt <= Date.now()) throw new Error('Invalid short approval marker');
120
+ } catch {
121
+ ok = false;
122
+ if (!moved) {
123
+ // A failed claim cancels the shortcut; deletion never counts as ownership.
124
+ try { fs.unlinkSync(file); }
125
+ catch {
126
+ let descriptor;
127
+ try {
128
+ PATH_SAFETY.canonicalRegularFile(dir, file, 'short approval marker');
129
+ descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
130
+ fs.ftruncateSync(descriptor, 0);
131
+ } catch { /* no permission follows a failed invalidation */ }
132
+ finally {
133
+ if (descriptor !== undefined) {
134
+ try { fs.closeSync(descriptor); } catch { /* already rejected */ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ } finally {
140
+ if (moved) {
141
+ try { fs.unlinkSync(claimed); } catch { ok = false; }
142
+ }
143
+ }
144
+ }
145
+ return { ok, requestIds };
146
+ }
147
+
87
148
  function cleanup(projectRoot, now = Date.now()) {
88
149
  const dir = secureStateDir(projectRoot);
89
150
  if (!dir) return;
90
151
  for (const name of fs.readdirSync(dir)) {
91
- if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
152
+ if (!/^(?:(?:pending|token)-[a-f0-9]+|short-[a-f0-9]{64}-[a-f0-9]{24})\.json$/.test(name)) continue;
92
153
  const file = path.join(dir, name);
93
154
  const record = readJson(dir, file);
94
- if (!record || Number(record.expiresAt) <= now) {
155
+ if (!record || !Number.isFinite(record.expiresAt) || record.expiresAt <= now) {
95
156
  try {
96
157
  fs.unlinkSync(file);
97
158
  } catch {
@@ -101,13 +162,20 @@ function cleanup(projectRoot, now = Date.now()) {
101
162
  }
102
163
  }
103
164
 
104
- function clearState(projectRoot) {
165
+ function clearState(projectRoot, sessionId) {
105
166
  try {
167
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return;
106
168
  const dir = secureStateDir(projectRoot);
107
169
  if (!dir) return;
108
170
  for (const name of fs.readdirSync(dir)) {
171
+ if (name.startsWith(`short-${hash(sessionId)}-`) && name.endsWith('.json')) {
172
+ try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently claimed */ }
173
+ continue;
174
+ }
109
175
  if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
110
- try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
176
+ const file = path.join(dir, name);
177
+ if (readJson(dir, file)?.sessionId !== sessionId) continue;
178
+ try { fs.unlinkSync(file); } catch { /* concurrently consumed */ }
111
179
  }
112
180
  }
113
181
  } catch {
@@ -123,26 +191,38 @@ function createPending({
123
191
  filePath
124
192
  }) {
125
193
  cleanup(projectRoot);
126
- if (!sessionId) throw new Error('A Codex session ID is required');
194
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('A Codex session ID is required');
127
195
  const paths = requestPaths(filePaths, filePath);
128
196
  if (paths.length === 0) throw new Error('At least one sensitive path is required');
197
+ const pathKeys = requestPathKeys(paths, sessionCwd);
198
+ const dir = secureStateDir(projectRoot, true);
199
+ for (const name of fs.readdirSync(dir)) {
200
+ const match = name.match(/^pending-([a-f0-9]{24})\.json$/);
201
+ if (!match) continue;
202
+ const pending = readJson(dir, path.join(dir, name));
203
+ if (validPending(pending, sessionId, match[1]) && samePathKeys(pending.pathKeys, pathKeys)) {
204
+ try { writeShortMarker(dir, pending); }
205
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
206
+ return pending;
207
+ }
208
+ }
129
209
  const requestId = crypto.randomBytes(12).toString('hex');
130
210
  const record = {
131
211
  version: 2,
132
212
  requestId,
133
213
  sessionId: String(sessionId || ''),
134
- pathKeys: requestPathKeys(paths, sessionCwd),
214
+ pathKeys,
135
215
  displayName: displayNames(paths),
136
216
  expiresAt: Date.now() + TTL_MS
137
217
  };
138
- const dir = secureStateDir(projectRoot, true);
139
218
  atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
219
+ writeShortMarker(dir, record);
140
220
  return record;
141
221
  }
142
222
 
143
223
  function approvePending({ projectRoot, sessionId, requestId }) {
144
224
  cleanup(projectRoot);
145
- if (!sessionId) return { ok: false, reason: 'session' };
225
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, reason: 'session' };
146
226
  if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
147
227
 
148
228
  const dir = secureStateDir(projectRoot);
@@ -152,6 +232,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
152
232
  if (!record) return { ok: false, reason: 'missing' };
153
233
  if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
154
234
  if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
235
+ if (!validPending(record, sessionId, requestId)) return { ok: false, reason: 'invalid' };
155
236
 
156
237
  try {
157
238
  fs.unlinkSync(pendingFile);
@@ -218,5 +299,6 @@ module.exports = {
218
299
  approvePending,
219
300
  hasGrant,
220
301
  cleanup,
221
- clearState
302
+ clearState,
303
+ claimShortMarkers
222
304
  };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { hookStateDir } = require('./hook-context.cjs');
7
+
8
+ function reservationFile(projectRoot, sessionId) {
9
+ const key = crypto.createHash('sha256')
10
+ .update(String(sessionId || 'unknown'))
11
+ .digest('hex')
12
+ .slice(0, 16);
13
+ return path.join(hookStateDir(projectRoot), `rules-${key}.json`);
14
+ }
15
+
16
+ /** Reserve one injection slot per session. Returns null on reservation errors. */
17
+ function reserveSession(projectRoot, sessionId) {
18
+ if (!sessionId) return null;
19
+ try {
20
+ const file = reservationFile(projectRoot, sessionId);
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
23
+ encoding: 'utf8',
24
+ flag: 'wx',
25
+ mode: 0o600
26
+ });
27
+ return true;
28
+ } catch (error) {
29
+ return error.code === 'EEXIST' ? false : null;
30
+ }
31
+ }
32
+
33
+ function clearSessionReservation(projectRoot, sessionId) {
34
+ if (!sessionId) return;
35
+ try { fs.rmSync(reservationFile(projectRoot, sessionId), { force: true }); } catch { /* fail-open */ }
36
+ }
37
+
38
+ module.exports = { reserveSession, clearSessionReservation };
@@ -0,0 +1 @@
1
+ module.exports = require('../../../claude/hooks/lib/secret-keywords.cjs');
@@ -37,8 +37,8 @@ function loadSharedReceipt() {
37
37
  'spec-receipt.cjs',
38
38
  'spec-receipt.cjs',
39
39
  (receipt) => {
40
- if (typeof receipt?.checkTaskReceipt !== 'function') {
41
- throw new Error('shared spec receipt helper has no checkTaskReceipt function');
40
+ if (typeof receipt?.checkWorkflowTaskReceipt !== 'function') {
41
+ throw new Error('shared spec receipt helper has no checkWorkflowTaskReceipt function');
42
42
  }
43
43
  },
44
44
  'shared spec receipt helper is missing'
@@ -55,7 +55,6 @@ function receiptHelper() {
55
55
  return loaded.receipt;
56
56
  }
57
57
 
58
- function evidenceBody(text) { return receiptHelper().evidenceBody(text, getSharedPolicy() || {}); }
59
58
  function safeTaskFile(featureDir, taskPath) {
60
59
  const result = receiptHelper().safeRead(featureDir, taskPath);
61
60
  return result.status === 'ok' ? result.path : null;
@@ -64,11 +63,6 @@ function validateCanonicalReceipt(body, options = {}) {
64
63
  const validate = getSharedValidate();
65
64
  return validate ? validate(body, options) : ['shared_validator'];
66
65
  }
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
66
  function checkWorkflowReceiptDetails(featureDir, taskPath, runtimeContext) {
73
67
  const policy = getSharedPolicy();
74
68
  const receipt = receiptHelper();
@@ -85,40 +79,18 @@ function checkWorkflowReceiptSet(candidates, projectRoot, runtimeSession) {
85
79
  }
86
80
  return receipt.checkWorkflowReceiptSet(candidates, projectRoot, runtimeSession, policy);
87
81
  }
88
- function checkReceipt(featureDir, taskPath, task, runtimeContext) {
89
- const map = {
90
- unsafe_path: 'a', missing_receipt: 'b', task_status: 'a', completed_at: 'd',
91
- verification_state: 'c', placeholder: 'c', validator_unavailable: 'c',
92
- command: 'e', command_identity: 'e', exit_result: 'f', provenance: 'g',
93
- artifact_hash: 'h', artifact_declaration: 'h',
94
- };
95
- return [...new Set(checkReceiptDetails(featureDir, taskPath, task, runtimeContext).failures.map((failure) => map[failure] || failure))];
96
- }
97
- function checkFeatureReceipt(featureDir, runtimeContext) {
98
- const policy = getSharedPolicy();
99
- if (!policy) return { failures: ['shared_validator'], status: 'missing' };
100
- return receiptHelper().checkFeatureReceipt(featureDir, runtimeContext, policy);
101
- }
102
82
  function receiptFixHint(failures, body) {
103
83
  const helper = receiptHelper();
104
84
  return typeof helper.receiptFixHint === 'function' ? helper.receiptFixHint(failures, body) : null;
105
85
  }
106
- function readTaskProof(featureDir, taskPath) {
107
- return receiptHelper().readTaskProof(featureDir, taskPath, getSharedPolicy() || {});
108
- }
109
86
 
110
87
  module.exports = {
111
- checkFeatureReceipt,
112
- checkReceipt,
113
- checkReceiptDetails,
114
88
  checkWorkflowReceiptDetails,
115
89
  checkWorkflowReceiptSet,
116
- evidenceBody,
117
90
  getSharedPolicy,
118
91
  getSharedValidate,
119
92
  loadSharedPolicy,
120
93
  loadSharedReceipt,
121
- readTaskProof,
122
94
  receiptFixHint,
123
95
  safeTaskFile,
124
96
  validateCanonicalReceipt,
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { packetAt, namedPackets, explicitFailure } = require('./spec-touch.cjs');
7
+ const { atomicWrite, assertConfiguredSpecsPath, hookStateDir } = require('./hook-context.cjs');
8
+
9
+ function sessionIdentity(payload) {
10
+ return [payload.session_id, payload.sessionId, payload.sessionID, payload.session?.id]
11
+ .find((value) => typeof value === 'string' && value.trim()) || null;
12
+ }
13
+
14
+ function patchPaths(command) {
15
+ if (typeof command !== 'string') return [];
16
+ const lines = command.trim().split(/\r?\n/);
17
+ if (lines.shift() !== '*** Begin Patch' || lines.pop() !== '*** End Patch') return [];
18
+ const paths = [];
19
+ let operation = null;
20
+ let body = false;
21
+ for (const line of lines) {
22
+ const header = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
23
+ if (header) {
24
+ if (operation && operation !== 'Delete' && !body) return [];
25
+ operation = header[1]; body = operation === 'Delete';
26
+ paths.push(header[2]);
27
+ } else if (/^\*\*\* Move to: .+/.test(line) && operation === 'Update' && !body) {
28
+ paths.push(line.slice('*** Move to: '.length));
29
+ } else if (operation === 'Add' && /^\+/.test(line)) {
30
+ body = true;
31
+ } else if (operation === 'Update' && /^(?:@@|[ +\-]|\*\*\* End of File$)/.test(line)) {
32
+ body = true;
33
+ } else {
34
+ return [];
35
+ }
36
+ }
37
+ return operation && body ? paths : [];
38
+ }
39
+
40
+ function responseText(response) {
41
+ if (typeof response === 'string') return response;
42
+ if (!response || typeof response !== 'object') return '';
43
+ const value = response.output ?? response.result ?? response.message ?? response.content;
44
+ if (typeof value === 'string') return value;
45
+ return Array.isArray(value) ? value.map((item) => item?.text || '').join('\n') : '';
46
+ }
47
+
48
+ function successfulMutation(payload) {
49
+ const response = payload.tool_response;
50
+ if (payload.hook_event_name !== 'PostToolUse' || explicitFailure(payload) || response == null) return false;
51
+ const text = responseText(response).trim();
52
+ if (/^(?:error\b|failed\b|failure\b|apply_patch verification failed)/im.test(text)) return false;
53
+ // Contract: github.com/openai/codex/blob/main/codex-rs/core/src/tools/context.rs
54
+ // Native ApplyPatchToolOutput emits a string summary to PostToolUse, whereas its
55
+ // code-mode return is {}. Unknown/empty output is not observed successful mutation.
56
+ const nativeSuccess = /^Success\. Updated the following files:\r?\n[AMD] .+/m.test(text);
57
+ if (payload.tool_name === 'apply_patch') return nativeSuccess;
58
+ return nativeSuccess || response?.ok === true || response?.success === true
59
+ || /^(?:success\b|.*\bupdated successfully[.!]?\s*$)/i.test(text);
60
+ }
61
+
62
+ function mutationPaths(payload) {
63
+ if (!successfulMutation(payload)) return [];
64
+ const input = payload.tool_input || {};
65
+ if (payload.tool_name === 'apply_patch') return patchPaths(input.command);
66
+ if (!['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) return [];
67
+ return [input.file_path, input.path, ...(Array.isArray(input.edits) ? input.edits.map((edit) => edit?.file_path || edit?.path) : [])]
68
+ .filter((value) => typeof value === 'string');
69
+ }
70
+
71
+ function touchSession(payload, context) {
72
+ const sessionId = sessionIdentity(payload);
73
+ const specsRoot = assertConfiguredSpecsPath(context.projectRoot, context.runtime);
74
+ if (!sessionId) return { sessionId, names: [], touched: [], specsRoot };
75
+ const key = crypto.createHash('sha256').update(`${context.projectRoot}\0${sessionId}`).digest('hex');
76
+ const file = path.join(hookStateDir(context.projectRoot), `spec-touched-${key}.json`);
77
+ let previous = [];
78
+ try { previous = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* first touch */ }
79
+ const touched = new Set(Array.isArray(previous) ? previous.filter((name) => typeof name === 'string') : []);
80
+ let names;
81
+ if (payload.hook_event_name === 'PostToolUse') {
82
+ names = mutationPaths(payload).map((value) => packetAt(value, specsRoot, context.sessionCwd)).filter(Boolean);
83
+ } else {
84
+ let packets = [];
85
+ try { packets = fs.readdirSync(specsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch { /* absent root */ }
86
+ names = namedPackets(payload.prompt || payload.user_prompt, packets, specsRoot, context.projectRoot);
87
+ const feature = payload.explicitFeature ?? payload.featureName ?? payload.feature;
88
+ if (typeof feature === 'string' && feature.trim() && !/[\\/]/.test(feature) && !['.', '..', '_shared'].includes(feature)) names.push(feature);
89
+ const explicitPath = payload.explicitPath ?? payload.specPath ?? payload.spec_path ?? payload.featurePath;
90
+ const fromPath = packetAt(explicitPath, specsRoot, context.projectRoot);
91
+ if (fromPath) names.push(fromPath);
92
+ }
93
+ for (const name of names) touched.add(name);
94
+ if (names.length) {
95
+ try { atomicWrite(file, `${JSON.stringify([...touched].sort())}\n`); } catch { /* no shared fallback */ }
96
+ }
97
+ return { sessionId, names: [...new Set(names)], touched: [...touched], specsRoot };
98
+ }
99
+
100
+ module.exports = { touchSession, sessionIdentity };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/spec-touch.cjs');
@@ -66,8 +66,8 @@ function sharedResolver() {
66
66
  // Use canonical realpath for require to avoid symlink hijack
67
67
  const realCandidate = canonicalRealpath(candidate);
68
68
  const resolver = require(realCandidate);
69
- if (typeof resolver?.resolveActiveSpec !== 'function') {
70
- throw new Error('shared spec resolver has no resolveActiveSpec function');
69
+ if (typeof resolver?.resolveWorkflowCandidate !== 'function') {
70
+ throw new Error('shared spec resolver has no resolveWorkflowCandidate function');
71
71
  }
72
72
  return resolver;
73
73
  } catch (error) {
@@ -80,31 +80,13 @@ function specsDirectory(projectRoot, runtime) {
80
80
  return sharedResolver().specsDirectory(projectRoot, runtime);
81
81
  }
82
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({
96
- projectRoot,
97
- runtime,
98
- explicitFeature,
99
- explicitPath,
100
- });
83
+ function findLegacyPackets(projectRoot, runtime) {
84
+ const resolver = sharedResolver();
85
+ return typeof resolver.findLegacyPackets === 'function' ? resolver.findLegacyPackets(projectRoot, runtime) : [];
101
86
  }
102
87
 
103
88
  function resolveWorkflowCandidate(projectRoot, runtime, explicitFeature, explicitPath) {
104
89
  const resolver = sharedResolver();
105
- if (typeof resolver.resolveWorkflowCandidate !== 'function') {
106
- return resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
107
- }
108
90
  if (projectRoot && typeof projectRoot === 'object') {
109
91
  return resolver.resolveWorkflowCandidate(projectRoot);
110
92
  }
@@ -123,23 +105,6 @@ function refineWorkflowGateResolution(resolved) {
123
105
  : resolved;
124
106
  }
125
107
 
126
- function findActiveSpec(projectRoot, runtime) {
127
- const resolved = resolveActiveSpec(projectRoot, runtime);
128
- if (!resolved) return null;
129
- if (resolved.error === 'multiple_active' || resolved.error === 'invalid_specs') return resolved;
130
- if (resolved.error) return null;
131
- return resolved;
132
- }
133
-
134
- function taskStatusMap(spec) {
135
- return Object.fromEntries(
136
- Object.entries(spec.task_registry || {}).map(([taskPath, task]) => [
137
- taskPath,
138
- task?.status || 'pending',
139
- ]),
140
- );
141
- }
142
-
143
108
  function extractExplicitTarget(...sources) {
144
109
  const resolver = sharedResolver();
145
110
  if (typeof resolver.extractExplicitTarget !== 'function') return null;
@@ -153,14 +118,10 @@ function readActiveFeatureTarget(options) {
153
118
  }
154
119
 
155
120
  module.exports = {
156
- findActiveSpec,
157
121
  extractExplicitTarget,
122
+ findLegacyPackets,
158
123
  readActiveFeatureTarget,
159
- findAllActiveSpecs,
160
- findAllSpecCandidates,
161
- resolveActiveSpec,
162
124
  resolveWorkflowCandidate,
163
125
  refineWorkflowGateResolution,
164
126
  specsDirectory,
165
- taskStatusMap,
166
127
  };
@@ -0,0 +1 @@
1
+ module.exports = require('../../../claude/hooks/lib/update-check.cjs');
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const { PROJECT_ROOT, atomicWrite, hookStateDir, logCrash, readPayload } = require('./lib/hook-context.cjs');
8
+
9
+ function gitLine(args) {
10
+ try {
11
+ return execFileSync('git', args, {
12
+ cwd: PROJECT_ROOT, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
13
+ }).trim() || null;
14
+ } catch { return null; }
15
+ }
16
+
17
+ try {
18
+ const payload = readPayload();
19
+ if (!payload) process.exit(0);
20
+ const sessionId = [payload.session_id, payload.sessionId, payload.sessionID, payload.session?.id]
21
+ .find(value => typeof value === 'string' && value.trim());
22
+ if (!sessionId) process.exit(0);
23
+ const status = gitLine(['status', '--porcelain']);
24
+ // These anchors are re-derivable facts, never proof or authorization.
25
+ const record = {
26
+ captured_at: new Date().toISOString(),
27
+ session_id: sessionId,
28
+ trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
29
+ project_root: PROJECT_ROOT,
30
+ branch: gitLine(['rev-parse', '--abbrev-ref', 'HEAD']),
31
+ head: gitLine(['rev-parse', '--short', 'HEAD']),
32
+ dirty_count: status === null ? null : status.split('\n').filter(line => line.trim()).length,
33
+ };
34
+ const key = crypto.createHash('sha256').update(sessionId).digest('hex');
35
+ atomicWrite(path.join(hookStateDir(PROJECT_ROOT), `compact-recovery-${key}.json`), `${JSON.stringify(record)}\n`);
36
+ } catch (error) {
37
+ if (error.message === 'hook payload is empty') process.exit(0);
38
+ logCrash('precompact', error);
39
+ }
@@ -1,16 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const {
5
- APPROVAL_PREFIX,
6
- approvePending,
7
- cleanup
8
- } = require('./lib/privacy-state.cjs');
9
- const {
10
- getHookContext,
11
- readPayload
12
- } = require('./lib/hook-context.cjs');
13
-
14
4
  function context(message) {
15
5
  process.stdout.write(`${JSON.stringify({
16
6
  hookSpecificOutput: {
@@ -20,10 +10,20 @@ function context(message) {
20
10
  })}\n`);
21
11
  }
22
12
 
13
+ let projectRoot, sessionId, state;
23
14
  try {
15
+ const { PROJECT_ROOT, readPayload } = require('./lib/hook-context.cjs');
24
16
  const data = readPayload();
25
17
  if (!data) process.exit(0);
26
- const { projectRoot } = getHookContext(data);
18
+ projectRoot = PROJECT_ROOT;
19
+ sessionId = data.session_id;
20
+ let claimed = { ok: false, requestIds: [] };
21
+ try {
22
+ state = require('./lib/privacy-state.cjs');
23
+ claimed = state.claimShortMarkers(projectRoot, sessionId);
24
+ } catch { /* the outer catch retries invalidation, never approval */ }
25
+ if (!claimed.ok) throw new Error('Short approval markers could not be claimed safely');
26
+ const { APPROVAL_PREFIX, approvePending, cleanup } = state;
27
27
  // A pasted prompt arrives with whatever whitespace the terminal added around
28
28
  // it. Rejecting on a trailing newline made the approval look broken while the
29
29
  // id it named was still perfectly valid; the exactness that matters is the
@@ -31,20 +31,25 @@ try {
31
31
  const prompt = String(data.prompt || '').trim();
32
32
  cleanup(projectRoot);
33
33
 
34
- if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
35
- if (!data.session_id) {
34
+ const shortApproval = new Set(['y', 'yes', 'có']).has(prompt.normalize('NFC').toLowerCase());
35
+ if (!shortApproval && !prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
36
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
36
37
  context('CafeKit privacy approval rejected: this hook invocation had no Codex session ID.');
37
38
  process.exit(0);
38
39
  }
39
- const requestId = prompt.slice(APPROVAL_PREFIX.length);
40
- if (`${APPROVAL_PREFIX}${requestId}` !== prompt || !/^[a-f0-9]{24}$/.test(requestId)) {
40
+ let requestId;
41
+ if (shortApproval) {
42
+ if (claimed.requestIds.length !== 1) process.exit(0);
43
+ requestId = claimed.requestIds[0];
44
+ } else requestId = prompt.slice(APPROVAL_PREFIX.length);
45
+ if (!/^[a-f0-9]{24}$/.test(requestId)) {
41
46
  context('CafeKit privacy approval rejected: the prompt did not exactly match a pending request.');
42
47
  process.exit(0);
43
48
  }
44
49
 
45
50
  const result = approvePending({
46
51
  projectRoot,
47
- sessionId: data.session_id,
52
+ sessionId,
48
53
  requestId
49
54
  });
50
55
  if (!result.ok) {
@@ -54,10 +59,16 @@ try {
54
59
 
55
60
  context(
56
61
  `User approved the pending sensitive access (${result.displayName}). ` +
57
- `The grant is session-bound, bound to exactly those paths, and expires in one hour.`
62
+ `The grant is session-bound, bound to exactly those paths, and expires in one hour. ` +
63
+ `Tell the user which paths were just granted (${result.displayName}) before reading them.`
58
64
  );
59
65
  process.exit(0);
60
66
  } catch {
67
+ try {
68
+ if (projectRoot && typeof sessionId === 'string' && sessionId.trim()) {
69
+ (state || require('./lib/privacy-state.cjs')).claimShortMarkers(projectRoot, sessionId);
70
+ }
71
+ } catch { /* no approval follows an invalidation failure */ }
61
72
  context('CafeKit privacy approval failed safely; no token was issued.');
62
73
  process.exit(0);
63
74
  }