@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,848 @@
1
+ // src/core/ask/tools.mjs
2
+ // The worca MCP tools (ask-worca-design.md §6.4) — READ-ONLY BY CONTRACT.
3
+ // House rule, enforced by test/ask-tools.test.mjs scanning this file: no
4
+ // uppercase SQL write verbs anywhere in this module (use lowercase in prose),
5
+ // no import of the db module and no direct database handle of any kind. Every reader is injected through
6
+ // `deps` (tool-deps.mjs builds the real bundle). Handler failures are
7
+ // AskToolError → the MCP child returns them as isError:true text so the model
8
+ // can self-correct; they are never JSON-RPC errors. No imports at all: diff paths
9
+ // are repo-relative POSIX, so path handling here is plain string work.
10
+
11
+ export class AskToolError extends Error {
12
+ constructor(message) { super(message); this.name = 'AskToolError'; }
13
+ }
14
+
15
+ const MEMBER_HEADER_RE = /^# ([a-z0-9][a-z0-9-]*-[0-9a-f]{8})$/; // workspace patches = member patches joined as `# <projectKey>\n<patch>`
16
+ const DIFF_GIT = 'diff --git ';
17
+ const FROM_RE = /^(?:rename|copy) from /; // extended-header line naming the SOURCE file of a rename/copy
18
+
19
+ // `\a \b \f \n \r \t \v` — every other escape (`\"`, `\\`) is the literal byte.
20
+ const C_ESCAPES = new Map([[0x61, 7], [0x62, 8], [0x66, 12], [0x6e, 10], [0x72, 13], [0x74, 9], [0x76, 11]]);
21
+
22
+ /** Undo git's C-quoting: `cl\303\251.pem` → `clé.pem`. Octal escapes are BYTES, so decode after reassembly. */
23
+ function unquoteDiffPath(s) {
24
+ const src = String(s ?? '');
25
+ if (!src.includes('\\')) return src;
26
+ const buf = Buffer.from(src, 'utf8');
27
+ const out = Buffer.alloc(buf.length);
28
+ let n = 0;
29
+ for (let i = 0; i < buf.length; i++) {
30
+ if (buf[i] !== 0x5c || i + 1 >= buf.length) { out[n++] = buf[i]; continue; }
31
+ const next = buf[i + 1];
32
+ if (next >= 0x30 && next <= 0x37) {
33
+ let v = 0;
34
+ for (let d = 0; d < 3 && i + 1 < buf.length && buf[i + 1] >= 0x30 && buf[i + 1] <= 0x37; d++) { v = v * 8 + (buf[i + 1] - 0x30); i += 1; }
35
+ out[n++] = v & 0xff;
36
+ continue;
37
+ }
38
+ out[n++] = C_ESCAPES.get(next) ?? next;
39
+ i += 1;
40
+ }
41
+ return out.subarray(0, n).toString('utf8');
42
+ }
43
+
44
+ /** Strip the surrounding quotes of a C-quoted token, or null when it is not quoted. */
45
+ function unquoteToken(tok) {
46
+ return tok.length > 1 && tok.startsWith('"') && tok.endsWith('"') ? tok.slice(1, -1) : null;
47
+ }
48
+
49
+ /**
50
+ * The a-side of a `diff --git ` header (`a/<p>`, or `"a/<p>"` — git quotes each
51
+ * side independently), or null when the token is not one. The prefix is required
52
+ * here because an a-side that lost it (`diff.noprefix`) is indistinguishable from
53
+ * a path, and a WRONG old path would drop a harmless file: the `--- ` label and
54
+ * `rename from ` line below are the exact sources, this is the last resort.
55
+ */
56
+ function aSidePath(tok) {
57
+ const inner = unquoteToken(tok);
58
+ const body = inner ?? tok;
59
+ if (!body.startsWith('a/') || body.length === 2) return null;
60
+ const path = body.slice(2);
61
+ return inner === null ? path : unquoteDiffPath(path);
62
+ }
63
+
64
+ /**
65
+ * The b-side path of a `diff --git ` header, or null when the line has no
66
+ * UNAMBIGUOUS one. git's core.quotePath defaults to true, so a path with
67
+ * non-ASCII, `"`, `\` or a control byte is emitted C-quoted, and EACH SIDE is
68
+ * quoted independently (`diff --git a/README.md "b/ren\303\245med.md"` on a
69
+ * rename) — a quoted b-side therefore always ENDS the line, which makes it
70
+ * unambiguous. Unquoted, the header separates the two sides with a bare space, so
71
+ * a path containing ` b/` makes it a GUESS: for `secrets/plan b/creds.json` both
72
+ * the first and the last ` b/` land inside a side, and either guess yields a path
73
+ * (`creds.json`) that no longer matches the secrets guardrail glob while the body
74
+ * still ships.
75
+ * So only two shapes are read here, and both are exact:
76
+ * - exactly one ` b/` — nothing else can be the separator;
77
+ * - `a/<p> b/<p>` (a non-rename), whose separator position is fixed by the
78
+ * lengths, so at most one index can satisfy it.
79
+ * Anything else returns null: the section's own `+++ ` line (tab-terminated, hence
80
+ * unambiguous) is preferred over this whole function anyway, and a section left
81
+ * with no path is dropped by get_run_diff rather than emitted.
82
+ * The a-side is read from the SAME two exact shapes, so a rename can be checked on
83
+ * both sides — see splitUnifiedDiff's oldPath.
84
+ * @returns {{path: string|null, oldPath: string|null}}
85
+ */
86
+ function diffGitPaths(line) {
87
+ const rest = line.slice(DIFF_GIT.length);
88
+ if (rest.endsWith('"')) {
89
+ const q = rest.lastIndexOf(' "b/');
90
+ if (q > 0) return { path: unquoteDiffPath(rest.slice(q + 4, -1)), oldPath: aSidePath(rest.slice(0, q)) };
91
+ }
92
+ const first = rest.indexOf(' b/');
93
+ if (first <= 0) return { path: null, oldPath: null };
94
+ if (rest.indexOf(' b/', first + 1) < 0) return { path: rest.slice(first + 3) || null, oldPath: aSidePath(rest.slice(0, first)) };
95
+ const half = (rest.length - 1) / 2; // `a/<p>` + ` b/` + `<p>`
96
+ if (!Number.isInteger(half) || !rest.startsWith('a/')) return { path: null, oldPath: null };
97
+ if (rest.slice(half, half + 3) !== ' b/' || rest.slice(2, half) !== rest.slice(half + 3)) return { path: null, oldPath: null };
98
+ const p = rest.slice(half + 3) || null;
99
+ return { path: p, oldPath: p }; // a non-rename: both sides are the same file
100
+ }
101
+
102
+ /**
103
+ * The a-side of a header whose sides diffGitPaths could not separate, recovered
104
+ * from a b-side the section's own `+++ ` line pinned down EXACTLY: the header then
105
+ * has to end with ` b/<that path>`, so what precedes it IS the a-side. Turns
106
+ * `a/a b/old.pem b/plain.txt` + `+++ b/plain.txt` into `a b/old.pem` — a rename out
107
+ * of a protected file that the header alone reads as a guess.
108
+ */
109
+ function headerOldFromNew(rest, newPath) {
110
+ if (rest == null || !newPath) return null;
111
+ const suffix = ` b/${newPath}`;
112
+ return rest.length > suffix.length && rest.endsWith(suffix) ? aSidePath(rest.slice(0, -suffix.length)) : null;
113
+ }
114
+
115
+ /** The path of a `--- `/`+++ ` label line, or null. git tab-terminates a name that needs it, so cut at the first tab. */
116
+ function labelPath(line, prefix) {
117
+ const tok = line.slice(4).split('\t')[0];
118
+ const inner = unquoteToken(tok);
119
+ const body = inner ?? tok;
120
+ const path = body.startsWith(prefix) ? body.slice(2) : body;
121
+ return inner === null ? path : unquoteDiffPath(path);
122
+ }
123
+
124
+ /** The source path of a `rename from `/`copy from ` line: a whole-line value, C-quoted when it needs to be, never prefixed. */
125
+ function fromPath(line) {
126
+ const tok = line.slice(line.indexOf(' from ') + 6);
127
+ const inner = unquoteToken(tok);
128
+ return (inner === null ? tok : unquoteDiffPath(inner)) || null;
129
+ }
130
+
131
+ /**
132
+ * `header` is true when a `diff --git ` line opened the section.
133
+ * Split a unified diff into per-file sections (pure), lossless: concatenating the
134
+ * sections' text reproduces the input. Text before the first header, and a header
135
+ * whose path cannot be read, are `path: null` sections — get_run_diff drops those
136
+ * rather than emitting them, because a section with no path cannot be checked
137
+ * against the protected-path filter.
138
+ * `oldPath` is the section's SOURCE file. diffPatch passes `-M` (git-info.mjs:127),
139
+ * so a rename+edit is ONE section: `path` is the new name — harmless for
140
+ * `config/.env` → `config/env.sample` — while its `-`/context lines are the old
141
+ * file's content, which get_run_diff has to filter on the old name. Sources, most
142
+ * exact first: `rename from ` / `copy from `, the tab-terminated `--- ` label, then
143
+ * the header's a-side. Extended header only: past the first `@@` both shapes are
144
+ * ordinary body lines — and only inside a section a `diff --git ` line opened,
145
+ * because a patch without one never splits, so its first label would name every
146
+ * file that follows.
147
+ */
148
+ export function splitUnifiedDiff(text) {
149
+ const sections = [];
150
+ let projectKey = null;
151
+ let cur = null;
152
+ const start = (path, headerOld, headerRest, hasHeader) => {
153
+ cur = { path, headerOld, headerRest, hasHeader, renameFrom: null, minusPath: null, projectKey, added: 0, removed: 0, lines: [], inHunks: false, fromPlus: false, fromMinus: false };
154
+ };
155
+ const flush = () => {
156
+ if (cur && (cur.lines.length || cur.path)) {
157
+ sections.push({ path: cur.path, oldPath: cur.renameFrom ?? cur.minusPath ?? cur.headerOld ?? headerOldFromNew(cur.headerRest, cur.path),
158
+ projectKey: cur.projectKey, member: false, header: cur.hasHeader, added: cur.added, removed: cur.removed, text: cur.lines.length ? `${cur.lines.join('\n')}\n` : '' });
159
+ }
160
+ cur = null;
161
+ };
162
+ const lines = String(text ?? '').split('\n');
163
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
164
+ for (const line of lines) {
165
+ const member = MEMBER_HEADER_RE.exec(line);
166
+ if (member) {
167
+ // The member header is a section of ITS OWN: anything after it must earn a
168
+ // section, so an unreadable body can never ride along inside the one part
169
+ // of a workspace patch that is kept without a path.
170
+ flush();
171
+ projectKey = member[1];
172
+ sections.push({ path: null, oldPath: null, projectKey, member: true, header: false, added: 0, removed: 0, text: `${line}\n` });
173
+ continue;
174
+ }
175
+ // Split on the literal marker, never on a path shape: `diff.noprefix`,
176
+ // `diff.mnemonicPrefix` and `diff.srcPrefix`/`dstPrefix` change or drop the
177
+ // `a/` … `b/` prefixes, and patches persisted before diffPatch pinned them
178
+ // cannot be regenerated. A header that starts no section swallows its file
179
+ // into the previous one — its `+` lines miscounted, its body past the filter.
180
+ if (line.startsWith(DIFF_GIT)) {
181
+ flush();
182
+ const p = diffGitPaths(line);
183
+ start(p.path, p.oldPath, line.slice(DIFF_GIT.length), true);
184
+ cur.lines.push(line);
185
+ continue;
186
+ }
187
+ if (!cur) start(null, null, null, false);
188
+ // Everything below is about the section's EXTENDED HEADER only: past the first
189
+ // `@@`, `+++ `/`--- ` at the head of a line are ordinary body lines (a diff of a
190
+ // diff, an added `++i;`, a removed YAML `---`), so they must not be read as
191
+ // paths and must not be excluded from the counts.
192
+ if (!cur.inHunks && line.startsWith('@@')) cur.inHunks = true;
193
+ // …and they are read only inside a section a `diff --git ` line STARTED. Without
194
+ // one nothing splits the run, so the first label would name every file that
195
+ // follows: `--- /tmp/git-blob-1/aaa.txt` from a legacy external-diff patch, or
196
+ // the inner patch of `diff.submodule=diff`, then ships the credential file after
197
+ // it under `aaa.txt`. A section git never opened resolves no path, so it is
198
+ // dropped whole — the harmless first file goes with it rather than carrying it.
199
+ if (cur.hasHeader && !cur.inHunks && !cur.fromPlus && line.startsWith('+++ ')) {
200
+ // The `+++ ` line WINS over the `diff --git ` header: git tab-terminates a
201
+ // name that needs it, so this line is unambiguous where the header is a
202
+ // guess (a path containing ` b/`). ui/public/diff-view.mjs:66-69,85 has the
203
+ // same precedence.
204
+ const p = labelPath(line, 'b/');
205
+ if (p && p !== '/dev/null') { cur.path = p; cur.fromPlus = true; }
206
+ }
207
+ // The old side, same precedence for the same reason: `rename from ` is a
208
+ // whole-line value, the `--- ` label is tab-terminated, the header is a guess.
209
+ if (cur.hasHeader && !cur.inHunks && !cur.renameFrom && FROM_RE.test(line)) cur.renameFrom = fromPath(line);
210
+ if (cur.hasHeader && !cur.inHunks && !cur.fromMinus && line.startsWith('--- ')) {
211
+ const p = labelPath(line, 'a/');
212
+ if (p && p !== '/dev/null') { cur.minusPath = p; cur.fromMinus = true; }
213
+ }
214
+ if (line.startsWith('+') && (cur.inHunks || !line.startsWith('+++'))) cur.added += 1;
215
+ else if (line.startsWith('-') && (cur.inHunks || !line.startsWith('---'))) cur.removed += 1;
216
+ cur.lines.push(line);
217
+ }
218
+ flush();
219
+ return sections;
220
+ }
221
+
222
+ const RE_SPECIAL = new Set(['.', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\']);
223
+ const globCache = new Map();
224
+ /** A guardrails.mjs pattern → anchored regex: a leading double-star + slash = any dirs, double-star = anything, one star = within one segment. */
225
+ function globRe(pattern) {
226
+ let re = globCache.get(pattern);
227
+ if (re) return re;
228
+ let body = '';
229
+ for (let i = 0; i < pattern.length; i++) {
230
+ const c = pattern[i];
231
+ if (c !== '*') { body += RE_SPECIAL.has(c) ? `\\${c}` : c; continue; }
232
+ if (pattern[i + 1] !== '*') { body += '[^/]*'; continue; }
233
+ if (pattern[i + 2] === '/') { body += '(?:.*/)?'; i += 2; } else { body += '.*'; i += 1; }
234
+ }
235
+ re = new RegExp(`^${body}$`);
236
+ globCache.set(pattern, re);
237
+ return re;
238
+ }
239
+
240
+ /**
241
+ * Guardrail match for a diff section path (repo-relative, POSIX). Mirrors the CLI
242
+ * semantics guardrails.mjs:16-18 documents: slash-LESS patterns (`*x*`, `*x`, `x*`,
243
+ * `x`) match the basename at any depth; slash-containing ones match the whole path.
244
+ * `~/…` and `//…` are absolute and can never name a file inside a run diff, so they
245
+ * are skipped rather than silently mis-matched.
246
+ */
247
+ export function isProtectedBasename(path, patterns = []) {
248
+ const full = String(path ?? '').replace(/^\.\//, '');
249
+ const base = full.slice(full.lastIndexOf('/') + 1);
250
+ for (const p of patterns) {
251
+ if (typeof p !== 'string' || !p || p.startsWith('~/') || p.startsWith('//')) continue;
252
+ if (globRe(p).test(p.includes('/') ? full : base)) return true;
253
+ }
254
+ return false;
255
+ }
256
+
257
+ /** Byte-offset paging: cut at the last newline inside the window; never inside a UTF-8 sequence, never zero progress. */
258
+ export function sliceBytes(text, offset = 0, maxBytes = 60000) {
259
+ const buf = Buffer.from(String(text ?? ''), 'utf8');
260
+ const totalBytes = buf.length;
261
+ const start = Math.min(Math.max(0, Math.trunc(Number(offset) || 0)), totalBytes);
262
+ let end = Math.min(start + Math.max(1, Math.trunc(Number(maxBytes) || 1)), totalBytes);
263
+ if (end < totalBytes) {
264
+ const nl = buf.lastIndexOf(0x0a, end - 1);
265
+ if (nl >= start) end = nl + 1;
266
+ else {
267
+ while (end > start && (buf[end] & 0xc0) === 0x80) end -= 1; // back off to a character boundary
268
+ // A window narrower than the first character would back off all the way to
269
+ // `start` and return nextOffset === offset, so the documented "page until
270
+ // truncated is false" loop never terminated. Emit that whole character instead.
271
+ if (end === start) { end = start + 1; while (end < totalBytes && (buf[end] & 0xc0) === 0x80) end += 1; }
272
+ }
273
+ }
274
+ return { text: buf.subarray(start, end).toString('utf8'), nextOffset: end, truncated: end < totalBytes, totalBytes };
275
+ }
276
+
277
+ /** Absent / non-numeric → dflt; otherwise clamped into [min, max] (spec §6.9: limit ≤ 100, maxBytes ≤ 200 000). */
278
+ const clampInt = (v, min, max, dflt) => {
279
+ if (v === null || v === undefined || v === '') return dflt;
280
+ const n = Math.trunc(Number(v));
281
+ if (!Number.isFinite(n)) return dflt;
282
+ return Math.min(Math.max(n, min), max);
283
+ };
284
+ const parseJson = (v, fallback) => { if (v == null) return fallback; try { return JSON.parse(v); } catch { return fallback; } };
285
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
286
+
287
+ const SCHEMA = {
288
+ obj: (properties, required = []) => ({ type: 'object', properties, ...(required.length ? { required } : {}), additionalProperties: false }),
289
+ s: (description) => ({ type: 'string', description }),
290
+ i: (description, minimum, maximum) => ({ type: 'integer', description, minimum, maximum }),
291
+ b: (description) => ({ type: 'boolean', description }),
292
+ };
293
+
294
+ /**
295
+ * @param {object} deps see tool-deps.mjs#defaultToolDeps for the real bundle
296
+ * @returns {{list: () => Array<{name:string, description:string, inputSchema:object}>, call: (name:string, input:any) => Promise<any>}}
297
+ */
298
+ export function createAskTools(deps) {
299
+ const L = deps.limits;
300
+
301
+ const defs = [
302
+ { name: 'list_projects',
303
+ description: 'List the registered projects (key, name, path) and workspaces (id, name, member project keys). Use the key / id in the other tools.',
304
+ inputSchema: SCHEMA.obj({}) },
305
+ { name: 'list_workflows',
306
+ description: 'List the saved workflows with their ordered step groups (parallel agent nodes share a group) and feedback loops. Pick one by name, domain and steps.',
307
+ inputSchema: SCHEMA.obj({}) },
308
+ { name: 'list_runs',
309
+ description: 'Find past runs, newest first. Optional filters: projectKey OR workspaceId, status (e.g. done, running, error, stopped), query (title substring). limit defaults to 20, max 100.',
310
+ inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects'),
311
+ status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring') }) },
312
+ { name: 'get_run',
313
+ description: 'Read one run: its metadata and the user\'s original prompt. Give projectKey or workspaceId when known; without them the id is searched everywhere.',
314
+ inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id (8 hex)'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace') }, ['id']) },
315
+ { name: 'get_run_diff',
316
+ description: 'Read the unified diff of a run, paged by byte offset (use nextOffset until truncated is false). Optional path = one file only. files[] lists every file with added/removed counts; credential files are omitted.',
317
+ inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace'),
318
+ path: SCHEMA.s('only this file path'), offset: SCHEMA.i('byte offset to start at', 0, Number.MAX_SAFE_INTEGER),
319
+ maxBytes: SCHEMA.i('bytes per page (default 60000, max 200000)', 1, L.diffMaxBytes) }, ['id']) },
320
+ { name: 'propose_run',
321
+ description: 'Propose a pipeline run for the user to confirm — it never starts anything. Exactly one of projectKey / workspaceId. guardrailsId defaults to "normal"; "permissive" is not allowed. Returns {ok:true, card} or {ok:false, errors}.',
322
+ inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('target project key'), workspaceId: SCHEMA.s('target workspace id'), workflowId: SCHEMA.s('workflow id (default wf_default)'),
323
+ brief: SCHEMA.s('the full task description for the run (≤ 8000 chars)'), title: SCHEMA.s('short run title'), guardrailsId: SCHEMA.s('guardrail set id (default normal)'),
324
+ sourceBranch: SCHEMA.s('branch to start from (default: current)'), featureBranch: SCHEMA.s('feature branch name'),
325
+ sourceBranchByKey: { type: 'object', description: 'workspace only: per-member source branch overrides keyed by project key', additionalProperties: { type: 'string' } },
326
+ commentIds: { type: 'array', items: { type: 'string' },
327
+ description: 'diff comment ids (dc_…) this run is meant to address. They are stamped with the run id once the user confirms the card AND the run actually starts; nothing is resolved.' } }, ['brief']) },
328
+ { name: 'read_attachment',
329
+ description: 'Read an attachment of this conversation by id, paged by byte offset (default 32000 bytes per page).',
330
+ inputSchema: SCHEMA.obj({ id: SCHEMA.s('attachment id'), offset: SCHEMA.i('byte offset', 0, Number.MAX_SAFE_INTEGER), maxBytes: SCHEMA.i('bytes per page', 1, L.attachmentReadMaxBytes) }, ['id']) },
331
+ { name: 'list_diff_comments',
332
+ description: 'List the internal review comments anchored to a run\'s diff lines, ordered by file then line then when they were written. status filters them (all | unresolved | resolved, default all); path narrows to one file. Every comment carries line_text — the snapshot of the line it was anchored to, taken when it was written, so it stays readable even though the source branch has moved on. When the patch is still readable, a few surrounding hunk lines come with each comment. Comments on credential files are never listed.',
333
+ inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace'),
334
+ status: SCHEMA.s('all | unresolved | resolved (default all)'), path: SCHEMA.s('only this file path') }, ['id']) },
335
+ { name: 'add_diff_comment',
336
+ description: 'Add an internal comment (authored by you) on one line of a run\'s diff. side is "old" for a removed line — give its OLD line number — and "new" for an added or context line. A workspace run also needs memberProjectKey, naming which member project the file belongs to; it is never guessed. The anchor is checked against the stored patch, so an unknown file, side or line is refused rather than saved wrong.',
337
+ inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace'),
338
+ memberProjectKey: SCHEMA.s('workspace runs: which member project owns this file (from get_run_diff files[].projectKey)'),
339
+ path: SCHEMA.s('file path as it appears in the diff'), side: SCHEMA.s('"old" or "new"'),
340
+ line: SCHEMA.i('line number on that side', 1, Number.MAX_SAFE_INTEGER),
341
+ body: SCHEMA.s(`the comment text (max ${L.commentBodyMaxChars} chars)`) }, ['id', 'path', 'side', 'line', 'body']) },
342
+ { name: 'resolve_diff_comment',
343
+ description: 'Mark one diff comment resolved, or reopen it with resolved:false. Nothing is deleted, and resolving is never automatic — do it only when the user asks.',
344
+ inputSchema: SCHEMA.obj({ commentId: SCHEMA.s('comment id (dc_…) from list_diff_comments'),
345
+ resolved: SCHEMA.b('true to resolve (default), false to reopen') }, ['commentId']) },
346
+ { name: 'delete_diff_comment',
347
+ description: 'Permanently delete one diff comment YOU wrote (author "ask"). The user\'s own comments cannot be deleted here — they delete those from the Diff tab. There is no undo and no history — confirm with the user before deleting anything, and always before deleting several.',
348
+ inputSchema: SCHEMA.obj({ commentId: SCHEMA.s('comment id (dc_…) from list_diff_comments') }, ['commentId']) },
349
+ { name: 'open_worktree',
350
+ description: 'Create a read-only DETACHED git worktree of a registered project at any branch/tag/commit (projectKey + ref), or of a run\'s feature branch (runId; workspace runs also need projectKey). Returns {worktreeId, path, ref, commit}. Capped per chat — reuse via list_worktrees, remove via remove_worktree when done.',
351
+ inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'),
352
+ ref: SCHEMA.s('branch, tag or commit to check out'),
353
+ runId: SCHEMA.s('run id — checks out that run\'s feature branch') }) },
354
+ { name: 'list_worktrees',
355
+ description: 'List this chat\'s worktrees: worktreeId, project, current ref and commit, path on disk.',
356
+ inputSchema: SCHEMA.obj({}) },
357
+ { name: 'remove_worktree',
358
+ description: 'Remove one of this chat\'s worktrees by id. Branches are never touched.',
359
+ inputSchema: SCHEMA.obj({ worktreeId: SCHEMA.s('worktree id') }, ['worktreeId']) },
360
+ { name: 'git',
361
+ description: 'Run a read-only git command inside one of this chat\'s worktrees; args is an argv array, e.g. ["diff","origin/master...HEAD"]. Allowed: diff, log, show, status, blame, branch/tag (list forms), rev-parse, merge-base, grep, shortlog, describe, ls-files, ls-tree, checkout/switch (always detached), fetch (configured remotes only). To read a file, check out the ref and use blame/log -p on it — the git tool serves diffs/logs/history. push/pull/commit/config/cat-file are impossible. Output paged by offset like get_run_diff.',
362
+ inputSchema: SCHEMA.obj({ worktreeId: SCHEMA.s('worktree id'),
363
+ args: { type: 'array', items: { type: 'string' }, description: 'git argv, without the leading "git"' },
364
+ offset: SCHEMA.i('byte offset to page from', 0, Number.MAX_SAFE_INTEGER),
365
+ maxBytes: SCHEMA.i('bytes per page (default 60000, max 200000)', 1, L.gitOutputMaxBytes) }, ['worktreeId', 'args']) },
366
+ ];
367
+
368
+ const EMPTY_DIFF = () => ({ available: false, files: [], text: '', truncated: false, totalBytes: 0, nextOffset: 0 });
369
+
370
+ async function resolveRow(input, tool) {
371
+ const id = str(input.id);
372
+ if (!id) throw new AskToolError(`${tool}: id is required`);
373
+ const projectKey = str(input.projectKey);
374
+ const workspaceId = str(input.workspaceId);
375
+ if (projectKey && workspaceId) throw new AskToolError(`${tool}: give projectKey OR workspaceId, not both`);
376
+ const row = projectKey
377
+ ? deps.lookupPipelineRow(projectKey, id)
378
+ : workspaceId
379
+ ? deps.lookupPipelineRow(`workspaces/${workspaceId}`, id)
380
+ : deps.findPipelineRowById(id);
381
+ if (!row) throw new AskToolError(`${tool}: run not found`);
382
+ return row;
383
+ }
384
+
385
+ // The History store key of a pipelines row — the same mapping lookupPipelineRow
386
+ // reverses. Comments are keyed the way History is.
387
+ const storeKeyOf = (row) => ((row.target === 'workspace' || row.workspace_key)
388
+ ? `workspaces/${row.workspace_key}` : row.project_key);
389
+
390
+ /**
391
+ * Return exactly what changed — never the whole run, never the patch. runId and
392
+ * storeKey are here because the parent process turns a successful write into the
393
+ * diff-comments-changed poke by reading them back out of the tool result
394
+ * (events.mjs); they are useful to the model too, since they say which run a
395
+ * comment belongs to.
396
+ */
397
+ const shapeComment = (c) => ({
398
+ id: c.id, runId: c.pipelineId, storeKey: c.storeKey, path: c.path, projectKey: c.projectKey,
399
+ side: c.side, line: c.line,
400
+ lineText: deps.redact(c.lineText), body: deps.redact(c.body), author: c.author,
401
+ resolved: c.resolved, resolvedAt: c.resolvedAt, sentRunId: c.sentRunId, createdAt: c.createdAt,
402
+ });
403
+
404
+ // Comment failures are model-actionable -> AskToolError text, never a crash.
405
+ const asCommentError = (tool, err) => (err && err.name === 'DiffCommentError'
406
+ ? new AskToolError(`${tool}: ${err.message}`) : err);
407
+
408
+ function shapeRun(row) {
409
+ const isWs = row.target === 'workspace' || !!row.workspace_key;
410
+ const branch = parseJson(row.branch, null) || {};
411
+ const wsMeta = isWs ? (parseJson(row.workspace_meta, null) || {}) : null;
412
+ const meta = isWs ? null : deps.readStoreMeta(row.project_key);
413
+ return {
414
+ id: row.id,
415
+ title: deps.redact(row.title ?? row.id),
416
+ target: isWs ? 'workspace' : 'project',
417
+ project: isWs ? null : { key: row.project_key, name: (meta && meta.name) || row.project_key },
418
+ workspace: isWs ? { id: row.workspace_key, name: wsMeta.workspaceName ?? row.workspace_key,
419
+ members: (Array.isArray(wsMeta.projects) ? wsMeta.projects : []).map((p) => p.projectName) } : null,
420
+ status: row.status ?? null,
421
+ phase: row.phase ?? null,
422
+ startedAt: row.started_at ?? null,
423
+ updatedAt: row.updated_at ?? null,
424
+ branch: branch.feature ?? null,
425
+ sourceBranch: branch.source ?? null,
426
+ guardrailsId: row.guardrails_id ?? null,
427
+ prompt: row.prompt == null ? null : deps.redact(row.prompt), // run prompts are untrusted text (spec §6.3/§6.6)
428
+ totalCostUsd: deps.totalsFor(row).cost,
429
+ archived: !!row.archived_at,
430
+ };
431
+ }
432
+
433
+ // Worktree failures are model-actionable → AskToolError text, never a crash.
434
+ const asToolError = (err) => (err && err.name === 'AskWorktreeError' ? new AskToolError(err.message) : err);
435
+
436
+ // Output shapes differ by subcommand, so the filter is chosen by what git ACTUALLY
437
+ // emitted, not by the subcommand name — running a path-list (grep/ls-*) or a
438
+ // commit-list (log --oneline) through the unified-diff section filter would drop
439
+ // ALL of it. Two sets:
440
+ // PATCH_CAPABLE — accept --no-ext-diff/--no-color and can emit a `diff --git`
441
+ // patch (diff, show <commit>, log -p). Output is section-filtered ONLY when a
442
+ // real diff header is present.
443
+ // LIST_SUBS — emit PATH LISTS (grep, ls-files, ls-tree). Output is line-filtered.
444
+ // blame is neither: a protected FILE is rejected at input (protectedInArgs); a
445
+ // non-protected file's annotated content is fine. cat-file is not in the allowlist.
446
+ const PATCH_CAPABLE = new Set(['diff', 'show', 'log']);
447
+ const LIST_SUBS = new Set(['grep', 'ls-files', 'ls-tree']);
448
+ // Trusted -c prepended by US (never the model — validateGitArgs already blocks the
449
+ // model's -c): neutralises a hostile repo's `.git/config` (or ~/.gitconfig, whose
450
+ // HOME survives the scrub) `diff.external`/pager, and forces predictable path/colour
451
+ // output the section parser depends on. --no-ext-diff/--no-color are the belt-braces.
452
+ const GIT_HARDEN = ['-c', 'core.quotePath=false', '-c', 'diff.external=', '-c', 'color.ui=never', '-c', 'diff.submodule=short'];
453
+ // Reject a command that NAMES a protected file BEFORE spawning: every non-flag
454
+ // positional — a bare token (blame .env), the <path> half of <rev>:<path>
455
+ // (show HEAD:.env), and anything after `--` (log -p -- .env). A ref like
456
+ // `main...leak` has a non-protected basename, so it passes.
457
+ //
458
+ // EVERY colon suffix is a candidate, not just the first: the index form
459
+ // `:0:.env` (stage 0 of .env) was checked as basename `0:.env`, matched nothing,
460
+ // and `rev-parse :0:.env` handed the model the blob sha (review of PR #376).
461
+ // Pathspec magic is stripped the same way (`:(top).env`, `:/.env`, `:!x`), and
462
+ // `-L<start>,<end>:<file>` carries its file after the last colon of an OPTION
463
+ // token, so attached `-L` values are scanned too.
464
+ const protectedInArgs = (args) => {
465
+ let afterSep = false;
466
+ const candidates = (tok) => {
467
+ const out = [tok];
468
+ const magic = /^:(\([^)]*\)|[/!^]*)/.exec(tok);
469
+ if (magic) out.push(tok.slice(magic[0].length));
470
+ for (let i = tok.indexOf(':'); i !== -1; i = tok.indexOf(':', i + 1)) out.push(tok.slice(i + 1));
471
+ return out.filter(Boolean);
472
+ };
473
+ for (const a of args.slice(1)) {
474
+ if (a === '--') { afterSep = true; continue; }
475
+ if (a.startsWith('-')) {
476
+ if (/^-L./.test(a)) { for (const cand of candidates(a.slice(2))) if (isProtectedBasename(cand, deps.protectedPaths)) return cand; }
477
+ continue;
478
+ }
479
+ for (const cand of (afterSep ? [a] : candidates(a))) if (isProtectedBasename(cand, deps.protectedPaths)) return cand;
480
+ }
481
+ return null;
482
+ };
483
+ // A bare object name carries no path, so no pattern can protect what it points
484
+ // at: `diff <blob> <blob>` printed a protected file's body under sha labels and
485
+ // `grep <pat> <blob>` printed it as `<sha>:line` (review of PR #376). Every
486
+ // positional that git will resolve as an object is typed via `cat-file -t` (a
487
+ // TRUSTED spawn — the model cannot call cat-file); a blob is refused everywhere,
488
+ // a tree is refused for `show` (its listing is the raw read this tool does not
489
+ // serve). Unknown names (patterns, paths, refs git will reject itself) pass.
490
+ const OBJECT_TYPED_SUBS = new Set(['diff', 'show', 'log', 'grep', 'blame']);
491
+ const refuseBlobPositionals = async (wtPath, args) => {
492
+ if (!OBJECT_TYPED_SUBS.has(args[0])) return;
493
+ const positionals = [];
494
+ for (const a of args.slice(1)) {
495
+ if (a === '--') break;
496
+ if (a.startsWith('-')) continue;
497
+ // `<rev>:<path>` forms are name-checked above; `a..b`/`a...b` ranges are split
498
+ // so a blob smuggled into a range end is typed too.
499
+ for (const part of a.split(/\.\.\.?/)) if (part && !part.includes(':')) positionals.push(part);
500
+ }
501
+ for (const p of positionals) {
502
+ const r = await deps.worktrees.runGit(wtPath, ['cat-file', '-t', `${p}^{}`]); // ^{} peels an annotated tag
503
+ const type = r.ok ? r.stdout.trim() : '';
504
+ if (type === 'blob') throw new AskToolError(`git: ${JSON.stringify(p)} is a raw blob — this tool serves diffs and history, not file contents; inspect the file through a commit`);
505
+ if (type === 'tree' && args[0] === 'show') throw new AskToolError(`git: ${JSON.stringify(p)} is a tree — git show displays commits; use ls-tree for a listing`);
506
+ }
507
+ };
508
+ const protectedLineFilter = (text) => text.split('\n')
509
+ .filter((line) => !line || !line.split(/[-\s:=\u0000]+/).some((tok) => isProtectedBasename(tok, deps.protectedPaths)))
510
+ .join('\n');
511
+
512
+ // Does this path hit the protected floor? UNQUOTE FIRST: git C-quotes any name
513
+ // holding '"', '\\', a tab or a control byte (and, in patches persisted before
514
+ // core.quotePath=false was pinned, any non-ASCII name), and a stored path can
515
+ // carry that literal — `"a/old\tsecret.pem"` does not match `*.pem`. Both the
516
+ // prefixed and the stripped form are tested: a `--- `-derived path keeps its a/
517
+ // or b/ prefix while a `rename from`-derived one does not, and stripping blindly
518
+ // would weaken the slash-anchored `**/secrets/**` pattern.
519
+ const guardedPath = (p) => {
520
+ if (!p) return false;
521
+ const s = String(p);
522
+ const inner = unquoteToken(s);
523
+ const real = inner === null ? s : unquoteDiffPath(inner);
524
+ return isProtectedBasename(real, deps.protectedPaths)
525
+ || isProtectedBasename(real.replace(/^[ab]\//, ''), deps.protectedPaths);
526
+ };
527
+
528
+ // The read filter shared by EVERY tool that echoes or mutates a comment by id
529
+ // (D5: "the read is the authority"). BOTH rename sides: -M makes a rename+edit
530
+ // one section under its NEW name, and old_path is persisted for exactly this
531
+ // check — which must keep working once the patch itself is gone.
532
+ const commentBlocked = (c) => !!c && (guardedPath(c.path) || guardedPath(c.oldPath));
533
+
534
+ const diffPageCache = new Map(); // run id -> { stamp, files, byPath, filtered } (get_run_diff paging)
535
+
536
+ const handlers = {
537
+ async list_projects() {
538
+ const cat = await deps.buildCatalog();
539
+ return { projects: cat.projects, workspaces: cat.workspaces };
540
+ },
541
+ async list_workflows() {
542
+ return (await deps.buildCatalog()).workflows;
543
+ },
544
+ async list_runs(input) {
545
+ const projectKey = str(input.projectKey);
546
+ const workspaceId = str(input.workspaceId);
547
+ if (projectKey && workspaceId) throw new AskToolError('list_runs: give projectKey OR workspaceId, not both');
548
+ const wantKey = workspaceId ? `workspaces/${workspaceId}` : (projectKey || null);
549
+ const status = str(input.status).toLowerCase();
550
+ const query = str(input.query).toLowerCase();
551
+ const limit = clampInt(input.limit, 1, L.listRunsMaxLimit, L.listRunsDefaultLimit);
552
+ // Unkeyed: the newest runsScanLimit rows are enough. Keyed: scan everything — a
553
+ // project's runs may all be older than the 200 globally newest (lite = one SQL
554
+ // + one readdir per store key, no git).
555
+ const rows = await deps.listAllPipelines({ lite: true, limit: wantKey ? -1 : L.runsScanLimit });
556
+ const out = [];
557
+ for (const e of rows) {
558
+ if (wantKey && e.projectKey !== wantKey) continue;
559
+ if (status && String(e.status ?? '').toLowerCase() !== status) continue;
560
+ if (query && !String(e.title ?? '').toLowerCase().includes(query)) continue;
561
+ const isWs = e.target === 'workspace' || String(e.projectKey).startsWith('workspaces/');
562
+ out.push({
563
+ id: e.id, title: deps.redact(e.title ?? e.id), target: isWs ? 'workspace' : 'project',
564
+ ...(isWs
565
+ ? { workspaceId: String(e.projectKey).slice('workspaces/'.length), workspaceName: e.workspaceName ?? null }
566
+ : { projectKey: e.projectKey, projectName: e.projectName ?? null }),
567
+ status: e.status ?? null, startedAt: e.startedAt ?? null,
568
+ updatedAt: e.mtime ? new Date(e.mtime).toISOString() : null,
569
+ branch: e.branch ?? null, sourceBranch: e.sourceBranch ?? null, guardrailsId: e.guardrailsId ?? null,
570
+ totalCostUsd: e.totalCostUsd ?? null,
571
+ });
572
+ if (out.length >= limit) break;
573
+ }
574
+ return out;
575
+ },
576
+ async get_run(input) {
577
+ const row = await resolveRow(input, 'get_run');
578
+ const run = shapeRun(row);
579
+ return { ...run, hasDiff: !run.archived && await deps.hasDiffPatch(row) };
580
+ },
581
+ async get_run_diff(input) {
582
+ const row = await resolveRow(input, 'get_run_diff');
583
+ if (row.archived_at) return EMPTY_DIFF();
584
+ const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
585
+ const maxBytes = clampInt(input.maxBytes, 1, L.diffMaxBytes, L.diffDefaultBytes);
586
+ // Paging re-entered here per page with a full read + section split + redact of
587
+ // the WHOLE patch (a 5 MB diff at the default page = ~85 passes — review of
588
+ // PR #376). The filtered body is memoised per run for the life of this
589
+ // closure (one MCP child = one turn); the row's stamp guards a run that
590
+ // finishes and writes its patch mid-turn.
591
+ const stamp = `${row.id}|${row.updated_at ?? row.updatedAt ?? ''}|${row.mtime ?? ''}|${row.status ?? ''}`;
592
+ const hit = diffPageCache.get(row.id);
593
+ if (hit && hit.stamp === stamp) {
594
+ const body = hit.byPath.get(str(input.path) || '') ?? hit.filtered(str(input.path));
595
+ return { available: true, files: hit.files, ...sliceBytes(body, offset, maxBytes) };
596
+ }
597
+ const text = await deps.readDiffPatch(row);
598
+ if (text == null) return EMPTY_DIFF();
599
+ // Fail closed: a section whose path could not be read cannot be checked
600
+ // against the guardrail patterns, so it is dropped rather than emitted
601
+ // verbatim. Member headers carry no path by design and are what scopes the
602
+ // sections after them, so they are the one path-less shape that is kept.
603
+ // BOTH sides are checked: `-M` (git-info.mjs:127) makes a rename+edit one
604
+ // section under its NEW name, so `config/.env` → `config/env.sample` would
605
+ // otherwise ship the old file's credentials as `-`/context lines.
606
+ const protectedSide = (p) => !!p && isProtectedBasename(p, deps.protectedPaths);
607
+ const kept = splitUnifiedDiff(text).filter((s) => s.member || (!!s.path && !protectedSide(s.path) && !protectedSide(s.oldPath)))
608
+ .map((s) => ({ ...s, text: deps.redact(s.text) }));
609
+ const files = kept.filter((s) => s.path).map((s) => ({ path: s.path, added: s.added, removed: s.removed, ...(s.projectKey ? { projectKey: s.projectKey } : {}) }));
610
+ const byPath = new Map();
611
+ const filtered = (wantPath) => {
612
+ const key = wantPath || '';
613
+ if (!byPath.has(key)) byPath.set(key, kept.filter((s) => (wantPath ? s.path === wantPath : true)).map((s) => s.text).join(''));
614
+ return byPath.get(key);
615
+ };
616
+ diffPageCache.set(row.id, { stamp, files, byPath, filtered });
617
+ return { available: true, files, ...sliceBytes(filtered(str(input.path)), offset, maxBytes) };
618
+ },
619
+ async propose_run(input) {
620
+ const r = await deps.validateProposal(input);
621
+ // commentIds are a ONE-WAY hand-off: a comment cited here is stamped
622
+ // "sent to #<runId>" the moment the user starts the run, and nothing ever
623
+ // un-stamps it. Refuse ids from a different project/workspace than this
624
+ // proposal targets. Unknown ids stay tolerated (the user may have deleted
625
+ // one since); only a WRONG-target id is an error — and propose_run already
626
+ // reports {ok:false, errors}, so the model can fix it itself.
627
+ const cited = Array.isArray(input.commentIds) ? input.commentIds : [];
628
+ if (r && r.ok && cited.length && deps.comments && typeof deps.comments.get === 'function') {
629
+ const want = r.card.workspaceId ? `workspaces/${r.card.workspaceId}` : r.card.projectKey;
630
+ const bad = want ? cited.filter((id) => {
631
+ const c = typeof id === 'string' ? deps.comments.get(id) : null;
632
+ return !!c && c.storeKey !== want;
633
+ }) : [];
634
+ if (bad.length) {
635
+ return { ok: false, errors: [`these diff comments are not from ${want}: ${bad.join(', ')} — cite comments from a run of the project this proposal targets`] };
636
+ }
637
+ }
638
+ return r;
639
+ },
640
+ async list_diff_comments(input) {
641
+ const row = await resolveRow(input, 'list_diff_comments');
642
+ const status = str(input.status) || 'all';
643
+ if (!['all', 'unresolved', 'resolved'].includes(status)) {
644
+ throw new AskToolError('list_diff_comments: status must be all, unresolved or resolved');
645
+ }
646
+ // Archived runs return null here (get_run_diff's posture); the comments still
647
+ // list, they simply lose their surrounding context. line_text is always there,
648
+ // which is exactly what it exists for.
649
+ const patchText = row.archived_at ? null : await deps.readDiffPatch(row);
650
+ const raw = deps.comments.list(storeKeyOf(row), row.id,
651
+ { status, path: str(input.path) || null, patchText, keep: (c) => !commentBlocked(c) });
652
+ // The READ is the authority, exactly as in get_run_diff: creation already
653
+ // refuses protected anchors, but a preset can GROW afterwards, so re-evaluate
654
+ // now and omit the whole comment rather than trim it. BOTH sides, because a
655
+ // rename+edit is one section under its new name (old_path is persisted for
656
+ // exactly this check, which must also work once the patch is gone).
657
+ // Re-applied here even though `keep` was handed to the bundle above: the
658
+ // filter is this module's guarantee, not the bundle's, and it costs nothing
659
+ // on rows that are already gone.
660
+ const comments = raw.filter((c) => !commentBlocked(c)).map((c) => ({
661
+ ...shapeComment(c),
662
+ // Every string the model sees is redacted: line_text and the context come
663
+ // from the patch, and the BODY is user-authored text that can hold a pasted
664
+ // secret just as easily. shapeComment already redacts the first two.
665
+ ...(Array.isArray(c.context) && c.context.length ? { context: c.context.map((l) => deps.redact(l)) } : {}),
666
+ }));
667
+ return { runId: row.id, patchAvailable: patchText != null, comments };
668
+ },
669
+ async add_diff_comment(input) {
670
+ const row = await resolveRow(input, 'add_diff_comment');
671
+ if (row.archived_at) throw new AskToolError('add_diff_comment: this run is archived — its diff is gone');
672
+ const patchText = await deps.readDiffPatch(row);
673
+ if (!patchText) throw new AskToolError('add_diff_comment: this run has no stored diff — comments cannot be created on it');
674
+ try {
675
+ const comment = deps.comments.add({
676
+ storeKey: storeKeyOf(row), pipelineId: row.id, patchText,
677
+ project: str(input.memberProjectKey) || null,
678
+ path: str(input.path), side: str(input.side), line: input.line, body: input.body,
679
+ });
680
+ return { comment: shapeComment(comment) };
681
+ } catch (err) { throw asCommentError('add_diff_comment', err); }
682
+ },
683
+ async resolve_diff_comment(input) {
684
+ const id = str(input.commentId);
685
+ if (!id) throw new AskToolError('resolve_diff_comment: commentId is required');
686
+ // The read filter applies to EVERY tool that echoes a comment, not just to
687
+ // list_diff_comments (D5: "the read is the authority"). Without this check a
688
+ // comment created before the preset grew is still echoable by id — with its
689
+ // path and its line_text — which is exactly the leak list_diff_comments closes.
690
+ // The id is not obtainable from list, so this is defence in depth, and it is
691
+ // one line. Checked BEFORE the write, so a protected comment is not silently
692
+ // mutated either. Both rename sides, same as list.
693
+ const before = deps.comments.get(id);
694
+ if (!before || commentBlocked(before)) throw new AskToolError('resolve_diff_comment: comment not found');
695
+ // Explicit tri-state, not `input.resolved !== false`: mcp-stdio.mjs checks only
696
+ // that `arguments` is an object — inputSchema is never enforced — so a model
697
+ // sending "false", 0 or null would otherwise RESOLVE the comment. Every other
698
+ // tool validates its own inputs the same way (git, open_worktree).
699
+ if (input.resolved !== undefined && typeof input.resolved !== 'boolean') {
700
+ throw new AskToolError('resolve_diff_comment: resolved must be true or false');
701
+ }
702
+ const comment = deps.comments.setResolved(id, input.resolved !== false);
703
+ if (!comment) throw new AskToolError('resolve_diff_comment: comment not found');
704
+ return { comment: shapeComment(comment) };
705
+ },
706
+ async delete_diff_comment(input) {
707
+ const id = str(input.commentId);
708
+ if (!id) throw new AskToolError('delete_diff_comment: commentId is required');
709
+ // Read BEFORE removing: the parent process needs the run this touched to emit
710
+ // the poke, and after the row is gone there is nothing to read.
711
+ // Same read filter as resolve (D5): a comment the guard hides is not
712
+ // destroyable by id either, and the refusal is word-for-word the not-found
713
+ // one so the guard cannot become an existence oracle.
714
+ const before = deps.comments.get(id);
715
+ if (!before || commentBlocked(before)) throw new AskToolError('delete_diff_comment: comment not found');
716
+ // This is the ONLY irreversible capability in the Ask surface — everything
717
+ // else is propose-only or read-only — and the model reads untrusted text
718
+ // (diffs, run prompts, attachments) whose ids are enumerable from
719
+ // list_diff_comments. So it may retract its OWN notes and nothing else; the
720
+ // user deletes theirs from the Diff tab, behind a confirm (app.js:11323).
721
+ if (before.author !== 'ask') {
722
+ throw new AskToolError('delete_diff_comment: only comments Ask wrote can be deleted — the user deletes their own from the Diff tab');
723
+ }
724
+ if (!deps.comments.remove(id)) throw new AskToolError('delete_diff_comment: comment not found');
725
+ return { ok: true, commentId: id, comment: { runId: before.pipelineId, storeKey: before.storeKey } };
726
+ },
727
+ async read_attachment(input) {
728
+ const id = str(input.id);
729
+ if (!id) throw new AskToolError('read_attachment: id is required');
730
+ const a = deps.readAttachment(id);
731
+ if (!a) throw new AskToolError('read_attachment: attachment not found');
732
+ const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
733
+ const maxBytes = clampInt(input.maxBytes, 1, L.attachmentReadMaxBytes, L.attachmentReadDefaultBytes);
734
+ const { text, truncated, totalBytes, nextOffset } = sliceBytes(deps.redact(a.text), offset, maxBytes);
735
+ return { name: a.name, text, truncated, totalBytes, nextOffset };
736
+ },
737
+ async open_worktree(input) {
738
+ try {
739
+ const wt = await deps.worktrees.open({
740
+ projectKey: str(input.projectKey) || undefined,
741
+ ref: str(input.ref) || undefined,
742
+ runId: str(input.runId) || undefined,
743
+ });
744
+ return { worktreeId: wt.worktreeId, path: wt.path, projectKey: wt.projectKey, ref: wt.ref, commit: wt.commit };
745
+ } catch (err) { throw asToolError(err); }
746
+ },
747
+ async list_worktrees() {
748
+ return { worktrees: deps.worktrees.list().map((w) => ({
749
+ worktreeId: w.worktreeId, projectKey: w.projectKey, ref: w.ref, commit: w.commit,
750
+ path: w.path, createdAt: w.createdAt })) };
751
+ },
752
+ async remove_worktree(input) {
753
+ const id = str(input.worktreeId);
754
+ if (!id) throw new AskToolError('remove_worktree: worktreeId is required');
755
+ try { await deps.worktrees.remove(id); return { ok: true }; } catch (err) { throw asToolError(err); }
756
+ },
757
+ async git(input) {
758
+ const id = str(input.worktreeId);
759
+ const wt = id ? deps.worktrees.get(id) : null;
760
+ if (!wt) throw new AskToolError('git: worktree not found — open_worktree first');
761
+ const v = deps.worktrees.validateGitArgs(input.args);
762
+ if (!v.ok) throw new AskToolError(`git: ${v.error}`);
763
+ const bad = protectedInArgs(v.args);
764
+ if (bad) throw new AskToolError(`git: ${JSON.stringify(bad)} is a protected path — check out the ref and inspect it another way`);
765
+ // `show <rev>:<path>` is a raw file dump — the read this tool does not serve
766
+ // (blame/log -p/diff show a file's content WITH its path on every line).
767
+ if (v.args[0] === 'show' && v.args.slice(1).some((a) => !a.startsWith('-') && a.includes(':'))) {
768
+ throw new AskToolError('git show displays commits — a raw blob or tree is not readable through this tool');
769
+ }
770
+ await refuseBlobPositionals(wt.path, v.args);
771
+ if (v.fetch) {
772
+ const remotes = await deps.worktrees.runGit(wt.path, ['remote']);
773
+ const names = remotes.ok ? remotes.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
774
+ const target = v.args.slice(1).find((a) => !a.startsWith('-'));
775
+ if (target && !names.includes(target)) throw new AskToolError(`git: unknown remote ${JSON.stringify(target)} (configured: ${names.join(', ') || 'none'})`);
776
+ if (!target && !v.args.includes('--all') && !names.length) throw new AskToolError('git: no remotes configured in this repository');
777
+ }
778
+ // Prepend the trusted hardening -c; add --no-ext-diff/--no-color ONLY to the
779
+ // patch-capable subs (ls-files/ls-tree/grep reject --no-ext-diff). `grep` also
780
+ // gets a forced `-H` so every match line carries its path for the LINE filter
781
+ // below — belt-and-braces, since git-allowlist.mjs already refuses the forms
782
+ // that would beat it (`-h`/`--heading`/`-z`, which win as the later flag).
783
+ const argv = [...GIT_HARDEN, v.args[0], ...(v.args[0] === 'grep' ? ['-H'] : []), ...v.args.slice(1),
784
+ ...(PATCH_CAPABLE.has(v.args[0]) ? ['--no-ext-diff', '--no-color'] : [])];
785
+ const r = await deps.worktrees.runGit(wt.path, argv, { maxBytes: L.gitCaptureMaxBytes });
786
+ // Row follows checkout/switch AND fetch (§5): fetch re-reads HEAD + stamps updated_at.
787
+ if (r.ok && (v.nav || v.fetch)) {
788
+ const positional = v.nav ? (v.args.filter((a) => !a.startsWith('-'))[1] ?? wt.ref) : wt.ref;
789
+ await deps.worktrees.noteNav(id, { ref: positional });
790
+ }
791
+ // `grep` (no match) and `diff --exit-code` use exit 1 as DATA, not an error.
792
+ const emptyOk = r.code === 1 && !((r.stderr || '').trim()) && (v.args[0] === 'grep' || v.args[0] === 'diff');
793
+ if (!r.ok && !emptyOk) throw new AskToolError(`git: ${deps.redact((r.stderr || '').trim() || `exited ${r.code}`)}`);
794
+ let body = r.stdout;
795
+ // A merge's COMBINED diff (`diff --cc` / `diff --combined`, from `log -p --cc`,
796
+ // `--diff-merges=combined|cc|dense-combined`, or a hostile repo's
797
+ // `log.diffMerges` config) is NOT unified-diff shaped: splitUnifiedDiff cannot
798
+ // section it, so `hasPatch` misses it and the protected-path filter would ship
799
+ // a merged .env verbatim. Detected on the OUTPUT, so a config-driven combined
800
+ // diff is caught too. Refuse rather than filter — the model inspects a parent.
801
+ if (/(^|\n)diff --(cc|combined) /.test(body)) {
802
+ throw new AskToolError('git: combined merge diffs cannot be filtered here — inspect a single parent (e.g. `diff <merge>^1 <merge>`)');
803
+ }
804
+ const hasPatch = /(^|\n)diff --git /.test(body);
805
+ // (A `show` that names a blob/tree, or a `<rev>:<path>`, was refused BEFORE
806
+ // the spawn — see refuseBlobPositionals — so a patch-less `show` here is a
807
+ // legitimate commit view: `-s`, `--stat`, `--name-only`, `--format=`.)
808
+ if (hasPatch) {
809
+ // Protected-path section filter over ANY patch output (diff, show <commit>,
810
+ // log -p). A section whose header opened but whose path is protected on
811
+ // either side is dropped. A header-LESS section (a commit-message preamble,
812
+ // which with `--stat` also carries the diffstat) is kept but LINE-filtered,
813
+ // so a protected filename never surfaces there either. A colour-escaped or
814
+ // external-diff dump has no parseable header, so `hasPatch` is false and it
815
+ // never reaches here.
816
+ const protectedSide = (p) => !!p && isProtectedBasename(p, deps.protectedPaths);
817
+ body = splitUnifiedDiff(body)
818
+ .filter((s) => s.member || !s.header || (!!s.path && !protectedSide(s.path) && !protectedSide(s.oldPath)))
819
+ .map((s) => (s.member || s.header ? s.text : protectedLineFilter(s.text))).join('');
820
+ } else if (LIST_SUBS.has(v.args[0]) || PATCH_CAPABLE.has(v.args[0])) {
821
+ // grep/ls-files/ls-tree emit PATH LISTS, not diffs — the section filter would
822
+ // drop ALL output. So do the patch-LESS forms of diff/log/show (`--stat`,
823
+ // `--name-only`), whose diffstat names protected files with no `diff --git`
824
+ // header; get_run_diff omits those files entirely, so this matches it.
825
+ // Splitting on `[\s:]` alone left `id_rsa-3-KEY` as ONE token matching no
826
+ // pattern, so an EXACT-name protected file's neighbouring lines leaked
827
+ // (`.env*` only escaped that by its prefix glob) — hence every delimiter.
828
+ body = protectedLineFilter(body);
829
+ }
830
+ const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
831
+ const maxBytes = clampInt(input.maxBytes, 1, L.gitOutputMaxBytes, L.diffDefaultBytes);
832
+ if (r.truncated) body += `\n[output capped at ${L.gitCaptureMaxBytes} bytes — narrow the command (a path, a range, -n <count>)]\n`;
833
+ return { command: ['git', ...v.args].join(' '), ...(r.truncated ? { capped: true } : {}), ...sliceBytes(deps.redact(body), offset, maxBytes) };
834
+ },
835
+ };
836
+
837
+ return {
838
+ list: () => defs.map((d) => ({ ...d })),
839
+ async call(name, input) {
840
+ const fn = Object.prototype.hasOwnProperty.call(handlers, name) ? handlers[name] : null;
841
+ if (!fn) throw new AskToolError(`unknown tool: ${name}`);
842
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
843
+ throw new AskToolError(`${name}: input must be an object`);
844
+ }
845
+ return fn(input ?? {});
846
+ },
847
+ };
848
+ }