kenaz 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/core/.claude/commands/Ansuz.md +36 -35
  2. package/core/.claude/hooks/__tests__/task-2395-project-registration.test.js +84 -0
  3. package/core/.claude/hooks/_ansuz-conversation.js +1 -0
  4. package/core/.claude/hooks/_ansuz-conversation.test.js +6 -0
  5. package/core/.claude/hooks/_run-user-prompt.js +0 -4
  6. package/core/.claude/hooks/_run-user-prompt.test.js +2 -3
  7. package/core/.claude/hooks/handoff-staleness.js +2 -2
  8. package/core/.claude/hooks/turn-context.js +50 -2
  9. package/core/.claude/settings.json +0 -37
  10. package/core/.collaboration/core/task-api.js +4 -12
  11. package/core/.formation/README.md +21 -146
  12. package/core/.formation/bifrost/codex-doctor.js +37 -2
  13. package/core/.formation/bifrost/codex-doctor.test.js +18 -0
  14. package/core/.formation/bifrost/launch.js +1 -1
  15. package/core/.formation/bifrost/openai/codex-hook-shim.js +6 -0
  16. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +11 -1
  17. package/core/.formation/bifrost/setup-codex.js +3 -0
  18. package/core/.formation/spec/task_operations.parity.test.js +0 -1
  19. package/core/.formation/tools/dashboard-models-chain.test.js +0 -6
  20. package/core/.formation/tools/dispatch-detail/cross-project-worktree.md +1 -9
  21. package/core/.formation/tools/dispatch-detail/worktree-subagent-mode.md +2 -2
  22. package/core/.formation/tools/dispatch.js +28 -549
  23. package/core/.formation/tools/mcp-allowlist.test.js +0 -1
  24. package/core/.formation/tools/post-complete/task2345.test.js +0 -5
  25. package/core/.formation/tools/pre-dispatch-snapshot.test.js +0 -100
  26. package/core/.formation/tools/prompt.js +66 -1607
  27. package/core/.formation/tools/prompt.test.js +43 -582
  28. package/core/.kenaz/claude-hooks-manifest.json +16 -16
  29. package/core/.specialist/Ansuz/RULES.md +6 -10
  30. package/core/.specialist/tools/__tests__/freya-cli-contract.test.js +0 -1
  31. package/core/.specialist/tools/__tests__/task-2218-boot-session-id.test.js +17 -1
  32. package/core/.specialist/tools/__tests__/task-2222-session-identity-interleave.test.js +17 -1
  33. package/core/.specialist/tools/__tests__/task-2254-handoff-archive-contract.test.js +6 -5
  34. package/core/.specialist/tools/ansuz-boot-fast.js +44 -100
  35. package/core/.specialist/tools/ansuz-boot-fast.test.js +45 -1
  36. package/core/KENAZ_CORE_VERSION +1 -1
  37. package/core/dev/plans/ANSUZ_SLIM_PLAN.md +68 -0
  38. package/core/plugins/kenaz/commands/Amelia.md +1 -1
  39. package/core/plugins/kenaz/commands/Ansuz.md +36 -35
  40. package/core/plugins/kenaz/hooks/hooks.json +1 -5
  41. package/core/plugins/kenaz/hooks/kenaz-hook.js +0 -4
  42. package/core/plugins/kenaz/scripts/kenaz-mode.js +11 -1
  43. package/core/plugins/kenaz/scripts/kenaz.test.js +1 -1
  44. package/core/plugins/kenaz/scripts/sync.js +6 -2
  45. package/core/plugins/kenaz/scripts/ygg.js +13 -1
  46. package/core/plugins/kenaz/skills/product_manager/SKILL.md +12 -12
  47. package/lib/cli.js +47 -6
  48. package/lib/cli.test.js +24 -0
  49. package/lib/dashboard.js +10 -0
  50. package/package.json +11 -7
  51. package/vendor/install-kenaz-cli.js +18 -7
  52. package/core/.formation/tools/broadcast.js +0 -103
  53. package/core/.formation/tools/dispatch-dryrun.test.js +0 -54
  54. package/core/.formation/tools/dispatch-notifier.js +0 -84
  55. package/core/.formation/tools/formations.js +0 -1203
  56. package/core/.formation/tools/headless-branch-recovery.test.js +0 -64
  57. package/core/.formation/tools/headless-dryrun.test.js +0 -79
  58. package/core/.formation/tools/headless-v2.js +0 -659
  59. package/core/.formation/tools/headless.js +0 -1071
  60. package/core/.formation/tools/legacy-scheduler.js +0 -1994
  61. package/core/.formation/tools/logger.js +0 -273
  62. package/core/.formation/tools/memory-loader.js +0 -422
  63. package/core/.formation/tools/process-state.js +0 -33
  64. package/core/.formation/tools/remote-conflict.js +0 -98
  65. package/core/.formation/tools/runner.js +0 -1332
  66. package/core/.formation/tools/task-utils.js +0 -344
  67. package/core/.formation/tools/task2028-headless-worktree.test.js +0 -112
  68. package/core/.formation/tools/tests/headless-mcp-args-plumbing.test.js +0 -123
  69. package/core/.formation/tools/worktree-leak.test.js +0 -162
  70. package/core/.specialist/tools/__tests__/task-1863-boot-lease-reuse.test.js +0 -99
  71. package/core/.specialist/tools/__tests__/task-1977-banner-cap.test.js +0 -129
