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.
- package/core/.claude/commands/Ansuz.md +36 -35
- package/core/.claude/hooks/__tests__/task-2395-project-registration.test.js +84 -0
- package/core/.claude/hooks/_ansuz-conversation.js +1 -0
- package/core/.claude/hooks/_ansuz-conversation.test.js +6 -0
- package/core/.claude/hooks/_run-user-prompt.js +0 -4
- package/core/.claude/hooks/_run-user-prompt.test.js +2 -3
- package/core/.claude/hooks/handoff-staleness.js +2 -2
- package/core/.claude/hooks/turn-context.js +50 -2
- package/core/.claude/settings.json +0 -37
- package/core/.collaboration/core/task-api.js +4 -12
- package/core/.formation/README.md +21 -146
- package/core/.formation/bifrost/codex-doctor.js +37 -2
- package/core/.formation/bifrost/codex-doctor.test.js +18 -0
- package/core/.formation/bifrost/launch.js +1 -1
- package/core/.formation/bifrost/openai/codex-hook-shim.js +6 -0
- package/core/.formation/bifrost/openai/codex-hook-shim.test.js +11 -1
- package/core/.formation/bifrost/setup-codex.js +3 -0
- package/core/.formation/spec/task_operations.parity.test.js +0 -1
- package/core/.formation/tools/dashboard-models-chain.test.js +0 -6
- package/core/.formation/tools/dispatch-detail/cross-project-worktree.md +1 -9
- package/core/.formation/tools/dispatch-detail/worktree-subagent-mode.md +2 -2
- package/core/.formation/tools/dispatch.js +28 -549
- package/core/.formation/tools/mcp-allowlist.test.js +0 -1
- package/core/.formation/tools/post-complete/task2345.test.js +0 -5
- package/core/.formation/tools/pre-dispatch-snapshot.test.js +0 -100
- package/core/.formation/tools/prompt.js +66 -1607
- package/core/.formation/tools/prompt.test.js +43 -582
- package/core/.kenaz/claude-hooks-manifest.json +16 -16
- package/core/.specialist/Ansuz/RULES.md +6 -10
- package/core/.specialist/tools/__tests__/freya-cli-contract.test.js +0 -1
- package/core/.specialist/tools/__tests__/task-2218-boot-session-id.test.js +17 -1
- package/core/.specialist/tools/__tests__/task-2222-session-identity-interleave.test.js +17 -1
- package/core/.specialist/tools/__tests__/task-2254-handoff-archive-contract.test.js +6 -5
- package/core/.specialist/tools/ansuz-boot-fast.js +44 -100
- package/core/.specialist/tools/ansuz-boot-fast.test.js +45 -1
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/ANSUZ_SLIM_PLAN.md +68 -0
- package/core/plugins/kenaz/commands/Amelia.md +1 -1
- package/core/plugins/kenaz/commands/Ansuz.md +36 -35
- package/core/plugins/kenaz/hooks/hooks.json +1 -5
- package/core/plugins/kenaz/hooks/kenaz-hook.js +0 -4
- package/core/plugins/kenaz/scripts/kenaz-mode.js +11 -1
- package/core/plugins/kenaz/scripts/kenaz.test.js +1 -1
- package/core/plugins/kenaz/scripts/sync.js +6 -2
- package/core/plugins/kenaz/scripts/ygg.js +13 -1
- package/core/plugins/kenaz/skills/product_manager/SKILL.md +12 -12
- package/lib/cli.js +47 -6
- package/lib/cli.test.js +24 -0
- package/lib/dashboard.js +10 -0
- package/package.json +11 -7
- package/vendor/install-kenaz-cli.js +18 -7
- package/core/.formation/tools/broadcast.js +0 -103
- package/core/.formation/tools/dispatch-dryrun.test.js +0 -54
- package/core/.formation/tools/dispatch-notifier.js +0 -84
- package/core/.formation/tools/formations.js +0 -1203
- package/core/.formation/tools/headless-branch-recovery.test.js +0 -64
- package/core/.formation/tools/headless-dryrun.test.js +0 -79
- package/core/.formation/tools/headless-v2.js +0 -659
- package/core/.formation/tools/headless.js +0 -1071
- package/core/.formation/tools/legacy-scheduler.js +0 -1994
- package/core/.formation/tools/logger.js +0 -273
- package/core/.formation/tools/memory-loader.js +0 -422
- package/core/.formation/tools/process-state.js +0 -33
- package/core/.formation/tools/remote-conflict.js +0 -98
- package/core/.formation/tools/runner.js +0 -1332
- package/core/.formation/tools/task-utils.js +0 -344
- package/core/.formation/tools/task2028-headless-worktree.test.js +0 -112
- package/core/.formation/tools/tests/headless-mcp-args-plumbing.test.js +0 -123
- package/core/.formation/tools/worktree-leak.test.js +0 -162
- package/core/.specialist/tools/__tests__/task-1863-boot-lease-reuse.test.js +0 -99
- 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);
|