@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
@@ -0,0 +1,780 @@
1
+ /**
2
+ * repo-profile.mjs - the per-repo project profile: where it lives, what a
3
+ * valid one looks like, how a re-derive merges into a confirmed one.
4
+ *
5
+ * A profile tells a generic skill how one specific repo works: which paths an
6
+ * automated account owns, which trees are generated and from what, the commit
7
+ * subject convention, the exact CI commands, the hooks that rewrite a commit.
8
+ * It is derived from the repo (repo-profile-derive.mjs), shown to the user and
9
+ * confirmed once, then read by the phases.
10
+ *
11
+ * Storage is `~/.claude/projects/<slug>/repo-profile.json`, the same per-project
12
+ * directory figma-config.json uses, keyed by the same slug the host uses for
13
+ * its own project directory (the absolute path with every non-alphanumeric
14
+ * character turned into `-`). The slug is taken from the MAIN checkout, so
15
+ * every worktree of a repo reads the one profile. The file is written
16
+ * atomically at 0600 and never inside the repo: a profile quotes owned paths
17
+ * and bot logins, which belong to the user's machine, not to the repo's
18
+ * history. An unattended run cannot write under ~/.claude (its OS sandbox
19
+ * denies it), so it falls back to `<unattended run root>/repo-profiles/<slug>/`,
20
+ * the one directory outside the worktree that sandbox allows; when neither is
21
+ * writable the profile lives in memory for the run and `persisted` is false.
22
+ *
23
+ * Every role is `{ value, source, evidence, confidence }`. `source` is
24
+ * `derived` until the user confirms (`confirmed`) or edits it (`manual`); a
25
+ * re-derive replaces only what is still `derived`.
26
+ *
27
+ * @module pipeline/lib/repo-profile
28
+ */
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { updateJsonFileSync, writeJsonAtomicSync } from "./json-file-lock.mjs";
36
+ import { unattendedRunRoot } from "./pr-request-location.mjs";
37
+ import { deriveProfile } from "./repo-profile-derive.mjs";
38
+ import { runPosture } from "./unattended.mjs";
39
+
40
+ export { deriveProfile };
41
+
42
+ export const SCHEMA_VERSION = "1.0.0";
43
+ export const PROFILE_FILE = "repo-profile.json";
44
+ export const UNATTENDED_PROFILES_SUBDIR = "repo-profiles";
45
+ const SCHEMA_FILE = join(
46
+ dirname(fileURLToPath(import.meta.url)),
47
+ "..",
48
+ "schemas",
49
+ "repo-profile.schema.json",
50
+ );
51
+
52
+ export const SOURCES = ["derived", "confirmed", "manual"];
53
+ export const CONFIDENCES = ["high", "medium", "low"];
54
+ export const EVIDENCE_RE = /^(commit:[0-9a-f]{7,40}|git:\S.*|[^:\s][^:]*(:[0-9]+)?)$/;
55
+
56
+ /** Dotted paths of every scalar role, in schema order. */
57
+ export const ROLE_PATHS = [
58
+ "repo.workBranch",
59
+ "repo.defaultBranch",
60
+ "commit.format",
61
+ "build",
62
+ "test",
63
+ "lint",
64
+ "resourceSource",
65
+ "accessors.localization",
66
+ "accessors.accessibility",
67
+ "accessors.testingId",
68
+ "accessors.tokens",
69
+ "di.registrarSuffix",
70
+ "di.registerMethod",
71
+ "di.injectAttribute",
72
+ "module.layout",
73
+ "module.validator",
74
+ "mock.system",
75
+ "mock.customDir",
76
+ ];
77
+
78
+ /** Dotted paths of every list role, with the field that identifies an entry. */
79
+ export const ENTRY_PATHS = {
80
+ ownedPaths: "glob",
81
+ generators: "output",
82
+ hooks: "path",
83
+ requiredChecks: "name",
84
+ "docs.authoritative": "path",
85
+ inRepoSkills: "name",
86
+ };
87
+
88
+ function git(cwd, args) {
89
+ try {
90
+ return execFileSync("git", ["-C", cwd, ...args], {
91
+ encoding: "utf8",
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ }).trim();
94
+ } catch {
95
+ return "";
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The main checkout of the repo `repo` belongs to. A linked worktree resolves
101
+ * to the checkout that owns its common git directory; a submodule, whose
102
+ * common directory lives under the superproject's `.git/modules/`, resolves to
103
+ * its own top level.
104
+ *
105
+ * @param {string} repo
106
+ * @returns {string}
107
+ */
108
+ export function mainRepoRoot(repo) {
109
+ const abs = realpathSync(resolve(repo));
110
+ const top = git(abs, ["rev-parse", "--show-toplevel"]);
111
+ if (!top) throw new Error(`not a git repository: ${abs}`);
112
+ const common = git(abs, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
113
+ if (common && basename(common) === ".git") return realpathSync(dirname(common));
114
+ return realpathSync(top);
115
+ }
116
+
117
+ /** The host's project-directory slug for an absolute path. */
118
+ export function projectSlug(absPath) {
119
+ return absPath.replace(/[^A-Za-z0-9]/g, "-");
120
+ }
121
+
122
+ /**
123
+ * @param {string} repo
124
+ * @param {{home?: string}} [opts]
125
+ * @returns {string}
126
+ */
127
+ export function profilePath(repo, { home = homedir() } = {}) {
128
+ return join(home, ".claude", "projects", projectSlug(mainRepoRoot(repo)), PROFILE_FILE);
129
+ }
130
+
131
+ /**
132
+ * Where an unattended run keeps the profile when ~/.claude is not writable.
133
+ *
134
+ * @param {string} repo
135
+ * @param {{home?: string, env?: Record<string,string|undefined>}} [opts]
136
+ * @returns {string}
137
+ */
138
+ export function unattendedProfilePath(repo, { home, env = process.env } = {}) {
139
+ const root = unattendedRunRoot({ ...env, HOME: home ?? env.HOME ?? homedir() });
140
+ return join(root, UNATTENDED_PROFILES_SUBDIR, projectSlug(mainRepoRoot(repo)), PROFILE_FILE);
141
+ }
142
+
143
+ function isInside(parent, child) {
144
+ const rel = relative(parent, child);
145
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
146
+ }
147
+
148
+ /** Resolve the deepest existing ancestor so a symlinked home is compared by its target. */
149
+ function realish(path) {
150
+ let head = path;
151
+ const tail = [];
152
+ while (!existsSync(head) && dirname(head) !== head) {
153
+ tail.unshift(basename(head));
154
+ head = dirname(head);
155
+ }
156
+ return join(realpathSync(head), ...tail);
157
+ }
158
+
159
+ function storagePath(repo, opts, path = profilePath(repo, opts)) {
160
+ const root = mainRepoRoot(repo);
161
+ if (isInside(root, realish(path))) {
162
+ throw new Error(`profile path ${path} is inside the repo ${root}; refusing to write there`);
163
+ }
164
+ return path;
165
+ }
166
+
167
+ function getPath(obj, dotted) {
168
+ return dotted.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
169
+ }
170
+
171
+ function setPath(obj, dotted, value) {
172
+ const keys = dotted.split(".");
173
+ const last = keys.pop();
174
+ const parent = keys.reduce((o, k) => (o[k] ??= {}), obj);
175
+ parent[last] = value;
176
+ }
177
+
178
+ /** @param {string} dotted @returns {unknown} */
179
+ export function readField(profile, dotted) {
180
+ return getPath(profile, dotted);
181
+ }
182
+
183
+ function roleErrors(where, r, { entry = false } = {}) {
184
+ const errs = [];
185
+ if (!r || typeof r !== "object" || Array.isArray(r)) return [`${where}: not an object`];
186
+ if (!entry && !("value" in r)) errs.push(`${where}: missing value`);
187
+ if (!SOURCES.includes(r.source)) errs.push(`${where}.source: ${JSON.stringify(r.source)}`);
188
+ if (!CONFIDENCES.includes(r.confidence)) {
189
+ errs.push(`${where}.confidence: ${JSON.stringify(r.confidence)}`);
190
+ }
191
+ if (!Array.isArray(r.evidence)) errs.push(`${where}.evidence: not an array`);
192
+ else {
193
+ r.evidence.forEach((e, i) => {
194
+ if (typeof e !== "string" || !EVIDENCE_RE.test(e)) {
195
+ errs.push(`${where}.evidence[${i}]: ${JSON.stringify(e)}`);
196
+ }
197
+ });
198
+ }
199
+ return errs;
200
+ }
201
+
202
+ let schemaCache;
203
+ function loadSchema() {
204
+ if (schemaCache === undefined) {
205
+ try {
206
+ schemaCache = JSON.parse(readFileSync(SCHEMA_FILE, "utf8"));
207
+ } catch {
208
+ schemaCache = null;
209
+ }
210
+ }
211
+ return schemaCache;
212
+ }
213
+
214
+ const typeOf = (v) =>
215
+ v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v;
216
+
217
+ function formatOk(format, v) {
218
+ if (format === "date-time") return !Number.isNaN(Date.parse(v)) && /T/.test(v);
219
+ if (format === "regex") {
220
+ try {
221
+ new RegExp(v);
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * The subset of JSON Schema 2020-12 repo-profile.schema.json uses: $ref into
232
+ * $defs, anyOf, const, enum, type, required, properties, additionalProperties,
233
+ * minProperties, items, uniqueItems, minLength, pattern, format (date-time,
234
+ * regex) and minimum. The shipped runtime has no schema engine, and the schema
235
+ * file stays the one contract the tests hold ajv to.
236
+ */
237
+ function schemaErrors(root, node, v, where, errs) {
238
+ let s = node;
239
+ if (s.$ref)
240
+ s = {
241
+ ...s.$ref
242
+ .split("/")
243
+ .slice(1)
244
+ .reduce((o, k) => o[k], root),
245
+ ...s,
246
+ $ref: undefined,
247
+ };
248
+ if (s.anyOf) {
249
+ const ok = s.anyOf.some((branch) => {
250
+ const e = [];
251
+ schemaErrors(root, branch, v, where, e);
252
+ return e.length === 0;
253
+ });
254
+ if (!ok) errs.push(`${where}: matches no allowed shape`);
255
+ return;
256
+ }
257
+ if ("const" in s && v !== s.const) errs.push(`${where}: expected ${JSON.stringify(s.const)}`);
258
+ if (s.enum && !s.enum.includes(v)) errs.push(`${where}: ${JSON.stringify(v)} not allowed`);
259
+ const t = typeOf(v);
260
+ if (s.type) {
261
+ const types = [s.type].flat();
262
+ if (!types.includes(t) && !(t === "integer" && types.includes("number"))) {
263
+ errs.push(`${where}: expected ${types.join("|")}, got ${t}`);
264
+ return;
265
+ }
266
+ }
267
+ if (t === "string") {
268
+ if (s.minLength !== undefined && v.length < s.minLength) errs.push(`${where}: too short`);
269
+ if (s.pattern && !new RegExp(s.pattern, "u").test(v))
270
+ errs.push(`${where}: ${JSON.stringify(v)}`);
271
+ if (s.format && !formatOk(s.format, v)) errs.push(`${where}: not a valid ${s.format}`);
272
+ }
273
+ if ((t === "integer" || t === "number") && s.minimum !== undefined && v < s.minimum) {
274
+ errs.push(`${where}: below ${s.minimum}`);
275
+ }
276
+ if (t === "array") {
277
+ if (s.items) v.forEach((item, i) => schemaErrors(root, s.items, item, `${where}[${i}]`, errs));
278
+ if (s.uniqueItems && new Set(v.map((x) => JSON.stringify(x))).size !== v.length) {
279
+ errs.push(`${where}: duplicate items`);
280
+ }
281
+ }
282
+ if (t === "object") {
283
+ for (const k of s.required || []) if (!(k in v)) errs.push(`${where}.${k}: missing`);
284
+ if (s.minProperties !== undefined && Object.keys(v).length < s.minProperties) {
285
+ errs.push(`${where}: empty`);
286
+ }
287
+ for (const [k, val] of Object.entries(v)) {
288
+ const sub = s.properties?.[k];
289
+ if (sub) schemaErrors(root, sub, val, `${where}.${k}`, errs);
290
+ else if (s.additionalProperties === false) errs.push(`${where}.${k}: unknown field`);
291
+ else if (s.additionalProperties && typeof s.additionalProperties === "object") {
292
+ schemaErrors(root, s.additionalProperties, val, `${where}.${k}`, errs);
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Check a profile against repo-profile.schema.json, plus the structural
300
+ * checks a consumer relies on (every role's metadata, every entry's
301
+ * identifying field), which also hold when the schema file cannot be read.
302
+ *
303
+ * @param {any} profile
304
+ * @returns {string[]} one line per problem, empty when valid
305
+ */
306
+ export function validateProfile(profile) {
307
+ if (!profile || typeof profile !== "object") return ["profile: not an object"];
308
+ const errs = [];
309
+ const schema = loadSchema();
310
+ if (schema) schemaErrors(schema, schema, profile, "profile", errs);
311
+ if (profile.schemaVersion !== SCHEMA_VERSION) {
312
+ errs.push(
313
+ `schemaVersion: expected ${SCHEMA_VERSION}, got ${JSON.stringify(profile.schemaVersion)}`,
314
+ );
315
+ }
316
+ if (typeof profile.repoRoot !== "string" || !profile.repoRoot) errs.push("repoRoot: missing");
317
+ if (typeof profile.derivedAt !== "string") errs.push("derivedAt: missing");
318
+ if (!("confirmedAt" in profile)) errs.push("confirmedAt: missing");
319
+ for (const p of ROLE_PATHS) errs.push(...roleErrors(p, getPath(profile, p)));
320
+ for (const [p, key] of Object.entries(ENTRY_PATHS)) {
321
+ const list = getPath(profile, p);
322
+ if (!Array.isArray(list)) {
323
+ errs.push(`${p}: not an array`);
324
+ continue;
325
+ }
326
+ list.forEach((item, i) => {
327
+ errs.push(...roleErrors(`${p}[${i}]`, item, { entry: true }));
328
+ if (item && (typeof item[key] !== "string" || !item[key]))
329
+ errs.push(`${p}[${i}].${key}: missing`);
330
+ });
331
+ }
332
+ (Array.isArray(profile.ownedPaths) ? profile.ownedPaths : []).forEach((o, i) => {
333
+ if (!o || typeof o.owner !== "string" || !formatOk("regex", o.owner)) {
334
+ errs.push(`ownedPaths[${i}].owner: not a regular expression`);
335
+ } else if (looksLikeUnanchoredRegex(o.owner)) {
336
+ errs.push(
337
+ `ownedPaths[${i}].owner: regex syntax without ^ or $ is compared literally; anchor it`,
338
+ );
339
+ }
340
+ const by = o?.bypass?.author;
341
+ if (typeof by === "string" && looksLikeUnanchoredRegex(by)) {
342
+ errs.push(
343
+ `ownedPaths[${i}].bypass.author: regex syntax without ^ or $ is compared literally; anchor it`,
344
+ );
345
+ }
346
+ });
347
+ return [...new Set(errs)];
348
+ }
349
+
350
+ /**
351
+ * An owner or bypass author is a regex only when anchored (`^` or `$`), and a
352
+ * literal name otherwise, where `[bot]` is part of the name. Escapes or
353
+ * quantifiers in an unanchored value would silently never match.
354
+ */
355
+ function looksLikeUnanchoredRegex(value) {
356
+ return !/^\^|\$$/.test(value) && /[\\*+?(){}|]/.test(value);
357
+ }
358
+
359
+ function assertValid(profile) {
360
+ const errs = validateProfile(profile);
361
+ if (errs.length) throw new Error(`invalid repo profile: ${errs.slice(0, 5).join("; ")}`);
362
+ }
363
+
364
+ /**
365
+ * Fold a fresh derive into a stored profile. A role or entry the user has
366
+ * confirmed or entered by hand survives; everything still `derived` takes the
367
+ * fresh value, so a re-derive picks up a new owned path without undoing a
368
+ * decision.
369
+ *
370
+ * @param {any} existing
371
+ * @param {any} fresh
372
+ * @returns {any}
373
+ */
374
+ export function mergeProfiles(existing, fresh) {
375
+ if (!existing) return structuredClone(fresh);
376
+ const out = structuredClone(fresh);
377
+ for (const p of ROLE_PATHS) {
378
+ const old = getPath(existing, p);
379
+ if (old && old.source && old.source !== "derived") setPath(out, p, structuredClone(old));
380
+ }
381
+ for (const [p, key] of Object.entries(ENTRY_PATHS)) {
382
+ const kept = (getPath(existing, p) || []).filter((e) => e && e.source !== "derived");
383
+ const keys = new Set(kept.map((e) => e[key]));
384
+ const next = (getPath(out, p) || []).filter((e) => !keys.has(e[key]));
385
+ setPath(out, p, [...structuredClone(kept), ...next]);
386
+ }
387
+ out.confirmedAt = existing.confirmedAt ?? null;
388
+ return out;
389
+ }
390
+
391
+ /**
392
+ * Mark every still-derived role and entry as confirmed and stamp confirmedAt.
393
+ *
394
+ * @param {any} profile
395
+ * @param {Date} [now]
396
+ * @returns {any}
397
+ */
398
+ export function markConfirmed(profile, now = new Date()) {
399
+ const out = structuredClone(profile);
400
+ const confirm = (r) => {
401
+ if (r && r.source === "derived") r.source = "confirmed";
402
+ };
403
+ for (const p of ROLE_PATHS) confirm(getPath(out, p));
404
+ for (const p of Object.keys(ENTRY_PATHS)) (getPath(out, p) || []).forEach(confirm);
405
+ out.confirmedAt = now.toISOString();
406
+ return out;
407
+ }
408
+
409
+ function readJson(path) {
410
+ if (!existsSync(path)) return null;
411
+ return JSON.parse(readFileSync(path, "utf8"));
412
+ }
413
+
414
+ /**
415
+ * The stored profile and where it came from. Attended runs read
416
+ * ~/.claude first and the unattended copy only when there is none; unattended
417
+ * runs take whichever of the two was derived last, since the copy under the
418
+ * run root is where their re-derives land.
419
+ *
420
+ * @param {string} repo
421
+ * @param {{home?: string, env?: Record<string,string|undefined>, mode?: "attended"|"unattended"}} [opts]
422
+ * @returns {{profile: any, path: string}|null}
423
+ */
424
+ export function locateProfile(repo, { home, env = process.env, mode = "attended" } = {}) {
425
+ const primaryPath = profilePath(repo, { home });
426
+ const primary = readJson(primaryPath);
427
+ if (primary && mode === "attended") return { profile: primary, path: primaryPath };
428
+ const fallbackPath = unattendedProfilePath(repo, { home, env });
429
+ let fallback;
430
+ try {
431
+ fallback = readJson(fallbackPath);
432
+ } catch {
433
+ fallback = null;
434
+ }
435
+ if (primary && fallback && Date.parse(fallback.derivedAt) > Date.parse(primary.derivedAt)) {
436
+ return { profile: fallback, path: fallbackPath };
437
+ }
438
+ if (primary) return { profile: primary, path: primaryPath };
439
+ return fallback ? { profile: fallback, path: fallbackPath } : null;
440
+ }
441
+
442
+ /**
443
+ * @param {string} repo
444
+ * @param {{home?: string, env?: Record<string,string|undefined>, mode?: "attended"|"unattended"}} [opts]
445
+ * @returns {any|null}
446
+ */
447
+ export function loadProfile(repo, opts = {}) {
448
+ return locateProfile(repo, opts)?.profile ?? null;
449
+ }
450
+
451
+ /**
452
+ * Validate and write `profile`, atomically, at 0600.
453
+ *
454
+ * @param {string} repo
455
+ * @param {any} profile
456
+ * @param {{home?: string, path?: string}} [opts]
457
+ * @returns {string} the path written
458
+ */
459
+ export function saveProfile(repo, profile, opts = {}) {
460
+ const path = storagePath(repo, opts, opts.path ?? profilePath(repo, opts));
461
+ assertValid(profile);
462
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
463
+ writeJsonAtomicSync(path, profile, { mode: 0o600 });
464
+ return path;
465
+ }
466
+
467
+ const WRITE_DENIED = new Set(["EACCES", "EPERM", "EROFS"]);
468
+
469
+ /**
470
+ * Save where this run can write: ~/.claude, then (unattended only) the
471
+ * unattended run root. A write the OS refuses is not a failure of the run; the
472
+ * profile is used from memory and `persisted` is false.
473
+ *
474
+ * @returns {{path: string|null, persisted: boolean, writeError: string|null}}
475
+ */
476
+ function persist(repo, profile, { home, env, mode, prefer }) {
477
+ const targets = [profilePath(repo, { home })];
478
+ if (mode === "unattended") targets.push(unattendedProfilePath(repo, { home, env }));
479
+ const ordered = targets.includes(prefer)
480
+ ? [prefer, ...targets.filter((t) => t !== prefer)]
481
+ : targets;
482
+ let writeError = null;
483
+ for (const path of ordered) {
484
+ try {
485
+ return {
486
+ path: saveProfile(repo, profile, { home, path }),
487
+ persisted: true,
488
+ writeError: null,
489
+ };
490
+ } catch (err) {
491
+ if (!WRITE_DENIED.has(err?.code)) throw err;
492
+ writeError = `${err.code}: ${path}`;
493
+ }
494
+ }
495
+ return { path: null, persisted: false, writeError };
496
+ }
497
+
498
+ /**
499
+ * Confirm the stored profile once. Throws when there is none.
500
+ *
501
+ * @param {string} repo
502
+ * @param {{home?: string, now?: Date}} [opts]
503
+ * @returns {any} the confirmed profile
504
+ */
505
+ export function confirmProfile(repo, { now = new Date(), ...opts } = {}) {
506
+ const path = storagePath(repo, opts);
507
+ if (!existsSync(path)) throw new Error(`no repo profile at ${path}; run save first`);
508
+ return updateJsonFileSync(
509
+ path,
510
+ (current) => {
511
+ if (!current) throw new Error(`unreadable repo profile at ${path}`);
512
+ const next = markConfirmed(current, now);
513
+ assertValid(next);
514
+ return next;
515
+ },
516
+ { mode: 0o600 },
517
+ );
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // consumption policy
522
+
523
+ /** Fields where acting on a wrong medium-confidence value fails safe. */
524
+ export const FAIL_SAFE_FIELDS = new Set(["ownedPaths", "generators", "commit.format"]);
525
+
526
+ /**
527
+ * Fields honoured in an unattended run whatever their confidence: treating a
528
+ * path as owned or generated blocks an edit, which a person can undo; editing
529
+ * a path a bot owns breaks CI or is silently reverted, which nobody sees.
530
+ */
531
+ export const ALWAYS_UNATTENDED_FIELDS = new Set(["ownedPaths", "generators"]);
532
+
533
+ export const DEFAULT_MAX_BEHIND = 200;
534
+
535
+ /**
536
+ * The storage mode, from the canonical table in lib/unattended.mjs: only
537
+ * MULTI_AGENT_UNATTENDED=1 is unattended. Terminal autopilot is attended with
538
+ * the gates active, so its profile lives under ~/.claude; its confidence
539
+ * policy is policyMode.
540
+ *
541
+ * @param {Record<string, string|undefined>} [env]
542
+ * @param {object|null} [state]
543
+ * @returns {"unattended"|"attended"}
544
+ */
545
+ export function profileMode(env = process.env, state = null) {
546
+ return runPosture(state, env).mode;
547
+ }
548
+
549
+ /**
550
+ * The confidence policy for a run that asks no confirmation: terminal autopilot
551
+ * applies the unattended policy, so a rule that fails safe (an owned path, a
552
+ * generator) is honoured wherever nobody is asked to confirm it. Storage still
553
+ * follows profileMode.
554
+ *
555
+ * @param {Record<string, string|undefined>} [env]
556
+ * @param {object|null} [state]
557
+ * @returns {"unattended"|"attended"}
558
+ */
559
+ export function policyMode(env = process.env, state = null) {
560
+ return runPosture(state, env).gatesActive ? "unattended" : "attended";
561
+ }
562
+
563
+ const isEmpty = (v) => v === null || v === undefined || (Array.isArray(v) && v.length === 0);
564
+
565
+ /** An owned-path rule that rests on commit history alone, with no CI job enforcing it. */
566
+ export const isHistoryOnly = (field, r) => field === "ownedPaths" && r?.basis === "history";
567
+
568
+ function decide(field, r, mode) {
569
+ if (!r) return { use: false, reason: "absent" };
570
+ if (r.source === "confirmed" || r.source === "manual") return { use: true, reason: r.source };
571
+ if (mode === "unattended" && ALWAYS_UNATTENDED_FIELDS.has(field)) {
572
+ return { use: true, reason: `${r.confidence}, honoured unattended (safety bias)` };
573
+ }
574
+ if (isHistoryOnly(field, r)) {
575
+ return {
576
+ use: false,
577
+ reason: "history only, no CI job enforces it; honoured attended once confirmed",
578
+ };
579
+ }
580
+ if (r.confidence === "high") return { use: true, reason: "high" };
581
+ if (r.confidence === "medium" && FAIL_SAFE_FIELDS.has(field)) {
582
+ return { use: true, reason: "medium, fails safe" };
583
+ }
584
+ if (r.confidence === "medium") {
585
+ return {
586
+ use: false,
587
+ reason: "medium confidence where a wrong value does not fail safe; default kept",
588
+ };
589
+ }
590
+ return { use: false, reason: "low confidence; default kept" };
591
+ }
592
+
593
+ /**
594
+ * What a consumer may act on. A scalar role returns its value when the policy
595
+ * allows it; a list role returns the entries the policy allows and names the
596
+ * ones it dropped.
597
+ *
598
+ * @param {any} profile
599
+ * @param {string} field dotted path, e.g. "commit.format" or "ownedPaths"
600
+ * @param {{mode?: "unattended"|"attended"}} [opts]
601
+ * @returns {{use: boolean, value: any, reason: string, ignored: string[]}}
602
+ */
603
+ export function resolveField(profile, field, { mode = "attended" } = {}) {
604
+ const node = getPath(profile, field);
605
+ if (field in ENTRY_PATHS) {
606
+ const key = ENTRY_PATHS[field];
607
+ const kept = [];
608
+ const ignored = [];
609
+ for (const e of node || []) {
610
+ const d = decide(field, e, mode);
611
+ if (d.use) kept.push(e);
612
+ else ignored.push(`${field}[${e[key]}] (${e.confidence}, ${e.source}): ${d.reason}`);
613
+ }
614
+ return {
615
+ use: kept.length > 0,
616
+ value: kept,
617
+ reason: kept.length
618
+ ? `${kept.length} of ${(node || []).length} entries`
619
+ : "no usable entries",
620
+ ignored,
621
+ };
622
+ }
623
+ if (!node || isEmpty(node.value)) {
624
+ return { use: false, value: null, reason: "not derived; default kept", ignored: [] };
625
+ }
626
+ const d = decide(field, node, mode);
627
+ return {
628
+ use: d.use,
629
+ value: d.use ? node.value : null,
630
+ reason: d.reason,
631
+ ignored: d.use ? [] : [`${field} (${node.confidence}, ${node.source}): ${d.reason}`],
632
+ };
633
+ }
634
+
635
+ /**
636
+ * The run report's view of the profile: which fields were derived, confirmed
637
+ * or entered by hand, which the run acted on, and which it ignored and why.
638
+ *
639
+ * @param {any} profile
640
+ * @param {{mode?: "unattended"|"attended"}} [opts]
641
+ */
642
+ export function consumptionReport(profile, { mode = "attended" } = {}) {
643
+ const rep = {
644
+ mode,
645
+ derived: [],
646
+ confirmed: [],
647
+ manual: [],
648
+ used: [],
649
+ absent: [],
650
+ ignored: [],
651
+ historyOnly: [],
652
+ };
653
+ const record = (label, field, r) => {
654
+ rep[r.source]?.push(label);
655
+ if (isHistoryOnly(field, r) && r.source === "derived") rep.historyOnly.push(label);
656
+ const d = decide(field, r, mode);
657
+ if (d.use) rep.used.push(label);
658
+ else
659
+ rep.ignored.push({
660
+ field: label,
661
+ confidence: r.confidence,
662
+ source: r.source,
663
+ reason: d.reason,
664
+ });
665
+ };
666
+ for (const p of ROLE_PATHS) {
667
+ const r = getPath(profile, p);
668
+ if (!r || isEmpty(r.value)) rep.absent.push(p);
669
+ else record(p, p, r);
670
+ }
671
+ for (const [p, key] of Object.entries(ENTRY_PATHS)) {
672
+ const list = getPath(profile, p) || [];
673
+ if (!list.length) rep.absent.push(p);
674
+ for (const e of list) record(`${p}[${e[key]}]`, p, e);
675
+ }
676
+ return rep;
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // staleness and the run-start entry point
681
+
682
+ /**
683
+ * Whether the stored profile still describes the repo: the head it was
684
+ * derived at is reachable and not too far behind, and no workflow file has
685
+ * changed since (workflows carry the CI commands, branch targets and owned
686
+ * globs).
687
+ *
688
+ * @param {any} profile
689
+ * @param {string} repo
690
+ * @param {{maxBehind?: number}} [opts]
691
+ * @returns {{stale: boolean, reasons: string[]}}
692
+ */
693
+ export function staleness(profile, repo, { maxBehind = DEFAULT_MAX_BEHIND } = {}) {
694
+ const reasons = [];
695
+ if (!profile) return { stale: true, reasons: ["missing"] };
696
+ if (profile.schemaVersion !== SCHEMA_VERSION) reasons.push("schema-version");
697
+ const top = git(repo, ["rev-parse", "--show-toplevel"]) || repo;
698
+ const head = git(top, ["rev-parse", "--verify", "--quiet", "HEAD"]);
699
+ const at = profile.repoHead;
700
+ if (head && !at) reasons.push("head-unknown");
701
+ if (head && at && at !== head) {
702
+ const known = git(top, ["cat-file", "-t", at]) === "commit";
703
+ const ancestor = known && execOk(top, ["merge-base", "--is-ancestor", at, head]);
704
+ if (!ancestor) reasons.push("head-diverged");
705
+ else {
706
+ const behind = Number(git(top, ["rev-list", "--count", `${at}..${head}`])) || 0;
707
+ if (behind > maxBehind) reasons.push(`head-behind:${behind}`);
708
+ const changed = git(top, ["diff", "--name-only", at, head, "--", ".github/workflows"])
709
+ .split("\n")
710
+ .filter(Boolean);
711
+ if (changed.length) reasons.push(`workflows-changed:${changed.length}`);
712
+ }
713
+ }
714
+ return { stale: reasons.length > 0, reasons };
715
+ }
716
+
717
+ function execOk(cwd, args) {
718
+ try {
719
+ execFileSync("git", ["-C", cwd, ...args], { stdio: "ignore" });
720
+ return true;
721
+ } catch {
722
+ return false;
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Run-start entry point. Missing: derive and save. Stale: re-derive, keep what
728
+ * the user confirmed or entered, save. Unattended and autopilot runs never
729
+ * wait on a person; other attended runs are told to ask once
730
+ * (needsConfirmation) and call confirmProfile on a yes. A save the OS refuses
731
+ * leaves the profile in memory (`persisted: false`) and the run continues.
732
+ *
733
+ * @param {string} repo
734
+ * @param {{home?: string, env?: Record<string,string|undefined>, state?: object|null,
735
+ * maxBehind?: number, botPattern?: string, historyWindow?: number, conventions?: object|null}} [opts]
736
+ */
737
+ export function ensureProfile(repo, opts = {}) {
738
+ const { home, env = process.env, state = null, maxBehind, ...deriveOpts } = opts;
739
+ const { mode, gatesActive } = runPosture(state, env);
740
+ const found = locateProfile(repo, { home, env, mode });
741
+ const existing = found?.profile ?? null;
742
+ let action = "loaded";
743
+ let stale = { stale: false, reasons: [] };
744
+ let profile = existing;
745
+ if (!existing) {
746
+ profile = deriveProfile(repo, deriveOpts);
747
+ action = "derived";
748
+ } else {
749
+ stale = staleness(existing, repo, { maxBehind });
750
+ if (stale.stale) {
751
+ const again = {
752
+ botPattern: existing.derivation?.botPattern,
753
+ historyWindow: existing.derivation?.historyWindow,
754
+ ...deriveOpts,
755
+ };
756
+ if (again.botPattern === "\\[bot\\]$") delete again.botPattern;
757
+ profile = mergeProfiles(existing, deriveProfile(repo, again));
758
+ if (mode === "attended") profile.confirmedAt = null;
759
+ action = "rederived";
760
+ }
761
+ }
762
+ const saved =
763
+ action === "loaded"
764
+ ? { path: found.path, persisted: true, writeError: null }
765
+ : persist(repo, profile, { home, env, mode, prefer: found?.path });
766
+ return {
767
+ action,
768
+ mode,
769
+ gatesActive,
770
+ path: saved.path,
771
+ persisted: saved.persisted,
772
+ writeError: saved.writeError,
773
+ confirmed: Boolean(profile.confirmedAt),
774
+ needsConfirmation: mode === "attended" && !gatesActive && !profile.confirmedAt,
775
+ stale,
776
+ profile,
777
+ policy: policyMode(env, state),
778
+ report: consumptionReport(profile, { mode: policyMode(env, state) }),
779
+ };
780
+ }