@haposoft/cafekit 0.15.3 → 0.16.0-rc.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 +4 -2
- package/bin/__tests__/codex-hooks.test.js +140 -0
- package/bin/__tests__/codex-native.test.js +2 -2
- package/bin/__tests__/develop-contract.test.js +1120 -0
- package/bin/__tests__/installer-safety.test.js +510 -2
- package/bin/__tests__/package-inventory.test.js +258 -0
- package/bin/__tests__/secret-scanner.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +713 -0
- package/bin/install.js +5 -1
- package/bin/lib/codex-install.js +21 -6
- package/bin/lib/context.js +17 -6
- package/bin/lib/copy-utils.js +31 -0
- package/bin/lib/instruction-blocks.js +85 -0
- package/bin/lib/managed-writer.js +4 -1
- package/bin/lib/opencode-install.js +75 -30
- package/bin/phases/claude-runtime.js +103 -4
- package/bin/phases/codex-runtime.js +9 -5
- package/bin/phases/post-install.js +29 -22
- package/bin/phases/root-config.js +89 -10
- package/package.json +14 -4
- package/src/claude/CLAUDE.md +6 -122
- package/src/claude/agents/code-auditor.md +28 -22
- package/src/claude/agents/debugger.md +3 -3
- package/src/claude/agents/deployer.md +1 -1
- package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
- package/src/claude/agents/project-manager.md +1 -1
- package/src/claude/agents/spec-maker.md +2 -2
- package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
- package/src/claude/hooks/agent.cjs +7 -14
- package/src/claude/hooks/rules.cjs +33 -53
- package/src/claude/hooks/spec-gate.cjs +66 -21
- package/src/claude/hooks/spec-state.cjs +5 -0
- package/src/claude/migration-manifest.json +9 -2
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
- package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
- package/src/claude/scripts/spec-ground.cjs +189 -83
- package/src/claude/scripts/validate-spec-output.cjs +294 -30
- package/src/claude/scripts/workflow-policy.cjs +609 -0
- package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
- package/src/claude/skills/backend-development/SKILL.md +4 -4
- package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
- package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
- package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
- package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
- package/src/claude/skills/backend-development/references/backend-security.md +55 -3
- package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
- package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
- package/src/claude/skills/brainstorm/SKILL.md +2 -3
- package/src/claude/skills/code-review/SKILL.md +12 -8
- package/src/claude/skills/code-review/references/verification-gate.md +4 -4
- package/src/claude/skills/debug/SKILL.md +2 -0
- package/src/claude/skills/develop/SKILL.md +100 -52
- package/src/claude/skills/develop/references/parallel-waves.md +105 -26
- package/src/claude/skills/develop/references/quality-gate.md +78 -38
- package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
- package/src/claude/skills/git/SKILL.md +44 -50
- package/src/claude/skills/hotfix/SKILL.md +10 -8
- package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
- package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
- package/src/claude/skills/inspect/SKILL.md +12 -30
- package/src/claude/skills/mobile-development/SKILL.md +12 -12
- package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
- package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
- package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
- package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
- package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
- package/src/claude/skills/question/SKILL.md +13 -13
- package/src/claude/skills/research/SKILL.md +2 -2
- package/src/claude/skills/specs/SKILL.md +32 -15
- package/src/claude/skills/specs/references/review.md +3 -3
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/spec-state.json +7 -3
- package/src/claude/skills/sync/SKILL.md +26 -1
- package/src/claude/skills/sync/references/sync-protocols.md +3 -3
- package/src/claude/skills/test/SKILL.md +12 -4
- package/src/claude/skills/test/references/failure-triage.md +17 -17
- package/src/claude/skills/test/references/test-memory.md +2 -2
- package/src/codex/AGENTS.md +16 -24
- package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
- package/src/codex/hooks/rules.cjs +27 -26
- package/src/codex/hooks/spec-gate.cjs +19 -8
- package/src/codex/hooks/spec-state.cjs +10 -1
- package/src/common/AGENTS.md +43 -0
- package/src/opencode/AGENTS.md +21 -121
- package/src/opencode/plugins/rules.ts +26 -34
- package/src/opencode/plugins/spec-gate.ts +283 -0
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
|
@@ -98,6 +98,12 @@ function makeFixture(opts = {}) {
|
|
|
98
98
|
'## Evidence',
|
|
99
99
|
'',
|
|
100
100
|
'Verification: PASS',
|
|
101
|
+
'Command: npm test',
|
|
102
|
+
'Exit: 0',
|
|
103
|
+
'Result: PASS',
|
|
104
|
+
'Base: abc123',
|
|
105
|
+
'Head: def456',
|
|
106
|
+
'Artifact: dist/bundle.js (sha256:deadbeef)',
|
|
101
107
|
'```',
|
|
102
108
|
'npm test',
|
|
103
109
|
'PASS: 10 tests',
|
|
@@ -105,7 +111,7 @@ function makeFixture(opts = {}) {
|
|
|
105
111
|
'',
|
|
106
112
|
].join('\n');
|
|
107
113
|
} else if (evidence === 'legacy-valid') {
|
|
108
|
-
evidenceBlock = '## Evidence\n\nnpm test — passed, exit code 0\n';
|
|
114
|
+
evidenceBlock = '## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: abc123\nHead: def456\nnpm test — passed, exit code 0\n';
|
|
109
115
|
} else if (evidence === 'failed') {
|
|
110
116
|
evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
|
|
111
117
|
} else if (evidence === 'fence-only') {
|
|
@@ -227,17 +233,21 @@ test('4. stop_hook_active: true → exit 0 silent even with violating task', ()
|
|
|
227
233
|
}
|
|
228
234
|
});
|
|
229
235
|
|
|
230
|
-
test('5. first run (no cache) with done-without-receipt →
|
|
236
|
+
test('5. first run (no cache) with done-without-receipt → block (cache is not truth)', () => {
|
|
231
237
|
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
232
238
|
try {
|
|
233
239
|
clearCache();
|
|
234
240
|
assert.ok(!fs.existsSync(CACHE), 'cache must be absent for first-run');
|
|
235
241
|
const { code, stdout } = runHook({}, dir);
|
|
236
242
|
assert.strictEqual(code, 0);
|
|
237
|
-
|
|
238
|
-
assert.ok(
|
|
243
|
+
const body = parseBlock(stdout);
|
|
244
|
+
assert.ok(body, 'first run must block done without receipt');
|
|
245
|
+
assert.strictEqual(body.decision, 'block');
|
|
246
|
+
assert.ok(body.reason.includes(TASK_REL));
|
|
247
|
+
assert.ok(fs.existsSync(CACHE), 'cache file must be created even when blocking');
|
|
239
248
|
const cache = readCache();
|
|
240
|
-
|
|
249
|
+
// failing task must not be cached as done
|
|
250
|
+
assert.strictEqual(cache[FEATURE]?.[TASK_REL], undefined);
|
|
241
251
|
} finally {
|
|
242
252
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
243
253
|
}
|
|
@@ -342,3 +352,344 @@ test('12. legacy successful receipt remains read-compatible', () => {
|
|
|
342
352
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
343
353
|
}
|
|
344
354
|
});
|
|
355
|
+
|
|
356
|
+
test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
357
|
+
const dir = tmpDir();
|
|
358
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
359
|
+
const tasksDir = path.join(featureDir, 'tasks');
|
|
360
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
361
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
362
|
+
status: 'in_progress',
|
|
363
|
+
current_phase: 'implementation',
|
|
364
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
365
|
+
}));
|
|
366
|
+
// Only Base, missing Head
|
|
367
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
368
|
+
'# Task',
|
|
369
|
+
'',
|
|
370
|
+
'**Status:** done',
|
|
371
|
+
'',
|
|
372
|
+
'## Evidence',
|
|
373
|
+
'',
|
|
374
|
+
'Verification: PASS',
|
|
375
|
+
'Command: npm test',
|
|
376
|
+
'Exit: 0',
|
|
377
|
+
'Base: abc123',
|
|
378
|
+
'```',
|
|
379
|
+
'npm test PASS',
|
|
380
|
+
'```',
|
|
381
|
+
].join('\n'));
|
|
382
|
+
try {
|
|
383
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
384
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
385
|
+
assert.ok(body, 'only Base should block');
|
|
386
|
+
assert.strictEqual(body.decision, 'block');
|
|
387
|
+
assert.match(body.reason, /\bg\b/);
|
|
388
|
+
} finally {
|
|
389
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
394
|
+
const dir = tmpDir();
|
|
395
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
396
|
+
const tasksDir = path.join(featureDir, 'tasks');
|
|
397
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
398
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
399
|
+
status: 'in_progress',
|
|
400
|
+
current_phase: 'implementation',
|
|
401
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
402
|
+
}));
|
|
403
|
+
// Only Head, missing Base
|
|
404
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
405
|
+
'# Task',
|
|
406
|
+
'',
|
|
407
|
+
'**Status:** done',
|
|
408
|
+
'',
|
|
409
|
+
'## Evidence',
|
|
410
|
+
'',
|
|
411
|
+
'Verification: PASS',
|
|
412
|
+
'Command: npm test',
|
|
413
|
+
'Exit: 0',
|
|
414
|
+
'Head: def456',
|
|
415
|
+
'```',
|
|
416
|
+
'npm test PASS',
|
|
417
|
+
'```',
|
|
418
|
+
].join('\n'));
|
|
419
|
+
try {
|
|
420
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
421
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
422
|
+
assert.ok(body, 'only Head should block');
|
|
423
|
+
assert.strictEqual(body.decision, 'block');
|
|
424
|
+
assert.match(body.reason, /\bg\b/);
|
|
425
|
+
} finally {
|
|
426
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('15. provenance with both Base and Head passes', () => {
|
|
431
|
+
const dir = tmpDir();
|
|
432
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
433
|
+
const tasksDir = path.join(featureDir, 'tasks');
|
|
434
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
435
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
436
|
+
status: 'in_progress',
|
|
437
|
+
current_phase: 'implementation',
|
|
438
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
439
|
+
}));
|
|
440
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
441
|
+
'# Task',
|
|
442
|
+
'',
|
|
443
|
+
'**Status:** done',
|
|
444
|
+
'',
|
|
445
|
+
'## Evidence',
|
|
446
|
+
'',
|
|
447
|
+
'Verification: PASS',
|
|
448
|
+
'Command: npm test',
|
|
449
|
+
'Exit: 0',
|
|
450
|
+
'Base: abc123',
|
|
451
|
+
'Head: def456',
|
|
452
|
+
'```',
|
|
453
|
+
'npm test PASS',
|
|
454
|
+
'```',
|
|
455
|
+
].join('\n'));
|
|
456
|
+
try {
|
|
457
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
458
|
+
const { stdout } = runHook({}, dir);
|
|
459
|
+
assert.strictEqual(stdout, '', 'both Base and Head should not block');
|
|
460
|
+
} finally {
|
|
461
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('16. registry task path with ../ escape or sibling-prefix is rejected (check a)', () => {
|
|
466
|
+
const dir = tmpDir();
|
|
467
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
468
|
+
const evilFeature = path.join(dir, 'specs', `${FEATURE}-evil`);
|
|
469
|
+
const maliciousRel = `../${FEATURE}-evil/tasks/task.md`;
|
|
470
|
+
const maliciousAbs = path.join(evilFeature, 'tasks/task.md');
|
|
471
|
+
// Prepare evil file with valid receipt outside feature (should be ignored due to path traversal)
|
|
472
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
473
|
+
fs.mkdirSync(path.join(evilFeature, 'tasks'), { recursive: true });
|
|
474
|
+
fs.writeFileSync(maliciousAbs, [
|
|
475
|
+
'# Task',
|
|
476
|
+
'',
|
|
477
|
+
'**Status:** done',
|
|
478
|
+
'',
|
|
479
|
+
'## Evidence',
|
|
480
|
+
'',
|
|
481
|
+
'Verification: PASS',
|
|
482
|
+
'Command: npm test',
|
|
483
|
+
'Exit: 0',
|
|
484
|
+
'Base: abc123',
|
|
485
|
+
'Head: def456',
|
|
486
|
+
'```',
|
|
487
|
+
'pass',
|
|
488
|
+
'```',
|
|
489
|
+
].join('\n'));
|
|
490
|
+
// Also create sibling-prefix style path: tasks/../../demo-evil/tasks/task.md
|
|
491
|
+
const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
|
|
492
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
493
|
+
status: 'in_progress',
|
|
494
|
+
current_phase: 'implementation',
|
|
495
|
+
task_registry: {
|
|
496
|
+
[maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
497
|
+
[siblingPrefixRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
498
|
+
[path.resolve(featureDir, maliciousRel)]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
499
|
+
},
|
|
500
|
+
}));
|
|
501
|
+
try {
|
|
502
|
+
seedCache({ [FEATURE]: { [maliciousRel]: 'pending', [siblingPrefixRel]: 'pending', [path.resolve(featureDir, maliciousRel)]: 'pending' } });
|
|
503
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
504
|
+
assert.ok(body, 'traversal path should block');
|
|
505
|
+
assert.strictEqual(body.decision, 'block');
|
|
506
|
+
// Must fail check a (file + Status)
|
|
507
|
+
assert.match(body.reason, /\ba\b/);
|
|
508
|
+
// Ensure malicious path is named in reason
|
|
509
|
+
assert.ok(body.reason.includes(maliciousRel) || body.reason.includes(siblingPrefixRel));
|
|
510
|
+
} finally {
|
|
511
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('17. empty or bare provenance fails — Base:/Head: require non-empty same-line values', () => {
|
|
516
|
+
const cases = [
|
|
517
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','Base:','Head: def456'].join('\n'), desc: 'empty Base:' },
|
|
518
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','Base: ','Head: def456'].join('\n'), desc: 'Base: spaces only' },
|
|
519
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','Base: abc','Head:'].join('\n'), desc: 'empty Head:' },
|
|
520
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','base_sha','head_sha: def'].join('\n'), desc: 'bare base_sha without colon' },
|
|
521
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','base_sha:','head_sha: def'].join('\n'), desc: 'empty base_sha:' },
|
|
522
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','base_sha: ','head_sha: def'].join('\n'), desc: 'base_sha spaces only' },
|
|
523
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0','base_sha head_sha'].join('\n'), desc: 'bare base_sha head_sha without colon' },
|
|
524
|
+
];
|
|
525
|
+
for (const { evidence, desc } of cases) {
|
|
526
|
+
const dir = tmpDir();
|
|
527
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
528
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
529
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
530
|
+
status: 'in_progress',
|
|
531
|
+
current_phase: 'implementation',
|
|
532
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
533
|
+
}));
|
|
534
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
535
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
536
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
537
|
+
assert.ok(body, `empty/bare provenance should block for ${desc}`);
|
|
538
|
+
assert.strictEqual(body.decision, 'block', `empty/bare should block: ${desc}`);
|
|
539
|
+
assert.match(body.reason, /\bg\b/, `should fail provenance g for ${desc}`);
|
|
540
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('18. valid non-empty Base: value and base_sha: value pass', () => {
|
|
545
|
+
const validCases = [
|
|
546
|
+
['Verification: PASS','Command: npm test','Exit: 0','Base: abc123','Head: def456'].join('\n'),
|
|
547
|
+
['Verification: PASS','Command: npm test','Exit: 0','base_sha: abc123','head_sha: def456'].join('\n'),
|
|
548
|
+
['Verification: PASS','Command: npm test','Exit: 0','Base: a1b2c3','Head: d4e5f6'].join('\n'),
|
|
549
|
+
];
|
|
550
|
+
for (const evidence of validCases) {
|
|
551
|
+
const dir = tmpDir();
|
|
552
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
553
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
554
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
555
|
+
status: 'in_progress',
|
|
556
|
+
current_phase: 'implementation',
|
|
557
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
558
|
+
}));
|
|
559
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
560
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
561
|
+
const { stdout } = runHook({}, dir);
|
|
562
|
+
assert.strictEqual(stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
563
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// --- Cache-hardening regressions: cached PASS must not hide later mutations ---
|
|
568
|
+
|
|
569
|
+
test('19. cache-hit: unchanged valid receipt stays PASS on second Stop (revalidation)', () => {
|
|
570
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
571
|
+
try {
|
|
572
|
+
clearCache();
|
|
573
|
+
// first Stop (no cache file) with valid receipt → no block, cache seeded as done
|
|
574
|
+
let res = runHook({}, dir);
|
|
575
|
+
assert.strictEqual(res.stdout, '', 'first run valid must not block');
|
|
576
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
577
|
+
// second Stop with same valid receipt and cache-hit → must still not block
|
|
578
|
+
res = runHook({}, dir);
|
|
579
|
+
assert.strictEqual(res.stdout, '', 'cache-hit with unchanged valid receipt must not block');
|
|
580
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
581
|
+
} finally {
|
|
582
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even though status stays done', () => {
|
|
587
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
588
|
+
try {
|
|
589
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
590
|
+
assert.strictEqual(runHook({}, dir).stdout, '', 'initial valid must pass');
|
|
591
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
592
|
+
// mutate: strip Verification: PASS and Command — keep file as done
|
|
593
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
594
|
+
fs.writeFileSync(taskFile, [
|
|
595
|
+
'# Task', '', '**Status:** done', '',
|
|
596
|
+
'## Evidence', '',
|
|
597
|
+
'Exit: 0', 'Base: abc123', 'Head: def456',
|
|
598
|
+
'```', 'pass', '```',
|
|
599
|
+
].join('\n'));
|
|
600
|
+
const blocked = parseBlock(runHook({}, dir).stdout);
|
|
601
|
+
assert.ok(blocked, 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
602
|
+
assert.strictEqual(blocked.decision, 'block');
|
|
603
|
+
assert.match(blocked.reason, /\bc\b|\be\b/);
|
|
604
|
+
// cache must not have been promoted to still-valid done; next run must still block
|
|
605
|
+
const secondBlocked = parseBlock(runHook({}, dir).stdout);
|
|
606
|
+
assert.ok(secondBlocked, 'second hit after mutation must still block');
|
|
607
|
+
} finally {
|
|
608
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('21. cache-hit: provenance mutation (removed Head / changed to stale) blocks', () => {
|
|
613
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
614
|
+
try {
|
|
615
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
616
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
617
|
+
// mutate: remove Head, leave only Base
|
|
618
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
619
|
+
fs.writeFileSync(taskFile, [
|
|
620
|
+
'# Task', '', '**Status:** done', '',
|
|
621
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123',
|
|
622
|
+
'```', 'pass', '```',
|
|
623
|
+
].join('\n'));
|
|
624
|
+
let body = parseBlock(runHook({}, dir).stdout);
|
|
625
|
+
assert.ok(body, 'missing Head provenance should block on cache-hit');
|
|
626
|
+
assert.match(body.reason, /\bg\b/);
|
|
627
|
+
// mutate back to include both but with empty Head value (stale)
|
|
628
|
+
fs.writeFileSync(taskFile, [
|
|
629
|
+
'# Task', '', '**Status:** done', '',
|
|
630
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', 'Base: abc123', 'Head:',
|
|
631
|
+
'```', 'pass', '```',
|
|
632
|
+
].join('\n'));
|
|
633
|
+
body = parseBlock(runHook({}, dir).stdout);
|
|
634
|
+
assert.ok(body, 'empty Head should block as stale provenance');
|
|
635
|
+
assert.match(body.reason, /\bg\b/);
|
|
636
|
+
} finally {
|
|
637
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('22. cache-hit: deleted task file blocks even though spec still says done', () => {
|
|
642
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
643
|
+
try {
|
|
644
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
645
|
+
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
646
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
647
|
+
fs.unlinkSync(path.join(dir, 'specs', FEATURE, TASK_REL));
|
|
648
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
649
|
+
assert.ok(body, 'deleted receipt file should block on cache-hit');
|
|
650
|
+
assert.strictEqual(body.decision, 'block');
|
|
651
|
+
assert.match(body.reason, /\ba\b/);
|
|
652
|
+
} finally {
|
|
653
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test('23. malformed cache JSON does not bypass validation', () => {
|
|
658
|
+
// valid receipt with malformed cache → must still PASS (fail-open on cache parse, not on receipt)
|
|
659
|
+
const dirValid = makeFixture({ evidence: 'valid' });
|
|
660
|
+
try {
|
|
661
|
+
clearCache();
|
|
662
|
+
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
|
|
663
|
+
fs.writeFileSync(CACHE, '{ not json');
|
|
664
|
+
const res = runHook({}, dirValid);
|
|
665
|
+
assert.strictEqual(res.stdout, '', 'malformed cache with valid receipt must not block');
|
|
666
|
+
assert.ok(fs.existsSync(CACHE), 'hook must rewrite cache file');
|
|
667
|
+
} finally {
|
|
668
|
+
fs.rmSync(dirValid, { recursive: true, force: true });
|
|
669
|
+
}
|
|
670
|
+
// invalid receipt with malformed cache → must still BLOCK (cache not truth)
|
|
671
|
+
const dirInvalid = makeFixture({ evidence: 'missing-evidence' });
|
|
672
|
+
try {
|
|
673
|
+
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
|
|
674
|
+
fs.writeFileSync(CACHE, '{ malformed: ');
|
|
675
|
+
const body = parseBlock(runHook({}, dirInvalid).stdout);
|
|
676
|
+
assert.ok(body, 'malformed cache with invalid receipt must block');
|
|
677
|
+
assert.strictEqual(body.decision, 'block');
|
|
678
|
+
} finally {
|
|
679
|
+
fs.rmSync(dirInvalid, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('24. first-run (no cache file) with valid receipt passes and seeds cache', () => {
|
|
684
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
685
|
+
try {
|
|
686
|
+
clearCache();
|
|
687
|
+
assert.ok(!fs.existsSync(CACHE));
|
|
688
|
+
const { stdout } = runHook({}, dir);
|
|
689
|
+
assert.strictEqual(stdout, '', 'first run valid receipt must not block');
|
|
690
|
+
assert.ok(fs.existsSync(CACHE), 'cache must be created on first valid run');
|
|
691
|
+
assert.strictEqual(readCache()[FEATURE][TASK_REL], 'done');
|
|
692
|
+
} finally {
|
|
693
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
694
|
+
}
|
|
695
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Implements: https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
7
7
|
*
|
|
8
8
|
* Fires when a subagent (Task tool) is spawned.
|
|
9
|
-
* Injects lightweight context: language,
|
|
9
|
+
* Injects lightweight context: language, paths, and skill venv guidance.
|
|
10
10
|
*
|
|
11
11
|
* Exit: 0 always (fail-open)
|
|
12
12
|
*/
|
|
@@ -19,8 +19,8 @@ try {
|
|
|
19
19
|
function readRuntime(cwd) {
|
|
20
20
|
try {
|
|
21
21
|
const p = path.join(cwd, '.claude', 'runtime.json');
|
|
22
|
-
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) :
|
|
23
|
-
} catch { return
|
|
22
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
|
|
23
|
+
} catch { return null; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Resolve Python venv executable path if it exists */
|
|
@@ -38,11 +38,11 @@ try {
|
|
|
38
38
|
if (!stdin) process.exit(0);
|
|
39
39
|
|
|
40
40
|
const payload = JSON.parse(stdin);
|
|
41
|
-
const agentType = payload.agent_type || 'unknown';
|
|
42
|
-
const agentId = payload.agent_id || 'unknown';
|
|
43
41
|
// Use payload.cwd for monorepo support — subagent may run in a different dir
|
|
44
|
-
const
|
|
42
|
+
const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
43
|
+
const agentCwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
|
|
45
44
|
const runtime = readRuntime(agentCwd);
|
|
45
|
+
if (runtime === null) process.exit(0);
|
|
46
46
|
|
|
47
47
|
// Language config from runtime.json
|
|
48
48
|
const thinkLang = runtime.locale?.thinkingLanguage || '';
|
|
@@ -51,17 +51,13 @@ try {
|
|
|
51
51
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
52
52
|
|
|
53
53
|
// Resolve paths from env (set by session.cjs) or runtime defaults
|
|
54
|
-
const baseDir =
|
|
54
|
+
const baseDir = agentCwd;
|
|
55
55
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
56
56
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
57
57
|
|
|
58
58
|
// Build context block
|
|
59
59
|
const lines = [];
|
|
60
60
|
|
|
61
|
-
lines.push(`## Subagent: ${agentType}`);
|
|
62
|
-
lines.push(`ID: ${agentId} | CWD: ${agentCwd}`);
|
|
63
|
-
lines.push('');
|
|
64
|
-
|
|
65
61
|
// Language section (only if configured)
|
|
66
62
|
const hasThink = effectThink && effectThink !== respondLang;
|
|
67
63
|
if (hasThink || respondLang) {
|
|
@@ -74,11 +70,8 @@ try {
|
|
|
74
70
|
// Python venv (optional — if .claude/skills/.venv exists)
|
|
75
71
|
const venv = resolveVenv(agentCwd);
|
|
76
72
|
|
|
77
|
-
// Rules
|
|
78
73
|
lines.push('## Rules');
|
|
79
74
|
lines.push(`- Plans → ${plansPath}/ | Docs → ${docsPath}/`);
|
|
80
|
-
lines.push('- YAGNI · KISS · DRY');
|
|
81
|
-
lines.push('- Be concise. List unresolved questions at end.');
|
|
82
75
|
if (venv) {
|
|
83
76
|
lines.push(`- Python in .claude/skills/: use \`${venv}\``);
|
|
84
77
|
lines.push('- Never use global pip install');
|
|
@@ -5,45 +5,42 @@
|
|
|
5
5
|
* UserPromptSubmit Hook — rules.cjs
|
|
6
6
|
* Implements: https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
7
7
|
*
|
|
8
|
-
* Injects
|
|
9
|
-
* Uses a per-session cooldown (5 min) to avoid repeating on every message.
|
|
8
|
+
* Injects project-specific rules once per session.
|
|
10
9
|
*
|
|
11
10
|
* Exit: 0 always (fail-open)
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
try {
|
|
14
|
+
const crypto = require('crypto');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
|
|
19
|
-
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
20
|
-
|
|
21
19
|
function readRuntime(cwd) {
|
|
22
20
|
try {
|
|
23
21
|
const p = path.join(cwd, '.claude', 'runtime.json');
|
|
24
|
-
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) :
|
|
25
|
-
} catch { return
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Check cooldown via temp file — returns true if injection was recent */
|
|
29
|
-
function recentlyInjected(sessionId) {
|
|
30
|
-
if (!sessionId) return false;
|
|
31
|
-
try {
|
|
32
|
-
const f = path.join(os.tmpdir(), `cafekit-rules-${sessionId}.json`);
|
|
33
|
-
if (!fs.existsSync(f)) return false;
|
|
34
|
-
const { ts } = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
35
|
-
return (Date.now() - ts) < COOLDOWN_MS;
|
|
36
|
-
} catch { return false; }
|
|
22
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
|
|
23
|
+
} catch { return null; }
|
|
37
24
|
}
|
|
38
25
|
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
/** Reserve one injection slot per session. Returns null on reservation errors. */
|
|
27
|
+
function reserveSession(sessionId) {
|
|
28
|
+
if (!sessionId) return null;
|
|
41
29
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
30
|
+
const key = crypto.createHash('sha256')
|
|
31
|
+
.update(String(sessionId))
|
|
32
|
+
.digest('hex')
|
|
33
|
+
.slice(0, 16);
|
|
34
|
+
const file = path.join(os.tmpdir(), `cafekit-rules-${key}.json`);
|
|
35
|
+
fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
flag: 'wx',
|
|
38
|
+
mode: 0o600
|
|
39
|
+
});
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return error.code === 'EEXIST' ? false : null;
|
|
43
|
+
}
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
@@ -53,11 +50,14 @@ try {
|
|
|
53
50
|
|
|
54
51
|
const payload = JSON.parse(stdin);
|
|
55
52
|
const sessionId = payload.session_id || null;
|
|
56
|
-
const
|
|
53
|
+
const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
54
|
+
const cwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
// No session identity or reservation means no injection. Fail open.
|
|
57
|
+
if (reserveSession(sessionId) !== true) process.exit(0);
|
|
59
58
|
|
|
60
59
|
const runtime = readRuntime(cwd);
|
|
60
|
+
if (runtime === null) process.exit(0);
|
|
61
61
|
|
|
62
62
|
// Language
|
|
63
63
|
const thinkLang = runtime.locale?.thinkingLanguage || '';
|
|
@@ -65,14 +65,13 @@ try {
|
|
|
65
65
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
66
66
|
|
|
67
67
|
// Paths
|
|
68
|
-
const baseDir =
|
|
68
|
+
const baseDir = cwd;
|
|
69
69
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
70
70
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
71
71
|
const maxLoc = runtime.docs?.maxLoc || 800;
|
|
72
72
|
|
|
73
73
|
const lines = [];
|
|
74
74
|
|
|
75
|
-
// Language reminder
|
|
76
75
|
const hasThink = effectThink && effectThink !== respondLang;
|
|
77
76
|
if (hasThink || respondLang) {
|
|
78
77
|
lines.push('## Language');
|
|
@@ -81,33 +80,14 @@ try {
|
|
|
81
80
|
lines.push('');
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
lines.push('- Sacrifice grammar for concision in reports. List unresolved Qs at end.');
|
|
91
|
-
lines.push('- Ensure token efficiency while maintaining high quality.');
|
|
92
|
-
lines.push('');
|
|
93
|
-
|
|
94
|
-
// Skill routing: advisory rule docs, no automatic prompt router.
|
|
95
|
-
lines.push('## Skill Routing');
|
|
96
|
-
lines.push('- Choose skills from intent using `.claude/rules/skill-workflow-routing.md` and `.claude/rules/skill-domain-routing.md`.');
|
|
97
|
-
lines.push('- If needed, run `node .claude/scripts/generate-skill-catalog.cjs --skills` to inspect installed skills.');
|
|
98
|
-
lines.push('- Explicit user commands and direct-answer requests override routing suggestions.');
|
|
99
|
-
lines.push('');
|
|
100
|
-
|
|
101
|
-
// Modularization
|
|
102
|
-
lines.push('## [IMPORTANT] Consider Modularization:');
|
|
103
|
-
lines.push('- If a file exceeds 200 lines, consider splitting it');
|
|
104
|
-
lines.push('- Check existing modules before creating new ones');
|
|
105
|
-
lines.push('- Prefer kebab-case (JS/TS/Python/shell); PascalCase (C#/Java); snake_case (Go/Rust)');
|
|
106
|
-
lines.push('- Write descriptive code comments');
|
|
107
|
-
lines.push('- Skip modularization for: markdown, plain text, bash scripts, config files, .env files');
|
|
83
|
+
lines.push(
|
|
84
|
+
'## Rules',
|
|
85
|
+
`- Markdown files: Plans → "${plansPath}/" | Docs → "${docsPath}/"`,
|
|
86
|
+
'- **DO NOT** create markdown files outside of those directories unless explicitly asked.',
|
|
87
|
+
`- docs.maxLoc: ${maxLoc} lines max per doc file`
|
|
88
|
+
);
|
|
108
89
|
|
|
109
90
|
console.log(lines.join('\n'));
|
|
110
|
-
markInjected(sessionId);
|
|
111
91
|
process.exit(0);
|
|
112
92
|
|
|
113
93
|
} catch (e) {
|