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,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 };
|