@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,708 @@
1
+ // src/core/worktree.mjs
2
+ // Per-pipeline git worktree isolation. Every helper is async, returns plain data,
3
+ // and never throws on missing files / non-zero git exits unless explicitly noted
4
+ // (createWorktree throws on a fatal git failure because the run cannot continue).
5
+ //
6
+ // Layout (legacy, the retained default):
7
+ // <projectDir>/.worca-cc/worktrees/<pipelineId>/ <- checkout
8
+ // Layout (detached, caller passes baseDir + checkoutName):
9
+ // <worcaHome>/runs/<pipelineId>/repos/<projectKey>/
10
+ // Either way the shared .git store stays in <projectDir>/.git <- no duplication.
11
+ //
12
+ // Branch naming: callers pass a fully-formed feature branch (e.g. "worca-cc/foo-abc12345")
13
+ // OR the orchestrator derives one via suggestBranchName(). sanitizeBranchName is the
14
+ // single source of truth for what reaches `git branch`.
15
+
16
+ import { spawn } from 'node:child_process';
17
+ import { existsSync } from 'node:fs';
18
+ import { mkdir, rm, realpath, readdir, rename, stat } from 'node:fs/promises';
19
+ import { basename, join, resolve, sep } from 'node:path';
20
+
21
+ import { slugify } from './artifacts.mjs';
22
+ // settings.mjs is a leaf (node builtins only), so importing the §10 flag reader here
23
+ // adds no dependency on the DB layer — worktree.mjs stays DB-free.
24
+ import { runRootMode } from './settings.mjs';
25
+ import {
26
+ readRunManifest, rmGuarded, rescueModifiedMounts, scanStrayEntries, copyRunManifestTo,
27
+ } from './run-manifest.mjs';
28
+
29
+ const WORKTREES_DIRNAME = join('.worca-cc', 'worktrees');
30
+ const BRANCH_PREFIX = 'worca-cc/';
31
+ const MAX_BRANCH_LEN = 80;
32
+ // Keep branch names short and readable: at most this many significant words
33
+ // survive into the slug (the shortId is appended after).
34
+ const MAX_BRANCH_WORDS = 6;
35
+ // Generic filler dropped from a prompt-derived slug so the name carries the
36
+ // meaningful nouns, not boilerplate. Articles, prepositions, conjunctions,
37
+ // pronouns, and the imperative scaffolding that prefixes most task prompts
38
+ // ("build a…", "let's create…"). Deliberately conservative — domain verbs like
39
+ // add/fix/refactor are kept because they carry intent.
40
+ const BRANCH_STOPWORDS = new Set([
41
+ 'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'in', 'on', 'into', 'with',
42
+ 'at', 'by', 'from', 'we', 'i', 'you', 'it', 'that', 'this', 'these', 'those',
43
+ 'lets', 'let', 'please', 'just', 'also', 'should', 'would', 'could', 'can',
44
+ 'will', 'want', 'wants', 'need', 'needs', 'make', 'build', 'create', 'some',
45
+ 'our', 'my',
46
+ ]);
47
+
48
+ // Checkout-heavy ops (worktree add/remove) legitimately exceed the default
49
+ // 30 s on large repos — give them a longer leash before the SIGKILL deadline.
50
+ const SLOW_GIT_TIMEOUT_MS = 120_000;
51
+
52
+ /** Run git and resolve to { ok, stdout, stderr, code }. Never throws. */
53
+ function git(cwd, args, { signal, timeout = 30_000 } = {}) {
54
+ return new Promise((res) => {
55
+ let child;
56
+ try {
57
+ child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], signal });
58
+ } catch (err) {
59
+ res({ ok: false, stdout: '', stderr: err.message, code: -1 });
60
+ return;
61
+ }
62
+ let stdout = '', stderr = '';
63
+ let settled = false;
64
+ const done = (val) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ res(val);
69
+ };
70
+ const timer = timeout > 0
71
+ ? setTimeout(() => {
72
+ try { child.kill('SIGKILL'); } catch {}
73
+ done({ ok: false, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out', code: -1 });
74
+ }, timeout)
75
+ : null;
76
+ child.stdout?.on('data', (b) => (stdout += b.toString()));
77
+ child.stderr?.on('data', (b) => (stderr += b.toString()));
78
+ child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
79
+ child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Reduce arbitrary text to a git-safe branch name fragment.
85
+ */
86
+ export function sanitizeBranchName(raw) {
87
+ const s = String(raw ?? '')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9/_-]+/g, '-')
90
+ .replace(/-{2,}/g, '-')
91
+ .replace(/^[-/_.]+|[-/_.]+$/g, '');
92
+ return s.slice(0, MAX_BRANCH_LEN);
93
+ }
94
+
95
+ /**
96
+ * Reduce free text to a short, meaningful kebab slug: slugify, drop generic
97
+ * stopwords, keep the first MAX_BRANCH_WORDS significant words. If stopword
98
+ * removal would empty the slug (every word was filler), keep the raw words so
99
+ * we never return nothing. Returns '' only for empty/punctuation-only input.
100
+ */
101
+ function keywordSlug(text) {
102
+ const line = firstLine(text);
103
+ if (!line) return ''; // slugify('') yields 'untitled'; let the caller pick 'feature'
104
+ const words = slugify(line).split('-').filter(Boolean);
105
+ if (!words.length) return '';
106
+ const kept = words.filter((w) => !BRANCH_STOPWORDS.has(w));
107
+ return (kept.length ? kept : words).slice(0, MAX_BRANCH_WORDS).join('-');
108
+ }
109
+
110
+ /**
111
+ * Propose a feature branch name WITHOUT an LLM (so preflight stays free and
112
+ * fully deterministic). Title-first: a caller-supplied title is the clearest
113
+ * intent, so slugify it directly; otherwise derive a keyword slug from the
114
+ * prompt's first line. Always returns a sanitized "<prefix><slug>-<shortId>".
115
+ */
116
+ export function suggestBranchName({ prompt, title, pipelineId } = {}) {
117
+ const shortId = String(pipelineId || '').slice(0, 8) || 'run';
118
+ // A title is explicit intent — slugify it verbatim (only word-cap for length),
119
+ // never strip stopwords (would mangle deliberate names like "A/B Testing").
120
+ const fromTitle = title
121
+ ? slugify(title).split('-').filter(Boolean).slice(0, MAX_BRANCH_WORDS).join('-')
122
+ : '';
123
+ const core = fromTitle || keywordSlug(prompt) || 'feature';
124
+ return sanitizeBranchName(`${BRANCH_PREFIX}${core}-${shortId}`);
125
+ }
126
+
127
+ /** All local branch names (no remotes). Empty array on a non-repo. */
128
+ export async function listLocalBranches(projectDir) {
129
+ const r = await git(projectDir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/']);
130
+ if (!r.ok) return [];
131
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
132
+ }
133
+
134
+ /** The branch HEAD currently points to in `projectDir`, or null. */
135
+ export async function currentBranch(projectDir) {
136
+ const r = await git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
137
+ if (!r.ok) return null;
138
+ const name = r.stdout.trim();
139
+ return name && name !== 'HEAD' ? name : null;
140
+ }
141
+
142
+ /**
143
+ * Best-effort default-branch resolution. HEAD branch, then init.defaultBranch,
144
+ * then origin/HEAD, then first local branch. On a detached HEAD with no local
145
+ * branches, fall back to the HEAD SHA (always a valid commit-ish for
146
+ * `worktree add`) and only then to the literal 'main' (m1).
147
+ */
148
+ export async function resolveDefaultBranch(projectDir) {
149
+ const head = await currentBranch(projectDir);
150
+ if (head) return head;
151
+ const cfg = await git(projectDir, ['config', '--get', 'init.defaultBranch']);
152
+ if (cfg.ok && cfg.stdout.trim()) return cfg.stdout.trim();
153
+ const originHead = await git(projectDir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
154
+ if (originHead.ok && originHead.stdout.trim()) {
155
+ return originHead.stdout.trim().replace(/^origin\//, '');
156
+ }
157
+ const branches = await listLocalBranches(projectDir);
158
+ if (branches.length) return branches.sort()[0];
159
+ // Detached HEAD, no branches: a raw SHA is a valid source for `worktree add`.
160
+ const sha = await git(projectDir, ['rev-parse', 'HEAD']);
161
+ if (sha.ok && sha.stdout.trim()) return sha.stdout.trim();
162
+ return 'main';
163
+ }
164
+
165
+ /**
166
+ * True iff `ref` resolves to a commit in `projectDir`. Rejects any value
167
+ * beginning with '-' (would be parsed as a git option). The single source of
168
+ * truth for "is this a usable worktree source" — used both by createWorktree
169
+ * (throws) and the API (clean 400) so an injected `--force`/`-q`/unknown ref
170
+ * never reaches `git worktree add`. (M1)
171
+ */
172
+ export async function isValidSourceRef(projectDir, ref) {
173
+ if (typeof ref !== 'string' || !ref || /^-/.test(ref)) return false;
174
+ const r = await git(projectDir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
175
+ return r.ok && !!r.stdout.trim();
176
+ }
177
+
178
+ /**
179
+ * Parse `git worktree list --porcelain` and return the path of the worktree
180
+ * that currently has `branch` checked out, or null. Used to detect the
181
+ * "branch already in use" conflict before a reuse `worktree add` (M2).
182
+ */
183
+ export async function worktreePathForBranch(projectDir, branch) {
184
+ const r = await git(projectDir, ['worktree', 'list', '--porcelain']);
185
+ if (!r.ok) return null;
186
+ let curPath = null;
187
+ for (const line of r.stdout.split(/\r?\n/)) {
188
+ if (line.startsWith('worktree ')) curPath = line.slice('worktree '.length).trim();
189
+ else if (line.startsWith('branch ')) {
190
+ const ref = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
191
+ if (ref === branch) return curPath;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Create a worktree checking out a new branch <featureBranch> off <sourceBranch>.
199
+ * When the branch already exists locally we attach to it instead of forking (resume
200
+ * semantics) and set reusedExisting=true.
201
+ *
202
+ * Placement: `<baseDir>/<checkoutName>` when both are supplied (the detached run
203
+ * root: `<worcaHome>/runs/<pipelineId>/repos/<projectKey>`), else the retained
204
+ * legacy default `<projectDir>/.worca-cc/worktrees/<pipelineId>` (§10 rollback).
205
+ * Git mechanics are safe out of tree: the shared object store stays in
206
+ * `<projectDir>/.git` and the worktree's gitdir pointers are absolute.
207
+ *
208
+ * @param {object} args
209
+ * @param {string} [args.baseDir] parent dir for the checkout (default: legacy base)
210
+ * @param {string} [args.checkoutName] checkout dir name (default: pipelineId)
211
+ */
212
+ export async function createWorktree({
213
+ projectDir, pipelineId, sourceBranch, featureBranch, signal, baseDir, checkoutName,
214
+ }) {
215
+ if (!projectDir) throw new Error('projectDir required');
216
+ if (!pipelineId) throw new Error('pipelineId required');
217
+ if (!sourceBranch) throw new Error('sourceBranch required');
218
+ // S2: pipelineId becomes a path segment and is later passed to a recursive
219
+ // remove — reject anything that could escape the worktrees base.
220
+ if (!/^[A-Za-z0-9._-]+$/.test(pipelineId) || pipelineId === '.' || pipelineId === '..') {
221
+ throw new Error(`invalid pipelineId: ${JSON.stringify(pipelineId)}`);
222
+ }
223
+ const branch = sanitizeBranchName(featureBranch);
224
+ if (!branch) throw new Error('featureBranch resolves to empty after sanitize');
225
+ // Compare sanitized forms so case/format variants of the same name don't slip past.
226
+ if (branch === sanitizeBranchName(sourceBranch)) {
227
+ throw new Error(`featureBranch and sourceBranch both resolve to "${branch}" — they must differ`);
228
+ }
229
+
230
+ const base = baseDir
231
+ ? resolve(baseDir)
232
+ : join(resolve(projectDir), WORKTREES_DIRNAME); // legacy path retained for rollback
233
+ await mkdir(base, { recursive: true });
234
+ // Canonicalize the base so worktreeDir matches what `git worktree list`
235
+ // reports (git emits realpaths). Without this the M2 self-equality check below
236
+ // mis-fires on symlinked roots (e.g. macOS /tmp -> /private/tmp).
237
+ const baseReal = await realpath(base).catch(() => resolve(base));
238
+ const name = checkoutName || pipelineId;
239
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
240
+ throw new Error(`invalid checkout name: ${JSON.stringify(name)}`);
241
+ }
242
+ const worktreeDir = join(baseReal, name);
243
+ // Belt-and-braces: the sanitized name can't traverse, but assert containment
244
+ // so a future caller can't turn this into a delete-anything primitive.
245
+ if (!worktreeDir.startsWith(baseReal + sep)) {
246
+ throw new Error(`worktree path escapes base: ${worktreeDir}`);
247
+ }
248
+
249
+ const branches = await listLocalBranches(projectDir);
250
+ const reusedExisting = branches.includes(branch);
251
+
252
+ let args;
253
+ if (reusedExisting) {
254
+ // M2: git forbids checking out one branch in two worktrees at once. Reap
255
+ // stale registrations first; if a *live* worktree still holds it, fail with
256
+ // an actionable message rather than a raw exit-128 mid-pipeline.
257
+ await git(projectDir, ['worktree', 'prune']);
258
+ const inUse = await worktreePathForBranch(projectDir, branch);
259
+ if (inUse && resolve(inUse) !== resolve(worktreeDir)) {
260
+ throw new Error(`branch "${branch}" is already checked out in worktree ${inUse}`);
261
+ }
262
+ args = ['worktree', 'add', '--', worktreeDir, branch];
263
+ } else {
264
+ // M1: validate the source resolves to a real commit (rejects leading-dash
265
+ // option injection and unknown refs); '--' stops git parsing the trailing
266
+ // positionals as options as a second line of defense.
267
+ if (!(await isValidSourceRef(projectDir, sourceBranch))) {
268
+ throw new Error(`sourceBranch is not a valid ref: ${JSON.stringify(sourceBranch)}`);
269
+ }
270
+ args = ['worktree', 'add', '-b', branch, '--', worktreeDir, sourceBranch];
271
+ }
272
+ const r = await git(projectDir, args, { signal, timeout: SLOW_GIT_TIMEOUT_MS });
273
+ if (!r.ok) {
274
+ const err = new Error(`git worktree add failed: ${r.stderr.trim() || `exit ${r.code}`}`);
275
+ // The ONE abort path that surfaced as a PLAIN error: on signal abort Node
276
+ // kills the spawned git and git() resolves ok:false (spawn's 'error' yields
277
+ // "The operation was aborted" — or whatever git wrote before dying, which
278
+ // may not mention the abort at all). Stamp the name every other abort/stop
279
+ // site sets so isAbort callers classify the stop as a stop, not a failure.
280
+ if (signal?.aborted) err.name = 'AbortError';
281
+ throw err;
282
+ }
283
+ return { worktreeDir, branch, sourceBranch, reusedExisting };
284
+ }
285
+
286
+ /**
287
+ * Remove a worktree dir + (optionally) its branch. Returns
288
+ * { ok, steps: [{ step, ok, stderr }] } so callers can log/assert — failures are
289
+ * never silently swallowed (M3).
290
+ *
291
+ * IMPORTANT: the non-force path only succeeds on a *pristine* checkout — git
292
+ * refuses to remove a worktree with modified/untracked files. Agents always
293
+ * edit files, so teardown of an agent-run worktree MUST pass force:true or it
294
+ * leaks. With force:true the dir is also removed directly as a backstop, and
295
+ * the registration is pruned so a later reuse of the branch won't trip M2.
296
+ */
297
+ export async function removeWorktree({ projectDir, worktreeDir, branch, force = false } = {}) {
298
+ const steps = [];
299
+ if (worktreeDir) {
300
+ const args = force
301
+ ? ['worktree', 'remove', '--force', worktreeDir]
302
+ : ['worktree', 'remove', worktreeDir];
303
+ const r = await git(projectDir, args, { timeout: SLOW_GIT_TIMEOUT_MS });
304
+ steps.push({ step: 'worktree-remove', ok: r.ok, stderr: r.stderr.trim() });
305
+ if (force) {
306
+ const fsRes = await rm(worktreeDir, { recursive: true, force: true })
307
+ .then(() => null)
308
+ .catch((e) => e.message);
309
+ if (fsRes) steps.push({ step: 'rm-dir', ok: false, stderr: fsRes });
310
+ }
311
+ // Reap the (now-missing) registration so it can't collide on reuse.
312
+ await git(projectDir, ['worktree', 'prune']);
313
+ }
314
+ if (branch) {
315
+ const r = await git(projectDir, ['branch', force ? '-D' : '-d', branch]);
316
+ steps.push({ step: 'branch-delete', ok: r.ok, stderr: r.stderr.trim() });
317
+ }
318
+ return { ok: steps.every((s) => s.ok), steps };
319
+ }
320
+
321
+ /**
322
+ * Stage every remaining change and render a binary-capable patch against HEAD
323
+ * DIRECTLY INTO outFile (no in-memory patch string — agent-created artifacts can
324
+ * be huge). Staging is intentional: it makes untracked files part of the patch.
325
+ * Uses the slow git timeout: big binary diffs legitimately take time.
326
+ * Crash-safe: streams to `<outFile>.part` and renames on success, so a
327
+ * SIGKILL/timeout/full disk can never leave a TRUNCATED patch under the final
328
+ * name. A clean tree is SUCCESS with `file: null` (nothing to save is not a
329
+ * failure — discard after a manual commit relies on it). A git failure returns
330
+ * without removing anything so the checkout stays authoritative.
331
+ */
332
+ export async function snapshotWorktreePatch(worktreeDir, outFile) {
333
+ if (!worktreeDir || !outFile) {
334
+ return { ok: false, step: 'path', message: 'worktreeDir and outFile are required' };
335
+ }
336
+ const add = await git(worktreeDir, ['add', '-A'], { timeout: SLOW_GIT_TIMEOUT_MS });
337
+ if (!add.ok) {
338
+ return { ok: false, step: 'add', message: add.stderr.trim() || `exit ${add.code}`, fromStderr: !!add.stderr.trim() };
339
+ }
340
+ const part = `${outFile}.part`;
341
+ const diff = await git(worktreeDir, ['diff', '--binary', `--output=${part}`, 'HEAD', '--'],
342
+ { timeout: SLOW_GIT_TIMEOUT_MS });
343
+ if (!diff.ok) {
344
+ await rm(part, { force: true }).catch(() => {});
345
+ return { ok: false, step: 'diff', message: diff.stderr.trim() || `exit ${diff.code}`, fromStderr: !!diff.stderr.trim() };
346
+ }
347
+ let bytes = 0;
348
+ try { bytes = (await stat(part)).size; } catch { /* treated as empty below */ }
349
+ if (!bytes) {
350
+ // Nothing uncommitted: a 0-byte "recovery patch" on disk would be a lie.
351
+ await rm(part, { force: true }).catch(() => {});
352
+ return { ok: true, file: null, bytes: 0 };
353
+ }
354
+ await rename(part, outFile);
355
+ return { ok: true, file: outFile, bytes };
356
+ }
357
+
358
+ function firstLine(text) {
359
+ if (!text) return '';
360
+ for (const line of String(text).split(/\r?\n/)) {
361
+ const t = line.replace(/^#+\s*/, '').trim();
362
+ if (t) return t;
363
+ }
364
+ return '';
365
+ }
366
+
367
+ // ── run-root sweeps (§8.12) ───────────────────────────────────────────────────
368
+ //
369
+ // The ACTIVE set. `paused` and `interrupted` are the two RESUMABLE statuses
370
+ // (the resume route rejects everything else — ui/server.mjs), `running` and
371
+ // `pausing` are LIVE (the live-entry guard treats them as non-terminal). All four
372
+ // must keep their run roots. Stated POSITIVELY — never "terminal ⇒ delete": at boot
373
+ // reconcileStaleRunning stamps every stale `running` row → `interrupted`, and
374
+ // `interrupted` runs ARE resumable, so a "not paused and not running ⇒ terminal"
375
+ // rule would destroy the uncommitted work of every crashed run at the exact boot
376
+ // that made it resumable (_commitWork runs only at teardown; _buildResults only on
377
+ // the done path — there is no patch artifact to fall back on).
378
+ export const RUN_ROOT_KEEP = new Set(['running', 'pausing', 'paused', 'interrupted']);
379
+ export const RUN_ROOT_REMOVE = new Set(['done', 'stopped', 'error']);
380
+
381
+ /**
382
+ * Sweep `<worcaHome>/runs/*`: keep active run roots, reclaim terminal ones,
383
+ * quarantine anything undecidable. Called at server boot (AFTER
384
+ * reconcileStaleRunning — the ordering is pinned, §8.12) and by `worca doctor`.
385
+ *
386
+ * `worktree.mjs` stays DB-FREE: every pipelines-row lookup is an INJECTED callback
387
+ * owned by the caller (server boot / doctor), which also makes this helper
388
+ * unit-testable with plain stubs.
389
+ *
390
+ * **A LOOKUP FAILURE IS NOT "NO ROW".** `statusOf` must return a status string for a
391
+ * row that exists, `null` only when the row is VERIFIABLY absent, and THROW when the
392
+ * lookup itself could not be performed. The distinction is load-bearing: the row-less
393
+ * disposition is *reclaim*, so a DB that cannot be opened (corrupt file, ABI mismatch
394
+ * after a Node upgrade, bad permissions) collapsing to null would force-remove the
395
+ * worktrees and rm -rf the run roots of every `paused`/`interrupted` run at the next
396
+ * boot — the §8.12 catastrophe, on the error path. A throw therefore skips that run
397
+ * root untouched (no rename, no removal: a transient DB problem must not
398
+ * orphan-quarantine everything either) and is reported in `failed` + logged loudly.
399
+ * The same applies to `membersOf`: if we cannot enumerate what to clean up, we do not
400
+ * remove anything.
401
+ *
402
+ * @param {object} args
403
+ * @param {string} args.worcaHome worcaHome() — `<home>/.worca-cc`
404
+ * @param {Function} args.statusOf (id) => status string | null (null = row
405
+ * verifiably absent); THROWS on lookup failure
406
+ * @param {Function} [args.membersOf] (id) => Promise<[{projectDir, worktreeDir}]> | null
407
+ * DB fallback used only when run.json is
408
+ * missing; THROWS on lookup failure
409
+ * @param {Function} [args.pipelineDirOf] (id) => Promise<string|null> — the durable
410
+ * artifact dir, for the rescue-before-remove
411
+ * triple (§8.11/§8.20/§5.2). Injected for the
412
+ * same DB-free reason as the two above. May
413
+ * degrade to null: it is consulted after the
414
+ * disposition and only names a copy target.
415
+ * @param {Function} [args.retainOf] (id) => retained-work record | null. DB
416
+ * fallback when the manifest is absent or
417
+ * its recorded worktrees are no longer live.
418
+ * @param {Function} [args.log] (level, message) sink; defaults to console
419
+ * @returns {Promise<{keep:string[], removed:string[], quarantined:string[],
420
+ * failed:string[], warnings:string[]}>}
421
+ */
422
+ export async function sweepRunRoots({
423
+ worcaHome, statusOf, membersOf, pipelineDirOf, retainOf, log,
424
+ } = {}) {
425
+ const out = { keep: [], removed: [], quarantined: [], failed: [], warnings: [] };
426
+ const say = typeof log === 'function'
427
+ ? log
428
+ : (level, msg) => { (level === 'warn' ? console.warn : console.log)(`[worca] run-root sweep: ${msg}`); };
429
+ if (!worcaHome) return out;
430
+ const runsBase = join(worcaHome, 'runs');
431
+ let entries;
432
+ try { entries = await readdir(runsBase, { withFileTypes: true }); } catch { return out; }
433
+
434
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
435
+ if (!entry.isDirectory()) continue;
436
+ // A previously quarantined root is skipped entirely, so nothing is re-logged
437
+ // forever and nothing is deleted on a guess.
438
+ if (/\.orphan-/.test(entry.name)) continue;
439
+ const dir = join(runsBase, entry.name);
440
+ const manifest = await readRunManifest(dir); // may be null
441
+ const id = manifest?.pipelineId || basename(dir);
442
+
443
+ // Three states, never two: status string / verifiably absent (null) / lookup
444
+ // FAILED. Only "verifiably absent" may reach the reclaim path below.
445
+ let status;
446
+ try {
447
+ if (typeof statusOf !== 'function') throw new Error('statusOf callback is required');
448
+ status = statusOf(id);
449
+ } catch (err) {
450
+ const reason = `skip ${dir}: pipelines-row lookup FAILED (${err?.message || err}) — leaving it untouched`;
451
+ out.failed.push(dir);
452
+ out.warnings.push(reason);
453
+ say('warn', reason);
454
+ continue; // never guess: a lookup failure removes and renames nothing
455
+ }
456
+
457
+ if (status && RUN_ROOT_KEEP.has(status)) {
458
+ out.keep.push(dir);
459
+ say('info', `keep ${dir} (${status})`);
460
+ continue;
461
+ }
462
+
463
+ // A manifest retain record is live only while at least one named checkout
464
+ // still exists. This makes manual recovery/removal self-clearing instead of
465
+ // leaking the run root forever. The DB callback is an independent fallback.
466
+ const manifestRetain = manifest?.retain;
467
+ const manifestMembers = Array.isArray(manifestRetain?.members) ? manifestRetain.members : [];
468
+ let retained = manifestMembers.some((m) => m?.worktreeDir && existsSync(m.worktreeDir))
469
+ ? manifestRetain
470
+ : null;
471
+ // Same three-state doctrine as statusOf/membersOf: "retention unknown" must
472
+ // never collapse into "remove", so a lookup throw skips this root untouched
473
+ // and is reported in `failed` + logged loudly (it must NOT abort the sweep —
474
+ // this call site is outside the statusOf try/catch).
475
+ if (!retained && typeof retainOf === 'function') {
476
+ try {
477
+ retained = await retainOf(id);
478
+ } catch (err) {
479
+ const reason = `skip ${dir}: retention lookup FAILED (${err?.message || err}) — leaving it untouched`;
480
+ out.failed.push(dir);
481
+ out.warnings.push(reason);
482
+ say('warn', reason);
483
+ continue;
484
+ }
485
+ }
486
+ if (retained) {
487
+ out.keep.push(dir);
488
+ say('info', `keep ${dir} (${status || 'no row'}, retained: ${retained.reason || 'unknown'})`);
489
+ continue;
490
+ }
491
+ if (status && !RUN_ROOT_REMOVE.has(status)) {
492
+ out.quarantined.push(dir);
493
+ say('warn', `quarantine ${dir}: unknown status ${status}`);
494
+ continue;
495
+ }
496
+
497
+ // status ∈ REMOVE, or the row is gone (deleted pipeline): reclaim.
498
+ if (status) {
499
+ // rescue-before-remove runs ONLY when a pipelines row exists (its artifact
500
+ // dir is derivable). Row-less roots skip it — the pipeline was deliberately
501
+ // deleted and its artifact dir is gone.
502
+ let pipelineDir = null;
503
+ try { pipelineDir = pipelineDirOf ? await pipelineDirOf(id) : null; } catch { pipelineDir = null; }
504
+ const injected = manifest?.injectedPaths || {};
505
+ for (const [scope, list] of Object.entries(injected)) {
506
+ const baseDir = scope === 'runRoot'
507
+ ? dir
508
+ : (manifest?.members || []).find((m) => m?.projectKey === scope)?.worktreeDir || join(dir, 'repos', scope);
509
+ const w = await rescueModifiedMounts({
510
+ baseDir, entries: list, pipelineDir, scope, pipelineId: id,
511
+ });
512
+ out.warnings.push(...w);
513
+ for (const line of w) say('warn', `${id}: ${line}`);
514
+ }
515
+ const strays = await scanStrayEntries({ runRoot: dir, pipelineDir });
516
+ out.warnings.push(...strays);
517
+ for (const line of strays) say('warn', `${id}: ${line}`);
518
+ await copyRunManifestTo(dir, pipelineDir);
519
+ } else if (!manifest) {
520
+ // Row-less AND no readable manifest: quarantine ONCE by renaming, rather
521
+ // than deleting on a guess. Later sweeps skip `*.orphan-*` entirely.
522
+ const dest = `${dir}.orphan-${Date.now()}`;
523
+ try {
524
+ await rename(dir, dest);
525
+ out.quarantined.push(dest);
526
+ say('warn', `quarantine ${dir} -> ${basename(dest)}: no pipelines row and no readable run.json`);
527
+ } catch (err) {
528
+ out.warnings.push(`quarantine rename failed for ${dir}: ${err?.message || err}`);
529
+ say('warn', `quarantine rename failed for ${dir}: ${err?.message || err}`);
530
+ }
531
+ continue;
532
+ }
533
+
534
+ // Same three-state rule for the DB fallback: if we cannot enumerate the members
535
+ // we must not remove anything, or the run root would be rm -rf'd while its
536
+ // worktrees stayed registered in the real repos.
537
+ let members = manifest?.members ?? null;
538
+ if (!members && membersOf) {
539
+ try {
540
+ members = await membersOf(id);
541
+ } catch (err) {
542
+ const reason = `skip ${dir}: member lookup FAILED (${err?.message || err}) — leaving it untouched`;
543
+ out.failed.push(dir);
544
+ out.warnings.push(reason);
545
+ say('warn', reason);
546
+ continue;
547
+ }
548
+ }
549
+ members = members ?? [];
550
+ for (const m of members) {
551
+ if (!m?.projectDir || !m?.worktreeDir) continue;
552
+ await removeWorktree({ projectDir: m.projectDir, worktreeDir: m.worktreeDir, branch: null, force: true });
553
+ }
554
+ const res = await rmGuarded(dir, { worcaHome, pipelineId: basename(dir) });
555
+ if (res.removed) {
556
+ out.removed.push(dir);
557
+ say('info', `removed ${dir}${status ? ` (${status})` : ' (no pipelines row)'}`);
558
+ } else {
559
+ out.warnings.push(res.reason || `removal refused for ${dir}`);
560
+ say('warn', res.reason || `removal refused for ${dir}`);
561
+ }
562
+ }
563
+ return out;
564
+ }
565
+
566
+ /**
567
+ * One-time migration sweep of the LEGACY worktree base
568
+ * `<projectDir>/.worca-cc/worktrees/*` for a registered project. DEFINED + unit
569
+ * tested in Phase 1; wired at server boot in Phase 7.
570
+ *
571
+ * "Leftover" is defined, not assumed: the directory basename IS the pipelineId, so
572
+ * each candidate is looked up in `pipelines` before anything is removed; a candidate
573
+ * is SKIPPED when its status is in RUN_ROOT_KEEP or when `referencedPaths` still
574
+ * names that path (a run may have been recorded under a different id shape); it is
575
+ * removed only for RUN_ROOT_REMOVE statuses; anything else is quarantine-logged.
576
+ *
577
+ * **A TOTAL NO-OP while the effective run-root mode is `legacy`** — under legacy
578
+ * `<projectDir>/.worca-cc/worktrees/<id>` is the LIVE location of every active run,
579
+ * so sweeping there would make the documented rollback self-destroying (run legacy →
580
+ * pause → restart → the paused run's checkout is deleted and resume() hard-fails
581
+ * with the agent's uncommitted work gone). This is a migration step for trees left
582
+ * behind by the flip, and it only ever runs when the flip is in effect.
583
+ *
584
+ * **A LOOKUP FAILURE IS NOT "NO ROW"** — the same three-state rule sweepRunRoots
585
+ * documents above: `statusOf` returns a status for a row that exists, `null` only
586
+ * when the row is VERIFIABLY absent, and THROWS when the lookup could not be
587
+ * performed. A throw skips that candidate untouched and is reported in `failed` +
588
+ * logged loudly, instead of collapsing into the row-less quarantine-log (which would
589
+ * misreport "the DB is unreadable" as "these runs were deleted").
590
+ *
591
+ * @param {string} projectDir
592
+ * @param {object} args
593
+ * @param {Function} args.statusOf (id) => status | null; THROWS on lookup failure
594
+ * @param {Function|string} [args.mode] injected effective mode; DEFAULTS to the
595
+ * real runRootMode() reader, so the Phase-7
596
+ * boot call needs only { statusOf }
597
+ * @param {Set<string>|string[]} [args.referencedPaths] worktree paths any row still
598
+ * claims (state.branch.worktreeDir /
599
+ * state.branches[*].worktreeDir /
600
+ * workspace_meta); injected because
601
+ * worktree.mjs stays DB-free
602
+ * @param {Function} [args.log]
603
+ */
604
+ export async function sweepLegacyWorktrees(projectDir, {
605
+ statusOf, mode = runRootMode, referencedPaths, log,
606
+ } = {}) {
607
+ const out = { keep: [], removed: [], quarantined: [], failed: [], warnings: [], skipped: false };
608
+ const say = typeof log === 'function'
609
+ ? log
610
+ : (level, msg) => { (level === 'warn' ? console.warn : console.log)(`[worca] legacy sweep: ${msg}`); };
611
+ const effective = typeof mode === 'function' ? mode() : mode;
612
+ if (effective !== 'detached') {
613
+ out.skipped = true; // §10: never sweep the live legacy base
614
+ return out;
615
+ }
616
+ if (!projectDir) return out;
617
+ const base = join(resolve(projectDir), WORKTREES_DIRNAME);
618
+ const referenced = new Set(
619
+ [...(referencedPaths || [])].filter(Boolean).map((p) => resolve(p)),
620
+ );
621
+ let entries;
622
+ try { entries = await readdir(base, { withFileTypes: true }); } catch { return out; }
623
+
624
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
625
+ if (!entry.isDirectory()) continue;
626
+ const dir = join(base, entry.name);
627
+ if (referenced.has(resolve(dir))) {
628
+ out.keep.push(dir);
629
+ say('info', `keep ${dir} (still referenced by a pipelines row)`);
630
+ continue;
631
+ }
632
+ const id = entry.name; // worktree.mjs names the dir by pipelineId
633
+ // Three states, never two: status string / verifiably absent (null) / lookup
634
+ // FAILED. Only "verifiably absent" may reach the quarantine-log below.
635
+ let status;
636
+ try {
637
+ if (typeof statusOf !== 'function') throw new Error('statusOf callback is required');
638
+ status = statusOf(id);
639
+ } catch (err) {
640
+ const reason = `skip ${dir}: pipelines-row lookup FAILED (${err?.message || err}) — leaving it untouched`;
641
+ out.failed.push(dir);
642
+ out.warnings.push(reason);
643
+ say('warn', reason);
644
+ continue; // never guess: a lookup failure removes and quarantine-logs nothing
645
+ }
646
+ if (status && RUN_ROOT_KEEP.has(status)) {
647
+ out.keep.push(dir);
648
+ say('info', `keep ${dir} (${status})`);
649
+ continue;
650
+ }
651
+ if (!status || !RUN_ROOT_REMOVE.has(status)) {
652
+ out.quarantined.push(dir);
653
+ say('warn', `quarantine ${dir}: ${status ? `unknown status ${status}` : 'no pipelines row'}`);
654
+ continue;
655
+ }
656
+ const res = await removeWorktree({ projectDir, worktreeDir: dir, branch: null, force: true });
657
+ out.removed.push(dir);
658
+ for (const s of res.steps.filter((x) => !x.ok)) {
659
+ out.warnings.push(`${entry.name}: ${s.step}: ${s.stderr || 'failed'}`);
660
+ }
661
+ say('info', `removed ${dir} (${status})`);
662
+ }
663
+ return out;
664
+ }
665
+
666
+ /**
667
+ * Fan `sweepLegacyWorktrees` out over a list of project dirs — the projects registry
668
+ * (`projects.mjs#listProjects`) at server boot and in `worca doctor` — aggregating
669
+ * every disposition into one report. Both wirings share this so the aggregation and
670
+ * the mode gate can never drift between them, while each keeps its own printing.
671
+ *
672
+ * Takes plain dirs plus the INJECTED lookups (`artifacts.mjs#legacySweepLookups`), so
673
+ * worktree.mjs stays DB-free. `mode` is resolved ONCE here and passed down verbatim:
674
+ * one boot can never sweep half the registry in one mode and half in the other.
675
+ *
676
+ * Idempotent by construction — every disposition is decided from the caller's row
677
+ * snapshot, and a second pass finds the removed dirs already gone — so re-running it
678
+ * at every boot, or during a §10 rollback, is safe. A dir with no
679
+ * `.worca-cc/worktrees` (or no such project on disk) contributes nothing, silently.
680
+ *
681
+ * @param {string[]} projectDirs
682
+ * @param {object} args statusOf / referencedPaths / log forwarded verbatim
683
+ * @param {Function|string} [args.mode]
684
+ * @returns {Promise<{projects:number, skipped:boolean, keep:string[], removed:string[],
685
+ * quarantined:string[], failed:string[], warnings:string[]}>}
686
+ */
687
+ export async function sweepLegacyWorktreesAll(projectDirs, {
688
+ statusOf, mode = runRootMode, referencedPaths, log,
689
+ } = {}) {
690
+ const out = {
691
+ projects: 0, skipped: false,
692
+ keep: [], removed: [], quarantined: [], failed: [], warnings: [],
693
+ };
694
+ const effective = typeof mode === 'function' ? mode() : mode;
695
+ if (effective !== 'detached') {
696
+ out.skipped = true; // §10: under legacy those paths hold every live and paused run
697
+ return out;
698
+ }
699
+ for (const dir of projectDirs || []) {
700
+ if (!dir) continue;
701
+ const res = await sweepLegacyWorktrees(dir, { statusOf, mode: effective, referencedPaths, log });
702
+ out.projects += 1;
703
+ for (const key of ['keep', 'removed', 'quarantined', 'failed', 'warnings']) {
704
+ out[key].push(...(res[key] || []));
705
+ }
706
+ }
707
+ return out;
708
+ }