@haposoft/cafekit 0.14.1 → 0.14.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.
- package/README.md +7 -3
- package/bin/__tests__/installer-safety.test.js +145 -0
- package/bin/install.js +9 -1
- package/bin/lib/backup.js +60 -16
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +57 -4
- package/bin/phases/post-install.js +39 -26
- package/bin/phases/select-platform.js +14 -9
- package/package.json +1 -1
- 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/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/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
|
@@ -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
|
+
});
|
|
@@ -39,24 +39,20 @@ try {
|
|
|
39
39
|
|
|
40
40
|
const lines = [];
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Case 1: source present but docs/ missing
|
|
43
43
|
if (!fs.existsSync(docsDir)) {
|
|
44
44
|
lines.push('');
|
|
45
|
-
lines.push('###
|
|
46
|
-
lines.push(
|
|
47
|
-
lines.push(
|
|
48
|
-
lines.push(
|
|
49
|
-
lines.push(
|
|
50
|
-
lines.push(`> 3. Khởi tạo file hidden \`docs/.sync_hash\` để theo dõi cập nhật.`);
|
|
51
|
-
lines.push(`> `);
|
|
52
|
-
lines.push(`> LƯU Ý BẮT BUỘC: Bạn phải chạy lệnh Git này trong Terminal để lấy Hash Code mới nhất: \`git log -1 --format="%H" -- . ":(exclude)docs"\``);
|
|
53
|
-
lines.push(`> Sau đó ghi chuẩn giá trị Hash đó vào file \`docs/.sync_hash\`.`);
|
|
45
|
+
lines.push('### Missing docs/');
|
|
46
|
+
lines.push('> Source exists but `docs/` does not. Create baseline docs first:');
|
|
47
|
+
lines.push('> 1. `docs/system-architecture.md` — system architecture and flows');
|
|
48
|
+
lines.push('> 2. `docs/project-overview-pdr.md` — overview, goals, features');
|
|
49
|
+
lines.push('> 3. Write current hash to `docs/.sync_hash` via `git log -1 --format="%H" -- . ":(exclude)docs"`');
|
|
54
50
|
lines.push('');
|
|
55
|
-
}
|
|
56
|
-
//
|
|
51
|
+
}
|
|
52
|
+
// Case 2: docs exist — check continuous sync vs source-only git hash
|
|
57
53
|
else {
|
|
58
54
|
try {
|
|
59
|
-
//
|
|
55
|
+
// Latest SOURCE-only hash (ignore docs-only commits)
|
|
60
56
|
const currentHash = execSync('git log -1 --format="%H" -- . ":(exclude)docs"', {
|
|
61
57
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
62
58
|
}).trim();
|
|
@@ -69,18 +65,16 @@ try {
|
|
|
69
65
|
|
|
70
66
|
if (lastSyncHash !== currentHash) {
|
|
71
67
|
lines.push('');
|
|
72
|
-
lines.push('###
|
|
73
|
-
lines.push(`> Source
|
|
74
|
-
lines.push(`>
|
|
75
|
-
lines.push(
|
|
76
|
-
lines.push(`>
|
|
77
|
-
lines.push(`> 3. Cập nhật Changelog (nhật ký thay đổi).`);
|
|
78
|
-
lines.push(`> 4. KHI HOÀN TẤT, BẠN PHẢI GHI ĐÈ GIÁ TRỊ SAU: \`${currentHash}\` VÀO FILE \`docs/.sync_hash\` ĐỂ CHỐT TRẠNG THÁI HIỆN TẠI.`);
|
|
68
|
+
lines.push('### Docs sync needed');
|
|
69
|
+
lines.push(`> Source changed (\`${currentHash}\`) since last docs sync (\`${lastSyncHash || 'none'}\`).`);
|
|
70
|
+
lines.push(`> 1. Review recent changes (\`git diff ${lastSyncHash} ${currentHash}\` or \`git log\`).`);
|
|
71
|
+
lines.push('> 2. Update architecture/code-standards docs and changelog as needed.');
|
|
72
|
+
lines.push(`> 3. Write \`${currentHash}\` to \`docs/.sync_hash\` when done.`);
|
|
79
73
|
lines.push('');
|
|
80
74
|
}
|
|
81
75
|
}
|
|
82
76
|
} catch (e) {
|
|
83
|
-
// Git
|
|
77
|
+
// Git not initialized or no commits — fail-open
|
|
84
78
|
}
|
|
85
79
|
}
|
|
86
80
|
|
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
// Directories that should never be read (too large / irrelevant to LLM)
|
|
22
22
|
const BLOCKED_DIRS = [
|
|
23
23
|
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
24
|
-
'__pycache__', '.venv', 'venv',
|
|
24
|
+
'__pycache__', '.venv', 'venv',
|
|
25
25
|
'vendor', 'target',
|
|
26
26
|
'.git', 'coverage', '.nyc_output',
|
|
27
27
|
];
|
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* Claude Code CLI privacy gate for sensitive files.
|
|
8
8
|
*
|
|
9
9
|
* Runtime contract:
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* - Bash access is allowed with a warning to enable the approved follow-up path
|
|
10
|
+
* - Sensitive access returns Claude Code's native PreToolUse "ask" decision
|
|
11
|
+
* - The same decision applies to direct tools and Bash commands
|
|
12
|
+
* - Symlinks are classified by their resolved target before their alias
|
|
14
13
|
*
|
|
15
|
-
* Exit: 0
|
|
14
|
+
* Exit: 0 (the JSON hook output carries the permission decision)
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
try {
|
|
@@ -20,7 +19,7 @@ try {
|
|
|
20
19
|
const path = require('path');
|
|
21
20
|
|
|
22
21
|
const RESTRICTED_PATTERNS = [
|
|
23
|
-
/^\.env(
|
|
22
|
+
/^\.env(?:[.\[*?{]|$)/i,
|
|
24
23
|
/^credentials/i,
|
|
25
24
|
/secrets?\.(ya?ml|json)$/i,
|
|
26
25
|
/\.pem$/i,
|
|
@@ -66,22 +65,28 @@ try {
|
|
|
66
65
|
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
67
66
|
* the original-path check in that case.
|
|
68
67
|
*/
|
|
69
|
-
function resolveTarget(filePath) {
|
|
68
|
+
function resolveTarget(filePath, cwd) {
|
|
70
69
|
try {
|
|
71
|
-
|
|
70
|
+
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
71
|
+
return fs.realpathSync(requested);
|
|
72
72
|
} catch {
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function extractBashPaths(command) {
|
|
78
|
-
const paths =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
const paths = new Set();
|
|
79
|
+
// Quotes are delimiters rather than grouped tokens so nested shell
|
|
80
|
+
// commands such as `bash -c 'cat .env'` cannot hide the sensitive path.
|
|
81
|
+
const tokens = command.split(/[\s|;&<>"']+/).filter(Boolean);
|
|
82
|
+
for (const token of tokens) {
|
|
83
|
+
const cleaned = token.replace(/^["'()`]+|["'(),]+$/g, '');
|
|
84
|
+
for (const candidate of cleaned.split('=')) {
|
|
85
|
+
const value = candidate.replace(/^["'()`]+|["'(),]+$/g, '');
|
|
86
|
+
if (value && (isSensitive(value) || isSafe(value))) paths.add(value);
|
|
87
|
+
}
|
|
83
88
|
}
|
|
84
|
-
return paths;
|
|
89
|
+
return [...paths];
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function extractPaths(toolName, input) {
|
|
@@ -107,42 +112,15 @@ try {
|
|
|
107
112
|
return paths.filter(Boolean);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function
|
|
115
|
+
function askForPermission(filePath) {
|
|
111
116
|
const basename = path.basename(filePath);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
header: 'File Access',
|
|
118
|
-
text: `I need to read "${basename}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
|
|
119
|
-
options: [
|
|
120
|
-
{
|
|
121
|
-
label: 'Yes, approve access',
|
|
122
|
-
description: `Allow reading ${basename} this time`
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
label: 'No, skip this file',
|
|
126
|
-
description: 'Continue without accessing this file'
|
|
127
|
-
}
|
|
128
|
-
]
|
|
117
|
+
process.stdout.write(JSON.stringify({
|
|
118
|
+
hookSpecificOutput: {
|
|
119
|
+
hookEventName: 'PreToolUse',
|
|
120
|
+
permissionDecision: 'ask',
|
|
121
|
+
permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
|
|
129
122
|
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
return [
|
|
133
|
-
'NOTE: This is not an error. This block protects sensitive data.',
|
|
134
|
-
'',
|
|
135
|
-
`PRIVACY BLOCK: Sensitive file access requires user approval`,
|
|
136
|
-
`File: ${filePath}`,
|
|
137
|
-
'',
|
|
138
|
-
'@@PRIVACY_PROMPT_START@@',
|
|
139
|
-
JSON.stringify(promptData, null, 2),
|
|
140
|
-
'@@PRIVACY_PROMPT_END@@',
|
|
141
|
-
'',
|
|
142
|
-
'Claude Code follow-up:',
|
|
143
|
-
`- If approved: use bash to read: cat "${filePath}"`,
|
|
144
|
-
'- If denied: continue without this file'
|
|
145
|
-
].join('\n');
|
|
123
|
+
}) + '\n');
|
|
146
124
|
}
|
|
147
125
|
|
|
148
126
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
@@ -160,22 +138,21 @@ try {
|
|
|
160
138
|
if (!paths.length) process.exit(0);
|
|
161
139
|
|
|
162
140
|
for (const filePath of paths) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.error(`WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${path.basename(filePath)}`);
|
|
174
|
-
process.exit(0);
|
|
141
|
+
const target = resolveTarget(filePath, cwd);
|
|
142
|
+
|
|
143
|
+
// The real target is authoritative: an exempt-looking alias must not hide
|
|
144
|
+
// a sensitive target, while a real .env.example target remains safe.
|
|
145
|
+
if (target) {
|
|
146
|
+
if (isSensitive(target) && !isSafe(target)) {
|
|
147
|
+
askForPermission(filePath);
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
if (isSafe(target)) continue;
|
|
175
151
|
}
|
|
176
152
|
|
|
177
|
-
|
|
178
|
-
|
|
153
|
+
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
154
|
+
askForPermission(filePath);
|
|
155
|
+
process.exit(0);
|
|
179
156
|
}
|
|
180
157
|
|
|
181
158
|
process.exit(0);
|
|
@@ -84,11 +84,12 @@ try {
|
|
|
84
84
|
const newlyDone = Object.keys(taskRegistry).filter((tp) =>
|
|
85
85
|
(taskRegistry[tp]?.status || 'pending') === 'done' && featureCache[tp] !== 'done'
|
|
86
86
|
);
|
|
87
|
-
if (newlyDone.length === 0) process.exit(0);
|
|
88
|
-
|
|
89
87
|
// /m so ^ matches line starts (Evidence is never at byte 0 of the file).
|
|
88
|
+
// legacy heading aliases: read-compat only, no longer advertised
|
|
90
89
|
const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
|
|
91
|
-
const
|
|
90
|
+
const PASS_MARKER_RE = /^\s*Verification:\s*PASS\s*$/m;
|
|
91
|
+
const LEGACY_SUCCESS_RE = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
92
|
+
const EXPLICIT_FAILURE_RE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
|
|
92
93
|
|
|
93
94
|
/** Body of first Evidence heading until next same-or-higher heading. */
|
|
94
95
|
function evidenceBody(text) {
|
|
@@ -122,12 +123,20 @@ try {
|
|
|
122
123
|
const body = evidenceBody(text);
|
|
123
124
|
if (body === null || !EVID_RE.test(text)) {
|
|
124
125
|
fails.push('b');
|
|
125
|
-
} else if (
|
|
126
|
+
} else if (
|
|
127
|
+
/\{\{[^}]+\}\}/.test(body) ||
|
|
128
|
+
EXPLICIT_FAILURE_RE.test(body) ||
|
|
129
|
+
!(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
|
|
130
|
+
) {
|
|
126
131
|
fails.push('c');
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
const at = taskRegistry[taskPath]?.completed_at;
|
|
130
|
-
if (
|
|
135
|
+
if (
|
|
136
|
+
typeof at !== 'string' ||
|
|
137
|
+
!/^\d{4}-\d{2}-\d{2}T/.test(at) ||
|
|
138
|
+
Number.isNaN(Date.parse(at))
|
|
139
|
+
) fails.push('d');
|
|
131
140
|
return fails;
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -135,7 +144,8 @@ try {
|
|
|
135
144
|
.map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
|
|
136
145
|
.filter((f) => f.fails.length > 0);
|
|
137
146
|
|
|
138
|
-
//
|
|
147
|
+
// Always persist status transitions, including done → pending. Leave failing
|
|
148
|
+
// newly-done tasks at their old status so the gate re-fires next Stop.
|
|
139
149
|
try {
|
|
140
150
|
const nextFeature = { ...featureCache, ...currentStatuses };
|
|
141
151
|
for (const { taskPath } of failures) {
|
|
@@ -147,6 +157,7 @@ try {
|
|
|
147
157
|
fs.writeFileSync(cacheFile, JSON.stringify(cache));
|
|
148
158
|
} catch { /* fail-open */ }
|
|
149
159
|
|
|
160
|
+
if (newlyDone.length === 0) process.exit(0);
|
|
150
161
|
if (failures.length === 0) process.exit(0);
|
|
151
162
|
|
|
152
163
|
const lines = [
|
|
@@ -155,7 +166,7 @@ try {
|
|
|
155
166
|
for (const { taskPath, fails } of failures) {
|
|
156
167
|
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
|
|
157
168
|
lines.push(
|
|
158
|
-
` Fix: add
|
|
169
|
+
` Fix: add \`Verification: PASS\` plus commands and successful outcomes to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`, then re-sync spec.json`
|
|
159
170
|
);
|
|
160
171
|
}
|
|
161
172
|
process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
|
|
@@ -30,6 +30,10 @@ try {
|
|
|
30
30
|
if (fs.existsSync(p)) runtime = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
31
31
|
} catch { /* ignore */ }
|
|
32
32
|
|
|
33
|
+
// Reminder toggle — same key the OpenCode spec-state plugin honors.
|
|
34
|
+
// (The Stop completion gate has its own toggle: spec.completion_gate.)
|
|
35
|
+
if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
|
|
36
|
+
|
|
33
37
|
const baseDir = process.env.PROJECT_ROOT || cwd;
|
|
34
38
|
const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
|
|
35
39
|
|
|
@@ -120,19 +120,29 @@ try {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
const changed = [];
|
|
123
124
|
try {
|
|
124
125
|
const diff = execSync('git diff --name-only HEAD', {
|
|
125
126
|
encoding: 'utf8',
|
|
126
127
|
timeout: 3000,
|
|
127
128
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
128
129
|
}).trim();
|
|
130
|
+
if (diff) changed.push(...diff.split('\n'));
|
|
131
|
+
} catch {
|
|
132
|
+
// A repository without HEAD can still report untracked files below.
|
|
133
|
+
}
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
try {
|
|
136
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
timeout: 3000,
|
|
139
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
140
|
+
}).trim();
|
|
141
|
+
if (untracked) changed.push(...untracked.split('\n'));
|
|
133
142
|
} catch {
|
|
134
143
|
// fail-open
|
|
135
144
|
}
|
|
145
|
+
data.modifiedFiles = [...new Set(changed)].slice(0, MAX_MODIFIED_FILES_DISPLAY);
|
|
136
146
|
|
|
137
147
|
return data;
|
|
138
148
|
}
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* Fetches Claude Code usage limits from Anthropic OAuth API and writes to cache.
|
|
9
9
|
* Cache is read by status.cjs for statusline display.
|
|
10
10
|
*
|
|
11
|
+
* ⚠ EXPERIMENTAL / UNDOCUMENTED API: api.anthropic.com/api/oauth/usage with the
|
|
12
|
+
* "oauth-2025-04-20" beta header is not a public contract and may break or
|
|
13
|
+
* change shape without notice. Every failure path degrades to
|
|
14
|
+
* status:"unavailable" (statusline hides the segment) — never block on it.
|
|
15
|
+
* Disable entirely with "usage": { "enabled": false } if it misbehaves.
|
|
16
|
+
*
|
|
11
17
|
* Features:
|
|
12
18
|
* - Cross-platform credential retrieval (macOS Keychain, file-based)
|
|
13
19
|
* - Throttled API calls: 1 min (prompt) / 5 min (tool use)
|
|
@@ -23,7 +23,7 @@ Example prompt:
|
|
|
23
23
|
## Resource Constraints
|
|
24
24
|
|
|
25
25
|
- Each subagent has a **200K token context window** — scope tasks to fit comfortably within it
|
|
26
|
-
- Spawning many parallel agents degrades system performance — check available CPU/memory before scaling out
|
|
26
|
+
- Spawning many parallel agents degrades system performance — check available CPU/memory before scaling out. For parallel implementation waves the cap is 3 concurrent agents by default, never more than 5 (each agent carries the 200K budget above)
|
|
27
27
|
- Prefer fewer, well-scoped agents over many overlapping ones
|
|
28
28
|
- Include system resource info (from hook injection) when delegating tasks so subagents can self-regulate
|
|
29
29
|
|
|
@@ -48,7 +48,7 @@ For implementation tasks, keep the chain explicit:
|
|
|
48
48
|
2. Verify with `test-runner` using task files and exact evidence commands.
|
|
49
49
|
3. Review with `code-auditor` using task files, design contracts, and the diff.
|
|
50
50
|
|
|
51
|
-
Do not dispatch multiple implementation agents against the same files or same task
|
|
51
|
+
Do not dispatch multiple implementation agents against the same files or same task **within the same working tree**. Parallelize only independent scopes with distinct file ownership — the sanctioned pattern for parallel implementation is **worktree isolation** (one agent per task, each in its own git worktree) with a **single writer per file per wave** and orchestrator-only spec-state writes, per `skills/develop/references/parallel-waves.md`.
|
|
52
52
|
|
|
53
53
|
### Parallel (independent tasks)
|
|
54
54
|
|