@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.
- package/README.md +86 -11
- package/bin/__tests__/codex-hooks.test.js +331 -0
- package/bin/__tests__/codex-native.test.js +458 -0
- package/bin/__tests__/installer-safety.test.js +259 -0
- package/bin/install.js +30 -8
- package/bin/lib/backup.js +82 -18
- package/bin/lib/codex-frontmatter.js +56 -0
- package/bin/lib/codex-install.js +195 -0
- package/bin/lib/context.js +65 -8
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/managed-writer.js +17 -2
- package/bin/lib/manifest.js +21 -4
- package/bin/lib/opencode-install.js +21 -19
- package/bin/lib/path-safety.js +40 -0
- package/bin/lib/version-check.js +25 -12
- package/bin/phases/claude-runtime.js +60 -9
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +98 -48
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +105 -66
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/code-auditor.md +1 -1
- package/src/claude/agents/debugger.md +1 -0
- package/src/claude/agents/god-developer.md +10 -1
- package/src/claude/agents/inspector.md +1 -0
- package/src/claude/agents/spec-maker.md +1 -1
- package/src/claude/agents/test-runner.md +2 -2
- package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
- package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
- package/src/claude/hooks/__tests__/state.test.js +65 -0
- package/src/claude/hooks/docs-sync.cjs +15 -21
- package/src/claude/hooks/inspect-block.cjs +1 -1
- package/src/claude/hooks/lib/config.cjs +0 -5
- package/src/claude/hooks/privacy-block.cjs +39 -62
- package/src/claude/hooks/spec-gate.cjs +18 -7
- package/src/claude/hooks/spec-state.cjs +4 -0
- package/src/claude/hooks/state.cjs +13 -3
- package/src/claude/hooks/usage.cjs +6 -0
- package/src/claude/rules/orchestrator.md +2 -2
- package/src/claude/rules/skill-domain-routing.md +27 -86
- package/src/claude/rules/skill-workflow-routing.md +11 -82
- package/src/claude/rules/state-sync.md +1 -1
- package/src/claude/runtime.json +7 -7
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/claude/scripts/validate-spec-output.cjs +6 -0
- package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
- package/src/claude/skills/develop/SKILL.md +26 -5
- package/src/claude/skills/develop/references/parallel-waves.md +112 -0
- package/src/claude/skills/develop/references/quality-gate.md +10 -2
- package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
- package/src/claude/skills/inspect/SKILL.md +42 -113
- package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
- package/src/claude/skills/specs/SKILL.md +134 -386
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/task.md +3 -1
- package/src/claude/skills/sync/SKILL.md +2 -2
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +1 -1
- package/src/claude/status.cjs +9 -6
- package/src/codex/AGENTS.md +30 -0
- package/src/codex/agents-gitignore +11 -0
- package/src/codex/gitignore +12 -0
- package/src/codex/hooks/agent.cjs +66 -0
- package/src/codex/hooks/docs-sync.cjs +66 -0
- package/src/codex/hooks/inspect-block.cjs +40 -0
- package/src/codex/hooks/lib/hook-context.cjs +93 -0
- package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
- package/src/codex/hooks/lib/privacy-state.cjs +199 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
- package/src/codex/hooks/lib/spec-utils.cjs +50 -0
- package/src/codex/hooks/lib/state-store.cjs +166 -0
- package/src/codex/hooks/privacy-approval.cjs +59 -0
- package/src/codex/hooks/privacy-block.cjs +182 -0
- package/src/codex/hooks/rules.cjs +79 -0
- package/src/codex/hooks/session.cjs +114 -0
- package/src/codex/hooks/spec-gate.cjs +80 -0
- package/src/codex/hooks/spec-state.cjs +73 -0
- package/src/codex/hooks/state.cjs +157 -0
- package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
- package/src/codex/hooks.json +122 -0
- package/src/codex/rules/hook-protocols.md +32 -0
- package/src/codex/rules/state-sync.md +41 -0
- package/src/codex/runtime.json +28 -0
- package/src/opencode/plugins/docs-sync.ts +10 -16
- package/src/claude/archive-command/code/SKILL.md +0 -55
- package/src/claude/archive-command/code/references/execution-loop.md +0 -21
- package/src/claude/archive-command/code.md +0 -17
- package/src/claude/archive-command/docs.md +0 -609
- package/src/claude/archive-command/review/codebase/parallel.md +0 -76
- package/src/claude/archive-command/spec-design.md +0 -247
- package/src/claude/archive-command/spec-init.md +0 -120
- package/src/claude/archive-command/spec-requirements.md +0 -138
- package/src/claude/archive-command/spec-status.md +0 -98
- package/src/claude/archive-command/spec-tasks.md +0 -173
- package/src/claude/archive-command/spec-validate.md +0 -118
- 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
130
|
-
if (
|
|
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
|
|
143
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
185
|
-
|
|
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
|
-
|
|
221
|
+
function getInstalledPlatform() {
|
|
222
|
+
return getInstalledPlatforms()[0] || null;
|
|
190
223
|
}
|
|
191
224
|
|
|
192
|
-
module.exports = {
|
|
225
|
+
module.exports = {
|
|
226
|
+
selectLanguage,
|
|
227
|
+
promptPlatformSelection,
|
|
228
|
+
resolvePlatforms,
|
|
229
|
+
getInstalledPlatform,
|
|
230
|
+
getInstalledPlatforms
|
|
231
|
+
};
|
package/bin/phases/summary.js
CHANGED
|
@@ -53,7 +53,12 @@ function printSummary(ctx) {
|
|
|
53
53
|
|
|
54
54
|
const lines = [];
|
|
55
55
|
for (const key of platforms) {
|
|
56
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|
package/src/claude/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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('
|
|
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,
|
|
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
|
+
});
|