@worca/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,420 @@
1
+ // src/core/workspace-scan.mjs
2
+ //
3
+ // The wizard's scan engine (Workspaces Milestone 5, §3). A WorkspaceScan is an
4
+ // EventEmitter the server wires onto the WS bus exactly like wireRun wires an
5
+ // Orchestrator. It (re)builds per-project graphify graphs in throwaway worktrees
6
+ // (D4), fans out one read-only investigator per member through the existing
7
+ // `workspaceScanner` agent (driven via runWorkspaceScan in phases.mjs — NOT
8
+ // reimplemented here), and synthesizes the editable §5.8 interconnection
9
+ // description. Progress streams over the `scan-*` event family:
10
+ //
11
+ // scan-progress { scanId, phase, projectsTotal, projectsDone, message } (many)
12
+ // scan-done { scanId, description, projects:[{projectKey,projectName}], graphify:{used} }
13
+ // scan-error { scanId, message }
14
+ //
15
+ // Phases progress STRICTLY graph -> investigate -> synthesize. run() NEVER throws
16
+ // (it emits scan-error instead), mirroring Orchestrator.run()'s try/catch/finally
17
+ // discipline. The scan is read-only: every scan worktree AND its branch are
18
+ // force-removed in finally (D4) — this DIFFERS from a run teardown, which keeps
19
+ // the branch (the scan branch existed only to isolate the graphify update write).
20
+
21
+ import { EventEmitter } from 'node:events';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { join } from 'node:path';
24
+ import { basename } from 'node:path';
25
+ import { mkdir, rm, readFile } from 'node:fs/promises';
26
+
27
+ import { worcaHome } from './projects.mjs';
28
+ import { projectKey, canonicalProjectRoot } from './store.mjs';
29
+ import { slugify, today } from './artifacts.mjs';
30
+ import { detectToolsPerProject, runGraphifyUpdate } from './preflight.mjs';
31
+ import {
32
+ createWorktree, removeWorktree, resolveDefaultBranch, sanitizeBranchName,
33
+ } from './worktree.mjs';
34
+ import { runWorkspaceScan } from './phases.mjs';
35
+ import { fanoutCap, mapWithCap } from './fanout.mjs';
36
+
37
+ // The scanning agent's name in the prompt-role/registry sense (drives the .md body
38
+ // it loads; the MOCK_ROLE marker differs — workspace-scan — and is set inside
39
+ // runWorkspaceScan, C3). The off-pipeline scanner body is NOT loaded by the
40
+ // orchestrator (it is the dispatcher's job), so the engine loads it itself.
41
+ const SCANNER_AGENT_FILE = 'worca-cc-workspace-scanner.md';
42
+
43
+ /**
44
+ * @param {object} opts
45
+ * @param {string[]} opts.projectPaths absolute member paths (>=2 after dedupe)
46
+ * @param {string} [opts.name] workspace name (heading + scan-branch slug)
47
+ * @param {string} [opts.agentsDir] agents/*.md dir (server passes AGENTS_DIR)
48
+ * @param {object} [opts.claude] { bin?, model?, permissionMode?, mock? }
49
+ * @param {boolean} [opts.mock] force mock (skips graphify builds); defaults to claude.mock
50
+ * @param {number} [opts.graphBuildTimeoutMs] per-project graphify cap (WORCA_GRAPH_TIMEOUT_MS|120000)
51
+ * @param {number} [opts.fanoutCap] graph-phase concurrency (default fanoutCap())
52
+ * @returns {WorkspaceScan}
53
+ */
54
+ export function createWorkspaceScan(opts = {}) {
55
+ return new WorkspaceScan(opts);
56
+ }
57
+
58
+ class WorkspaceScan extends EventEmitter {
59
+ constructor(opts = {}) {
60
+ super();
61
+ this.opts = opts || {};
62
+ this.name = (typeof this.opts.name === 'string' && this.opts.name.trim()) || 'Workspace';
63
+ this.agentsDir = this.opts.agentsDir || null;
64
+ this.claude = this.opts.claude || {};
65
+ // Mock skips graphify builds (no worktrees, no spawns). claude.mock drives the
66
+ // scanning AGENT; opts.mock can independently force the graph phase off too.
67
+ this.mock = this.opts.mock !== undefined ? !!this.opts.mock : !!this.claude.mock;
68
+ this.graphBuildTimeoutMs = Number(this.opts.graphBuildTimeoutMs)
69
+ || Number(process.env.WORCA_GRAPH_TIMEOUT_MS) || 120000;
70
+ this.cap = Number(this.opts.fanoutCap) > 0 ? Number(this.opts.fanoutCap) : fanoutCap();
71
+
72
+ // Resolve + sort members by projectKey ascending (the canonical order used
73
+ // everywhere); de-dupe by canonical root so two paths into the same repo
74
+ // collapse. Each entry carries its absolute dir + key + display name.
75
+ const raw = Array.isArray(this.opts.projectPaths) ? this.opts.projectPaths : [];
76
+ const seen = new Set();
77
+ const members = [];
78
+ for (const dir of raw) {
79
+ if (typeof dir !== 'string' || !dir) continue;
80
+ let root;
81
+ try { root = canonicalProjectRoot(dir); } catch { root = dir; }
82
+ if (seen.has(root)) continue;
83
+ seen.add(root);
84
+ members.push({ projectDir: dir, projectKey: projectKey(dir), projectName: basename(dir) });
85
+ }
86
+ members.sort((a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0));
87
+ this.projects = members;
88
+
89
+ // Ephemeral id + scratch dir (pre-persist, no workspaceKey yet, §3.6).
90
+ this.scanId = `scan_${randomUUID()}`;
91
+ this.shortId = this.scanId.slice(5, 13); // the 8 hex after "scan_"
92
+ this.scratchDir = join(worcaHome(), 'tmp', 'scan', this.shortId);
93
+ this.outPath = join(this.scratchDir, 'workspace-description.md');
94
+
95
+ this.abort = new AbortController();
96
+ this.warnings = [];
97
+
98
+ // Live progress state.
99
+ this.phase = 'graph';
100
+ this.projectsTotal = members.length;
101
+ this.projectsDone = 0;
102
+ this.message = 'preparing scan…';
103
+ this.status = 'created';
104
+ this._terminal = false; // guards exactly-one terminal event
105
+ }
106
+
107
+ // ── public surface ──────────────────────────────────────────────────────────
108
+
109
+ getState() {
110
+ return {
111
+ scanId: this.scanId,
112
+ phase: this.phase,
113
+ projectsTotal: this.projectsTotal,
114
+ projectsDone: this.projectsDone,
115
+ message: this.message,
116
+ status: this.status,
117
+ scratchDir: this.scratchDir,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Abort an in-flight scan. Aborts the signal (which unblocks the scanning agent's
123
+ * runClaude) and flips status to 'stopped'; the run() finally block does the
124
+ * best-effort worktree/branch cleanup and emits the terminal scan-error{stopped}.
125
+ */
126
+ stop() {
127
+ if (this.status === 'done' || this.status === 'stopped' || this.status === 'error') return;
128
+ this.status = 'stopped';
129
+ try { this.abort.abort(); } catch { /* ignore */ }
130
+ }
131
+
132
+ /**
133
+ * graph -> investigate -> synthesize. Resolves { status, description, projects,
134
+ * graphify:{used}, warnings }; NEVER throws (emits scan-error instead). The whole
135
+ * body is wrapped try/catch (scan-error) / finally (D4 cleanup), mirroring the
136
+ * orchestrator.
137
+ */
138
+ async run() {
139
+ const cleanup = []; // { projectDir, worktreeDir, branch } per created scan worktree
140
+ let graphifyUsed = false;
141
+ try {
142
+ this.status = 'running';
143
+ this._checkAbort();
144
+
145
+ if (!this.projects.length) {
146
+ throw new Error('a workspace scan needs at least 2 member projects');
147
+ }
148
+
149
+ // ── PHASE 1: graph (parallel, cap, CLI-only, D4 throwaway worktree) ───────
150
+ this._setPhase('graph', `detecting tooling across ${this.projectsTotal} project(s)…`);
151
+ graphifyUsed = await this._graphPhase(cleanup);
152
+ this._checkAbort();
153
+
154
+ // ── PHASE 2: investigate (scan-fanout) ───────────────────────────────────
155
+ this._setPhase('investigate', `investigating relations across ${this.projectsTotal} project(s)…`);
156
+ await mkdir(this.scratchDir, { recursive: true });
157
+ const { description: raw } = await this._runScanningAgent();
158
+ this._checkAbort();
159
+
160
+ // ── PHASE 3: synthesize ──────────────────────────────────────────────────
161
+ this._setPhase('synthesize', 'synthesizing the interconnection description…');
162
+ const description = String(raw || '').trim(); // full text, no cap (was this._capDescription(raw))
163
+ if (!description) throw new Error('the scan produced an empty description');
164
+
165
+ this.status = 'done';
166
+ const payload = {
167
+ description,
168
+ projects: this.projects.map((p) => ({ projectKey: p.projectKey, projectName: p.projectName })),
169
+ graphify: { used: graphifyUsed },
170
+ };
171
+ this._emitTerminal('scan-done', payload);
172
+ return { status: 'done', warnings: this.warnings, ...payload };
173
+ } catch (err) {
174
+ if (isAbort(err) || this.status === 'stopped') {
175
+ this.status = 'stopped';
176
+ this._emitTerminal('scan-error', { message: 'stopped' });
177
+ return { status: 'stopped', warnings: this.warnings };
178
+ }
179
+ this.status = 'error';
180
+ const message = (err && err.message) || String(err);
181
+ this._emitTerminal('scan-error', { message });
182
+ return { status: 'error', message, warnings: this.warnings };
183
+ } finally {
184
+ // D4: force-remove every scan worktree AND DELETE its branch (read-only scan;
185
+ // the branch only isolated the graphify write). Best-effort; record warnings.
186
+ for (const c of cleanup) {
187
+ try {
188
+ const r = await removeWorktree({
189
+ projectDir: c.projectDir, worktreeDir: c.worktreeDir, branch: c.branch, force: true,
190
+ });
191
+ if (r && r.ok === false) {
192
+ this.warnings.push(`scan worktree cleanup incomplete for ${c.branch}`);
193
+ }
194
+ } catch (e) {
195
+ this.warnings.push(`scan worktree cleanup failed for ${c.branch}: ${(e && e.message) || e}`);
196
+ }
197
+ }
198
+ // Remove the ephemeral scratch dir (the description is already in memory).
199
+ await rm(this.scratchDir, { recursive: true, force: true }).catch(() => {});
200
+ }
201
+ }
202
+
203
+ // ── phases ────────────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Build a fresh graphify graph per CLI member in a throwaway worktree (D4). On
207
+ * success set p.scanDir/p.graphify (the scanning agent's task prompt reads these
208
+ * to point each investigator at its fresh graphify-out/). On failure/timeout or
209
+ * a non-cli member, DEGRADE to source-reading (emit a progress note, never abort).
210
+ * Returns whether ANY member produced a graph (graphify.used).
211
+ * @returns {Promise<boolean>}
212
+ */
213
+ async _graphPhase(cleanup) {
214
+ // Mock mode skips graphify builds entirely (no worktrees, used=false, §3 note 3).
215
+ if (this.mock) {
216
+ this._progress(`mock mode — investigators will source-read ${this.projectsTotal} project(s)`);
217
+ return false;
218
+ }
219
+
220
+ const tools = await detectToolsPerProject(this.projects.map((p) => p.projectDir));
221
+ const wsSlug = slugify(this.name);
222
+ const date = today();
223
+ let used = false;
224
+
225
+ // Per-project build with the same bounded-concurrency primitive the orchestrator
226
+ // uses for its own per-project IO (fanout.mjs, capped at fanoutCap). Each member
227
+ // is a DISTINCT repo, so concurrent worktree writes never contend; `used` is only
228
+ // ever flipped to true, so the shared OR across callbacks is benign. An abort
229
+ // rethrows out of mapWithCap (Promise.all semantics) and is caught in run().
230
+ await mapWithCap(this.projects, this.cap, async (p) => {
231
+ this._checkAbort();
232
+ const info = tools.get(p.projectDir) || {};
233
+ if (info.kind !== 'cli') {
234
+ this._progress(`${p.projectName}: no graphify CLI — source-reading`);
235
+ return;
236
+ }
237
+ this._progress(`building graph for ${p.projectName}…`);
238
+ try {
239
+ const source = await resolveDefaultBranch(p.projectDir);
240
+ const branch = sanitizeBranchName(`worca-cc/ws-scan-${wsSlug}-${date}-${this.shortId}`);
241
+ const pipelineId = `ws-scan-${this.shortId}`;
242
+ const wt = await createWorktree({
243
+ projectDir: p.projectDir, pipelineId, sourceBranch: source, featureBranch: branch,
244
+ signal: this.abort.signal,
245
+ });
246
+ // Track for cleanup BEFORE the (possibly failing) graph build so a
247
+ // mid-build abort still tears the worktree + branch down (D4).
248
+ cleanup.push({ projectDir: p.projectDir, worktreeDir: wt.worktreeDir, branch: wt.branch });
249
+ const res = await runGraphifyUpdate({
250
+ dir: wt.worktreeDir, cwd: wt.worktreeDir, timeoutMs: this.graphBuildTimeoutMs,
251
+ });
252
+ if (res && res.ok) {
253
+ p.scanDir = wt.worktreeDir;
254
+ p.graphify = true;
255
+ used = true;
256
+ this._progress(`graph built for ${p.projectName}`);
257
+ } else {
258
+ this._progress(`${p.projectName}: graph build failed — source-reading`);
259
+ }
260
+ } catch (e) {
261
+ if (isAbort(e)) throw e;
262
+ // createWorktree threw (e.g. branch already checked out elsewhere) or a
263
+ // git error — degrade this member, keep the others (§3.8).
264
+ this._progress(`${p.projectName}: graph unavailable — source-reading`);
265
+ }
266
+ });
267
+ return used;
268
+ }
269
+
270
+ /**
271
+ * Phase 2: ONE scanning agent that fans out <=cap read-only investigators. We do
272
+ * NOT reimplement the scanning-agent invocation — we drive runWorkspaceScan
273
+ * (phases.mjs), which grants Task/Agent (effectiveAllowedTools(..., fanOut)) via
274
+ * ctx.fanOut, names every member + its graph, carries the §5.8 template + MOCK
275
+ * markers, and writes the description to outPath. ctx.onEvent drives the changing
276
+ * scan-progress.message and bumps projectsDone.
277
+ * @returns {Promise<{description:string}>}
278
+ */
279
+ async _runScanningAgent() {
280
+ const agentBody = await this._loadScannerBody();
281
+ const ctx = {
282
+ // The scanner runs from the primary member's dir; investigators get each
283
+ // member's absolute path from the task prompt runWorkspaceScan builds.
284
+ projectDir: this.projects[0]?.projectDir,
285
+ pipelineDir: this.scratchDir,
286
+ projects: this.projects.map((p) => ({
287
+ projectKey: p.projectKey,
288
+ projectName: p.projectName,
289
+ projectDir: p.projectDir,
290
+ scanDir: p.scanDir, // the graph worktree when a build succeeded
291
+ graphify: !!p.graphify,
292
+ })),
293
+ workspaceName: this.name,
294
+ // The scanner IS the source of the description, so it gets NO injected
295
+ // workspace block (runWorkspaceScan passes undefined as the 4th arg).
296
+ toolInstruction: '',
297
+ agentPrompts: { workspaceScanner: agentBody },
298
+ // ctxFanOut(ctx) -> ctx.fanOut (no node) -> grants Task/Agent so the agent
299
+ // can dispatch investigators (scan-fanout).
300
+ fanOut: true,
301
+ claudeOpts: {
302
+ permissionMode: this.claude.permissionMode || 'acceptEdits',
303
+ model: this.claude.model,
304
+ bin: this.claude.bin,
305
+ mock: this.claude.mock,
306
+ },
307
+ signal: this.abort.signal,
308
+ onEvent: (e) => this._onAgentEvent(e),
309
+ };
310
+ const { description } = await runWorkspaceScan(ctx, { outPath: this.outPath, name: this.name });
311
+ // Authoritative read-back (runWorkspaceScan already reads the file; re-read is
312
+ // cheap and keeps the engine the single owner of the delivered string).
313
+ let text = description;
314
+ try { text = await readFile(this.outPath, 'utf8'); } catch { /* keep returned text */ }
315
+ return { description: text };
316
+ }
317
+
318
+ // ── agent-event -> changing live status ─────────────────────────────────────
319
+
320
+ /**
321
+ * Turn the scanning agent's events into the CHANGING scan-progress.message and
322
+ * bump projectsDone. The reliable cross-mode signal is the agent's own
323
+ * `INVESTIGATING <key> relations to <other>` / `SYNTHESIZING …` log lines (the
324
+ * mock emits them; the real agent is prompted to). We also opportunistically
325
+ * surface a Task tool_use description (the orchestrator's sub-agent-label idea)
326
+ * for richer real-mode text.
327
+ */
328
+ _onAgentEvent(e) {
329
+ if (!e || typeof e !== 'object') return;
330
+ const text = typeof e.text === 'string' ? e.text : '';
331
+ const inv = text.match(/INVESTIGATING\s+(\S+)\s+relations to\s+(\S+)/i);
332
+ if (inv) {
333
+ const fromName = this._nameForKey(inv[1]) || inv[1];
334
+ const toName = this._nameForKey(inv[2]) || inv[2];
335
+ this.projectsDone = Math.min(this.projectsTotal, this.projectsDone + 1);
336
+ this._progress(`investigating ${fromName} relations to ${toName}…`);
337
+ return;
338
+ }
339
+ if (/SYNTHESIZING/i.test(text)) {
340
+ this._progress('merging investigator reports…');
341
+ return;
342
+ }
343
+ // Opportunistic: a dispatched Task's description (real mode), via the same
344
+ // stream-json shape registerSubAgents reads.
345
+ const desc = taskDescription(e.raw);
346
+ if (desc) this._progress(desc);
347
+ }
348
+
349
+ _nameForKey(key) {
350
+ const m = this.projects.find((p) => p.projectKey === key);
351
+ return m ? m.projectName : null;
352
+ }
353
+
354
+ // ── emit helpers ────────────────────────────────────────────────────────────
355
+
356
+ _setPhase(phase, message) {
357
+ this.phase = phase;
358
+ this._progress(message);
359
+ }
360
+
361
+ /** Emit a fresh scan-progress with the current (changing) message. */
362
+ _progress(message) {
363
+ if (this._terminal) return;
364
+ if (message) this.message = message;
365
+ this.emit('scan-progress', {
366
+ scanId: this.scanId,
367
+ phase: this.phase,
368
+ projectsTotal: this.projectsTotal,
369
+ projectsDone: this.projectsDone,
370
+ message: this.message,
371
+ });
372
+ }
373
+
374
+ /** Emit exactly ONE terminal event (scan-done | scan-error). */
375
+ _emitTerminal(type, payload) {
376
+ if (this._terminal) return;
377
+ this._terminal = true;
378
+ this.emit(type, { scanId: this.scanId, ...payload });
379
+ }
380
+
381
+ // ── small utilities ───────────────────────────────────────────────────────────
382
+
383
+ async _loadScannerBody() {
384
+ if (!this.agentsDir) return '';
385
+ try {
386
+ return await readFile(join(this.agentsDir, SCANNER_AGENT_FILE), 'utf8');
387
+ } catch {
388
+ return ''; // body missing -> empty; runWorkspaceScan falls back gracefully
389
+ }
390
+ }
391
+
392
+ _checkAbort() {
393
+ if (this.abort.signal.aborted || this.status === 'stopped') {
394
+ const err = new Error('stopped');
395
+ err.name = 'AbortError';
396
+ throw err;
397
+ }
398
+ }
399
+ }
400
+
401
+ function isAbort(err) {
402
+ // NAME only, matching orchestrator.isAbort (kept local: importing it would
403
+ // create an import cycle). _checkAbort stamps name='AbortError'; a message
404
+ // sniff also matched real failures mentioning "aborted"/"stopped" and would
405
+ // end a scan silently.
406
+ return !!err && err.name === 'AbortError';
407
+ }
408
+
409
+ /** A dispatched Task/Agent tool_use description from a stream-json event, or ''. */
410
+ function taskDescription(raw) {
411
+ const content = raw?.message?.content;
412
+ if (!Array.isArray(content)) return '';
413
+ for (const c of content) {
414
+ if (c?.type === 'tool_use' && (c.name === 'Task' || c.name === 'Agent')) {
415
+ const d = c.input?.description || c.input?.prompt;
416
+ if (d) return String(d).slice(0, 80);
417
+ }
418
+ }
419
+ return '';
420
+ }