@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
@@ -2,8 +2,8 @@
2
2
 
3
3
  // spec-state injects the active packet's tollgate on every prompt. With several sessions in
4
4
  // one checkout, a Claude session kept seeing a packet another session was working on. On
5
- // Claude the hook now speaks only about packets this session touched; omp, grok and
6
- // payloads without a Claude session id keep the old behaviour. Each case names the failure
5
+ // Claude and omp the hook now speaks only about packets this session touched; grok and
6
+ // payloads without a session id keep the old behaviour. Each case names the failure
7
7
  // it prevents.
8
8
 
9
9
  const { test } = require('node:test');
@@ -12,6 +12,7 @@ const { spawnSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
+ const crypto = require('node:crypto');
15
16
 
16
17
  const CLAUDE_ROOT = path.join(__dirname, '..', '..');
17
18
 
@@ -36,7 +37,7 @@ function fixture(packets, runtimeDir = '.claude') {
36
37
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
37
38
  fs.mkdirSync(scripts, { recursive: true });
38
39
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
39
- for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
40
+ for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'spec-touch.cjs']) {
40
41
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
41
42
  }
42
43
  for (const file of ['provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'workflow-policy.cjs']) {
@@ -48,23 +49,61 @@ function fixture(packets, runtimeDir = '.claude') {
48
49
  const git = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
49
50
  assert.strictEqual(git.status, 0, git.stderr);
50
51
  }
52
+ let lastSession = null;
51
53
  const run = (payload) => {
54
+ lastSession = payload.session_id;
55
+ const env = { ...process.env };
56
+ delete env.CLAUDE_PROJECT_DIR;
57
+ delete env.PROJECT_ROOT;
52
58
  const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
53
- cwd: root, env: { ...process.env, PROJECT_ROOT: root },
59
+ cwd: root, env,
54
60
  input: JSON.stringify({ cwd: root, ...payload }), encoding: 'utf8',
55
61
  });
56
62
  assert.strictEqual(result.status, 0, result.stderr);
57
63
  return result.stdout.trim();
58
64
  };
59
65
  const prompt = (session, text) => run({ hook_event_name: 'UserPromptSubmit', session_id: session, prompt: text });
60
- const cacheFile = path.join(hooks, '.logs', 'tollgate-last.txt');
61
- return { root, hooks, run, prompt, cacheFile, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
66
+ return {
67
+ root, hooks, run, prompt,
68
+ get cacheFile() {
69
+ const name = ['.claude', '.omp'].includes(runtimeDir) && typeof lastSession === 'string' && lastSession
70
+ ? `tollgate-last-${crypto.createHash('sha256').update(lastSession).digest('hex').slice(0, 16)}.txt`
71
+ : 'tollgate-last.txt';
72
+ return path.join(hooks, '.logs', name);
73
+ },
74
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
75
+ };
62
76
  }
63
77
 
64
78
  function within(fx, fn) {
65
79
  try { return fn(fx); } finally { fx.cleanup(); }
66
80
  }
67
81
 
