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,659 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Formation Headless V2 Launcher
4
- *
5
- * 雙軸架構:--base(行為模式)+ --role(角色專業)
6
- *
7
- * 核心概念:
8
- * base = unit/{do,plan,think}.yaml → 決定「做什麼」(workflow、output format)
9
- * role = 任意 .formation/*.yaml → 決定「用什麼角色」(identity、strengths)
10
- * prompt = base + role context + task
11
- *
12
- * V1 的問題:loadSkill('unit', 'developer') → 找不到 .formation/unit/developer.yaml
13
- * V2 的解法:loadSkill('unit', 'do') + loadRole('legacy/developer') → 合成 prompt
14
- *
15
- * Usage:
16
- * node .formation/tools/headless-v2.js --base=do --task=TASK_301
17
- * node .formation/tools/headless-v2.js --base=do --role=developer --task=TASK_301
18
- * node .formation/tools/headless-v2.js --base=think --role=strategy --task=TASK_302
19
- * node .formation/tools/headless-v2.js --base=plan --role=designer --task=TASK_303
20
- * node .formation/tools/headless-v2.js --base=do --role=legacy/developer --task=TASK_304
21
- * node .formation/tools/headless-v2.js --base=do --role=solo/strategy --task=TASK_305
22
- */
23
-
24
- const fs = require('fs');
25
- const path = require('path');
26
- const { execSync } = require('child_process');
27
- const { loadSkill, loadTask } = require('./loader');
28
- const {
29
- buildPrompt, HEADLESS_PREFIX
30
- } = require('./prompt');
31
- const { runHeadless } = require('./runner');
32
- const { generateSessionId, getLogDir, ROOT, COLLABORATION_API, ensureGitReady,
33
- createWorktree, removeWorktree, ensureWorktreeDirIgnored } = require('./utils');
34
- const { postComplete } = require('./post-complete');
35
-
36
- let canary;
37
- try { canary = require('../../.specialist/tools/canary'); } catch { canary = null; }
38
-
39
- // ─────────────────────────────────────────
40
- // Role Map — 從 role-map.yaml 載入對照表
41
- // ─────────────────────────────────────────
42
-
43
- const ROLE_MAP_PATH = path.join(ROOT, '.formation', 'role-map.yaml');
44
- const VALID_BASES = ['do', 'plan', 'think'];
45
-
46
- function loadRoleMap() {
47
- const yaml = (() => {
48
- try { return require('js-yaml'); }
49
- catch { return require('../../.vendor/js-yaml.cjs'); }
50
- })();
51
-
52
- if (!fs.existsSync(ROLE_MAP_PATH)) {
53
- console.error(`❌ role-map.yaml not found: ${ROLE_MAP_PATH}`);
54
- process.exit(1);
55
- }
56
- return yaml.load(fs.readFileSync(ROLE_MAP_PATH, 'utf-8'));
57
- }
58
-
59
- /**
60
- * 解析 --role 參數,回傳 skill YAML 的完整內容
61
- *
62
- * 支援格式:
63
- * --role=developer → 查 role-map.yaml → legacy/developer.yaml
64
- * --role=legacy/developer → 直接載入 .formation/legacy/developer.yaml
65
- * --role=solo/strategy → 直接載入 .formation/solo/strategy.yaml
66
- * --role=path/to/custom.yaml → 直接載入自訂 YAML
67
- */
68
- function loadRole(roleName) {
69
- const yaml = (() => {
70
- try { return require('js-yaml'); }
71
- catch { return require('../../.vendor/js-yaml.cjs'); }
72
- })();
73
-
74
- // Case 1: 直接指定路徑(含 / 或 \)
75
- if (roleName.includes('/') || roleName.includes('\\')) {
76
- // 相對於 .formation/ 的路徑
77
- let rolePath = path.join(ROOT, '.formation', roleName);
78
- if (!rolePath.endsWith('.yaml')) rolePath += '.yaml';
79
-
80
- if (!fs.existsSync(rolePath)) {
81
- console.error(`❌ Role YAML not found: ${rolePath}`);
82
- process.exit(1);
83
- }
84
- return yaml.load(fs.readFileSync(rolePath, 'utf-8'));
85
- }
86
-
87
- // Case 2: 短名稱 → 從 role-map.yaml 查找
88
- const roleMap = loadRoleMap();
89
- const roleEntry = roleMap.roles?.[roleName];
90
-
91
- if (!roleEntry) {
92
- const available = Object.keys(roleMap.roles || {}).join(', ');
93
- console.error(`❌ Unknown role: "${roleName}"`);
94
- console.error(` Available roles: ${available}`);
95
- process.exit(1);
96
- }
97
-
98
- const rolePath = path.join(ROOT, '.formation', roleEntry.skill_path);
99
- if (!fs.existsSync(rolePath)) {
100
- console.error(`❌ Role YAML not found: ${rolePath} (mapped from role "${roleName}")`);
101
- process.exit(1);
102
- }
103
- return yaml.load(fs.readFileSync(rolePath, 'utf-8'));
104
- }
105
-
106
- // ─────────────────────────────────────────
107
- // Prompt 合成:base + role
108
- // ─────────────────────────────────────────
109
-
110
- /**
111
- * 將 base skill 和 role 合成為最終 prompt
112
- *
113
- * 策略:
114
- * 1. 用 base(do/plan/think)的 buildPrompt 產生基礎 prompt
115
- * 2. 在 Role section 後注入 role 的 identity 和 strengths
116
- * 3. 保留 base 的 workflow 和 output format
117
- */
118
- function buildMergedPrompt(baseDef, roleDef, taskInput, taskData, options = {}) {
119
- // 合成 skillDef:以 base 為主體,注入 role 的 identity
120
- const mergedDef = JSON.parse(JSON.stringify(baseDef));
121
-
122
- if (roleDef) {
123
- // 保留 base 的 workflow/output,但替換 role identity
124
- const baseIdentity = mergedDef.role?.identity || '';
125
- const roleIdentity = roleDef.role?.identity || '';
126
- const rolePriority = roleDef.role?.priority || '';
127
-
128
- mergedDef.role = {
129
- identity: `${roleIdentity}\n\n[Base Mode: ${baseDef.name}] ${baseIdentity}`,
130
- priority: rolePriority || mergedDef.role?.priority || '',
131
- };
132
-
133
- // 合併 behavior:role 的 behavior 在前(角色特性),base 的在後(行為模式)
134
- const roleBehavior = roleDef.behavior || [];
135
- const baseBehavior = baseDef.behavior || [];
136
- mergedDef.behavior = [...roleBehavior, ...baseBehavior];
137
-
138
- // 注入 role 的 perspectives(如 solo/strategy 的六視角)
139
- if (roleDef.perspectives) {
140
- mergedDef.perspectives = roleDef.perspectives;
141
- }
142
-
143
- // 注入 role 的 analysis_principles(如 solo/diagnosis)
144
- if (roleDef.analysis_principles) {
145
- mergedDef.analysis_principles = roleDef.analysis_principles;
146
- }
147
- }
148
-
149
- return buildPrompt(mergedDef, taskInput, taskData, options);
150
- }
151
-
152
- // ─────────────────────────────────────────
153
- // 任務相關(複用 headless.js 邏輯)
154
- // ─────────────────────────────────────────
155
-
156
- function findNextTask(projectPath) {
157
- const pathMod = require('path');
158
- const yamlLib = (() => { try { return require('js-yaml'); } catch { return require('../../.vendor/js-yaml.cjs'); } })();
159
-
160
- const coreRoot = pathMod.resolve(__dirname, '../..');
161
- const backlogDirs = [];
162
- if (projectPath && projectPath !== coreRoot) {
163
- backlogDirs.push(pathMod.join(projectPath, '.kenaz', 'tasks', 'backlog'));
164
- backlogDirs.push(pathMod.join(projectPath, '.collaboration', 'tasks', 'backlog'));
165
- }
166
- if (!projectPath || projectPath === coreRoot) {
167
- backlogDirs.push(pathMod.join(coreRoot, '.collaboration', 'tasks', 'backlog'));
168
- }
169
-
170
- let backlogDir = null;
171
- for (const dir of backlogDirs) {
172
- if (fs.existsSync(dir)) {
173
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml'));
174
- if (files.length > 0) { backlogDir = dir; break; }
175
- }
176
- }
177
- if (!backlogDir) return null;
178
-
179
- const files = fs.readdirSync(backlogDir).filter(f => f.endsWith('.yaml'));
180
- if (files.length === 0) return null;
181
-
182
- const taskRoot = pathMod.resolve(backlogDir, '..');
183
- const doneDir = pathMod.join(taskRoot, 'done');
184
- const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
185
- const tasks = [];
186
-
187
- for (const file of files) {
188
- try {
189
- const content = fs.readFileSync(pathMod.join(backlogDir, file), 'utf-8');
190
- const data = yamlLib.load(content);
191
- if (!data || !data.id) continue;
192
- const deps = Array.isArray(data.dependencies) ? data.dependencies : (data.dependencies ? [data.dependencies] : []); // TASK_1771
193
- if (deps.length > 0) {
194
- const archivedDir = pathMod.join(taskRoot, 'archived');
195
- const hasPendingDep = deps.some(dep => {
196
- const doneFiles = fs.existsSync(doneDir) ? fs.readdirSync(doneDir) : [];
197
- const archivedFiles = fs.existsSync(archivedDir) ? fs.readdirSync(archivedDir) : [];
198
- return !doneFiles.some(f => f.startsWith(dep)) && !archivedFiles.some(f => f.startsWith(dep));
199
- });
200
- if (hasPendingDep) continue;
201
- }
202
- const prio = priorityOrder[data.priority] ?? 99;
203
- const idNum = parseInt((data.id.match(/\d+/) || [0])[0], 10);
204
- tasks.push({ id: data.id, priority: prio, idNum, title: data.title });
205
- } catch { /* skip */ }
206
- }
207
- if (tasks.length === 0) return null;
208
- tasks.sort((a, b) => a.priority - b.priority || a.idNum - b.idNum);
209
- return tasks[0];
210
- }
211
-
212
- function checkTaskDependencies(taskData, projectPath) {
213
- let deps = [];
214
- try {
215
- const parsed = JSON.parse(taskData);
216
- deps = parsed.dependencies || [];
217
- } catch {
218
- const match = (taskData || '').match(/^dependencies:\s*\n((?:\s+-\s+\S+\n?)+)/m);
219
- if (match) {
220
- deps = match[1].split('\n').map(l => l.trim().replace(/^-\s*/, '')).filter(Boolean);
221
- }
222
- }
223
- if (deps.length === 0) return { blocked: false, unmet: [] };
224
-
225
- const completedIds = new Set();
226
- const blockedIds = new Set();
227
- const scanDir = (dir, bucket) => {
228
- if (!dir || !fs.existsSync(dir)) return;
229
- try {
230
- for (const f of fs.readdirSync(dir)) {
231
- if (f.startsWith('TASK_')) bucket.add(f.split('_').slice(0, 2).join('_'));
232
- }
233
- } catch { /* ignore */ }
234
- };
235
-
236
- const projectRoot = projectPath || ROOT;
237
- scanDir(path.join(projectRoot, '.kenaz', 'tasks', 'done'), completedIds);
238
- scanDir(path.join(projectRoot, '.kenaz', 'tasks', 'archived'), completedIds);
239
- scanDir(path.join(projectRoot, '.kenaz', 'tasks', 'blocked'), blockedIds);
240
- scanDir(path.join(projectRoot, '.collaboration', 'tasks', 'done'), completedIds);
241
- scanDir(path.join(projectRoot, '.collaboration', 'tasks', 'archived'), completedIds);
242
- scanDir(path.join(projectRoot, '.collaboration', 'tasks', 'blocked'), blockedIds);
243
- scanDir(path.join(projectRoot, 'dev', 'pipeline', 'completed'), completedIds);
244
-
245
- const blockedDeps = deps.filter(dep => {
246
- const taskId = dep.match(/TASK_\d+/)?.[0];
247
- if (!taskId) return false;
248
- return blockedIds.has(taskId);
249
- });
250
- const unmet = deps.filter(dep => {
251
- const taskId = dep.match(/TASK_\d+/)?.[0];
252
- if (!taskId) return false;
253
- return !completedIds.has(taskId);
254
- });
255
- return { blocked: unmet.length > 0, unmet, blockedDeps };
256
- }
257
-
258
- function ensureTaskComplete(taskId, projectPath) {
259
- if (!taskId || !taskId.startsWith('TASK_')) return;
260
- try {
261
- const ppArg = projectPath ? ` --project-path "${projectPath}"` : '';
262
- const info = execSync(
263
- `node "${COLLABORATION_API}"${ppArg} get ${taskId}`,
264
- { encoding: 'utf-8', timeout: 10000, windowsHide: true }
265
- );
266
- if (info.includes('"status":"done"') || info.includes('status: done')) return;
267
- execSync(
268
- `node "${COLLABORATION_API}"${ppArg} complete ${taskId}`,
269
- { encoding: 'utf-8', timeout: 10000, windowsHide: true }
270
- );
271
- } catch { /* ignore */ }
272
- }
273
-
274
- // ─────────────────────────────────────────
275
- // Active dispatch registry(複用 headless.js 格式)
276
- // ─────────────────────────────────────────
277
-
278
- const ACTIVE_DISPATCH_FILE = path.join(ROOT, '.formation', '.active-dispatches.json');
279
-
280
- function readActiveDispatches() {
281
- try {
282
- if (fs.existsSync(ACTIVE_DISPATCH_FILE)) {
283
- return JSON.parse(fs.readFileSync(ACTIVE_DISPATCH_FILE, 'utf-8'));
284
- }
285
- } catch { /* corrupted file */ }
286
- return [];
287
- }
288
-
289
- function writeActiveDispatches(list) {
290
- try {
291
- fs.writeFileSync(ACTIVE_DISPATCH_FILE, JSON.stringify(list, null, 2));
292
- } catch { /* ignore */ }
293
- }
294
-
295
- function registerDispatch({ sessionId, logPath, taskId, formation, projectPath: pp }) {
296
- const list = readActiveDispatches();
297
- const nextEntry = {
298
- sessionId,
299
- logPath,
300
- taskId,
301
- formation,
302
- startedAt: new Date().toISOString(),
303
- projectPath: pp || null,
304
- };
305
- const existingIndex = list.findIndex(d => d.sessionId === sessionId);
306
- if (existingIndex >= 0) {
307
- const mergedEntry = {
308
- ...list[existingIndex],
309
- startedAt: list[existingIndex].startedAt || nextEntry.startedAt,
310
- };
311
- for (const [key, value] of Object.entries(nextEntry)) {
312
- if (key === 'startedAt') continue;
313
- if (value !== null && value !== undefined) {
314
- mergedEntry[key] = value;
315
- }
316
- }
317
- list[existingIndex] = mergedEntry;
318
- } else {
319
- list.push(nextEntry);
320
- }
321
- writeActiveDispatches(list);
322
- }
323
-
324
- function unregisterDispatch(sessionId) {
325
- const list = readActiveDispatches().filter(d => d.sessionId !== sessionId);
326
- if (list.length === 0) {
327
- try { fs.unlinkSync(ACTIVE_DISPATCH_FILE); } catch { /* ignore */ }
328
- } else {
329
- writeActiveDispatches(list);
330
- }
331
- }
332
-
333
- function syncCommit(taskId, projectPath) {
334
- try {
335
- const commitSync = require('./commit-sync');
336
- if (commitSync && typeof commitSync.sync === 'function') {
337
- commitSync.sync({ taskId, projectPath });
338
- }
339
- } catch { /* commit-sync not available */ }
340
- }
341
-
342
- function broadcastStatusSync(projectPath, status) {
343
- try {
344
- const { broadcastSync } = require('./utils');
345
- if (broadcastSync) broadcastSync(projectPath, status);
346
- } catch { /* ignore */ }
347
- }
348
-
349
- function recoverStaleTasks(projectPath) {
350
- try {
351
- const { recoverStale } = require('./task-recovery');
352
- if (recoverStale) recoverStale(projectPath);
353
- } catch { /* ignore */ }
354
- }
355
-
356
- // ─────────────────────────────────────────
357
- // 主調度
358
- // ─────────────────────────────────────────
359
-
360
- function launch(options) {
361
- const {
362
- base = 'do',
363
- role = null,
364
- task,
365
- dryRun = false,
366
- projectPath = null,
367
- skipPostComplete = false,
368
- skipTests = false,
369
- force = false,
370
- } = options;
371
-
372
- // Validate base
373
- if (!VALID_BASES.includes(base)) {
374
- console.error(`❌ Invalid --base: "${base}". Must be one of: ${VALID_BASES.join(', ')}`);
375
- process.exit(1);
376
- }
377
-
378
- const sessionId = generateSessionId();
379
-
380
- // 0. Cleanup
381
- try {
382
- const { cleanOrphanedClaude } = require('../../.specialist/tools/session');
383
- const orphanResult = cleanOrphanedClaude();
384
- if (orphanResult.killed > 0) {
385
- console.log(`🧹 Cleaned ${orphanResult.killed} orphaned claude.exe process(es)`);
386
- }
387
- } catch { /* session.js not available */ }
388
- recoverStaleTasks(projectPath);
389
-
390
- // 0.1 Parallel dispatch guard
391
- const activeDispatches = readActiveDispatches();
392
- const sameProject = activeDispatches.filter(d =>
393
- (d.projectPath || null) === (projectPath || null)
394
- );
395
- if (sameProject.length > 0) {
396
- console.log(`⏳ Waiting for ${sameProject.length} active dispatch(es) on same project...`);
397
- const PARALLEL_WAIT_TIMEOUT_MS = 600000;
398
- const PARALLEL_POLL_MS = 5000;
399
- const deadline = Date.now() + PARALLEL_WAIT_TIMEOUT_MS;
400
- while (Date.now() < deadline) {
401
- const current = readActiveDispatches();
402
- const still = current.filter(d => (d.projectPath || null) === (projectPath || null));
403
- if (still.length === 0) { console.log(' ✅ Previous dispatches completed.'); break; }
404
- const wait = Date.now() + PARALLEL_POLL_MS;
405
- while (Date.now() < wait) { /* busy wait */ }
406
- }
407
- const remaining = readActiveDispatches().filter(d => (d.projectPath || null) === (projectPath || null));
408
- if (remaining.length > 0) {
409
- console.log(` ⚠️ Timed out. Clearing ${remaining.length} stale dispatch(es).`);
410
- writeActiveDispatches(readActiveDispatches().filter(d => (d.projectPath || null) !== (projectPath || null)));
411
- }
412
- }
413
-
414
- // 0.2 Auto-select task
415
- let resolvedTask = task;
416
- if (!resolvedTask) {
417
- const nextTask = findNextTask(projectPath);
418
- if (!nextTask) {
419
- console.log('✅ No pending tasks found in backlog. Nothing to do.');
420
- if (projectPath) console.log(` (searched in: ${projectPath})`);
421
- process.exit(0);
422
- }
423
- resolvedTask = nextTask.id;
424
- console.log(`🔍 Auto-selected: [${nextTask.id}] ${nextTask.title} (${['P0','P1','P2','P3'][nextTask.priority] || 'P?'})\n`);
425
- }
426
-
427
- // 1. 載入 base skill
428
- const baseDef = loadSkill('unit', base);
429
- const loadOpts = projectPath ? { projectPath } : undefined;
430
- const taskData = resolvedTask && resolvedTask.startsWith('TASK_') ? loadTask(resolvedTask, loadOpts) : null;
431
- const taskInput = resolvedTask || '';
432
-
433
- // 1.1 載入 role(如有指定)
434
- const roleDef = role ? loadRole(role) : null;
435
-
436
- // 1.2 依賴預檢
437
- if (taskData) {
438
- const depCheck = checkTaskDependencies(taskData, projectPath);
439
- if (depCheck.blocked) {
440
- console.error(`❌ Dispatch blocked: ${taskInput} has unmet dependencies`);
441
- for (const dep of depCheck.unmet) {
442
- if (depCheck.blockedDeps && depCheck.blockedDeps.includes(dep)) {
443
- console.error(` ⛔ ${dep} — blocked`);
444
- } else {
445
- console.error(` ⛔ ${dep} — not in done/completed`);
446
- }
447
- }
448
- if (!force) {
449
- console.error(` → Complete the dependencies first, or use --force to override`);
450
- process.exitCode = 1;
451
- return null;
452
- }
453
- console.warn(` ⚠️ --force: dispatching despite unmet dependencies`);
454
- }
455
- }
456
-
457
- // 1.5 Worktree isolation — create an isolated git worktree for real TASK_xxx dispatches
458
- // This closes the no-lock gap: headless-v2 previously had neither a dispatch-lock nor a worktree.
459
- const isRealTask = resolvedTask && /^TASK_\d+$/i.test(resolvedTask);
460
- let _worktreePath = null;
461
-
462
- if (isRealTask) {
463
- const projectRoot = projectPath || ROOT;
464
- const worktreeName = `task_${resolvedTask.toLowerCase()}`;
465
- const absoluteWorktreePath = require('path').join(projectRoot, '.formation', 'worktrees', worktreeName);
466
-
467
- ensureWorktreeDirIgnored(projectRoot);
468
-
469
- const branchName = `task/${resolvedTask.toLowerCase()}`;
470
- try {
471
- createWorktree(projectRoot, absoluteWorktreePath, branchName);
472
- _worktreePath = absoluteWorktreePath;
473
- console.log(` 🌿 V2 worktree: .formation/worktrees/${worktreeName}`);
474
-
475
- // Cleanup on exit
476
- const releaseWorktree = () => {
477
- try { removeWorktree(projectRoot, absoluteWorktreePath); } catch {}
478
- };
479
- process.on('exit', releaseWorktree);
480
- process.on('SIGTERM', () => { releaseWorktree(); process.exit(1); });
481
- process.on('SIGINT', () => { releaseWorktree(); process.exit(1); });
482
- } catch (err) {
483
- console.warn(`⚠️ Worktree creation failed: ${err.message}`);
484
- console.warn(` Proceeding without worktree isolation.`);
485
- }
486
- }
487
-
488
- // 2. 合成 prompt
489
- const promptOpts = {};
490
- if (projectPath) promptOpts.projectPath = projectPath;
491
- if (_worktreePath) promptOpts.worktreePath = _worktreePath;
492
- const rawPrompt = buildMergedPrompt(baseDef, roleDef, taskInput, taskData, promptOpts);
493
-
494
- // 2.1 Parse scope paths for canary
495
- let taskScopePaths = [];
496
- if (taskData) {
497
- try {
498
- const parsed = JSON.parse(taskData);
499
- taskScopePaths = parsed.scope_paths || [];
500
- } catch { /* raw YAML */ }
501
- }
502
-
503
- // 3. Banner
504
- const roleLabel = role ? ` + ${role}` : '';
505
- console.log(`⚔️ Formation Headless V2 Launch`);
506
- console.log(` Base: ${base}${roleLabel}`);
507
- console.log(` Task: ${resolvedTask || '(inline)'}`);
508
- console.log(` Session: ${sessionId}`);
509
- if (projectPath) console.log(` Project: ${projectPath}`);
510
- console.log('');
511
-
512
- if (dryRun) {
513
- console.log('--- DRY RUN: Merged Prompt ---');
514
- console.log(rawPrompt);
515
- console.log('--- END ---');
516
- console.log(`\nPrompt length: ${rawPrompt.length.toLocaleString()} chars`);
517
- if (roleDef) {
518
- console.log(`Base: unit/${base}.yaml`);
519
- console.log(`Role: ${role} → ${roleDef.name || 'unknown'}`);
520
- }
521
- return;
522
- }
523
-
524
- // 4. Launch
525
- let finalPrompt = HEADLESS_PREFIX + rawPrompt;
526
- if (canary) {
527
- try {
528
- const { block } = canary.generate({
529
- session: sessionId,
530
- specialist: 'unit-v2',
531
- task: resolvedTask || 'none',
532
- scope: taskScopePaths.join(','),
533
- }, projectPath);
534
- finalPrompt = block + '\n\n' + finalPrompt;
535
- } catch (err) {
536
- console.warn(`⚠️ Canary injection skipped: ${err.message}`);
537
- }
538
- }
539
-
540
- const skillLabel = role ? `${base}+${role}` : base;
541
- const child = runHeadless({
542
- prompt: finalPrompt,
543
- formation: 'unit',
544
- skill: skillLabel,
545
- sessionId,
546
- projectPath,
547
- worktreeDir: _worktreePath,
548
- });
549
-
550
- // 5. 註冊 dispatch
551
- const logPath = child._formation?.logPath;
552
- registerDispatch({ sessionId, logPath, taskId: resolvedTask, formation: 'unit-v2', projectPath });
553
- process.on('exit', () => unregisterDispatch(sessionId));
554
-
555
- // 6. 閉環:post-complete
556
- let cleanedUp = false;
557
- const onDone = (code) => {
558
- if (cleanedUp) return;
559
- cleanedUp = true;
560
- unregisterDispatch(sessionId);
561
-
562
- // Worktree cleanup: remove isolated worktree after agent completes
563
- if (_worktreePath) {
564
- try { removeWorktree(projectPath || ROOT, _worktreePath); } catch {}
565
- }
566
-
567
- if (resolvedTask && resolvedTask.startsWith('TASK_') && !skipPostComplete) {
568
- if (code === 0) {
569
- console.log(`\n⚔️ Auto Post-Complete: ${resolvedTask}`);
570
- ensureTaskComplete(resolvedTask, projectPath);
571
- try {
572
- postComplete({ taskId: resolvedTask, projectPath, skipTests });
573
- } catch (e) {
574
- console.error(`❌ Post-complete failed: ${e.message}`);
575
- }
576
- } else {
577
- console.log(`\n⚠️ Agent exited with code ${code} — skipping post-complete`);
578
- }
579
- } else if (code === 0) {
580
- syncCommit(resolvedTask, projectPath);
581
- }
582
-
583
- broadcastStatusSync(projectPath, { is_online: false, current_task: null, touching_files: [] });
584
- setTimeout(() => process.exit(code || 0), 500);
585
- };
586
-
587
- child.on('close', onDone);
588
- child.on('formation:done', onDone);
589
-
590
- return child;
591
- }
592
-
593
- // ─────────────────────────────────────────
594
- // CLI
595
- // ─────────────────────────────────────────
596
-
597
- if (require.main === module) {
598
- const args = process.argv.slice(2);
599
-
600
- if (args.includes('--help') || args.includes('-h')) {
601
- console.log(`
602
- ⚔️ Formation Headless V2 Launcher
603
-
604
- 雙軸架構:base(行為模式)+ role(角色專業)
605
-
606
- Usage:
607
- node .formation/tools/headless-v2.js --base=<mode> [--role=<name>] --task=<value>
608
-
609
- Options:
610
- --base <mode> 行為模式: do (default), plan, think
611
- --role <name> 角色名稱或路徑(可選)
612
- 短名稱: developer, frontend, backend, designer, qa, sre, pm
613
- 路徑: legacy/developer, solo/strategy, solo/diagnosis
614
- --task <value> TASK_ID 或任務描述
615
- --project-path <p> 子專案路徑
616
- --dry-run 只印合成的 prompt,不啟動進程
617
- --skip-post-complete 跳過 post-complete
618
- --skip-tests 跳過回歸測試
619
- --force 強制執行(忽略依賴未滿足)
620
-
621
- Role Map: .formation/role-map.yaml
622
-
623
- Examples:
624
- # 開發者執行任務
625
- node .formation/tools/headless-v2.js --base=do --role=developer --task=TASK_301
626
-
627
- # 策略分析
628
- node .formation/tools/headless-v2.js --base=think --role=strategy --task=TASK_302
629
-
630
- # 純執行(無角色注入)
631
- node .formation/tools/headless-v2.js --base=do --task=TASK_303
632
-
633
- # 用路徑指定角色
634
- node .formation/tools/headless-v2.js --base=do --role=solo/strategy --task=TASK_304
635
- `);
636
- process.exit(0);
637
- }
638
-
639
- const getArg = (flag) => {
640
- for (let i = 0; i < args.length; i++) {
641
- if (args[i].startsWith(`${flag}=`)) return args[i].split('=').slice(1).join('=');
642
- if (args[i] === flag && args[i + 1]) return args[i + 1];
643
- }
644
- return null;
645
- };
646
-
647
- const base = getArg('--base') || 'do';
648
- const role = getArg('--role') || null;
649
- const task = getArg('--task');
650
- const dryRun = args.includes('--dry-run');
651
- const projectPath = getArg('--project-path');
652
- const skipPostComplete = args.includes('--skip-post-complete');
653
- const skipTests = args.includes('--skip-tests');
654
- const force = args.includes('--force');
655
-
656
- launch({ base, role, task, dryRun, projectPath, skipPostComplete, skipTests, force });
657
- }
658
-
659
- module.exports = { launch, loadRoleMap, loadRole, VALID_BASES };