@haposoft/cafekit 0.18.1 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -11,11 +11,24 @@
11
11
  * Exit: 0 always (fail-open)
12
12
  */
13
13
 
14
+ function logCrash(error) {
15
+ try {
16
+ const fs = require('fs'), path = require('path');
17
+ const dir = require('./lib/hook-state-dir.cjs').hookStateDir();
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ fs.appendFileSync(path.join(dir, 'hook-log.jsonl'),
20
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'session', status: 'crash', error: error.message }) + '\n');
21
+ } catch (_) {}
22
+ }
23
+
14
24
  try {
15
25
  const fs = require('fs');
16
26
  const path = require('path');
17
27
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
18
28
  const os = require('os');
29
+ const { projectRoot } = require('./lib/project-root.cjs');
30
+ const { checkCafeKitUpdate } = require('./lib/update-check.cjs');
31
+ const { clearSessionReservation } = require('./lib/rules-reservation.cjs');
19
32
  const { execSync } = require('child_process');
20
33
 
21
34
  // ── Utilities ─────────────────────────────────────────────────────────────
@@ -41,21 +54,6 @@ try {
41
54
  } catch { /* fail-open */ }
42
55
  }
43
56
 
44
- function projectRoot() {
45
- const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
46
- ? process.env.CLAUDE_PROJECT_DIR.trim()
47
- : '';
48
- if (configured) {
49
- try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
50
- }
51
-
52
- const installedRoot = path.resolve(__dirname, '..', '..');
53
- const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
54
- if (fs.existsSync(installedHook)) return installedRoot;
55
-
56
- try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
57
- }
58
-
59
57
  /** Read <runtime>/runtime.json config */
