@mmerterden/multi-agent-pipeline 20.24.0 → 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 +24 -0
- package/README.md +6 -5
- package/README.tr.md +6 -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 +1 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +89 -82
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- 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 +18 -6
- 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 +3 -3
- 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/credential-inventory.sh +5 -5
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +21 -4
- 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/multi-agent-refs/cross-cli-contract.md +8 -9
- 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 +17 -12
- 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-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/schemas/agent-state.schema.json +5 -0
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +4 -4
- package/pipeline/schemas/prefs.schema.json +27 -4
- package/pipeline/scripts/_code-graph.mjs +2 -2
- package/pipeline/scripts/_transcript-hosts.mjs +406 -0
- 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 +26 -19
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +151 -29
- package/pipeline/scripts/edit-syntax-gate.sh +9 -6
- 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/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/pre-push-check.sh +3 -6
- package/pipeline/scripts/transcript-index.mjs +423 -242
- 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/skills/.skill-manifest.json +14 -10
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- 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 +10 -7
- 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 +3 -3
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/skills-index.md +2 -1
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* edit-syntax-gate.mjs - parse the file an edit just wrote, report a syntax error.
|
|
4
4
|
*
|
|
5
|
-
* Called by edit-syntax-gate.sh as a PostToolUse hook
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
9
22
|
*
|
|
10
23
|
* .swift swiftc -parse (when swiftc is on PATH)
|
|
11
24
|
* .js .mjs .cjs node --check (a file that looks like JSX: skipped)
|
|
@@ -25,6 +38,10 @@
|
|
|
25
38
|
* the edit's fault, and a false alarm costs more than a missed one: the build
|
|
26
39
|
* still catches what this lets through.
|
|
27
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
|
+
*
|
|
28
45
|
* Exit 0 always.
|
|
29
46
|
*
|
|
30
47
|
* @module pipeline/scripts/edit-syntax-gate
|
|
@@ -37,8 +54,12 @@ import { basename, extname, join, resolve, sep } from "node:path";
|
|
|
37
54
|
|
|
38
55
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
39
56
|
|
|
40
|
-
const
|
|
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";
|
|
41
60
|
const DEADLINE_MS = 2000;
|
|
61
|
+
const BUDGET_MS = 8000;
|
|
62
|
+
const MAX_FILES = 8;
|
|
42
63
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
43
64
|
const MAX_LINES = 6;
|
|
44
65
|
const MAX_CHARS = 800;
|
|
@@ -196,15 +217,128 @@ function brief(output) {
|
|
|
196
217
|
return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}...` : text;
|
|
197
218
|
}
|
|
198
219
|
|
|
199
|
-
|
|
200
|
-
|
|
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") {
|
|
201
299
|
if (!result || result.ok) return null;
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 };
|
|
208
342
|
}
|
|
209
343
|
|
|
210
344
|
async function main() {
|
|
@@ -214,23 +348,11 @@ async function main() {
|
|
|
214
348
|
} catch {
|
|
215
349
|
return;
|
|
216
350
|
}
|
|
217
|
-
|
|
218
|
-
|
|
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);
|
|
351
|
+
const edit = editedFiles(payload);
|
|
352
|
+
if (!edit) return;
|
|
224
353
|
if (!gateEnabled()) return;
|
|
225
|
-
|
|
226
|
-
|
|
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`);
|
|
354
|
+
const { text } = checkEdited(edit.files);
|
|
355
|
+
if (text) process.stdout.write(`${JSON.stringify(contextFor(text, edit.host))}\n`);
|
|
234
356
|
}
|
|
235
357
|
|
|
236
358
|
// Fail open: a gate bug must not surface as a hook error on every edit.
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# edit-syntax-gate.sh - PostToolUse syntax check for the multi-agent pipeline.
|
|
3
3
|
#
|
|
4
|
-
# Runs after
|
|
4
|
+
# Runs after a file edit (Claude Code Edit / Write / MultiEdit, Codex
|
|
5
|
+
# apply_patch) and parses the edited file with the
|
|
5
6
|
# fastest parser its extension has, so a broken file is reported in the turn
|
|
6
7
|
# that broke it instead of at the next build. The parsing is in
|
|
7
8
|
# edit-syntax-gate.mjs; this wrapper exists because every hook the pipeline
|
|
8
|
-
# registers is a `bash "$HOME
|
|
9
|
+
# registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
|
|
9
10
|
#
|
|
10
|
-
# Contract (
|
|
11
|
+
# Contract (PostToolUse hook on each host):
|
|
11
12
|
# - Reads the tool-call JSON on stdin.
|
|
12
|
-
# - On a parse error prints
|
|
13
|
-
# "
|
|
14
|
-
# reads as context for its
|
|
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.
|
|
15
18
|
# - Exits 0 on every path: the edit has already happened, so blocking would
|
|
16
19
|
# only hide the message.
|
|
17
20
|
#
|
|
@@ -388,8 +388,8 @@ $d"
|
|
|
388
388
|
# Rule 1 again: landed work is not residue, whichever pass finds it. Order
|
|
389
389
|
# matters here - the waiting-phase test is a proxy for "still waiting", and
|
|
390
390
|
# a run whose status already says it FINISHED is not waiting for anyone. A
|
|
391
|
-
# complete run sitting at the report phase
|
|
392
|
-
#
|
|
391
|
+
# complete run sitting at the report phase would otherwise read as waiting
|
|
392
|
+
# and never be reaped, holding its worktree indefinitely.
|
|
393
393
|
case "$st" in
|
|
394
394
|
complete | completed | failed) ;;
|
|
395
395
|
awaiting_input)
|
|
@@ -33,9 +33,9 @@ const args = Object.fromEntries(
|
|
|
33
33
|
|
|
34
34
|
const MODE = args.mode;
|
|
35
35
|
|
|
36
|
-
// The phase list has one owner: schemas/phases.json. This file
|
|
37
|
-
// it,
|
|
38
|
-
//
|
|
36
|
+
// The phase list has one owner: schemas/phases.json. This file reads it rather
|
|
37
|
+
// than restating it, and smoke-phase-contract.sh holds every remaining copy to
|
|
38
|
+
// the same file.
|
|
39
39
|
const CONTRACT = JSON.parse(
|
|
40
40
|
readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8"),
|
|
41
41
|
);
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* `diff --name-only` as `"G\303\266r\303\274n\303\274m.swift"`, quotes and all.
|
|
9
9
|
* A consumer that treats that string as a path sees a name that matches no
|
|
10
10
|
* glob, resolves to no file, and simply DISAPPEARS from whatever it was
|
|
11
|
-
* feeding
|
|
12
|
-
* from risk scoring.
|
|
11
|
+
* feeding: a non-ASCII-named file silently drops out of risk scoring.
|
|
13
12
|
*
|
|
14
13
|
* Shared rather than copied because the failure is silent on every axis that
|
|
15
14
|
* uses it: a dropped file is not an error anywhere, it is an absence.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file host-hooks.mjs - register the pipeline's hooks on Codex.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's hooks are merged into ~/.claude/settings.json by
|
|
6
|
+
* /multi-agent:setup Step 8 from install/templates/claude-hooks.json. Codex
|
|
7
|
+
* reads hooks from <CODEX_HOME|~/.codex>/hooks.json, shared with the user's
|
|
8
|
+
* own hooks, so this does the same merge there from the template the
|
|
9
|
+
* installer copied into ~/.codex/templates (~/.codex whether or not
|
|
10
|
+
* CODEX_HOME is set): a template group is added only
|
|
11
|
+
* when its full commands are not already registered, and remove takes out
|
|
12
|
+
* only the handlers whose full command is one the template registers
|
|
13
|
+
* (refusing when the template is missing). A file that does not parse or is
|
|
14
|
+
* not shaped like a hooks file is left untouched and the call fails.
|
|
15
|
+
*
|
|
16
|
+
* Copilot CLI is not managed: as of 1.0.45 it loads ~/.copilot/hooks/*.json
|
|
17
|
+
* but discards a postToolUse hook's output and applies no matcher, so the
|
|
18
|
+
* syntax gate would cost a spawn per tool call and tell the model nothing.
|
|
19
|
+
*
|
|
20
|
+
* Codex runs a user hook only after it has been reviewed and trusted in its
|
|
21
|
+
* /hooks view, so install says so.
|
|
22
|
+
*
|
|
23
|
+
* Usage: host-hooks.mjs <install|remove|status> --host codex [--dry-run]
|
|
24
|
+
* Exit codes: 0 done | 1 a file could not be read or written | 2 usage
|
|
25
|
+
*
|
|
26
|
+
* @module pipeline/scripts/host-hooks
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
|
|
33
|
+
import { writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
|
|
34
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
35
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
36
|
+
|
|
37
|
+
const PIPELINE_SCRIPT = /\/scripts\/([A-Za-z0-9_.-]+\.(?:sh|mjs))\b/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where `host` keeps its hooks and where the installer put the template.
|
|
41
|
+
* Null for a host this does not manage.
|
|
42
|
+
*/
|
|
43
|
+
export function hostPaths(host, env = process.env) {
|
|
44
|
+
const home = env.HOME || homedir();
|
|
45
|
+
if (host !== "codex") return null;
|
|
46
|
+
// install/codex.mjs always installs under ~/.codex, while Codex reads its
|
|
47
|
+
// hooks from $CODEX_HOME when that is set.
|
|
48
|
+
const installDir = join(home, ".codex");
|
|
49
|
+
return {
|
|
50
|
+
template: join(installDir, "templates", "codex-hooks.json"),
|
|
51
|
+
hooksFile: join(env.CODEX_HOME || installDir, "hooks.json"),
|
|
52
|
+
home,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The command strings a hook group runs (`command`, or a flat handler's `bash`). */
|
|
57
|
+
function commandsOf(group) {
|
|
58
|
+
const handlers = Array.isArray(group?.hooks) ? group.hooks : [group];
|
|
59
|
+
return handlers.flatMap((h) => [h?.command, h?.bash]).filter((c) => typeof c === "string");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalize = (command) => command.trim().replace(/\s+/g, " ");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The full command strings `template` registers, each also with `$HOME`
|
|
66
|
+
* expanded to `home`, normalised for whitespace. A hook is the pipeline's
|
|
67
|
+
* only when its command is one of these: matching on a script name alone
|
|
68
|
+
* would take a user hook that runs some other `scripts/<name>.sh`.
|
|
69
|
+
*/
|
|
70
|
+
export function templateCommands(template, home = null) {
|
|
71
|
+
const out = new Set();
|
|
72
|
+
for (const group of Object.values(template?.hooks || {}).flat()) {
|
|
73
|
+
for (const command of commandsOf(group)) {
|
|
74
|
+
out.add(normalize(command));
|
|
75
|
+
if (home) out.add(normalize(command.replace(/\$\{HOME\}|\$HOME\b/g, home)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isPlainObject = (v) =>
|
|
82
|
+
v !== null && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Throws unless `doc` has the shape a hooks file has: a plain-object root, a
|
|
86
|
+
* plain-object `hooks` when present, and an array for every event in it.
|
|
87
|
+
* Anything else is a file this does not understand, and rewriting it would
|
|
88
|
+
* lose what is there.
|
|
89
|
+
*/
|
|
90
|
+
function assertHooksShape(doc) {
|
|
91
|
+
if (!isPlainObject(doc)) throw new Error("the hooks file's root is not a JSON object");
|
|
92
|
+
if (doc.hooks === undefined) return;
|
|
93
|
+
if (!isPlainObject(doc.hooks)) throw new Error("the hooks file's `hooks` is not a JSON object");
|
|
94
|
+
for (const [event, groups] of Object.entries(doc.hooks)) {
|
|
95
|
+
if (!Array.isArray(groups)) throw new Error(`the hooks file's \`${event}\` is not an array`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `existing` with every template group added whose commands are not all
|
|
101
|
+
* already registered for the same event (compared as full commands, also
|
|
102
|
+
* with `$HOME` expanded to `home`). Never edits a group. Throws on a document
|
|
103
|
+
* that is not shaped like a hooks file.
|
|
104
|
+
*/
|
|
105
|
+
export function mergeHooks(existing, template, home = null) {
|
|
106
|
+
const doc = structuredClone(existing ?? {});
|
|
107
|
+
assertHooksShape(doc);
|
|
108
|
+
if (!doc.hooks) doc.hooks = {};
|
|
109
|
+
let added = 0;
|
|
110
|
+
for (const [event, groups] of Object.entries(template?.hooks || {})) {
|
|
111
|
+
const current = doc.hooks[event] || [];
|
|
112
|
+
const present = new Set(current.flatMap(commandsOf).map(normalize));
|
|
113
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
114
|
+
const commands = [...templateCommands({ hooks: { [event]: [group] } })];
|
|
115
|
+
const known = (c) =>
|
|
116
|
+
present.has(c) || (home && present.has(normalize(c.replace(/\$\{HOME\}|\$HOME\b/g, home))));
|
|
117
|
+
if (commands.length && commands.every(known)) continue;
|
|
118
|
+
current.push(structuredClone(group));
|
|
119
|
+
commands.forEach((c) => present.add(c));
|
|
120
|
+
added++;
|
|
121
|
+
}
|
|
122
|
+
doc.hooks[event] = current;
|
|
123
|
+
}
|
|
124
|
+
return { doc, added };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `doc` without the handlers whose full command is in `commands` (from
|
|
129
|
+
* templateCommands). A group left with no handler is dropped, then an event
|
|
130
|
+
* left with no group. Throws when `commands` is empty: without the template
|
|
131
|
+
* there is no way to tell the pipeline's hooks from the user's.
|
|
132
|
+
*/
|
|
133
|
+
export function removeHooks(doc, commands) {
|
|
134
|
+
if (!(commands instanceof Set) || !commands.size)
|
|
135
|
+
throw new Error("no hook template to identify the pipeline's hooks");
|
|
136
|
+
const out = structuredClone(doc ?? {});
|
|
137
|
+
assertHooksShape(out);
|
|
138
|
+
let removed = 0;
|
|
139
|
+
if (!out.hooks) return { doc: out, removed };
|
|
140
|
+
const ours = (handler) =>
|
|
141
|
+
[handler?.command, handler?.bash].some(
|
|
142
|
+
(c) => typeof c === "string" && commands.has(normalize(c)),
|
|
143
|
+
);
|
|
144
|
+
for (const [event, groups] of Object.entries(out.hooks)) {
|
|
145
|
+
if (!Array.isArray(groups)) continue;
|
|
146
|
+
const kept = [];
|
|
147
|
+
for (const group of groups) {
|
|
148
|
+
if (!Array.isArray(group?.hooks)) {
|
|
149
|
+
if (ours(group)) removed++;
|
|
150
|
+
else kept.push(group);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const handlers = group.hooks.filter((h) => !ours(h));
|
|
154
|
+
removed += group.hooks.length - handlers.length;
|
|
155
|
+
if (handlers.length === group.hooks.length) kept.push(group);
|
|
156
|
+
else if (handlers.length) kept.push({ ...group, hooks: handlers });
|
|
157
|
+
}
|
|
158
|
+
if (kept.length) out.hooks[event] = kept;
|
|
159
|
+
else delete out.hooks[event];
|
|
160
|
+
}
|
|
161
|
+
if (!Object.keys(out.hooks).length) delete out.hooks;
|
|
162
|
+
return { doc: out, removed };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readJson(path) {
|
|
166
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write `doc` to `path` atomically. A symlinked file (a dotfiles repo) is
|
|
171
|
+
* written through to its target, and an existing file keeps its mode.
|
|
172
|
+
*/
|
|
173
|
+
function writeHooksFile(path, doc) {
|
|
174
|
+
let mode = 0o644;
|
|
175
|
+
try {
|
|
176
|
+
mode = statSync(path).mode & 0o777;
|
|
177
|
+
} catch {
|
|
178
|
+
// A new file gets the default mode.
|
|
179
|
+
}
|
|
180
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
181
|
+
writeJsonAtomicSync(path, doc, { mode, followSymlink: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function install(paths, dryRun, log) {
|
|
185
|
+
const template = readJson(paths.template);
|
|
186
|
+
const existing = existsSync(paths.hooksFile) ? readJson(paths.hooksFile) : {};
|
|
187
|
+
const { doc, added } = mergeHooks(existing, template, paths.home);
|
|
188
|
+
if (added && !dryRun) writeHooksFile(paths.hooksFile, doc);
|
|
189
|
+
log(
|
|
190
|
+
added
|
|
191
|
+
? `${dryRun ? "would add" : "added"} ${added} hook group(s) to ${paths.hooksFile}`
|
|
192
|
+
: `already registered in ${paths.hooksFile}`,
|
|
193
|
+
);
|
|
194
|
+
log("Codex runs a new user hook only once it is trusted: open /hooks in Codex and trust it.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function remove(paths, dryRun, log) {
|
|
198
|
+
if (!existsSync(paths.hooksFile)) {
|
|
199
|
+
log(`nothing to remove: ${paths.hooksFile} does not exist`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!existsSync(paths.template))
|
|
203
|
+
throw new Error(`${paths.template} is missing, so the pipeline's hooks cannot be told apart`);
|
|
204
|
+
const commands = templateCommands(readJson(paths.template), paths.home);
|
|
205
|
+
const { doc, removed } = removeHooks(readJson(paths.hooksFile), commands);
|
|
206
|
+
if (removed && !dryRun) writeHooksFile(paths.hooksFile, doc);
|
|
207
|
+
log(
|
|
208
|
+
removed
|
|
209
|
+
? `${dryRun ? "would remove" : "removed"} ${removed} hook group(s) from ${paths.hooksFile}`
|
|
210
|
+
: `no pipeline hook in ${paths.hooksFile}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function status(paths, log) {
|
|
215
|
+
if (!existsSync(paths.hooksFile)) {
|
|
216
|
+
log(`not installed (${paths.hooksFile} does not exist)`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!existsSync(paths.template)) {
|
|
220
|
+
log(`unknown: ${paths.template} is missing, so the pipeline's hooks cannot be told apart`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const commands = templateCommands(readJson(paths.template), paths.home);
|
|
224
|
+
const doc = readJson(paths.hooksFile);
|
|
225
|
+
assertHooksShape(doc);
|
|
226
|
+
const found = Object.values(doc.hooks || {})
|
|
227
|
+
.flat()
|
|
228
|
+
.flatMap(commandsOf)
|
|
229
|
+
.filter((c) => commands.has(normalize(c)))
|
|
230
|
+
.map((c) => (c.match(PIPELINE_SCRIPT) || [])[1] || c);
|
|
231
|
+
log(
|
|
232
|
+
found.length
|
|
233
|
+
? `installed: ${[...new Set(found)].join(", ")} in ${paths.hooksFile}`
|
|
234
|
+
: `not installed in ${paths.hooksFile}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const ACTIONS = new Set(["install", "remove", "status"]);
|
|
239
|
+
|
|
240
|
+
async function main() {
|
|
241
|
+
const argv = process.argv.slice(2);
|
|
242
|
+
const action = argv[0];
|
|
243
|
+
const hostAt = argv.indexOf("--host");
|
|
244
|
+
const host = hostAt >= 0 ? argv[hostAt + 1] : null;
|
|
245
|
+
const dryRun = argv.includes("--dry-run");
|
|
246
|
+
const paths = hostPaths(host);
|
|
247
|
+
if (!ACTIONS.has(action) || !paths) {
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
"usage: host-hooks.mjs <install|remove|status> --host codex [--dry-run]\n",
|
|
250
|
+
);
|
|
251
|
+
process.exitCode = 2;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const log = (line) => process.stdout.write(`${line}\n`);
|
|
255
|
+
try {
|
|
256
|
+
if (action === "install") install(paths, dryRun, log);
|
|
257
|
+
else if (action === "remove") remove(paths, dryRun, log);
|
|
258
|
+
else status(paths, log);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
process.stderr.write(`host-hooks: ${err.message}; nothing was changed\n`);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (invokedDirectly(import.meta.url)) runMain("host-hooks", main, { code: 1 });
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# jira-attach.sh - upload one or more files as Jira issue attachments and print
|
|
4
4
|
# the filename plus the URL of each, one per line, tab-separated.
|
|
5
5
|
#
|
|
6
|
-
# Two callers need this and they
|
|
6
|
+
# Two callers need this, and they share it rather than each carrying a curl: the issue
|
|
7
7
|
# generator (`generate-issue.md` step 12) and visual evidence (Phase 3 / Phase 5
|
|
8
8
|
# captures rendered into the Phase 5 Jira comment). Two copies of an upload is
|
|
9
9
|
# two places for the `X-Atlassian-Token: no-check` header to go missing, which
|
|
@@ -218,9 +218,9 @@ def set_(label: str, value: str, account: Optional[str] = None) -> None:
|
|
|
218
218
|
|
|
219
219
|
# The secret goes in as `-X <hex>`, not `-w "<text>"`. `security -i`
|
|
220
220
|
# tokenizes ONE command per line, so a newline inside the value ends the
|
|
221
|
-
# command and the remainder is read as further commands
|
|
222
|
-
# service-account JSON
|
|
223
|
-
#
|
|
221
|
+
# command and the remainder is read as further commands, so a multi-line
|
|
222
|
+
# service-account JSON becomes "security: unknown command" instead of a
|
|
223
|
+
# stored credential. Hex is single-line by construction and byte-exact,
|
|
224
224
|
# and it stays on stdin, so the argv exposure this branch exists to avoid
|
|
225
225
|
# is still avoided.
|
|
226
226
|
target = "".join(f' "{quote(k)}"' for k in _keychain())
|
|
@@ -121,7 +121,7 @@ try:
|
|
|
121
121
|
except Exception:
|
|
122
122
|
sys.exit(0)
|
|
123
123
|
# named, decimal and hex - a pattern covering only the named forms would miss
|
|
124
|
-
#
|
|
124
|
+
# the decimal and hex spellings.
|
|
125
125
|
pat = re.compile(r'&(amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|#[0-9]+|#[xX][0-9a-fA-F]+);')
|
|
126
126
|
for p in d.get('phases', []):
|
|
127
127
|
n = p.get('name', '')
|
|
@@ -230,8 +230,8 @@ usage_live_ping() {
|
|
|
230
230
|
#
|
|
231
231
|
# The program is fed through a QUOTED heredoc, not `node -e '...'`. Inside a
|
|
232
232
|
# single-quoted shell string every apostrophe in the JS (and in its comments)
|
|
233
|
-
# terminates the string early; shellcheck flags it as SC2140 and
|
|
234
|
-
#
|
|
233
|
+
# terminates the string early; shellcheck flags it as SC2140, and such code works
|
|
234
|
+
# only by accident of where the quotes happen to fall. A quoted
|
|
235
235
|
# heredoc passes the body through verbatim, so the JS can use any quote it likes.
|
|
236
236
|
decode_entities() {
|
|
237
237
|
local raw="$1" out
|
|
@@ -561,7 +561,7 @@ release_state_lock() {
|
|
|
561
561
|
trap release_state_lock EXIT
|
|
562
562
|
|
|
563
563
|
# The cost table is keyed by family ("opus", "sonnet", "gpt-5.6"), but the name
|
|
564
|
-
# an agent has at hand is the full id ("claude-opus-5"). Storing the id verbatim
|
|
564
|
+
# an agent has at hand is the full id ("claude-opus-5-5"). Storing the id verbatim
|
|
565
565
|
# priced every phase at "-" while the tracker looked perfectly healthy, so the
|
|
566
566
|
# family is resolved here, once, and an unresolvable name is said out loud
|
|
567
567
|
# instead of quietly costing nothing.
|
|
@@ -1183,9 +1183,8 @@ case "$ACTION" in
|
|
|
1183
1183
|
esac
|
|
1184
1184
|
# Accounting gate. A phase that ran LLM work and recorded nothing produces a
|
|
1185
1185
|
# completion line with nothing to say and a cost breakdown that reads as
|
|
1186
|
-
# unavailable
|
|
1187
|
-
#
|
|
1188
|
-
# for the CALL and nothing checks that the call happened. Refusing the
|
|
1186
|
+
# unavailable. The gates lint the docs for the CALL; only this point can
|
|
1187
|
+
# check that the call happened. Refusing the
|
|
1189
1188
|
# completion is recoverable (record, then re-run) and loses no work; a
|
|
1190
1189
|
# phase that genuinely made no LLM call says so with --no-llm.
|
|
1191
1190
|
if [ "$STATUS" = "completed" ] && [ "$NO_LLM" -eq 0 ]; then
|
|
@@ -146,9 +146,9 @@ export function evaluate(state, extraInput = "", { env = process.env } = {}) {
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Step 3 picks the base branch and Step 8 creates the worktree. A run that
|
|
150
|
-
// both develops on whatever the checkout
|
|
151
|
-
//
|
|
149
|
+
// Step 3 picks the base branch and Step 8 creates the worktree. A run that skips
|
|
150
|
+
// both develops on whatever the checkout happens to have: a Jira task implemented
|
|
151
|
+
// on a stale local branch with no picker ever shown. The state file
|
|
152
152
|
// is the only durable evidence those steps ran, so it has to carry their output.
|
|
153
153
|
//
|
|
154
154
|
// `local` provider runs still choose a base branch; what they skip is push and PR.
|
|
@@ -11,12 +11,9 @@
|
|
|
11
11
|
# broken commit and npm.
|
|
12
12
|
#
|
|
13
13
|
# It therefore runs the CANONICAL chain, `npm test`, rather than a hand-picked
|
|
14
|
-
# subset.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# `for f in smoke-*.sh` loop that run-smokes.mjs was written to replace,
|
|
18
|
-
# because that loop cannot tell a passing suite from one that exited 0 having
|
|
19
|
-
# asserted nothing. A safety net with holes in it is worse than a known gap:
|
|
14
|
+
# subset. A subset drifts from the chain as steps are added, and a bare
|
|
15
|
+
# `for f in smoke-*.sh` loop cannot tell a passing suite from one that exited 0
|
|
16
|
+
# having asserted nothing; run-smokes.mjs can. A safety net with holes in it is worse than a known gap:
|
|
20
17
|
# it is trusted.
|
|
21
18
|
#
|
|
22
19
|
# Two modes, because a gate that runs INSIDE the push is a gate that breaks
|