@haposoft/cafekit 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -29,9 +29,10 @@ const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIF
|
|
|
29
29
|
function runHook(payload, cwd) {
|
|
30
30
|
const projectRoot = cwd || payload.cwd;
|
|
31
31
|
const specsRoot = path.join(projectRoot, 'specs');
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Source hooks use payload.cwd without inherited root overrides.
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
35
|
+
delete env.PROJECT_ROOT;
|
|
35
36
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
36
37
|
input: JSON.stringify({
|
|
37
38
|
hook_event_name: 'Stop',
|
|
@@ -235,6 +236,8 @@ function installClaudeGate(root, policyMode = 'valid') {
|
|
|
235
236
|
fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
|
|
236
237
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
237
238
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'runtime-dir.cjs'), path.join(hooks, 'lib', 'runtime-dir.cjs'));
|
|
239
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'project-root.cjs'), path.join(hooks, 'lib', 'project-root.cjs'));
|
|
240
|
+
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'spec-touch.cjs'), path.join(hooks, 'lib', 'spec-touch.cjs'));
|
|
238
241
|
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'hook-payload.cjs'), path.join(hooks, 'lib', 'hook-payload.cjs'));
|
|
239
242
|
fs.copyFileSync(
|
|
240
243
|
path.join(__dirname, '..', 'lib', 'hook-state-dir.cjs'),
|
|
@@ -1264,11 +1267,14 @@ function boundReceipt(base = VALID_BASE, head = VALID_HEAD) {
|
|
|
1264
1267
|
}
|
|
1265
1268
|
|
|
1266
1269
|
function runHookFrom(dir, cwd) {
|
|
1270
|
+
const env = { ...process.env };
|
|
1271
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
1272
|
+
delete env.PROJECT_ROOT;
|
|
1267
1273
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
1268
1274
|
cwd,
|
|
1269
1275
|
input: JSON.stringify({ hook_event_name: 'Stop', session_id: 'test', transcript_path: '/tmp/t', stop_hook_active: false, cwd: dir }),
|
|
1270
1276
|
encoding: 'utf8',
|
|
1271
|
-
env
|
|
1277
|
+
env,
|
|
1272
1278
|
});
|
|
1273
1279
|
return { code: res.status, stdout: (res.stdout || '').trim(), stderr: res.stderr || '' };
|
|
1274
1280
|
}
|
|
@@ -1371,17 +1377,142 @@ test('41. unborn HEAD fails closed before any receipt check', () => {
|
|
|
1371
1377
|
clearCache();
|
|
1372
1378
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
1373
1379
|
assert.ok(body && body.decision === 'block', 'an unborn HEAD must block');
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
|
|
1377
|
-
// not the binding-mode selector. The selector's own HEAD-unreadable branch
|
|
1378
|
-
// is guarded by cases 38 and 39.
|
|
1379
|
-
assert.match(body.reason, /controlled failure/);
|
|
1380
|
+
// A done task still blocks before receipt validation, with guidance to
|
|
1381
|
+
// create the first commit before recording its Base/Head.
|
|
1382
|
+
assert.match(body.reason, /first commit/);
|
|
1380
1383
|
} finally {
|
|
1381
1384
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
1382
1385
|
}
|
|
1383
1386
|
});
|
|
1384
1387
|
|
|
1388
|
+
function noHistoryFixture() {
|
|
1389
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-no-history-'));
|
|
1390
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
|
|
1391
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
1392
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1393
|
+
}
|
|
1394
|
+
installClaudeGate(dir);
|
|
1395
|
+
fs.copyFileSync(path.join(__dirname, '..', 'spec-state.cjs'), path.join(dir, '.claude/hooks/spec-state.cjs'));
|
|
1396
|
+
return dir;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
test('Stop still blocks multiple active specs for an untouched session', () => {
|
|
1400
|
+
const dir = tmpDir();
|
|
1401
|
+
try {
|
|
1402
|
+
const gate = installClaudeGate(dir);
|
|
1403
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'in_progress');
|
|
1404
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
1405
|
+
cwd: dir, encoding: 'utf8', env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir },
|
|
1406
|
+
input: JSON.stringify({ cwd: dir, session_id: 'untouched-session', hook_event_name: 'Stop', prompt: 'Continue' }),
|
|
1407
|
+
});
|
|
1408
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1409
|
+
const body = parseBlock(result.stdout);
|
|
1410
|
+
assert.strictEqual(body.decision, 'block');
|
|
1411
|
+
assert.match(body.reason, /multiple active specs/);
|
|
1412
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
function writeNoHistoryPacket(dir, feature, status) {
|
|
1416
|
+
const packet = path.join(dir, 'specs', feature);
|
|
1417
|
+
fs.mkdirSync(packet, { recursive: true });
|
|
1418
|
+
fs.writeFileSync(path.join(packet, 'plan.md'), `# ${feature}\nSpecs-Contract: process-first-ready-v1\n`);
|
|
1419
|
+
fs.writeFileSync(path.join(packet, 'task-01-work.md'), [
|
|
1420
|
+
'# Task 01: work', '', `Status: ${status}`, '', '## Dependencies', '', '- none', '',
|
|
1421
|
+
'## Verification Plan', '', '- Command: node --test', '',
|
|
1422
|
+
...(status === 'done' ? ['## Receipt', '', 'Verification: PASS', ''] : []),
|
|
1423
|
+
].join('\n'));
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function runInstalledHistoryHook(dir, hook = 'spec-gate', extraEnv = {}) {
|
|
1427
|
+
return spawnSync(process.execPath, [path.join(dir, '.claude/hooks', `${hook}.cjs`)], {
|
|
1428
|
+
cwd: dir, encoding: 'utf8',
|
|
1429
|
+
env: { ...process.env, PROJECT_ROOT: dir, CLAUDE_PROJECT_DIR: dir, ...extraEnv },
|
|
1430
|
+
input: JSON.stringify({ cwd: dir, session_id: 'no-history',
|
|
1431
|
+
hook_event_name: hook === 'spec-state' ? 'UserPromptSubmit' : 'Stop', prompt: 'Continue specs/demo' }),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
test('no commits allows Stop without done tasks and prints one reminder', () => {
|
|
1436
|
+
const dir = noHistoryFixture();
|
|
1437
|
+
try {
|
|
1438
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1439
|
+
const gate = runInstalledHistoryHook(dir);
|
|
1440
|
+
assert.strictEqual(gate.status, 0, gate.stderr);
|
|
1441
|
+
assert.strictEqual(gate.stdout.trim(), '');
|
|
1442
|
+
const state = runInstalledHistoryHook(dir, 'spec-state');
|
|
1443
|
+
assert.strictEqual(state.status, 0, state.stderr);
|
|
1444
|
+
assert.match(state.stdout, /no commits/);
|
|
1445
|
+
assert.strictEqual(state.stdout.trim().split('\n').length, 1);
|
|
1446
|
+
assert.doesNotMatch(state.stdout, /Spec tollgate unavailable/);
|
|
1447
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
test('no commits blocks a done task with first commit guidance', () => {
|
|
1451
|
+
const dir = noHistoryFixture();
|
|
1452
|
+
try {
|
|
1453
|
+
writeNoHistoryPacket(dir, 'demo', 'done');
|
|
1454
|
+
const result = runInstalledHistoryHook(dir);
|
|
1455
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1456
|
+
const body = parseBlock(result.stdout);
|
|
1457
|
+
assert.strictEqual(body.decision, 'block');
|
|
1458
|
+
assert.match(body.reason, /first commit/);
|
|
1459
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1460
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test('no commits blocks two completed packets with first commit guidance', () => {
|
|
1464
|
+
const dir = noHistoryFixture();
|
|
1465
|
+
try {
|
|
1466
|
+
for (const feature of ['demo', 'other']) writeNoHistoryPacket(dir, feature, 'done');
|
|
1467
|
+
const result = runInstalledHistoryHook(dir);
|
|
1468
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1469
|
+
const body = parseBlock(result.stdout);
|
|
1470
|
+
assert.strictEqual(body.decision, 'block');
|
|
1471
|
+
assert.match(body.reason, /first commit/);
|
|
1472
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
1473
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test('a corrupt current branch ref remains a controlled failure', () => {
|
|
1477
|
+
const dir = tmpDir();
|
|
1478
|
+
try {
|
|
1479
|
+
installClaudeGate(dir);
|
|
1480
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1481
|
+
const ref = spawnSync('git', ['-C', dir, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
|
|
1482
|
+
assert.strictEqual(ref.status, 0, ref.stderr);
|
|
1483
|
+
fs.writeFileSync(path.join(dir, '.git', ref.stdout.trim()), 'corrupt-ref\n');
|
|
1484
|
+
const result = runInstalledHistoryHook(dir);
|
|
1485
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1486
|
+
const body = parseBlock(result.stdout);
|
|
1487
|
+
assert.strictEqual(body.decision, 'block');
|
|
1488
|
+
assert.match(body.reason, /controlled failure/);
|
|
1489
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test('unavailable git remains a controlled failure in a repo without commits', () => {
|
|
1493
|
+
const dir = noHistoryFixture();
|
|
1494
|
+
try {
|
|
1495
|
+
writeNoHistoryPacket(dir, 'demo', 'in_progress');
|
|
1496
|
+
const emptyPath = path.join(dir, 'empty-path');
|
|
1497
|
+
fs.mkdirSync(emptyPath);
|
|
1498
|
+
const result = runInstalledHistoryHook(dir, 'spec-gate', { PATH: emptyPath });
|
|
1499
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1500
|
+
const body = parseBlock(result.stdout);
|
|
1501
|
+
assert.strictEqual(body.decision, 'block');
|
|
1502
|
+
assert.match(body.reason, /controlled failure/);
|
|
1503
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
test('a committed repo still accepts a valid done task receipt', () => {
|
|
1507
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
1508
|
+
try {
|
|
1509
|
+
installClaudeGate(dir);
|
|
1510
|
+
const result = runInstalledHistoryHook(dir);
|
|
1511
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
1512
|
+
assert.strictEqual(result.stdout.trim(), '');
|
|
1513
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1385
1516
|
test('42. a dirty tree outside specs does not reopen a committed receipt', () => {
|
|
1386
1517
|
// Contract change. This used to require a block. `Base` is the last commit
|
|
1387
1518
|
// touching anything outside the specs root, so a receipt written before any
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// spec-state injects the active packet's tollgate on every prompt. With several sessions in
|
|
4
4
|
// one checkout, a Claude session kept seeing a packet another session was working on. On
|
|
5
|
-
// Claude the hook now speaks only about packets this session touched;
|
|
6
|
-
// payloads without a
|
|
5
|
+
// Claude and omp the hook now speaks only about packets this session touched; grok and
|
|
6
|
+
// payloads without a session id keep the old behaviour. Each case names the failure
|
|
7
7
|
// it prevents.
|
|
8
8
|
|
|
9
9
|
const { test } = require('node:test');
|
|
@@ -12,6 +12,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
12
12
|
const fs = require('node:fs');
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
15
16
|
|
|
16
17
|
const CLAUDE_ROOT = path.join(__dirname, '..', '..');
|
|
17
18
|
|
|
@@ -36,7 +37,7 @@ function fixture(packets, runtimeDir = '.claude') {
|
|
|
36
37
|
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
37
38
|
fs.mkdirSync(scripts, { recursive: true });
|
|
38
39
|
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'spec-state.cjs'), path.join(hooks, 'spec-state.cjs'));
|
|
39
|
-
for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs']) {
|
|
40
|
+
for (const lib of ['runtime-dir.cjs', 'hook-payload.cjs', 'hook-state-dir.cjs', 'project-root.cjs', 'spec-touch.cjs']) {
|
|
40
41
|
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks', 'lib', lib), path.join(hooks, 'lib', lib));
|
|
41
42
|
}
|
|
42
43
|
for (const file of ['provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'workflow-policy.cjs']) {
|
|
@@ -48,23 +49,61 @@ function fixture(packets, runtimeDir = '.claude') {
|
|
|
48
49
|
const git = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
49
50
|
assert.strictEqual(git.status, 0, git.stderr);
|
|
50
51
|
}
|
|
52
|
+
let lastSession = null;
|
|
51
53
|
const run = (payload) => {
|
|
54
|
+
lastSession = payload.session_id;
|
|
55
|
+
const env = { ...process.env };
|
|
56
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
57
|
+
delete env.PROJECT_ROOT;
|
|
52
58
|
const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
|
|
53
|
-
cwd: root, env
|
|
59
|
+
cwd: root, env,
|
|
54
60
|
input: JSON.stringify({ cwd: root, ...payload }), encoding: 'utf8',
|
|
55
61
|
});
|
|
56
62
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
57
63
|
return result.stdout.trim();
|
|
58
64
|
};
|
|
59
65
|
const prompt = (session, text) => run({ hook_event_name: 'UserPromptSubmit', session_id: session, prompt: text });
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
return {
|
|
67
|
+
root, hooks, run, prompt,
|
|
68
|
+
get cacheFile() {
|
|
69
|
+
const name = ['.claude', '.omp'].includes(runtimeDir) && typeof lastSession === 'string' && lastSession
|
|
70
|
+
? `tollgate-last-${crypto.createHash('sha256').update(lastSession).digest('hex').slice(0, 16)}.txt`
|
|
71
|
+
: 'tollgate-last.txt';
|
|
72
|
+
return path.join(hooks, '.logs', name);
|
|
73
|
+
},
|
|
74
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
75
|
+
};
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
function within(fx, fn) {
|
|
65
79
|
try { return fn(fx); } finally { fx.cleanup(); }
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
test('Claude spec-state says receipt revalidation never grants approval', () => {
|
|
83
|
+
within(fixture(['auth-flow']), fx => {
|
|
84
|
+
assert.match(fx.prompt('message-session', 'specs/auth-flow'), /^- Hooks revalidate receipt bytes but never grant approval\.$/m);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('Claude subagent rules include the shared simplicity and reporting instructions', () => {
|
|
89
|
+
within(fixture([]), fx => {
|
|
90
|
+
fs.copyFileSync(path.join(CLAUDE_ROOT, 'hooks/agent.cjs'), path.join(fx.hooks, 'agent.cjs'));
|
|
91
|
+
fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), '{}');
|
|
92
|
+
const env = { ...process.env };
|
|
93
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
94
|
+
delete env.PROJECT_ROOT;
|
|
95
|
+
const result = spawnSync(process.execPath, [path.join(fx.hooks, 'agent.cjs')], {
|
|
96
|
+
cwd: fx.root, env, encoding: 'utf8', input: JSON.stringify({ cwd: fx.root, hook_event_name: 'SubagentStart', agent_type: 'checker' }),
|
|
97
|
+
});
|
|
98
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
99
|
+
const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
|
|
100
|
+
const rules = context.split('## Rules\n')[1];
|
|
101
|
+
assert.ok(rules, 'the rules section must be present');
|
|
102
|
+
assert.match(rules, /^- YAGNI · KISS · DRY$/m);
|
|
103
|
+
assert.match(rules, /^- Be concise\. List unresolved questions at end\.$/m);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
68
107
|
test('an untouched packet prints nothing on Claude', () => {
|
|
69
108
|
within(fixture(['auth-flow']), (fx) => {
|
|
70
109
|
assert.strictEqual(fx.prompt('s1', 'Continue'), '');
|
|
@@ -72,6 +111,58 @@ test('an untouched packet prints nothing on Claude', () => {
|
|
|
72
111
|
});
|
|
73
112
|
});
|
|
74
113
|
|
|
114
|
+
test('a touched packet respects the project tollgate toggle from a nested cwd', () => {
|
|
115
|
+
within(fixture(['auth-flow']), fx => {
|
|
116
|
+
assert.match(fx.prompt('s1', 'specs/auth-flow'), /Spec state changed/);
|
|
117
|
+
fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ spec: { tollgate: false } }));
|
|
118
|
+
const cwd = path.join(fx.root, 'sub/dir');
|
|
119
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
120
|
+
assert.strictEqual(fx.run({ cwd, session_id: 's1', hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), '');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('an explicitly failed edit does not touch its packet', () => {
|
|
125
|
+
within(fixture(['auth-flow']), fx => {
|
|
126
|
+
fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Edit',
|
|
127
|
+
tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { is_error: true } });
|
|
128
|
+
assert.strictEqual(fx.prompt('s1', 'Continue'), '');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('written content mentioning failure still touches its packet', () => {
|
|
133
|
+
within(fixture(['auth-flow']), fx => {
|
|
134
|
+
fx.run({ session_id: 's1', hook_event_name: 'PostToolUse', tool_name: 'Write',
|
|
135
|
+
tool_input: { file_path: path.join(fx.root, 'specs/auth-flow/plan.md') }, tool_response: { content: 'Failed x' } });
|
|
136
|
+
assert.match(fx.prompt('s1', 'Continue'), /Spec state changed/);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a configured specs prefix names a plain lowercase packet', () => {
|
|
141
|
+
within(fixture(['auth']), fx => {
|
|
142
|
+
fs.renameSync(path.join(fx.root, 'specs'), path.join(fx.root, 'planning'));
|
|
143
|
+
fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: 'planning' } }));
|
|
144
|
+
assert.match(fx.prompt('s1', 'Continue planning/auth'), /Spec state changed: `auth`/);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('an explicit packet path records the session touch', () => {
|
|
149
|
+
within(fixture(['auth']), fx => {
|
|
150
|
+
assert.match(fx.run({ session_id: 's1', hook_event_name: 'UserPromptSubmit',
|
|
151
|
+
specPath: path.join(fs.realpathSync(fx.root), 'specs/auth'), prompt: 'Continue' }), /Spec state changed: `auth`/);
|
|
152
|
+
assert.match(fx.prompt('s1', 'Continue'), /Tollgate active/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('another session does not displace the compact tollgate cache', () => {
|
|
157
|
+
within(fixture(['auth-flow']), fx => {
|
|
158
|
+
assert.match(fx.prompt('session-a', 'specs/auth-flow'), /Spec state changed/);
|
|
159
|
+
assert.match(fx.prompt('session-b', 'specs/auth-flow'), /Spec state changed/);
|
|
160
|
+
const again = fx.prompt('session-a', 'Continue');
|
|
161
|
+
assert.match(again, /Tollgate active/);
|
|
162
|
+
assert.doesNotMatch(again, /Spec state changed/);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
75
166
|
test('naming the packet in the prompt touches it and keeps the full block', () => {
|
|
76
167
|
within(fixture(['auth-flow']), (fx) => {
|
|
77
168
|
assert.strictEqual(fx.prompt('s1', 'Continue'), '');
|
|
@@ -113,10 +204,15 @@ test("another session's touch does not leak", () => {
|
|
|
113
204
|
});
|
|
114
205
|
});
|
|
115
206
|
|
|
116
|
-
test('omp
|
|
207
|
+
test('omp filters by touch like Claude', () => {
|
|
117
208
|
within(fixture(['auth-flow'], '.omp'), (fx) => {
|
|
118
|
-
assert.
|
|
209
|
+
assert.strictEqual(fx.prompt('s5', 'Continue'), '', 'an untouched packet is silent under omp');
|
|
210
|
+
assert.match(fx.prompt('s5', 'specs/auth-flow'), /Spec state changed/);
|
|
211
|
+
assert.strictEqual(fx.prompt('s5b', 'Continue'), '', "another omp session's touch does not leak");
|
|
119
212
|
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('grok and session-less payloads keep the old behaviour', () => {
|
|
120
216
|
within(fixture(['auth-flow']), (fx) => {
|
|
121
217
|
assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', sessionId: 's6', prompt: 'Continue' }), /Spec/, 'grok sends sessionId');
|
|
122
218
|
});
|
|
@@ -133,6 +229,53 @@ test('a prompt touch is remembered even when two packets are active', () => {
|
|
|
133
229
|
});
|
|
134
230
|
});
|
|
135
231
|
|
|
232
|
+
test('an untouched session stays quiet with two active packets', () => {
|
|
233
|
+
within(fixture(['auth-flow', 'billing-2']), (fx) => {
|
|
234
|
+
assert.strictEqual(fx.prompt('s1', 'Continue'), '');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
function invalidateTask(root, feature) {
|
|
239
|
+
fs.writeFileSync(path.join(root, 'specs', feature, 'task-01-work.md'),
|
|
240
|
+
'# Task 01: broken\n\n## Verification Plan\n\n- Command: node --test\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test('an untouched session stays quiet when a packet is invalid', () => {
|
|
244
|
+
within(fixture(['broken-flow']), (fx) => {
|
|
245
|
+
invalidateTask(fx.root, 'broken-flow');
|
|
246
|
+
assert.strictEqual(fx.prompt('s1', 'Continue'), '');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('touching an invalid packet still prints its warning', () => {
|
|
251
|
+
within(fixture(['broken-flow']), (fx) => {
|
|
252
|
+
invalidateTask(fx.root, 'broken-flow');
|
|
253
|
+
assert.match(fx.prompt('s1', 'Continue specs/broken-flow'), /Invalid workflow packet/);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('a session-less prompt still warns about two active packets', () => {
|
|
258
|
+
within(fixture(['auth-flow', 'billing-2']), (fx) => {
|
|
259
|
+
assert.match(fx.run({ hook_event_name: 'UserPromptSubmit', prompt: 'Continue' }), /Multiple active specs/);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('a touched valid packet prints its tollgate when another packet is invalid', () => {
|
|
264
|
+
within(fixture(['auth-flow', 'broken-flow']), (fx) => {
|
|
265
|
+
invalidateTask(fx.root, 'broken-flow');
|
|
266
|
+
const output = fx.prompt('s1', 'Continue specs/auth-flow');
|
|
267
|
+
assert.match(output, /Spec state changed: `auth-flow`/);
|
|
268
|
+
assert.doesNotMatch(output, /Invalid workflow packet/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('an invalid specs root still warns an untouched session', () => {
|
|
273
|
+
within(fixture(['auth-flow']), (fx) => {
|
|
274
|
+
fs.writeFileSync(path.join(fx.root, '.claude/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
275
|
+
assert.match(fx.prompt('s1', 'Continue'), /Invalid workflow packet detected: <specs>/);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
136
279
|
function writeLegacyPacket(root, name) {
|
|
137
280
|
const dir = path.join(root, 'specs', name);
|
|
138
281
|
fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
|
|
@@ -158,3 +301,56 @@ test('a legacy packet is noticed even when it is the only packet', () => {
|
|
|
158
301
|
assert.match(fx.prompt('s1', 'Continue'), LEGACY_NOTICE);
|
|
159
302
|
});
|
|
160
303
|
});
|
|
304
|
+
|
|
305
|
+
test('Claude spec-state uses one snapshot for done task receipts', (t) => {
|
|
306
|
+
within(fixture(['auth-flow']), (fx) => {
|
|
307
|
+
const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-snapshot-count-'));
|
|
308
|
+
try {
|
|
309
|
+
const feature = path.join(fx.root, 'specs/auth-flow');
|
|
310
|
+
const tasks = ['task-01-work.md', 'task-02-work.md'];
|
|
311
|
+
for (const name of tasks) fs.writeFileSync(path.join(feature, name), taskContent('done'));
|
|
312
|
+
fs.writeFileSync(path.join(feature, 'task-03-work.md'), taskContent('in_progress'));
|
|
313
|
+
const scripts = path.join(fx.root, '.claude/scripts');
|
|
314
|
+
const provenance = require(path.join(scripts, 'provenance.cjs'));
|
|
315
|
+
const policy = require(path.join(scripts, 'workflow-policy.cjs'));
|
|
316
|
+
const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
|
|
317
|
+
const context = provenance.deriveRuntimeContext({ projectRoot: fx.root,
|
|
318
|
+
specsRoot: path.join(fx.root, 'specs'), specFile: path.join(feature, 'plan.md'),
|
|
319
|
+
featureName: 'auth-flow', runtimeSession: 'one-snapshot' });
|
|
320
|
+
for (const name of tasks) {
|
|
321
|
+
fs.appendFileSync(path.join(feature, name), [
|
|
322
|
+
'\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
323
|
+
`Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
|
|
324
|
+
].join('\n'));
|
|
325
|
+
assert.deepStrictEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
|
|
326
|
+
}
|
|
327
|
+
const countFile = path.join(trace, 'git-status.txt');
|
|
328
|
+
const preload = path.join(trace, 'count.cjs');
|
|
329
|
+
fs.writeFileSync(preload, `
|
|
330
|
+
const cp = require('node:child_process');
|
|
331
|
+
const fs = require('node:fs');
|
|
332
|
+
const path = require('node:path');
|
|
333
|
+
for (const method of ['spawnSync', 'execFileSync']) {
|
|
334
|
+
const original = cp[method];
|
|
335
|
+
cp[method] = function(command, args, ...rest) {
|
|
336
|
+
if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
|
|
337
|
+
fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
|
|
338
|
+
return original.call(this, command, args, ...rest);
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
`);
|
|
342
|
+
const result = spawnSync(process.execPath, [path.join(fx.hooks, 'spec-state.cjs')], {
|
|
343
|
+
cwd: fx.root, encoding: 'utf8',
|
|
344
|
+
env: { ...process.env, PROJECT_ROOT: fx.root,
|
|
345
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
|
|
346
|
+
input: JSON.stringify({ cwd: fx.root, hook_event_name: 'UserPromptSubmit',
|
|
347
|
+
session_id: 'one-snapshot', prompt: 'Continue specs/auth-flow' }),
|
|
348
|
+
});
|
|
349
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
350
|
+
assert.match(result.stdout, /Spec state changed/);
|
|
351
|
+
const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
|
|
352
|
+
t.diagnostic(`Claude git status calls: ${count}`);
|
|
353
|
+
assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
|
|
354
|
+
} finally { fs.rmSync(trace, { recursive: true, force: true }); }
|
|
355
|
+
});
|
|
356
|
+
});
|