@worca/app 1.0.0 → 1.2.0-rc.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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,285 @@
1
+ // src/core/ask/worktrees.mjs
2
+ // Per-thread detached git worktrees of the Ask Worca chat
3
+ // (ask-worca-worktrees-design.md §3-§5): registry rows in ask_worktrees,
4
+ // checkouts under <worcaHome>/ask/<threadId>/wt/<wtId>. Git mechanics come from
5
+ // ../worktree.mjs (DB-free primitives); this module owns rows, paths, caps and
6
+ // the sweep. Synchronous DB via getDb()/prepare() — the store.mjs conventions;
7
+ // ids are shape-checked before they reach any path (the store.mjs doctrine:
8
+ // these paths feed recursive removes).
9
+ import { randomBytes } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
11
+ import { realpath } from 'node:fs/promises';
12
+ import { join, sep } from 'node:path';
13
+ import { getDb, prepare } from '../db.mjs';
14
+ import { listProjects } from '../projects.mjs';
15
+ import { readStoreMeta, findPipelineRowById } from '../artifacts.mjs';
16
+ import { branchExists } from '../git-info.mjs';
17
+ import { createDetachedWorktree, removeWorktree, worktreeHead, isValidSourceRef } from '../worktree.mjs';
18
+ import { ASK_ID_RE, askRoot } from './store.mjs';
19
+ import { ASK_LIMITS } from './limits.mjs';
20
+
21
+ export const WT_ID_RE = /^wt_[0-9a-f]{8}$/;
22
+ // The shared ASK_ID_RE (store.mjs) is the loose /^[a-z]+_[0-9a-f]{8}$/; a thread
23
+ // id that becomes a filesystem path is re-checked here against the strict
24
+ // ask_-anchored form (matches askWorktreeAllowRules), so a `wt_…`/other-prefix id
25
+ // can never reach a path.
26
+ const ASK_THREAD_RE = /^ask_[0-9a-f]{8}$/;
27
+
28
+ export class AskWorktreeError extends Error {
29
+ constructor(message) { super(message); this.name = 'AskWorktreeError'; }
30
+ }
31
+
32
+ const newWtId = () => `wt_${randomBytes(4).toString('hex')}`;
33
+ const now = () => new Date().toISOString();
34
+ const parse = (v, fallback) => { if (v == null) return fallback; try { return JSON.parse(v); } catch { return fallback; } };
35
+
36
+ /**
37
+ * `<askRoot>/<threadId>/wt` — refuses a thread id the store never minted, and
38
+ * REALPATHS the dir once it exists. openAskWorktree stores a realpath'd
39
+ * worktree_dir (git emits realpaths; the base is realpath'd), so without this
40
+ * every later `worktreeDirFor`/lookup would MISS on a symlinked home (macOS
41
+ * `/var` -> `/private/var`, any home on a symlinked mount).
42
+ */
43
+ export function worktreesDir(threadId) {
44
+ if (typeof threadId !== 'string' || !ASK_THREAD_RE.test(threadId)) {
45
+ throw new Error('worktreesDir: refusing a thread id the store never minted');
46
+ }
47
+ const dir = join(askRoot(), threadId, 'wt');
48
+ try { return realpathSync(dir); } catch { return dir; } // realpath'd once it exists
49
+ }
50
+
51
+ /** `<askRoot>/<threadId>/wt/<wtId>` — both ids shape-checked. */
52
+ export function worktreeDirFor(threadId, wtId) {
53
+ if (typeof wtId !== 'string' || !WT_ID_RE.test(wtId)) {
54
+ throw new Error('worktreeDirFor: refusing a worktree id the store never minted');
55
+ }
56
+ return join(worktreesDir(threadId), wtId);
57
+ }
58
+
59
+ function rowToWorktree(r) {
60
+ return {
61
+ worktreeId: r.id, threadId: r.thread_id, projectKey: r.project_key,
62
+ projectDir: r.project_dir, ref: r.ref, commit: r.resolved_commit,
63
+ runId: r.run_id ?? null, path: r.worktree_dir,
64
+ createdAt: r.created_at, updatedAt: r.updated_at,
65
+ };
66
+ }
67
+
68
+ export function listAskWorktrees(threadId) {
69
+ getDb();
70
+ return prepare('SELECT * FROM ask_worktrees WHERE thread_id = ? ORDER BY created_at, id')
71
+ .all(threadId).map(rowToWorktree);
72
+ }
73
+
74
+ export function getAskWorktree(threadId, wtId) {
75
+ getDb();
76
+ const r = prepare('SELECT * FROM ask_worktrees WHERE thread_id = ? AND id = ?').get(threadId, wtId);
77
+ return r ? rowToWorktree(r) : null;
78
+ }
79
+
80
+ /** (projectKey, ref) directly, or runId → the record's feature branch;
81
+ * workspace records need projectKey to pick the member (§5 steps 1-3). */
82
+ async function resolveTarget({ projectKey, ref, runId }) {
83
+ if (runId) {
84
+ const row = findPipelineRowById(runId);
85
+ if (!row) throw new AskWorktreeError(`run not found: ${runId}`);
86
+ const isWs = row.target === 'workspace' || !!row.workspace_key;
87
+ if (isWs) {
88
+ const meta = parse(row.workspace_meta, {}) || {};
89
+ const members = Array.isArray(meta.projects) ? meta.projects : [];
90
+ if (!projectKey) {
91
+ throw new AskWorktreeError(`run ${runId} is a workspace run — pass projectKey to pick the member (one of: ${members.map((m) => m.projectKey).join(', ') || 'none'})`);
92
+ }
93
+ const member = members.find((m) => m.projectKey === projectKey);
94
+ const b = (meta.branches || {})[projectKey];
95
+ if (!member || !b || !b.feature) throw new AskWorktreeError(`run ${runId} has no member ${projectKey} with a feature branch`);
96
+ if (!(await branchExists(member.projectDir, b.feature))) {
97
+ throw new AskWorktreeError(`branch ${b.feature} no longer exists — use get_run_diff for the recorded diff`);
98
+ }
99
+ return { projectKey, projectDir: member.projectDir, ref: b.feature, runId };
100
+ }
101
+ const branch = parse(row.branch, {}) || {};
102
+ if (!branch.feature) throw new AskWorktreeError(`run ${runId} has no feature branch`);
103
+ const meta = readStoreMeta(row.project_key);
104
+ const projectDir = meta && meta.path;
105
+ if (!projectDir) throw new AskWorktreeError(`run ${runId}: project directory unknown`);
106
+ if (!(await branchExists(projectDir, branch.feature))) {
107
+ throw new AskWorktreeError(`branch ${branch.feature} no longer exists — use get_run_diff for the recorded diff`);
108
+ }
109
+ return { projectKey: row.project_key, projectDir, ref: branch.feature, runId };
110
+ }
111
+ if (!projectKey || !ref) throw new AskWorktreeError('open_worktree: give (projectKey and ref) or runId');
112
+ const projects = await listProjects();
113
+ const p = projects.find((x) => x.key === projectKey);
114
+ if (!p) throw new AskWorktreeError(`unknown projectKey: ${projectKey} — see list_projects`);
115
+ return { projectKey, projectDir: p.path, ref, runId: null };
116
+ }
117
+
118
+ export async function openAskWorktree({ threadId, projectKey, ref, runId, signal } = {}) {
119
+ getDb();
120
+ if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)
121
+ || !prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
122
+ throw new AskWorktreeError('unknown thread');
123
+ }
124
+ const perThread = prepare('SELECT count(*) AS n FROM ask_worktrees WHERE thread_id = ?').get(threadId).n;
125
+ if (perThread >= ASK_LIMITS.worktreesPerThread) {
126
+ throw new AskWorktreeError(`worktree cap reached (${ASK_LIMITS.worktreesPerThread} per chat) — remove one with remove_worktree, or reuse one from list_worktrees`);
127
+ }
128
+ const globalCount = prepare('SELECT count(*) AS n FROM ask_worktrees').get().n;
129
+ if (globalCount >= ASK_LIMITS.worktreesGlobal) {
130
+ throw new AskWorktreeError(`global worktree cap reached (${ASK_LIMITS.worktreesGlobal}) — remove unused worktrees first`);
131
+ }
132
+ const t = await resolveTarget({
133
+ projectKey: projectKey || undefined, ref: ref || undefined, runId: runId || undefined,
134
+ });
135
+ if (!existsSync(join(t.projectDir, '.git'))) {
136
+ throw new AskWorktreeError(`project ${t.projectKey} has no git repository at ${t.projectDir}`);
137
+ }
138
+ if (!(await isValidSourceRef(t.projectDir, t.ref))) {
139
+ throw new AskWorktreeError(`ref does not resolve: ${JSON.stringify(t.ref)}`);
140
+ }
141
+ const wtId = newWtId();
142
+ mkdirSync(worktreesDir(threadId), { recursive: true });
143
+ // Re-read AFTER mkdir: worktreesDir() realpaths an existing dir, so the stored
144
+ // worktree_dir matches BOTH what `git worktree list` reports AND every later
145
+ // worktreesDir()/worktreeDirFor() lookup. Reading `base` before mkdir would get
146
+ // the un-realpath'd form and every subsequent lookup would miss on a symlinked
147
+ // home (macOS /var -> /private/var). The createWorktree precedent.
148
+ const baseReal = worktreesDir(threadId);
149
+ const dir = join(baseReal, wtId);
150
+ if (!dir.startsWith(baseReal + sep)) throw new AskWorktreeError('worktree path escapes base'); // belt-and-braces
151
+ const { commit } = await createDetachedWorktree({ projectDir: t.projectDir, worktreeDir: dir, ref: t.ref, signal });
152
+ const ts = now();
153
+ // The thread may have been DELETED while `git worktree add` ran (the DELETE
154
+ // route stops the turn, but an in-flight open_worktree in the MCP child still
155
+ // completes): registering the row would fail or orphan, and the checkout
156
+ // would sit in the user's repo until the next sweep. Roll it back instead.
157
+ try {
158
+ if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) throw new AskWorktreeError('unknown thread');
159
+ prepare(`INSERT INTO ask_worktrees (id, thread_id, project_key, project_dir, ref, resolved_commit, run_id, worktree_dir, created_at, updated_at)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
161
+ .run(wtId, threadId, t.projectKey, t.projectDir, t.ref, commit ?? '', t.runId, dir, ts, ts);
162
+ } catch (err) {
163
+ try { await removeWorktree({ projectDir: t.projectDir, worktreeDir: dir, branch: null, force: true }); } catch { /* best-effort */ }
164
+ throw err instanceof AskWorktreeError ? err : new AskWorktreeError(`worktree could not be registered: ${err && err.message ? err.message : err}`);
165
+ }
166
+ return getAskWorktree(threadId, wtId);
167
+ }
168
+
169
+ export async function removeAskWorktree({ threadId, wtId } = {}) {
170
+ const wt = typeof wtId === 'string' && WT_ID_RE.test(wtId) ? getAskWorktree(threadId, wtId) : null;
171
+ if (!wt) throw new AskWorktreeError('worktree not found');
172
+ const res = await removeWorktree({ projectDir: wt.projectDir, worktreeDir: wt.path, branch: null, force: true });
173
+ prepare('DELETE FROM ask_worktrees WHERE thread_id = ? AND id = ?').run(threadId, wtId);
174
+ return { ok: true, steps: res.steps };
175
+ }
176
+
177
+ /** Thread-delete cascade step (§5): git-proper removal of every row BEFORE the
178
+ * SQL cascade + the thread-dir rmSync backstop. Never throws — a dead source
179
+ * repo degrades to removeWorktree's rm-rf + prune best effort. */
180
+ export async function removeThreadWorktrees(threadId) {
181
+ if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)) return { removed: 0 };
182
+ let removed = 0;
183
+ for (const wt of listAskWorktrees(threadId)) {
184
+ try {
185
+ await removeAskWorktree({ threadId, wtId: wt.worktreeId });
186
+ removed += 1;
187
+ } catch { /* row raced away or repo gone — the rmSync backstop covers the dir */ }
188
+ }
189
+ return { removed };
190
+ }
191
+
192
+ /** After a successful checkout/switch: re-read HEAD and stamp the row so
193
+ * list_worktrees and the UI always show where the checkout actually points. */
194
+ export async function noteWorktreeNavigation(threadId, wtId, { ref } = {}) {
195
+ const wt = getAskWorktree(threadId, wtId);
196
+ if (!wt) return null;
197
+ const head = await worktreeHead(wt.path);
198
+ prepare('UPDATE ask_worktrees SET ref = ?, resolved_commit = ?, updated_at = ? WHERE thread_id = ? AND id = ?')
199
+ .run(ref ?? wt.ref, head ?? wt.commit, now(), threadId, wtId);
200
+ return getAskWorktree(threadId, wtId);
201
+ }
202
+
203
+ /** Recover the source repo of an orphan dir from its `.git` gitdir pointer
204
+ * (`gitdir: <repo>/.git/worktrees/<name>`), or null. */
205
+ function repoDirOfOrphan(dir) {
206
+ try {
207
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(join(dir, '.git'), 'utf8'));
208
+ if (!m) return null;
209
+ const gitdir = m[1].trim();
210
+ const marker = `${sep}.git${sep}worktrees${sep}`;
211
+ const i = gitdir.lastIndexOf(marker);
212
+ return i > 0 ? gitdir.slice(0, i) : null;
213
+ } catch { return null; }
214
+ }
215
+
216
+ /**
217
+ * Boot/doctor sweep (§5): reconcile rows vs dirs BOTH ways. Three-state
218
+ * doctrine — a DB failure aborts the sweep with nothing removed (failed > 0),
219
+ * never "no rows ⇒ reclaim everything".
220
+ * @param {{log?: (level:string, msg:string) => void}} [opts]
221
+ */
222
+ export async function sweepAskWorktrees({ log = () => {} } = {}) {
223
+ const summary = { removedDirs: 0, prunedRows: 0, failed: 0 };
224
+ let rows;
225
+ try {
226
+ getDb();
227
+ rows = prepare('SELECT * FROM ask_worktrees').all().map(rowToWorktree);
228
+ } catch (err) {
229
+ summary.failed += 1;
230
+ log('warn', `ask-worktrees: row scan failed — nothing swept (${err.message})`);
231
+ return summary;
232
+ }
233
+ const live = new Set();
234
+ for (const wt of rows) {
235
+ if (existsSync(wt.path)) {
236
+ live.add(await realpath(wt.path).catch(() => wt.path));
237
+ continue;
238
+ }
239
+ try {
240
+ // dir already gone: this is prune + registration cleanup only
241
+ await removeWorktree({ projectDir: wt.projectDir, worktreeDir: wt.path, branch: null, force: true });
242
+ prepare('DELETE FROM ask_worktrees WHERE thread_id = ? AND id = ?').run(wt.threadId, wt.worktreeId);
243
+ summary.prunedRows += 1;
244
+ log('info', `ask-worktrees: dropped stale row ${wt.worktreeId} (dir missing)`);
245
+ } catch (err) {
246
+ summary.failed += 1;
247
+ log('warn', `ask-worktrees: row ${wt.worktreeId} skipped (${err.message})`);
248
+ }
249
+ }
250
+ let root;
251
+ try { root = askRoot(); } catch { return summary; }
252
+ let threads = [];
253
+ try {
254
+ threads = readdirSync(root, { withFileTypes: true })
255
+ .filter((d) => d.isDirectory() && ASK_ID_RE.test(d.name)).map((d) => d.name);
256
+ } catch { return summary; } // no ask root yet — nothing to do
257
+ for (const tid of threads) {
258
+ const base = join(root, tid, 'wt');
259
+ let entries = [];
260
+ try {
261
+ entries = readdirSync(base, { withFileTypes: true })
262
+ .filter((d) => d.isDirectory() && WT_ID_RE.test(d.name)).map((d) => d.name);
263
+ } catch { continue; } // thread has no wt/ dir
264
+ for (const wtId of entries) {
265
+ const dir = join(base, wtId);
266
+ const real = await realpath(dir).catch(() => dir);
267
+ if (live.has(real)) continue;
268
+ const repoDir = repoDirOfOrphan(dir);
269
+ try {
270
+ await removeWorktree({ projectDir: repoDir ?? dir, worktreeDir: dir, branch: null, force: true });
271
+ if (!existsSync(dir)) {
272
+ summary.removedDirs += 1;
273
+ log('info', `ask-worktrees: removed orphan dir ${dir}`);
274
+ } else {
275
+ summary.failed += 1;
276
+ log('warn', `ask-worktrees: orphan ${dir} could not be removed`);
277
+ }
278
+ } catch (err) {
279
+ summary.failed += 1;
280
+ log('warn', `ask-worktrees: orphan ${dir} skipped (${err.message})`);
281
+ }
282
+ }
283
+ }
284
+ return summary;
285
+ }
@@ -11,8 +11,10 @@
11
11
  // this module never imports Express or the orchestrator.
12
12
 
13
13
  import { parseCommand } from './parser.mjs';
14
+ import { BOOKEND_EXECUTION_IDS } from '../../shared/graph/constants.mjs';
14
15
  import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
15
16
  import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
17
+ import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
16
18
 
17
19
  const md = (value) => ({ kind: 'markdown', value });
18
20
  const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
@@ -43,7 +45,7 @@ const HELP_TEXT = [
43
45
  '`/status [*ref]` — run detail · `/cost [*ref]` — run cost',
44
46
  '`/pause [*ref]` · `/stop [*ref]` · `/resume [*ref]`',
45
47
  '`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
46
- '`/abort [*ref]` — abort a recovery prompt',
48
+ '`/abort [*ref]` — give up on a recovery prompt (pauses the run; nothing is discarded)',
47
49
  '`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
48
50
  '`/projects` · `/use <name>` — scope commands to one project',
49
51
  '`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
@@ -94,6 +96,12 @@ function runLine(r) {
94
96
  return `${statusEmoji(r.status)} \`${runRef(r.runId || r.id)}\` ${r.status} — ${title}`;
95
97
  }
96
98
 
99
+ /** Final segment of a host-native path, on either separator ("/x/proj",
100
+ * "C:\\x\\proj", trailing separator tolerated). Exported for tests. */
101
+ export function lastPathSegment(p) {
102
+ return String(p || '').split(/[\\/]/).filter(Boolean).pop() || '';
103
+ }
104
+
97
105
  /**
98
106
  * @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
99
107
  * actions: listRuns(), runState(runId), pendingQuestion(runId),
@@ -107,7 +115,9 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
107
115
  const all = actions.listRuns().filter((r) => (r.kind || 'run') === 'run' || r.kind === 'workspace-run');
108
116
  const scope = projectOf(chatKey);
109
117
  if (!scope) return all;
110
- return all.filter((r) => String(r.projectDir || '').split('/').pop() === scope
118
+ // Last path segment on EITHER separator: projectDir is host-native, so on
119
+ // Windows it is `C:\\…\\proj` and a "/"-only split never matched the scope.
120
+ return all.filter((r) => lastPathSegment(r.projectDir) === scope
111
121
  || (r.projectNames || []).includes(scope));
112
122
  };
113
123
 
@@ -165,15 +175,24 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
165
175
  const r = t.row;
166
176
  return reply([runLine({ ...r, runId: r.id }),
167
177
  ...(fmtUsd(r.totalCostUsd) ? [` **Cost:** ${fmtUsd(r.totalCostUsd)}`] : []),
168
- ...(r.pauseReason ? [` **Pause reason:** ${r.pauseReason}`] : []),
178
+ ...(r.pauseReason ? [` **Pause reason:** ${describePauseReason(r.pauseReason) || r.pauseReason}`] : []),
179
+ ...(r.pauseDetail ? [` **${pauseConsequences(r.pauseReason).severity === 'error' ? 'Error' : 'Cause'}:** ${r.pauseDetail}`] : []),
169
180
  ].join('\n'));
170
181
  }
171
182
  const r = t.run;
172
183
  const lines = [runLine(r)];
173
184
  const state = actions.runState(r.runId);
174
185
  if (state) {
175
- const doneSteps = (state.steps || []).filter((s) => s.status === 'done').length;
176
- lines.push(` **Steps:** ${doneSteps}/${(state.steps || []).length} done · **Phase:** ${state.phase || '—'}`);
186
+ // `x:` is NOT a bookend filter every v2 executionId starts with it —
187
+ // so the two BOOKEND rows are named explicitly.
188
+ const ledger = (state.steps || []).filter((s) => !BOOKEND_EXECUTION_IDS.includes(String(s.key || '')));
189
+ const doneSteps = ledger.filter((s) => s.status === 'done').length;
190
+ const nodes = state.stepper?.graph?.nodes || [];
191
+ const active = (state.active || [])
192
+ .map((a) => nodes.find((n) => n.id === a.nodeId)?.label || a.nodeId);
193
+ const activeLabel = active.length === 0 ? '—'
194
+ : (active.length === 1 ? active[0] : `${active.length} agents running`);
195
+ lines.push(` **Executions:** ${doneSteps}/${ledger.length} done · **Active:** ${activeLabel}`);
177
196
  const cost = fmtUsd(state.totalCostUsd);
178
197
  if (cost) lines.push(` **Cost:** ${cost}`);
179
198
  }
@@ -305,14 +324,16 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
305
324
  if (verb === 'abort') return reply(`Gates have no abort — \`/approve ${ref}\`, \`/retry ${ref}\`, or \`/stop ${ref}\`.`, 'warning');
306
325
  payload = { decision: verb === 'approve' ? 'continue' : 'another' };
307
326
  } else if (pq.kind === 'recovery') {
308
- payload = { decision: verb === 'abort' ? 'abort' : 'retry' };
327
+ // /abort is the give-up choice; what it does (pause or abort) is the row's
328
+ // option (failure-policy.mjs) — the option id is the wire decision.
329
+ payload = { decision: verb === 'abort' ? giveUpOption(pq.recovery?.options).id : 'retry' };
309
330
  } else {
310
331
  return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
311
332
  }
312
333
  await actions.answer(t.run.runId, pq.id, payload);
313
334
  const what = pq.kind === 'gate'
314
335
  ? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
315
- : (payload.decision === 'retry' ? 'retrying' : 'aborting');
336
+ : (payload.decision === 'retry' ? 'retrying' : payload.decision === 'abort' ? 'aborting the run' : 'pausing the run');
316
337
  return reply(`✅ \`${ref}\` ${what}.`, 'success');
317
338
  }
318
339
 
@@ -14,6 +14,7 @@ import { readPluginConfig } from '../plugin-config.mjs';
14
14
  import { parseIdList } from './allowlist.mjs';
15
15
  import { createRateLimiter } from './rate-limiter.mjs';
16
16
  import { renderDone, renderError, renderQuestion } from './renderers.mjs';
17
+ import { pauseConsequences } from '../failure-policy.mjs';
17
18
 
18
19
  /**
19
20
  * @param {{channelHost: object, getPrefs: () => {notify:object, channels:object},
@@ -89,7 +90,11 @@ export function createNotifier({ channelHost, getPrefs, chatContext, logger = ()
89
90
  const status = payload?.status || 'done';
90
91
  if (status === 'error') return; // the richer 'error' event already went out
91
92
  const prefs = getPrefsSafe().notify;
92
- if (status === 'paused' ? prefs.paused === false : prefs.done === false) return;
93
+ // Which preference gates a pause follows its reason (failure-policy.mjs):
94
+ // an error-pause IS the failure notification (no 'error' event precedes
95
+ // it), so notify.error gates it, not notify.paused.
96
+ const gate = status === 'paused' ? prefs[pauseConsequences(payload?.reason).notifyPref] : prefs.done;
97
+ if (gate === false) return;
93
98
  deliver(renderDone(meta(), payload || {}));
94
99
  }));
95
100
 
@@ -9,6 +9,8 @@
9
9
  // ordinals and embeds the exact reply commands (/approve, /retry, /answer n…)
10
10
  // using the run-id wildcard-suffix convention the command router resolves.
11
11
 
12
+ import { pauseConsequences, describePauseReason, giveUpOption } from '../failure-policy.mjs';
13
+
12
14
  const md = (value) => ({ kind: 'markdown', value });
13
15
 
14
16
  export function fmtMs(ms) {
@@ -41,10 +43,6 @@ function head(icon, meta) {
41
43
  return parts;
42
44
  }
43
45
 
44
- const PAUSE_REASONS = {
45
- cost_pipeline: 'pipeline cost limit reached',
46
- cost_total: 'total cost limit reached',
47
- };
48
46
 
49
47
  /**
50
48
  * done event: status done|stopped|paused (+reason for limit pauses).
@@ -53,11 +51,20 @@ const PAUSE_REASONS = {
53
51
  export function renderDone(meta, payload = {}) {
54
52
  const status = payload.status || 'done';
55
53
  if (status === 'paused') {
56
- const reason = payload.reason ? (PAUSE_REASONS[payload.reason] || payload.reason) : null;
57
- const parts = head('', meta);
54
+ // The icon, severity and wording follow the pause's reason (failure-policy.mjs):
55
+ // an error-pause IS the failure notification (no 'error' event precedes it).
56
+ const { severity } = pauseConsequences(payload.reason);
57
+ const isError = severity === 'error';
58
+ const reason = payload.reason ? (describePauseReason(payload.reason) || payload.reason) : null;
59
+ const parts = head(isError ? '\u{1F534}' : '⏸', meta);
58
60
  parts.push(` **Status:** paused${reason ? ` — ${reason}` : ''}`);
61
+ if (payload.detail && payload.reason) {
62
+ // Already bounded (PAUSE_DETAIL_MAX, middle-clipped so the runner's trailing
63
+ // cause survives) — a head clip here would throw exactly that tail away.
64
+ parts.push(` **${isError ? 'Error' : 'Cause'}:** ${String(payload.detail)}`);
65
+ }
59
66
  parts.push(` Resume from the worca-cc UI, or reply: /resume ${runRef(meta.runId)}`);
60
- return mdMsg(parts.join('\n'), 'warning');
67
+ return mdMsg(parts.join('\n'), isError ? 'error' : 'warning');
61
68
  }
62
69
  if (status === 'stopped') {
63
70
  const parts = head('⏹', meta);
@@ -109,7 +116,7 @@ export function renderQuestion(meta, payload = {}) {
109
116
  }
110
117
  parts.push(kind === 'gate'
111
118
  ? ` Reply: /approve ${ref} to continue · /retry ${ref} for another cycle`
112
- : ` Reply: /approve ${ref} to retry · /abort ${ref} to abort`);
119
+ : ` Reply: /approve ${ref} to retry · /abort ${ref} to ${giveUpOption(payload.recovery?.options).id === 'abort' ? 'abort the run' : 'pause the run'}`);
113
120
  return mdMsg(parts.join('\n'), 'warning');
114
121
  }
115
122