@haposoft/cafekit 0.14.1 → 0.15.0

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 (100) hide show
  1. package/README.md +86 -11
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +259 -0
  5. package/bin/install.js +30 -8
  6. package/bin/lib/backup.js +82 -18
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +60 -9
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +98 -48
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +105 -66
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/CLAUDE.md +1 -1
  26. package/src/claude/agents/code-auditor.md +1 -1
  27. package/src/claude/agents/debugger.md +1 -0
  28. package/src/claude/agents/god-developer.md +10 -1
  29. package/src/claude/agents/inspector.md +1 -0
  30. package/src/claude/agents/spec-maker.md +1 -1
  31. package/src/claude/agents/test-runner.md +2 -2
  32. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  33. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  34. package/src/claude/hooks/__tests__/state.test.js +65 -0
  35. package/src/claude/hooks/docs-sync.cjs +15 -21
  36. package/src/claude/hooks/inspect-block.cjs +1 -1
  37. package/src/claude/hooks/lib/config.cjs +0 -5
  38. package/src/claude/hooks/privacy-block.cjs +39 -62
  39. package/src/claude/hooks/spec-gate.cjs +18 -7
  40. package/src/claude/hooks/spec-state.cjs +4 -0
  41. package/src/claude/hooks/state.cjs +13 -3
  42. package/src/claude/hooks/usage.cjs +6 -0
  43. package/src/claude/rules/orchestrator.md +2 -2
  44. package/src/claude/rules/skill-domain-routing.md +27 -86
  45. package/src/claude/rules/skill-workflow-routing.md +11 -82
  46. package/src/claude/rules/state-sync.md +1 -1
  47. package/src/claude/runtime.json +7 -7
  48. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  49. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  50. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  51. package/src/claude/skills/develop/SKILL.md +26 -5
  52. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  53. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  54. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  55. package/src/claude/skills/inspect/SKILL.md +42 -113
  56. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  57. package/src/claude/skills/specs/SKILL.md +134 -386
  58. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  59. package/src/claude/skills/specs/templates/task.md +3 -1
  60. package/src/claude/skills/sync/SKILL.md +2 -2
  61. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  62. package/src/claude/skills/test/SKILL.md +1 -1
  63. package/src/claude/status.cjs +9 -6
  64. package/src/codex/AGENTS.md +30 -0
  65. package/src/codex/agents-gitignore +11 -0
  66. package/src/codex/gitignore +12 -0
  67. package/src/codex/hooks/agent.cjs +66 -0
  68. package/src/codex/hooks/docs-sync.cjs +66 -0
  69. package/src/codex/hooks/inspect-block.cjs +40 -0
  70. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  71. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  72. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  73. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  74. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  75. package/src/codex/hooks/lib/state-store.cjs +166 -0
  76. package/src/codex/hooks/privacy-approval.cjs +59 -0
  77. package/src/codex/hooks/privacy-block.cjs +182 -0
  78. package/src/codex/hooks/rules.cjs +79 -0
  79. package/src/codex/hooks/session.cjs +114 -0
  80. package/src/codex/hooks/spec-gate.cjs +80 -0
  81. package/src/codex/hooks/spec-state.cjs +73 -0
  82. package/src/codex/hooks/state.cjs +157 -0
  83. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  84. package/src/codex/hooks.json +122 -0
  85. package/src/codex/rules/hook-protocols.md +32 -0
  86. package/src/codex/rules/state-sync.md +41 -0
  87. package/src/codex/runtime.json +28 -0
  88. package/src/opencode/plugins/docs-sync.ts +10 -16
  89. package/src/claude/archive-command/code/SKILL.md +0 -55
  90. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  91. package/src/claude/archive-command/code.md +0 -17
  92. package/src/claude/archive-command/docs.md +0 -609
  93. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  94. package/src/claude/archive-command/spec-design.md +0 -247
  95. package/src/claude/archive-command/spec-init.md +0 -120
  96. package/src/claude/archive-command/spec-requirements.md +0 -138
  97. package/src/claude/archive-command/spec-status.md +0 -98
  98. package/src/claude/archive-command/spec-tasks.md +0 -173
  99. package/src/claude/archive-command/spec-validate.md +0 -118
  100. package/src/claude/archive-command/test.md +0 -8
