@haposoft/cafekit 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: project-manager
3
- description: 'Ecosystem Orchestrator. Oversees the cf:specs lifecycle, aggregates outputs, and tracks implementation progress. Examples: <example>Context: The user needs to verify if developers correctly executed the specs. user: "I finished coding the new login flow. Can you aggregate the results and check progress?" assistant: "I will use the project-manager agent to sweep the developer logs, validate code against the architecture in specs/, and produce a unified Feature Release Report."</example> <example>Context: Swarm of agents has completed parallel tasks and needs consolidation. user: "The backend and frontend agents said they are done. What is the overall status?" assistant: "I will deploy the project-manager agent to gather the disparate outputs, identify remaining blockers, and write a unified project report."</example>'
3
+ description: 'Ecosystem Orchestrator. Aggregates evidence for a process-first feature packet (plan.md, flat task files, receipts, worker and review reports) and reports scope drift, task and receipt status, blockers, and the pending GATE-DONE decision. Use when several tasks or agents have finished and the overall state needs reconciling; it does not write task Status or Receipts.'
4
4
  model: haiku
5
5
  tools: Glob, Grep, Read, Edit, Write, NotebookEdit, Bash, WebFetch, TaskCreate, TaskGet, TaskUpdate, TaskList, WebSearch, ListMcpResourcesTool, ReadMcpResourceTool, SendMessage
6
6
  ---
@@ -45,7 +45,7 @@ Before you declare any phase complete or issue a final status report, you must i
45
45
  decision; never end with a vague conclusion.
46
46
 
47
47
  ## Format & Output Constraints
48
- - **Sacrifice Grammar for Concision:** Do not write flowery prose. Your reports must be highly mechanical, bulleted, and brutally concise.
48
+ - **Concision:** Keep reports scannable and limited to what the minimum report below requires.
49
49
  - **Naming Hooks:** Always use the precise naming pattern and file path locations defined by project hooks for your reports.
50
50
  - **Minimum report:** Include GATE-SCOPE scope drift, accepted GATE-REVIEW finding coverage,
51
51
  task/receipt status, executed command results, blockers, docs impact, and the
@@ -17,7 +17,7 @@ removes product decisions from implementation. The output is Markdown under
17
17
  - existing plans or decisions explicitly placed in scope;
18
18
  - the three human-gate decisions from `cf:specs`.
19
19
 
20
- Read `skills/specs/SKILL.md` and `skills/specs/references/templates.md` before
20
+ Read `.claude/skills/specs/SKILL.md` and `.claude/skills/specs/references/templates.md` before
21
21
  routing; they are the canonical risk and coverage authority. Do not duplicate
22
22
  their taxonomy here. Apply their risk-first route before GATE-SCOPE and stop when the
23
23
  request qualifies for direct work; hand off when it requires Brainstorm-only exploration.
@@ -58,7 +58,7 @@ task `Status: blocked` while GATE-REVIEW is open, and keep its `## Receipt` empt
58
58
 
59
59
  ### 4. Review from fresh context
60
60
 
61
- Read `skills/specs/references/review.md`. Route fresh reviewers by capability
61
+ Read `.claude/skills/specs/references/review.md`. Route fresh reviewers by capability
62
62
  and give them only the plan packet plus repository access. Require a severity,
63
63
  plan location, concrete failure, current `path:line` evidence, and smallest
64
64
  repair for every finding.
@@ -91,14 +91,14 @@ chooses when execution begins.
91
91
  - Ownership overlap or dependency uncertainty prevents a parallel claim.
92
92
  - A command that cannot be run from the named work context is not verification.
93
93
  - User approval and test proof are different facts; never infer either.
94
- - Existing legacy specs remain untouched unless migration is explicitly asked.
94
+ - Leave an existing packet directory without `plan.md` untouched unless migration is explicitly asked.
95
95
 
96
96
  ## Output
97
97
 
