@mmerterden/multi-agent-pipeline 20.23.2 → 20.24.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 +22 -0
- package/README.md +7 -6
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/manifest.json +75 -54
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/plan-todos.sh +156 -33
- 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 +4 -4
- 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/model-fallback.md +4 -2
- 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 +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
- 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/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +31 -1
- package/pipeline/schemas/plan-todos.schema.json +101 -0
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +96 -0
- 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/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/cost-analyze.mjs +6 -24
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
- package/pipeline/scripts/edit-syntax-gate.sh +29 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- 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 +950 -0
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +11 -7
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -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/skills-index.md +2 -1
|
@@ -46,20 +46,12 @@
|
|
|
46
46
|
* 2 usage error
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import {
|
|
50
|
-
createReadStream,
|
|
51
|
-
existsSync,
|
|
52
|
-
mkdirSync,
|
|
53
|
-
readdirSync,
|
|
54
|
-
readFileSync,
|
|
55
|
-
statSync,
|
|
56
|
-
writeFileSync,
|
|
57
|
-
} from "node:fs";
|
|
58
|
-
import { createInterface } from "node:readline";
|
|
49
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
59
50
|
import { homedir } from "node:os";
|
|
60
51
|
import { dirname, join } from "node:path";
|
|
61
52
|
import { fileURLToPath } from "node:url";
|
|
62
53
|
import { costUsd } from "./_cost.mjs";
|
|
54
|
+
import { readJsonl } from "./_jsonl.mjs";
|
|
63
55
|
import { runMain } from "../lib/fatal.mjs";
|
|
64
56
|
import { messages, outputLanguage } from "../lib/user-facing.mjs";
|
|
65
57
|
|
|
@@ -263,20 +255,10 @@ async function readSeries({ days }) {
|
|
|
263
255
|
const unpricedModels = new Set();
|
|
264
256
|
|
|
265
257
|
for (const f of files) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
for await (const line of rl) {
|
|
273
|
-
if (!line || line.charCodeAt(0) !== 123) continue;
|
|
274
|
-
let d;
|
|
275
|
-
try {
|
|
276
|
-
d = JSON.parse(line);
|
|
277
|
-
} catch {
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
258
|
+
// Uncapped: a cost total that silently stops at a byte cap under-reports.
|
|
259
|
+
for await (const row of readJsonl(f.path, { maxBytes: Infinity, maxLineBytes: Infinity })) {
|
|
260
|
+
const d = row.record;
|
|
261
|
+
if (!d) continue;
|
|
280
262
|
const msg = d.message;
|
|
281
263
|
const u = msg?.usage;
|
|
282
264
|
if (!u) continue;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decisions-log.mjs - append to and render `state.decisions[]`, the list of
|
|
4
|
+
* choices an autopilot run made where an attended run would have asked.
|
|
5
|
+
*
|
|
6
|
+
* Writers: a picker resolved without asking (picker-contract.md, autopilot),
|
|
7
|
+
* a split consensus taken on the triage verdict, a model fallback taken
|
|
8
|
+
* without the date-gate question, a rework dispatched one rung up
|
|
9
|
+
* (rework-escalation.mjs). gate-ledger.mjs park and review-decision-gate.mjs
|
|
10
|
+
* write their own entries through lib/decisions.mjs.
|
|
11
|
+
*
|
|
12
|
+
* `add` writes only while the quality gates are active (lib/unattended.mjs
|
|
13
|
+
* gatesActive): in an attended run the person answered, so there is nothing to
|
|
14
|
+
* report back. Writes take the write-state lock through gate-ledger.mjs
|
|
15
|
+
* updateState.
|
|
16
|
+
*
|
|
17
|
+
* `render` prints the markdown section Phase 5 puts in its report, redacted,
|
|
18
|
+
* and nothing at all for a run with no entries.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* decisions-log.mjs add --what <text> --why <text> --cost-if-wrong <text> --phase <0-5>
|
|
22
|
+
* [--state <path>] [--repo <dir>]
|
|
23
|
+
* decisions-log.mjs render [--state <path>] [--repo <dir>]
|
|
24
|
+
*
|
|
25
|
+
* Exit: 0 written, skipped (attended) or rendered · 2 usage · 3 no state or I/O error
|
|
26
|
+
*
|
|
27
|
+
* @module pipeline/scripts/decisions-log
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { writeFileSync } from "node:fs";
|
|
31
|
+
|
|
32
|
+
import { decisionEntry, renderDecisions, withDecision } from "../lib/decisions.mjs";
|
|
33
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
34
|
+
import { readRegularJson } from "../lib/regular-file.mjs";
|
|
35
|
+
import { gateContext, resolveStatePath, updateState } from "./gate-ledger.mjs";
|
|
36
|
+
|
|
37
|
+
class UsageError extends Error {}
|
|
38
|
+
|
|
39
|
+
function parse(argv) {
|
|
40
|
+
const opts = {};
|
|
41
|
+
for (let i = 1; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (!a.startsWith("--")) throw new UsageError(`unexpected argument ${a}`);
|
|
44
|
+
const v = argv[++i];
|
|
45
|
+
if (v === undefined) throw new UsageError(`${a} needs a value`);
|
|
46
|
+
opts[a.slice(2)] = v;
|
|
47
|
+
}
|
|
48
|
+
return opts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append one entry when the gates are active. Resolves to whether it wrote.
|
|
53
|
+
*
|
|
54
|
+
* @param {{what: string, why: string, costIfWrong: string, phase: number}} input
|
|
55
|
+
* @param {{statePath?: string|null, repo?: string|null, env?: Record<string,string|undefined>}} [opts]
|
|
56
|
+
*/
|
|
57
|
+
export async function addDecision(
|
|
58
|
+
input,
|
|
59
|
+
{ statePath = null, repo = null, env = process.env } = {},
|
|
60
|
+
) {
|
|
61
|
+
const entry = decisionEntry(input);
|
|
62
|
+
const ctx = gateContext({ statePath, repo, env });
|
|
63
|
+
if (!ctx.active || !ctx.statePath) return { written: false, entry };
|
|
64
|
+
await updateState(ctx.statePath, (s) => withDecision(s, entry));
|
|
65
|
+
return { written: true, entry, statePath: ctx.statePath };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main(argv) {
|
|
69
|
+
const mode = argv[0];
|
|
70
|
+
const opts = parse(argv);
|
|
71
|
+
if (mode === "add") {
|
|
72
|
+
let input;
|
|
73
|
+
try {
|
|
74
|
+
input = decisionEntry({
|
|
75
|
+
what: opts.what,
|
|
76
|
+
why: opts.why,
|
|
77
|
+
costIfWrong: opts["cost-if-wrong"],
|
|
78
|
+
phase: opts.phase,
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new UsageError(err.message);
|
|
82
|
+
}
|
|
83
|
+
await addDecision(input, { statePath: opts.state || null, repo: opts.repo || process.cwd() });
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (mode === "render") {
|
|
87
|
+
const path = resolveStatePath({ statePath: opts.state, repo: opts.repo || process.cwd() });
|
|
88
|
+
if (!path) {
|
|
89
|
+
process.stderr.write("decisions-log: no run state found\n");
|
|
90
|
+
return 3;
|
|
91
|
+
}
|
|
92
|
+
const state = readRegularJson(path, null);
|
|
93
|
+
if (!state || typeof state !== "object") {
|
|
94
|
+
process.stderr.write(`decisions-log: cannot read ${path}\n`);
|
|
95
|
+
return 3;
|
|
96
|
+
}
|
|
97
|
+
const md = renderDecisions(state.decisions);
|
|
98
|
+
if (md) writeFileSync(1, md);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
throw new UsageError("mode must be add or render");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (invokedDirectly(import.meta.url)) {
|
|
105
|
+
main(process.argv.slice(2)).then(
|
|
106
|
+
(code) => process.exit(code),
|
|
107
|
+
(err) => {
|
|
108
|
+
process.stderr.write(`decisions-log: ${err.message}\n`);
|
|
109
|
+
if (err instanceof UsageError) {
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
"usage: decisions-log.mjs add --what t --why t --cost-if-wrong t --phase n [--state p] | render [--state p]\n",
|
|
112
|
+
);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
process.exit(3);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
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 on Edit / Write /
|
|
6
|
+
* MultiEdit. Picks one parser by extension, runs it under a short deadline and,
|
|
7
|
+
* when the file does not parse, prints the Claude Code PostToolUse context
|
|
8
|
+
* object so the model sees the error before its next step:
|
|
9
|
+
*
|
|
10
|
+
* .swift swiftc -parse (when swiftc is on PATH)
|
|
11
|
+
* .js .mjs .cjs node --check (a file that looks like JSX: skipped)
|
|
12
|
+
* .py python3 py_compile, bytecode written to a temp dir
|
|
13
|
+
* .json JSON.parse after a UTF-8 BOM is dropped; files that
|
|
14
|
+
* are JSONC by convention (tsconfig / jsconfig, .vscode,
|
|
15
|
+
* .devcontainer, .eslintrc, turbo, deno) are skipped
|
|
16
|
+
* .sh .bash bash -n
|
|
17
|
+
*
|
|
18
|
+
* Kotlin has no parser that answers inside the deadline (kotlinc starts a JVM),
|
|
19
|
+
* so .kt / .kts are skipped, as is every other extension (.jsx and .jsonc
|
|
20
|
+
* among them).
|
|
21
|
+
*
|
|
22
|
+
* Silent on: the gate switched off (`editSyntaxGate.enabled` not true), a parser
|
|
23
|
+
* not installed, a parser that runs past the deadline, a file that is gone or
|
|
24
|
+
* too large, an unreadable payload. A missing tool is the user's toolchain, not
|
|
25
|
+
* the edit's fault, and a false alarm costs more than a missed one: the build
|
|
26
|
+
* still catches what this lets through.
|
|
27
|
+
*
|
|
28
|
+
* Exit 0 always.
|
|
29
|
+
*
|
|
30
|
+
* @module pipeline/scripts/edit-syntax-gate
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
35
|
+
import { homedir, tmpdir } from "node:os";
|
|
36
|
+
import { basename, extname, join, resolve, sep } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
39
|
+
|
|
40
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
|
|
41
|
+
const DEADLINE_MS = 2000;
|
|
42
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
43
|
+
const MAX_LINES = 6;
|
|
44
|
+
const MAX_CHARS = 800;
|
|
45
|
+
const NODE_INTERNAL_FRAME = /^\s*at .*\(?node:internal[/:]/;
|
|
46
|
+
const PREF = "editSyntaxGate.enabled";
|
|
47
|
+
|
|
48
|
+
function prefsCandidates() {
|
|
49
|
+
const home = process.env.HOME || homedir();
|
|
50
|
+
return [
|
|
51
|
+
process.env.MULTI_AGENT_PREFS,
|
|
52
|
+
join(home, ".claude", "multi-agent-preferences.json"),
|
|
53
|
+
join(home, ".config", "multi-agent-pipeline", "multi-agent-preferences.json"),
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `prefs.global.editSyntaxGate.enabled` from the first prefs file that exists. */
|
|
58
|
+
export function gateEnabled(paths = prefsCandidates()) {
|
|
59
|
+
for (const p of paths) {
|
|
60
|
+
if (!existsSync(p)) continue;
|
|
61
|
+
try {
|
|
62
|
+
let node = JSON.parse(readFileSync(p, "utf8"))?.global;
|
|
63
|
+
for (const key of PREF.split(".")) node = node?.[key];
|
|
64
|
+
return node === true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const JSONC_NAMES =
|
|
73
|
+
/^(?:(?:tsconfig|jsconfig)(?:\..+)?\.json|\.eslintrc\.json|turbo\.json|deno\.jsonc?|devcontainer\.json|\.devcontainer\.json)$/;
|
|
74
|
+
|
|
75
|
+
function isJsonc(file) {
|
|
76
|
+
return (
|
|
77
|
+
JSONC_NAMES.test(basename(file)) ||
|
|
78
|
+
file.includes(`${sep}.vscode${sep}`) ||
|
|
79
|
+
file.includes(`${sep}.devcontainer${sep}`)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `text` with string literals and comments blanked, so only code is left. */
|
|
84
|
+
function codeOnly(text) {
|
|
85
|
+
let out = "";
|
|
86
|
+
for (let i = 0; i < text.length; i++) {
|
|
87
|
+
const c = text[i];
|
|
88
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
89
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
90
|
+
out += "\n";
|
|
91
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
92
|
+
const end = text.indexOf("*/", i + 2);
|
|
93
|
+
i = end === -1 ? text.length : end + 1;
|
|
94
|
+
out += " ";
|
|
95
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
96
|
+
for (i++; i < text.length && text[i] !== c; i++) {
|
|
97
|
+
if (text[i] === "\\") i++;
|
|
98
|
+
else if (c !== "`" && text[i] === "\n") break;
|
|
99
|
+
}
|
|
100
|
+
out += " ";
|
|
101
|
+
} else out += c;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const JSX_TAG = /<[A-Z][\w.]*(?=[\s/>])|<\/[A-Za-z]?/;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* True when the code (strings and comments aside) carries an element-like
|
|
110
|
+
* `<Capitalized` or a closing `</`. node --check rejects JSX, and a file that
|
|
111
|
+
* only looks like JSX is skipped rather than flagged.
|
|
112
|
+
*/
|
|
113
|
+
export function looksLikeJsx(text) {
|
|
114
|
+
return JSX_TAG.test(codeOnly(text));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function run(cmd, args, cwd) {
|
|
118
|
+
const r = spawnSync(cmd, args, {
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
timeout: DEADLINE_MS,
|
|
122
|
+
killSignal: "SIGKILL",
|
|
123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
124
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
|
125
|
+
});
|
|
126
|
+
if (r.error || r.signal || r.status === null) return null;
|
|
127
|
+
if (r.status === 0) return { ok: true };
|
|
128
|
+
return { ok: false, output: `${r.stderr || ""}${r.stdout || ""}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse one file. `null` = not judged (no parser, parser missing, deadline);
|
|
133
|
+
* `{ok}` = parsed; `{ok: false, checker, output}` = syntax error.
|
|
134
|
+
*/
|
|
135
|
+
export function checkFile(file) {
|
|
136
|
+
const ext = extname(file).toLowerCase();
|
|
137
|
+
if (ext === ".json") {
|
|
138
|
+
if (isJsonc(file)) return null;
|
|
139
|
+
try {
|
|
140
|
+
JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
141
|
+
return { ok: true };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { ok: false, checker: "JSON.parse", output: err.message };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const scratch = mkdtempSync(join(tmpdir(), "edit-syntax-gate-"));
|
|
147
|
+
try {
|
|
148
|
+
let checker;
|
|
149
|
+
let res;
|
|
150
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
151
|
+
if (looksLikeJsx(readFileSync(file, "utf8"))) return null;
|
|
152
|
+
checker = "node --check";
|
|
153
|
+
res = run(process.execPath, ["--check", file], scratch);
|
|
154
|
+
} else if (ext === ".sh" || ext === ".bash") {
|
|
155
|
+
checker = "bash -n";
|
|
156
|
+
res = run("bash", ["-n", file], scratch);
|
|
157
|
+
} else if (ext === ".py") {
|
|
158
|
+
checker = "python3 py_compile";
|
|
159
|
+
res = run(
|
|
160
|
+
"python3",
|
|
161
|
+
[
|
|
162
|
+
"-I",
|
|
163
|
+
"-c",
|
|
164
|
+
[
|
|
165
|
+
"import py_compile, sys",
|
|
166
|
+
"try:",
|
|
167
|
+
" py_compile.compile(sys.argv[1], cfile=sys.argv[2], doraise=True)",
|
|
168
|
+
"except py_compile.PyCompileError as e:",
|
|
169
|
+
" sys.stderr.write(e.msg)",
|
|
170
|
+
" sys.exit(1)",
|
|
171
|
+
].join("\n"),
|
|
172
|
+
file,
|
|
173
|
+
join(scratch, "out.pyc"),
|
|
174
|
+
],
|
|
175
|
+
scratch,
|
|
176
|
+
);
|
|
177
|
+
} else if (ext === ".swift") {
|
|
178
|
+
checker = "swiftc -parse";
|
|
179
|
+
res = run("swiftc", ["-parse", file], scratch);
|
|
180
|
+
} else {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (!res) return null;
|
|
184
|
+
return res.ok ? res : { ...res, checker };
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function brief(output) {
|
|
191
|
+
const lines = String(output || "")
|
|
192
|
+
.split("\n")
|
|
193
|
+
.map((l) => l.trimEnd())
|
|
194
|
+
.filter((l) => l && !NODE_INTERNAL_FRAME.test(l));
|
|
195
|
+
const text = lines.slice(0, MAX_LINES).join("\n");
|
|
196
|
+
return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}...` : text;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The PostToolUse context object for a syntax error, or null. */
|
|
200
|
+
export function message(file, result) {
|
|
201
|
+
if (!result || result.ok) return null;
|
|
202
|
+
return {
|
|
203
|
+
hookSpecificOutput: {
|
|
204
|
+
hookEventName: "PostToolUse",
|
|
205
|
+
additionalContext: `edit-syntax-gate: ${file} no longer parses (${result.checker}). Fix it before going on:\n${brief(result.output)}`,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function main() {
|
|
211
|
+
let payload;
|
|
212
|
+
try {
|
|
213
|
+
payload = JSON.parse(readFileSync(0, "utf8"));
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!EDIT_TOOLS.has(payload?.tool_name)) return;
|
|
218
|
+
const raw = payload?.tool_input?.file_path;
|
|
219
|
+
if (typeof raw !== "string" || !raw) return;
|
|
220
|
+
// The checkers run in a scratch directory, so a relative path is made
|
|
221
|
+
// absolute here, against the session's cwd when the payload names one.
|
|
222
|
+
const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
223
|
+
const file = resolve(base, raw);
|
|
224
|
+
if (!gateEnabled()) return;
|
|
225
|
+
let st;
|
|
226
|
+
try {
|
|
227
|
+
st = statSync(file);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!st.isFile() || st.size > MAX_BYTES) return;
|
|
232
|
+
const out = message(file, checkFile(file));
|
|
233
|
+
if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fail open: a gate bug must not surface as a hook error on every edit.
|
|
237
|
+
if (invokedDirectly(import.meta.url)) {
|
|
238
|
+
const done = () => process.exit(0);
|
|
239
|
+
main().catch(done).then(done);
|
|
240
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# edit-syntax-gate.sh - PostToolUse syntax check for the multi-agent pipeline.
|
|
3
|
+
#
|
|
4
|
+
# Runs after an Edit / Write / MultiEdit and parses the edited file with the
|
|
5
|
+
# fastest parser its extension has, so a broken file is reported in the turn
|
|
6
|
+
# that broke it instead of at the next build. The parsing is in
|
|
7
|
+
# edit-syntax-gate.mjs; this wrapper exists because every hook the pipeline
|
|
8
|
+
# registers is a `bash "$HOME/.claude/scripts/<name>"` command.
|
|
9
|
+
#
|
|
10
|
+
# Contract (Claude Code PostToolUse hook):
|
|
11
|
+
# - Reads the tool-call JSON on stdin.
|
|
12
|
+
# - On a parse error prints {"hookSpecificOutput": {"hookEventName":
|
|
13
|
+
# "PostToolUse", "additionalContext": "..."}} on stdout, which the model
|
|
14
|
+
# reads as context for its next step.
|
|
15
|
+
# - Exits 0 on every path: the edit has already happened, so blocking would
|
|
16
|
+
# only hide the message.
|
|
17
|
+
#
|
|
18
|
+
# Inert unless `prefs.global.editSyntaxGate.enabled` is true. No node on PATH,
|
|
19
|
+
# a missing parser, a timeout or any internal error means no message.
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
24
|
+
CORE="$HERE/edit-syntax-gate.mjs"
|
|
25
|
+
[ -f "$CORE" ] || exit 0
|
|
26
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
27
|
+
|
|
28
|
+
node "$CORE" 2>/dev/null || true
|
|
29
|
+
exit 0
|