82
+ test('Claude spec-state says receipt revalidation never grants approval', () => {
83
+ within(fixture(['auth-flow']), fx => {
84
+ assert.match(fx.prompt('message-session', 'specs/auth-flow'), /^- Hooks revalidate receipt bytes but never grant approval\.$/m);
85
+ });
86
+ });
87
+
88
+ test('Claude subagent rules include the shared simplicity and reporting instructions', () => {
89
+ within(fixture([]), fx => {
90
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks/agent.cjs'), path.join(fx.hooks, 'agent.cjs'));
91
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), '{}');
92
+ const env = { ...process.env };
93
+ delete env.CLAUDE_PROJECT_DIR;
94
+ delete env.PROJECT_ROOT;
95
+ const result = spawnSync(process.execPath, [path.join(fx.hooks, 'agent.cjs')], {
96
+ cwd: fx.root, env, encoding: 'utf8', input: JSON.stringify({ cwd: fx.root, hook_event_name: 'SubagentStart', agent_type: 'checker' }),
97
+ });
98
+ assert.strictEqual(result.status, 0, result.stderr);
99
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
100
+ const rules = context.split('## Rules\n')[1];
101
+ assert.ok(rules, 'the rules section must be present');
102
+ assert.match(rules, /^- YAGNI · KISS · DRY$/m);
103
+ assert.match(rules, /^- Be concise\. List unresolved questions at end\.$/m);
104
+ });
105
+ });
106
+
68
107
  test('an untouched packet prints nothing on Claude', () => {
69
108
  within(fixture(['auth-flow']), (fx) => {
70
109
  assert.strictEqual(fx.prompt('s1', 'Continue'), '');
@@ -72,6 +111,58 @@ test('an untouched packet prints nothing on Claude', () => {
72
111
  });
73
112
  });
74
113
 
114
+ test('a touched packet respects the project tollgate toggle from a nested cwd', () => {
115
+ within(fixture(['auth-flow']), fx => {
116
+ assert.match(fx.prompt('s1', 'specs/auth-flow'), /Spec state changed/);
117
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ spec: { tollgate: false } }));
118
+ const cwd = path.join(fx.root, 'sub/dir');
119
+ fs.mkdirSync(cwd, { recursive: true });
120
+ assert.strictEqual(fx.run({ cwd, session_id: 's1', hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), '');
121
+ });
122
+ });
123
+
124
+ test('an explicitly failed edit does not touch its packet', () => {
125
+ within(fixture(['auth-flow']), fx => {
126
+ fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Edit',
127
+ tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { is_error: true } });
128
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
129
+ });
130
+ });
131
+
132
+ test('written content mentioning failure still touches its packet', () => {
133
+ within(fixture(['auth-flow']), fx => {
134
+ fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Write',
135
+ tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { content: 'Failed x' } });
136
+ assert.match(fx.prompt('s1', 'Continue'), /Spec state changed/);
137
+ });
138
+ });
139
+
140
+ test('a configured specs prefix names a plain lowercase packet', () => {
141
+ within(fixture(['auth']), fx => {
142
+ fs.renameSync(path.join(fx.root, 'specs'), path.join(fx.root, 'planning'));
143
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: 'planning' } }));
144
+ assert.match(fx.prompt('s1', 'Continue planning/auth'), /Spec state changed: `auth`/);
145
+ });
146
+ });
147
+
148
+ test('an explicit packet path records the session touch', () => {
149
+ within(fixture(['auth']), fx => {
150
+ assert.match(fx.run({ session_id: 's1', hook_event_name: 'UserPromptSubmit',
151
+ specPath: path.join(fs.realpathSync(fx.root), 'specs/auth'), prompt: 'Continue' }), /Spec state changed: `auth`/);
152
+ assert.match(fx.prompt('s1', 'Continue'), /Tollgate active/);
153
+ });
154
+ });
155
+
156
+ test('another session does not displace the compact tollgate cache', () => {
157
+ within(fixture(['auth-flow']), fx => {
158
+ assert.match(fx.prompt('session-a', 'specs/auth-flow'), /Spec state changed/);
159
+ assert.match(fx.prompt('session-b', 'specs/auth-flow'), /Spec state changed/);
160
+ const again = fx.prompt('session-a', 'Continue');
161
+ assert.match(again, /Tollgate active/);
162
+ assert.doesNotMatch(again, /Spec state changed/);
163
+ });
164
+ });
165
+
75
166
  test('naming the packet in the prompt touches it and keeps the full block', () => {
76
167
  within(fixture(['auth-flow']), (fx) => {
77
168
  assert.strictEqual(fx.prompt('s1', 'Continue'), '');
@@ -113,10 +204,15 @@ test("another session's touch does not leak", () => {
113
204
  });
114
205
  });
115
206
 
116
- test('omp, grok and session-less payloads keep the old behaviour', () => {
207
+ test('omp filters by touch like Claude', () => {
117
208
  within(fixture(['auth-flow'], '.omp'), (fx) => {
118
- assert.match(fx.prompt('s5', 'Continue'), /Spec state changed/, 'omp is unchanged');
209
+ assert.strictEqual(fx.prompt('s5', 'Continue'), '', 'an untouched packet is silent under omp');
210
+ assert.match(fx.prompt('s5', 'specs/auth-flow'), /Spec state changed/);
211
+ assert.strictEqual(fx.prompt('s5b', 'Continue'), '', "another omp session's touch does not leak");
119
212
  });
213
+ });
214
+
215
+ test('grok and session-less payloads keep the old behaviour', () => {
120
216
  within(fixture(['auth-flow']), (fx) => {
121
217
  assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', sessionId: 's6', prompt: 'Continue' }), /Spec/, 'grok sends sessionId');
122
218
  });
@@ -133,6 +229,53 @@ test('a prompt touch is remembered even when two packets are active', () => {
133
229
  });
134
230
  });
135
231
 
232
+ test('an untouched session stays quiet with two active packets', () => {
233
+ within(fixture(['auth-flow', 'billing-2']), (fx) => {
234
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
235
+ });
236
+ });
237
+
238
+ function invalidateTask(root, feature) {
239
+ fs.writeFileSync(path.join(root, 'specs', feature, 'task-01-work.md'),
240
+ '# Task 01: broken\n\n## Verification Plan\n\n- Command: node --test\n');
241
+ }
242
+
243
+ test('an untouched session stays quiet when a packet is invalid', () => {
244
+ within(fixture(['broken-flow']), (fx) => {
245
+ invalidateTask(fx.root, 'broken-flow');
246
+ assert.strictEqual(fx.prompt('s1', 'Continue'), '');
247
+ });
248
+ });
249
+
250
+ test('touching an invalid packet still prints its warning', () => {
251
+ within(fixture(['broken-flow']), (fx) => {
252
+ invalidateTask(fx.root, 'broken-flow');
253
+ assert.match(fx.prompt('s1', 'Continue specs/broken-flow'), /Invalid workflow packet/);
254
+ });
255
+ });
256
+
257
+ test('a session-less prompt still warns about two active packets', () => {
258
+ within(fixture(['auth-flow', 'billing-2']), (fx) => {
259
+ assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), /Multiple active specs/);
260
+ });
261
+ });
262
+
263
+ test('a touched valid packet prints its tollgate when another packet is invalid', () => {
264
+ within(fixture(['auth-flow', 'broken-flow']), (fx) => {
265
+ invalidateTask(fx.root, 'broken-flow');
266
+ const output = fx.prompt('s1', 'Continue specs/auth-flow');
267
+ assert.match(output, /Spec state changed: `auth-flow`/);
268
+ assert.doesNotMatch(output, /Invalid workflow packet/);
269
+ });
270
+ });
271
+
272
+ test('an invalid specs root still warns an untouched session', () => {
273
+ within(fixture(['auth-flow']), (fx) => {
274
+ fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
275
+ assert.match(fx.prompt('s1', 'Continue'), /Invalid workflow packet detected: <specs>/);
276
+ });
277
+ });
278
+
136
279
  function writeLegacyPacket(root, name) {
137
280
  const dir = path.join(root, 'specs', name);
138
281
  fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
@@ -158,3 +301,56 @@ test('a legacy packet is noticed even when it is the only packet', () => {
158
301
  assert.match(fx.prompt('s1', 'Continue'), LEGACY_NOTICE);
159
302
  });
160
303
  });
304
+
305
+ test('Claude spec-state uses one snapshot for done task receipts', (t) => {
306
+ within(fixture(['auth-flow']), (fx) => {
307
+ const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-snapshot-count-'));
308
+ try {
309
+ const feature = path.join(fx.root, 'specs/auth-flow');
310
+ const tasks = ['task-01-work.md', 'task-02-work.md'];
311
+ for (const name of tasks) fs.writeFileSync(path.join(feature, name), taskContent('done'));
312
+ fs.writeFileSync(path.join(feature, 'task-03-work.md'), taskContent('in_progress'));
313
+ const scripts = path.join(fx.root, '.claude/scripts');
314
+ const provenance = require(path.join(scripts, 'provenance.cjs'));
315
+ const policy = require(path.join(scripts, 'workflow-policy.cjs'));
316
+ const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
317
+ const context = provenance.deriveRuntimeContext({ projectRoot: fx.root,
318
+ specsRoot: path.join(fx.root, 'specs'), specFile: path.join(feature, 'plan.md'),
319
+ featureName: 'auth-flow', runtimeSession: 'one-snapshot' });
320
+ for (const name of tasks) {
321
+ fs.appendFileSync(path.join(feature, name), [
322
+ '\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
323
+ `Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
324
+ ].join('\n'));
325
+ assert.deepStrictEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
326
+ }
327
+ const countFile = path.join(trace, 'git-status.txt');
328
+ const preload = path.join(trace, 'count.cjs');
329
+ fs.writeFileSync(preload, `
330
+ const cp = require('node:child_process');
331
+ const fs = require('node:fs');
332
+ const path = require('node:path');
333
+ for (const method of ['spawnSync', 'execFileSync']) {
334
+ const original = cp[method];
335
+ cp[method] = function(command, args, ...rest) {
336
+ if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
337
+ fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
338
+ return original.call(this, command, args, ...rest);
339
+ };
340
+ }
341
+ `);
342
+ const result = spawnSync(process.execPath, [path.join(fx.hooks, 'spec-state.cjs')], {
343
+ cwd: fx.root, encoding: 'utf8',
344
+ env: { ...process.env, PROJECT_ROOT: fx.root,
345
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
346
+ input: JSON.stringify({ cwd: fx.root, hook_event_name: 'UserPromptSubmit',
347
+ session_id: 'one-snapshot', prompt: 'Continue specs/auth-flow' }),
348
+ });
349
+ assert.strictEqual(result.status, 0, result.stderr);
350
+ assert.match(result.stdout, /Spec state changed/);
351
+ const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
352
+ t.diagnostic(`Claude git status calls: ${count}`);
353
+ assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
354
+ } finally { fs.rmSync(trace, { recursive: true, force: true }); }
355
+ });
356
+ });
@@ -6,9 +6,13 @@ const { spawnSync } = require('node:child_process');
6
6
  const fs = require('node:fs');
7
7
  const os = require('node:os');
8
8
  const path = require('node:path');
9
+ const crypto = require('node:crypto');
9
10
 
10
11
  const CLAUDE_ROOT = path.join(__dirname, '..', '..');
11
12
  const STATE_HOOK = path.join(CLAUDE_ROOT, 'hooks', 'state.cjs');
13
+ const HOOK_ENV = { ...process.env };
14
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
15
+ delete HOOK_ENV.PROJECT_ROOT;
12
16
  const POLICY = require(path.join(CLAUDE_ROOT, 'scripts', 'workflow-policy.cjs'));
13
17
 
14
18
  function processTaskContent(title, status, dependency = 'none') {
@@ -44,6 +48,9 @@ function inInstalledProcessSpecStateFixture(run) {
44
48
  fs.mkdirSync(scripts, { recursive: true });
45
49
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
46
50
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
51
+ for (const lib of ['project-root.cjs', 'spec-touch.cjs']) {
52
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
53
+ }
47
54
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
48
55
  fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
49
56
  fs.copyFileSync(
@@ -92,7 +99,7 @@ function inInstalledProcessSpecStateFixture(run) {
92
99
  encoding: 'utf8',
93
100
  });
94
101
  return run({
95
- cacheFile: path.join(hooks, '.logs', 'tollgate-last.txt'),
102
+ cacheFile: path.join(hooks, '.logs', `tollgate-last-${crypto.createHash('sha256').update('session-auth-state').digest('hex').slice(0, 16)}.txt`),
96
103
  dir,
97
104
  feature,
98
105
  hooks,
@@ -277,6 +284,7 @@ test('Stop snapshot includes tracked modifications and untracked files', () => {
277
284
  fs.writeFileSync(path.join(dir, 'new-untracked.json'), '{}\n');
278
285
  const result = spawnSync(process.execPath, [STATE_HOOK], {
279
286
  cwd: dir,
287
+ env: HOOK_ENV,
280
288
  input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
281
289
  encoding: 'utf8',
282
290
  });
@@ -302,6 +310,7 @@ test('Stop snapshot includes untracked files before the first commit', () => {
302
310
 
303
311
  const result = spawnSync(process.execPath, [STATE_HOOK], {
304
312
  cwd: dir,
313
+ env: HOOK_ENV,
305
314
  input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
306
315
  encoding: 'utf8',
307
316
  });
@@ -344,7 +353,7 @@ const PLACEHOLDER_STATE = [
344
353
  '',
345
354
  ].join('\n');
346
355
 
347
- function sessionStartWith(content, runtimeFolder = '.claude') {
356
+ function sessionStartWith(content, runtimeFolder = '.claude', readingSession = 'start-1') {
348
357
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-start-'));
349
358
  try {
350
359
  let hook = STATE_HOOK;
@@ -352,17 +361,19 @@ function sessionStartWith(content, runtimeFolder = '.claude') {
352
361
  const hooks = path.join(dir, runtimeFolder, 'hooks');
353
362
  fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
354
363
  fs.copyFileSync(STATE_HOOK, path.join(hooks, 'state.cjs'));
355
- for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
364
+ for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'secret-keywords.cjs']) {
356
365
  fs.copyFileSync(path.join(path.dirname(STATE_HOOK), 'lib', lib), path.join(hooks, 'lib', lib));
357
366
  }
358
367
  hook = path.join(hooks, 'state.cjs');
359
368
  }
360
- const stateFolder = path.join(dir, runtimeFolder, 'session-state');
369
+ const stateFolder = path.join(dir, runtimeFolder, 'session-state',
370
+ ...(['.claude', '.omp'].includes(runtimeFolder) ? [crypto.createHash('sha256').update('start-1').digest('hex').slice(0, 16)] : []));
361
371
  fs.mkdirSync(stateFolder, { recursive: true });
362
372
  fs.writeFileSync(path.join(stateFolder, 'latest.md'), content.replace('GENERATED', new Date().toISOString()));
363
373
  const result = spawnSync(process.execPath, [hook], {
364
374
  cwd: dir,
365
- input: JSON.stringify({ hook_event_name: 'SessionStart', session_id: 'start-1', cwd: dir }),
375
+ env: HOOK_ENV,
376
+ input: JSON.stringify({ hook_event_name: 'SessionStart', session_id: readingSession, cwd: dir }),
366
377
  encoding: 'utf8',
367
378
  });
368
379
  assert.strictEqual(result.status, 0, result.stderr);
@@ -381,6 +392,195 @@ test('SessionStart prints prior context that has a completed todo', () => {
381
392
  assert.match(sessionStartWith(withTodo), /Prior Execution Context[\s\S]*Ship the statusline/);
382
393
  });
383
394
 
384
- test('SessionStart under .omp keeps printing placeholder-only context', () => {
385
- assert.match(sessionStartWith(PLACEHOLDER_STATE, '.omp'), /Prior Execution Context/);
395
+ test('SessionStart under .omp drops placeholder-only context like Claude', () => {
396
+ assert.strictEqual(sessionStartWith(PLACEHOLDER_STATE, '.omp'), '');
397
+ });
398
+
399
+ test('two omp sessions keep separate state', () => {
400
+ const withTodo = PLACEHOLDER_STATE.replace('- (No completed tasks recorded)', '- Ship the statusline');
401
+ assert.match(sessionStartWith(withTodo, '.omp'), /Ship the statusline/, 'the same session reads its own state');
402
+ assert.strictEqual(sessionStartWith(withTodo, '.omp', 'start-2'), '', 'another omp session does not');
403
+ });
404
+
405
+ function inSessionStateFixture(run) {
406
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-sessions-'));
407
+ try {
408
+ const hooks = path.join(dir, '.claude/hooks');
409
+ fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
410
+ fs.copyFileSync(STATE_HOOK, path.join(hooks, 'state.cjs'));
411
+ for (const lib of ['parser.cjs', 'runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'secret-keywords.cjs']) {
412
+ fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks/lib', lib), path.join(hooks, 'lib', lib));
413
+ }
414
+ const invoke = (payload) => {
415
+ const result = spawnSync(process.execPath, [path.join(hooks, 'state.cjs')], {
416
+ cwd: dir, env: HOOK_ENV, encoding: 'utf8', input: JSON.stringify({ cwd: dir, ...payload }),
417
+ });
418
+ assert.strictEqual(result.status, 0, result.stderr);
419
+ return result.stdout;
420
+ };
421
+ const write = (session, todo) => {
422
+ const transcript = path.join(dir, `${session}.jsonl`);
423
+ fs.writeFileSync(transcript, JSON.stringify({ message: { content: [{
424
+ type: 'tool_use', id: 'todo-write', name: 'TodoWrite',
425
+ input: { todos: [{ content: todo, status: 'completed' }] },
426
+ }] } }) + '\n');
427
+ invoke({ hook_event_name: 'PostToolUse', session_id: session, tool_name: 'TodoWrite', transcript_path: transcript });
428
+ };
429
+ run({ dir, invoke, write, stateRoot: path.join(dir, '.claude/session-state') });
430
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
431
+ }
432
+
433
+ function savedSession(stateRoot, session = 'robust-session') {
434
+ return path.join(stateRoot, crypto.createHash('sha256').update(session).digest('hex').slice(0, 16));
435
+ }
436
+
437
+ test('Agent refreshes the installed state from transcript todos and is parsed as an agent', () => {
438
+ inSessionStateFixture(({ dir, invoke, stateRoot }) => {
439
+ const transcript = path.join(dir, 'agent.jsonl');
440
+ const agent = { type: 'tool_use', id: 'agent-call', name: 'Agent', input: { subagent_type: 'tester' } };
441
+ fs.writeFileSync(transcript, JSON.stringify({ message: { content: [agent, {
442
+ type: 'tool_use', id: 'todos', name: 'TodoWrite', input: { todos: [{ content: 'Verify agent snapshot', status: 'pending' }] },
443
+ }] } }) + '\n');
444
+ invoke({ hook_event_name: 'PostToolUse', tool_name: 'Agent', session_id: 'robust-session', transcript_path: transcript });
445
+ const file = path.join(savedSession(stateRoot), 'latest.md');
446
+ assert.ok(fs.existsSync(file), 'Agent must create the state snapshot');
447
+ assert.match(fs.readFileSync(file, 'utf8'), /Verify agent snapshot/);
448
+ const agents = new Map();
449
+ const tools = new Map();
450
+ require('../lib/parser.cjs').processEntry({ message: { content: [agent] } }, tools, agents, [], {});
451
+ assert.ok(agents.has('agent-call'));
452
+ assert.ok(!tools.has('agent-call'));
453
+ });
454
+ });
455
+
456
+ test('Stop persists a bounded summary of the last assistant message', () => {
457
+ inSessionStateFixture(({ invoke, stateRoot }) => {
458
+ invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'Verified the change. ' + 'x'.repeat(700) });
459
+ const state = fs.readFileSync(path.join(savedSession(stateRoot), 'latest.md'), 'utf8');
460
+ assert.match(state, /## Latest Assistant Message\nVerified the change\./);
461
+ assert.ok(!state.includes('x'.repeat(601)), 'the summary must be bounded');
462
+ });
463
+ });
464
+
465
+ test('Stop omits secret-related assistant messages before saving state', () => {
466
+ inSessionStateFixture(({ invoke, stateRoot }) => {
467
+ const message = 'API key handling was discussed in this synthetic test.';
468
+ invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: message });
469
+ const state = fs.readFileSync(path.join(savedSession(stateRoot), 'latest.md'), 'utf8');
470
+ assert.ok(state.includes('[omitted: possible secret]'));
471
+ assert.ok(!state.includes(message));
472
+ });
473
+ });
474
+
475
+ test('SubagentStop saves the assistant summary and omits secret-related content', () => {
476
+ inSessionStateFixture(({ invoke, stateRoot }) => {
477
+ invoke({ hook_event_name: 'SubagentStop', session_id: 'robust-session', agent_type: 'tester', last_assistant_message: 'Checks passed.' });
478
+ const file = path.join(savedSession(stateRoot), 'latest.md');
479
+ assert.match(fs.readFileSync(file, 'utf8'), /Checks passed\./);
480
+ const message = 'Credentials handling was discussed in this synthetic test.';
481
+ invoke({ hook_event_name: 'SubagentStop', session_id: 'robust-session', agent_type: 'reviewer', last_assistant_message: message });
482
+ const state = fs.readFileSync(file, 'utf8');
483
+ assert.ok(state.includes('[omitted: possible secret]'));
484
+ assert.ok(!state.includes(message));
485
+ });
486
+ });
487
+
488
+ test('a fresh state write lock skips the snapshot without changing existing bytes', () => {
489
+ inSessionStateFixture(({ invoke, stateRoot }) => {
490
+ const dir = savedSession(stateRoot);
491
+ fs.mkdirSync(path.join(dir, '.write-lock'), { recursive: true });
492
+ const file = path.join(dir, 'latest.md');
493
+ fs.writeFileSync(file, 'Prior state must survive\n');
494
+ const before = fs.readFileSync(file);
495
+ invoke({ hook_event_name: 'Stop', session_id: 'robust-session', last_assistant_message: 'New snapshot' });
496
+ assert.deepStrictEqual(fs.readFileSync(file), before);
497
+ assert.ok(fs.existsSync(path.join(dir, '.write-lock')));
498
+ });
499
+ });
500
+
501
+ test('a stale state write lock is removed and the snapshot is saved', () => {
502
+ inSessionStateFixture(({ invoke, stateRoot }) => {
503
+ const dir = savedSession(stateRoot);
504
+ const lock = path.join(dir, '.write-lock');
505
+ fs.mkdirSync(lock, { recursive: true });
506
+ const old = new Date(Date.now() - 20000);
507
+ fs.utimesSync(lock, old, old);
508
+ invoke({ hook_event_name: 'Stop', session_id: 'robust-session' });
509
+ assert.ok(fs.existsSync(path.join(dir, 'latest.md')));
510
+ assert.ok(!fs.existsSync(lock), 'the stale lock must be released');
511
+ });
512
+ });
513
+
514
+ test('consecutive PostToolUse snapshots both save their todos and release the write lock', () => {
515
+ inSessionStateFixture(({ write, stateRoot }) => {
516
+ const dir = savedSession(stateRoot);
517
+ for (const todo of ['First saved todo', 'Second saved todo']) {
518
+ write('robust-session', todo);
519
+ assert.ok(fs.readFileSync(path.join(dir, 'latest.md'), 'utf8').includes(todo));
520
+ assert.ok(!fs.existsSync(path.join(dir, '.write-lock')));
521
+ }
522
+ });
523
+ });
524
+
525
+ test('malformed state input exits successfully and records a crash', () => {
526
+ inSessionStateFixture(({ dir }) => {
527
+ const hooks = path.join(dir, '.claude/hooks');
528
+ const result = spawnSync(process.execPath, [path.join(hooks, 'state.cjs')], { cwd: dir, input: '{', encoding: 'utf8' });
529
+ assert.strictEqual(result.status, 0);
530
+ const file = path.join(hooks, '.logs/hook-log.jsonl');
531
+ assert.ok(fs.existsSync(file), 'the asynchronous failure must be logged');
532
+ const rows = fs.readFileSync(file, 'utf8').trim().split('\n').map(line => JSON.parse(line));
533
+ assert.strictEqual(rows.length, 1);
534
+ assert.strictEqual(rows[0].hook, 'state');
535
+ assert.strictEqual(rows[0].status, 'crash');
536
+ });
537
+ });
538
+
539
+ test('a new Claude session startup does not inherit another session context', () => {
540
+ inSessionStateFixture(({ write, invoke }) => {
541
+ write('session-a', 'Completed work for session A');
542
+ const output = invoke({ hook_event_name: 'SessionStart', session_id: 'session-b', source: 'startup' });
543
+ assert.doesNotMatch(output, /Prior Execution Context/);
544
+ });
545
+ });
546
+
547
+ test('a Claude session resumes its own saved todo', () => {
548
+ inSessionStateFixture(({ write, invoke, stateRoot }) => {
549
+ write('session-a', 'Completed work for session A');
550
+ const key = crypto.createHash('sha256').update('session-a').digest('hex').slice(0, 16);
551
+ assert.match(fs.readFileSync(path.join(stateRoot, key, 'latest.md'), 'utf8'), /Completed work for session A/);
552
+ const output = invoke({ hook_event_name: 'SessionStart', session_id: 'session-a', source: 'resume' });
553
+ assert.match(output, /Prior Execution Context[\s\S]*Completed work for session A/);
554
+ });
555
+ });
556
+
557
+ test('two Claude sessions resume only their own completed todos', () => {
558
+ inSessionStateFixture(({ write, invoke }) => {
559
+ write('session-a', 'Completed work for session A');
560
+ write('session-b', 'Completed work for session B');
561
+ for (const [session, own, other] of [['session-a', 'session A', 'session B'], ['session-b', 'session B', 'session A']]) {
562
+ const output = invoke({ hook_event_name: 'SessionStart', session_id: session, source: 'resume' });
563
+ const verified = output.split('## What Worked (Verified)')[1]?.split("## What's Left")[0] || '';
564
+ assert.match(output, /Prior Execution Context/);
565
+ assert.ok(verified.includes(own));
566
+ assert.ok(!verified.includes(other));
567
+ }
568
+ });
569
+ });
570
+
571
+ test('a session-less payload still reads project-level context', () => {
572
+ inSessionStateFixture(({ stateRoot, invoke }) => {
573
+ fs.mkdirSync(stateRoot, { recursive: true });
574
+ fs.writeFileSync(path.join(stateRoot, 'latest.md'), PLACEHOLDER_STATE.replace('GENERATED', new Date().toISOString())
575
+ .replace('- (No completed tasks recorded)', '- Legacy project todo'));
576
+ assert.match(invoke({ hook_event_name: 'SessionStart' }), /Prior Execution Context[\s\S]*Legacy project todo/);
577
+ });
578
+ });
579
+
580
+ test('Claude SessionStart does not create state directories', () => {
581
+ inSessionStateFixture(({ stateRoot, invoke }) => {
582
+ assert.strictEqual(fs.existsSync(stateRoot), false);
583
+ assert.strictEqual(invoke({ hook_event_name: 'SessionStart', session_id: 'new-session', source: 'startup' }).trim(), '');
584
+ assert.strictEqual(fs.existsSync(stateRoot), false);
585
+ });
386
586
  });
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { spawnSync } = require('node:child_process');
9
+
10
+ async function fixture(run) {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-update-check-'));
12
+ try {
13
+ fs.writeFileSync(path.join(dir, 'cafekit.json'), JSON.stringify({ version: '1.2.3' }));
14
+ const cache = path.join(dir, '.cafekit-update-cache.json');
15
+ await run({ dir, cache, check: (...args) => require('../lib/update-check.cjs').checkCafeKitUpdate(...args) });
16
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
17
+ }
18
+
19
+ test('a newer version produces the existing notice and caches only version data', async () => {
20
+ await fixture(async ({ dir, cache, check }) => {
21
+ const notice = await check(dir, async () => '1.3.0');
22
+ assert.equal(notice, '\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n');
23
+ const saved = JSON.parse(fs.readFileSync(cache, 'utf8'));
24
+ assert.deepEqual(Object.keys(saved).sort(), ['latest', 'ts']);
25
+ assert.equal(saved.latest, '1.3.0');
26
+ assert.equal(await check(dir, async () => { throw Error('fresh cache must avoid fetch'); }), notice);
27
+ });
28
+ });
29
+
30
+ test('the installed version produces no update notice', async () => {
31
+ await fixture(async ({ dir, check }) => {
32
+ assert.equal(await check(dir, async () => '1.2.3'), '');
33
+ });
34
+ });
35
+
36
+ test('an invalid cached latest version is refetched instead of printed', async () => {
37
+ await fixture(async ({ dir, cache, check }) => {
38
+ fs.writeFileSync(cache, JSON.stringify({ ts: Date.now(), latest: '9.0.0\nInjected cache text' }));
39
+ let calls = 0;
40
+ assert.equal(await check(dir, async () => { calls++; return '1.2.3'; }), '');
41
+ assert.equal(calls, 1);
42
+ });
43
+ });
44
+
45
+ test('a cache timestamp in the future requires a fresh version fetch', async () => {
46
+ await fixture(async ({ dir, cache, check }) => {
47
+ fs.writeFileSync(cache, JSON.stringify({ ts: Date.now() + 60000, latest: '9.0.0' }));
48
+ let calls = 0;
49
+ assert.equal(await check(dir, async () => { calls++; return '1.2.3'; }), '');
50
+ assert.equal(calls, 1);
51
+ });
52
+ });
53
+
54
+ test('a legacy notice cache cannot inject its stored text into the session', async () => {
55
+ await fixture(async ({ dir, cache, check }) => {
56
+ const injected = 'Untrusted cached instruction';
57
+ fs.writeFileSync(cache, JSON.stringify({ ts: Date.now(), notice: injected }));
58
+ let calls = 0;
59
+ const notice = await check(dir, async () => { calls++; return '1.3.0'; });
60
+ assert.equal(calls, 1);
61
+ assert.ok(!notice.includes(injected));
62
+ assert.match(notice, /1\.2\.3 → 1\.3\.0/);
63
+ });
64
+ });
65
+
66
+ test('an interrupted version response fails open without an unhandled error', () => {
67
+ for (const event of ['error', 'aborted']) {
68
+ const script = `
69
+ const { EventEmitter } = require('events');
70
+ require('https').get = (url, options, callback) => {
71
+ const request = new EventEmitter();
72
+ request.destroy = () => {};
73
+ process.nextTick(() => {
74
+ const response = new EventEmitter();
75
+ callback(response);
76
+ response.emit(${JSON.stringify(event)}, new Error('synthetic response reset'));
77
+ });
78
+ return request;
79
+ };
80
+ require(${JSON.stringify(path.resolve(__dirname, '../lib/update-check.cjs'))}).fetchLatestVersion()
81
+ .then(value => { if (value !== null) process.exit(3); process.stdout.write('null'); });
82
+ `;
83
+ const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 5000 });
84
+ assert.equal(result.status, 0, result.stderr);
85
+ assert.equal(result.stderr, '');
86
+ assert.equal(result.stdout, 'null');
87
+ }
88
+ });