60
58
  function readRuntime(cwd) {
61
59
  try {
@@ -104,94 +102,6 @@ try {
104
102
  return '';
105
103
  }
106
104
 
107
- // ── CafeKit Update Check ──────────────────────────────────────────────────
108
-
109
- /**
110
- * Fetch the latest published version of @haposoft/cafekit from the npm registry.
111
- * Returns a string (e.g. "0.11.2") or null on any failure. Timeout: 3 s.
112
- */
113
- function fetchLatestVersion() {
114
- try {
115
- const https = require('https');
116
- return new Promise((resolve) => {
117
- const req = https.get(
118
- 'https://registry.npmjs.org/@haposoft/cafekit/latest',
119
- { timeout: 3000, headers: { Accept: 'application/json' } },
120
- (res) => {
121
- let body = '';
122
- res.on('data', (chunk) => { body += chunk; });
123
- res.on('end', () => {
124
- try { resolve(JSON.parse(body).version || null); }
125
- catch { resolve(null); }
126
- });
127
- }
128
- );
129
- req.on('error', () => resolve(null));
130
- req.on('timeout', () => { req.destroy(); resolve(null); });
131
- });
132
- } catch { return Promise.resolve(null); }
133
- }
134
-
135
- /** Simple semver compare: returns true when b > a. */
136
- function isNewer(a, b) {
137
- const n = (v) => String(v).split('.').map((x) => parseInt(x, 10) || 0);
138
- const [aN, bN] = [n(a), n(b)];
139
- for (let i = 0; i < 3; i++) {
140
- if (bN[i] > aN[i]) return true;
141
- if (bN[i] < aN[i]) return false;
142
- }
143
- return false;
144
- }
145
-
146
- /**
147
- * Read the installed CafeKit version from <runtime>/cafekit.json.
148
- * Returns null if not found.
149
- */
150
- function getInstalledVersion(cwd) {
151
- try {
152
- const p = runtimePath(cwd, 'cafekit.json');
153
- if (!fs.existsSync(p)) return null;
154
- const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
155
- return typeof meta.version === 'string' ? meta.version : null;
156
- } catch { return null; }
157
- }
158
-
159
- /**
160
- * Check for a newer CafeKit version and return an update notice string,
161
- * or an empty string if already up to date or check fails.
162
- * Result is cached in <runtime>/.cafekit-update-cache.json for 12 hours.
163
- */
164
- async function checkCafeKitUpdate(cwd) {
165
- const installed = getInstalledVersion(cwd);
166
- if (!installed) return '';
167
-
168
- const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
169
- const cachePath = runtimePath(cwd, '.cafekit-update-cache.json');
170
-
171
- // Return cached result if still fresh.
172
- try {
173
- if (fs.existsSync(cachePath)) {
174
- const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
175
- if (Date.now() - (cache.ts || 0) < CACHE_TTL) {
176
- return cache.notice || '';
177
- }
178
- }
179
- } catch { /* stale or corrupt — refetch */ }
180
-
181
- // Fetch from npm registry.
182
- const latest = await fetchLatestVersion();
183
- const notice = (latest && isNewer(installed, latest))
184
- ? `\n⚡ CafeKit update available: ${installed} → ${latest}\n Run: npx @haposoft/cafekit\n`
185
- : '';
186
-
187
- // Persist cache (fail-open if write fails).
188
- try {
189
- fs.writeFileSync(cachePath, JSON.stringify({ ts: Date.now(), notice }), 'utf8');
190
- } catch { /* fail-open */ }
191
-
192
- return notice;
193
- }
194
-
195
105
  // ── Main ──────────────────────────────────────────────────────────────────
196
106
 
197
107
  (async () => {
@@ -199,12 +109,16 @@ try {
199
109
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
200
110
  const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
201
111
  const source = payload.source || 'unknown';
112
+ if (source === 'compact') clearSessionReservation(payload.session_id);
202
113
  const envFile = process.env.CLAUDE_ENV_FILE;
203
- const cwd = projectRoot();
114
+ const cwd = projectRoot(payload);
204
115
  const runtime = readRuntime(cwd);
205
116
 
206
117
  // Check CafeKit update in parallel with project detection (async, fail-open).
207
- const updateCheckPromise = checkCafeKitUpdate(cwd);
118
+ // The plugin is updated by its marketplace, so an npx notice would be wrong there.
119
+ const updateCheckPromise = process.env.CLAUDE_PLUGIN_ROOT
120
+ ? Promise.resolve('')
121
+ : checkCafeKitUpdate(runtimeDir(cwd)).catch(() => '');
208
122
 
209
123
  // Project detection
210
124
  const projectType = runtime.project?.type !== 'auto'
@@ -221,7 +135,6 @@ try {
221
135
 
222
136
  // Static environment
223
137
  const gitBranch = run('git branch --show-current', '', cwd);
224
- const gitUrl = run('git remote get-url origin', '', cwd);
225
138
  const gitRoot = run('git rev-parse --show-toplevel', '', cwd);
226
139
  const nodeVer = process.version;
227
140
  const pythonVer = run('python3 --version', '', cwd) || run('python --version', '', cwd);
@@ -235,7 +148,6 @@ try {
235
148
  writeEnv(envFile, 'PACKAGE_MANAGER', packageManager);
236
149
  writeEnv(envFile, 'FRAMEWORK', framework);
237
150
  writeEnv(envFile, 'GIT_BRANCH', gitBranch);
238
- writeEnv(envFile, 'GIT_URL', gitUrl);
239
151
  writeEnv(envFile, 'GIT_ROOT', gitRoot);
240
152
  writeEnv(envFile, 'NODE_VERSION', nodeVer);
241
153
  writeEnv(envFile, 'PYTHON_VERSION', pythonVer);
@@ -262,6 +174,14 @@ try {
262
174
 
263
175
  console.log(`Session ${source}. ${parts.length ? parts.join(' | ') : 'No project info detected.'}${updateNotice}`);
264
176
 
177
+ if (typeof payload.cwd === 'string' && payload.cwd) {
178
+ let sessionCwd = path.resolve(payload.cwd);
179
+ try { sessionCwd = fs.realpathSync(sessionCwd); } catch { /* lexical fallback */ }
180
+ if (sessionCwd !== cwd) {
181
+ console.log(`CafeKit project root: ${cwd} (session working directory: ${sessionCwd}).`);
182
+ }
183
+ }
184
+
265
185
  // Compact warning — context compaction can lose pending approval state
266
186
  if (source === 'compact') {
267
187
  console.log('\n🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:');
@@ -274,8 +194,11 @@ try {
274
194
  // bearings, and nothing more.
275
195
  try {
276
196
  const fs2 = require('fs'), p2 = require('path');
277
- const file = p2.join(require('./lib/hook-state-dir.cjs').hookStateDir(), 'compact-recovery.json');
197
+ if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) process.exit(0);
198
+ const key = require('crypto').createHash('sha256').update(payload.session_id).digest('hex');
199
+ const file = p2.join(require('./lib/hook-state-dir.cjs').hookStateDir(), `compact-recovery-${key}.json`);
278
200
  const record = JSON.parse(fs2.readFileSync(file, 'utf8'));
201
+ if (record.session_id !== payload.session_id) process.exit(0);
279
202
  const parts = [];
280
203
  if (record.branch) parts.push(`branch ${record.branch}`);
281
204
  if (record.head) parts.push(`HEAD ${record.head}`);
@@ -297,15 +220,12 @@ try {
297
220
  }
298
221
 
299
222
  process.exit(0);
300
- })(); // end async IIFE
223
+ })().catch((error) => {
224
+ logCrash(error);
225
+ process.exit(0);
226
+ });
301
227
 
302
- } catch (e) {
303
- try {
304
- const fs = require('fs'), p = require('path');
305
- const d = require('./lib/hook-state-dir.cjs').hookStateDir();
306
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
307
- fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
308
- JSON.stringify({ ts: new Date().toISOString(), hook: 'session', status: 'crash', error: e.message }) + '\n');
309
- } catch (_) {}
228
+ } catch (error) {
229
+ logCrash(error);
310
230
  process.exit(0);
311
231
  }
@@ -16,32 +16,7 @@
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
19
-
20
- function projectRoot(payload = {}) {
21
- const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
22
- ? process.env.CLAUDE_PROJECT_DIR.trim()
23
- : '';
24
- if (configured) {
25
- try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
26
- }
27
-
28
- const installedRoot = path.resolve(__dirname, '..', '..');
29
- const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
30
- if (fs.existsSync(installedHook)) return installedRoot;
31
-
32
- const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
33
- ? process.env.PROJECT_ROOT.trim()
34
- : '';
35
- if (sourceFixture) {
36
- try { return fs.realpathSync(path.resolve(sourceFixture)); } catch { /* continue */ }
37
- }
38
-
39
- const legacy = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
40
- if (legacy) {
41
- try { return fs.realpathSync(path.resolve(legacy)); } catch { /* continue */ }
42
- }
43
- try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
44
- }
19
+ const { projectRoot } = require('./lib/project-root.cjs');
45
20
 
46
21
  function logCrash(error) {
47
22
  try {
@@ -68,6 +43,7 @@ function sessionIdentity(payload) {
68
43
  return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
69
44
  }
70
45
 
46
+ let hasDoneTasks = false;
71
47
  try {
72
48
  const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
73
49
 
@@ -132,6 +108,8 @@ try {
132
108
  }
133
109
  if (!resolved) process.exit(0);
134
110
  if (resolved.layoutKind === 'process-v3-completed-set') {
111
+ hasDoneTasks = resolved.candidates.some(candidate =>
112
+ Object.values(candidate.taskRegistry || {}).some(task => task?.status === 'done'));
135
113
  const configuredGate = runtime.spec?.completion_gate;
136
114
  if (configuredGate !== undefined && configuredGate !== true) {
137
115
  emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
@@ -178,6 +156,7 @@ try {
178
156
 
179
157
  const featureName = resolved.featureName;
180
158
  const specsPath = resolved.specsDir;
159
+ hasDoneTasks = Object.values(resolved.taskRegistry || {}).some(task => task?.status === 'done');
181
160
  const runtimeContext = POLICY.deriveRuntimeContext({
182
161
  projectRoot: baseDir,
183
162
  specsRoot: specsPath,
@@ -250,6 +229,10 @@ try {
250
229
  process.exit(0);
251
230
 
252
231
  } catch (e) {
232
+ if (e.code === 'no_git_history') {
233
+ if (hasDoneTasks) emitBlock('Completion gate: repo has no commits yet; create the first commit, then record Base/Head after it.');
234
+ process.exit(0);
235
+ }
253
236
  logCrash(e);
254
237
  emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
255
238
  process.exit(0);
@@ -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, touchedPaths } = 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,10 @@ 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
+ // omp's ast_edit keeps its own name: aliasing it to Edit would lift the inspect scope check.
123
+ if (touchFilter && specsRoot && !explicitFailure(payload) && /^(Edit|Write|MultiEdit|ast_edit)$/.test(payload.tool_name || '')) {
124
+ const names = touchedPaths(payload).map((value) => packetAt(value, specsRoot, cwd)).filter(Boolean);
125
+ if (names.length) addTouched(names);
166
126
  }
167
127
  process.exit(0);
168
128
  }
@@ -204,12 +164,29 @@ try {
204
164
  try {
205
165
  packets = fs.readdirSync(specsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
206
166
  } catch { /* no specs root */ }
207
- const named = packetsNamedIn(payload.prompt, packets);
167
+ const named = specsRoot ? namedPackets(payload.prompt || payload.user_prompt, packets, specsRoot, baseDir) : [];
208
168
  if (typeof explicitFeature === 'string' && explicitFeature) named.push(explicitFeature);
169
+ const fromPath = specsRoot ? packetAt(explicitPath, specsRoot, baseDir) : null;
170
+ if (fromPath) named.push(fromPath);
209
171
  if (named.length) addTouched(named);
210
172
  }
211
173
 
212
- const resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
174
+ let resolved = RESOLVER.resolveWorkflowCandidate({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
175
+ if (touchFilter && !explicitFeature && !explicitPath
176
+ && ['multiple_active', 'invalid_specs'].includes(resolved?.error)) {
177
+ const touched = readTouched();
178
+ const candidates = resolved.candidates || [];
179
+ if (!candidates.includes('<specs>') && !candidates.some(name => touched.has(name))) {
180
+ const selected = [...touched].map(name => RESOLVER.resolveWorkflowCandidate({
181
+ projectRoot: baseDir, runtime, explicitFeature: name,
182
+ }));
183
+ resolved = selected.find(candidate => candidate?.error);
184
+ const active = selected.filter(candidate => candidate && !candidate.error && !candidate.allTasksDone);
185
+ if (!resolved) resolved = active.length > 1
186
+ ? { error: 'multiple_active', candidates: active.map(candidate => candidate.featureName) }
187
+ : active[0] || null;
188
+ }
189
+ }
213
190
 
214
191
  if (!resolved) {
215
192
  process.exit(0); // No active spec, do nothing
@@ -299,7 +276,10 @@ try {
299
276
  done: taskCounts.done || 0,
300
277
  total: taskEntries.length,
301
278
  });
302
- const cacheFile = path.join(require('./lib/hook-state-dir.cjs').hookStateDir(), 'tollgate-last.txt');
279
+ const cacheName = touchFilter
280
+ ? `tollgate-last-${require('crypto').createHash('sha256').update(claudeSession).digest('hex').slice(0, 16)}.txt`
281
+ : 'tollgate-last.txt';
282
+ const cacheFile = path.join(require('./lib/hook-state-dir.cjs').hookStateDir(), cacheName);
303
283
 
304
284
  let lastKey = '';
305
285
  if (stateKey) {
@@ -335,12 +315,17 @@ try {
335
315
  }
336
316
  lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
337
317
  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.`);
318
+ lines.push('- Hooks revalidate receipt bytes but never grant approval.');
338
319
  lines.push('');
339
320
 
340
321
  console.log(lines.join('\n'));
341
322
  process.exit(0);
342
323
 
343
324
  } catch (e) {
325
+ if (e.code === 'no_git_history') {
326
+ console.log('Repo has no commits yet; create the first commit, then record Base/Head after it.');
327
+ process.exit(0);
328
+ }
344
329
  logCrash(e);
345
330
  emitControlledFailure(e.message);
346
331
  process.exit(0);