98
98
  End with:
99
99
 
100
100
  ```text
101
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
101
+ Status: DONE | CONCERNS | BLOCKED | NEEDS_INFO
102
102
  Files: <plan and task paths>
103
103
  GATE-SCOPE: <decision>
104
104
  GATE-REVIEW: <accepted/rejected/revised counts>
@@ -16,11 +16,12 @@ separate concise redacted report. Do not invent proof from remembered output.
16
16
  When the target is a feature or task, classify current bytes before execution:
17
17
 
18
18
  - A valid process-first packet has a regular `plan.md` with
19
- `Specs-Contract: process-first-ready-v1`, regular flat `task-NN-*.md` files,
20
- and no legacy marker. Read the exact current Verification Plan.
21
- - A valid legacy packet keeps its isolated adapter and separate receipt rules.
22
- - Mixed, orphaned, malformed, symlinked, nonregular, or identity-conflicting
23
- packet markers return `BLOCKED`; never migrate or repair them.
19
+ `Specs-Contract: process-first-ready-v1` and regular flat `task-NN-*.md`
20
+ files. Read the exact current Verification Plan.
21
+ - A packet directory with no `plan.md` that holds the old JSON planning state is no
22
+ longer read; return `BLOCKED` and name it.
23
+ - Orphaned, malformed, symlinked, nonregular, or identity-conflicting packet
24
+ markers return `BLOCKED`; never migrate or repair them.
24
25
  - With no packet marker, use ordinary repository-aware test selection.
25
26
 
26
27
  For process-first work, the task's exact Command, exact unique Named probes,
@@ -125,9 +126,5 @@ proof and does not rerun it.
125
126
  Do not paste the full payload, secrets, verbose logs, or screenshots into this
126
127
  report. `PASS_WITH_WARNINGS` remains unfinished.
127
128
 
128
- ## Legacy workflow compatibility
129
-
130
- After a valid legacy route, resolve its current spec/task contract and emit the
131
- existing legacy evidence through its separate-receipt adapter. Normalize legacy
132
- diagnostics to the canonical four verdicts. Never search for or write a separate
133
- receipt for process-first work, and never copy legacy proof into a flat task.
129
+ Proof for process-first work lives only in the task's inline `## Receipt`; never
130
+ search for or write a receipt file beside it.
@@ -30,7 +30,6 @@ You are an award-caliber UI/UX designer. You merge aesthetic excellence with eng
30
30
  .claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<mood>" --domain typography
31
31
  .claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<industry>" --domain color