@@ -1,162 +0,0 @@
1
- /**
2
- * TASK_1530 — regression test for the Bifrost/Codex worktree WRITE-LEAK class.
3
- *
4
- * Confirmed external-user incident: a Codex (OpenAI adapter) dispatch wrote a
5
- * modified tracked file to the MAIN repo instead of the isolated worktree, then:
6
- * 1. spawnBifrost never set CLAUDE_PROJECT_DIR / never captured
7
- * originalWorktreeHead → detectWorktreeLeak NEVER fired for Bifrost.
8
- * 2. detectWorktreeLeak was WARN-ONLY: it wrote a marker + console.error but
9
- * did NOT salvage the leaked content nor block the dispatch, so the file
10
- * sat in the main working tree where a later force-checkout could discard
11
- * it, and the dispatch was still reported as a clean success.
12
- *
13
- * This test drives the REAL detectWorktreeLeak (exported from runner.js) against
14
- * a synthetic temp repo + worktree (no external repo needed), simulating a
15
- * dispatch that produced ZERO task-branch commits while the main repo has an
16
- * uncommitted TRACKED modification. It asserts:
17
- * • the leak is SALVAGED — recoverable on a branch, and
18
- * • the dispatch is BLOCKED — child._formationExitCode flips to 1.
19
- *
20
- * Revert-proof: if the runner.js change (salvage + block) is reverted, this test
21
- * fails (no salvage branch / exit code stays 0).
22
- *
23
- * Scaffolding mirrors ./post-complete/worktree-rescue.test.js (TASK_1529).
24
- */
25
-
26
- const test = require('node:test');
27
- const assert = require('node:assert/strict');
28
- const fs = require('fs');
29
- const os = require('os');
30
- const path = require('path');
31
- const { execSync } = require('child_process');
32
-
33
- const { detectWorktreeLeak } = require('./runner');
34
-
35
- function git(cmd, cwd) {
36
- return execSync(cmd, { cwd, encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
37
- }
38
- function gitSafe(cmd, cwd) {
39
- try { return git(cmd, cwd); } catch { return null; }
40
- }
41
-
42
- function makeGitRepo(dir) {
43
- git('git init', dir);
44
- git('git config user.email "test@test.com"', dir);
45
- git('git config user.name "Test"', dir);
46
- git('git config commit.gpgsign false', dir);
47
- gitSafe('git checkout -b userwork', dir);
48
- fs.writeFileSync(path.join(dir, 'README.md'), 'init\n');
49
- git('git add README.md', dir);
50
- git('git commit --no-verify -m "init"', dir);
51
- const cur = git('git rev-parse --abbrev-ref HEAD', dir);
52
- if (cur !== 'userwork') git(`git branch -m ${cur} userwork`, dir);
53
- }
54
-
55
- function setup() {
56
- const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tn1530-')));
57
- const repo = path.join(tmpRoot, 'repo');
58
- fs.mkdirSync(repo, { recursive: true });
59
- makeGitRepo(repo);
60
- return { tmpRoot, repo };
61
- }
62
-
63
- function teardown(tmpRoot, repo) {
64
- try {
65
- const wt = git('git worktree list --porcelain', repo);
66
- for (const line of wt.split('\n')) {
67
- if (line.startsWith('worktree ')) {
68
- const p = line.slice('worktree '.length).trim();
69
- if (p && path.resolve(p) !== path.resolve(repo)) {
70
- gitSafe(`git worktree remove --force "${p}"`, repo);
71
- }
72
- }
73
- }
74
- } catch { /* best effort */ }
75
- fs.rmSync(tmpRoot, { recursive: true, force: true });
76
- }
77
-
78
- // ─────────────────────────────────────────────────────────────────────────────
79
- // LEAK: zero task-branch commits + main repo has an uncommitted tracked change.
80
- // detectWorktreeLeak must SALVAGE the leaked change AND BLOCK the dispatch.
81
- // ─────────────────────────────────────────────────────────────────────────────
82
-
83
- test('TASK_1530: Bifrost write-leak (zero task commits + dirty main) is salvaged AND blocks dispatch', () => {
84
- const { tmpRoot, repo } = setup();
85
- try {
86
- // Baseline tracked file the user cares about.
87
- const lostContent = 'module.exports = { version: 2, critical: true };\n';
88
- fs.writeFileSync(path.join(repo, 'kb.js'), 'module.exports = { version: 1 };\n');
89
- git('git add kb.js', repo);
90
- git('git commit --no-verify -m "seed kb.js baseline"', repo);
91
-
92
- // Agent worktree on a task branch — snapshot HEAD BEFORE any agent work
93
- // (exactly what spawnBifrost now captures as originalWorktreeHead).
94
- const worktree = path.join(tmpRoot, 'wt');
95
- git(`git worktree add -b task/task_repro1530 "${worktree}" HEAD`, repo);
96
- const originalWorktreeHead = git('git rev-parse HEAD', worktree);
97
-
98
- // THE BUG: the Codex agent produced ZERO commits on the task branch, and its
99
- // modified tracked file landed in the MAIN repo (CLAUDE_PROJECT_DIR misroute).
100
- fs.writeFileSync(path.join(repo, 'kb.js'), lostContent);
101
-
102
- // Simulate runner.js's child object so the function can flip the exit code.
103
- const child = { _formation: { sessionId: 'sess1530', skill: 'feature_agent' } };
104
-
105
- // Drive the REAL detector against the synthetic repo (mainRoot = temp repo).
106
- const res = detectWorktreeLeak(worktree, originalWorktreeHead, child, repo);
107
-
108
- // 1) A leak must be reported.
109
- assert.ok(res && res.leaked === true, `expected leak to be detected, got ${JSON.stringify(res)}`);
110
-
111
- // 2) The dispatch must be BLOCKED: exit code flipped to 1.
112
- assert.equal(child._formationExitCode, 1,
113
- `expected child._formationExitCode=1 (blocked), got ${child._formationExitCode}`);
114
- assert.ok(child._formationLeak, 'expected child._formationLeak to be set');
115
-
116
- // 3) The leaked modification must be SALVAGED onto a recoverable branch.
117
- assert.ok(res.salvageBranch, `expected a salvage branch, got ${res.salvageBranch}`);
118
- const salvaged = gitSafe(`git show ${res.salvageBranch}:kb.js`, repo);
119
- assert.equal(salvaged, lostContent.trim(),
120
- `salvage branch must contain the leaked kb.js content. Got: ${JSON.stringify(salvaged)}`);
121
-
122
- // 4) A LEAK_DETECTED marker is persisted in the worktree.
123
- assert.ok(fs.existsSync(path.join(worktree, 'LEAK_DETECTED')), 'expected LEAK_DETECTED marker');
124
- } finally {
125
- teardown(tmpRoot, repo);
126
- }
127
- });
128
-
129
- // ─────────────────────────────────────────────────────────────────────────────
130
- // REGRESSION GUARD: a healthy dispatch (>=1 task-branch commit) must NOT be
131
- // flagged as a leak even if the main repo happens to be dirty.
132
- // ─────────────────────────────────────────────────────────────────────────────
133
-
134
- test('TASK_1530 regression: dispatch WITH task-branch commits is NOT flagged as leak', () => {
135
- const { tmpRoot, repo } = setup();
136
- try {
137
- fs.writeFileSync(path.join(repo, 'kb.js'), 'v1\n');
138
- git('git add kb.js', repo);
139
- git('git commit --no-verify -m "seed"', repo);
140
-
141
- const worktree = path.join(tmpRoot, 'wt');
142
- git(`git worktree add -b task/task_ok1530 "${worktree}" HEAD`, repo);
143
- const originalWorktreeHead = git('git rev-parse HEAD', worktree);
144
-
145
- // Agent committed real work on the task branch (the healthy path).
146
- fs.writeFileSync(path.join(worktree, 'feature.js'), 'export const x = 1;\n');
147
- git('git add feature.js', worktree);
148
- git('git commit --no-verify -m "[TASK_OK1530] add feature"', worktree);
149
-
150
- // Main repo is incidentally dirty (e.g. unrelated state files).
151
- fs.writeFileSync(path.join(repo, 'kb.js'), 'v1\nincidental\n');
152
-
153
- const child = { _formation: { sessionId: 'sessok', skill: 'feature_agent' } };
154
- const res = detectWorktreeLeak(worktree, originalWorktreeHead, child, repo);
155
-
156
- assert.equal(res, null, `healthy dispatch must not be flagged as leak, got ${JSON.stringify(res)}`);
157
- assert.equal(child._formationExitCode, undefined, 'exit code must remain unset for healthy dispatch');
158
- assert.ok(!child._formationLeak, 'no leak record for healthy dispatch');
159
- } finally {
160
- teardown(tmpRoot, repo);
161
- }
162
- });
@@ -1,99 +0,0 @@
1
- // TASK_1863 — boot ghost-lease fix: re-running ansuz-boot-fast.js within the SAME
2
- // conversation must reuse the existing stream lease (via stream-lease register's
3
- // findLeaseByConversationId reuse path), not mint a new boot-<ts>-<pid> ghost.
4
- // Isolated sandbox via CLAUDE_PROJECT_DIR (stream-lease resolveBase honors it);
5
- // never touches the real repo's leases.
6
- //
7
- // Arms:
8
- // 1 (AC#2): 3 consecutive boots, same conversation id -> readAllLeases must hold
9
- // exactly ONE lease for that conversation; boots 2/3 must not print a
10
- // [Multi-Ansuz] banner (no self-ghost).
11
- // 2 (control): a boot under a DIFFERENT conversation id still creates its own
12
- // new lease (reuse is conversation-scoped, not a global singleton).
13
- //
14
- // Usage: node .specialist/tools/__tests__/task-1863-boot-lease-reuse.test.js
15
- // Exit 0=all pass, non-zero=at least one failure.
16
-
17
- const { spawnSync } = require('child_process');
18
- const fs = require('fs');
19
- const path = require('path');
20
- const os = require('os');
21
-
22
- function resolveRoot() {
23
- var d = path.resolve(__dirname);
24
- for (var i = 0; i < 10; i++) {
25
- if (fs.existsSync(path.join(d, '.kenaz')) && fs.existsSync(path.join(d, '.specialist'))) return d;
26
- var parent = path.dirname(d); if (parent === d) break; d = parent;
27
- }
28
- if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
29
- throw new Error('Cannot resolve repo root from ' + __dirname);
30
- }
31
- const WT = resolveRoot();
32
- const SB = path.join(os.tmpdir(), 't1863_boot_' + process.pid);
33
- const BOOT = path.join(WT, '.specialist/tools/ansuz-boot-fast.js');
34
- const STREAM_LEASE = path.join(WT, '.specialist/tools/stream-lease.js');
35
- const CONV1 = 'conv-t1863-boot';
36
- const CONV2 = 'conv-t1863-other';
37
-
38
- for (const d of [
39
- '.kenaz/tasks/backlog', '.kenaz/tasks/active', '.kenaz/tasks/done',
40
- '.kenaz/tasks/blocked', '.kenaz/tasks/cancel',
41
- '.kenaz/specialist/sessions/leases',
42
- ]) fs.mkdirSync(path.join(SB, d), { recursive: true });
43
-
44
- const env = { ...process.env, CLAUDE_PROJECT_DIR: SB };
45
-
46
- const boot = (convId) => spawnSync(process.execPath, [
47
- BOOT,
48
- '--project-path=' + SB,
49
- '--conversation-id=' + convId,
50
- '--mcp-base-url=http://127.0.0.1:1', // dead port: preamble arm fails instantly, offline-safe
51
- ], { encoding: 'utf8', env, windowsHide: true, cwd: SB, timeout: 30000 });
52
-
53
- // Count via the exported readAllLeases (card: do NOT re-implement lease scanning).
54
- // Child process so resolveBase picks up CLAUDE_PROJECT_DIR=SB at module load.
55
- const readLeases = () => {
56
- const r = spawnSync(process.execPath, ['-e',
57
- 'const sl=require(' + JSON.stringify(STREAM_LEASE) + ');process.stdout.write(JSON.stringify(sl.readAllLeases()));',
58
- ], { encoding: 'utf8', env, windowsHide: true });
59
- return JSON.parse(r.stdout || '[]');
60
- };
61
-
62
- let pass = 0, fail = 0;
63
- const check = (name, cond, detail) => {
64
- console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (cond ? '' : ' | ' + detail));
65
- cond ? pass++ : fail++;
66
- };
67
-
68
- // ── Arm 1: 3 boots, same conversation ──
69
- const outs = [];
70
- for (let i = 0; i < 3; i++) {
71
- const r = boot(CONV1);
72
- outs.push((r.stdout || '') + (r.stderr || ''));
73
- check('boot#' + (i + 1) + ' runs (banner printed)', (r.stdout || '').includes('Ansuz'),
74
- 'status=' + r.status + ' | ' + ((r.stdout || '') + (r.stderr || '')).slice(0, 300));
75
- }
76
- const all = readLeases();
77
- const mine = all.filter((l) => l.conversation_id === CONV1);
78
- check('AC#2: exactly ONE lease for the conversation after 3 boots', mine.length === 1,
79
- 'count=' + mine.length + ' sids=' + all.map((l) => l.session_id).join(','));
80
- check('boots 2+3 show no [Multi-Ansuz] self-ghost',
81
- !outs[1].includes('[Multi-Ansuz]') && !outs[2].includes('[Multi-Ansuz]'),
82
- 'boot2/3 banner: ' + (outs[1] + outs[2]).split(String.fromCharCode(10)).filter((l) => l.includes('Multi-Ansuz') || l.includes('[live]') || l.includes('[stale]')).join(' / '));
83
-
84
- // ── Arm 2 (control): different conversation still gets its OWN new lease ──
85
- const r2 = boot(CONV2);
86
- const all2 = readLeases();
87
- const mine2 = all2.filter((l) => l.conversation_id === CONV2);
88
- const mine1 = all2.filter((l) => l.conversation_id === CONV1);
89
- check('control: new conversation creates a NEW lease (reuse is conversation-scoped)',
90
- mine2.length === 1 && mine1.length === 1 && all2.length === 2,
91
- 'conv2=' + mine2.length + ' conv1=' + mine1.length + ' total=' + all2.length);
92
- check('control: second conversation SEES the first as a live peer (banner intact)',
93
- ((r2.stdout || '')).includes('[Multi-Ansuz]'),
94
- 'no peer banner: ' + (r2.stdout || '').slice(0, 400));
95
-
96
- fs.rmSync(SB, { recursive: true, force: true });
97
- console.log('---');
98
- console.log('RESULT: ' + pass + ' pass, ' + fail + ' fail');
99
- process.exit(fail ? 1 : 0);
@@ -1,129 +0,0 @@
1
- // TASK_1977 R3/R4(d) — ansuz-boot-fast.js handoff banner fan-out cap.
2
- //
3
- // MEASURED 2026-09-20: a run of 19 accumulated handoff slots printed 33.8 KB
4
- // into the fast-boot banner, most repeating the same pending_decisions text.
5
- // Fix: sort newest-first, print at most 3 full slots (deduped by topic
6
- // prefix), fold the rest into one summary line. Archiving must still touch
7
- // EVERY slot regardless of what gets rendered.
8
- //
9
- // Arms:
10
- // 1. 5 distinct-topic fixture slots -> 3 printed in full + 1 summary line
11
- // naming the other 2 topics; all 5 archived.
12
- // 2. dedup case: several slots share a topic prefix -> duplicates fold
13
- // into the summary instead of eating a "printed" slot.
14
- //
15
- // Usage: node .specialist/tools/__tests__/task-1977-banner-cap.test.js
16
- // Exit 0=all pass, non-zero=at least one failure.
17
-
18
- const { spawnSync } = require('child_process');
19
- const fs = require('fs');
20
- const os = require('os');
21
- const path = require('path');
22
-
23
- function resolveRoot() {
24
- let d = path.resolve(__dirname);
25
- for (let i = 0; i < 10; i++) {
26
- if (fs.existsSync(path.join(d, '.kenaz')) && fs.existsSync(path.join(d, '.specialist'))) return d;
27
- const parent = path.dirname(d);
28
- if (parent === d) break;
29
- d = parent;
30
- }
31
- if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
32
- throw new Error('Cannot resolve repo root from ' + __dirname);
33
- }
34
- const WT = resolveRoot();
35
- const BOOT = path.join(WT, '.specialist/tools/ansuz-boot-fast.js');
36
-
37
- let pass = 0, fail = 0;
38
- function check(name, cond, detail) {
39
- console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (cond ? '' : ' | ' + detail));
40
- cond ? pass++ : fail++;
41
- }
42
-
43
- function makeSandbox(prefix) {
44
- const sb = path.join(os.tmpdir(), prefix + '_' + process.pid + '_' + Date.now());
45
- for (const d of [
46
- '.kenaz/tasks/backlog', '.kenaz/tasks/active', '.kenaz/tasks/done',
47
- '.kenaz/tasks/blocked', '.kenaz/tasks/cancel',
48
- '.kenaz/specialist/sessions/leases',
49
- '.kenaz/specialist/handoffs',
50
- ]) fs.mkdirSync(path.join(sb, d), { recursive: true });
51
- return sb;
52
- }
53
-
54
- function writeSlot(sb, name, topic, generatedAt) {
55
- const p = path.join(sb, '.kenaz/specialist/handoffs', name);
56
- fs.writeFileSync(p, JSON.stringify({
57
- generated_at: generatedAt,
58
- isbar: {
59
- situation: { current_topic: topic, pending_decisions: 'pending for ' + topic },
60
- recommendation: { first_action: 'continue ' + topic },
61
- assessment: { unfinished: 'unfinished ' + topic },
62
- },
63
- }));
64
- }
65
-
66
- function boot(sb) {
67
- return spawnSync(process.execPath, [
68
- BOOT,
69
- '--project-path=' + sb,
70
- '--conversation-id=t1977-banner-' + path.basename(sb),
71
- '--mcp-base-url=http://127.0.0.1:1', // dead port: preamble arm fails instantly, offline-safe
72
- ], { encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: sb }, windowsHide: true, cwd: sb, timeout: 30000 });
73
- }
74
-
75
- // ── Arm 1: 5 distinct-topic slots, oldest to newest ──
76
- const sb1 = makeSandbox('t1977_banner_distinct');
77
- writeSlot(sb1, 'slot-1.json', 'Topic ONE', '2026-09-15T00:00:00.000Z');
78
- writeSlot(sb1, 'slot-2.json', 'Topic TWO', '2026-09-16T00:00:00.000Z');
79
- writeSlot(sb1, 'slot-3.json', 'Topic THREE', '2026-09-17T00:00:00.000Z');
80
- writeSlot(sb1, 'slot-4.json', 'Topic FOUR', '2026-09-18T00:00:00.000Z');
81
- writeSlot(sb1, 'slot-5.json', 'Topic FIVE', '2026-09-19T00:00:00.000Z'); // newest
82
- const r1 = boot(sb1);
83
- const out1 = (r1.stdout || '') + (r1.stderr || '');
84
- check('arm1: boot runs (banner printed)', out1.includes('Ansuz'), 'status=' + r1.status + ' | ' + out1.slice(0, 300));
85
- // TASK_2280: the banner prints String(slot_file).slice(0, 8) (TASK_1988 diet),
86
- // so "slot-5.json" shows as "[slot-5.j]" -- match the 8-char prefix, not the filename.
87
- const fullBlocks1 =(out1.match(/📜 Handoff \[slot-\d\.j\]:/g) || []).length;
88
- check('arm1: exactly 3 full slots printed', fullBlocks1 === 3, 'count=' + fullBlocks1 + '\n' + out1);
89
- check('arm1: newest 3 topics printed in full (FIVE, FOUR, THREE)',
90
- out1.includes('Topic FIVE') && out1.includes('Topic FOUR') && out1.includes('Topic THREE'),
91
- out1);
92
- check('arm1: one summary line for the remaining 2, naming their topics',
93
- /2 more handoff slots archived \(topics: Topic TWO; Topic ONE\)/.test(out1),
94
- out1);
95
- const archiveDir1 = path.join(sb1, '.kenaz/specialist/handoff_archive');
96
- const archived1 = fs.existsSync(archiveDir1) ? fs.readdirSync(archiveDir1) : [];
97
- check('arm1: ALL 5 slots archived regardless of banner selection', archived1.length === 5, 'archived=' + JSON.stringify(archived1));
98
- const remaining1 = fs.readdirSync(path.join(sb1, '.kenaz/specialist/handoffs'));
99
- check('arm1: handoffs dir drained after archiving', remaining1.length === 0, 'remaining=' + JSON.stringify(remaining1));
100
-
101
- // ── Arm 2: dedup -- 4 slots share a topic prefix, 1 is distinct ──
102
- const sb2 = makeSandbox('t1977_banner_dedup');
103
- const REPEATED_TOPIC = 'Repeated handoff topic used verbatim across several stale Codex sessions that keeps recurring';
104
- writeSlot(sb2, 'dup-1.json', REPEATED_TOPIC, '2026-09-16T00:00:00.000Z');
105
- writeSlot(sb2, 'dup-2.json', REPEATED_TOPIC, '2026-09-17T00:00:00.000Z');
106
- writeSlot(sb2, 'dup-3.json', REPEATED_TOPIC, '2026-09-18T00:00:00.000Z'); // newest of the dupes
107
- writeSlot(sb2, 'unique.json', 'A distinct one-off topic', '2026-09-15T00:00:00.000Z'); // oldest overall
108
- const r2 = boot(sb2);
109
- const out2 = (r2.stdout || '') + (r2.stderr || '');
110
- check('arm2: boot runs (banner printed)', out2.includes('Ansuz'), 'status=' + r2.status + ' | ' + out2.slice(0, 300));
111
- const fullBlocks2 = (out2.match(/📜 Handoff \[(dup-\d\.js|unique\.j)\]:/g) || []).length;
112
- check('arm2: only 2 full slots printed (one per distinct topic prefix, dupes folded)', fullBlocks2 === 2, 'count=' + fullBlocks2 + '\n' + out2);
113
- check('arm2: newest dupe (dup-3) is the one printed in full, not dup-1/dup-2',
114
- out2.includes('📜 Handoff [dup-3.js]:') && !out2.includes('📜 Handoff [dup-1.js]:') && !out2.includes('📜 Handoff [dup-2.js]:'),
115
- out2);
116
- check('arm2: the distinct topic is also printed in full', out2.includes('📜 Handoff [unique.j]:'), out2);
117
- check('arm2: summary line folds the 2 duplicate slots', /2 more handoff slots archived/.test(out2), out2);
118
- const archiveDir2 = path.join(sb2, '.kenaz/specialist/handoff_archive');
119
- const archived2 = fs.existsSync(archiveDir2) ? fs.readdirSync(archiveDir2) : [];
120
- check('arm2: ALL 4 slots archived regardless of dedup folding', archived2.length === 4, 'archived=' + JSON.stringify(archived2));
121
-
122
- // ── cleanup ──
123
- for (const sb of [sb1, sb2]) {
124
- try { fs.rmSync(sb, { recursive: true, force: true }); } catch { /* best-effort */ }
125
- }
126
-
127
- console.log('---');
128
- console.log('RESULT: ' + pass + ' pass, ' + fail + ' fail');
129
- process.exit(fail > 0 ? 1 : 0);