@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,64 @@
1
+ // src/core/run-log.mjs
2
+ import { appendFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ /** Per-run live-log file (NDJSON) — dir-relative artifact, mirrors prompt.md. */
6
+ export const RUN_LOG_FILE = 'live-log.ndjson';
7
+ /** Artifact kind indexed in the `artifacts` table. */
8
+ export const RUN_LOG_KIND = 'live-log';
9
+
10
+ /**
11
+ * Buffered, serialized NDJSON writer for a run's live `log` event stream.
12
+ *
13
+ * - Lines are buffered in memory and flushed on a timer (flushMs) or once the
14
+ * buffer reaches maxBuffer — NEVER one append per line.
15
+ * - Appends are serialized through a single FIFO promise chain, so two flushes
16
+ * can never interleave or reorder bytes (race-safe).
17
+ * - UNCAPPED: the full stream is persisted (no MAX_LOG_LINES equivalent).
18
+ * - Best-effort: a failed append never rejects into the run (mirrors recordArtifact).
19
+ *
20
+ * Lifecycle: push() from construction (pre-bind lines are retained in the buffer);
21
+ * bind(dir) once the pipeline dir exists; close() at run end (flushes + stops timer).
22
+ */
23
+ export function createRunLogWriter({ flushMs = 1000, maxBuffer = 256 } = {}) {
24
+ let buf = [];
25
+ let file = null;
26
+ let timer = null;
27
+ let chain = Promise.resolve(); // serializes appendFile calls (FIFO)
28
+ let closed = false;
29
+
30
+ function flush() {
31
+ if (!file || buf.length === 0) return chain; // pre-bind: keep buffering
32
+ const batch = buf;
33
+ buf = [];
34
+ const text = batch.map((e) => JSON.stringify(e)).join('\n') + '\n';
35
+ chain = chain.then(() => appendFile(file, text, 'utf8')).catch(() => {});
36
+ return chain;
37
+ }
38
+
39
+ return {
40
+ /** Point the writer at <dir>/live-log.ndjson and start the flush timer. Idempotent. */
41
+ bind(dir) {
42
+ if (file || !dir) return;
43
+ file = join(dir, RUN_LOG_FILE);
44
+ timer = setInterval(() => { flush(); }, flushMs);
45
+ if (timer && typeof timer.unref === 'function') timer.unref(); // never hold the CLI open
46
+ },
47
+ /** Queue one log event. No-op after close. Eager-flushes at maxBuffer. */
48
+ push(evt) {
49
+ if (closed || !evt) return;
50
+ buf.push(evt);
51
+ if (buf.length >= maxBuffer) flush();
52
+ },
53
+ /** Stop the timer, flush everything still buffered, and await all queued appends. */
54
+ async close() {
55
+ if (closed) return;
56
+ closed = true;
57
+ if (timer) { clearInterval(timer); timer = null; }
58
+ flush();
59
+ await chain;
60
+ },
61
+ /** @internal test hook */
62
+ _pending() { return buf.length; },
63
+ };
64
+ }
@@ -0,0 +1,317 @@
1
+ // src/core/run-manifest.mjs
2
+ // The per-run manifest (`<runRoot>/run.json`) plus the small set of fs-only
3
+ // helpers every run-root consumer needs: the §8.13 removal guard, the §8.11 stray
4
+ // scan, the §8.20 modified-mount rescue, and the §5.2 durability copy.
5
+ //
6
+ // LEAF MODULE by design: node builtins only. worktree.mjs (sweepRunRoots),
7
+ // orchestrator.mjs (_teardownRunRoot), and pipeline-delete.mjs all import it, so
8
+ // it must never reach back into the DB layer or the orchestrator.
9
+ //
10
+ // Phase 1 writes the MINIMAL manifest (pipelineId, runRootMode, isWorkspace,
11
+ // members[]) so the boot sweep and pipeline-delete have member real dirs from the
12
+ // very first detached run. Phase 3 EXTENDS the same file with injectedPaths,
13
+ // skillResolutions, renames, bytes, warnings, capabilities, and the teardown
14
+ // `retain` record — every read here is deliberately shape-tolerant so a manifest
15
+ // from either phase parses.
16
+
17
+ import { mkdir, readFile, writeFile, rm, rename, readdir, stat, cp, lstat } from 'node:fs/promises';
18
+ import { join, resolve, sep, basename, dirname } from 'node:path';
19
+
20
+ export const RUN_MANIFEST_FILE = 'run.json';
21
+
22
+ /** Stable machine-readable reasons why a terminal run root must be retained. */
23
+ export const RETAIN_REASONS = Object.freeze({ COMMIT_FAILED: 'commit_failed' });
24
+
25
+ /** Entries worca-cc itself owns at a run root. Anything else there is a STRAY (§8.11). */
26
+ export const RUN_ROOT_KNOWN_SET = new Set(['CLAUDE.md', 'mcp.json', RUN_MANIFEST_FILE, '.claude', 'repos']);
27
+
28
+ // The V3 CLAUDE.md fallback's delimiter-fenced section (§4.1). The begin marker
29
+ // carries the pipelineId so two concurrent runs over the same real dir can never
30
+ // strip each other's block.
31
+ export const claudeMdFenceBegin = (pipelineId) => `<!-- worca-cc:context:begin ${pipelineId} -->`;
32
+ export const CLAUDE_MD_FENCE_END = '<!-- worca-cc:context:end -->';
33
+
34
+ /** Absolute path of a run root's manifest. */
35
+ export function runManifestPath(runRoot) {
36
+ return join(runRoot, RUN_MANIFEST_FILE);
37
+ }
38
+
39
+ /**
40
+ * Write (or overwrite) `<runRoot>/run.json`. Creates the run root when missing.
41
+ * Best-effort atomic: temp file + rename, so a concurrent reader never sees a
42
+ * half-written manifest. Never throws — a manifest is an optimization for the
43
+ * sweeps, never a correctness dependency (they fall back to the DB columns).
44
+ * @param {string} runRoot
45
+ * @param {object} data
46
+ * @returns {Promise<boolean>} true when the manifest landed
47
+ */
48
+ export async function writeRunManifest(runRoot, data) {
49
+ if (!runRoot) return false;
50
+ try {
51
+ await mkdir(runRoot, { recursive: true });
52
+ const file = runManifestPath(runRoot);
53
+ const tmp = `${file}.tmp`;
54
+ await writeFile(tmp, JSON.stringify(data ?? {}, null, 2) + '\n', 'utf8');
55
+ await rename(tmp, file);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read `<runRoot>/run.json`. Missing / unreadable / unparseable / non-object => null.
64
+ * Shape-tolerant on purpose: Phase 1 writes a subset of the Phase 3 schema, and a
65
+ * hand-written fixture is a legitimate input.
66
+ * @param {string} runRoot
67
+ * @returns {Promise<object|null>}
68
+ */
69
+ export async function readRunManifest(runRoot) {
70
+ if (!runRoot) return null;
71
+ try {
72
+ const data = JSON.parse(await readFile(runManifestPath(runRoot), 'utf8'));
73
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Merge `patch` into an existing manifest (or create one). Used by Phase 3's
81
+ * assembly and by teardown when it appends warnings. Never throws.
82
+ */
83
+ export async function updateRunManifest(runRoot, patch) {
84
+ const cur = (await readRunManifest(runRoot)) || {};
85
+ return writeRunManifest(runRoot, { ...cur, ...(patch || {}) });
86
+ }
87
+
88
+ /**
89
+ * §8.13 run-root removal safety. `rm -rf <runRoot>` is guarded by TWO assertions
90
+ * everywhere it appears (_teardownRunRoot, sweepRunRoots, pipeline-delete): the
91
+ * resolved path must live under `<worcaHome>/runs/`, and its basename must equal
92
+ * the pipelineId. A guard failure REFUSES the removal and reports why — it never
93
+ * throws into a teardown path.
94
+ * @param {string} runRoot
95
+ * @param {{worcaHome:string, pipelineId:string}} opts
96
+ * @returns {Promise<{ok:boolean, removed:boolean, reason?:string}>}
97
+ */
98
+ export async function rmGuarded(runRoot, { worcaHome, pipelineId } = {}) {
99
+ if (!runRoot || !worcaHome || !pipelineId) {
100
+ return { ok: false, removed: false, reason: 'runRoot, worcaHome and pipelineId are all required' };
101
+ }
102
+ const target = resolve(runRoot);
103
+ const runsBase = resolve(join(worcaHome, 'runs'));
104
+ if (!target.startsWith(runsBase + sep)) {
105
+ return { ok: false, removed: false, reason: `refusing to remove ${target}: outside ${runsBase}${sep}` };
106
+ }
107
+ if (basename(target) !== pipelineId) {
108
+ return { ok: false, removed: false, reason: `refusing to remove ${target}: basename !== pipelineId ${pipelineId}` };
109
+ }
110
+ try {
111
+ await rm(target, { recursive: true, force: true });
112
+ return { ok: true, removed: true };
113
+ } catch (err) {
114
+ return { ok: false, removed: false, reason: err?.message || String(err) };
115
+ }
116
+ }
117
+
118
+ /** All file paths under `p`, relative to it. A plain file yields ['']. [] when absent. */
119
+ async function filesUnder(p) {
120
+ let st;
121
+ try { st = await lstat(p); } catch { return []; }
122
+ if (!st.isDirectory()) return [''];
123
+ const out = [];
124
+ const walk = async (rel) => {
125
+ let entries;
126
+ try { entries = await readdir(join(p, rel), { withFileTypes: true }); } catch { return; }
127
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
128
+ const child = rel ? join(rel, e.name) : e.name;
129
+ if (e.isDirectory()) await walk(child);
130
+ else out.push(child);
131
+ }
132
+ };
133
+ await walk('');
134
+ return out;
135
+ }
136
+
137
+ async function readMaybe(p) {
138
+ try { return await readFile(p); } catch { return null; }
139
+ }
140
+
141
+ /**
142
+ * True when the tree at `mounted` differs byte-wise from the tree at `source`
143
+ * (a file added inside the mount counts as a change). A missing mount is NOT a
144
+ * change (nothing to rescue); a missing source IS (we cannot prove it unchanged).
145
+ */
146
+ async function treeDiffers(mounted, source) {
147
+ const mountedFiles = await filesUnder(mounted);
148
+ if (!mountedFiles.length) return false; // mount gone: nothing to rescue
149
+ const sourceFiles = await filesUnder(source);
150
+ if (!sourceFiles.length) return true; // source gone: cannot prove unchanged
151
+ if (mountedFiles.length !== sourceFiles.length) return true;
152
+ const known = new Set(sourceFiles);
153
+ for (const rel of mountedFiles) {
154
+ if (!known.has(rel)) return true; // added file
155
+ const a = await readMaybe(rel ? join(mounted, rel) : mounted);
156
+ const b = await readMaybe(rel ? join(source, rel) : source);
157
+ if (!a || !b || !a.equals(b)) return true;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /** Extract the worca-cc-managed fenced block from `text`, or null when absent. */
163
+ export function extractClaudeMdFence(text, pipelineId) {
164
+ const begin = claudeMdFenceBegin(pipelineId);
165
+ const s = String(text ?? '');
166
+ const i = s.indexOf(begin);
167
+ if (i === -1) return null;
168
+ const j = s.indexOf(CLAUDE_MD_FENCE_END, i + begin.length);
169
+ if (j === -1) return null;
170
+ return s.slice(i + begin.length, j).replace(/^\r?\n/, '').replace(/\r?\n$/, '');
171
+ }
172
+
173
+ /**
174
+ * Strip the worca-cc-managed fenced block (begin..end inclusive) from `text`.
175
+ * Returns the text unchanged when no complete fence is present.
176
+ */
177
+ export function stripClaudeMdFence(text, pipelineId) {
178
+ const begin = claudeMdFenceBegin(pipelineId);
179
+ const s = String(text ?? '');
180
+ const i = s.indexOf(begin);
181
+ if (i === -1) return s;
182
+ const j = s.indexOf(CLAUDE_MD_FENCE_END, i + begin.length);
183
+ if (j === -1) return s;
184
+ const before = s.slice(0, i).replace(/\n{2,}$/, '\n');
185
+ const after = s.slice(j + CLAUDE_MD_FENCE_END.length).replace(/^\r?\n+/, '');
186
+ return after ? `${before}${before && !before.endsWith('\n') ? '\n' : ''}${after}` : before;
187
+ }
188
+
189
+ /**
190
+ * §8.20 modified-mount rescue. Every injected entry of kind `skill` / `claudeMd` /
191
+ * `claudeMdSection` is byte-compared against its recorded `source`; a changed or
192
+ * added file means the agent edited a DISPOSABLE copy, so the whole entry is copied
193
+ * to `<pipelineDir>/stray/<scope>/<path>` and named in a warning. `kind:'link'` (and
194
+ * `mount:'symlink'`) entries are exempt: they are write-through by design.
195
+ *
196
+ * Read-only apart from the rescue copy, so it is safe to run FIRST in teardown and
197
+ * again from the sweep. Never throws.
198
+ *
199
+ * @param {{baseDir:string, entries:Array<object>, pipelineDir:string|null, scope:string, pipelineId?:string}} args
200
+ * baseDir the checkout (or run root) the entries were materialized into
201
+ * entries injectedPaths[<scope>] — [{ path, source, kind, mount? }]
202
+ * pipelineDir the durable artifact dir; null skips the copy (warning still emitted)
203
+ * scope projectKey, or 'runRoot'
204
+ * @returns {Promise<string[]>} warnings (one per rescued entry)
205
+ */
206
+ export async function rescueModifiedMounts({ baseDir, entries, pipelineDir, scope, pipelineId } = {}) {
207
+ const warnings = [];
208
+ if (!baseDir || !Array.isArray(entries) || !entries.length) return warnings;
209
+ for (const e of entries) {
210
+ const kind = e?.kind;
211
+ if (!e?.path || kind === 'link' || e?.mount === 'symlink') continue;
212
+ if (kind !== 'skill' && kind !== 'claudeMd' && kind !== 'claudeMdSection') continue;
213
+ const mounted = join(baseDir, e.path);
214
+ try {
215
+ if (kind === 'claudeMdSection') {
216
+ const live = extractClaudeMdFence(await readFile(mounted, 'utf8').catch(() => ''), pipelineId);
217
+ if (live === null) continue; // fence gone: nothing to compare
218
+ const recorded = e.source ? await readFile(e.source, 'utf8').catch(() => null) : null;
219
+ if (recorded !== null && live.trim() === String(recorded).trim()) continue;
220
+ const dest = pipelineDir ? join(pipelineDir, 'stray', scope || 'runRoot', e.path) : null;
221
+ if (dest) {
222
+ await mkdir(dirname(dest), { recursive: true });
223
+ await writeFile(dest, live + '\n', 'utf8');
224
+ }
225
+ warnings.push(
226
+ `agent modified the worca-cc CLAUDE.md section in \`${e.path}\`; the block is disposable — ` +
227
+ `rescued to \`${dest || '(no pipeline dir)'}\`; apply it to \`${e.source || 'the real dir'}\` manually if intended.`,
228
+ );
229
+ continue;
230
+ }
231
+ if (!(await treeDiffers(mounted, e.source || ''))) continue;
232
+ const dest = pipelineDir ? join(pipelineDir, 'stray', scope || 'runRoot', e.path) : null;
233
+ if (dest) {
234
+ await mkdir(dirname(dest), { recursive: true });
235
+ await cp(mounted, dest, { recursive: true, force: true, dereference: true });
236
+ }
237
+ const label = kind === 'skill' ? `mounted skill \`${basename(e.path)}\`` : `mounted \`${e.path}\``;
238
+ warnings.push(
239
+ `agent modified ${label}; the mount is disposable — rescued to \`${dest || '(no pipeline dir)'}\`; ` +
240
+ `apply it to \`${e.source || 'the real dir'}\` manually if intended.`,
241
+ );
242
+ } catch { /* rescue is best-effort per entry */ }
243
+ }
244
+ return warnings;
245
+ }
246
+
247
+ /**
248
+ * §8.11 stray scan. Anything at the run root outside RUN_ROOT_KNOWN_SET is copied
249
+ * to `<pipelineDir>/stray/` with a loud warning, so the guarded removal below can
250
+ * never silently lose an agent's misplaced write. Never throws.
251
+ * @returns {Promise<string[]>} warnings
252
+ */
253
+ export async function scanStrayEntries({ runRoot, pipelineDir } = {}) {
254
+ const warnings = [];
255
+ if (!runRoot) return warnings;
256
+ let entries;
257
+ try { entries = await readdir(runRoot, { withFileTypes: true }); } catch { return warnings; }
258
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
259
+ if (RUN_ROOT_KNOWN_SET.has(e.name)) continue;
260
+ const src = join(runRoot, e.name);
261
+ const dest = pipelineDir ? join(pipelineDir, 'stray', e.name) : null;
262
+ try {
263
+ if (dest) {
264
+ await mkdir(dirname(dest), { recursive: true });
265
+ await cp(src, dest, { recursive: true, force: true, dereference: true });
266
+ }
267
+ } catch { /* best-effort */ }
268
+ warnings.push(
269
+ `unexpected entry \`${e.name}\` at the run root (agents must write inside repos/); ` +
270
+ `rescued to \`${dest || '(no pipeline dir)'}\`.`,
271
+ );
272
+ }
273
+ return warnings;
274
+ }
275
+
276
+ /**
277
+ * §5.2 durable ledger: copy `<runRoot>/run.json` to `<pipelineDir>/run.json`
278
+ * BEFORE the run root is removed. Never throws.
279
+ * @returns {Promise<boolean>} true when the copy landed
280
+ */
281
+ export async function copyRunManifestTo(runRoot, pipelineDir) {
282
+ if (!runRoot || !pipelineDir) return false;
283
+ try {
284
+ await stat(runManifestPath(runRoot));
285
+ await mkdir(pipelineDir, { recursive: true });
286
+ await cp(runManifestPath(runRoot), join(pipelineDir, RUN_MANIFEST_FILE), { force: true });
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Remove every injected path from one checkout (teardown per-member step 4), so
295
+ * nothing worca-cc created can be committed dangling or outlive the run. Prunes an
296
+ * emptied `.claude` tree too. `claudeMdSection` entries are skipped — their file is
297
+ * the user's tracked CLAUDE.md (the fence strip of step 2 is what cleans those).
298
+ * Never throws.
299
+ */
300
+ export async function removeInjectedPaths(baseDir, entries) {
301
+ if (!baseDir || !Array.isArray(entries)) return;
302
+ const parents = new Set();
303
+ for (const e of entries) {
304
+ if (!e?.path || e.kind === 'claudeMdSection') continue;
305
+ try { await rm(join(baseDir, e.path), { recursive: true, force: true }); } catch { /* best-effort */ }
306
+ let rel = dirname(e.path);
307
+ while (rel && rel !== '.' && rel !== sep) { parents.add(rel); rel = dirname(rel); }
308
+ }
309
+ // Deepest-first so an emptied `.claude/skills` is pruned before `.claude`.
310
+ for (const rel of [...parents].sort((a, b) => b.length - a.length)) {
311
+ try {
312
+ const abs = join(baseDir, rel);
313
+ const left = await readdir(abs);
314
+ if (!left.length) await rm(abs, { recursive: true, force: true });
315
+ } catch { /* best-effort */ }
316
+ }
317
+ }
@@ -0,0 +1,167 @@
1
+ // src/core/runners.mjs
2
+ // Runner registry: maps an agent's runnerType -> a function the dispatcher calls.
3
+ //
4
+ // There are exactly two runner types (CONTRACT):
5
+ // - producer : generates artifacts/code (Plan, Refine, Implement, Manual Tests
6
+ // Checklist). Always returns status "ok"; may carry a review.
7
+ // - verifier : emits a protocol.mjs review verdict (Review, Manual web UI
8
+ // testing). status is "blocked" iff the review has blocking
9
+ // (critical/major) issues; eligible as a loopSource.
10
+ //
11
+ // Each runner receives the orchestrator's node ctx (see Orchestrator._nodeCtx):
12
+ // { projectDir, pipelineDir, taskPrompt, toolInstruction, agentPrompts,
13
+ // checkpointRef, signal, onEvent, claudeOpts:{model,effort,mock,...},
14
+ // node:{nodeId,key,runnerType,loopSource,...}, nodeId, stepIndex, cycle,
15
+ // ...per-call fields the dispatcher threads in (planPath, planFilePath,
16
+ // reviewMdPath, reviewJsonPath, outPlanPath, inPlanPath, baseName,
17
+ // answers, reviewPath, mode) }
18
+ //
19
+ // New agents pick an existing runnerType and need NO engine code; a genuinely new
20
+ // behavior = add one branch (or one runner) here.
21
+
22
+ import {
23
+ runPlannerPlan,
24
+ runRefiner,
25
+ runDecomposer,
26
+ runImplementer,
27
+ runReviewer,
28
+ runPlanReviewer,
29
+ runWorkspaceReviewer,
30
+ runManualTestsChecklist,
31
+ runManualWebUiTesting,
32
+ runGenericProducer,
33
+ runGenericVerifier,
34
+ } from './phases.mjs';
35
+ import { hasBlocking, blockingIssues } from './protocol.mjs';
36
+
37
+ /** Normalize a protocol review into the RunnerResult verdict fields. */
38
+ function verdict(review) {
39
+ return {
40
+ status: hasBlocking(review) ? 'blocked' : 'ok',
41
+ issues: blockingIssues(review),
42
+ review,
43
+ summary: review?.summary || '',
44
+ };
45
+ }
46
+
47
+ /**
48
+ * producer — generates artifacts/code. Dispatches on the canonical agent key.
49
+ * Always status "ok" (producers do not gate); the refiner additionally surfaces
50
+ * its review so a workflow MAY hang a loop off it, but default routing does not.
51
+ * @param {object} ctx node ctx from the orchestrator
52
+ * @returns {Promise<{status:'ok', summary?:string, planPath?:string, outPlanPath?:string, review?:object}>}
53
+ */
54
+ async function producer(ctx) {
55
+ const key = ctx?.node?.key;
56
+ switch (key) {
57
+ case 'planner': {
58
+ const { planPath } = await runPlannerPlan(ctx, {
59
+ answers: ctx.answers || [],
60
+ planFilePath: ctx.planFilePath,
61
+ baseName: ctx.baseName,
62
+ reviewPath: ctx.reviewPath,
63
+ });
64
+ return { status: 'ok', planPath, summary: 'Plan written.' };
65
+ }
66
+ case 'refiner': {
67
+ const { outPlanPath, review } = await runRefiner(ctx, {
68
+ inPlanPath: ctx.inPlanPath,
69
+ outPlanPath: ctx.outPlanPath,
70
+ cycle: ctx.cycle,
71
+ reviewJsonPath: ctx.reviewJsonPath,
72
+ });
73
+ // A producer never blocks; expose the review (+ issues) for loop wiring.
74
+ return { status: 'ok', outPlanPath, review, issues: blockingIssues(review), summary: review?.summary || '' };
75
+ }
76
+ case 'decomposer': {
77
+ const { decompositionPath, decomposition } = await runDecomposer(ctx, {
78
+ planPath: ctx.planPath,
79
+ decompositionPath: ctx.decompositionPath,
80
+ });
81
+ return { status: 'ok', decompositionPath, decomposition, summary: 'Plan decomposed.' };
82
+ }
83
+ case 'implementer': {
84
+ const { summary } = await runImplementer(ctx, {
85
+ planPath: ctx.planPath,
86
+ reviewPath: ctx.reviewPath,
87
+ taskPath: ctx.node?.taskPath,
88
+ siblings: ctx.node?.siblings,
89
+ mode: ctx.mode || 'implement',
90
+ });
91
+ return { status: 'ok', summary };
92
+ }
93
+ case 'manualTestsChecklist': {
94
+ const { checklistPath, summary } = await runManualTestsChecklist(ctx, {
95
+ planPath: ctx.planPath,
96
+ checklistPath: ctx.checklistPath,
97
+ });
98
+ return { status: 'ok', checklistPath, summary };
99
+ }
100
+ default: {
101
+ // Generic branch: a metadata-declared agent runs with ZERO core edits.
102
+ const { summary } = await runGenericProducer(ctx);
103
+ return { status: 'ok', summary };
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * verifier — emits a protocol review verdict. status "blocked" iff the review has
110
+ * blocking issues. Eligible as a loopSource.
111
+ * @param {object} ctx node ctx from the orchestrator
112
+ * @returns {Promise<{status:'ok'|'blocked', issues:Array, review:object, summary:string}>}
113
+ */
114
+ async function verifier(ctx) {
115
+ const key = ctx?.node?.key;
116
+ switch (key) {
117
+ case 'reviewer': {
118
+ const { review } = await runReviewer(ctx, {
119
+ planPath: ctx.planPath,
120
+ reviewMdPath: ctx.reviewMdPath,
121
+ reviewJsonPath: ctx.reviewJsonPath,
122
+ cycle: ctx.cycle,
123
+ });
124
+ // CONV-5: thread the review markdown path so a loop rewind runs the implementer in `fix` mode.
125
+ return { ...verdict(review), reviewMdPath: ctx.reviewMdPath };
126
+ }
127
+ case 'planReviewer': {
128
+ const { review } = await runPlanReviewer(ctx, {
129
+ planPath: ctx.planPath,
130
+ reviewMdPath: ctx.reviewMdPath,
131
+ reviewJsonPath: ctx.reviewJsonPath,
132
+ cycle: ctx.cycle,
133
+ });
134
+ // Thread the review md path so a loop rewind to the planner reads the review.
135
+ return { ...verdict(review), reviewMdPath: ctx.reviewMdPath };
136
+ }
137
+ case 'workspaceReviewer': {
138
+ const { review } = await runWorkspaceReviewer(ctx, {
139
+ planPath: ctx.planPath,
140
+ reviewMdPath: ctx.reviewMdPath,
141
+ reviewJsonPath: ctx.reviewJsonPath,
142
+ cycle: ctx.cycle,
143
+ });
144
+ // CONV-5: thread the review markdown path so a loop rewind runs the implementer in `fix` mode.
145
+ return { ...verdict(review), reviewMdPath: ctx.reviewMdPath };
146
+ }
147
+ case 'manualWebUiTesting': {
148
+ const { review } = await runManualWebUiTesting(ctx, {
149
+ checklistPath: ctx.checklistPath,
150
+ reviewMdPath: ctx.reviewMdPath,
151
+ reviewJsonPath: ctx.reviewJsonPath,
152
+ cycle: ctx.cycle,
153
+ });
154
+ // CONV-5: thread the review markdown path (web-UI loop source → implementer fix mode).
155
+ return { ...verdict(review), reviewMdPath: ctx.reviewMdPath };
156
+ }
157
+ default: {
158
+ // Generic branch: a metadata-declared verifier emits the standard protocol
159
+ // verdict; thread the md path so a loop rewind reads the review (CONV-5).
160
+ const { review, reviewMdPath } = await runGenericVerifier(ctx);
161
+ return { ...verdict(review), reviewMdPath };
162
+ }
163
+ }
164
+ }
165
+
166
+ /** The runner registry: runnerType -> async (ctx) => RunnerResult. */
167
+ export const runners = { producer, verifier };