@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/docs/facts.json +1 -1
  3. package/install/claude.mjs +1 -1
  4. package/manifest.json +37 -28
  5. package/package.json +1 -1
  6. package/pipeline/lib/claude-md-links.mjs +328 -0
  7. package/pipeline/lib/owned-path-gate.mjs +699 -0
  8. package/pipeline/lib/repo-profile-derive.mjs +1771 -0
  9. package/pipeline/lib/repo-profile.mjs +780 -0
  10. package/pipeline/lib/stack-detect.sh +59 -19
  11. package/pipeline/lib/unattended.mjs +17 -0
  12. package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
  13. package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
  14. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
  15. package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
  16. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
  17. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
  18. package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
  19. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
  21. package/pipeline/rules/outside-the-pipeline.md +6 -1
  22. package/pipeline/schemas/agent-state.schema.json +66 -2
  23. package/pipeline/schemas/phases.json +4 -4
  24. package/pipeline/schemas/repo-profile.schema.json +1107 -0
  25. package/pipeline/schemas/token-budget.json +4 -4
  26. package/pipeline/scripts/agent-guard.py +30 -0
  27. package/pipeline/scripts/owned-path-gate.mjs +205 -0
  28. package/pipeline/scripts/pre-commit-check.sh +151 -1
  29. package/pipeline/scripts/repo-profile.mjs +244 -0
  30. package/pipeline/scripts/review-decision-gate.mjs +42 -18
  31. package/pipeline/scripts/skill-candidates.mjs +882 -0
  32. package/pipeline/scripts/unattended_policy.py +90 -0
  33. package/pipeline/scripts/usage-report.mjs +36 -6
  34. 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": 10000
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": 6500
19
+ "max_tokens": 6600
20
20
  },
21
21
  "phase-5-report": {
22
- "max_tokens": 5550
22
+ "max_tokens": 5700
23
23
  }
24
24
  },
25
- "total_max_tokens": 63300
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. The PreToolUse hook contract
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);