@@ -22,30 +22,39 @@ const OTHER = '__other__';
22
22
  * Read saved locale label from .claude/runtime.json (written by post-install).
23
23
  * Returns the freeform label string (e.g. "Tiếng Việt") or null.
24
24
  */
25
- function getInstalledLocale() {
26
- const runtimeJson = path.join(process.cwd(), '.claude', 'runtime.json');
27
- if (!fs.existsSync(runtimeJson)) return null;
28
- try {
29
- const data = JSON.parse(fs.readFileSync(runtimeJson, 'utf8'));
30
- return (data && data.locale && typeof data.locale.responseLanguage === 'string')
31
- ? data.locale.responseLanguage
32
- : null;
33
- } catch { return null; }
25
+ function getInstalledLocale(platformKeys = getPlatformKeys()) {
26
+ for (const key of platformKeys) {
27
+ const runtimeJson = path.join(process.cwd(), PLATFORMS[key].folder, 'runtime.json');
28
+ if (!fs.existsSync(runtimeJson)) continue;
29
+ try {
30
+ const data = JSON.parse(fs.readFileSync(runtimeJson, 'utf8'));
31
+ if (data && data.locale && typeof data.locale.responseLanguage === 'string') {
32
+ return data.locale.responseLanguage;
33
+ }
34
+ } catch { /* try the next installed runtime */ }
35
+ }
36
+ return null;
34
37
  }
35
38
 
36
39
  /** First step: pick the installer/UI language (interactive only). */
37
40
  async function selectLanguage(ctx) {
38
- if (!ctx.interactive || ctx.options.lang) return ctx;
39
-
40
- // If already installed, restore saved locale and skip the prompt
41
- const savedLocale = getInstalledLocale();
42
- if (savedLocale) {
43
- const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
44
- ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
45
- ctx.ui.info(ctx.t('langKept', { lang: savedLocale }));
46
- return ctx;
41
+ // Restore the saved locale BEFORE the interactivity check: a non-interactive
42
+ // upgrade (--yes) must not forget the configured language. Skipping this left
43
+ // ctx at the 'en' default, and patchRuntimeLocale then clobbered the user's
44
+ // responseLanguage on every upgrade.
45
+ if (!ctx.options.lang) {
46
+ const requested = (ctx.options.platforms || []).filter((key) => PLATFORMS[key]);
47
+ const savedLocale = getInstalledLocale(requested.length ? requested : getPlatformKeys());
48
+ if (savedLocale) {
49
+ const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
50
+ ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
51
+ if (ctx.interactive) ctx.ui.info(ctx.t('langKept', { lang: savedLocale }));
52
+ return ctx;
53
+ }
47
54
  }
48
55
 
56
+ if (!ctx.interactive || ctx.options.lang) return ctx;
57
+
49
58
  const options = [
50
59
  ...SUPPORTED.map((code) => ({ value: code, label: LANGUAGE_LABELS[code] })),
51
60
  { value: OTHER, label: OTHER_LABEL.en + ' / その他 / Ngôn ngữ khác' }
@@ -99,26 +108,67 @@ async function confirmAllDetected(ctx, detected) {
99
108
  return r;
100
109
  }
101
110
 
111
+ function platformNames(platforms) {
112
+ return platforms.map((key) => PLATFORMS[key].name).join(', ');
113
+ }
114
+
115
+ function reportInstallMode(ctx) {
116
+ if (ctx.options.forceOverwrite) {
117
+ ctx.ui.info(ctx.t('modeForce'));
118
+ } else if (ctx.dryRun) {
119
+ ctx.ui.info(ctx.t('modeDryRun'));
120
+ } else {
121
+ ctx.ui.info(ctx.t('modeInstall'));
122
+ }
123
+ }
124
+
125
+ function cancelSelection(ctx) {
126
+ ctx.ui.outro(ctx.t('cancelled'));
127
+ ctx.cancelled = true;
128
+ return [];
129
+ }
130
+
131
+ async function promptPlatforms(ctx) {
132
+ const platforms = await promptPlatformSelection(ctx);
133
+ return platforms.length > 0 ? platforms : cancelSelection(ctx);
134
+ }
135
+
102
136
  /**
103
137
  * Resolve ctx.platforms via detection + prompts. Sets ctx.cancelled on backout.
104
138
  * If cafekit.json exists (prior install), reads platform from it to skip prompt.
105
139
  * Note: ctx.isUpdate is set later by checkVersions and cannot be used here.
106
140
  */
107
141
  async function resolvePlatforms(ctx) {
142
+ const requested = [...new Set((ctx.options.platforms || []).map((key) => key.trim()).filter(Boolean))];
143
+ if (requested.length > 0) {
144
+ const unknown = requested.filter((key) => !PLATFORMS[key]);
145
+ if (unknown.length > 0) {
146
+ throw new Error(`Unknown platform: ${unknown.join(', ')}. Expected: ${getPlatformKeys().join(', ')}`);
147
+ }
148
+ ctx.platforms = requested;
149
+ ctx.ui.info(ctx.t('installingFor', {
150
+ names: platformNames(requested)
151
+ }));
152
+ warnLegacyClaudeFolder(requested);
153
+ reportInstallMode(ctx);
154
+ return ctx;
155
+ }
156
+
108
157
  // Skip platform prompt when a prior install is detected via cafekit.json
109
- const savedPlatform = getInstalledPlatform();
110
- if (savedPlatform) {
111
- ctx.platforms = [savedPlatform];
112
- const platformNames = PLATFORMS[savedPlatform].name;
113
- ctx.ui.info(ctx.t('platformKept', { names: platformNames }));
114
-
115
- if (ctx.options.forceOverwrite) {
116
- ctx.ui.info(ctx.t('modeForce'));
117
- } else if (ctx.dryRun) {
118
- ctx.ui.info(ctx.t('modeDryRun'));
119
- } else {
120
- ctx.ui.info(ctx.t('modeInstall'));
158
+ const savedPlatforms = getInstalledPlatforms();
159
+ if (savedPlatforms.length > 0) {
160
+ let platforms = [...new Set([...savedPlatforms, ...detectPlatforms()])];
161
+ if (ctx.interactive && platforms.some((key) => !savedPlatforms.includes(key))) {
162
+ const proceed = await confirmAllDetected(ctx, platforms);
163
+ if (!proceed) {
164
+ platforms = await promptPlatforms(ctx);
165
+ if (ctx.cancelled) return ctx;
166
+ }
121
167
  }
168
+ ctx.platforms = platforms;
169
+ ctx.ui.info(ctx.t('platformKept', { names: platformNames(platforms) }));
170
+
171
+ reportInstallMode(ctx);
122
172
  return ctx;
123
173
  }
124
174
 
@@ -126,12 +176,8 @@ async function resolvePlatforms(ctx) {
126
176
 
127
177
  if (platforms.length === 0) {
128
178
  if (ctx.interactive) {
129
- platforms = await promptPlatformSelection(ctx);
130
- if (platforms.length === 0) {
131
- ctx.ui.outro(ctx.t('cancelled'));
132
- ctx.cancelled = true;
133
- return ctx;
134
- }
179
+ platforms = await promptPlatforms(ctx);
180
+ if (ctx.cancelled) return ctx;
135
181
  } else {
136
182
  platforms = ['claude'];
137
183
  ctx.ui.info('No platform detected; defaulting to Claude Code (.claude/).');
@@ -139,28 +185,17 @@ async function resolvePlatforms(ctx) {
139
185
  } else if (platforms.length > 1 && ctx.interactive) {
140
186
  const proceed = await confirmAllDetected(ctx, platforms);
141
187
  if (!proceed) {
142
- platforms = await promptPlatformSelection(ctx);
143
- if (platforms.length === 0) {
144
- ctx.ui.outro(ctx.t('cancelled'));
145
- ctx.cancelled = true;
146
- return ctx;
147
- }
188
+ platforms = await promptPlatforms(ctx);
189
+ if (ctx.cancelled) return ctx;
148
190
  }
149
191
  }
150
192
 
151
193
  ctx.platforms = platforms;
152
194
 
153
- const platformNames = platforms.map((key) => PLATFORMS[key].name).join(', ');
154
- ctx.ui.info(ctx.t('installingFor', { names: platformNames }));
195
+ ctx.ui.info(ctx.t('installingFor', { names: platformNames(platforms) }));
155
196
  warnLegacyClaudeFolder(platforms);
156
197
 
157
- if (ctx.options.forceOverwrite) {
158
- ctx.ui.info(ctx.t('modeForce'));
159
- } else if (ctx.dryRun) {
160
- ctx.ui.info(ctx.t('modeDryRun'));
161
- } else {
162
- ctx.ui.info(ctx.t('modeInstall'));
163
- }
198
+ reportInstallMode(ctx);
164
199
 
165
200
  return ctx;
166
201
  }
@@ -169,24 +204,28 @@ async function resolvePlatforms(ctx) {
169
204
  * Get the installed platform from cafekit.json files.
170
205
  * Returns the platform key (e.g. 'claude', 'opencode') or null.
171
206
  */
172
- function getInstalledPlatform() {
173
- const claudeJson = path.join(process.cwd(), '.claude', 'cafekit.json');
174
- if (fs.existsSync(claudeJson)) {
175
- try {
176
- const data = JSON.parse(fs.readFileSync(claudeJson, 'utf8'));
177
- if (data.platform) return data.platform;
178
- } catch { /* ignore */ }
179
- }
180
-
181
- const opencodeJson = path.join(process.cwd(), '.opencode', 'cafekit.json');
182
- if (fs.existsSync(opencodeJson)) {
207
+ function getInstalledPlatforms() {
208
+ const installed = [];
209
+ for (const key of getPlatformKeys()) {
210
+ const metadataPath = path.join(process.cwd(), PLATFORMS[key].folder, 'cafekit.json');
211
+ if (!fs.existsSync(metadataPath)) continue;
183
212
  try {
184
- const data = JSON.parse(fs.readFileSync(opencodeJson, 'utf8'));
185
- if (data.platform) return data.platform;
213
+ const data = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
214
+ const platformKey = data.platform && PLATFORMS[data.platform] ? data.platform : key;
215
+ if (!installed.includes(platformKey)) installed.push(platformKey);
186
216
  } catch { /* ignore */ }
187
217
  }
218
+ return installed;
219
+ }
188
220
 
189
- return null;
221
+ function getInstalledPlatform() {
222
+ return getInstalledPlatforms()[0] || null;
190
223
  }
191
224
 
192
- module.exports = { selectLanguage, promptPlatformSelection, resolvePlatforms };
225
+ module.exports = {
226
+ selectLanguage,
227
+ promptPlatformSelection,
228
+ resolvePlatforms,
229
+ getInstalledPlatform,
230
+ getInstalledPlatforms
231
+ };
@@ -53,7 +53,12 @@ function printSummary(ctx) {
53
53
 
54
54
  const lines = [];
55
55
  for (const key of platforms) {
56
- lines.push(key === 'claude' ? t('nsClaude') : t('nsOpencode'));
56
+ const messageKey = {
57
+ claude: 'nsClaude',
58
+ opencode: 'nsOpencode',
59
+ codex: 'nsCodex'
60
+ }[key];
61
+ if (messageKey) lines.push(t(messageKey));
57
62
  }
58
63
  // rtk token-saver result (set by phases/setup-rtk.js when setup completed).
59
64
  if (ctx.rtkSetupRan) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.14.1",
4
- "description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
3
+ "version": "0.15.0",
4
+ "description": "Native spec-driven workflows for Claude Code, Codex CLI, and OpenCode. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -34,6 +34,7 @@
34
34
  "spec-driven",
35
35
  "workflow",
36
36
  "claude-code",
37
+ "codex-cli",
37
38
  "ai-coding",
38
39
  "specification",
39
40
  "requirements",
@@ -37,7 +37,7 @@ These rules reduce common agent coding failures: hidden assumptions, overbuilt s
37
37
  ### 4. Goal-Driven Execution
38
38
 
39
39
  - Convert requests into verifiable success criteria.
40
- - For spec tasks, use `Completion Criteria` and `Evidence` as the source of truth. Existing task files may use `Task Test Plan & Verification Evidence` or legacy `Verification & Evidence`.
40
+ - For spec tasks, use `Completion Criteria` and `## Evidence` (legacy heading aliases still parse) as the source of truth.
41
41
  - For bugs, reproduce with a failing test or concrete evidence when feasible before fixing.
42
42
  - Loop until verification passes or a real blocker is recorded.
43
43
 
@@ -23,7 +23,7 @@ Extract and verify:
23
23
  1. Declared deliverables (files, routes, entrypoints, UI surfaces, schemas, migrations)
24
24
  2. Declared task scope (`Related Files` and direct support files that are clearly justified)
25
25
  3. Completion Criteria
26
- 4. Task Evidence expectations (or Task Test Plan & Verification Evidence / legacy Verification & Evidence)
26
+ 4. Task `## Evidence` (legacy heading aliases still parse) expectations
27
27
  5. Canonical Contracts & Invariants from the design
28
28
  6. Named technologies and runtime choices that the task/spec explicitly requires
29
29
  7. Runtime entrypoints/callers and reachability obligations from task evidence or the task-aware scout report
@@ -2,6 +2,7 @@
2
2
  name: debugger
3
3
  description: "Investigates bugs, incidents, CI/log/DB/performance/frontend failures, traces exact root causes with evidence, and hands off a verification-ready fix plan. Edits code only when explicitly requested by a fix workflow."
4
4
  model: sonnet
5
+ memory: user
5
6
  tools: Glob, Grep, Read, Bash, WebFetch, WebSearch
6
7
  ---
7
8
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: god-developer
3
- description: "Primary code execution agent. Receives specifications (spec) from hapo:specs or task files and transforms them into production-grade source code. Operates on a Single-Track principle (linear, non-parallel)."
3
+ description: "Primary code execution agent. Receives specifications (spec) from hapo:specs or task files and transforms them into production-grade source code. Operates on a Single-Track principle within its workspace (linear inside one working tree; multiple instances may run concurrently in separate isolated worktrees)."
4
4
  model: sonnet
5
5
  tools: Glob, Grep, Read, Edit, Write, NotebookEdit, Bash, WebFetch, WebSearch
6
6
  ---
@@ -94,3 +94,12 @@ Upon completion, output a concise report in this format:
94
94
  - Do not add AI attribution to code or commit messages.
95
95
  - Prioritize security (validate input, never hardcode secrets).
96
96
  - Code should be self-documenting — only add comments for complex logic.
97
+
98
+ ## Worktree Conduct (Parallel Wave dispatch)
99
+
100
+ When dispatched into an isolated git worktree by `hapo:develop --parallel`:
101
+
102
+ - Work ONLY inside this worktree; Single-Track discipline applies unchanged within it.
103
+ - Do NOT edit `spec.json` or any `tasks/*.md` — the orchestrator is the single writer of spec state.
104
+ - Do NOT touch files outside this task's `Related Files`.
105
+ - Commit your completed work: `git commit -m "task(<id>): <title>"` — an uncommitted worktree cannot be merged back.
@@ -3,6 +3,7 @@ name: inspector
3
3
  tools: Glob, Grep, Read, Bash
4
4
  description: "Codebase structure scanner. Use this agent when you need to quickly scout/inspect the codebase architecture, files, and directories. Specializes in finding relevant files for a given work scope before implementation begins."
5
5
  model: haiku
6
+ memory: user
6
7
  ---
7
8
 
8
9
  # Inspect — Codebase Scout
@@ -127,7 +127,7 @@ Before writing `design.md`, select a discovery mode and record the reason:
127
127
  - When requirement coverage format: list numeric IDs only, no descriptive suffixes
128
128
  - Apply `(P)` parallel markers when applicable (load `.claude/skills/specs/rules/tasks-parallel-analysis.md`)
129
129
  - Every task MUST use the compact implementation-ready shape: `Context`, `Steps`, `Requirements`, `Related Files`, `Completion Criteria`, `Evidence`, `Risk Assessment`.
130
- - `Evidence` MUST include exact commands, artifacts/runtime surfaces, runtime reachability proof, and negative-path checks. Existing specs may use `Task Test Plan & Verification Evidence` or legacy `Verification & Evidence`.
130
+ - `## Evidence` (legacy heading aliases still parse) MUST include exact commands, artifacts/runtime surfaces, runtime reachability proof, and negative-path checks.
131
131
  - Completion criteria MUST be objective enough that a downstream quality gate can prove them without guesswork.
132
132
  - UI/app/runtime workflows MUST include a final integration/reachability task or final integration section that names the real entrypoint and proves all scoped user-facing surfaces are wired.
133
133
  - Do not allow orphan task outputs: components, services, hooks, routes, commands, workers, providers, reducers, data loaders, and generated artifacts must be reachable now or assigned to a named later integration task.
@@ -11,7 +11,7 @@ You are a battle-hardened QA engineer who has been burned by production incident
11
11
 
12
12
  ## Task-Aware Inputs
13
13
 
14
- If the prompt includes task file paths, Completion Criteria, Evidence, Task Test Plan & Verification Evidence, or legacy Verification & Evidence instructions, treat them as authoritative.
14
+ If the prompt includes task file paths, Completion Criteria, or `## Evidence` (legacy heading aliases still parse) instructions, treat them as authoritative.
15
15
  Diff-aware test selection does NOT replace task-specific verification.
16
16
  If the task/spec names a specific framework, auth system, transport, or shared-state boundary, keep that contract visible while evaluating evidence.
17
17
  If the prompt includes a feature name or `specs/<feature>`, load `spec.json`, `requirements.md`, `design.md`, and the active/recent task files. Treat `scope_lock`, Completion Criteria, and Evidence as the test contract.
@@ -30,7 +30,7 @@ Select tests by the task's touched surface:
30
30
  ## Command Resolution Order
31
31
 
32
32
  When the task file names exact commands, use this order:
33
- 1. Run every exact executable command from `Evidence` (or `Task Test Plan & Verification Evidence` / legacy `Verification & Evidence`) in declaration order.
33
+ 1. Run every exact executable command from `## Evidence` (legacy heading aliases still parse) in declaration order.
34
34
  2. Run repo-default typecheck/test/build commands only to fill gaps not already covered above.
35
35
  3. Apply diff-aware test selection only after task-mandated commands are satisfied.
36
36
 
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // Behavioral tests for privacy-block.cjs. The hook is run as a real subprocess:
4
- // a PreToolUse payload is piped to stdin and the exit code is asserted
5
- // (0 = allow, 2 = block). Covers the symlink-bypass hardening.
4
+ // a PreToolUse payload is piped to stdin and native permission output asserted.
6
5
 
7
6
  const { test } = require('node:test');
8
7
  const assert = require('node:assert');
@@ -13,40 +12,47 @@ const path = require('node:path');
13
12
 
14
13
  const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
15
14
 
16
- /** Run the hook with a tool payload; return { code, stderr }. */
15
+ /** Run the hook with a tool payload; return process output. */
17
16
  function runHook(payload, cwd) {
18
17
  const res = spawnSync(process.execPath, [HOOK], {
19
18
  input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
20
19
  encoding: 'utf8',
21
20
  });
22
- return { code: res.status, stderr: res.stderr || '' };
21
+ return { code: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
23
22
  }
24
23
 
25
24
  function tmpDir() {
26
25
  return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-'));
27
26
  }
28
27
 
29
- test('blocks a direct Read of .env (exit 2)', () => {
28
+ function permissionDecision(stdout) {
29
+ if (!stdout.trim()) return null;
30
+ return JSON.parse(stdout).hookSpecificOutput?.permissionDecision || null;
31
+ }
32
+
33
+ test('asks natively for a direct Read of .env', () => {
30
34
  const dir = tmpDir();
31
35
  try {
32
36
  const env = path.join(dir, '.env');
33
37
  fs.writeFileSync(env, 'SECRET=1');
34
- const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: env } }, dir);
35
- assert.strictEqual(code, 2);
38
+ const { code, stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: env } }, dir);
39
+ assert.strictEqual(code, 0);
40
+ assert.strictEqual(permissionDecision(stdout), 'ask');
36
41
  } finally {
37
42
  fs.rmSync(dir, { recursive: true, force: true });
38
43
  }
39
44
  });
40
45
 
41
- test('blocks a symlink whose target is .env (regression: symlink bypass)', () => {
46
+ test('asks for a symlink whose target is .env (regression: symlink bypass)', () => {
42
47
  const dir = tmpDir();
43
48
  try {
44
49
  const env = path.join(dir, '.env');
45
50
  fs.writeFileSync(env, 'SECRET=1');
46
51
  const link = path.join(dir, 'notes.txt'); // harmless-looking name
47
52
  fs.symlinkSync(env, link);
48
- const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
49
- assert.strictEqual(code, 2, 'symlink to .env must be blocked, not allowed');
53
+ const { code, stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
54
+ assert.strictEqual(code, 0);
55
+ assert.strictEqual(permissionDecision(stdout), 'ask');
50
56
  } finally {
51
57
  fs.rmSync(dir, { recursive: true, force: true });
52
58
  }
@@ -77,3 +83,37 @@ test('allows a symlink to an exempt .env.example (exemption still wins)', () =>
77
83
  fs.rmSync(dir, { recursive: true, force: true });
78
84
  }
79
85
  });
86
+
87
+ test('an exempt-looking symlink to .env still asks based on its target', () => {
88
+ const dir = tmpDir();
89
+ try {
90
+ const env = path.join(dir, '.env');
91
+ fs.writeFileSync(env, 'SECRET=1');
92
+ const link = path.join(dir, '.env.example');
93
+ fs.symlinkSync(env, link);
94
+ const { stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
95
+ assert.strictEqual(permissionDecision(stdout), 'ask');
96
+ } finally {
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ for (const command of [
102
+ 'cat ".env"',
103
+ 'cat .env',
104
+ 'cat .env*',
105
+ 'sed -n 1p .env',
106
+ "bash -c 'sed -n 1p .env'",
107
+ ]) {
108
+ test(`asks natively for Bash sensitive reference: ${command}`, () => {
109
+ const dir = tmpDir();
110
+ try {
111
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
112
+ const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
113
+ assert.strictEqual(code, 0);
114
+ assert.strictEqual(permissionDecision(stdout), 'ask');
115
+ } finally {
116
+ fs.rmSync(dir, { recursive: true, force: true });
117
+ }
118
+ });
119
+ }
@@ -64,7 +64,7 @@ function readCache() {
64
64
  * @param {string} [opts.taskStatus='done']
65
65
  * @param {string|null} [opts.completed_at='2026-07-01T00:00:00Z']
66
66
  * @param {string} [opts.mdStatus='done']
67
- * @param {'valid'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
67
+ * @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
68
68
  * @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
69
69
  */
70
70
  function makeFixture(opts = {}) {
@@ -97,12 +97,19 @@ function makeFixture(opts = {}) {
97
97
  evidenceBlock = [
98
98
  '## Evidence',
99
99
  '',
100
+ 'Verification: PASS',
100
101
  '```',
101
102
  'npm test',
102
103
  'PASS: 10 tests',
103
104
  '```',
104
105
  '',
105
106
  ].join('\n');
107
+ } else if (evidence === 'legacy-valid') {
108
+ evidenceBlock = '## Evidence\n\nnpm test — passed, exit code 0\n';
109
+ } else if (evidence === 'failed') {
110
+ evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
111
+ } else if (evidence === 'fence-only') {
112
+ evidenceBlock = '## Evidence\n\n```\nnpm test\n```\n';
106
113
  } else if (evidence === 'placeholder') {
107
114
  evidenceBlock = [
108
115
  '## Evidence',
@@ -268,3 +275,70 @@ test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
268
275
  fs.rmSync(dir, { recursive: true, force: true });
269
276
  }
270
277
  });
278
+
279
+ test('8. explicit failure cannot pass even when PASS text is present', () => {
280
+ const dir = makeFixture({ evidence: 'failed' });
281
+ try {
282
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
283
+ const body = parseBlock(runHook({}, dir).stdout);
284
+ assert.ok(body);
285
+ assert.match(body.reason, /\bc\b/);
286
+ } finally {
287
+ fs.rmSync(dir, { recursive: true, force: true });
288
+ }
289
+ });
290
+
291
+ test('9. a code fence alone is not verification proof', () => {
292
+ const dir = makeFixture({ evidence: 'fence-only' });
293
+ try {
294
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
295
+ assert.ok(parseBlock(runHook({}, dir).stdout));
296
+ } finally {
297
+ fs.rmSync(dir, { recursive: true, force: true });
298
+ }
299
+ });
300
+
301
+ test('10. completed_at must be a valid ISO timestamp', () => {
302
+ const dir = makeFixture({ completed_at: 'yesterday' });
303
+ try {
304
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
305
+ const body = parseBlock(runHook({}, dir).stdout);
306
+ assert.ok(body);
307
+ assert.match(body.reason, /\bd\b/);
308
+ } finally {
309
+ fs.rmSync(dir, { recursive: true, force: true });
310
+ }
311
+ });
312
+
313
+ test('11. done → pending cache transition is persisted and can be gated again', () => {
314
+ const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending' });
315
+ try {
316
+ seedCache({ [FEATURE]: { [TASK_REL]: 'done' } });
317
+ assert.strictEqual(runHook({}, dir).stdout, '');
318
+ assert.strictEqual(readCache()[FEATURE][TASK_REL], 'pending');
319
+
320
+ const specFile = path.join(dir, 'specs', FEATURE, 'spec.json');
321
+ const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
322
+ spec.task_registry[TASK_REL] = {
323
+ status: 'done',
324
+ completed_at: '2026-07-01T00:00:00Z',
325
+ };
326
+ fs.writeFileSync(specFile, JSON.stringify(spec));
327
+ const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
328
+ fs.writeFileSync(taskFile, '# Task\n\n**Status:** done\n\n## Evidence\n\nFAIL: tests failed\n');
329
+
330
+ assert.ok(parseBlock(runHook({}, dir).stdout), 're-completed failing task must be gated');
331
+ } finally {
332
+ fs.rmSync(dir, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+ test('12. legacy successful receipt remains read-compatible', () => {
337
+ const dir = makeFixture({ evidence: 'legacy-valid' });
338
+ try {
339
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
340
+ assert.strictEqual(runHook({}, dir).stdout, '');
341
+ } finally {
342
+ fs.rmSync(dir, { recursive: true, force: true });
343
+ }
344
+ });
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const { spawnSync } = require('node:child_process');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const HOOK = path.join(__dirname, '..', 'state.cjs');
11
+
12
+ test('Stop snapshot includes tracked modifications and untracked files', () => {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-'));
14
+ try {
15
+ fs.mkdirSync(path.join(dir, '.claude'));
16
+ spawnSync('git', ['init', '-q'], { cwd: dir });
17
+ spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
18
+ spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
19
+ fs.writeFileSync(path.join(dir, 'tracked.txt'), 'before\n');
20
+ spawnSync('git', ['add', 'tracked.txt'], { cwd: dir });
21
+ spawnSync('git', ['commit', '-qm', 'initial'], { cwd: dir });
22
+
23
+ fs.writeFileSync(path.join(dir, 'tracked.txt'), 'after\n');
24
+ fs.writeFileSync(path.join(dir, 'new-untracked.json'), '{}\n');
25
+ const result = spawnSync(process.execPath, [HOOK], {
26
+ cwd: dir,
27
+ input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
28
+ encoding: 'utf8',
29
+ });
30
+
31
+ assert.strictEqual(result.status, 0);
32
+ const state = fs.readFileSync(
33
+ path.join(dir, '.claude', 'session-state', 'latest.md'),
34
+ 'utf8',
35
+ );
36
+ assert.match(state, /- tracked\.txt/);
37
+ assert.match(state, /- new-untracked\.json/);
38
+ } finally {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('Stop snapshot includes untracked files before the first commit', () => {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-state-no-head-'));
45
+ try {
46
+ fs.mkdirSync(path.join(dir, '.claude'));
47
+ spawnSync('git', ['init', '-q'], { cwd: dir });
48
+ fs.writeFileSync(path.join(dir, 'first-untracked.txt'), 'new\n');
49
+
50
+ const result = spawnSync(process.execPath, [HOOK], {
51
+ cwd: dir,
52
+ input: JSON.stringify({ hook_event_name: 'Stop', cwd: dir }),
53
+ encoding: 'utf8',
54
+ });
55
+
56
+ assert.strictEqual(result.status, 0);
57
+ const state = fs.readFileSync(
58
+ path.join(dir, '.claude', 'session-state', 'latest.md'),
59
+ 'utf8',
60
+ );
61
+ assert.match(state, /- first-untracked\.txt/);
62
+ } finally {
63
+ fs.rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ });