@mmerterden/multi-agent-pipeline 20.8.3 → 20.9.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 +37 -0
- package/docs/facts.json +1 -1
- package/install/claude.mjs +1 -1
- package/manifest.json +37 -28
- package/package.json +1 -1
- package/pipeline/lib/claude-md-links.mjs +328 -0
- package/pipeline/lib/owned-path-gate.mjs +699 -0
- package/pipeline/lib/repo-profile-derive.mjs +1771 -0
- package/pipeline/lib/repo-profile.mjs +780 -0
- package/pipeline/lib/stack-detect.sh +59 -19
- package/pipeline/lib/unattended.mjs +17 -0
- package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
- package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
- package/pipeline/rules/outside-the-pipeline.md +6 -1
- package/pipeline/schemas/agent-state.schema.json +66 -2
- package/pipeline/schemas/phases.json +4 -4
- package/pipeline/schemas/repo-profile.schema.json +1107 -0
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/agent-guard.py +30 -0
- package/pipeline/scripts/owned-path-gate.mjs +205 -0
- package/pipeline/scripts/pre-commit-check.sh +151 -1
- package/pipeline/scripts/repo-profile.mjs +244 -0
- package/pipeline/scripts/review-decision-gate.mjs +42 -18
- package/pipeline/scripts/skill-candidates.mjs +882 -0
- package/pipeline/scripts/unattended_policy.py +90 -0
- package/pipeline/scripts/usage-report.mjs +36 -6
- package/pipeline/skills/.skill-manifest.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"max_tokens": 13400
|
|
8
8
|
},
|
|
9
9
|
"phase-1-plan": {
|
|
10
|
-
"max_tokens":
|
|
10
|
+
"max_tokens": 10250
|
|
11
11
|
},
|
|
12
12
|
"phase-2-dev": {
|
|
13
13
|
"max_tokens": 10650
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"max_tokens": 17200
|
|
17
17
|
},
|
|
18
18
|
"phase-4-commit": {
|
|
19
|
-
"max_tokens":
|
|
19
|
+
"max_tokens": 6600
|
|
20
20
|
},
|
|
21
21
|
"phase-5-report": {
|
|
22
|
-
"max_tokens":
|
|
22
|
+
"max_tokens": 5700
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
|
-
"total_max_tokens":
|
|
25
|
+
"total_max_tokens": 63550
|
|
26
26
|
}
|
|
@@ -594,6 +594,33 @@ def git_subcommand_mode() -> None:
|
|
|
594
594
|
print(subs)
|
|
595
595
|
|
|
596
596
|
|
|
597
|
+
def commit_scope_mode() -> None:
|
|
598
|
+
"""`agent-guard.py --commit-scope`: read a Bash payload and print what its
|
|
599
|
+
commit takes beyond the index (unattended_policy.git_commit_scope), one
|
|
600
|
+
item per line. Never executes anything. At the deadline or on an error it
|
|
601
|
+
prints `ADD_ALL` and `COMMIT_ALL`, so the caller scans the widest set rather
|
|
602
|
+
than missing a path the commit could include."""
|
|
603
|
+
widest = "ADD_ALL\nCOMMIT_ALL"
|
|
604
|
+
_arm_deadline(lambda: _exit_with(widest))
|
|
605
|
+
try:
|
|
606
|
+
data = json.load(sys.stdin)
|
|
607
|
+
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
608
|
+
except Exception:
|
|
609
|
+
cmd = ""
|
|
610
|
+
if not isinstance(cmd, str) or not cmd:
|
|
611
|
+
return
|
|
612
|
+
try:
|
|
613
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
614
|
+
import unattended_policy
|
|
615
|
+
|
|
616
|
+
items = unattended_policy.git_commit_scope(cmd)
|
|
617
|
+
except Exception:
|
|
618
|
+
print(widest)
|
|
619
|
+
return
|
|
620
|
+
for item in items:
|
|
621
|
+
print(item)
|
|
622
|
+
|
|
623
|
+
|
|
597
624
|
def commit_dir_mode() -> None:
|
|
598
625
|
"""`agent-guard.py --commit-dir`: read a Bash payload and print the
|
|
599
626
|
directory the first commit-writing git command on the line runs in, after
|
|
@@ -624,6 +651,9 @@ def main() -> None:
|
|
|
624
651
|
if len(sys.argv) > 1 and sys.argv[1] == "--git-subcommand":
|
|
625
652
|
git_subcommand_mode()
|
|
626
653
|
return
|
|
654
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--commit-scope":
|
|
655
|
+
commit_scope_mode()
|
|
656
|
+
return
|
|
627
657
|
if len(sys.argv) > 1 and sys.argv[1] == "--commit-dir":
|
|
628
658
|
commit_dir_mode()
|
|
629
659
|
return
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file owned-path-gate.mjs - block a change to a path the repo profile says
|
|
5
|
+
* an automated account owns (pipeline/lib/owned-path-gate.mjs).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* owned-path-gate.mjs --help
|
|
9
|
+
* owned-path-gate.mjs [--repo <dir>]
|
|
10
|
+
* [--staged | --range <base>..<head> | --paths <a,b|-> | --base <ref>]
|
|
11
|
+
* [--pr-labels <a,b>] [--author "<name> <email>"] [--no-gh]
|
|
12
|
+
* [--state <agent-state.json|auto>] [--out <file>]
|
|
13
|
+
*
|
|
14
|
+
* `--state auto` (the commit hook) reads the newest agent-state.json under the
|
|
15
|
+
* log root whose worktreePath is this checkout and whose run is still going
|
|
16
|
+
* (in_progress, awaiting_input or paused, no finishedAt), then
|
|
17
|
+
* <worktree>/agent-state.json; none means an attended run.
|
|
18
|
+
*
|
|
19
|
+
* The change set is one of: the index (--staged); the commits of a range, from
|
|
20
|
+
* their merge-base (--range); a given list (--paths, comma separated, or `-`
|
|
21
|
+
* for a NUL or newline separated list on stdin); by default everything since
|
|
22
|
+
* the merge-base with the profile's work branch (origin/HEAD when it has none,
|
|
23
|
+
* or the ref given with --base) plus uncommitted and untracked files. Without
|
|
24
|
+
* --base the default branch, origin/HEAD, origin/main, origin/master, main and
|
|
25
|
+
* master are tried in turn; when none shares history with HEAD an attended run
|
|
26
|
+
* skips with a note (a shallow clone is named as such) and an unattended run
|
|
27
|
+
* scans the index and working tree instead. A default change set above
|
|
28
|
+
* MAX_CHANGED paths is a wrong base, exit 2, not thousands of violations.
|
|
29
|
+
*
|
|
30
|
+
* The profile comes from repo-profile.mjs and its policy: attended runs honour
|
|
31
|
+
* owned entries that are confirmed, manual, or derived at high or medium
|
|
32
|
+
* confidence; runs that ask no confirmation (MULTI_AGENT_UNATTENDED=1, or a
|
|
33
|
+
* terminal autopilot run with state.autopilot) honour all of them
|
|
34
|
+
* (policyMode). Without a stored profile an attended run skips with a note,
|
|
35
|
+
* and a run that asks nothing derives one first (stored outside the repo).
|
|
36
|
+
*
|
|
37
|
+
* `gh` is asked for the PR's labels only when a violation has a bypass label
|
|
38
|
+
* that --pr-labels did not supply, and never makes the gate fail. --no-gh (or
|
|
39
|
+
* OWNED_PATH_GATE_NO_GH=1) turns it off.
|
|
40
|
+
*
|
|
41
|
+
* Output: JSON {violations, exempt, findings, ignored, checked, mode, skipped,
|
|
42
|
+
* profileSource, profileAction[, note]} on stdout, or in --out; one line per
|
|
43
|
+
* violation on stderr, the first MAX_PRINTED and a count of the rest. Exit 0
|
|
44
|
+
* clean or skipped, 1 violations, 2 usage or environment error. An existing
|
|
45
|
+
* --out file is removed first, so an error never leaves a stale result.
|
|
46
|
+
*
|
|
47
|
+
* @module pipeline/scripts/owned-path-gate
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
52
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
53
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
54
|
+
import { repoTop, runGate } from "../lib/owned-path-gate.mjs";
|
|
55
|
+
import { listRuns } from "./_run-paths.mjs";
|
|
56
|
+
|
|
57
|
+
const NAME = "owned-path-gate";
|
|
58
|
+
const VALUE_FLAGS = new Set([
|
|
59
|
+
"repo",
|
|
60
|
+
"range",
|
|
61
|
+
"paths",
|
|
62
|
+
"base",
|
|
63
|
+
"pr-labels",
|
|
64
|
+
"author",
|
|
65
|
+
"state",
|
|
66
|
+
"out",
|
|
67
|
+
]);
|
|
68
|
+
const BOOL_FLAGS = new Set(["staged", "no-gh", "help"]);
|
|
69
|
+
const MAX_PRINTED = 50;
|
|
70
|
+
|
|
71
|
+
const USAGE = `Usage:
|
|
72
|
+
owned-path-gate.mjs [--repo <dir>]
|
|
73
|
+
[--staged | --range <base>..<head> | --paths <a,b|-> | --base <ref>]
|
|
74
|
+
[--pr-labels <a,b>] [--author "<name> <email>"] [--no-gh]
|
|
75
|
+
[--state <agent-state.json|auto>] [--out <file>]
|
|
76
|
+
|
|
77
|
+
Blocks a change to a path the repo profile says an automated account owns.
|
|
78
|
+
--staged the index only
|
|
79
|
+
--range a..b the commits of a range, from their merge-base
|
|
80
|
+
--paths a,b|- a given list (- reads a NUL or newline separated list on stdin)
|
|
81
|
+
--base <ref> the default diff (merge-base..HEAD plus uncommitted and
|
|
82
|
+
untracked files) starts from this ref
|
|
83
|
+
--pr-labels PR labels for bypass.label; --no-gh stops the read through gh
|
|
84
|
+
--author the identity uncommitted changes are attributed to
|
|
85
|
+
--state agent-state.json, for the run's mode; \`auto\` finds the live
|
|
86
|
+
run whose worktree is this repo
|
|
87
|
+
--out <file> write the JSON there instead of stdout
|
|
88
|
+
|
|
89
|
+
Exit 0 clean or skipped, 1 violations, 2 usage or environment error.
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
function parseArgs(argv) {
|
|
93
|
+
const flags = {};
|
|
94
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
95
|
+
const a = argv[i];
|
|
96
|
+
if (!a.startsWith("--")) throw new Error(`unexpected argument: ${a}`);
|
|
97
|
+
const [k, inline] = a.slice(2).split(/=(.*)/s);
|
|
98
|
+
if (VALUE_FLAGS.has(k)) {
|
|
99
|
+
const v = inline ?? argv[(i += 1)];
|
|
100
|
+
if (v === undefined) throw new Error(`--${k} needs a value`);
|
|
101
|
+
flags[k] = v;
|
|
102
|
+
} else if (BOOL_FLAGS.has(k)) flags[k] = true;
|
|
103
|
+
else throw new Error(`unknown flag: --${k}`);
|
|
104
|
+
}
|
|
105
|
+
const sources = ["staged", "range", "paths", "base"].filter((k) => flags[k] !== undefined);
|
|
106
|
+
if (sources.length > 1) {
|
|
107
|
+
throw new Error(`pick one of --staged, --range, --paths, --base (got ${sources.join(", ")})`);
|
|
108
|
+
}
|
|
109
|
+
return flags;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readPaths(value) {
|
|
113
|
+
const text = value === "-" ? readFileSync(0, "utf8") : value;
|
|
114
|
+
const sep = value === "-" ? /[\0\n]/ : /,/;
|
|
115
|
+
return text
|
|
116
|
+
.split(sep)
|
|
117
|
+
.map((s) => s.trim())
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const LIVE = new Set(["in_progress", "awaiting_input", "paused"]);
|
|
122
|
+
|
|
123
|
+
function realOrNull(p) {
|
|
124
|
+
try {
|
|
125
|
+
return realpathSync(p);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The state of the live run working in `repo`, or null. */
|
|
132
|
+
export function findRunState(repo) {
|
|
133
|
+
const top = realOrNull(repoTop(repo));
|
|
134
|
+
if (!top) return null;
|
|
135
|
+
let best = null;
|
|
136
|
+
for (const run of listRuns()) {
|
|
137
|
+
const file = join(run.dir, "agent-state.json");
|
|
138
|
+
let state;
|
|
139
|
+
let mtime;
|
|
140
|
+
try {
|
|
141
|
+
state = JSON.parse(readFileSync(file, "utf8"));
|
|
142
|
+
mtime = statSync(file).mtimeMs;
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!state || !LIVE.has(state.status) || state.finishedAt) continue;
|
|
147
|
+
const trees = [state.worktreePath, ...(state.projects ?? []).map((p) => p?.worktreePath)];
|
|
148
|
+
if (!trees.some((t) => typeof t === "string" && realOrNull(t) === top)) continue;
|
|
149
|
+
if (!best || mtime > best.mtime) best = { state, mtime };
|
|
150
|
+
}
|
|
151
|
+
return best ? best.state : readState(join(top, "agent-state.json"));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readState(path) {
|
|
155
|
+
if (!path) return null;
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function main() {
|
|
164
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
165
|
+
if (flags.help) {
|
|
166
|
+
process.stdout.write(USAGE);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (flags.out) rmSync(flags.out, { force: true });
|
|
170
|
+
const result = runGate(flags.repo || process.cwd(), {
|
|
171
|
+
staged: Boolean(flags.staged),
|
|
172
|
+
range: flags.range || null,
|
|
173
|
+
paths: flags.paths !== undefined ? readPaths(flags.paths) : null,
|
|
174
|
+
base: flags.base || null,
|
|
175
|
+
author: flags.author || null,
|
|
176
|
+
labels: flags["pr-labels"]
|
|
177
|
+
? flags["pr-labels"]
|
|
178
|
+
.split(",")
|
|
179
|
+
.map((s) => s.trim())
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
: [],
|
|
182
|
+
gh: !flags["no-gh"] && process.env.OWNED_PATH_GATE_NO_GH !== "1",
|
|
183
|
+
state:
|
|
184
|
+
flags.state === "auto" ? findRunState(flags.repo || process.cwd()) : readState(flags.state),
|
|
185
|
+
});
|
|
186
|
+
const { exitCode, ...body } = result;
|
|
187
|
+
const text = `${JSON.stringify(body, null, 2)}\n`;
|
|
188
|
+
if (flags.out) {
|
|
189
|
+
mkdirSync(dirname(flags.out), { recursive: true });
|
|
190
|
+
writeFileSync(flags.out, text);
|
|
191
|
+
} else {
|
|
192
|
+
process.stdout.write(text);
|
|
193
|
+
}
|
|
194
|
+
for (const v of body.violations.slice(0, MAX_PRINTED)) {
|
|
195
|
+
process.stderr.write(`${NAME}: BLOCKED ${v.path} (owned: ${v.glob}): ${v.fix}\n`);
|
|
196
|
+
}
|
|
197
|
+
const rest = body.violations.length - MAX_PRINTED;
|
|
198
|
+
if (rest > 0) {
|
|
199
|
+
process.stderr.write(`${NAME}: ... and ${rest} more owned path(s), listed in the JSON\n`);
|
|
200
|
+
}
|
|
201
|
+
if (body.skipped && body.note) process.stderr.write(`${NAME}: ${body.note}\n`);
|
|
202
|
+
process.exitCode = exitCode;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (invokedDirectly(import.meta.url)) runMain(NAME, main, { code: 2 });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Pre-commit secret detection for multi-agent pipeline
|
|
3
3
|
# Scans staged git changes for accidentally committed secrets
|
|
4
|
-
# Exit 0 = clean, Exit 2 = secrets found
|
|
4
|
+
# Exit 0 = clean, Exit 2 = secrets found or, on a commit, a path an automated
|
|
5
|
+
# account owns (owned_path_gate below). The PreToolUse hook contract
|
|
5
6
|
# (install/templates/claude-hooks.json, agent-guard.sh) blocks the tool call
|
|
6
7
|
# only on exit 2, with the reason on stderr.
|
|
7
8
|
#
|
|
@@ -547,6 +548,155 @@ if [ $FOUND -eq 1 ]; then
|
|
|
547
548
|
exit 2
|
|
548
549
|
fi
|
|
549
550
|
|
|
551
|
+
# Owned-path gate: a commit must not carry a path the repo profile says an
|
|
552
|
+
# automated account owns. Commit hook only; a direct run stays the secret scan.
|
|
553
|
+
# The profile's globs are read with sed, so a commit that touches no owned
|
|
554
|
+
# prefix never reaches the gate itself. Anything missing (profile, node, a
|
|
555
|
+
# readable result) lets the commit through with one line.
|
|
556
|
+
# The literal directory part of each glob on stdin, before its first wildcard
|
|
557
|
+
# (lib/owned-path-gate.mjs ownedPrefix), normalized the way the gate does:
|
|
558
|
+
# doubled slashes collapsed, leading `./` and `/` and trailing slashes trimmed. Each line
|
|
559
|
+
# carries a `P:` sentinel so an empty prefix (a glob that starts with a
|
|
560
|
+
# wildcard, which every path matches) survives command substitution.
|
|
561
|
+
owned_prefixes() {
|
|
562
|
+
local glob
|
|
563
|
+
while IFS= read -r glob; do
|
|
564
|
+
while [ "${glob#*//}" != "$glob" ]; do glob="${glob%%//*}/${glob#*//}"; done
|
|
565
|
+
while [ "${glob#./}" != "$glob" ]; do glob="${glob#./}"; done
|
|
566
|
+
glob="${glob#/}"
|
|
567
|
+
while [ "${glob%/}" != "$glob" ]; do glob="${glob%/}"; done
|
|
568
|
+
case "$glob" in
|
|
569
|
+
*[\*\?\[]*)
|
|
570
|
+
glob="${glob%%[\*\?\[]*}"
|
|
571
|
+
case "$glob" in
|
|
572
|
+
*/*) printf 'P:%s\n' "${glob%/*}/" ;;
|
|
573
|
+
*) printf 'P:\n' ;;
|
|
574
|
+
esac
|
|
575
|
+
;;
|
|
576
|
+
*) printf 'P:%s\n' "$glob" ;;
|
|
577
|
+
esac
|
|
578
|
+
done
|
|
579
|
+
}
|
|
580
|
+
# The host's project slug for an absolute path (lib/repo-profile.mjs
|
|
581
|
+
# projectSlug): every UTF-16 code unit outside [A-Za-z0-9] becomes `-`. sed
|
|
582
|
+
# counts bytes or characters depending on the locale, so a path that is not
|
|
583
|
+
# plain ASCII goes through node, which counts the way the library does.
|
|
584
|
+
owned_slug() {
|
|
585
|
+
if printf '%s' "$1" | LC_ALL=C grep -q '[^ -~]'; then
|
|
586
|
+
command -v node >/dev/null 2>&1 || return 1
|
|
587
|
+
node -e 'process.stdout.write(process.argv[1].replace(/[^A-Za-z0-9]/g, "-"))' "$1"
|
|
588
|
+
else
|
|
589
|
+
printf '%s' "$1" | LC_ALL=C sed 's/[^A-Za-z0-9]/-/g'
|
|
590
|
+
fi
|
|
591
|
+
}
|
|
592
|
+
owned_path_gate() {
|
|
593
|
+
local common main slug profile profiles prefixes list="" p hit=0 always=0 out rc glob top
|
|
594
|
+
local scope item add_all=0 tracked_all=0 add_specs commit_specs
|
|
595
|
+
top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
596
|
+
[ -n "$top" ] || top="$PWD"
|
|
597
|
+
# The commit's own paths: the index, plus what the same command adds or
|
|
598
|
+
# `commit -a` / `commit <pathspec>` takes (agent-guard.py --commit-scope).
|
|
599
|
+
# A dirty file the commit leaves out is not judged. Without the helper the
|
|
600
|
+
# widest set is scanned, as the secret scan above does.
|
|
601
|
+
scope="ADD_ALL"$'\n'"COMMIT_ALL"
|
|
602
|
+
if command -v python3 >/dev/null 2>&1 && [ -f "$PRE_COMMIT_DIR/agent-guard.py" ] && [ -n "${HOOK_INPUT:-}" ]; then
|
|
603
|
+
scope="$(printf '%s' "$HOOK_INPUT" | python3 "$PRE_COMMIT_DIR/agent-guard.py" --commit-scope 2>/dev/null)" \
|
|
604
|
+
|| scope="ADD_ALL"$'\n'"COMMIT_ALL"
|
|
605
|
+
fi
|
|
606
|
+
add_specs=()
|
|
607
|
+
commit_specs=()
|
|
608
|
+
while IFS= read -r item; do
|
|
609
|
+
case "$item" in
|
|
610
|
+
ADD_ALL) add_all=1 ;;
|
|
611
|
+
ADD_UPDATE | COMMIT_ALL) tracked_all=1 ;;
|
|
612
|
+
"ADD "*) add_specs+=("${item#ADD }") ;;
|
|
613
|
+
"COMMIT "*) commit_specs+=("${item#COMMIT }") ;;
|
|
614
|
+
esac
|
|
615
|
+
done <<<"$scope"
|
|
616
|
+
while IFS= read -r -d '' p; do
|
|
617
|
+
list="$list$p"$'\n'
|
|
618
|
+
done < <(
|
|
619
|
+
git -C "$top" diff --cached --name-only --no-renames -z 2>/dev/null
|
|
620
|
+
if [ "$add_all" = "1" ] || [ "$tracked_all" = "1" ]; then
|
|
621
|
+
git -C "$top" diff HEAD --name-only --no-renames -z 2>/dev/null
|
|
622
|
+
fi
|
|
623
|
+
if [ "$add_all" = "1" ]; then
|
|
624
|
+
git -C "$top" ls-files --others --exclude-standard --full-name -z 2>/dev/null
|
|
625
|
+
fi
|
|
626
|
+
if [ "${#add_specs[@]}" -gt 0 ]; then
|
|
627
|
+
git ls-files --modified --others --exclude-standard --full-name -z -- "${add_specs[@]}" 2>/dev/null
|
|
628
|
+
fi
|
|
629
|
+
if [ "${#commit_specs[@]}" -gt 0 ]; then
|
|
630
|
+
git ls-files --modified --full-name -z -- "${commit_specs[@]}" 2>/dev/null
|
|
631
|
+
fi
|
|
632
|
+
)
|
|
633
|
+
[ -n "$list" ] || return 0
|
|
634
|
+
common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
|
|
635
|
+
if [ -n "$common" ] && [ "$(basename "$common")" = ".git" ]; then
|
|
636
|
+
main="$(cd "$(dirname "$common")" 2>/dev/null && pwd -P)"
|
|
637
|
+
else
|
|
638
|
+
main="$(cd "$top" 2>/dev/null && pwd -P)"
|
|
639
|
+
fi
|
|
640
|
+
[ -n "$main" ] || return 0
|
|
641
|
+
if ! slug="$(owned_slug "$main")" || [ -z "$slug" ]; then
|
|
642
|
+
echo "pre-commit-check: node unavailable to locate the repo profile for $main - owned-path gate skipped" >&2
|
|
643
|
+
return 0
|
|
644
|
+
fi
|
|
645
|
+
profiles=""
|
|
646
|
+
for profile in "${HOME:-}/.claude/projects/$slug/repo-profile.json" \
|
|
647
|
+
"${MA_UNATTENDED_RUN_ROOT:-${HOME:-}/.multi-agent-unattended}/repo-profiles/$slug/repo-profile.json"; do
|
|
648
|
+
[ -f "$profile" ] && profiles="$profiles$profile"$'\n'
|
|
649
|
+
done
|
|
650
|
+
if [ -z "$profiles" ]; then
|
|
651
|
+
echo "pre-commit-check: no repo profile for $main - owned-path gate skipped" >&2
|
|
652
|
+
return 0
|
|
653
|
+
fi
|
|
654
|
+
prefixes="$(while IFS= read -r profile; do
|
|
655
|
+
[ -n "$profile" ] && sed -n 's/^ *"glob": *"\(.*\)",\{0,1\} *$/\1/p' "$profile"
|
|
656
|
+
done <<<"$profiles" | owned_prefixes)"
|
|
657
|
+
if [ -z "$prefixes" ]; then
|
|
658
|
+
echo "pre-commit-check: the repo profile for $main lists no owned path - owned-path gate skipped" >&2
|
|
659
|
+
return 0
|
|
660
|
+
fi
|
|
661
|
+
while IFS= read -r glob; do
|
|
662
|
+
[ "$glob" = "P:" ] && always=1
|
|
663
|
+
done <<<"$prefixes"
|
|
664
|
+
if [ "$always" = "1" ]; then
|
|
665
|
+
hit=1
|
|
666
|
+
else
|
|
667
|
+
while IFS= read -r p; do
|
|
668
|
+
[ -z "$p" ] && continue
|
|
669
|
+
while IFS= read -r glob; do
|
|
670
|
+
glob="${glob#P:}"
|
|
671
|
+
case "$p" in "$glob"*) hit=1; break ;; esac
|
|
672
|
+
done <<<"$prefixes"
|
|
673
|
+
[ "$hit" = "1" ] && break
|
|
674
|
+
done <<<"$list"
|
|
675
|
+
fi
|
|
676
|
+
[ "$hit" = "1" ] || return 0
|
|
677
|
+
if ! command -v node >/dev/null 2>&1 || [ ! -f "$PRE_COMMIT_DIR/owned-path-gate.mjs" ]; then
|
|
678
|
+
echo "pre-commit-check: node or owned-path-gate.mjs unavailable - owned-path gate skipped" >&2
|
|
679
|
+
return 0
|
|
680
|
+
fi
|
|
681
|
+
# --state auto finds the live pipeline run working in this checkout, so an
|
|
682
|
+
# autopilot run's commit gets the policy of a run that asks nothing.
|
|
683
|
+
out="$(printf '%s' "$list" | node "$PRE_COMMIT_DIR/owned-path-gate.mjs" \
|
|
684
|
+
--repo "$top" --paths - --state auto 2>&1 >/dev/null)"
|
|
685
|
+
rc=$?
|
|
686
|
+
if [ "$rc" = "1" ]; then
|
|
687
|
+
printf '%s\n' "$out" >&2
|
|
688
|
+
echo "BLOCKED: the commit edits paths an automated account owns (see above)." >&2
|
|
689
|
+
return 2
|
|
690
|
+
fi
|
|
691
|
+
if [ "$rc" != "0" ]; then
|
|
692
|
+
echo "pre-commit-check: owned-path gate could not run (exit $rc) - skipped" >&2
|
|
693
|
+
fi
|
|
694
|
+
return 0
|
|
695
|
+
}
|
|
696
|
+
if [ "$IS_COMMIT_HOOK" = "1" ]; then
|
|
697
|
+
owned_path_gate || exit 2
|
|
698
|
+
fi
|
|
699
|
+
|
|
550
700
|
# Unattended commit: the verification gates must have passed for this HEAD.
|
|
551
701
|
# The environment is the hook's own, inherited from the process the launcher
|
|
552
702
|
# started, so the run cannot talk its way past it by editing its state. An
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file repo-profile.mjs - derive, store, confirm and read the per-repo
|
|
5
|
+
* project profile (pipeline/schemas/repo-profile.schema.json).
|
|
6
|
+
*
|
|
7
|
+
* Subcommands (every one takes --repo <path>, default the current directory):
|
|
8
|
+
* derive [--bot-pattern <re>] [--history <n>] [--conventions <file>]
|
|
9
|
+
* [--with-conventions <ios|android|backend|web>]
|
|
10
|
+
* Print a draft profile. Reads the repo, writes nothing.
|
|
11
|
+
* save [--from <file|->] [--replace] [derive flags]
|
|
12
|
+
* Store a profile. With --from, the file is the edited profile and
|
|
13
|
+
* replaces the stored one as given: a manual value wins over a
|
|
14
|
+
* confirmed one, and an entry left out is removed. Without it, a
|
|
15
|
+
* fresh derive merges into the stored one (confirmed and manual
|
|
16
|
+
* roles survive) unless --replace. Prints the path written.
|
|
17
|
+
* confirm Mark the stored profile confirmed. Exit 2 when there is none.
|
|
18
|
+
* ensure [--state <agent-state.json>] [--max-behind <n>] [derive flags]
|
|
19
|
+
* Run-start entry point: derive when missing, re-derive when stale,
|
|
20
|
+
* and say whether to ask the user. MULTI_AGENT_UNATTENDED=1 and
|
|
21
|
+
* state.autopilot never ask. Prints {action, mode, policy, gatesActive,
|
|
22
|
+
* path, persisted, writeError, confirmed, needsConfirmation, stale,
|
|
23
|
+
* report}; a profile the OS would not let it save is used from
|
|
24
|
+
* memory with persisted false.
|
|
25
|
+
* show Print the stored profile. Exit 2 when there is none.
|
|
26
|
+
* get <field>
|
|
27
|
+
* Print one field of the stored profile, e.g. commit.format.
|
|
28
|
+
* resolve <field> [--state <agent-state.json>]
|
|
29
|
+
* Print what a consumer may act on under the confidence policy:
|
|
30
|
+
* {use, value, reason, ignored}.
|
|
31
|
+
* report [--state <agent-state.json>]
|
|
32
|
+
* Print the run-report view: derived / confirmed / manual / used /
|
|
33
|
+
* ignored fields.
|
|
34
|
+
* path Print where the profile is stored.
|
|
35
|
+
*
|
|
36
|
+
* Storage: ~/.claude/projects/<slug>/repo-profile.json, 0600, never inside the
|
|
37
|
+
* repo; an unattended run that cannot write there uses
|
|
38
|
+
* <unattended run root>/repo-profiles/<slug>/repo-profile.json. `--help` prints
|
|
39
|
+
* the usage. Exit codes: 0 ok; 1 usage or IO; 2 no stored profile.
|
|
40
|
+
*
|
|
41
|
+
* @module pipeline/scripts/repo-profile
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { execFileSync } from "node:child_process";
|
|
45
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
49
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
50
|
+
import {
|
|
51
|
+
confirmProfile,
|
|
52
|
+
consumptionReport,
|
|
53
|
+
deriveProfile,
|
|
54
|
+
ensureProfile,
|
|
55
|
+
loadProfile,
|
|
56
|
+
policyMode,
|
|
57
|
+
mergeProfiles,
|
|
58
|
+
profileMode,
|
|
59
|
+
profilePath,
|
|
60
|
+
readField,
|
|
61
|
+
resolveField,
|
|
62
|
+
saveProfile,
|
|
63
|
+
} from "../lib/repo-profile.mjs";
|
|
64
|
+
|
|
65
|
+
const USAGE = `Usage: repo-profile <subcommand> [--repo <path>] [flags]
|
|
66
|
+
|
|
67
|
+
derive [--bot-pattern <re>] [--history <n>] [--conventions <file>]
|
|
68
|
+
[--with-conventions <ios|android|backend|web>] print a draft, write nothing
|
|
69
|
+
save [--from <file|->] [--replace] [derive flags] store a profile; --from replaces
|
|
70
|
+
confirm mark the stored profile confirmed
|
|
71
|
+
ensure [--state <file>] [--max-behind <n>] [derive flags]
|
|
72
|
+
run-start: derive, re-derive, report
|
|
73
|
+
show print the stored profile
|
|
74
|
+
get <field> print one field
|
|
75
|
+
resolve <field> [--state <file>] what a consumer may act on
|
|
76
|
+
report [--state <file>] derived / confirmed / used / ignored
|
|
77
|
+
path where the profile is stored
|
|
78
|
+
|
|
79
|
+
Exit codes: 0 ok; 1 usage or IO; 2 no stored profile.
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const NAME = "repo-profile";
|
|
83
|
+
const SUBCOMMANDS = [
|
|
84
|
+
"derive",
|
|
85
|
+
"save",
|
|
86
|
+
"confirm",
|
|
87
|
+
"ensure",
|
|
88
|
+
"show",
|
|
89
|
+
"get",
|
|
90
|
+
"resolve",
|
|
91
|
+
"report",
|
|
92
|
+
"path",
|
|
93
|
+
];
|
|
94
|
+
const VALUE_FLAGS = new Set([
|
|
95
|
+
"repo",
|
|
96
|
+
"bot-pattern",
|
|
97
|
+
"history",
|
|
98
|
+
"conventions",
|
|
99
|
+
"with-conventions",
|
|
100
|
+
"from",
|
|
101
|
+
"state",
|
|
102
|
+
"max-behind",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function parseArgs(argv) {
|
|
106
|
+
const flags = {};
|
|
107
|
+
const positional = [];
|
|
108
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
109
|
+
const a = argv[i];
|
|
110
|
+
if (!a.startsWith("--")) {
|
|
111
|
+
positional.push(a);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const [k, inline] = a.slice(2).split(/=(.*)/s);
|
|
115
|
+
if (VALUE_FLAGS.has(k)) {
|
|
116
|
+
const v = inline ?? argv[(i += 1)];
|
|
117
|
+
if (v === undefined) throw new Error(`--${k} needs a value`);
|
|
118
|
+
flags[k] = v;
|
|
119
|
+
} else if (k === "replace") flags.replace = true;
|
|
120
|
+
else if (k === "help") flags.help = true;
|
|
121
|
+
else throw new Error(`unknown flag: --${k}`);
|
|
122
|
+
}
|
|
123
|
+
return { flags, positional };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readJsonArg(src) {
|
|
127
|
+
const text = src === "-" ? readFileSync(0, "utf8") : readFileSync(src, "utf8");
|
|
128
|
+
return JSON.parse(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function conventionsFrom(flags, repo) {
|
|
132
|
+
if (flags.conventions) return readJsonArg(flags.conventions);
|
|
133
|
+
if (!flags["with-conventions"]) return null;
|
|
134
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
135
|
+
const script = join(here, "..", "lib", "extract-conventions.sh");
|
|
136
|
+
if (!existsSync(script)) throw new Error("extract-conventions.sh not found next to this script");
|
|
137
|
+
const out = execFileSync("bash", [script, repo, flags["with-conventions"]], {
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
140
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
141
|
+
});
|
|
142
|
+
return JSON.parse(out);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function positiveInt(flags, name) {
|
|
146
|
+
const n = Number(flags[name]);
|
|
147
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`--${name} must be a positive integer`);
|
|
148
|
+
return n;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function deriveOpts(flags, repo) {
|
|
152
|
+
const opts = {};
|
|
153
|
+
if (flags["bot-pattern"]) opts.botPattern = flags["bot-pattern"];
|
|
154
|
+
if (flags.history !== undefined) opts.historyWindow = positiveInt(flags, "history");
|
|
155
|
+
const conventions = conventionsFrom(flags, repo);
|
|
156
|
+
if (conventions) opts.conventions = conventions;
|
|
157
|
+
return opts;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stateFrom(flags) {
|
|
161
|
+
if (!flags.state) return null;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(readFileSync(flags.state, "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const print = (v) =>
|
|
170
|
+
process.stdout.write(`${typeof v === "string" ? v : JSON.stringify(v, null, 2)}\n`);
|
|
171
|
+
|
|
172
|
+
function missing(repo) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
`${NAME}: no stored profile for ${repo}; run \`save\` or \`ensure\` first\n`,
|
|
175
|
+
);
|
|
176
|
+
process.exitCode = 2;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
const argv = process.argv.slice(2);
|
|
181
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
182
|
+
process.stdout.write(USAGE);
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const [sub, ...rest] = argv;
|
|
186
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
187
|
+
throw new Error(`unknown subcommand: ${sub ?? "(none)"} (expected ${SUBCOMMANDS.join("|")})`);
|
|
188
|
+
}
|
|
189
|
+
const { flags, positional } = parseArgs(rest);
|
|
190
|
+
const repo = flags.repo || process.cwd();
|
|
191
|
+
const mode = () => profileMode(process.env, stateFrom(flags));
|
|
192
|
+
const stored = () => loadProfile(repo, { mode: mode() });
|
|
193
|
+
|
|
194
|
+
switch (sub) {
|
|
195
|
+
case "derive":
|
|
196
|
+
return print(deriveProfile(repo, deriveOpts(flags, repo)));
|
|
197
|
+
case "path":
|
|
198
|
+
return print(profilePath(repo));
|
|
199
|
+
case "save": {
|
|
200
|
+
if (flags.from) return print(saveProfile(repo, readJsonArg(flags.from)));
|
|
201
|
+
const next = deriveProfile(repo, deriveOpts(flags, repo));
|
|
202
|
+
return print(
|
|
203
|
+
saveProfile(repo, flags.replace ? next : mergeProfiles(loadProfile(repo), next)),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
case "confirm": {
|
|
207
|
+
if (!loadProfile(repo)) return missing(repo);
|
|
208
|
+
confirmProfile(repo);
|
|
209
|
+
return print(profilePath(repo));
|
|
210
|
+
}
|
|
211
|
+
case "ensure": {
|
|
212
|
+
const opts = { state: stateFrom(flags), ...deriveOpts(flags, repo) };
|
|
213
|
+
if (flags["max-behind"] !== undefined) opts.maxBehind = positiveInt(flags, "max-behind");
|
|
214
|
+
const result = ensureProfile(repo, opts);
|
|
215
|
+
delete result.profile;
|
|
216
|
+
return print(result);
|
|
217
|
+
}
|
|
218
|
+
case "show": {
|
|
219
|
+
const p = stored();
|
|
220
|
+
return p ? print(p) : missing(repo);
|
|
221
|
+
}
|
|
222
|
+
case "get":
|
|
223
|
+
case "resolve": {
|
|
224
|
+
const field = positional[0];
|
|
225
|
+
if (!field) throw new Error(`${sub} needs a field, e.g. commit.format`);
|
|
226
|
+
const p = stored();
|
|
227
|
+
if (!p) return missing(repo);
|
|
228
|
+
if (sub === "resolve")
|
|
229
|
+
return print(resolveField(p, field, { mode: policyMode(process.env, stateFrom(flags)) }));
|
|
230
|
+
const v = readField(p, field);
|
|
231
|
+
if (v === undefined) throw new Error(`no field ${field} in the profile`);
|
|
232
|
+
return print(v);
|
|
233
|
+
}
|
|
234
|
+
case "report": {
|
|
235
|
+
const p = stored();
|
|
236
|
+
if (!p) return missing(repo);
|
|
237
|
+
return print(consumptionReport(p, { mode: policyMode(process.env, stateFrom(flags)) }));
|
|
238
|
+
}
|
|
239
|
+
default:
|
|
240
|
+
throw new Error(`unhandled subcommand: ${sub}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (invokedDirectly(import.meta.url)) runMain(NAME, main);
|