@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
|
@@ -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
|
+
}
|