@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.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- 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
|
+
}
|