@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.0
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/CHANGELOG.md +46 -0
- package/README.md +8 -6
- package/README.tr.md +7 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -6
- package/docs/features.md +9 -4
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +126 -98
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/credential-inventory.sh +5 -5
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +177 -37
- package/pipeline/lib/post-pr-review.sh +4 -6
- package/pipeline/lib/route-state.sh +1 -1
- package/pipeline/lib/shadow-git.sh +4 -5
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
- package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +36 -1
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +104 -3
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +122 -3
- package/pipeline/scripts/_code-graph.mjs +2 -2
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/_transcript-hosts.mjs +406 -0
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/build-stack-plugins.mjs +3 -3
- package/pipeline/scripts/capture-flush.sh +7 -7
- package/pipeline/scripts/code-graph-rules/android.json +1 -1
- package/pipeline/scripts/cost-analyze.mjs +32 -43
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
- package/pipeline/scripts/edit-syntax-gate.sh +32 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/gc-abandoned.sh +2 -2
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/git-path.mjs +1 -2
- package/pipeline/scripts/host-hooks.mjs +265 -0
- package/pipeline/scripts/jira-attach.sh +1 -1
- package/pipeline/scripts/keychain.py +3 -3
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/output-quality-check.sh +1 -1
- package/pipeline/scripts/phase-tracker.sh +5 -6
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/pre-push-check.sh +3 -6
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +1131 -0
- package/pipeline/scripts/uninstall.mjs +90 -4
- package/pipeline/scripts/update-issue-progress.sh +4 -6
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +20 -12
- package/pipeline/skills/.skills-index.json +23 -1
- package/pipeline/skills/shared/README.md +5 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/skills-index.md +3 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* edit-syntax-gate.mjs - parse the file an edit just wrote, report a syntax error.
|
|
4
|
+
*
|
|
5
|
+
* Called by edit-syntax-gate.sh as a PostToolUse hook. Three hosts' payloads
|
|
6
|
+
* are read (editedFiles):
|
|
7
|
+
*
|
|
8
|
+
* Claude Code tool_name Edit / Write / MultiEdit, tool_input.file_path
|
|
9
|
+
* Copilot CLI toolName edit / create, toolArgs.path (toolArgs arrives as a
|
|
10
|
+
* JSON string); the VS Code-compatible snake_case form carries
|
|
11
|
+
* tool_name and tool_input.path instead. Read and answered,
|
|
12
|
+
* but not registered by the pipeline: as of 1.0.45 Copilot
|
|
13
|
+
* CLI discards postToolUse output and applies no matcher
|
|
14
|
+
* Codex tool_name apply_patch, tool_input.command holding the patch
|
|
15
|
+
* text; every file it adds, updates or moves to is checked
|
|
16
|
+
*
|
|
17
|
+
* For each edited file it picks one parser by extension, runs it under a short
|
|
18
|
+
* deadline and, when the file does not parse, prints the context object that
|
|
19
|
+
* host reads back (hookSpecificOutput.additionalContext on Claude Code and
|
|
20
|
+
* Codex, a top-level additionalContext on Copilot CLI) so the model sees the
|
|
21
|
+
* error before its next step:
|
|
22
|
+
*
|
|
23
|
+
* .swift swiftc -parse (when swiftc is on PATH)
|
|
24
|
+
* .js .mjs .cjs node --check (a file that looks like JSX: skipped)
|
|
25
|
+
* .py python3 py_compile, bytecode written to a temp dir
|
|
26
|
+
* .json JSON.parse after a UTF-8 BOM is dropped; files that
|
|
27
|
+
* are JSONC by convention (tsconfig / jsconfig, .vscode,
|
|
28
|
+
* .devcontainer, .eslintrc, turbo, deno) are skipped
|
|
29
|
+
* .sh .bash bash -n
|
|
30
|
+
*
|
|
31
|
+
* Kotlin has no parser that answers inside the deadline (kotlinc starts a JVM),
|
|
32
|
+
* so .kt / .kts are skipped, as is every other extension (.jsx and .jsonc
|
|
33
|
+
* among them).
|
|
34
|
+
*
|
|
35
|
+
* Silent on: the gate switched off (`editSyntaxGate.enabled` not true), a parser
|
|
36
|
+
* not installed, a parser that runs past the deadline, a file that is gone or
|
|
37
|
+
* too large, an unreadable payload. A missing tool is the user's toolchain, not
|
|
38
|
+
* the edit's fault, and a false alarm costs more than a missed one: the build
|
|
39
|
+
* still catches what this lets through.
|
|
40
|
+
*
|
|
41
|
+
* One hook call checks at most 8 files within about 8 s overall (the hook is
|
|
42
|
+
* killed at 10 s); files past either limit are not checked, and their count
|
|
43
|
+
* is added to a message that reports an error.
|
|
44
|
+
*
|
|
45
|
+
* Exit 0 always.
|
|
46
|
+
*
|
|
47
|
+
* @module pipeline/scripts/edit-syntax-gate
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { spawnSync } from "node:child_process";
|
|
51
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
52
|
+
import { homedir, tmpdir } from "node:os";
|
|
53
|
+
import { basename, extname, join, resolve, sep } from "node:path";
|
|
54
|
+
|
|
55
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
56
|
+
|
|
57
|
+
const CLAUDE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
|
|
58
|
+
const COPILOT_EDIT_TOOLS = new Set(["edit", "create"]);
|
|
59
|
+
const CODEX_PATCH_TOOL = "apply_patch";
|
|
60
|
+
const DEADLINE_MS = 2000;
|
|
61
|
+
const BUDGET_MS = 8000;
|
|
62
|
+
const MAX_FILES = 8;
|
|
63
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
64
|
+
const MAX_LINES = 6;
|
|
65
|
+
const MAX_CHARS = 800;
|
|
66
|
+
const NODE_INTERNAL_FRAME = /^\s*at .*\(?node:internal[/:]/;
|
|
67
|
+
const PREF = "editSyntaxGate.enabled";
|
|
68
|
+
|
|
69
|
+
function prefsCandidates() {
|
|
70
|
+
const home = process.env.HOME || homedir();
|
|
71
|
+
return [
|
|
72
|
+
process.env.MULTI_AGENT_PREFS,
|
|
73
|
+
join(home, ".claude", "multi-agent-preferences.json"),
|
|
74
|
+
join(home, ".config", "multi-agent-pipeline", "multi-agent-preferences.json"),
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `prefs.global.editSyntaxGate.enabled` from the first prefs file that exists. */
|
|
79
|
+
export function gateEnabled(paths = prefsCandidates()) {
|
|
80
|
+
for (const p of paths) {
|
|
81
|
+
if (!existsSync(p)) continue;
|
|
82
|
+
try {
|
|
83
|
+
let node = JSON.parse(readFileSync(p, "utf8"))?.global;
|
|
84
|
+
for (const key of PREF.split(".")) node = node?.[key];
|
|
85
|
+
return node === true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const JSONC_NAMES =
|
|
94
|
+
/^(?:(?:tsconfig|jsconfig)(?:\..+)?\.json|\.eslintrc\.json|turbo\.json|deno\.jsonc?|devcontainer\.json|\.devcontainer\.json)$/;
|
|
95
|
+
|
|
96
|
+
function isJsonc(file) {
|
|
97
|
+
return (
|
|
98
|
+
JSONC_NAMES.test(basename(file)) ||
|
|
99
|
+
file.includes(`${sep}.vscode${sep}`) ||
|
|
100
|
+
file.includes(`${sep}.devcontainer${sep}`)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `text` with string literals and comments blanked, so only code is left. */
|
|
105
|
+
function codeOnly(text) {
|
|
106
|
+
let out = "";
|
|
107
|
+
for (let i = 0; i < text.length; i++) {
|
|
108
|
+
const c = text[i];
|
|
109
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
110
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
111
|
+
out += "\n";
|
|
112
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
113
|
+
const end = text.indexOf("*/", i + 2);
|
|
114
|
+
i = end === -1 ? text.length : end + 1;
|
|
115
|
+
out += " ";
|
|
116
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
117
|
+
for (i++; i < text.length && text[i] !== c; i++) {
|
|
118
|
+
if (text[i] === "\\") i++;
|
|
119
|
+
else if (c !== "`" && text[i] === "\n") break;
|
|
120
|
+
}
|
|
121
|
+
out += " ";
|
|
122
|
+
} else out += c;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const JSX_TAG = /<[A-Z][\w.]*(?=[\s/>])|<\/[A-Za-z]?/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when the code (strings and comments aside) carries an element-like
|
|
131
|
+
* `<Capitalized` or a closing `</`. node --check rejects JSX, and a file that
|
|
132
|
+
* only looks like JSX is skipped rather than flagged.
|
|
133
|
+
*/
|
|
134
|
+
export function looksLikeJsx(text) {
|
|
135
|
+
return JSX_TAG.test(codeOnly(text));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function run(cmd, args, cwd) {
|
|
139
|
+
const r = spawnSync(cmd, args, {
|
|
140
|
+
cwd,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
timeout: DEADLINE_MS,
|
|
143
|
+
killSignal: "SIGKILL",
|
|
144
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
145
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
|
146
|
+
});
|
|
147
|
+
if (r.error || r.signal || r.status === null) return null;
|
|
148
|
+
if (r.status === 0) return { ok: true };
|
|
149
|
+
return { ok: false, output: `${r.stderr || ""}${r.stdout || ""}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse one file. `null` = not judged (no parser, parser missing, deadline);
|
|
154
|
+
* `{ok}` = parsed; `{ok: false, checker, output}` = syntax error.
|
|
155
|
+
*/
|
|
156
|
+
export function checkFile(file) {
|
|
157
|
+
const ext = extname(file).toLowerCase();
|
|
158
|
+
if (ext === ".json") {
|
|
159
|
+
if (isJsonc(file)) return null;
|
|
160
|
+
try {
|
|
161
|
+
JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
162
|
+
return { ok: true };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ok: false, checker: "JSON.parse", output: err.message };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const scratch = mkdtempSync(join(tmpdir(), "edit-syntax-gate-"));
|
|
168
|
+
try {
|
|
169
|
+
let checker;
|
|
170
|
+
let res;
|
|
171
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
172
|
+
if (looksLikeJsx(readFileSync(file, "utf8"))) return null;
|
|
173
|
+
checker = "node --check";
|
|
174
|
+
res = run(process.execPath, ["--check", file], scratch);
|
|
175
|
+
} else if (ext === ".sh" || ext === ".bash") {
|
|
176
|
+
checker = "bash -n";
|
|
177
|
+
res = run("bash", ["-n", file], scratch);
|
|
178
|
+
} else if (ext === ".py") {
|
|
179
|
+
checker = "python3 py_compile";
|
|
180
|
+
res = run(
|
|
181
|
+
"python3",
|
|
182
|
+
[
|
|
183
|
+
"-I",
|
|
184
|
+
"-c",
|
|
185
|
+
[
|
|
186
|
+
"import py_compile, sys",
|
|
187
|
+
"try:",
|
|
188
|
+
" py_compile.compile(sys.argv[1], cfile=sys.argv[2], doraise=True)",
|
|
189
|
+
"except py_compile.PyCompileError as e:",
|
|
190
|
+
" sys.stderr.write(e.msg)",
|
|
191
|
+
" sys.exit(1)",
|
|
192
|
+
].join("\n"),
|
|
193
|
+
file,
|
|
194
|
+
join(scratch, "out.pyc"),
|
|
195
|
+
],
|
|
196
|
+
scratch,
|
|
197
|
+
);
|
|
198
|
+
} else if (ext === ".swift") {
|
|
199
|
+
checker = "swiftc -parse";
|
|
200
|
+
res = run("swiftc", ["-parse", file], scratch);
|
|
201
|
+
} else {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (!res) return null;
|
|
205
|
+
return res.ok ? res : { ...res, checker };
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function brief(output) {
|
|
212
|
+
const lines = String(output || "")
|
|
213
|
+
.split("\n")
|
|
214
|
+
.map((l) => l.trimEnd())
|
|
215
|
+
.filter((l) => l && !NODE_INTERNAL_FRAME.test(l));
|
|
216
|
+
const text = lines.slice(0, MAX_LINES).join("\n");
|
|
217
|
+
return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}...` : text;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const PATCH_TARGET = /^\*\*\* (?:Add File|Update File|Move to): (.+)$/;
|
|
221
|
+
const PATCH_MOVE = /^\*\*\* Move to: /;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The files an apply_patch leaves on disk, in patch order: every added and
|
|
225
|
+
* updated file, with a moved file named by its new path. Deleted files are
|
|
226
|
+
* gone, so they are not listed.
|
|
227
|
+
*/
|
|
228
|
+
export function patchFiles(patch) {
|
|
229
|
+
if (typeof patch !== "string") return [];
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
232
|
+
const m = line.match(PATCH_TARGET);
|
|
233
|
+
if (!m) continue;
|
|
234
|
+
const path = m[1].trim();
|
|
235
|
+
if (!path) continue;
|
|
236
|
+
if (PATCH_MOVE.test(line) && out.length) out[out.length - 1] = path;
|
|
237
|
+
else out.push(path);
|
|
238
|
+
}
|
|
239
|
+
return [...new Set(out)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function argsObject(raw) {
|
|
243
|
+
if (raw && typeof raw === "object") return raw;
|
|
244
|
+
if (typeof raw !== "string") return null;
|
|
245
|
+
try {
|
|
246
|
+
const v = JSON.parse(raw);
|
|
247
|
+
return v && typeof v === "object" ? v : null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Which host sent `payload` and the absolute paths it edited, or null for a
|
|
255
|
+
* call that wrote no file. Relative paths resolve against the payload's cwd,
|
|
256
|
+
* else the process cwd: the checkers run in a scratch directory.
|
|
257
|
+
*
|
|
258
|
+
* @returns {{host: "claude"|"copilot"|"copilot-vscode"|"codex", files: string[]} | null}
|
|
259
|
+
*/
|
|
260
|
+
export function editedFiles(payload) {
|
|
261
|
+
if (!payload || typeof payload !== "object") return null;
|
|
262
|
+
const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
263
|
+
const abs = (paths) =>
|
|
264
|
+
paths.filter((p) => typeof p === "string" && p).map((p) => resolve(base, p));
|
|
265
|
+
let host;
|
|
266
|
+
let paths;
|
|
267
|
+
if (typeof payload.toolName === "string") {
|
|
268
|
+
if (!COPILOT_EDIT_TOOLS.has(payload.toolName)) return null;
|
|
269
|
+
host = "copilot";
|
|
270
|
+
paths = [argsObject(payload.toolArgs)?.path];
|
|
271
|
+
} else if (payload.tool_name === CODEX_PATCH_TOOL) {
|
|
272
|
+
host = "codex";
|
|
273
|
+
paths = patchFiles(payload.tool_input?.command);
|
|
274
|
+
} else if (CLAUDE_EDIT_TOOLS.has(payload.tool_name)) {
|
|
275
|
+
host = "claude";
|
|
276
|
+
paths = [payload.tool_input?.file_path];
|
|
277
|
+
} else if (COPILOT_EDIT_TOOLS.has(payload.tool_name)) {
|
|
278
|
+
host = "copilot-vscode";
|
|
279
|
+
const input = argsObject(payload.tool_input);
|
|
280
|
+
paths = [input?.path ?? input?.file_path];
|
|
281
|
+
} else {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const files = abs(paths);
|
|
285
|
+
return files.length ? { host, files } : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function report(file, result) {
|
|
289
|
+
return `edit-syntax-gate: ${file} no longer parses (${result.checker}). Fix it before going on:\n${brief(result.output)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function contextFor(text, host) {
|
|
293
|
+
if (host === "copilot") return { additionalContext: text };
|
|
294
|
+
return { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The PostToolUse context object `host` reads for a syntax error, or null. */
|
|
298
|
+
export function message(file, result, host = "claude") {
|
|
299
|
+
if (!result || result.ok) return null;
|
|
300
|
+
return contextFor(report(file, result), host);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Check `files` in order within the hook's time: at most `maxFiles` of them,
|
|
305
|
+
* and none started once a further parser deadline would overrun `budgetMs`
|
|
306
|
+
* (the hook itself is killed at 10 s). Files left over are not checked, and
|
|
307
|
+
* their count is always reported: appended to the error message, or as a note
|
|
308
|
+
* of its own when every checked file parsed, so a skipped file that may be
|
|
309
|
+
* broken is not passed off as clean.
|
|
310
|
+
*
|
|
311
|
+
* @returns {{reports: string[], skipped: number, text: string|null}}
|
|
312
|
+
*/
|
|
313
|
+
export function checkEdited(
|
|
314
|
+
files,
|
|
315
|
+
{ budgetMs = BUDGET_MS, maxFiles = MAX_FILES, now = Date.now } = {},
|
|
316
|
+
) {
|
|
317
|
+
const start = now();
|
|
318
|
+
const reports = [];
|
|
319
|
+
let checked = 0;
|
|
320
|
+
let i = 0;
|
|
321
|
+
for (; i < files.length; i++) {
|
|
322
|
+
if (checked >= maxFiles || now() - start + DEADLINE_MS > budgetMs) break;
|
|
323
|
+
const file = files[i];
|
|
324
|
+
let st;
|
|
325
|
+
try {
|
|
326
|
+
st = statSync(file);
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!st.isFile() || st.size > MAX_BYTES) continue;
|
|
331
|
+
checked++;
|
|
332
|
+
const result = checkFile(file);
|
|
333
|
+
if (result && !result.ok) reports.push(report(file, result));
|
|
334
|
+
}
|
|
335
|
+
const skipped = files.length - i;
|
|
336
|
+
let text = reports.length ? reports.join("\n\n") : null;
|
|
337
|
+
if (text && skipped)
|
|
338
|
+
text += `\n\n(${skipped} more file(s) not checked: time or file-count limit)`;
|
|
339
|
+
else if (skipped)
|
|
340
|
+
text = `edit-syntax-gate: the checked files parse; ${skipped} file(s) not checked (time or file-count limit), so their syntax is unknown.`;
|
|
341
|
+
return { reports, skipped, text };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function main() {
|
|
345
|
+
let payload;
|
|
346
|
+
try {
|
|
347
|
+
payload = JSON.parse(readFileSync(0, "utf8"));
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const edit = editedFiles(payload);
|
|
352
|
+
if (!edit) return;
|
|
353
|
+
if (!gateEnabled()) return;
|
|
354
|
+
const { text } = checkEdited(edit.files);
|
|
355
|
+
if (text) process.stdout.write(`${JSON.stringify(contextFor(text, edit.host))}\n`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Fail open: a gate bug must not surface as a hook error on every edit.
|
|
359
|
+
if (invokedDirectly(import.meta.url)) {
|
|
360
|
+
const done = () => process.exit(0);
|
|
361
|
+
main().catch(done).then(done);
|
|
362
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# edit-syntax-gate.sh - PostToolUse syntax check for the multi-agent pipeline.
|
|
3
|
+
#
|
|
4
|
+
# Runs after a file edit (Claude Code Edit / Write / MultiEdit, Codex
|
|
5
|
+
# apply_patch) and parses the edited file with the
|
|
6
|
+
# fastest parser its extension has, so a broken file is reported in the turn
|
|
7
|
+
# that broke it instead of at the next build. The parsing is in
|
|
8
|
+
# edit-syntax-gate.mjs; this wrapper exists because every hook the pipeline
|
|
9
|
+
# registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
|
|
10
|
+
#
|
|
11
|
+
# Contract (PostToolUse hook on each host):
|
|
12
|
+
# - Reads the tool-call JSON on stdin.
|
|
13
|
+
# - On a parse error prints the context object the host reads back on
|
|
14
|
+
# stdout ({"hookSpecificOutput": {"hookEventName": "PostToolUse",
|
|
15
|
+
# "additionalContext": "..."}}), which the model reads as context for its
|
|
16
|
+
# next step. Copilot CLI's payload is read too, but the pipeline does not
|
|
17
|
+
# register the hook there: as of 1.0.45 Copilot discards its output.
|
|
18
|
+
# - Exits 0 on every path: the edit has already happened, so blocking would
|
|
19
|
+
# only hide the message.
|
|
20
|
+
#
|
|
21
|
+
# Inert unless `prefs.global.editSyntaxGate.enabled` is true. No node on PATH,
|
|
22
|
+
# a missing parser, a timeout or any internal error means no message.
|
|
23
|
+
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
27
|
+
CORE="$HERE/edit-syntax-gate.mjs"
|
|
28
|
+
[ -f "$CORE" ] || exit 0
|
|
29
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
30
|
+
|
|
31
|
+
node "$CORE" 2>/dev/null || true
|
|
32
|
+
exit 0
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* find-polluter.mjs - which test leaves state behind that something else trips on?
|
|
4
|
+
*
|
|
5
|
+
* Two symptoms, one search:
|
|
6
|
+
*
|
|
7
|
+
* --absent <path> a file or directory appears after the suite runs and no
|
|
8
|
+
* test should leave it behind
|
|
9
|
+
* --target <id> a test that passes on its own fails after some others
|
|
10
|
+
*
|
|
11
|
+
* Given the candidate tests in suite order and a command that runs one of them,
|
|
12
|
+
* it bisects the list: run half of the candidates from a clean state, check the
|
|
13
|
+
* symptom, keep the half that reproduces it. When neither half does on its own,
|
|
14
|
+
* the pollution needs a test from each, and both halves are searched with the
|
|
15
|
+
* other held in place, so the answer is the smallest set that still reproduces
|
|
16
|
+
* it (usually one test, sometimes a pair). Each trial costs at most the tests in
|
|
17
|
+
* that half; a single polluter among n tests is found in about 2*log2(n) trials.
|
|
18
|
+
*
|
|
19
|
+
* Clean state between trials: with --absent the path is removed (only a path
|
|
20
|
+
* under --cwd whose parent, symlinks resolved, is under --cwd too, and only
|
|
21
|
+
* one that did not exist when the search started; one that already exists,
|
|
22
|
+
* a dangling symlink included, is refused rather than deleted). --reset "<cmd>" runs
|
|
23
|
+
* before every trial for anything else the tests mutate. Without a reset,
|
|
24
|
+
* --target assumes each trial starts clean on its own, and checks it: when the
|
|
25
|
+
* target fails alone after the full-list trial, the search stops with exit 2
|
|
26
|
+
* rather than blaming whichever test ran first.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* node find-polluter.mjs --run "<command with {test}>" (--list <file> | --test <id>...)
|
|
30
|
+
* (--absent <path> | --target <id>) [--reset "<cmd>"] [--cwd <dir>]
|
|
31
|
+
* [--timeout-sec n] [--json]
|
|
32
|
+
*
|
|
33
|
+
* {test} is replaced by the shell-quoted test id; a command without it gets
|
|
34
|
+
* the id appended. --list holds one id per line (blank and # lines skipped).
|
|
35
|
+
* Commands run through /bin/sh from --cwd (default: the current directory).
|
|
36
|
+
*
|
|
37
|
+
* Exit: 0 one polluter found · 4 only a combination reproduces it (all named) ·
|
|
38
|
+
* 3 the full list does not reproduce the symptom · 2 usage or setup error
|
|
39
|
+
*
|
|
40
|
+
* Zero runtime dependencies.
|
|
41
|
+
*
|
|
42
|
+
* @module pipeline/scripts/find-polluter
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { spawnSync } from "node:child_process";
|
|
46
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
47
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
48
|
+
|
|
49
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_TIMEOUT_SEC = 300;
|
|
52
|
+
|
|
53
|
+
class UsageError extends Error {}
|
|
54
|
+
|
|
55
|
+
const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
|
|
57
|
+
/** Fill {test}; append the id when the command names none. */
|
|
58
|
+
export function renderRun(template, id) {
|
|
59
|
+
if (!template.includes("{test}")) return `${template} ${shellQuote(id)}`;
|
|
60
|
+
return template.replaceAll("{test}", shellQuote(id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Smallest subset of `items` for which `pollutes` holds, found by halving.
|
|
65
|
+
* The caller has already seen `pollutes(items)` hold. Subsets are passed in
|
|
66
|
+
* the original order.
|
|
67
|
+
*
|
|
68
|
+
* @template T
|
|
69
|
+
* @param {T[]} items
|
|
70
|
+
* @param {(subset: T[]) => boolean} pollutes
|
|
71
|
+
* @returns {{culprits: T[], trials: number}}
|
|
72
|
+
*/
|
|
73
|
+
export function bisect(items, pollutes) {
|
|
74
|
+
let trials = 0;
|
|
75
|
+
const order = (idx) => [...new Set(idx)].sort((a, b) => a - b);
|
|
76
|
+
const test = (idx) => {
|
|
77
|
+
trials++;
|
|
78
|
+
return pollutes(order(idx).map((i) => items[i]));
|
|
79
|
+
};
|
|
80
|
+
const search = (cands, context) => {
|
|
81
|
+
if (cands.length <= 1) return cands;
|
|
82
|
+
const mid = Math.ceil(cands.length / 2);
|
|
83
|
+
const a = cands.slice(0, mid);
|
|
84
|
+
const b = cands.slice(mid);
|
|
85
|
+
if (test([...context, ...a])) return search(a, context);
|
|
86
|
+
if (test([...context, ...b])) return search(b, context);
|
|
87
|
+
const fromA = search(a, [...context, ...b]);
|
|
88
|
+
return [...fromA, ...search(b, [...context, ...fromA])];
|
|
89
|
+
};
|
|
90
|
+
const found = search(
|
|
91
|
+
items.map((_, i) => i),
|
|
92
|
+
[],
|
|
93
|
+
);
|
|
94
|
+
return { culprits: order(found).map((i) => items[i]), trials };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseArgs(argv) {
|
|
98
|
+
const opts = { tests: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
|
|
99
|
+
for (let i = 0; i < argv.length; i++) {
|
|
100
|
+
const a = argv[i];
|
|
101
|
+
const val = () => {
|
|
102
|
+
const v = argv[++i];
|
|
103
|
+
if (v === undefined) throw new UsageError(`${a} needs a value`);
|
|
104
|
+
return v;
|
|
105
|
+
};
|
|
106
|
+
if (a === "--run") opts.run = val();
|
|
107
|
+
else if (a === "--list") opts.list = val();
|
|
108
|
+
else if (a === "--test") opts.tests.push(val());
|
|
109
|
+
else if (a === "--absent") opts.absent = val();
|
|
110
|
+
else if (a === "--target") opts.target = val();
|
|
111
|
+
else if (a === "--reset") opts.reset = val();
|
|
112
|
+
else if (a === "--cwd") opts.cwd = val();
|
|
113
|
+
else if (a === "--timeout-sec") opts.timeoutSec = Number(val());
|
|
114
|
+
else if (a === "--json") opts.json = true;
|
|
115
|
+
else throw new UsageError(`unexpected argument ${a}`);
|
|
116
|
+
}
|
|
117
|
+
if (!opts.run) throw new UsageError("--run is required");
|
|
118
|
+
if (Boolean(opts.absent) === Boolean(opts.target)) {
|
|
119
|
+
throw new UsageError("exactly one of --absent or --target is required");
|
|
120
|
+
}
|
|
121
|
+
if (!(opts.timeoutSec > 0)) throw new UsageError("--timeout-sec must be a positive number");
|
|
122
|
+
if (opts.list) {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = readFileSync(opts.list, "utf8");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new UsageError(`cannot read --list: ${err.message}`);
|
|
128
|
+
}
|
|
129
|
+
for (const line of text.split("\n")) {
|
|
130
|
+
const id = line.trim();
|
|
131
|
+
if (id && !id.startsWith("#")) opts.tests.push(id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
opts.tests = opts.tests.filter((t) => t !== opts.target);
|
|
135
|
+
if (opts.tests.length === 0) throw new UsageError("no candidate tests");
|
|
136
|
+
return opts;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const inside = (child, parent) => {
|
|
140
|
+
const rel = relative(parent, child);
|
|
141
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel) && !rel.split(sep).includes("..");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** True when something, a dangling symlink included, sits at `path`. */
|
|
145
|
+
function present(path) {
|
|
146
|
+
try {
|
|
147
|
+
lstatSync(path);
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* True when the nearest existing ancestor of `path`, with symlinks resolved,
|
|
156
|
+
* is `cwd` or lies under it, so removing `path` cannot reach outside.
|
|
157
|
+
*/
|
|
158
|
+
function parentInside(path, cwd) {
|
|
159
|
+
let dir = dirname(path);
|
|
160
|
+
while (!existsSync(dir)) {
|
|
161
|
+
const up = dirname(dir);
|
|
162
|
+
if (up === dir) return false;
|
|
163
|
+
dir = up;
|
|
164
|
+
}
|
|
165
|
+
const real = realpathSync(dir);
|
|
166
|
+
return real === cwd || inside(real, cwd);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sh(command, cwd, timeoutMs) {
|
|
170
|
+
const { NODE_TEST_CONTEXT: _inherited, ...env } = process.env;
|
|
171
|
+
const r = spawnSync("/bin/sh", ["-c", command], {
|
|
172
|
+
cwd,
|
|
173
|
+
env,
|
|
174
|
+
stdio: "ignore",
|
|
175
|
+
timeout: timeoutMs,
|
|
176
|
+
killSignal: "SIGKILL",
|
|
177
|
+
});
|
|
178
|
+
return r.status === 0 && !r.error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @returns {{exit: number, report: object}}
|
|
183
|
+
*/
|
|
184
|
+
export function findPolluter(argv) {
|
|
185
|
+
const opts = parseArgs(argv);
|
|
186
|
+
const cwd = realpathSync(resolve(opts.cwd || process.cwd()));
|
|
187
|
+
const timeoutMs = Math.round(opts.timeoutSec * 1000);
|
|
188
|
+
const runTest = (id) => sh(renderRun(opts.run, id), cwd, timeoutMs);
|
|
189
|
+
|
|
190
|
+
let stray = null;
|
|
191
|
+
if (opts.absent) {
|
|
192
|
+
stray = resolve(cwd, opts.absent);
|
|
193
|
+
if (!inside(stray, cwd) || !parentInside(stray, cwd)) {
|
|
194
|
+
throw new UsageError(`--absent ${opts.absent} is not under --cwd ${cwd}`);
|
|
195
|
+
}
|
|
196
|
+
if (present(stray)) {
|
|
197
|
+
throw new UsageError(`${opts.absent} already exists; remove it before searching`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let runs = 0;
|
|
202
|
+
const reset = () => {
|
|
203
|
+
if (stray && parentInside(stray, cwd)) rmSync(stray, { recursive: true, force: true });
|
|
204
|
+
if (opts.reset) sh(opts.reset, cwd, timeoutMs);
|
|
205
|
+
};
|
|
206
|
+
const pollutes = (subset) => {
|
|
207
|
+
reset();
|
|
208
|
+
for (const id of subset) {
|
|
209
|
+
runTest(id);
|
|
210
|
+
runs++;
|
|
211
|
+
}
|
|
212
|
+
if (stray) return present(stray);
|
|
213
|
+
runs++;
|
|
214
|
+
return !runTest(opts.target);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const report = {
|
|
218
|
+
symptom: stray ? { absent: opts.absent } : { target: opts.target },
|
|
219
|
+
candidates: opts.tests.length,
|
|
220
|
+
polluters: [],
|
|
221
|
+
alone: null,
|
|
222
|
+
trials: 0,
|
|
223
|
+
runs: 0,
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
if (!stray) {
|
|
227
|
+
reset();
|
|
228
|
+
runs++;
|
|
229
|
+
if (!runTest(opts.target)) throw new UsageError(`${opts.target} fails on its own`);
|
|
230
|
+
}
|
|
231
|
+
if (!pollutes(opts.tests)) {
|
|
232
|
+
report.reason = "the full list does not reproduce the symptom";
|
|
233
|
+
return { exit: 3, report };
|
|
234
|
+
}
|
|
235
|
+
if (!stray && !opts.reset) {
|
|
236
|
+
runs++;
|
|
237
|
+
if (!runTest(opts.target)) {
|
|
238
|
+
throw new UsageError(
|
|
239
|
+
`${opts.target} now fails on its own: state persists between trials, pass --reset "<cmd>" to restore it`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const { culprits, trials } = bisect(opts.tests, pollutes);
|
|
244
|
+
report.polluters = culprits;
|
|
245
|
+
report.trials = trials;
|
|
246
|
+
report.alone = culprits.length === 1 ? pollutes(culprits) : false;
|
|
247
|
+
return { exit: culprits.length === 1 ? 0 : 4, report };
|
|
248
|
+
} finally {
|
|
249
|
+
reset();
|
|
250
|
+
report.runs = runs;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function main(argv) {
|
|
255
|
+
let result;
|
|
256
|
+
try {
|
|
257
|
+
result = findPolluter(argv);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
process.stderr.write(`find-polluter: ${err.message}\n`);
|
|
260
|
+
if (err instanceof UsageError) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
'usage: find-polluter.mjs --run "<cmd with {test}>" (--list file | --test id...) (--absent path | --target id) [--reset "<cmd>"] [--cwd dir] [--timeout-sec n] [--json]\n',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
const { exit, report } = result;
|
|
268
|
+
if (argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
269
|
+
else if (report.polluters.length) {
|
|
270
|
+
const how = report.alone ? "on its own" : "together";
|
|
271
|
+
process.stdout.write(`polluter (${how}): ${report.polluters.join(", ")}\n`);
|
|
272
|
+
process.stdout.write(`${report.trials} trials, ${report.runs} test runs\n`);
|
|
273
|
+
} else {
|
|
274
|
+
process.stdout.write(`${report.reason}\n`);
|
|
275
|
+
}
|
|
276
|
+
return exit;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (invokedDirectly(import.meta.url)) {
|
|
280
|
+
process.exit(main(process.argv.slice(2)));
|
|
281
|
+
}
|