@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.
- package/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- 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
|
+
}
|