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,344 +0,0 @@
1
- 'use strict';
2
- /**
3
- * Task Utilities
4
- *
5
- * Task management helpers extracted from headless.js.
6
- *
7
- * Exports: FIX_QUEUE_FILE, checkAndDispatchFix, checkTaskDependencies,
8
- * recoverStaleTasks, findNextTask
9
- */
10
-
11
- const fs = require('fs');
12
- const path = require('path');
13
- const { ROOT } = require('./utils');
14
- const { readActiveDispatches } = require('./dispatch-registry');
15
-
16
- // ─────────────────────────────────────────
17
- // FIX queue — 閉環自動 re-dispatch
18
- // ─────────────────────────────────────────
19
-
20
- const FIX_QUEUE_FILE = path.join(ROOT, '.formation', '.fix-queue.json');
21
-
22
- /**
23
- * 檢查 post-complete 是否建立了 FIX 任務,若有則自動 re-dispatch。
24
- * 讀取後立即刪除 queue 檔 — 防止無限遞迴。
25
- */
26
- function checkAndDispatchFix({ formation, skill, projectPath }) {
27
- try {
28
- if (!fs.existsSync(FIX_QUEUE_FILE)) return;
29
- const queue = JSON.parse(fs.readFileSync(FIX_QUEUE_FILE, 'utf-8'));
30
- fs.unlinkSync(FIX_QUEUE_FILE); // 消費 queue(atomic)
31
-
32
- if (!queue.fixTaskId) return;
33
-
34
- console.log(`\n🔄 Closed-loop: regression FIX detected — auto-dispatching ${queue.fixTaskId}`);
35
- console.log(` Source: ${queue.sourceTaskId} → Fix: ${queue.fixTaskId}`);
36
-
37
- // 使用 headless 自身的 main() 邏輯 re-dispatch
38
- const { spawnSync: spawnS } = require('child_process');
39
- const headlessPath = path.join(ROOT, '.formation', 'tools', 'headless.js');
40
- spawnS('node', [
41
- headlessPath,
42
- `--formation=${formation}`,
43
- `--skill=${skill}`,
44
- `--task=${queue.fixTaskId}`,
45
- ...(projectPath ? [`--project-path=${projectPath}`] : []),
46
- ], {
47
- cwd: ROOT,
48
- stdio: 'inherit',
49
- timeout: 600000, // 10 min max for FIX task
50
- windowsHide: true,
51
- });
52
- } catch (err) {
53
- console.log(` ⚠️ FIX auto-dispatch failed: ${err.message}`);
54
- }
55
- }
56
-
57
- // ─────────────────────────────────────────
58
- // 依賴預檢(與 dispatch.js 共用邏輯)
59
- // ─────────────────────────────────────────
60
-
61
- /**
62
- * 檢查任務的依賴是否都已完成。
63
- * @param {string} taskData - JSON 任務文本
64
- * @param {string|null} projectPath - 子專案路徑
65
- * @returns {{ blocked: boolean, unmet: string[] }}
66
- */
67
- function checkTaskDependencies(taskData, projectPath) {
68
- let deps = [];
69
- try {
70
- const parsed = JSON.parse(taskData);
71
- deps = parsed.dependencies || [];
72
- } catch {
73
- const match = (taskData || '').match(/^dependencies:\s*\n((?:\s+-\s+\S+\n?)+)/m);
74
- if (match) {
75
- deps = match[1].split('\n').map(l => l.trim().replace(/^-\s*/, '')).filter(Boolean);
76
- }
77
- }
78
- if (deps.length === 0) return { blocked: false, unmet: [] };
79
-
80
- const completedIds = new Set();
81
- const blockedIds = new Set();
82
- const scanDir = (dir, bucket) => {
83
- if (!dir || !fs.existsSync(dir)) return;
84
- try {
85
- for (const f of fs.readdirSync(dir)) {
86
- const m = f.match(/^(TASK_\d+)/); if (m) bucket.add(m[1]);
87
- }
88
- } catch { /* ignore */ }
89
- };
90
-
91
- const projectRoot = projectPath || ROOT;
92
- // Scan BOTH task directories — KenazAI core has both .kenaz/tasks/ and .collaboration/tasks/
93
- const kenazTasks = path.join(projectRoot, '.kenaz', 'tasks');
94
- const collabTasks = path.join(projectRoot, '.collaboration', 'tasks');
95
-
96
- scanDir(path.join(kenazTasks, 'done'), completedIds);
97
- scanDir(path.join(kenazTasks, 'archived'), completedIds);
98
- scanDir(path.join(collabTasks, 'done'), completedIds);
99
- scanDir(path.join(collabTasks, 'archived'), completedIds);
100
- scanDir(path.join(kenazTasks, 'blocked'), blockedIds);
101
- scanDir(path.join(collabTasks, 'blocked'), blockedIds);
102
- scanDir(path.join(projectRoot, 'dev', 'pipeline', 'completed'), completedIds);
103
-
104
- // Only check deps that match TASK_\d+ format; skip descriptive text
105
- const blockedDeps = deps.filter(dep => {
106
- const taskId = dep.match(/TASK_\d+/)?.[0];
107
- if (!taskId) return false;
108
- return blockedIds.has(taskId);
109
- });
110
- const unmet = deps.filter(dep => {
111
- const taskId = dep.match(/TASK_\d+/)?.[0];
112
- if (!taskId) return false; // not a real task ID — skip
113
- return !completedIds.has(taskId);
114
- });
115
- return { blocked: unmet.length > 0, unmet, blockedDeps };
116
- }
117
-
118
- // ─────────────────────────────────────────
119
- // Stale active-task recovery
120
- // ─────────────────────────────────────────
121
-
122
- /**
123
- * 偵測卡在 active/ 但沒有對應 dispatch 的任務,自動 complete。
124
- * 常見原因:互動對話 context 耗盡、手動中斷忘記收尾。
125
- *
126
- * 判定條件(全部滿足才觸發):
127
- * 1. 任務檔在 active/ 目錄
128
- * 2. claimed_at 超過 STALE_THRESHOLD_HOURS 小時
129
- * 3. 沒有對應的 .active-dispatches.json 記錄
130
- * 4. git log 有該任務的 commit(代表程式碼已完成)
131
- */
132
- const STALE_THRESHOLD_HOURS = 2;
133
-
134
- function recoverStaleTasks(projectPath) {
135
- const pathMod = require('path');
136
- const yamlLib = (() => { try { return require('js-yaml'); } catch { return require('../../.vendor/js-yaml.cjs'); } })();
137
-
138
- const coreRoot = pathMod.resolve(__dirname, '../..');
139
- const projectRoot = projectPath || coreRoot;
140
-
141
- // Find active task directories
142
- const activeDirs = [];
143
- const kenazActive = pathMod.join(projectRoot, '.kenaz', 'tasks', 'active');
144
- const collabActive = pathMod.join(projectRoot, '.collaboration', 'tasks', 'active');
145
- if (fs.existsSync(kenazActive)) activeDirs.push(kenazActive);
146
- if (fs.existsSync(collabActive)) activeDirs.push(collabActive);
147
- if (activeDirs.length === 0) return;
148
-
149
- // Currently running dispatches
150
- const activeDispatches = readActiveDispatches();
151
- const dispatchedTaskIds = new Set(activeDispatches.map(d => d.taskId));
152
-
153
- const now = Date.now();
154
- let recovered = 0;
155
-
156
- for (const dir of activeDirs) {
157
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml'));
158
- for (const file of files) {
159
- try {
160
- const filePath = pathMod.join(dir, file);
161
- const content = fs.readFileSync(filePath, 'utf-8');
162
- const data = yamlLib.load(content);
163
- if (!data || !data.id) continue;
164
-
165
- // Skip if there's an active dispatch for this task
166
- if (dispatchedTaskIds.has(data.id)) continue;
167
-
168
- // Check if claimed_at is stale
169
- const claimedAt = data.metadata?.claimed_at || data.assigned_to?.timestamp;
170
- if (!claimedAt) continue;
171
- const ageHours = (now - new Date(claimedAt).getTime()) / (1000 * 60 * 60);
172
- if (ageHours < STALE_THRESHOLD_HOURS) continue;
173
-
174
- // Check if git has a commit for this task (code was completed)
175
- try {
176
- const { execSync: es } = require('child_process');
177
- const gitLog = es(`git log --oneline --all --grep="${data.id}" -1`, {
178
- cwd: projectRoot, encoding: 'utf8', timeout: 5000, windowsHide: true,
179
- }).trim();
180
- if (!gitLog) continue; // No commit found — task may still be in progress manually
181
- } catch {
182
- continue;
183
- }
184
-
185
- // All conditions met — auto-complete
186
- console.log(`🔧 Stale task recovery: ${data.id} (active for ${ageHours.toFixed(1)}h, has commit, no dispatch)`);
187
- try {
188
- const { execSync: es } = require('child_process');
189
- es(`node "${pathMod.join(coreRoot, '.collaboration', 'core', 'task-api.js')}" complete ${data.id} --by=system --git-user=auto-recovery --skip-post-complete`, {
190
- cwd: coreRoot, encoding: 'utf8', timeout: 30000, stdio: 'pipe', windowsHide: true,
191
- });
192
- recovered++;
193
- } catch (err) {
194
- console.warn(` ⚠️ Recovery failed for ${data.id}: ${err.message}`);
195
- }
196
- } catch {
197
- // skip invalid files
198
- }
199
- }
200
- }
201
-
202
- if (recovered > 0) {
203
- console.log(`🔧 Recovered ${recovered} stale task(s)\n`);
204
- }
205
- }
206
-
207
- // ─────────────────────────────────────────
208
- // 自動尋找最高優先任務
209
- // ─────────────────────────────────────────
210
-
211
- /**
212
- * 從 backlog 中找出最高優先級的任務
213
- * 支援多專案:優先搜尋 projectPath 的 .kenaz/tasks/backlog/,
214
- * 再 fallback 到 core 的 .collaboration/tasks/backlog/。
215
- *
216
- * @param {string} [projectPath] - 子專案路徑(可選)
217
- * @returns {{ id: string, priority: number, idNum: number, title: string }|null}
218
- */
219
- function findNextTask(projectPath) {
220
- const fs = require('fs');
221
- const pathMod = require('path');
222
- const yamlLib = (() => { try { return require('js-yaml'); } catch { return require('../../.vendor/js-yaml.cjs'); } })();
223
-
224
- const coreRoot = pathMod.resolve(__dirname, '../..');
225
-
226
- // Build candidate backlog directories to scan
227
- const backlogDirs = [];
228
- if (projectPath) {
229
- // All projects with projectPath: scan .kenaz/tasks/ first, then .collaboration/tasks/
230
- backlogDirs.push(pathMod.join(projectPath, '.kenaz', 'tasks', 'backlog'));
231
- backlogDirs.push(pathMod.join(projectPath, '.collaboration', 'tasks', 'backlog'));
232
- } else {
233
- // No projectPath: use core's .collaboration/tasks/
234
- backlogDirs.push(pathMod.join(coreRoot, '.collaboration', 'tasks', 'backlog'));
235
- }
236
-
237
- // Find the first backlog dir that exists and has tasks
238
- let backlogDir = null;
239
- for (const dir of backlogDirs) {
240
- if (fs.existsSync(dir)) {
241
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml'));
242
- if (files.length > 0) {
243
- backlogDir = dir;
244
- break;
245
- }
246
- }
247
- }
248
- if (!backlogDir) return null;
249
-
250
- const files = fs.readdirSync(backlogDir).filter(f => f.endsWith('.yaml'));
251
- if (files.length === 0) return null;
252
-
253
- // Resolve the done dir relative to the same task root
254
- const taskRoot = pathMod.resolve(backlogDir, '..');
255
- const doneDir = pathMod.join(taskRoot, 'done');
256
-
257
- const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
258
- const tasks = [];
259
-
260
- for (const file of files) {
261
- try {
262
- const content = fs.readFileSync(pathMod.join(backlogDir, file), 'utf-8');
263
- const data = yamlLib.load(content);
264
- if (!data || !data.id) continue;
265
-
266
- // 跳過有未完成依賴的任務(done/ + archived/ 都算完成)
267
- const deps = Array.isArray(data.dependencies) ? data.dependencies : (data.dependencies ? [data.dependencies] : []); // TASK_1771
268
- if (deps.length > 0) {
269
- const archivedDir = pathMod.join(taskRoot, 'archived');
270
- const hasPendingDep = deps.some(dep => {
271
- const doneFiles = fs.existsSync(doneDir) ? fs.readdirSync(doneDir) : [];
272
- const archivedFiles = fs.existsSync(archivedDir) ? fs.readdirSync(archivedDir) : [];
273
- return !doneFiles.some(f => f.startsWith(dep)) && !archivedFiles.some(f => f.startsWith(dep));
274
- });
275
- if (hasPendingDep) continue;
276
- }
277
-
278
- const prio = priorityOrder[data.priority] ?? 99;
279
- const idNum = parseInt((data.id.match(/\d+/) || [0])[0], 10);
280
- tasks.push({ id: data.id, priority: prio, idNum, title: data.title });
281
- } catch {
282
- // skip invalid files
283
- }
284
- }
285
-
286
- if (tasks.length === 0) return null;
287
-
288
- // 排序: 優先級 asc, ID asc
289
- tasks.sort((a, b) => a.priority - b.priority || a.idNum - b.idNum);
290
- return tasks[0];
291
- }
292
-
293
- // ─────────────────────────────────────────
294
- // Cross-project task verification
295
- // ─────────────────────────────────────────
296
-
297
- /**
298
- * Verify that resolvedTask YAML exists inside the specified projectPath.
299
- * Prevents cross-project dispatch errors (2026-03-17 incident: TASK_549 → wrong repo).
300
- *
301
- * Extracted from launch() in headless.js (TASK_891).
302
- *
303
- * @param {string|null} taskData - JSON task data (guard is skipped when null)
304
- * @param {string} resolvedTask - Task ID (e.g. TASK_891)
305
- * @param {string|null} projectPath - Sub-project path to search in
306
- * @returns {boolean} true = valid (or guard not applicable), false = cross-project error
307
- */
308
- function verifyTaskInProject(taskData, resolvedTask, projectPath) {
309
- if (!taskData || !projectPath || !resolvedTask || !resolvedTask.startsWith('TASK_')) return true;
310
-
311
- const taskDirs = ['.kenaz/tasks', '.collaboration/tasks'];
312
- const statusDirs = ['active', 'backlog', 'done', 'blocked', 'todo', 'archived'];
313
- let foundInProject = false;
314
- for (const taskDir of taskDirs) {
315
- for (const status of statusDirs) {
316
- const dirPath = path.join(projectPath, taskDir, status);
317
- if (!fs.existsSync(dirPath)) continue;
318
- try {
319
- const files = fs.readdirSync(dirPath);
320
- if (files.some(f => f.startsWith(resolvedTask) && f.endsWith('.yaml'))) {
321
- foundInProject = true;
322
- break;
323
- }
324
- } catch { /* dir not readable — skip */ }
325
- }
326
- if (foundInProject) break;
327
- }
328
- if (!foundInProject) {
329
- console.error(`❌ Task ${resolvedTask} not found in project ${projectPath}`);
330
- console.error(` Searched: ${taskDirs.map(d => path.join(projectPath, d, '*/')).join(', ')}`);
331
- console.error(` This may indicate a cross-project dispatch error. Aborting.`);
332
- return false;
333
- }
334
- return true;
335
- }
336
-
337
- module.exports = {
338
- FIX_QUEUE_FILE,
339
- checkAndDispatchFix,
340
- checkTaskDependencies,
341
- recoverStaleTasks,
342
- findNextTask,
343
- verifyTaskInProject,
344
- };
@@ -1,112 +0,0 @@
1
- 'use strict';
2
- /**
3
- * TASK_2028 follow-up — regression test for the REAL root cause of the stray
4
- * .formation/worktrees/task_task_<id> worktrees observed on TASK_2025/2027.
5
- *
6
- * Pinned root cause (not the dangling-branch collision theory from the first
7
- * TASK_2028 commit, which remains valid hardening but was not the trigger):
8
- *
9
- * post-complete/milestone.js's triggerHuginn(taskId, projectPath) fires
10
- * every HUGINN_MILESTONE_INTERVAL (10) completions and spawns
11
- * `headless.js --skill=huginn-think --trigger=milestone --task=<taskId>
12
- * --skip-post-complete` so the trigger can be attributed back to the task
13
- * that crossed the milestone. Because --task is set, headless.js's
14
- * `isRealTask` guard was true, so `formations.setupUnitWorktree({taskId})`
15
- * ran and created `.formation/worktrees/task_<id>` on branch `task/<id>`
16
- * at HEAD -- BEFORE the `skill === 'huginn-think'` special route returned.
17
- * That route calls formations.launchHuginnThink() directly and returns
18
- * without ever using or cleaning the worktree it was just handed (it never
19
- * reaches launchUnit's onDone cleanup), so the worktree/branch pair is
20
- * orphaned every 10th completion. .prefrontal/milestone_counter.json
21
- * showed count=610 with last_trigger matching the exact minute
22
- * task_task_2027 appeared; TASK_2026 (count 609) did not trigger it.
23
- *
24
- * Fix: the huginn-think special route was moved ABOVE the worktree-setup
25
- * block in headless.js's launch(), so setupUnitWorktree() is never called
26
- * for skill=huginn-think regardless of whether --task is set. `observe`
27
- * (Muninn's milestone-adjacent trigger) is unaffected -- it has no
28
- * early-return route, so it flows through the normal launchUnit path and
29
- * already receives the same onDone worktree cleanup as any other dispatch.
30
- *
31
- * This test drives the REAL CLI entry point as a child process (not an
32
- * in-process require+call — headless.js's model-resolution helper is only
33
- * defined inside its `if (require.main === module)` CLI block, so an
34
- * in-process call would hit an unrelated pre-existing gap) with --dry-run,
35
- * which formations.js's launchHuginnThink checks FIRST and returns before
36
- * ever spawning a real claude session — matching the task's "no real claude
37
- * launch" requirement — and --project-path pointed at a disposable temp git
38
- * repo, so nothing under the real KenazAI repo is touched by the assertion
39
- * itself (the huginn-think route reads/writes nothing there either way).
40
- *
41
- * Run: node --test .formation/tools/task2028-headless-worktree.test.js
42
- */
43
-
44
- const test = require('node:test');
45
- const assert = require('node:assert/strict');
46
- const fs = require('fs');
47
- const os = require('os');
48
- const path = require('path');
49
- const { execSync, execFileSync } = require('child_process');
50
-
51
- function git(cmd, cwd) {
52
- return execSync(cmd, { cwd, encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
53
- }
54
- function gitSafe(cmd, cwd) { try { return git(cmd, cwd); } catch { return null; } }
55
-
56
- function makeFixtureRepo() {
57
- const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tn2028hl-')));
58
- const repo = path.join(tmpRoot, 'repo');
59
- fs.mkdirSync(repo, { recursive: true });
60
- git('git init -q', repo);
61
- git('git config user.email "t2028hl@test.local"', repo);
62
- git('git config user.name "T2028HL"', repo);
63
- git('git config commit.gpgsign false', repo);
64
- gitSafe('git checkout -q -b xyz9516', repo);
65
- for (const d of ['backlog', 'active', 'done', 'blocked']) {
66
- fs.mkdirSync(path.join(repo, '.kenaz', 'tasks', d), { recursive: true });
67
- }
68
- fs.writeFileSync(path.join(repo, 'README.md'), 'init\n');
69
- git('git add -A', repo);
70
- git('git commit -q --no-verify -m "init"', repo);
71
- return { tmpRoot, repo };
72
- }
73
-
74
- test('TASK_2028 follow-up: headless.js does NOT create a worktree for skill=huginn-think with --task set', async () => {
75
- const { tmpRoot, repo } = makeFixtureRepo();
76
- try {
77
- const headlessPath = path.join(__dirname, 'headless.js');
78
- const args = [
79
- headlessPath,
80
- '--skill=huginn-think',
81
- '--trigger=milestone',
82
- '--task=TASK_9999', // mirrors milestone.js's triggerHuginn(taskId, projectPath)
83
- `--project-path=${repo}`,
84
- '--dry-run',
85
- '--skip-post-complete',
86
- ];
87
-
88
- let stdout = '';
89
- let exitCode = 0;
90
- try {
91
- stdout = execFileSync(process.execPath, args, {
92
- encoding: 'utf8', timeout: 60000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
93
- });
94
- } catch (e) {
95
- stdout = (e.stdout || '').toString();
96
- exitCode = typeof e.status === 'number' ? e.status : 1;
97
- }
98
-
99
- assert.match(stdout, /HUGINN THINK DRY RUN/,
100
- 'the huginn-think special route must still run (dry-run banner expected in stdout)');
101
- assert.equal(exitCode, 0, `headless.js should exit 0 on a dry-run huginn-think dispatch; stdout:\n${stdout}`);
102
-
103
- assert.equal(fs.existsSync(path.join(repo, '.formation', 'worktrees')), false,
104
- 'no .formation/worktrees/ directory should exist in the fixture repo after a huginn-think dispatch with --task set — this is exactly what leaked .formation/worktrees/task_task_<id> on every 10th real completion');
105
-
106
- const branches = gitSafe('git branch -a', repo) || '';
107
- assert.doesNotMatch(branches, /task\/task_9999/,
108
- 'no task/<id> branch should have been created for a huginn-think dispatch either');
109
- } finally {
110
- fs.rmSync(tmpRoot, { recursive: true, force: true });
111
- }
112
- });
@@ -1,123 +0,0 @@
1
- // TASK_1929 (Agent Hub M2) — zero-regression + new-flag proof for the
2
- // --mcp-config-extra / --allowed-tools plumbing added to headless.js /
3
- // formations.js / runner.js.
4
- //
5
- // Runs the REAL headless.js as a child process (not `require`d in-process —
6
- // spawnClaude calls `process.exit(0)` under the KENAZ_DEBUG_SPAWN_ARGS
7
- // diagnostic, which would kill an in-process test runner). The diagnostic
8
- // itself is the point: it exits BEFORE actually spawning `claude`/`node
9
- // relay.js`, so this test costs zero API calls / zero codex invocations —
10
- // see runner.js spawnClaude/spawnBifrost's "TASK_1929: opt-in, zero-cost
11
- // args dump" comment.
12
- //
13
- // Run: node --test .formation/tools/tests/headless-mcp-args-plumbing.test.js
14
-
15
- const { test } = require('node:test');
16
- const assert = require('node:assert/strict');
17
- const { spawnSync } = require('node:child_process');
18
- const path = require('node:path');
19
- const fs = require('node:fs');
20
- const os = require('node:os');
21
-
22
- const ROOT = path.resolve(__dirname, '..', '..', '..');
23
- const HEADLESS_JS = path.join(ROOT, '.formation', 'tools', 'headless.js');
24
-
25
- function runHeadless(extraArgs, scratchProject) {
26
- const args = [
27
- HEADLESS_JS,
28
- '--formation=unit',
29
- '--skill=do',
30
- `--task=zero-regression check ${Math.random()}`,
31
- `--project-path=${scratchProject}`,
32
- '--skip-post-complete',
33
- ...extraArgs,
34
- ];
35
- const result = spawnSync(process.execPath, args, {
36
- cwd: ROOT,
37
- // KENAZ_MAX_DISPATCHES: each KENAZ_DEBUG_SPAWN_ARGS run pre-registers a
38
- // pid=null dispatch entry (headless.js's own "early dispatch
39
- // registration") and then exits via process.exit(0) BEFORE ever
40
- // spawning claude/reaching the normal unregister path — dispatch-
41
- // registry.js's pruneZombieDispatches() only treats a pid=null entry as
42
- // prunable once it's >30s old (PENDING_STALE_MS), so three of these
43
- // tests run back-to-back (well under 30s apart) exhaust the default
44
- // MAX_CONCURRENT_DISPATCHES=3 slot pool and waitForDispatchSlot() blocks
45
- // for real (observed: hangs to the 30s spawnSync timeout below). This
46
- // env var is the existing, documented escape hatch (dispatch-
47
- // registry.js reads it) — raising it is a test-harness fix for an
48
- // artifact of the debug diagnostic never registering a real PID, not a
49
- // product bug.
50
- env: { ...process.env, KENAZ_DEBUG_SPAWN_ARGS: '1', KENAZ_MAX_DISPATCHES: '1000' },
51
- encoding: 'utf8',
52
- timeout: 30_000,
53
- });
54
- assert.equal(result.status, 0, `headless.js exited non-zero: stderr=${result.stderr}`);
55
- const jsonLine = result.stdout.trim().split('\n').filter(Boolean).pop();
56
- let parsed;
57
- try {
58
- parsed = JSON.parse(jsonLine);
59
- } catch (e) {
60
- throw new Error(`last stdout line was not the args-dump JSON: ${jsonLine}\nfull stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
61
- }
62
- return parsed;
63
- }
64
-
65
- function makeScratchProject() {
66
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-task1929-'));
67
- return dir;
68
- }
69
-
70
- test('baseline (no new flags): args carry no --mcp-config-extra path and no --allowedTools override', (t) => {
71
- const scratch = makeScratchProject();
72
- t.after(() => fs.rmSync(scratch, { recursive: true, force: true }));
73
-
74
- const { cmd, args } = runHeadless([], scratch);
75
- assert.ok(cmd.toLowerCase().includes('claude'), 'baseline (no --dispatch-model/--bifrost-adapter) must resolve to the native claude CLI');
76
-
77
- // The exact default allowedTools list (unchanged shape) — this is the
78
- // BEFORE-this-task behavior, still produced when no override is passed.
79
- const idx = args.indexOf('--allowedTools');
80
- assert.ok(idx !== -1);
81
- assert.ok(args[idx + 1].startsWith('Bash,Read,Edit,Write,Glob,Grep,ToolSearch,mcp__kenaz-dashboard__kenaz_lsp_callers'));
82
-
83
- const mcpIdx = args.indexOf('--mcp-config');
84
- assert.ok(mcpIdx !== -1);
85
- assert.ok(args[mcpIdx + 1].endsWith('.mcp.json'));
86
- // Exactly one path follows --mcp-config (the core .mcp.json) — no extra
87
- // path appended when --mcp-config-extra was never passed.
88
- const afterMcpConfig = args.slice(mcpIdx + 1);
89
- assert.equal(afterMcpConfig.length, 1, `expected exactly one --mcp-config value, got: ${JSON.stringify(afterMcpConfig)}`);
90
- });
91
-
92
- test('with --mcp-config-extra + --allowed-tools: both are threaded through additively', (t) => {
93
- const scratch = makeScratchProject();
94
- t.after(() => fs.rmSync(scratch, { recursive: true, force: true }));
95
-
96
- const fakeMcpConfigPath = path.join(scratch, 'fake-mcp-config.json');
97
- fs.writeFileSync(fakeMcpConfigPath, JSON.stringify({ mcpServers: {} }));
98
-
99
- const { args } = runHeadless(
100
- ['--mcp-config-extra', fakeMcpConfigPath, '--allowed-tools', 'Read,Glob,Grep,mcp__flowpix'],
101
- scratch
102
- );
103
-
104
- const allowedIdx = args.indexOf('--allowedTools');
105
- assert.equal(args[allowedIdx + 1], 'Read,Glob,Grep,mcp__flowpix', 'allowedTools override must replace the default list verbatim');
106
-
107
- const mcpIdx = args.indexOf('--mcp-config');
108
- assert.ok(mcpIdx !== -1);
109
- const afterMcpConfig = args.slice(mcpIdx + 1);
110
- assert.equal(afterMcpConfig.length, 2, `expected core .mcp.json + the extra path, got: ${JSON.stringify(afterMcpConfig)}`);
111
- assert.ok(afterMcpConfig[0].endsWith('.mcp.json'));
112
- assert.equal(afterMcpConfig[1], fakeMcpConfigPath);
113
- });
114
-
115
- test('--dispatch-model routes through --model unchanged', (t) => {
116
- const scratch = makeScratchProject();
117
- t.after(() => fs.rmSync(scratch, { recursive: true, force: true }));
118
-
119
- const { args } = runHeadless(['--dispatch-model', 'claude-sonnet-5'], scratch);
120
- const idx = args.indexOf('--model');
121
- assert.ok(idx !== -1);
122
- assert.equal(args[idx + 1], 'claude-sonnet-5');
123
- });