@worca/app 1.0.0 → 1.1.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 (138) hide show
  1. package/README.md +22 -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 +319 -45
  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 +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. 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,6 +11,7 @@
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';
16
17
 
@@ -94,6 +95,12 @@ function runLine(r) {
94
95
  return `${statusEmoji(r.status)} \`${runRef(r.runId || r.id)}\` ${r.status} — ${title}`;
95
96
  }
96
97
 
98
+ /** Final segment of a host-native path, on either separator ("/x/proj",
99
+ * "C:\\x\\proj", trailing separator tolerated). Exported for tests. */
100
+ export function lastPathSegment(p) {
101
+ return String(p || '').split(/[\\/]/).filter(Boolean).pop() || '';
102
+ }
103
+
97
104
  /**
98
105
  * @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
99
106
  * actions: listRuns(), runState(runId), pendingQuestion(runId),
@@ -107,7 +114,9 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
107
114
  const all = actions.listRuns().filter((r) => (r.kind || 'run') === 'run' || r.kind === 'workspace-run');
108
115
  const scope = projectOf(chatKey);
109
116
  if (!scope) return all;
110
- return all.filter((r) => String(r.projectDir || '').split('/').pop() === scope
117
+ // Last path segment on EITHER separator: projectDir is host-native, so on
118
+ // Windows it is `C:\\…\\proj` and a "/"-only split never matched the scope.
119
+ return all.filter((r) => lastPathSegment(r.projectDir) === scope
111
120
  || (r.projectNames || []).includes(scope));
112
121
  };
113
122
 
@@ -172,8 +181,16 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
172
181
  const lines = [runLine(r)];
173
182
  const state = actions.runState(r.runId);
174
183
  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 || '—'}`);
184
+ // `x:` is NOT a bookend filter every v2 executionId starts with it —
185
+ // so the two BOOKEND rows are named explicitly.
186
+ const ledger = (state.steps || []).filter((s) => !BOOKEND_EXECUTION_IDS.includes(String(s.key || '')));
187
+ const doneSteps = ledger.filter((s) => s.status === 'done').length;
188
+ const nodes = state.stepper?.graph?.nodes || [];
189
+ const active = (state.active || [])
190
+ .map((a) => nodes.find((n) => n.id === a.nodeId)?.label || a.nodeId);
191
+ const activeLabel = active.length === 0 ? '—'
192
+ : (active.length === 1 ? active[0] : `${active.length} agents running`);
193
+ lines.push(` **Executions:** ${doneSteps}/${ledger.length} done · **Active:** ${activeLabel}`);
177
194
  const cost = fmtUsd(state.totalCostUsd);
178
195
  if (cost) lines.push(` **Cost:** ${cost}`);
179
196
  }