32
32
  ```
33
- - Study current design trends sourced from Dribbble, Awwwards, Mobbin via the python extractor outputs.
34
33
  - Review existing `docs/design-guidelines.md` if it exists.
35
34
  - For competitive analysis, use bounded `WebSearch`/`WebFetch` directly or return a `NEEDS_RESEARCH` note for the controller to delegate.
36
35
 
@@ -6,6 +6,8 @@
6
6
  .env
7
7
  .env.*
8
8
  !.env.example
9
+ !.env.sample
10
+ !.env.template
9
11
 
10
12
  # Skill dependency installs (Python venv + npm)
11
13
  skills/.venv/
@@ -25,3 +27,4 @@ agent-memory/
25
27
  settings.bak.json
26
28
  .mcp.json
27
29
  .cafekit-update-cache.json
30
+ *.tmp
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { spawnSync } = require('node:child_process');
9
+ const { test } = require('node:test');
10
+
11
+ const CLAUDE = path.resolve(__dirname, '../..');
12
+ const MANIFEST = require('../../migration-manifest.json');
13
+ const RUNTIME = { paths: { specs: 'work-specs', plans: 'work-plans', docs: 'work-docs' }, inspect: { enabled: false } };
14
+
15
+ function fixture(run, withRuntime = true) {
16
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-installed-root-'));
17
+ const sessions = new Set();
18
+ try {
19
+ for (const relative of MANIFEST.runtime.files) {
20
+ const target = path.join(root, '.claude', relative);
21
+ fs.mkdirSync(path.dirname(target), { recursive: true });
22
+ fs.copyFileSync(path.join(CLAUDE, relative), target);
23
+ }
24
+ for (const relative of MANIFEST.scripts.required) {
25
+ const target = path.join(root, '.claude/scripts', relative);
26
+ fs.mkdirSync(path.dirname(target), { recursive: true });
27
+ fs.copyFileSync(path.join(CLAUDE, 'scripts', relative), target);
28
+ }
29
+ const runtimeFile = path.join(root, '.claude/runtime.json');
30
+ const writeRuntime = () => fs.writeFileSync(runtimeFile, JSON.stringify(RUNTIME));
31
+ if (withRuntime) writeRuntime();
32
+ else fs.unlinkSync(runtimeFile);
33
+ const cwd = path.join(root, 'sub/dir');
34
+ fs.mkdirSync(cwd, { recursive: true });
35
+ const env = { ...process.env };
36
+ delete env.CLAUDE_PROJECT_DIR;
37
+ delete env.PROJECT_ROOT;
38
+ const invoke = (name, payload) => {
39
+ if (payload.session_id) sessions.add(payload.session_id);
40
+ return spawnSync(process.execPath, [path.join(root, '.claude/hooks', `${name}.cjs`)], {
41
+ cwd, env, encoding: 'utf8', input: JSON.stringify({ cwd, ...payload }),
42
+ });
43
+ };
44
+ run({ root: fs.realpathSync(root), cwd, invoke, writeRuntime });
45
+ } finally {
46
+ for (const session of sessions) {
47
+ const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
48
+ fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
49
+ }
50
+ fs.rmSync(root, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ test('installed rules read the project runtime from a nested session cwd', () => {
55
+ fixture(({ root, invoke }) => {
56
+ const result = invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() });
57
+ assert.equal(result.status, 0, result.stderr);
58
+ assert.match(result.stdout, /## Rules/);
59
+ assert.ok(result.stdout.includes(`${root}/work-specs/`));
60
+ });
61
+ });
62
+
63
+ test('rules wait for runtime before reserving the session injection', () => {
64
+ fixture(({ invoke, writeRuntime }) => {
65
+ const payload = { hook_event_name: 'UserPromptSubmit', session_id: crypto.randomUUID() };
66
+ const absent = invoke('rules', payload);
67
+ assert.equal(absent.status, 0, absent.stderr);
68
+ assert.equal(absent.stdout, '');
69
+ writeRuntime();
70
+ const present = invoke('rules', payload);
71
+ assert.equal(present.status, 0, present.stderr);
72
+ assert.match(present.stdout, /## Rules/);
73
+ }, false);
74
+ });
75
+
76
+ test('installed inspect respects a disabled project runtime from a nested cwd', () => {
77
+ fixture(({ invoke }) => {
78
+ const result = invoke('inspect-block', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'node_modules/pkg/index.js' } });
79
+ assert.equal(result.status, 0, result.stderr);
80
+ });
81
+ });
82
+
83
+ test('installed agent context uses project paths from a nested session cwd', () => {
84
+ fixture(({ root, invoke }) => {
85
+ const result = invoke('agent', { hook_event_name: 'SubagentStart', agent_id: 'nested-agent' });
86
+ assert.equal(result.status, 0, result.stderr);
87
+ assert.match(result.stdout, /additionalContext/);
88
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
89
+ for (const relative of ['work-specs', 'work-plans', 'work-docs']) assert.ok(context.includes(`${root}/${relative}/`));
90
+ });
91
+ });
92
+
93
+ test('installed spec gate checks root packet receipts from a nested session cwd', () => {
94
+ fixture(({ root, invoke }) => {
95
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
96
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
97
+ assert.equal(result.status, 0, result.stderr);
98
+ }
99
+ const packet = path.join(root, 'work-specs/demo');
100
+ fs.mkdirSync(packet, { recursive: true });
101
+ fs.writeFileSync(path.join(packet, 'plan.md'), '# Demo\nSpecs-Contract: process-first-ready-v1\n');
102
+ fs.writeFileSync(path.join(packet, 'task-01-work.md'), '# Work\n\nStatus: done\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- Command: node --test\n');
103
+ const result = invoke('spec-gate', { hook_event_name: 'Stop', session_id: crypto.randomUUID() });
104
+ assert.equal(result.status, 0, result.stderr);
105
+ const block = JSON.parse(result.stdout);
106
+ assert.equal(block.decision, 'block');
107
+ assert.match(block.reason, /receipt/i);
108
+ assert.match(block.reason, /task-01-work\.md/);
109
+ assert.doesNotMatch(block.reason, /unavailable|controlled failure/i);
110
+ });
111
+ });
@@ -11,11 +11,21 @@ const { spawnSync } = require('node:child_process');
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
13
  const path = require('node:path');
14
+ const crypto = require('node:crypto');
14
15
 
15
16
  const PRECOMPACT = path.join(__dirname, '..', 'precompact.cjs');
16
17
  const SESSION = path.join(__dirname, '..', 'session.cjs');
17
18
  const { hookStateDir } = require(path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'));
18
- const RECOVERY = path.join(hookStateDir(), 'compact-recovery.json');
19
+ const CAPTURES = new Set([path.join(hookStateDir(), 'compact-recovery.json')]);
20
+ function recoveryPath(session = 'compact-session') {
21
+ const key = crypto.createHash('sha256').update(session).digest('hex');
22
+ const file = path.join(hookStateDir(), `compact-recovery-${key}.json`);
23
+ CAPTURES.add(file);
24
+ return file;
25
+ }
26
+ const HOOK_ENV = { ...process.env };
27
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
28
+ delete HOOK_ENV.PROJECT_ROOT;
19
29
 
20
30
  function fixture() {
21
31
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-'));
@@ -36,21 +46,24 @@ function fixture() {
36
46
  }
37
47
 
38
48
  function run(hook, dir, payload) {
49
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) recoveryPath(payload.session_id);
39
50
  const result = spawnSync(process.execPath, [hook], {
40
51
  cwd: dir,
41
52
  input: JSON.stringify(payload),
42
53
  encoding: 'utf8',
43
- env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
54
+ env: HOOK_ENV,
44
55
  });
45
56
  return { code: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
46
57
  }
47
58
 
48
59
  function clearCapture() {
49
- try { fs.unlinkSync(RECOVERY); } catch { /* absent */ }
60
+ for (const file of CAPTURES) {
61
+ try { fs.unlinkSync(file); } catch { /* absent */ }
62
+ }
50
63
  }
51
64
 
52
- function readCapture() {
53
- return JSON.parse(fs.readFileSync(RECOVERY, 'utf8'));
65
+ function readCapture(session) {
66
+ return JSON.parse(fs.readFileSync(recoveryPath(session), 'utf8'));
54
67
  }
55
68
 
56
69
  test('PreCompact records branch, HEAD, dirty count and the in-flight task', () => {
@@ -93,8 +106,8 @@ test('a compact restart prints the captured anchors alongside the authorization
93
106
  const dir = fixture();
94
107
  try {
95
108
  clearCapture();
96
- assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', cwd: dir }).code, 0);
97
- const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', cwd: dir });
109
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', trigger: 'manual', session_id: 'compact-session', cwd: dir }).code, 0);
110
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session', cwd: dir });
98
111
  assert.strictEqual(restart.code, 0, restart.stderr);
99
112
  assert.match(restart.stdout, /VERIFY PENDING AUTHORIZATIONS/, 'the existing warning must survive');
100
113
  assert.match(restart.stdout, /State before compaction/);
@@ -119,6 +132,53 @@ test('a compact restart without a capture still prints the warning and nothing i
119
132
  }
120
133
  });
121
134
 
135
+ test('a compact restart in another session prints the warning without foreign anchors', () => {
136
+ const dir = fixture();
137
+ try {
138
+ clearCapture();
139
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
140
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-b', cwd: dir });
141
+ assert.strictEqual(restart.code, 0, restart.stderr);
142
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
143
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
144
+ } finally {
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ clearCapture();
147
+ }
148
+ });
149
+
150
+ test('a compact restart in the same session restores its captured anchors', () => {
151
+ const dir = fixture();
152
+ try {
153
+ clearCapture();
154
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'session-a', cwd: dir }).code, 0);
155
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-a', cwd: dir });
156
+ assert.strictEqual(restart.code, 0, restart.stderr);
157
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
158
+ assert.match(restart.stdout, /State before compaction[\s\S]*branch work/);
159
+ } finally {
160
+ fs.rmSync(dir, { recursive: true, force: true });
161
+ clearCapture();
162
+ }
163
+ });
164
+
165
+ for (const [captureId, restartId] of [[undefined, 'session-a'], ['session-a', undefined], [undefined, undefined]]) {
166
+ test(`a compact restart without ${captureId ? 'restart' : restartId ? 'capture' : 'either'} session identity keeps only the warning`, () => {
167
+ const dir = fixture();
168
+ try {
169
+ clearCapture();
170
+ assert.strictEqual(run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: captureId, cwd: dir }).code, 0);
171
+ const restart = run(SESSION, dir, { hook_event_name: 'SessionStart', source: 'compact', session_id: restartId, cwd: dir });
172
+ assert.strictEqual(restart.code, 0, restart.stderr);
173
+ assert.match(restart.stdout, /SESSION COMPRESSED/);
174
+ assert.doesNotMatch(restart.stdout, /State before compaction/);
175
+ } finally {
176
+ fs.rmSync(dir, { recursive: true, force: true });
177
+ clearCapture();
178
+ }
179
+ });
180
+ }
181
+
122
182
  test('a normal session start prints no compaction anchors', () => {
123
183
  const dir = fixture();
124
184
  try {
@@ -138,7 +198,7 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
138
198
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-precompact-bare-'));
139
199
  try {
140
200
  clearCapture();
141
- const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', cwd: dir });
201
+ const noGit = run(PRECOMPACT, dir, { hook_event_name: 'PreCompact', session_id: 'compact-session', cwd: dir });
142
202
  assert.strictEqual(noGit.code, 0, 'a directory outside git must not fail the hook');
143
203
  const record = readCapture();
144
204
  assert.strictEqual(record.branch, null, 'unavailable git facts are recorded as null, never guessed');
@@ -147,12 +207,148 @@ test('PreCompact fails open outside a repository and on malformed input', () =>
147
207
 
148
208
  clearCapture();
149
209
  const malformed = spawnSync(process.execPath, [PRECOMPACT], {
150
- cwd: dir, input: 'not json', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
210
+ cwd: dir, input: 'not json', encoding: 'utf8', env: HOOK_ENV,
151
211
  });
152
212
  assert.strictEqual(malformed.status, 0, 'malformed payload must fail open');
153
- assert.strictEqual(fs.existsSync(RECOVERY), false, 'a malformed payload writes no capture');
213
+ assert.strictEqual(fs.existsSync(recoveryPath()), false, 'a malformed payload writes no capture');
154
214
  } finally {
155
215
  fs.rmSync(dir, { recursive: true, force: true });
156
216
  clearCapture();
157
217
  }
158
218
  });
219
+
220
+ function installedFixture(callback) {
221
+ const root = fixture();
222
+ const source = path.resolve(__dirname, '../..');
223
+ const manifest = require('../../migration-manifest.json');
224
+ const sessions = new Set();
225
+ try {
226
+ for (const relative of manifest.runtime.files) {
227
+ const target = path.join(root, '.claude', relative);
228
+ fs.mkdirSync(path.dirname(target), { recursive: true });
229
+ fs.copyFileSync(path.join(source, relative), target);
230
+ }
231
+ for (const relative of manifest.scripts.required) {
232
+ const target = path.join(root, '.claude/scripts', relative);
233
+ fs.mkdirSync(path.dirname(target), { recursive: true });
234
+ fs.copyFileSync(path.join(source, 'scripts', relative), target);
235
+ }
236
+ const hooks = path.join(root, '.claude/hooks');
237
+ const invoke = (name, payload, overrides = {}) => {
238
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) sessions.add(payload.session_id);
239
+ return spawnSync(process.execPath, [path.join(hooks, `${name}.cjs`)], {
240
+ cwd: root, env: HOOK_ENV, encoding: 'utf8', timeout: 10000,
241
+ input: typeof payload === 'string' ? payload : JSON.stringify(payload), ...overrides,
242
+ });
243
+ };
244
+ callback({ root: fs.realpathSync(root), hooks, invoke });
245
+ } finally {
246
+ for (const session of sessions) {
247
+ const key = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16);
248
+ fs.rmSync(path.join(os.tmpdir(), `cafekit-rules-${key}.json`), { force: true });
249
+ }
250
+ fs.rmSync(root, { recursive: true, force: true });
251
+ }
252
+ }
253
+
254
+ test('two compacting sessions keep separate anchors and restore only their own branch', () => {
255
+ installedFixture(({ root, hooks, invoke }) => {
256
+ const sessions = [crypto.randomUUID(), crypto.randomUUID()];
257
+ const files = [];
258
+ for (const [index, session_id] of sessions.entries()) {
259
+ const branch = `anchor-${index === 0 ? 'first' : 'second'}`;
260
+ const checkout = spawnSync('git', ['-C', root, 'checkout', '-qb', branch], { encoding: 'utf8' });
261
+ assert.strictEqual(checkout.status, 0, checkout.stderr);
262
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
263
+ assert.strictEqual(capture.status, 0, capture.stderr);
264
+ const hash = crypto.createHash('sha256').update(session_id).digest('hex');
265
+ files.push(path.join(hooks, '.logs', `compact-recovery-${hash}.json`));
266
+ }
267
+ for (const [index, file] of files.entries()) {
268
+ assert.ok(fs.existsSync(file), 'both session captures must survive');
269
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
270
+ assert.strictEqual(record.session_id, sessions[index]);
271
+ assert.strictEqual(record.branch, `anchor-${index === 0 ? 'first' : 'second'}`);
272
+ }
273
+ for (const [index, session_id] of sessions.entries()) {
274
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id, cwd: root });
275
+ assert.strictEqual(compact.status, 0, compact.stderr);
276
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
277
+ assert.match(compact.stdout, /State before compaction/);
278
+ assert.ok(compact.stdout.includes(`branch anchor-${index === 0 ? 'first' : 'second'}`));
279
+ assert.ok(!compact.stdout.includes(`branch anchor-${index === 0 ? 'second' : 'first'}`));
280
+ }
281
+ });
282
+ });
283
+
284
+ test('PreCompact without a usable session identity creates no anchor or state directory', () => {
285
+ installedFixture(({ root, hooks, invoke }) => {
286
+ for (const session_id of [undefined, null, '', 42, ' ']) {
287
+ const capture = invoke('precompact', { hook_event_name: 'PreCompact', session_id, cwd: root });
288
+ assert.strictEqual(capture.status, 0, capture.stderr);
289
+ assert.strictEqual(fs.existsSync(path.join(hooks, '.logs')), false);
290
+ }
291
+ });
292
+ });
293
+
294
+ test('compact reinjects rules only for its own session even without matching anchors', () => {
295
+ installedFixture(({ root, hooks, invoke }) => {
296
+ const session = crypto.randomUUID();
297
+ const other = crypto.randomUUID();
298
+ const prompt = id => invoke('rules', { hook_event_name: 'UserPromptSubmit', session_id: id, cwd: root });
299
+ for (const id of [session, other]) {
300
+ const first = prompt(id);
301
+ assert.strictEqual(first.status, 0, first.stderr);
302
+ assert.match(first.stdout, /## Rules/);
303
+ assert.strictEqual(prompt(id).stdout, '');
304
+ }
305
+ fs.mkdirSync(path.join(hooks, '.logs'), { recursive: true });
306
+ const otherHash = crypto.createHash('sha256').update(other).digest('hex');
307
+ fs.writeFileSync(path.join(hooks, '.logs', `compact-recovery-${otherHash}.json`), JSON.stringify({ session_id: other }));
308
+ const compact = invoke('session', { hook_event_name: 'SessionStart', source: 'compact', session_id: session, cwd: root });
309
+ assert.strictEqual(compact.status, 0, compact.stderr);
310
+ assert.match(compact.stdout, /SESSION COMPRESSED/);
311
+ assert.doesNotMatch(compact.stdout, /State before compaction/);
312
+ const again = prompt(session);
313
+ assert.strictEqual(again.status, 0, again.stderr);
314
+ assert.match(again.stdout, /## Rules/);
315
+ assert.strictEqual(prompt(other).stdout, '');
316
+ });
317
+ });
318
+
319
+ test('malformed session input fails open without a stack and logs its crash', () => {
320
+ installedFixture(({ hooks, invoke }) => {
321
+ const result = invoke('session', '{');
322
+ assert.strictEqual(result.status, 0);
323
+ assert.strictEqual(result.stderr, '');
324
+ const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
325
+ assert.strictEqual(rows.length, 1);
326
+ assert.strictEqual(rows[0].hook, 'session');
327
+ assert.strictEqual(rows[0].status, 'crash');
328
+ });
329
+ });
330
+
331
+ test('installed session explains a nested cwd but omits the root line without a cwd', () => {
332
+ installedFixture(({ root, invoke }) => {
333
+ const cwd = path.join(root, 'sub/dir');
334
+ fs.mkdirSync(cwd, { recursive: true });
335
+ const nested = invoke('session', { source: 'startup', cwd }, { cwd });
336
+ assert.strictEqual(nested.status, 0, nested.stderr);
337
+ assert.ok(nested.stdout.includes(`CafeKit project root: ${root} (session working directory: ${cwd}).`));
338
+ const missing = invoke('session', { source: 'startup' }, { cwd });
339
+ assert.strictEqual(missing.status, 0, missing.stderr);
340
+ assert.doesNotMatch(missing.stdout, /CafeKit project root:/);
341
+ });
342
+ });
343
+
344
+ test('PreCompact bounds slow git calls and finishes within ten seconds', { timeout: 15000 }, () => {
345
+ installedFixture(({ root, invoke }) => {
346
+ const bin = path.join(root, 'fake-bin');
347
+ fs.mkdirSync(bin);
348
+ fs.writeFileSync(path.join(bin, 'git'), '#!/bin/sh\nexec /bin/sleep 30\n', { mode: 0o755 });
349
+ const start = Date.now();
350
+ const result = invoke('precompact', { hook_event_name: 'PreCompact', session_id: crypto.randomUUID(), cwd: root }, { env: { ...HOOK_ENV, PATH: bin } });
351
+ assert.strictEqual(result.status, 0, 'slow git must not exhaust the hook timeout');
352
+ assert.ok(Date.now() - start < 10000);
353
+ });
354
+ });
@@ -11,12 +11,16 @@ const os = require('node:os');
11
11
  const path = require('node:path');
12
12
 
13
13
  const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
14
+ const HOOK_ENV = { ...process.env };
15
+ delete HOOK_ENV.CLAUDE_PROJECT_DIR;
16
+ delete HOOK_ENV.PROJECT_ROOT;
14
17
 
15
18
  /** Run the hook with a tool payload; return process output. */
16
19
  function runHook(payload, cwd) {
17
20
  const res = spawnSync(process.execPath, [HOOK], {
18
21
  input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
19
22
  encoding: 'utf8',
23
+ env: HOOK_ENV,
20
24
  });
21
25
  return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
22
26
  }
@@ -30,6 +34,29 @@ function permissionDecision(stdout) {
30
34
  return JSON.parse(stdout).hookSpecificOutput?.permissionDecision || null;
31
35
  }
32
36
 
37
+ test('sensitive reads list sorted distinct basenames and the remaining count', () => {
38
+ const dir = tmpDir();
39
+ try {
40
+ const command = 'cat token.json nested/.env secrets.json credentials.json .env';
41
+ const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
42
+ assert.strictEqual(code, 0);
43
+ const output = JSON.parse(stdout).hookSpecificOutput;
44
+ assert.strictEqual(output.permissionDecision, 'ask');
45
+ assert.strictEqual(output.permissionDecisionReason,
46
+ 'Sensitive file access requires approval: .env, credentials.json, secrets.json (+1 more)');
47
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
48
+ });
49
+
50
+ test('empty stdin asks because sensitive access cannot be evaluated safely', () => {
51
+ const result = spawnSync(process.execPath, [HOOK], { input: '', encoding: 'utf8', env: HOOK_ENV });
52
+ assert.strictEqual(result.status, 0);
53
+ assert.strictEqual(result.stderr, '');
54
+ assert.deepStrictEqual(JSON.parse(result.stdout).hookSpecificOutput, {
55
+ hookEventName: 'PreToolUse', permissionDecision: 'ask',
56
+ permissionDecisionReason: 'Sensitive access could not be evaluated safely.',
57
+ });
58
+ });
59
+
33
60
  test('asks natively for a direct Read of .env', () => {
34
61
  const dir = tmpDir();
35
62
  try {
@@ -17,6 +17,31 @@ const path = require('node:path');
17
17
 
18
18
  const HOOK = path.join(__dirname, '..', 'session.cjs');
19
19
 
20
+ test('session.cjs does not export GIT_URL or remote userinfo', () => {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-session-remote-'));
22
+ const envFile = path.join(dir, 'env.sh');
23
+ try {
24
+ git(['init', '-q'], dir);
25
+ const userinfo = 'fixture-user:fixture-placeholder';
26
+ const remote = `https://${userinfo}@example.invalid/r.git`;
27
+ const configured = spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: dir, encoding: 'utf8' });
28
+ assert.strictEqual(configured.status, 0, 'fake remote setup must succeed');
29
+ const result = spawnSync(process.execPath, [HOOK], {
30
+ cwd: dir, input: JSON.stringify({ source: 'startup', cwd: dir }), encoding: 'utf8',
31
+ env: { ...process.env, CLAUDE_ENV_FILE: envFile },
32
+ });
33
+ assert.strictEqual(result.status, 0, 'session hook must exit successfully');
34
+ assert.ok(fs.existsSync(envFile), 'session env file must exist');
35
+ const content = fs.readFileSync(envFile, 'utf8');
36
+ assert.deepStrictEqual({
37
+ exportsGitUrl: /^\s*(?:export\s+)?GIT_URL=/m.test(content),
38
+ containsUserInfo: content.includes(userinfo),
39
+ }, { exportsGitUrl: false, containsUserInfo: false });
40
+ } finally {
41
+ fs.rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
20
45
  function git(args, cwd) {
21
46
  const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
22
47
  if (res.status !== 0) {