@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +8 -6
- package/README.tr.md +7 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -6
- package/docs/features.md +9 -4
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +126 -98
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/credential-inventory.sh +5 -5
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +177 -37
- package/pipeline/lib/post-pr-review.sh +4 -6
- package/pipeline/lib/route-state.sh +1 -1
- package/pipeline/lib/shadow-git.sh +4 -5
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
- package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +36 -1
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +104 -3
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +122 -3
- package/pipeline/scripts/_code-graph.mjs +2 -2
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/_transcript-hosts.mjs +406 -0
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/build-stack-plugins.mjs +3 -3
- package/pipeline/scripts/capture-flush.sh +7 -7
- package/pipeline/scripts/code-graph-rules/android.json +1 -1
- package/pipeline/scripts/cost-analyze.mjs +32 -43
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
- package/pipeline/scripts/edit-syntax-gate.sh +32 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/gc-abandoned.sh +2 -2
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/git-path.mjs +1 -2
- package/pipeline/scripts/host-hooks.mjs +265 -0
- package/pipeline/scripts/jira-attach.sh +1 -1
- package/pipeline/scripts/keychain.py +3 -3
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/output-quality-check.sh +1 -1
- package/pipeline/scripts/phase-tracker.sh +5 -6
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/pre-push-check.sh +3 -6
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +1131 -0
- package/pipeline/scripts/uninstall.mjs +90 -4
- package/pipeline/scripts/update-issue-progress.sh +4 -6
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +20 -12
- package/pipeline/skills/.skills-index.json +23 -1
- package/pipeline/skills/shared/README.md +5 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/skills-index.md +3 -1
|
@@ -52,6 +52,7 @@ import { spawnSync } from "node:child_process";
|
|
|
52
52
|
import { existsSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
53
53
|
import { basename, dirname } from "node:path";
|
|
54
54
|
|
|
55
|
+
import { decisionEntry, withDecision } from "../lib/decisions.mjs";
|
|
55
56
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
56
57
|
import { readRegularFile, readRegularJson } from "../lib/regular-file.mjs";
|
|
57
58
|
import { attendedVerdict, gatesActive } from "../lib/unattended.mjs";
|
|
@@ -316,6 +317,9 @@ export async function recordQuietly(gate, verdict, detail, opts) {
|
|
|
316
317
|
* Park an unattended run for a person. The runner reads `waitingFor` as
|
|
317
318
|
* awaiting-answer and `verificationFailed` as verification-failed.
|
|
318
319
|
*
|
|
320
|
+
* Parking is the run deciding not to go on, so the same write appends the
|
|
321
|
+
* entry to `decisions[]` that the Phase 5 report and the PR body list.
|
|
322
|
+
*
|
|
319
323
|
* @param {string} path
|
|
320
324
|
* @param {{outcome: "awaiting-answer"|"verification-failed", waitingFor?: string,
|
|
321
325
|
* gate?: string, reason?: string}} opts
|
|
@@ -327,26 +331,55 @@ export async function park(path, { outcome, waitingFor, gate, reason = "", pendi
|
|
|
327
331
|
if (waitingFor === "question" && !pendingQuestion) {
|
|
328
332
|
throw new LedgerError("usage", "waitingFor question needs a pendingQuestion to answer");
|
|
329
333
|
}
|
|
330
|
-
return updateState(path, (s) =>
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
334
|
+
return updateState(path, (s) =>
|
|
335
|
+
withDecision(
|
|
336
|
+
{
|
|
337
|
+
...s,
|
|
338
|
+
waitingFor,
|
|
339
|
+
...(pendingQuestion ? { pendingQuestion } : {}),
|
|
340
|
+
status: "awaiting_input",
|
|
341
|
+
haltReason: reason || `waiting for ${waitingFor}`,
|
|
342
|
+
},
|
|
343
|
+
decisionEntry({
|
|
344
|
+
what: `Parked the run to wait for ${waitingFor}`,
|
|
345
|
+
why: reason || `waiting for ${waitingFor}`,
|
|
346
|
+
costIfWrong:
|
|
347
|
+
"the run stops until someone answers, even if the default would have been fine",
|
|
348
|
+
phase: phaseOf(s),
|
|
349
|
+
at,
|
|
350
|
+
}),
|
|
351
|
+
),
|
|
352
|
+
);
|
|
337
353
|
}
|
|
338
354
|
if (outcome === "verification-failed") {
|
|
339
355
|
if (!gate) throw new LedgerError("usage", "verification-failed needs --gate");
|
|
340
|
-
return updateState(path, (s) =>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
356
|
+
return updateState(path, (s) =>
|
|
357
|
+
withDecision(
|
|
358
|
+
{
|
|
359
|
+
...s,
|
|
360
|
+
status: "failed",
|
|
361
|
+
verificationFailed: { gate, reason, at },
|
|
362
|
+
haltReason: `verification failed at ${gate}: ${reason}`,
|
|
363
|
+
},
|
|
364
|
+
decisionEntry({
|
|
365
|
+
what: `Parked the run: ${gate} rejected the work`,
|
|
366
|
+
why: reason || `${gate} failed`,
|
|
367
|
+
costIfWrong: "work that would have passed is held back until someone resumes it",
|
|
368
|
+
phase: phaseOf(s),
|
|
369
|
+
at,
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
346
373
|
}
|
|
347
374
|
throw new LedgerError("usage", `unknown outcome ${outcome}`);
|
|
348
375
|
}
|
|
349
376
|
|
|
377
|
+
/** The run's current phase, held to the 0..5 range a decision entry takes. */
|
|
378
|
+
function phaseOf(state) {
|
|
379
|
+
const n = Number(state?.currentPhase);
|
|
380
|
+
return Number.isInteger(n) ? Math.min(5, Math.max(0, n)) : 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
350
383
|
/**
|
|
351
384
|
* Latest entry per gate for one sha. "Latest" is by `at`, then by position.
|
|
352
385
|
*/
|
|
@@ -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())
|