@haposoft/cafekit 0.18.1 → 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 (93) 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 +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  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 +1 -1
  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-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -13,6 +13,8 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const { projectRoot } = require('./lib/project-root.cjs');
17
+ const { packetAt, namedPackets, explicitFailure } = require('./lib/spec-touch.cjs');
16
18
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
17
19
 
18
20
  function logCrash(error) {
@@ -40,44 +42,6 @@ function sessionIdentity(payload) {
40
42
  return candidates.find((value) => typeof value === 'string' && value.length > 0) || null;
41
43
  }
42
44
 
43
- function canonicalPath(value) {
44
- try { return fs.realpathSync(value); } catch { return null; }
45
- }
46
-
47
- /** Real path of `value`, resolving its deepest existing ancestor (the file may not exist yet). */
48
- function canonicalTarget(value) {
49
- if (typeof value !== 'string' || !value) return null;
50
- let current = path.resolve(value);
51
- const rest = [];
52
- while (!fs.existsSync(current)) {
53
- const parent = path.dirname(current);
54
- if (parent === current) return null;
55
- rest.unshift(path.basename(current));
56
- current = parent;
57
- }
58
- const real = canonicalPath(current);
59
- return real ? path.join(real, ...rest) : null;
60
- }
61
-
62
- function escapeRegExp(text) {
63
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
- }
65
-
66
- /**
67
- * Packets the prompt names: `specs/<name>`, a `/cf:<command> <name>` token, or the bare
68
- * name when it carries `-`, `_` or a digit. A plain word such as `docs` must use `specs/`,
69
- * so an ordinary sentence does not touch a packet.
70
- */
71
- function packetsNamedIn(text, names) {
72
- if (typeof text !== 'string' || !text) return [];
73
- return names.filter((name) => {
74
- const n = escapeRegExp(name);
75
- if (new RegExp(`(^|[^\\w-])specs/${n}(?![\\w-])`).test(text)) return true;
76
- if (new RegExp(`/cf:[\\w-]+\\s+(?:specs/)?${n}(?![\\w-])`).test(text)) return true;
77
- return /[-_0-9]/.test(name) && new RegExp(`(^|[^\\w/-])${n}(?![\\w-])`).test(text);
78
- });
79
- }
80
-
81
45
  try {
82
46
  // ── Main ──────────────────────────────────────────────────────────────────
83
47
 
@@ -94,6 +58,7 @@ try {
94
58
  let RECEIPT;
95
59
  try {
96
60
  POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
61
+ require(path.join(__dirname, '..', 'scripts', 'provenance.cjs')).enableSnapshotMemo();
97
62
  RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
98
63
  RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
99
64
  if (typeof RESOLVER.resolveWorkflowCandidate !== 'function') throw new Error('shared spec resolver has no resolveWorkflowCandidate function');
@@ -103,10 +68,12 @@ try {
103
68
  process.exit(0);
104
69
  }
105
70
 
71
+ const baseDir = projectRoot(payload);
72
+
106
73
  // Read runtime configuration if exists
107
74
  let runtime = {};
108
75
  try {
109
- const p = runtimePath(cwd, 'runtime.json');
76
+ const p = runtimePath(baseDir, 'runtime.json');
110
77
  if (fs.existsSync(p)) runtime = JSON.parse(fs.readFileSync(p, 'utf8'));
111
78
  } catch { /* ignore */ }
112
79
 
@@ -114,20 +81,17 @@ try {
114
81
  // (The Stop completion gate has its own toggle: spec.completion_gate.)
115
82
  if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
116
83
 
117
- // Same rule as spec-gate: an installed hook trusts its own location over the environment.
118
- const installedRoot = path.basename(path.resolve(__dirname, '..')).startsWith('.') ? path.resolve(__dirname, '..', '..') : null;
119
- const baseDir = installedRoot || process.env.PROJECT_ROOT || cwd;
120
84
  const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
121
- const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
85
+ const explicitPath = payload.explicitPath || payload.specPath || payload.spec_path || payload.featurePath || null;
122
86
 
123
- // ── Per-session touch set (Claude only) ───────────────────────────────────
124
- // Several sessions can share one checkout. On Claude the tollgate speaks only about a
125
- // packet this session touched. omp and Codex run their own copies, and grok sends
126
- // `sessionId` before normalization, so all three keep the old behaviour.
87
+ // ── Per-session touch set (Claude and omp) ────────────────────────────────
88
+ // Several sessions can share one checkout. On Claude and omp the tollgate speaks only
89
+ // about a packet this session touched. Codex runs its own copy, and grok sends
90
+ // `sessionId` before normalization, so both keep the old behaviour.
127
91
  const claudeSession = rawPayload && typeof rawPayload.session_id === 'string' && rawPayload.session_id
128
92
  ? rawPayload.session_id
129
93
  : null;
130
- const touchFilter = runtimeDirName() === '.claude' && claudeSession !== null;
94
+ const touchFilter = ['.claude', '.omp'].includes(runtimeDirName()) && claudeSession !== null;
131
95
  const touchFile = touchFilter
132
96
  ? path.join(
133
97
  require('./lib/hook-state-dir.cjs').hookStateDir(),
@@ -155,14 +119,9 @@ try {
155
119
 
156
120
  if (payload.hook_event_name === 'PostToolUse') {
157
121
  // Record only; no resolve, no git, no tollgate cache.
158
- if (touchFilter && specsRoot && /^(Edit|Write|MultiEdit)$/.test(payload.tool_name || '')) {
159
- const realRoot = canonicalPath(specsRoot);
160
- const realTarget = canonicalTarget(payload.tool_input && payload.tool_input.file_path);
161
- if (realRoot && realTarget) {
162
- const rel = path.relative(realRoot, realTarget);
163
- const parts = rel.split(path.sep);
164
- if (rel && parts[0] !== '..' && !path.isAbsolute(rel) && parts.length > 1) addTouched([parts[0]]);
165
- }
122
+ if (touchFilter && specsRoot && !explicitFailure(payload) && /^(Edit|Write|MultiEdit)$/.test(payload.tool_name || '')) {
123
+ const name = packetAt(payload.tool_input && payload.tool_input.file_path, specsRoot, cwd);
124
+ if (name) addTouched([name]);
166
125
  }
167
126
  process.exit(0);
168
127
  }
@@ -204,12 +163,29 @@ try {
204
163
  try {
205
164
  packets = fs.readdirSync(specsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
206
165
  } catch { /* no specs root */ }
207
- const named = packetsNamedIn(payload.prompt, packets);
166
+ const named = specsRoot ? namedPackets(payload.prompt || payload.user_prompt, packets, specsRoot, baseDir) : [];
208
167
  if (typeof explicitFeature === 'string' && explicitFeature) named.push(explicitFeature);
168
+ const fromPath = specsRoot ? packetAt(explicitPath, specsRoot, baseDir) : null;
169
+ if (fromPath) named.push(fromPath);
209
170
  if (named.length) addTouched(named);
210
171
  }
211
172
 
212
- const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
173
+ let resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
174
+ if (touchFilter && !explicitFeature && !explicitPath
175
+ && ['multiple_active', 'invalid_specs'].includes(resolved?.error)) {
176
+ const touched = readTouched();
177
+ const candidates = resolved.candidates || [];
178
+ if (!candidates.includes('<specs>') && !candidates.some(name => touched.has(name))) {
179
+ const selected = [...touched].map(name => RESOLVER.resolveWorkflowCandidate({
180
+ projectRoot: baseDir, runtime, explicitFeature: name,
181
+ }));
182
+ resolved = selected.find(candidate => candidate?.error);
183
+ const active = selected.filter(candidate => candidate && !candidate.error && !candidate.allTasksDone);
184
+ if (!resolved) resolved = active.length > 1
185
+ ? { error: 'multiple_active', candidates: active.map(candidate => candidate.featureName) }
186
+ : active[0] || null;
187
+ }
188
+ }
213
189
 
214
190
  if (!resolved) {
215
191
  process.exit(0); // No active spec, do nothing
@@ -299,7 +275,10 @@ try {
299
275
  done: taskCounts.done || 0,
300
276
  total: taskEntries.length,
301
277
  });
302
- const cacheFile = path.join(require('./lib/hook-state-dir.cjs').hookStateDir(), 'tollgate-last.txt');
278
+ const cacheName = touchFilter
279
+ ? `tollgate-last-${require('crypto').createHash('sha256').update(claudeSession).digest('hex').slice(0, 16)}.txt`
280
+ : 'tollgate-last.txt';
281
+ const cacheFile = path.join(require('./lib/hook-state-dir.cjs').hookStateDir(), cacheName);
303
282
 
304
283
  let lastKey = '';
305
284
  if (stateKey) {
@@ -335,12 +314,17 @@ try {
335
314
  }
336
315
  lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
337
316
  lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime only while a committed task file no longer matches its committed bytes.`);
317
+ lines.push('- Hooks revalidate receipt bytes but never grant approval.');
338
318
  lines.push('');
339
319
 
340
320
  console.log(lines.join('\n'));
341
321
  process.exit(0);
342
322
 
343
323
  } catch (e) {
324
+ if (e.code === 'no_git_history') {
325
+ console.log('Repo has no commits yet; create the first commit, then record Base/Head after it.');
326
+ process.exit(0);
327
+ }
344
328
  logCrash(e);
345
329
  emitControlledFailure(e.message);
346
330
  process.exit(0);
@@ -8,14 +8,26 @@
8
8
  *
9
9
  * Events:
10
10
  * SessionStart → load previous state and print to context
11
- * PostToolUse → refresh state after Task/TaskCreate/TaskUpdate/TodoWrite
11
+ * PostToolUse → refresh state after Agent/Task/TaskCreate/TaskUpdate/TodoWrite
12
12
  * Stop → persist full session state and archive
13
13
  * SubagentStop → append agent completion note to current state
14
14
  *
15
- * Storage: <runtime>/session-state/latest.md (+ archive/)
15
+ * Storage: <runtime>/session-state/[session hash/]latest.md (+ archive/)
16
16
  * Exit: 0 always (fail-open)
17
17
  */
18
18
 
19
+ function logCrash(error) {
20
+ try {
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const logDir = require('./lib/hook-state-dir.cjs').hookStateDir();
24
+ fs.mkdirSync(logDir, { recursive: true });
25
+ fs.appendFileSync(path.join(logDir, 'hook-log.jsonl'), JSON.stringify({
26
+ ts: new Date().toISOString(), hook: 'state', status: 'crash', error: error.message,
27
+ }) + '\n');
28
+ } catch (_) {}
29
+ }
30
+
19
31
  try {
20
32
  const fs = require('fs');
21
33
  const path = require('path');
@@ -23,34 +35,73 @@ try {
23
35
  const crypto = require('crypto');
24
36
  const { execSync } = require('child_process');
25
37
  const { parseTranscript } = require('./lib/parser.cjs');
38
+ const { projectRoot } = require('./lib/project-root.cjs');
39
+ const { containsSecretKeyword } = require('./lib/secret-keywords.cjs');
26
40
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
27
41
 
28
42
  const EXPIRY_DAYS = 7;
29
43
  const MAX_ARCHIVES = 5;
30
44
  const MAX_AGENT_SECTIONS = 3;
31
45
  const MAX_MODIFIED_FILES_DISPLAY = 5;
32
- const TRACKED_POST_TOOL_EVENTS = new Set(['Task', 'TaskCreate', 'TaskUpdate', 'TodoWrite']);
46
+ const TRACKED_POST_TOOL_EVENTS = new Set(['Agent', 'Task', 'TaskCreate', 'TaskUpdate', 'TodoWrite']);
47
+ const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
33
48
 
34
- function stateDir(cwd) {
49
+ function withStateLock(dir, callback) {
50
+ const lock = path.join(dir, '.write-lock');
51
+ const deadline = Date.now() + 3000;
52
+ while (Date.now() < deadline) {
53
+ try {
54
+ fs.mkdirSync(lock);
55
+ } catch (error) {
56
+ if (error.code !== 'EEXIST') return;
57
+ try {
58
+ if (Date.now() - fs.statSync(lock).mtimeMs > 15000) {
59
+ fs.rmSync(lock, { recursive: true, force: true });
60
+ continue;
61
+ }
62
+ } catch { continue; }
63
+ Atomics.wait(WAIT_BUFFER, 0, 0, 20);
64
+ continue;
65
+ }
66
+ try { return callback(); } finally {
67
+ try { fs.rmdirSync(lock); } catch { /* already released */ }
68
+ }
69
+ }
70
+ }
71
+
72
+ function assistantSummary(value) {
73
+ if (value === null || value === undefined) return '';
74
+ let text;
75
+ if (typeof value === 'object') {
76
+ const preferred = value.message ?? value.output ?? value.result ?? value.content;
77
+ text = typeof preferred === 'string' ? preferred : JSON.stringify(value);
78
+ } else {
79
+ text = String(value);
80
+ }
81
+ if (containsSecretKeyword(text)) return '[omitted: possible secret]';
82
+ const clean = text.replace(/\0/g, '').trim();
83
+ return clean.length > 600 ? `${clean.slice(0, 600)}…` : clean;
84
+ }
85
+
86
+ function stateDir(cwd, sessionId, create = true) {
35
87
  try {
36
- const local = runtimePath(cwd, 'session-state');
88
+ let dir;
37
89
  if (fs.existsSync(runtimeDir(cwd))) {
38
- if (!fs.existsSync(local)) fs.mkdirSync(local, { recursive: true });
39
- return local;
90
+ dir = runtimePath(cwd, 'session-state');
91
+ } else {
92
+ const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
93
+ dir = path.join(os.homedir(), '.claude', 'session-states', hash);
40
94
  }
41
-
42
- const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
43
- const global = path.join(os.homedir(), '.claude', 'session-states', hash);
44
- if (!fs.existsSync(global)) fs.mkdirSync(global, { recursive: true });
45
- return global;
95
+ if (sessionId) dir = path.join(dir, crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 16));
96
+ if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
97
+ return dir;
46
98
  } catch {
47
99
  return null;
48
100
  }
49
101
  }
50
102
 
51
- function loadLatest(cwd) {
103
+ function loadLatest(dir) {
52
104
  try {
53
- const dir = stateDir(cwd);
54
105
  if (!dir) return null;
55
106
 
56
107
  const file = path.join(dir, 'latest.md');
@@ -109,7 +160,8 @@ try {
109
160
  timestamp: new Date().toISOString(),
110
161
  branch: process.env.GIT_BRANCH || '',
111
162
  todos: [],
112
- modifiedFiles: []
163
+ modifiedFiles: [],
164
+ lastAssistant: assistantSummary(stdinData.last_assistant_message),
113
165
  };
114
166
 
115
167
  if (stdinData.transcript_path && fs.existsSync(stdinData.transcript_path)) {
@@ -178,6 +230,7 @@ try {
178
230
  "## What's Left",
179
231
  ...(pending.length ? pending.map((todo) => `- [ ] ${todo.content}`) : ['- (All tasks completed)']),
180
232
  '',
233
+ ...(data.lastAssistant ? ['## Latest Assistant Message', data.lastAssistant, ''] : []),
181
234
  '## Key Files Modified',
182
235
  ...(data.modifiedFiles.length ? data.modifiedFiles.map((file) => `- ${file}`) : ['- (No file changes detected)']),
183
236
  ''
@@ -186,9 +239,10 @@ try {
186
239
 
187
240
  function buildAgentSection(data) {
188
241
  const agentType = data.agent_type || '';
189
- if (!agentType || agentType === 'unknown') return '';
242
+ const message = assistantSummary(data.last_assistant_message);
243
+ if (!agentType || agentType === 'unknown') return message ? `\n## Latest Assistant Message\n${message}\n` : '';
190
244
  const time = new Date().toISOString().slice(11, 19);
191
- return `\n## Agent Result: ${agentType} (${time})\n- Completed at ${time}\n`;
245
+ return `\n## Agent Result: ${agentType} (${time})\n- ${message || `Completed at ${time}`}\n`;
192
246
  }
193
247
 
194
248
  function takeRecentAgentSections(sections) {
@@ -229,11 +283,13 @@ try {
229
283
  }
230
284
 
231
285
  async function persistSnapshot(dir, data, options = {}) {
232
- const file = path.join(dir, 'latest.md');
233
- const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
234
- const content = mergeAgentSections(existing, buildStateContent(data));
235
- writeAtomic(file, content);
236
- if (options.archive) archive(dir);
286
+ withStateLock(dir, () => {
287
+ const file = path.join(dir, 'latest.md');
288
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
289
+ const content = mergeAgentSections(existing, buildStateContent(data));
290
+ writeAtomic(file, content);
291
+ if (options.archive) archive(dir);
292
+ });
237
293
  }
238
294
 
239
295
  async function main() {
@@ -244,14 +300,14 @@ try {
244
300
  const rawPayload = JSON.parse(stdin);
245
301
  const data = normalizeHookPayload(rawPayload);
246
302
  const event = data.hook_event_name || '';
247
- const cwd = data.cwd || process.cwd();
248
- const dir = stateDir(cwd);
303
+ const cwd = projectRoot(data);
304
+ const claudeSession = ['.claude', '.omp'].includes(runtimeDirName())
305
+ && typeof rawPayload.session_id === 'string' && rawPayload.session_id.length > 0;
306
+ const dir = stateDir(cwd, claudeSession ? rawPayload.session_id : null, event !== 'SessionStart');
249
307
 
250
308
  if (event === 'SessionStart') {
251
- const previous = loadLatest(cwd);
252
- // Claude only: grok (camelCase sessionId), omp and Codex keep printing the block.
253
- const claudeSession = runtimeDirName() === '.claude'
254
- && typeof rawPayload.session_id === 'string' && rawPayload.session_id.length > 0;
309
+ const previous = loadLatest(dir);
310
+ // Claude and omp: grok (camelCase sessionId) and Codex keep printing the block.
255
311
  if (previous && (!claudeSession || hasPriorContent(previous))) {
256
312
  console.log('\n=== Prior Execution Context ===');
257
313
  console.log(previous.trim());
@@ -275,13 +331,15 @@ try {
275
331
  const agentSection = buildAgentSection(data);
276
332
  if (!agentSection) process.exit(0);
277
333
 
278
- const file = path.join(dir, 'latest.md');
279
- const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
280
- const updated = existing
281
- ? appendAgentSection(existing, agentSection)
282
- : `${buildStateContent(await extractSessionData(data))}\n${agentSection}`;
283
-
284
- writeAtomic(file, updated);
334
+ const sessionData = await extractSessionData(data);
335
+ withStateLock(dir, () => {
336
+ const file = path.join(dir, 'latest.md');
337
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
338
+ const updated = existing
339
+ ? appendAgentSection(existing, agentSection)
340
+ : `${buildStateContent(sessionData)}\n${agentSection}`;
341
+ writeAtomic(file, updated);
342
+ });
285
343
  process.exit(0);
286
344
  }
287
345
 
@@ -294,24 +352,11 @@ try {
294
352
  process.exit(0);
295
353
  }
296
354
 
297
- main().catch(() => {
355
+ main().catch((error) => {
356
+ logCrash(error);
298
357
  process.exit(0);
299
358
  });
300
359
  } catch (error) {
301
- try {
302
- const fs = require('fs');
303
- const path = require('path');
304
- const logDir = require('./lib/hook-state-dir.cjs').hookStateDir();
305
- if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
306
- fs.appendFileSync(
307
- path.join(logDir, 'hook-log.jsonl'),
308
- JSON.stringify({
309
- ts: new Date().toISOString(),
310
- hook: 'state',
311
- status: 'crash',
312
- error: error.message
313
- }) + '\n'
314
- );
315
- } catch (_) {}
360
+ logCrash(error);
316
361
  process.exit(0);
317
362
  }
@@ -143,7 +143,8 @@
143
143
  ".codex\\hooks\\semantic-review-authority.cjs",
144
144
  ".claude/hooks/task-scaffold-guard.cjs",
145
145
  ".codex/hooks/task-scaffold-guard.cjs",
146
- ".codex\\hooks\\task-scaffold-guard.cjs"
146
+ ".codex\\hooks\\task-scaffold-guard.cjs",
147
+ ".claude/hooks/inspector-block.cjs"
147
148
  ]
148
149
  },
149
150
  "runtime": {
@@ -170,8 +171,14 @@
170
171
  "hooks/lib/config.cjs",
171
172
  "hooks/lib/hook-state-dir.cjs",
172
173
  "hooks/lib/runtime-dir.cjs",
174
+ "hooks/lib/spec-touch.cjs",
175
+ "hooks/lib/project-root.cjs",
176
+ "hooks/lib/update-check.cjs",
177
+ "hooks/lib/rules-reservation.cjs",
173
178
  "hooks/lib/hook-payload.cjs",
179
+ "hooks/lib/inspect-paths.cjs",
174
180
  "hooks/lib/privacy-command-analysis.cjs",
181
+ "hooks/lib/privacy-paths.cjs",
175
182
  "hooks/lib/secret-keywords.cjs",
176
183
  "hooks/lib/runtime-path-safety.cjs"
177
184
  ]
@@ -24,5 +24,5 @@ Never use another command, path, encoding, or side channel to access a privacy-b
24
24
 
25
25
  - Treat hook errors as blockers when they affect safety, privacy, or task state.
26
26
  - Record the blocker in the task/spec state when relevant.
27
- - If the hook output is malformed, stop and ask for clarification instead of guessing.
27
+ - If hook output is malformed, stop and report the exact failure instead of guessing.
28
28
 
@@ -26,9 +26,6 @@
26
26
  "thinkingLanguage": null,
27
27
  "responseLanguage": null
28
28
  },
29
- "usage": {
30
- "enabled": true
31
- },
32
29
  "hooks": {},
33
30
  "assertions": []
34
31
  }
@@ -27,7 +27,7 @@ function usage() {
27
27
  console.log(`Usage:
28
28
  node <runtime>/scripts/generate-skill-catalog.cjs [--skills] [--json] [--root <skills-dir>]
29
29
 
30
- The default root is bound to the script runtime: .claude/skills for Claude,
30
+ The default root is bound to the script runtime: the Claude runtime skills directory for Claude,
31
31
  .agents/skills for Codex, and ../skills for the source tree.`);
32
32
  }
33
33
 
@@ -192,7 +192,7 @@ function renderMarkdown(catalog) {
192
192
  const lines = ['# CafeKit Skills Catalog', '', `Skills root: \`${catalog.root}\``,
193
193
  `Routable skills: ${catalog.skills.length}`, ''];
194
194
  for (const skill of catalog.skills) {
195
- const intent = skill.when_to_use ? ` Use: ${skill.when_to_use}` : '';
195
+ const intent = skill.when_to_use && !skill.description.includes(skill.when_to_use) ? ` Use: ${skill.when_to_use}` : '';
196
196
  lines.push(`- \`${skill.name}\` (${skill.directory}, ${skill.category}): ${skill.description}${intent}`);
197
197
  }
198
198
  if (catalog.diagnostics.length > 0) {
@@ -84,6 +84,19 @@ function oneLine(value, label) {
84
84
  }
85
85
 
86
86
  function gitBase(root, specsRoot) {
87
+ const symbolic = spawnSync('git', ['-C', root, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'buffer' });
88
+ if (!symbolic.error && !symbolic.signal && symbolic.status === 0) {
89
+ const ref = oneLine(symbolic.stdout, 'git symbolic-ref HEAD');
90
+ const exists = spawnSync('git', ['-C', root, 'show-ref', '--verify', '-q', ref], { encoding: 'buffer' });
91
+ if (!exists.error && !exists.signal && exists.status === 1) {
92
+ fail('no_git_history', 'repo has no commits yet; create the first commit, then record Base/Head after it');
93
+ }
94
+ if (exists.error || exists.signal || exists.status !== 0) {
95
+ fail('git_command_failed', `git show-ref HEAD failed: ${exists.error?.message || String(exists.stderr || '').trim() || `exit ${exists.status}`}`);
96
+ }
97
+ } else if (symbolic.error || symbolic.signal || symbolic.status !== 1) {
98
+ fail('git_command_failed', `git symbolic-ref HEAD failed: ${symbolic.error?.message || String(symbolic.stderr || '').trim() || `exit ${symbolic.status}`}`);
99
+ }
87
100
  const specsRelative = path.relative(root, specsRoot).split(path.sep).join('/');
88
101
  const sourceHistory = decode(runGit(
89
102
  root,
@@ -156,6 +169,7 @@ const RUNTIME_STATE_ROOTS = Object.freeze([
156
169
  '.claude/hooks/.logs', '.codex/hooks/.logs', '.omp/hooks/.logs',
157
170
  '.claude/.logs', '.codex/.logs', '.omp/.logs',
158
171
  '.claude/runtime.json', '.codex/runtime.json', '.omp/runtime.json',
172
+ '.codex/session-state', '.codex/hooks/.privacy',
159
173
  ]);
160
174
 
161
175
  function excluded(root, absolute, specsRoot) {
@@ -63,7 +63,7 @@
63
63
  ],
64
64
  "PreToolUse": [
65
65
  {
66
- "matcher": "Read|Write|Edit|MultiEdit|Bash|Glob|Grep",
66
+ "matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Bash|Glob|Grep|mcp__.*",
67
67
  "hooks": [
68
68
  {
69
69
  "type": "command",
@@ -5,7 +5,6 @@ Validate ai-multimodal skill setup and configuration.
5
5
  Checks:
6
6
  - API key presence and format
7
7
  - Python dependencies
8
- - Centralized resolver availability
9
8
  - Directory structure
10
9
  """
11
10
 
@@ -85,69 +84,16 @@ def check_dependencies():
85
84
  return True
86
85
 
87
86
 
88
- def check_centralized_resolver():
89
- """Check if centralized resolver is available."""
90
- print_header("Checking Centralized Resolver")
91
-
92
- claude_root = Path(__file__).parent.parent.parent.parent
93
- resolver_path = claude_root / 'scripts' / 'resolve_env.py'
94
-
95
- if resolver_path.exists():
96
- print_success(f"Centralized resolver found: {resolver_path}")
97
-
98
- # Try to import it
99
- sys.path.insert(0, str(resolver_path.parent))
100
- try:
101
- from resolve_env import resolve_env
102
- print_success("Centralized resolver can be imported")
103
- return True
104
- except ImportError as e:
105
- print_error(f"Centralized resolver exists but cannot be imported: {e}")
106
- return False
107
- else:
108
- print_warning(f"Centralized resolver not found: {resolver_path}")
109
- print_info("Skill will use fallback resolution logic")
110
- return True # Not critical, fallback works
111
-
112
-
113
87
  def find_api_key():
114
- """Find and validate API key using centralized resolver."""
88
+ """Find the API key in the runtime environment."""
115
89
  print_header("Checking API Key Configuration")
116
-
117
- # Try to use centralized resolver
118
- claude_root = Path(__file__).parent.parent.parent.parent
119
- sys.path.insert(0, str(claude_root / 'scripts'))
120
- try:
121
- from resolve_env import resolve_env
122
-
123
- print_info("Using centralized resolver...")
124
- api_key = resolve_env('GEMINI_API_KEY', skill='ai-multimodal')
125
-
126
- if api_key:
127
- print_success("API key found via centralized resolver")
128
- print_info(f"Key preview: {api_key[:20]}...{api_key[-4:]}")
129
-
130
- # Show hierarchy
131
- print_info("\nTo see where the key was found, run:")
132
- print_info("python ~/packages/spec/src/claude/scripts/resolve_env.py GEMINI_API_KEY --skill ai-multimodal --verbose")
133
-
134
- return api_key
135
- else:
136
- print_error("API key not found in any location")
137
- return None
138
-
139
- except ImportError:
140
- print_warning("Centralized resolver not available, using fallback")
141
-
142
- # Fallback: check environment
143
- api_key = os.getenv('GEMINI_API_KEY')
144
- if api_key:
145
- print_success("API key found in process.env")
146
- print_info(f"Key preview: {api_key[:20]}...{api_key[-4:]}")
147
- return api_key
148
- else:
149
- print_error("API key not found")
150
- return None
90
+ api_key = os.getenv('GEMINI_API_KEY')
91
+ if api_key:
92
+ print_success("API key found in process.env")
93
+ print_info(f"Key preview: {api_key[:20]}...{api_key[-4:]}")
94
+ return api_key
95
+ print_error("API key not found")
96
+ return None
151
97
 
152
98
 
153
99
  def validate_api_key_format(api_key):
@@ -250,9 +196,6 @@ def provide_setup_instructions():
250
196
  print("\n3. Verify setup:")
251
197
  print(f" $ python {Path(__file__)}")
252
198
 
253
- print("\n4. Debug if needed:")
254
- print(f" $ python ~/packages/spec/src/claude/scripts/resolve_env.py --show-hierarchy --skill ai-multimodal")
255
- print(f" $ python ~/packages/spec/src/claude/scripts/resolve_env.py GEMINI_API_KEY --skill ai-multimodal --verbose")
256
199
 
257
200
 
258
201
  def main():
@@ -265,9 +208,6 @@ def main():
265
208
  if not check_directory_structure():
266
209
  all_passed = False
267
210
 
268
- # Check centralized resolver
269
- check_centralized_resolver()
270
-
271
211
  # Check dependencies
272
212
  if not check_dependencies():
273
213
  all_passed = False