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,1071 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Formation Headless Launcher — Orchestrator (L5)
|
|
4
|
-
*
|
|
5
|
-
* 無頭模式啟動器:由 Formation Lead 配好任務後直接呼叫。
|
|
6
|
-
* subagent 不會問問題,直接執行。
|
|
7
|
-
*
|
|
8
|
-
* ┌─────────────────────────────────────────────────────────────────┐
|
|
9
|
-
* │ Module Dependency Graph (L3–L5 layer) │
|
|
10
|
-
* │ │
|
|
11
|
-
* │ headless.js (L5 orchestrator) │
|
|
12
|
-
* │ ├── formations.js — all launchXxx + setupUnitWorktree │
|
|
13
|
-
* │ ├── task-utils.js — task helpers + verifyTaskInProject │
|
|
14
|
-
* │ ├── broadcast.js — Supabase status broadcasting │
|
|
15
|
-
* │ ├── dispatch-registry.js— zombie pruning + parallel slots │
|
|
16
|
-
* │ ├── process-state.js — _activeChildren, _activeWorktrees │
|
|
17
|
-
* │ ├── prompt.js (L3) — prompt assembly + HEADLESS_PREFIX │
|
|
18
|
-
* │ ├── runner.js (L4) — runHeadless / killProcessTree │
|
|
19
|
-
* │ ├── loader.js — skill + task YAML loading │
|
|
20
|
-
* │ ├── utils.js — ROOT, generateSessionId, git ops │
|
|
21
|
-
* │ └── post-complete.js — (used by formations.js, not here) │
|
|
22
|
-
* └─────────────────────────────────────────────────────────────────┘
|
|
23
|
-
*
|
|
24
|
-
* 5 種陣型(implemented in formations.js, dispatched here):
|
|
25
|
-
* launchUnit — 單一 agent 直接執行
|
|
26
|
-
* launchSolo — 單一 agent 多視角分析
|
|
27
|
-
* launchSquad — 多 agent 並行 + 辯論 + 整合
|
|
28
|
-
* launchLegion — Agent Teams(TeamCreate / SendMessage)
|
|
29
|
-
* launchLegacy — 連續排程器(Rust SingleAgentScheduler 對等實作)
|
|
30
|
-
*
|
|
31
|
-
* Usage:
|
|
32
|
-
* node .formation/tools/headless.js --skill=do --task="修正按鈕顏色"
|
|
33
|
-
* node .formation/tools/headless.js --formation=solo --skill=strategy --task="評估快取方案"
|
|
34
|
-
* node .formation/tools/headless.js --formation=squad --skill=blitz --task=TASK_201
|
|
35
|
-
* node .formation/tools/headless.js --formation=legion --skill=forge --task=TASK_202
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
const fs = require('fs');
|
|
39
|
-
const path = require('path');
|
|
40
|
-
const { execSync } = require('child_process');
|
|
41
|
-
const { loadSkill, loadTask } = require('./loader');
|
|
42
|
-
const {
|
|
43
|
-
buildPrompt, buildSquadSynthesisPrompt, buildSquadDebatePrompts,
|
|
44
|
-
buildLegionLeadPrompt, HEADLESS_PREFIX,
|
|
45
|
-
} = require('./prompt');
|
|
46
|
-
const { runSingle, runHeadless, killProcessTree } = require('./runner');
|
|
47
|
-
const {
|
|
48
|
-
generateSessionId, ROOT, LOG_DIR, getLogDir, ensureGitReady, dispatchLockPath, dispatchLockStaleness,
|
|
49
|
-
unlinkNodeModulesBestEffort, loadDashboardModels,
|
|
50
|
-
} = require('./utils');
|
|
51
|
-
const { pruneZombieDispatches, cleanupStaleLocks, isProcessAlive, readActiveDispatches, writeActiveDispatches } = require('./dispatch-registry');
|
|
52
|
-
const { _activeChildren, _activeWorktrees } = require('./process-state');
|
|
53
|
-
const formations = require('./formations');
|
|
54
|
-
|
|
55
|
-
// ─────────────────────────────────────────
|
|
56
|
-
// NOTE: ensureTaskComplete was REMOVED.
|
|
57
|
-
// Task must NOT be marked done before git merge completes.
|
|
58
|
-
// post-complete.js handles task completion internally:
|
|
59
|
-
// - git merge success → task → done (line ~2247 in post-complete.js)
|
|
60
|
-
// - git merge failure → task stays in active
|
|
61
|
-
// This ensures the user branch always has the code before task is closed.
|
|
62
|
-
// ─────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
process.on('exit', () => {
|
|
65
|
-
for (const child of _activeChildren) {
|
|
66
|
-
try { killProcessTree(child); } catch {}
|
|
67
|
-
}
|
|
68
|
-
// Cleanup dangling worktrees (process.on('exit') must be sync — use execSync)
|
|
69
|
-
for (const [worktreePath, projectRoot] of _activeWorktrees) {
|
|
70
|
-
// TASK_1844/TASK_2030: MUST run before any `git worktree remove` -- Git for
|
|
71
|
-
// Windows recurses through a node_modules junction/symlink on removal and
|
|
72
|
-
// empties the link target (the main checkout's node_modules). Sync — this
|
|
73
|
-
// handler runs inside process.on('exit').
|
|
74
|
-
try { unlinkNodeModulesBestEffort(worktreePath); } catch {}
|
|
75
|
-
try {
|
|
76
|
-
execSync(`git worktree remove --force "${worktreePath}"`, {
|
|
77
|
-
cwd: projectRoot, encoding: 'utf8', timeout: 10000, windowsHide: true, stdio: 'pipe',
|
|
78
|
-
});
|
|
79
|
-
} catch {}
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const { broadcastStatus, broadcastStatusSync } = require('./broadcast');
|
|
84
|
-
const {
|
|
85
|
-
FIX_QUEUE_FILE, checkAndDispatchFix, checkTaskDependencies,
|
|
86
|
-
recoverStaleTasks, findNextTask, verifyTaskInProject,
|
|
87
|
-
} = require('./task-utils');
|
|
88
|
-
|
|
89
|
-
// TASK_2243: recovery extracted so tests can run it against throwaway repos.
|
|
90
|
-
// Kenaz dispatch branches are exactly task/task_<digits> (dispatch.js, formations.js,
|
|
91
|
-
// headless-v2.js, git-merge.js all build `task/${taskId.toLowerCase()}`). Any other
|
|
92
|
-
// task/* branch (a user's task/600-foo) is NOT ours and must never be moved.
|
|
93
|
-
const DISPATCH_BRANCH_RE = /^task\/task_(\d+)$/i;
|
|
94
|
-
|
|
95
|
-
// Conservative by design: plain `git checkout` only -- no -f, no stash. A dirty
|
|
96
|
-
// tracked tree, a live dispatch lock for the same card, or any git failure ABORTS
|
|
97
|
-
// the recovery and leaves the branch and the edits untouched.
|
|
98
|
-
function recoverStaleDispatchBranch(gitRoot, opts = {}) {
|
|
99
|
-
const es = (cmd) => String(execSync(cmd, { cwd: gitRoot, encoding: 'utf8', timeout: 10000, windowsHide: true, stdio: 'pipe' }));
|
|
100
|
-
const log = opts.log || console.log;
|
|
101
|
-
const currentBranch = opts.currentBranch || es('git rev-parse --abbrev-ref HEAD').trim();
|
|
102
|
-
const m = DISPATCH_BRANCH_RE.exec(currentBranch);
|
|
103
|
-
if (!m) {
|
|
104
|
-
log(` ℹ️ Pre-dispatch: ${currentBranch} is not a Kenaz dispatch branch, no recovery`);
|
|
105
|
-
return { recovered: false, reason: 'not-dispatch-branch' };
|
|
106
|
-
}
|
|
107
|
-
// Cheap live-dispatch signal: the project dispatch lock names the card it serves.
|
|
108
|
-
try {
|
|
109
|
-
const holder = JSON.parse(fs.readFileSync(opts.lockFile || dispatchLockPath(opts.projectPath || null), 'utf8'));
|
|
110
|
-
const { stale, pidDead } = dispatchLockStaleness(holder);
|
|
111
|
-
const holderId = String((holder && holder.taskId) || '').replace(/\D/g, '');
|
|
112
|
-
if (!stale && !pidDead && holderId === m[1]) {
|
|
113
|
-
log(` ⚠️ Pre-dispatch: ${currentBranch} is held by a live dispatch, recovery skipped`);
|
|
114
|
-
return { recovered: false, reason: 'live-dispatch' };
|
|
115
|
-
}
|
|
116
|
-
} catch { /* no/corrupt lock file -- no live-dispatch signal */ }
|
|
117
|
-
try {
|
|
118
|
-
if (es('git status --porcelain --untracked-files=no').trim()) {
|
|
119
|
-
log(` ⚠️ Pre-dispatch: ${currentBranch} has uncommitted tracked changes, recovery ABORTED (staying on branch, work untouched)`);
|
|
120
|
-
return { recovered: false, reason: 'dirty-tree' };
|
|
121
|
-
}
|
|
122
|
-
const targetBranch = opts.targetBranch || resolveDispatchTargetBranch(gitRoot);
|
|
123
|
-
log(` ⚠️ Pre-dispatch: stuck on ${currentBranch}, recovering to ${targetBranch}`);
|
|
124
|
-
es(`git checkout ${targetBranch}`);
|
|
125
|
-
log(` ✅ Pre-dispatch: recovered to ${targetBranch}`);
|
|
126
|
-
return { recovered: true, reason: 'recovered' };
|
|
127
|
-
} catch (e) {
|
|
128
|
-
log(` ⚠️ Pre-dispatch: recovery ABORTED (${String((e && e.message) || e).split('\n')[0]}); staying on ${currentBranch}, work untouched`);
|
|
129
|
-
return { recovered: false, reason: 'checkout-failed' };
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function resolveDispatchTargetBranch(gitRoot) {
|
|
134
|
-
try {
|
|
135
|
-
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
136
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'pipe',
|
|
137
|
-
}).trim();
|
|
138
|
-
if (currentBranch && !currentBranch.startsWith('task/')) return currentBranch;
|
|
139
|
-
} catch {}
|
|
140
|
-
|
|
141
|
-
// If HEAD is a task/* branch (e.g. dispatch invoked from inside a worktree),
|
|
142
|
-
// ask git for the main worktree's branch instead of guessing from user.name.
|
|
143
|
-
// `git worktree list --porcelain` first stanza is always the main worktree.
|
|
144
|
-
try {
|
|
145
|
-
const worktreeList = execSync('git worktree list --porcelain', {
|
|
146
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'pipe',
|
|
147
|
-
});
|
|
148
|
-
for (const line of worktreeList.split(/\r?\n/)) {
|
|
149
|
-
const m = line.match(/^branch refs\/heads\/(.+)$/);
|
|
150
|
-
if (m && !m[1].startsWith('task/')) return m[1];
|
|
151
|
-
}
|
|
152
|
-
} catch {}
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const dispatchFile = path.join(gitRoot, '.formation', '.dispatch-branch');
|
|
156
|
-
if (fs.existsSync(dispatchFile)) {
|
|
157
|
-
const savedBranch = fs.readFileSync(dispatchFile, 'utf8').trim();
|
|
158
|
-
if (savedBranch && !savedBranch.startsWith('task/')) return savedBranch;
|
|
159
|
-
}
|
|
160
|
-
} catch {}
|
|
161
|
-
|
|
162
|
-
return 'main';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function persistDispatchBranch(branchName, projectPath) {
|
|
166
|
-
if (!branchName) return;
|
|
167
|
-
try {
|
|
168
|
-
fs.writeFileSync(path.join(ROOT, '.formation', '.dispatch-branch'), branchName);
|
|
169
|
-
if (projectPath && path.resolve(projectPath) !== path.resolve(ROOT)) {
|
|
170
|
-
const projFormation = path.join(projectPath, '.formation');
|
|
171
|
-
if (!fs.existsSync(projFormation)) fs.mkdirSync(projFormation, { recursive: true });
|
|
172
|
-
fs.writeFileSync(path.join(projFormation, '.dispatch-branch'), branchName);
|
|
173
|
-
}
|
|
174
|
-
} catch {}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function resolveLaunchBifrostAdapter(explicitAdapter) {
|
|
178
|
-
const normalizedExplicit = explicitAdapter?.trim();
|
|
179
|
-
if (normalizedExplicit) return { adapter: normalizedExplicit, source: 'dispatch' };
|
|
180
|
-
|
|
181
|
-
const envAdapter = process.env.BIFROST_ADAPTER?.trim();
|
|
182
|
-
if (envAdapter) return { adapter: envAdapter, source: 'env' };
|
|
183
|
-
|
|
184
|
-
if (process.env.CODEX_THREAD_ID) {
|
|
185
|
-
return { adapter: 'openai', source: 'codex_thread' };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const signalPath = path.join(ROOT, '.formation', '.bifrost-active');
|
|
190
|
-
if (fs.existsSync(signalPath)) {
|
|
191
|
-
const signal = JSON.parse(fs.readFileSync(signalPath, 'utf8'));
|
|
192
|
-
// Self-heal: validate signal.pid is alive before honoring. A stale signal
|
|
193
|
-
// (process crashed without cleanup, force-kill, etc.) silently mis-routes
|
|
194
|
-
// every subsequent dispatch to the wrong adapter — observed 8-day silent
|
|
195
|
-
// failure on 2026-05-01 where pid=5700 from 2026-04-23 forced openai.
|
|
196
|
-
if (signal.pid != null && !isProcessAlive(signal.pid)) {
|
|
197
|
-
try { fs.unlinkSync(signalPath); } catch {}
|
|
198
|
-
// fall through to adapter:none — caller will use native Claude CLI
|
|
199
|
-
} else {
|
|
200
|
-
const signalAdapter = signal.adapter?.trim();
|
|
201
|
-
if (signalAdapter) return { adapter: signalAdapter, source: 'signal' };
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
} catch {}
|
|
205
|
-
|
|
206
|
-
return { adapter: null, source: 'none' };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function attachBifrostAdapterCleanup(result, restoreEnv) {
|
|
210
|
-
const targets = [];
|
|
211
|
-
if (result?.child?.on) {
|
|
212
|
-
targets.push(result.child);
|
|
213
|
-
} else if (Array.isArray(result)) {
|
|
214
|
-
for (const child of result) {
|
|
215
|
-
if (child?.on) targets.push(child);
|
|
216
|
-
}
|
|
217
|
-
} else if (result?.on) {
|
|
218
|
-
targets.push(result);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (targets.length === 0) {
|
|
222
|
-
restoreEnv();
|
|
223
|
-
return result;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let pending = targets.length;
|
|
227
|
-
let restored = false;
|
|
228
|
-
const finish = () => {
|
|
229
|
-
if (restored) return;
|
|
230
|
-
pending -= 1;
|
|
231
|
-
if (pending > 0) return;
|
|
232
|
-
restored = true;
|
|
233
|
-
restoreEnv();
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
for (const target of targets) {
|
|
237
|
-
target.once('close', finish);
|
|
238
|
-
target.once('formation:done', finish);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return result;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ─────────────────────────────────────────
|
|
245
|
-
// 主調度
|
|
246
|
-
// ─────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
function launch(options) {
|
|
249
|
-
const {
|
|
250
|
-
formation = 'unit',
|
|
251
|
-
skill = 'do',
|
|
252
|
-
task,
|
|
253
|
-
dryRun = false,
|
|
254
|
-
mode,
|
|
255
|
-
concurrency,
|
|
256
|
-
projectPath = null,
|
|
257
|
-
skipPostComplete = false,
|
|
258
|
-
skipTests = false,
|
|
259
|
-
inProcess = false,
|
|
260
|
-
trigger = null,
|
|
261
|
-
focus = null,
|
|
262
|
-
query = null,
|
|
263
|
-
originSessionId = null,
|
|
264
|
-
bifrostAdapter = null,
|
|
265
|
-
// Dashboard-driven model override (Quick Actions → Default Models →
|
|
266
|
-
// Dispatch agent). When set, takes precedence over per-skill
|
|
267
|
-
// model_override and over the parent session inheritance pattern.
|
|
268
|
-
// Read by kenaz_dispatch backend route from .kenaz/dashboard-models.json.
|
|
269
|
-
dispatchModel = null,
|
|
270
|
-
depth = 'kernel', // Inert: forwarded as promptOpts.depth, but prompt.js buildPrompt never reads it.
|
|
271
|
-
// Dispatch agents get a skill brief (skill + principles + task), never kernel.yaml or elixir.
|
|
272
|
-
// TASK_1929 (Agent Hub M2): see headless.js CLI section + runner.js
|
|
273
|
-
// spawnClaude / spawnBifrost for how these two flow into the actual
|
|
274
|
-
// child process. Both default to "no override" so every pre-existing
|
|
275
|
-
// caller of launch() (nothing sets these) behaves identically.
|
|
276
|
-
mcpConfigExtra = [],
|
|
277
|
-
allowedToolsOverride = null,
|
|
278
|
-
} = options;
|
|
279
|
-
|
|
280
|
-
const sessionId = process.env.KENAZ_SESSION_ID?.trim() || generateSessionId();
|
|
281
|
-
const previousAdapter = process.env.BIFROST_ADAPTER;
|
|
282
|
-
const adapterSelection = resolveLaunchBifrostAdapter(bifrostAdapter);
|
|
283
|
-
if (adapterSelection.adapter) {
|
|
284
|
-
process.env.BIFROST_ADAPTER = adapterSelection.adapter;
|
|
285
|
-
console.log(` Bifrost adapter: ${adapterSelection.adapter} (source=${adapterSelection.source})`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// 0.-1 Bifröst auto-detect: if launch.js wrote .bifrost-active signal, route through Codex relay.
|
|
289
|
-
// Signal file is created by launch.js on start and deleted on cleanup — reliable across all shells.
|
|
290
|
-
if (false && !process.env.BIFROST_ADAPTER) {
|
|
291
|
-
try {
|
|
292
|
-
const signalPath = path.join(ROOT, '.formation', '.bifrost-active');
|
|
293
|
-
if (fs.existsSync(signalPath)) {
|
|
294
|
-
const signal = JSON.parse(fs.readFileSync(signalPath, 'utf8'));
|
|
295
|
-
if (signal.adapter) {
|
|
296
|
-
process.env.BIFROST_ADAPTER = signal.adapter;
|
|
297
|
-
console.log(` 🌈 Bifröst: active session detected (${signal.adapter}) — routing agents through relay`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} catch { /* non-fatal — default to Claude */ }
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// 0. Cleanup: kill orphaned claude.exe processes + recover stale tasks
|
|
304
|
-
try {
|
|
305
|
-
const { cleanOrphanedClaude } = require('../../.specialist/tools/session');
|
|
306
|
-
const orphanResult = cleanOrphanedClaude();
|
|
307
|
-
if (orphanResult.killed > 0) {
|
|
308
|
-
console.log(`🧹 Cleaned ${orphanResult.killed} orphaned claude.exe process(es)`);
|
|
309
|
-
}
|
|
310
|
-
} catch { /* session.js not available — skip */ }
|
|
311
|
-
recoverStaleTasks(projectPath);
|
|
312
|
-
pruneZombieDispatches();
|
|
313
|
-
cleanupStaleLocks(projectPath || ROOT); // TASK_1391: clear stale .merge-lock + .dispatch-lock before spawn
|
|
314
|
-
|
|
315
|
-
// 0.0 Parallel dispatch — worktree isolation mode (with serial dispatch-lock fallback)
|
|
316
|
-
// IMPORTANT: Only real task executions (TASK_XXX) get worktrees.
|
|
317
|
-
// Coordinator sessions (Ansuz via loop.js) skip — they dispatch sub-tasks, not code changes.
|
|
318
|
-
const isRealTask = task && /^TASK_\d+$/i.test(task);
|
|
319
|
-
|
|
320
|
-
// 0.0.0 Auto-activate task: move backlog → active and git-commit BEFORE worktree creation
|
|
321
|
-
// Root cause: uncommitted filesystem changes are invisible to git worktrees (worktree forks from HEAD).
|
|
322
|
-
// Fix: commit the status change so the forked worktree sees the correct active state.
|
|
323
|
-
// Guard: only for real task dispatches (not coordinator sessions, not legacy formation).
|
|
324
|
-
if (isRealTask && formation !== 'legacy' && dryRun) {
|
|
325
|
-
// TASK_2283: dry-run must not move the task or commit.
|
|
326
|
-
console.log(` [dry-run] would auto-activate ${task} (task-api move active + git commit NOT performed)`);
|
|
327
|
-
} else if (isRealTask && formation !== 'legacy') {
|
|
328
|
-
try {
|
|
329
|
-
const activateProjectPath = projectPath
|
|
330
|
-
|| (fs.existsSync(path.join(ROOT, '.kenaz', 'tasks')) ? ROOT : null);
|
|
331
|
-
const activateArgs = [];
|
|
332
|
-
if (activateProjectPath) activateArgs.push('--project-path', activateProjectPath);
|
|
333
|
-
activateArgs.push('move', task, 'active');
|
|
334
|
-
execSync(
|
|
335
|
-
`node "${path.join(ROOT, '.collaboration', 'core', 'task-api.js')}" ${activateArgs.join(' ')}`,
|
|
336
|
-
{ encoding: 'utf8', timeout: 10000, cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
|
|
337
|
-
);
|
|
338
|
-
console.log(` 📋 Auto-activated: ${task} → active/`);
|
|
339
|
-
} catch (e) {
|
|
340
|
-
// Non-fatal: task may already be active, completed, or in an incompatible state
|
|
341
|
-
const msg = (e.stderr || e.message || '').toString().split('\n')[0];
|
|
342
|
-
console.log(` ⚠️ Auto-activate skipped: ${msg}`);
|
|
343
|
-
}
|
|
344
|
-
// Commit the status change to git so the worktree forks from the correct state
|
|
345
|
-
try {
|
|
346
|
-
const gitRoot = projectPath || ROOT;
|
|
347
|
-
execSync('git add .kenaz/tasks/ .collaboration/tasks/', {
|
|
348
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 10000, windowsHide: true, stdio: 'pipe',
|
|
349
|
-
});
|
|
350
|
-
execSync(`git commit -m "[Formation] Auto-activate: ${task} -> active"`, {
|
|
351
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 15000, windowsHide: true, stdio: 'pipe',
|
|
352
|
-
});
|
|
353
|
-
console.log(` 💾 Committed: ${task} status → active`);
|
|
354
|
-
} catch (commitErr) {
|
|
355
|
-
// Non-fatal: nothing to commit (already active/committed) or git not available
|
|
356
|
-
const msg = (commitErr.stderr || commitErr.message || '').toString().split('\n')[0];
|
|
357
|
-
console.log(` ⚠️ Status commit skipped: ${msg}`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Fix: always record dispatch branch BEFORE worktree creation.
|
|
362
|
-
// Root cause: the post-dispatch branch-write block (line ~282) skips worktree mode,
|
|
363
|
-
// so .dispatch-branch was only updated during non-worktree (read-only) dispatches.
|
|
364
|
-
// Since all real tasks use worktree mode, the file could lag by days.
|
|
365
|
-
if (dryRun) {
|
|
366
|
-
console.log(' [dry-run] would record dispatch branch (.dispatch-branch NOT written)');
|
|
367
|
-
} else try {
|
|
368
|
-
const gitRoot = projectPath || ROOT;
|
|
369
|
-
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
370
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'pipe',
|
|
371
|
-
}).trim();
|
|
372
|
-
persistDispatchBranch(currentBranch && !currentBranch.startsWith('task/')
|
|
373
|
-
? currentBranch
|
|
374
|
-
: resolveDispatchTargetBranch(gitRoot), projectPath);
|
|
375
|
-
} catch { /* non-fatal — post-complete has its own fallback */ }
|
|
376
|
-
|
|
377
|
-
// Special routing: huginn-think — Prefrontal Thinking Engine (no task, no skill YAML).
|
|
378
|
-
// TASK_2028: MUST stay above the worktree-setup block below: --task can make isRealTask true,
|
|
379
|
-
// and this route returns without ever using (or cleaning) a worktree.
|
|
380
|
-
if (skill === 'huginn-think') {
|
|
381
|
-
const huginnModel = resolveAgentModel('huginnModel');
|
|
382
|
-
return formations.launchHuginnThink({ trigger, focus, query, sessionId, projectPath, dryRun, inProcess, depth, model: huginnModel });
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// TASK_2029: skills that are READ-ONLY (never touch git, produce no commits) must never get
|
|
386
|
-
// a worktree. huginn-think already avoids it via its early return above.
|
|
387
|
-
// heimdall: code_watch.rs spawns it in the user's live repo on EVERY commit
|
|
388
|
-
// (post-commit hook -> /api/code_watch/trigger). Its skill only produces
|
|
389
|
-
// proposals, never git writes; without this entry the pre-dispatch branch
|
|
390
|
-
// recovery ran 8 s after each commit and switched a user's task/* branch to
|
|
391
|
-
// main (field report 2026-10-03, gamem, wish-301c720c; see TASK_2243).
|
|
392
|
-
const READ_ONLY_SKILLS = ['huginn-think', 'heimdall'];
|
|
393
|
-
|
|
394
|
-
// Worktree state — passed through to launchUnit for cleanup in onDone
|
|
395
|
-
let _unitWorktreePath = null; // non-null = worktree mode active
|
|
396
|
-
let _unitCountFile = null;
|
|
397
|
-
let _unitCountLock = null;
|
|
398
|
-
|
|
399
|
-
if (formation !== 'legacy' && isRealTask && !READ_ONLY_SKILLS.includes(skill) && dryRun) {
|
|
400
|
-
// TASK_2283: dry-run only reports. setupUnitWorktree writes a dirty snapshot, takes the
|
|
401
|
-
// parallel-count lock, creates the worktree + task branch and may recover a stale branch.
|
|
402
|
-
// _unitWorktreePath stays null; the dry-run exit below never reaches launch/cleanup.
|
|
403
|
-
const wtPath = path.join(projectPath || ROOT, '.formation', 'worktrees', `task_${task.toLowerCase()}`);
|
|
404
|
-
console.log(` [dry-run] would create unit worktree: ${wtPath} (parallel-count lock NOT taken, no branch recovery)`);
|
|
405
|
-
} else if (formation !== 'legacy' && isRealTask && !READ_ONLY_SKILLS.includes(skill)) {
|
|
406
|
-
const result = formations.setupUnitWorktree({
|
|
407
|
-
projectRoot: projectPath || ROOT,
|
|
408
|
-
projectPath,
|
|
409
|
-
taskId: task,
|
|
410
|
-
sessionId,
|
|
411
|
-
});
|
|
412
|
-
_unitWorktreePath = result.worktreePath;
|
|
413
|
-
_unitCountFile = result.countFile;
|
|
414
|
-
_unitCountLock = result.countLock;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// 0.1 自動尋找任務(當沒有指定 --task 且非 legacy 時)
|
|
418
|
-
// 使用 projectPath 查找當前專案的任務
|
|
419
|
-
let resolvedTask = task;
|
|
420
|
-
if (!resolvedTask && formation !== 'legacy') {
|
|
421
|
-
const nextTask = findNextTask(projectPath);
|
|
422
|
-
if (!nextTask) {
|
|
423
|
-
console.log('✅ No pending tasks found in backlog. Nothing to do.');
|
|
424
|
-
if (projectPath) console.log(` (searched in: ${projectPath})`);
|
|
425
|
-
if (inProcess) return null;
|
|
426
|
-
process.exit(0);
|
|
427
|
-
}
|
|
428
|
-
resolvedTask = nextTask.id;
|
|
429
|
-
console.log(`🔍 Auto-selected: [${nextTask.id}] ${nextTask.title} (${['P0','P1','P2','P3'][nextTask.priority] || 'P?'})\n`);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// 1. 載入(支援 projectPath)
|
|
433
|
-
const skillDef = loadSkill(formation, skill);
|
|
434
|
-
const loadOpts = projectPath ? { projectPath } : undefined;
|
|
435
|
-
const taskData = resolvedTask && resolvedTask.startsWith('TASK_') ? loadTask(resolvedTask, loadOpts) : null;
|
|
436
|
-
const taskInput = resolvedTask || '';
|
|
437
|
-
|
|
438
|
-
// 1.0.0 護欄:明確指定 TASK_ID 但任務不存在 → 早期終止,不啟動 agent
|
|
439
|
-
if (resolvedTask?.startsWith('TASK_') && !taskData) {
|
|
440
|
-
const searchPaths = projectPath
|
|
441
|
-
? [path.join(projectPath, '.kenaz/tasks'), path.join(projectPath, '.collaboration/tasks')]
|
|
442
|
-
: ['.kenaz/tasks', '.collaboration/tasks'];
|
|
443
|
-
console.error(`❌ Task ${resolvedTask} not found — task does not exist.`);
|
|
444
|
-
console.error(` Searched: ${searchPaths.join(', ')}`);
|
|
445
|
-
console.error(` Aborting: will not start agent for a non-existent task.`);
|
|
446
|
-
if (!inProcess) process.exitCode = 1;
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// 1.0.1 跨專案安全護欄:驗證 task YAML 確實屬於指定專案
|
|
451
|
-
// 防止 task 被誤 dispatch 到錯誤專案(2026-03-17 incident: TASK_549 → wrong repo)
|
|
452
|
-
if (!verifyTaskInProject(taskData, resolvedTask, projectPath)) {
|
|
453
|
-
if (!inProcess) process.exitCode = 1;
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// 1.1 依賴預檢:確認所有依賴都已完成(legacy scheduler 有自己的依賴邏輯,跳過)
|
|
458
|
-
if (taskData && formation !== 'legacy') {
|
|
459
|
-
const depCheck = checkTaskDependencies(taskData, projectPath);
|
|
460
|
-
if (depCheck.blocked) {
|
|
461
|
-
console.error(`❌ Dispatch blocked: ${taskInput} has unmet dependencies`);
|
|
462
|
-
for (const dep of depCheck.unmet) {
|
|
463
|
-
if (depCheck.blockedDeps && depCheck.blockedDeps.includes(dep)) {
|
|
464
|
-
console.error(` ⛔ ${dep} — blocked`);
|
|
465
|
-
} else {
|
|
466
|
-
console.error(` ⛔ ${dep} — not in done/completed`);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
console.error(` → Complete the dependencies first, or use --force to override`);
|
|
470
|
-
if (!options.force) {
|
|
471
|
-
if (!inProcess) process.exitCode = 1;
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
console.warn(` ⚠️ --force: dispatching despite unmet dependencies`);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const promptOpts = { analysisMode: mode };
|
|
479
|
-
if (projectPath) promptOpts.projectPath = projectPath;
|
|
480
|
-
promptOpts.depth = depth || 'kernel';
|
|
481
|
-
// TASK_1819: thread worktree-mode awareness into prompt assembly. Serial
|
|
482
|
-
// (non-worktree) dispatches share the working tree with the user/Ansuz, so
|
|
483
|
-
// appendWorkflow must not emit a blanket `git add -A` commit step for them
|
|
484
|
-
// (TASK_188: swept 392 unrelated files into one commit). Worktree setup runs
|
|
485
|
-
// BEFORE this point, so _unitWorktreePath already reflects reality — including
|
|
486
|
-
// the fallback-to-serial case when worktree creation fails.
|
|
487
|
-
promptOpts.isWorktree = !!_unitWorktreePath;
|
|
488
|
-
const rawPrompt = buildPrompt(skillDef, taskInput, taskData, promptOpts);
|
|
489
|
-
|
|
490
|
-
// 1.5. Parse taskData for canary scope binding + analytics role
|
|
491
|
-
let taskScopePaths = [];
|
|
492
|
-
let taskRole = null;
|
|
493
|
-
let taskRigor = null;
|
|
494
|
-
if (taskData) {
|
|
495
|
-
try {
|
|
496
|
-
const parsed = JSON.parse(taskData);
|
|
497
|
-
taskScopePaths = parsed.scope_paths || [];
|
|
498
|
-
taskRole = parsed.role || null;
|
|
499
|
-
taskRigor = parsed.rigor || null;
|
|
500
|
-
} catch { /* raw YAML or non-JSON — no structured scope */ }
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// 2. Banner
|
|
504
|
-
console.log(`⚔️ Formation Headless Launch`);
|
|
505
|
-
console.log(` Formation: ${formation}`);
|
|
506
|
-
console.log(` Skill: ${skill}`);
|
|
507
|
-
console.log(` Task: ${taskInput.slice(0, 60)}${taskInput.length > 60 ? '...' : ''}`);
|
|
508
|
-
console.log(` Session: ${sessionId}`);
|
|
509
|
-
if (projectPath) console.log(` Project: ${projectPath}`);
|
|
510
|
-
console.log('');
|
|
511
|
-
|
|
512
|
-
// 3. Dry run
|
|
513
|
-
if (dryRun) {
|
|
514
|
-
return printDryRun(formation, rawPrompt, skillDef, skill);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// 4. 確保子專案 git 已就緒
|
|
518
|
-
ensureGitReady(projectPath);
|
|
519
|
-
|
|
520
|
-
// 4.1. Pre-dispatch branch recovery: if stuck on a task branch from a previous
|
|
521
|
-
// failed dispatch, switch back before doing anything else.
|
|
522
|
-
// This is the FIRST LINE OF DEFENSE against "stuck on task branch" incidents.
|
|
523
|
-
// Skip for read-only skills (huginn-think, heimdall) — they don't need git and
|
|
524
|
-
// their branch recovery can race with parallel post-complete git ops.
|
|
525
|
-
// Skip for worktree mode — worktree starts clean on its own task branch, no recovery needed.
|
|
526
|
-
// (READ_ONLY_SKILLS declared once, above the worktree-setup gate — TASK_2029.)
|
|
527
|
-
if (READ_ONLY_SKILLS.includes(skill) || _unitWorktreePath) {
|
|
528
|
-
// Skip git recovery — read-only skills don't touch git; worktree mode starts clean
|
|
529
|
-
} else try {
|
|
530
|
-
const { execSync: es } = require('child_process');
|
|
531
|
-
const gitRoot = projectPath || ROOT;
|
|
532
|
-
const currentBranch = es('git rev-parse --abbrev-ref HEAD', {
|
|
533
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
534
|
-
}).trim();
|
|
535
|
-
|
|
536
|
-
if (currentBranch.startsWith('task/')) {
|
|
537
|
-
recoverStaleDispatchBranch(gitRoot, { currentBranch, projectPath });
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Save current branch for post-complete recovery (re-read after potential recovery)
|
|
541
|
-
const branchAfter = es('git rev-parse --abbrev-ref HEAD', {
|
|
542
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
543
|
-
}).trim();
|
|
544
|
-
persistDispatchBranch(
|
|
545
|
-
branchAfter.startsWith('task/') ? resolveDispatchTargetBranch(gitRoot) : branchAfter,
|
|
546
|
-
projectPath,
|
|
547
|
-
);
|
|
548
|
-
} catch { /* ignore — post-complete will fallback to getCurrentBranch() */ }
|
|
549
|
-
|
|
550
|
-
// 4.2. Auto-claim task: move from backlog → active before agent starts
|
|
551
|
-
// Agents may forget or delay claiming, causing dashboard to show task stuck in backlog
|
|
552
|
-
// Auto-detect .kenaz/tasks/ presence: if cwd has .kenaz/tasks/, use cwd as --project-path
|
|
553
|
-
// even when projectPath is not explicitly passed (core project with .kenaz/ layout)
|
|
554
|
-
if (resolvedTask && resolvedTask.startsWith('TASK_') && formation !== 'legacy') {
|
|
555
|
-
try {
|
|
556
|
-
const apiArgs = [];
|
|
557
|
-
const effectiveProjectPath = projectPath
|
|
558
|
-
|| (fs.existsSync(path.join(ROOT, '.kenaz', 'tasks')) ? ROOT : null);
|
|
559
|
-
if (effectiveProjectPath) apiArgs.push('--project-path', effectiveProjectPath);
|
|
560
|
-
|
|
561
|
-
// TASK_1787 (DEFECT-F): read-only self-claim guard.
|
|
562
|
-
// The dispatching Ansuz stream typically pre-claims the task before spawning us.
|
|
563
|
-
// The claim subprocess below is a different pid, so task-api's re-entrancy check
|
|
564
|
-
// (owner_session AND owner_pid) can never match cross-process → it emits a spurious
|
|
565
|
-
// "already claimed" warning indistinguishable from a real foreign-stream collision.
|
|
566
|
-
// Detect our own marker here (anchor: owner_conversation_id; fallback: owner_session
|
|
567
|
-
// when conv id is null; never owner_pid) and skip the claim subprocess entirely.
|
|
568
|
-
// Foreign, absent, or corrupt markers fall through to the existing claim path untouched.
|
|
569
|
-
let selfClaimed = false;
|
|
570
|
-
if (effectiveProjectPath) {
|
|
571
|
-
try {
|
|
572
|
-
const marker = JSON.parse(fs.readFileSync(
|
|
573
|
-
path.join(effectiveProjectPath, '.kenaz', 'locks', `claim_${resolvedTask}.json`), 'utf8'));
|
|
574
|
-
selfClaimed = require('./dispatch')._isOwnClaimMarker(marker, effectiveProjectPath);
|
|
575
|
-
} catch { /* no marker / no stream identity (worker context) / corrupt → normal claim path */ }
|
|
576
|
-
}
|
|
577
|
-
if (selfClaimed) {
|
|
578
|
-
console.log(` 📋 Auto-claim: ${resolvedTask} already claimed by this stream — skipping`);
|
|
579
|
-
} else {
|
|
580
|
-
apiArgs.push('claim', resolvedTask, '--by=system', `--git-user=${resolvedTask}`);
|
|
581
|
-
execSync(
|
|
582
|
-
`node "${path.join(ROOT, '.collaboration', 'core', 'task-api.js')}" ${apiArgs.join(' ')}`,
|
|
583
|
-
{ encoding: 'utf8', timeout: 10000, cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
|
|
584
|
-
);
|
|
585
|
-
console.log(` 📋 Auto-claimed: ${resolvedTask} → active/`);
|
|
586
|
-
}
|
|
587
|
-
} catch (e) {
|
|
588
|
-
// Claim may fail if already active — non-fatal
|
|
589
|
-
console.log(` ⚠️ Auto-claim skipped: ${(e.stderr || e.message || '').split('\n')[0]}`);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// 4.5 Yggdrasil: 廣播上線狀態(非 legacy,legacy 自己廣播)
|
|
594
|
-
if (formation !== 'legacy') {
|
|
595
|
-
broadcastStatus(projectPath, {
|
|
596
|
-
is_online: true,
|
|
597
|
-
current_task: resolvedTask || null,
|
|
598
|
-
touching_files: taskScopePaths || [],
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
// Signal handlers: await cleanup 確保 Supabase 更新完成
|
|
602
|
-
// Note: process.on('exit') 不支援 async,由 child.on('close') 處理 cleanup
|
|
603
|
-
// inProcess 模式跳過 — 不能在 MCP server 上註冊 signal handlers
|
|
604
|
-
if (!inProcess) {
|
|
605
|
-
const handleSignal = (code) => {
|
|
606
|
-
broadcastStatusSync(projectPath, { is_online: false, current_task: null, touching_files: [] });
|
|
607
|
-
process.exit(code);
|
|
608
|
-
};
|
|
609
|
-
process.on('SIGINT', () => handleSignal(130));
|
|
610
|
-
process.on('SIGTERM', () => handleSignal(143));
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// 4.6 TASK_904: Agent Attention SNN — pre-dispatch conflict detection.
|
|
615
|
-
// Query the Thalamus HTTP endpoint for agent conflict warnings on scope files.
|
|
616
|
-
// If conflicts exist, log a warning. In --strict-conflict mode, block dispatch.
|
|
617
|
-
if (taskScopePaths.length > 0 && formation !== 'legacy') {
|
|
618
|
-
try {
|
|
619
|
-
const http = require('http');
|
|
620
|
-
const conflictCheck = new Promise((resolve) => {
|
|
621
|
-
const postData = JSON.stringify({ file_paths: taskScopePaths });
|
|
622
|
-
const req = http.request({
|
|
623
|
-
hostname: '127.0.0.1',
|
|
624
|
-
port: 54321,
|
|
625
|
-
path: '/api/rust/agent-flow/agent-conflicts',
|
|
626
|
-
method: 'POST',
|
|
627
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
|
|
628
|
-
timeout: 3000,
|
|
629
|
-
}, (res) => {
|
|
630
|
-
let data = '';
|
|
631
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
632
|
-
res.on('end', () => {
|
|
633
|
-
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
634
|
-
});
|
|
635
|
-
});
|
|
636
|
-
req.on('error', () => resolve(null));
|
|
637
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
638
|
-
req.write(postData);
|
|
639
|
-
req.end();
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
// Synchronous wait with timeout (non-blocking for 3s max)
|
|
643
|
-
const conflictResult = (() => {
|
|
644
|
-
try {
|
|
645
|
-
// Use execSync to make a synchronous HTTP call (simpler for pre-dispatch check)
|
|
646
|
-
const curlCmd = process.platform === 'win32'
|
|
647
|
-
? `powershell -Command "try { $r = Invoke-RestMethod -Uri 'http://127.0.0.1:54321/api/rust/agent-flow/agent-conflicts' -Method POST -ContentType 'application/json' -Body '${JSON.stringify({ file_paths: taskScopePaths }).replace(/'/g, "''")}' -TimeoutSec 3; $r | ConvertTo-Json -Compress } catch { '{}' }"`
|
|
648
|
-
: `curl -s --max-time 3 -X POST -H 'Content-Type: application/json' -d '${JSON.stringify({ file_paths: taskScopePaths })}' http://127.0.0.1:54321/api/rust/agent-flow/agent-conflicts`;
|
|
649
|
-
const result = execSync(curlCmd, { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
650
|
-
return result ? JSON.parse(result) : null;
|
|
651
|
-
} catch { return null; }
|
|
652
|
-
})();
|
|
653
|
-
|
|
654
|
-
if (conflictResult && Array.isArray(conflictResult.conflicts) && conflictResult.conflicts.length > 0) {
|
|
655
|
-
console.log(` ⚠️ Agent Conflict Warning (TASK_904):`);
|
|
656
|
-
for (const c of conflictResult.conflicts) {
|
|
657
|
-
console.log(` 🔥 ${c.file_path} — ${c.fire_count} conflict fire(s)`);
|
|
658
|
-
}
|
|
659
|
-
if (options.strictConflict) {
|
|
660
|
-
console.error(` ❌ Dispatch blocked: --strict-conflict is set and ${conflictResult.conflicts.length} file(s) have active agent conflicts`);
|
|
661
|
-
if (!inProcess) process.exitCode = 1;
|
|
662
|
-
return null;
|
|
663
|
-
}
|
|
664
|
-
console.log(` → Proceeding with warning (use --strict-conflict to block)`);
|
|
665
|
-
}
|
|
666
|
-
} catch { /* conflict check failed — non-fatal, proceed with dispatch */ }
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// 4.7 TASK_944: Pre-dispatch health snapshot — take a fresh before-snapshot
|
|
670
|
-
// at dispatch time so Heimdall compares accurate baselines (not stale ones).
|
|
671
|
-
if (resolvedTask && resolvedTask.startsWith('TASK_') && formation !== 'legacy') {
|
|
672
|
-
try {
|
|
673
|
-
const { measureHealthScoreSync, saveTaskHealthSnapshot } = require('./post-complete');
|
|
674
|
-
const health = measureHealthScoreSync(projectPath);
|
|
675
|
-
if (health) {
|
|
676
|
-
saveTaskHealthSnapshot(resolvedTask, 'before', health, projectPath);
|
|
677
|
-
console.log(` 🛡️ Pre-dispatch health snapshot: ${health.score}/100 (${health.grade}) — saved as ${resolvedTask}_before.json`);
|
|
678
|
-
} else {
|
|
679
|
-
console.log(` 🛡️ Pre-dispatch health snapshot: Dashboard not running — skipped`);
|
|
680
|
-
}
|
|
681
|
-
} catch { /* non-fatal — post-complete will handle missing snapshot */ }
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// 5. 分發到 5 種陣型(各自獨立)
|
|
685
|
-
const launchers = {
|
|
686
|
-
unit: formations.launchUnit,
|
|
687
|
-
solo: formations.launchSolo,
|
|
688
|
-
squad: formations.launchSquad,
|
|
689
|
-
legion: formations.launchLegion,
|
|
690
|
-
legacy: formations.launchLegacy,
|
|
691
|
-
};
|
|
692
|
-
|
|
693
|
-
const launcher = launchers[formation];
|
|
694
|
-
if (!launcher) {
|
|
695
|
-
console.error(`❌ Unknown formation: ${formation}`);
|
|
696
|
-
process.exit(1);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const restoreAdapterEnv = () => {
|
|
700
|
-
if (previousAdapter === undefined) {
|
|
701
|
-
delete process.env.BIFROST_ADAPTER;
|
|
702
|
-
} else {
|
|
703
|
-
process.env.BIFROST_ADAPTER = previousAdapter;
|
|
704
|
-
}
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
// Per-agent model override: if the task role or skill matches a known agent
|
|
708
|
-
// with a dashboard model override, use that instead of the global dispatchModel.
|
|
709
|
-
let effectiveDispatchModel = dispatchModel;
|
|
710
|
-
const agentModelKey =
|
|
711
|
-
(taskRole === 'heimdall' || skill === 'heimdall') ? 'heimdallModel' :
|
|
712
|
-
(taskRole === 'forseti' || skill === 'forseti') ? 'forsetiModel' :
|
|
713
|
-
(taskRole === 'huginn' || skill === 'huginn-think') ? 'huginnModel' :
|
|
714
|
-
null;
|
|
715
|
-
if (agentModelKey) {
|
|
716
|
-
const agentModel = resolveAgentModel(agentModelKey);
|
|
717
|
-
if (agentModel) effectiveDispatchModel = agentModel;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Early dispatch registration: write entry BEFORE calling launcher() to eliminate the
|
|
721
|
-
// race window where a status check arrives between headless.js start and launchUnit()
|
|
722
|
-
// calling registerDispatch(). The entry is written with pid=null; formations.js will
|
|
723
|
-
// call registerDispatch() again with the real PID. The post-launch dedup block below
|
|
724
|
-
// merges these two entries into one authoritative record with the real PID so that
|
|
725
|
-
// zombie pruning can correctly detect process exit.
|
|
726
|
-
if (formation !== 'legacy' && skill !== 'huginn-think') {
|
|
727
|
-
try {
|
|
728
|
-
const logRoot = projectPath ? getLogDir(projectPath) : LOG_DIR;
|
|
729
|
-
const expectedLogDir = path.join(logRoot, formation || 'misc');
|
|
730
|
-
const expectedLogPath = path.join(expectedLogDir, `${skill}_${sessionId}.log`);
|
|
731
|
-
const list = readActiveDispatches();
|
|
732
|
-
if (!list.find(d => d.sessionId === sessionId)) {
|
|
733
|
-
list.push({
|
|
734
|
-
sessionId,
|
|
735
|
-
logPath: expectedLogPath,
|
|
736
|
-
logPathStatus: 'provisional',
|
|
737
|
-
taskId: resolvedTask || null,
|
|
738
|
-
formation,
|
|
739
|
-
startedAt: new Date().toISOString(),
|
|
740
|
-
projectPath: projectPath || null,
|
|
741
|
-
originSessionId: originSessionId || null,
|
|
742
|
-
originTransport: originSessionId ? 'mcp' : null,
|
|
743
|
-
originNotificationMethod: originSessionId ? 'notifications/kenaz/dispatch_complete' : null,
|
|
744
|
-
pid: null,
|
|
745
|
-
});
|
|
746
|
-
writeActiveDispatches(list);
|
|
747
|
-
console.log(` 📡 Pre-registered dispatch: ${sessionId} (task=${resolvedTask || formation})`);
|
|
748
|
-
}
|
|
749
|
-
} catch { /* non-fatal — formations.launchUnit will register on its own */ }
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const launchResult = launcher({ rawPrompt, skill, sessionId, skillDef, mode, concurrency, projectPath, taskId: resolvedTask, taskScopePaths, role: taskRole, rigor: taskRigor, skipPostComplete, skipTests, inProcess, worktreePath: _unitWorktreePath, countFile: _unitCountFile, countLock: _unitCountLock, originSessionId, dispatchModel: effectiveDispatchModel, mcpConfigExtra, allowedToolsOverride });
|
|
753
|
-
attachBifrostAdapterCleanup(launchResult, restoreAdapterEnv);
|
|
754
|
-
|
|
755
|
-
// Post-launch dedup: formations.js launchUnit() calls registerDispatch() with the real PID,
|
|
756
|
-
// but the early registration above already pushed an entry with pid=null. This creates
|
|
757
|
-
// two entries for the same sessionId. The first (pid=null) wins dispatch-status .find(),
|
|
758
|
-
// making zombie pruning unable to detect process exit — isRunning stays true for 60 minutes.
|
|
759
|
-
// Fix: merge all entries for this sessionId into one authoritative entry with the real PID.
|
|
760
|
-
if (launchResult && formation !== 'legacy' && skill !== 'huginn-think') {
|
|
761
|
-
try {
|
|
762
|
-
const realPid = launchResult?.pid || launchResult?.child?.pid || null;
|
|
763
|
-
const realLogPath = launchResult.logPath || null;
|
|
764
|
-
if (realPid) {
|
|
765
|
-
const allEntries = readActiveDispatches();
|
|
766
|
-
const mine = allEntries.filter(d => d.sessionId === sessionId);
|
|
767
|
-
if (mine.length > 1 || (mine.length === 1 && mine[0].pid === null)) {
|
|
768
|
-
const others = allEntries.filter(d => d.sessionId !== sessionId);
|
|
769
|
-
// Prefer the entry that already has a real pid; fall back to last entry.
|
|
770
|
-
const base = mine.find(d => d.pid !== null) || mine[mine.length - 1];
|
|
771
|
-
base.pid = realPid;
|
|
772
|
-
if (realLogPath) { base.logPath = realLogPath; delete base.logPathStatus; }
|
|
773
|
-
writeActiveDispatches([...others, base]);
|
|
774
|
-
if (mine.length > 1) {
|
|
775
|
-
console.log(` 🔄 Deduped ${mine.length} dispatch entries → 1 (pid=${realPid})`);
|
|
776
|
-
} else {
|
|
777
|
-
console.log(` 🔄 Updated pre-registered dispatch with real PID (pid=${realPid})`);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
} catch { /* non-fatal — does not interrupt dispatch */ }
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
if (launchResult && typeof launchResult === 'object' && !Array.isArray(launchResult)) {
|
|
785
|
-
launchResult.adapter = adapterSelection.adapter;
|
|
786
|
-
// Mark log path as provisional: the .log file is created when the agent
|
|
787
|
-
// first writes output, not at launch time. Callers should not assume the
|
|
788
|
-
// file exists immediately after launch() returns.
|
|
789
|
-
if (launchResult.logPath && !('logPathStatus' in launchResult)) {
|
|
790
|
-
launchResult.logPathStatus = 'provisional';
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
return launchResult;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function printDryRun(formation, rawPrompt, skillDef, skill) {
|
|
798
|
-
console.log(`--- DRY RUN ---\n`);
|
|
799
|
-
|
|
800
|
-
if (formation === 'legion') {
|
|
801
|
-
const legionPrompt = buildLegionLeadPrompt(rawPrompt);
|
|
802
|
-
console.log(`[Legion Lead Prompt]\n${legionPrompt}\n`);
|
|
803
|
-
} else if (formation === 'squad' && Array.isArray(rawPrompt)) {
|
|
804
|
-
const reportDir = `squad-${skill}`;
|
|
805
|
-
for (const m of rawPrompt) {
|
|
806
|
-
const prompt = m.prompt.replace(/\{session\}/g, reportDir);
|
|
807
|
-
console.log(`[Round 1 - Member: ${m.memberId}]\n${HEADLESS_PREFIX}${prompt}\n`);
|
|
808
|
-
}
|
|
809
|
-
if (skillDef.debate?.enabled) {
|
|
810
|
-
console.log(`--- Round 2: Debate ---\n`);
|
|
811
|
-
const debatePrompts = buildSquadDebatePrompts(skillDef, reportDir);
|
|
812
|
-
for (const m of debatePrompts) {
|
|
813
|
-
console.log(`[Round 2 - Member: ${m.memberId}]\n${HEADLESS_PREFIX}${m.prompt}\n`);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
if (skillDef.merge) {
|
|
817
|
-
const synthPrompt = buildSquadSynthesisPrompt(skillDef, reportDir);
|
|
818
|
-
console.log(`[Synthesis Agent]\n${HEADLESS_PREFIX}${synthPrompt}\n`);
|
|
819
|
-
}
|
|
820
|
-
} else {
|
|
821
|
-
console.log(`${HEADLESS_PREFIX}${rawPrompt}`);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
console.log(`--- END ---`);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// ─────────────────────────────────────────
|
|
828
|
-
// CLI
|
|
829
|
-
// ─────────────────────────────────────────
|
|
830
|
-
|
|
831
|
-
if (require.main === module) {
|
|
832
|
-
const args = process.argv.slice(2);
|
|
833
|
-
|
|
834
|
-
if (args.includes('--help') || args.includes('-h')) {
|
|
835
|
-
console.log(`
|
|
836
|
-
⚔️ Formation Headless Launcher
|
|
837
|
-
|
|
838
|
-
無頭模式:Formation Lead 配好任務後直接呼叫,subagent 不問問題。
|
|
839
|
-
|
|
840
|
-
Usage:
|
|
841
|
-
node .formation/tools/headless.js --formation=<type> --skill=<name> --task=<value>
|
|
842
|
-
|
|
843
|
-
Options:
|
|
844
|
-
--formation <name> 陣型: unit (default), solo, squad, legion, legacy
|
|
845
|
-
--skill <name> 技能 (YAML filename without extension)
|
|
846
|
-
--task <value> TASK_ID 或任務描述
|
|
847
|
-
--concurrency <n> 並行執行數(預設 3,上限 5,僅 legacy)
|
|
848
|
-
--project-path <p> 子專案路徑(任務從此專案查找)
|
|
849
|
-
--mode <name> 分析模式: optimization, innovation (squad only)
|
|
850
|
-
--dry-run 只印 prompt,不啟動進程
|
|
851
|
-
--skip-post-complete 跳過 post-complete(手動管理 archive/git/verify)
|
|
852
|
-
--skip-tests 跳過回歸測試 + acceptance 驗證(傳遞給 post-complete)
|
|
853
|
-
--depth <mode> Specialist prompt depth: capsule (≤12KB) | kernel (default) | standard (full)
|
|
854
|
-
--bifrost-adapter <a> Bifröst adapter override (e.g. openai) — route agent through relay
|
|
855
|
-
|
|
856
|
-
Formation-Skill:
|
|
857
|
-
unit: do, think, plan, heimdall, huginn-think
|
|
858
|
-
solo: strategy, diagnosis
|
|
859
|
-
squad: blitz, sweep
|
|
860
|
-
legion: forge, hive
|
|
861
|
-
legacy: scheduler (連續排程器)
|
|
862
|
-
|
|
863
|
-
Huginn Think (special skill — no task required):
|
|
864
|
-
--trigger <type> manual (default)
|
|
865
|
-
--focus <dim> architecture | agent | knowledge (optional)
|
|
866
|
-
--query <topic> directed thinking topic (optional)
|
|
867
|
-
|
|
868
|
-
Logs: .formation/logs/{formation}/
|
|
869
|
-
|
|
870
|
-
Examples:
|
|
871
|
-
node .formation/tools/headless.js --skill=do --task="修正按鈕顏色"
|
|
872
|
-
node .formation/tools/headless.js --formation=solo --skill=strategy --task="評估快取方案"
|
|
873
|
-
node .formation/tools/headless.js --formation=squad --skill=blitz --task=TASK_201
|
|
874
|
-
node .formation/tools/headless.js --formation=legion --skill=forge --task=TASK_202 --dry-run
|
|
875
|
-
`);
|
|
876
|
-
process.exit(0);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
const getArg = (flag) => {
|
|
880
|
-
for (let i = 0; i < args.length; i++) {
|
|
881
|
-
if (args[i].startsWith(`${flag}=`)) return args[i].split('=').slice(1).join('=');
|
|
882
|
-
if (args[i] === flag && args[i + 1]) return args[i + 1];
|
|
883
|
-
}
|
|
884
|
-
return null;
|
|
885
|
-
};
|
|
886
|
-
|
|
887
|
-
// TASK_1929 (Agent Hub M2): repeatable-flag variant of getArg — collects
|
|
888
|
-
// EVERY occurrence, not just the first. Used by --mcp-config-extra, which
|
|
889
|
-
// agent_hub/launch.rs's tokio::process::Command may pass more than once
|
|
890
|
-
// in a future multi-endpoint launch (today it passes at most one).
|
|
891
|
-
const getArgAll = (flag) => {
|
|
892
|
-
const out = [];
|
|
893
|
-
for (let i = 0; i < args.length; i++) {
|
|
894
|
-
if (args[i].startsWith(`${flag}=`)) out.push(args[i].split('=').slice(1).join('='));
|
|
895
|
-
else if (args[i] === flag && args[i + 1]) out.push(args[i + 1]);
|
|
896
|
-
}
|
|
897
|
-
return out;
|
|
898
|
-
};
|
|
899
|
-
|
|
900
|
-
const formation = getArg('--formation') || 'unit';
|
|
901
|
-
const skill = getArg('--skill');
|
|
902
|
-
const task = getArg('--task');
|
|
903
|
-
const dryRun = args.includes('--dry-run');
|
|
904
|
-
const mode = getArg('--mode');
|
|
905
|
-
const concurrency = getArg('--concurrency');
|
|
906
|
-
const projectPath = getArg('--project-path');
|
|
907
|
-
const originSessionId = getArg('--origin-session-id');
|
|
908
|
-
const depth = getArg('--depth');
|
|
909
|
-
// TASK_1774: Bifröst adapter override from the dispatch caller (e.g. dashboard
|
|
910
|
-
// kenaz_dispatch backend with dispatchFamily=bifrost/dispatchAdapter=openai).
|
|
911
|
-
// Threaded into launch() opts — resolveLaunchBifrostAdapter treats it as the
|
|
912
|
-
// highest-priority source (logged as source=dispatch).
|
|
913
|
-
const bifrostAdapter = getArg('--bifrost-adapter');
|
|
914
|
-
// TASK_1929 (Agent Hub M2): extra --mcp-config file(s) to register alongside
|
|
915
|
-
// the core .mcp.json (native claude path) / translate into codex `-c`
|
|
916
|
-
// overrides (bifrost/openai/relay.js) — see runner.js spawnClaude and
|
|
917
|
-
// relay.js buildCodexMcpOverrides. Empty array = zero behavior change.
|
|
918
|
-
const mcpConfigExtra = getArgAll('--mcp-config-extra');
|
|
919
|
-
// TASK_1929: full override of spawnClaude's default allowedTools list —
|
|
920
|
-
// used by agent_hub/launch.rs's `hub` skill launches to restrict a
|
|
921
|
-
// website-triggered agent to Read/Glob/Grep + the site's own MCP server,
|
|
922
|
-
// never the default Bash/Edit/Write/synapse_* toolkit. null = unchanged
|
|
923
|
-
// default (every other dispatch path never passes this flag).
|
|
924
|
-
const allowedToolsArg = getArg('--allowed-tools');
|
|
925
|
-
// Dashboard dispatch model override (Quick Actions → Default Models →
|
|
926
|
-
// Dispatch agent). Set by the kenaz_dispatch backend route after
|
|
927
|
-
// reading .kenaz/dashboard-models.json. Takes precedence over the
|
|
928
|
-
// legacy session-inheritance pattern in runner.js spawnClaude.
|
|
929
|
-
let dispatchModel = getArg('--dispatch-model');
|
|
930
|
-
// TASK_2249: ONE parsed object (first parseable file wins) feeds both dispatchModel
|
|
931
|
-
// and per-agent models — never mixed across project/core files.
|
|
932
|
-
const _dashboardModels = loadDashboardModels(projectPath);
|
|
933
|
-
if (dispatchModel === null && _dashboardModels
|
|
934
|
-
&& Object.prototype.hasOwnProperty.call(_dashboardModels, 'dispatchModel')) {
|
|
935
|
-
dispatchModel = _dashboardModels.dispatchModel;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Resolve per-agent model from dashboard-models.json.
|
|
940
|
-
* Returns the agent-specific model if set (non-empty string), else null.
|
|
941
|
-
* @param {string} agentKey - e.g. 'huginnModel', 'forsetiModel', 'heimdallModel'
|
|
942
|
-
* @returns {string|null}
|
|
943
|
-
*/
|
|
944
|
-
function resolveAgentModel(agentKey) {
|
|
945
|
-
const val = _dashboardModels?.[agentKey];
|
|
946
|
-
// Empty string = use agent built-in default (return null to not override)
|
|
947
|
-
return (typeof val === 'string' && val.length > 0) ? val : null;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const skipPostComplete = args.includes('--skip-post-complete');
|
|
951
|
-
const skipTests = args.includes('--skip-tests');
|
|
952
|
-
const force = args.includes('--force');
|
|
953
|
-
|
|
954
|
-
// Huginn Think args
|
|
955
|
-
const trigger = getArg('--trigger');
|
|
956
|
-
const focus = getArg('--focus');
|
|
957
|
-
const query = getArg('--query');
|
|
958
|
-
// ─────────────────────────────────────────
|
|
959
|
-
// Ansuz Wake / Sleep (Continuous Ansuz Daemon — TASK_900)
|
|
960
|
-
// Spawned by Tauri lib.rs on Dashboard start/close.
|
|
961
|
-
// ─────────────────────────────────────────
|
|
962
|
-
const ansuzWake = args.includes('--ansuz-wake');
|
|
963
|
-
const ansuzSleep = args.includes('--ansuz-sleep');
|
|
964
|
-
|
|
965
|
-
if (ansuzWake || ansuzSleep) {
|
|
966
|
-
const effectiveProjectPath = projectPath || ROOT;
|
|
967
|
-
const pidFile = path.join(effectiveProjectPath, '.kenaz', 'specialist', 'ansuz_pid.txt');
|
|
968
|
-
|
|
969
|
-
if (ansuzSleep) {
|
|
970
|
-
if (fs.existsSync(pidFile)) {
|
|
971
|
-
const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
|
972
|
-
if (existingPid && isProcessAlive(existingPid)) {
|
|
973
|
-
try {
|
|
974
|
-
if (process.platform === 'win32') {
|
|
975
|
-
require('child_process').execSync(
|
|
976
|
-
`taskkill /T /F /PID ${existingPid}`,
|
|
977
|
-
{ stdio: 'ignore', timeout: 10000, windowsHide: true }
|
|
978
|
-
);
|
|
979
|
-
} else {
|
|
980
|
-
process.kill(existingPid, 'SIGTERM');
|
|
981
|
-
}
|
|
982
|
-
console.log(`[Ansuz sleep] Terminated session (PID: ${existingPid})`);
|
|
983
|
-
} catch (e) {
|
|
984
|
-
console.error(`[Ansuz sleep] Kill failed: ${e.message}`);
|
|
985
|
-
}
|
|
986
|
-
} else {
|
|
987
|
-
console.log('[Ansuz sleep] PID in file is stale — clearing');
|
|
988
|
-
}
|
|
989
|
-
try { fs.unlinkSync(pidFile); } catch {}
|
|
990
|
-
} else {
|
|
991
|
-
console.log('[Ansuz sleep] No active session');
|
|
992
|
-
}
|
|
993
|
-
process.exit(0);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Wake: delegate to ansuz-sdk-runner.js (TASK_912 — multi-turn SDK mode)
|
|
997
|
-
// Spawn as detached process to escape Windows Job Object
|
|
998
|
-
(async () => {
|
|
999
|
-
const { spawn: nodeSpawn } = require('child_process');
|
|
1000
|
-
// SDK runner lives in KenazAI core (ROOT), not in the selected project
|
|
1001
|
-
const sdkRunner = path.join(ROOT, '.specialist', 'Ansuz', 'tools', 'ansuz-sdk-runner.js');
|
|
1002
|
-
|
|
1003
|
-
// Zombie guard
|
|
1004
|
-
if (fs.existsSync(pidFile)) {
|
|
1005
|
-
try {
|
|
1006
|
-
const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
|
1007
|
-
if (existingPid && isProcessAlive(existingPid)) {
|
|
1008
|
-
console.log(`[Ansuz wake] SDK runner already alive (PID: ${existingPid}) — skipping`);
|
|
1009
|
-
process.exit(0);
|
|
1010
|
-
}
|
|
1011
|
-
} catch { /* ignore */ }
|
|
1012
|
-
try { fs.unlinkSync(pidFile); } catch {}
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
if (!fs.existsSync(sdkRunner)) {
|
|
1016
|
-
console.error(`[Ansuz wake] SDK runner not found: ${sdkRunner}`);
|
|
1017
|
-
process.exit(1);
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
const nodeExe = process.execPath;
|
|
1021
|
-
const httpPort = process.env.KENAZ_HTTP_PORT || '18080';
|
|
1022
|
-
let child, spawnedPid;
|
|
1023
|
-
|
|
1024
|
-
if (process.platform === 'win32') {
|
|
1025
|
-
// PowerShell Start-Process escapes Node.js Job Object on Windows
|
|
1026
|
-
const ps1File = path.join(require('os').tmpdir(), `ansuz_sdk_wake_${Date.now()}.ps1`);
|
|
1027
|
-
const ps1Content = [
|
|
1028
|
-
`$env:KENAZ_HTTP_PORT = '${httpPort}'`,
|
|
1029
|
-
`Start-Process -FilePath '${nodeExe.replace(/'/g, "''")}' -ArgumentList '"${sdkRunner.replace(/"/g, '\\"')}"','--project-path=${effectiveProjectPath.replace(/'/g, "''")}' -WindowStyle Hidden`,
|
|
1030
|
-
`Remove-Item '${ps1File.replace(/'/g, "''")}' -ErrorAction SilentlyContinue`,
|
|
1031
|
-
].join('\n');
|
|
1032
|
-
fs.writeFileSync(ps1File, ps1Content, 'utf8');
|
|
1033
|
-
child = nodeSpawn('powershell.exe', ['-WindowStyle', 'Hidden', '-NonInteractive', '-File', ps1File], {
|
|
1034
|
-
detached: true, stdio: 'ignore', windowsHide: true,
|
|
1035
|
-
});
|
|
1036
|
-
child.unref();
|
|
1037
|
-
spawnedPid = child.pid;
|
|
1038
|
-
} else {
|
|
1039
|
-
child = nodeSpawn(nodeExe, [sdkRunner, `--project-path=${effectiveProjectPath}`], {
|
|
1040
|
-
detached: true, stdio: 'ignore', cwd: effectiveProjectPath,
|
|
1041
|
-
env: { ...process.env, KENAZ_HTTP_PORT: httpPort },
|
|
1042
|
-
});
|
|
1043
|
-
child.unref();
|
|
1044
|
-
spawnedPid = child.pid;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
1048
|
-
fs.writeFileSync(pidFile, String(spawnedPid));
|
|
1049
|
-
console.log(`[Ansuz wake] SDK runner started (PID: ${spawnedPid})`);
|
|
1050
|
-
process.exit(0);
|
|
1051
|
-
})().catch(e => {
|
|
1052
|
-
console.error(`[Ansuz wake] Unexpected error: ${e.message}`);
|
|
1053
|
-
process.exit(1);
|
|
1054
|
-
});
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (!skill) { console.error('❌ --skill is required'); process.exit(1); }
|
|
1059
|
-
|
|
1060
|
-
const opts = { formation, skill, task, dryRun, mode, projectPath, skipPostComplete, skipTests, force, trigger, focus, query, originSessionId, depth, dispatchModel, bifrostAdapter };
|
|
1061
|
-
if (concurrency != null) opts.concurrency = parseInt(concurrency, 10);
|
|
1062
|
-
// TASK_1929: only set when actually provided — omitting the key (rather
|
|
1063
|
-
// than passing an empty array / null) keeps every pre-existing dispatch's
|
|
1064
|
-
// opts object byte-identical to before this task.
|
|
1065
|
-
if (mcpConfigExtra.length > 0) opts.mcpConfigExtra = mcpConfigExtra;
|
|
1066
|
-
if (allowedToolsArg) opts.allowedToolsOverride = allowedToolsArg;
|
|
1067
|
-
|
|
1068
|
-
launch(opts);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
module.exports = { launch, recoverStaleDispatchBranch };
|