@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -6
  7. package/docs/features.md +9 -4
  8. package/install/catalog-history.json +1 -1
  9. package/install/templates/claude-hooks.json +1 -1
  10. package/install/templates/codex-hooks.json +18 -0
  11. package/manifest.json +89 -82
  12. package/package.json +1 -1
  13. package/pipeline/agents/task-clarifier.md +1 -1
  14. package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
  15. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  16. package/pipeline/commands/multi-agent/postmortem/SKILL.md +18 -6
  17. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  18. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  22. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  23. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  24. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  25. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  26. package/pipeline/contract/fixtures/runs-running.json +1 -1
  27. package/pipeline/lib/credential-inventory.sh +5 -5
  28. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  29. package/pipeline/lib/model-rung.sh +1 -1
  30. package/pipeline/lib/plan-todos.sh +21 -4
  31. package/pipeline/lib/post-pr-review.sh +4 -6
  32. package/pipeline/lib/route-state.sh +1 -1
  33. package/pipeline/lib/shadow-git.sh +4 -5
  34. package/pipeline/multi-agent-refs/cross-cli-contract.md +8 -9
  35. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  36. package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
  37. package/pipeline/multi-agent-refs/features/model-fallback.md +17 -12
  38. package/pipeline/multi-agent-refs/help/en.md +1 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  40. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  41. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
  42. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  43. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  44. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  45. package/pipeline/schemas/agent-state.schema.json +5 -0
  46. package/pipeline/schemas/phases.json +4 -0
  47. package/pipeline/schemas/plan-todos.schema.json +4 -4
  48. package/pipeline/schemas/prefs.schema.json +27 -4
  49. package/pipeline/scripts/_code-graph.mjs +2 -2
  50. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  51. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  52. package/pipeline/scripts/capture-flush.sh +7 -7
  53. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  54. package/pipeline/scripts/cost-analyze.mjs +26 -19
  55. package/pipeline/scripts/cost-table.json +35 -19
  56. package/pipeline/scripts/dep-bump.mjs +1556 -0
  57. package/pipeline/scripts/edit-syntax-gate.mjs +151 -29
  58. package/pipeline/scripts/edit-syntax-gate.sh +9 -6
  59. package/pipeline/scripts/gc-abandoned.sh +2 -2
  60. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  61. package/pipeline/scripts/git-path.mjs +1 -2
  62. package/pipeline/scripts/host-hooks.mjs +265 -0
  63. package/pipeline/scripts/jira-attach.sh +1 -1
  64. package/pipeline/scripts/keychain.py +3 -3
  65. package/pipeline/scripts/output-quality-check.sh +1 -1
  66. package/pipeline/scripts/phase-tracker.sh +5 -6
  67. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  68. package/pipeline/scripts/pre-push-check.sh +3 -6
  69. package/pipeline/scripts/transcript-index.mjs +423 -242
  70. package/pipeline/scripts/uninstall.mjs +90 -4
  71. package/pipeline/scripts/update-issue-progress.sh +4 -6
  72. package/pipeline/scripts/usage-report.mjs +2 -2
  73. package/pipeline/skills/.skill-manifest.json +14 -10
  74. package/pipeline/skills/.skills-index.json +12 -1
  75. package/pipeline/skills/shared/README.md +4 -3
  76. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  77. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
  78. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  79. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +10 -7
  80. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  81. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  82. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  83. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  84. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  85. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  86. 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 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:
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 EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
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
- /** The PostToolUse context object for a syntax error, or null. */
200
- export function message(file, result) {
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
- 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
- };
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
- 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);
351
+ const edit = editedFiles(payload);
352
+ if (!edit) return;
224
353
  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`);
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 an Edit / Write / MultiEdit and parses the edited file with the
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/.claude/scripts/<name>"` command.
9
+ # registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
9
10
  #
10
- # Contract (Claude Code PostToolUse hook):
11
+ # Contract (PostToolUse hook on each host):
11
12
  # - 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.
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 was read as waiting and never
392
- # reaped, which is how three finished worktrees held 5.1 GB indefinitely.
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 used to restate
37
- // it, which is how eight independent copies came to exist. smoke-phase-contract.sh
38
- // holds every remaining copy to the same file.
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 - which is how every non-ASCII-named file was once silently dropped
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 used to carry their own curl: the issue
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 - which is how a
222
- # service-account JSON turned into "security: unknown command" instead of
223
- # a stored credential. Hex is single-line by construction and byte-exact,
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
- # exactly the two spellings the first version of the decoder missed.
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 the first version
234
- # here worked only by accident of where the quotes happened to fall. A quoted
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 - which is exactly how per-phase token reporting went missing
1187
- # for months while every gate stayed green, because the gates lint the docs
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 skipped
150
- // both develops on whatever the checkout happened to have, which is how a Jira task
151
- // got implemented on a stale local branch with no picker ever shown. The state file
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. The subset it used to run was missing eight of the eleven steps
15
- # (lint-skills, lint-mcp-refs, three of the four evals, validate-prefs,
16
- # scorecard) and drove the smoke suites through the bare
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