@mmerterden/multi-agent-pipeline 20.21.0 → 20.23.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 +62 -0
- package/docs/adr/0010-own-code-graph.md +3 -2
- package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/ecosystem.md +6 -5
- package/docs/facts.json +5 -4
- package/docs/features.md +2 -2
- package/install/_usage-lifecycle.mjs +44 -0
- package/install/catalog-history.json +1 -1
- package/install/index.mjs +2 -0
- package/install/templates/copilot-instructions.md +1 -0
- package/manifest.json +72 -65
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +9 -18
- package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
- package/pipeline/lib/json-file-lock.mjs +24 -2
- package/pipeline/multi-agent-refs/_dev-context.md +15 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +6 -4
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +2 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +5 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +66 -2
- package/pipeline/multi-agent-refs/features/product-context.md +238 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
- package/pipeline/multi-agent-refs/generate-issue.md +11 -1
- package/pipeline/multi-agent-refs/knowledge.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +9 -14
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
- package/pipeline/multi-agent-refs/platform-parity.md +23 -9
- package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
- package/pipeline/schemas/agent-state.schema.json +31 -0
- package/pipeline/schemas/analysis-spec.schema.json +14 -0
- package/pipeline/schemas/code-graph.schema.json +13 -2
- package/pipeline/schemas/diff-risk.schema.json +2 -1
- package/pipeline/schemas/prefs.schema.json +84 -4
- package/pipeline/scripts/_code-graph.mjs +188 -6
- package/pipeline/scripts/_diff-impact.mjs +319 -0
- package/pipeline/scripts/_supertypes.mjs +133 -0
- package/pipeline/scripts/analysis-story-body.mjs +24 -8
- package/pipeline/scripts/capture-flush.sh +62 -4
- package/pipeline/scripts/code-graph-rules/android.json +1 -0
- package/pipeline/scripts/code-graph-rules/ios.json +5 -0
- package/pipeline/scripts/code-graph-rules/node.json +1 -0
- package/pipeline/scripts/code-graph-rules/python.json +1 -0
- package/pipeline/scripts/diff-risk-score.mjs +80 -9
- package/pipeline/scripts/git-path.mjs +21 -0
- package/pipeline/scripts/graph-affected.mjs +168 -36
- package/pipeline/scripts/graph-confirm.mjs +140 -0
- package/pipeline/scripts/graph-query.mjs +16 -4
- package/pipeline/scripts/learnings-ledger.mjs +3 -2
- package/pipeline/scripts/product-context.mjs +550 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
- package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
- package/pipeline/scripts/test-gap-scan.mjs +4 -1
- package/pipeline/scripts/test-strength.mjs +7 -8
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +181 -15
- package/pipeline/scripts/validate-code-graph.mjs +3 -2
- package/pipeline/scripts/validate-diff-risk.mjs +1 -0
- package/pipeline/scripts/valven-gate.mjs +9 -1
- package/pipeline/scripts/valven-port.mjs +16 -5
- package/pipeline/scripts/valven-split.mjs +6 -4
- package/pipeline/skills/.skill-manifest.json +4 -4
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +11 -9
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* product-context.mjs - which other repositories a run reads, and what it
|
|
4
|
+
* learned from them.
|
|
5
|
+
*
|
|
6
|
+
* A product is made of several repositories (iOS app, Android app, web client,
|
|
7
|
+
* backend services, a shared resources repo). A run develops in some of them;
|
|
8
|
+
* planning reads all the others, read-only, at their latest integration branch,
|
|
9
|
+
* through the toolkit's repo_* tools. This script owns the deterministic half:
|
|
10
|
+
*
|
|
11
|
+
* refs the reference set: product repos minus the repos this run develops.
|
|
12
|
+
* Symmetric by construction - whichever stack is primary, the rest
|
|
13
|
+
* are references. Exit 2 when nothing is mapped.
|
|
14
|
+
* suggest candidate repos for a product that is not mapped yet, by name core
|
|
15
|
+
* from a repo-cache listing. Never guesses a stack.
|
|
16
|
+
* needs-mapping whether the project has never answered the mapping question.
|
|
17
|
+
* learn write a confirmed product map into the preferences file.
|
|
18
|
+
* decline remember that the project is not part of a product.
|
|
19
|
+
* record normalise a repo_find_keys result into state.productContext and
|
|
20
|
+
* append each finding to the related repo's own knowledge directory,
|
|
21
|
+
* keyed by the sha it was read at.
|
|
22
|
+
* recall findings already recorded for a repo at a given sha.
|
|
23
|
+
*
|
|
24
|
+
* Knowledge lands in $HOME/.claude/knowledge/<repo-name>/product-context.jsonl,
|
|
25
|
+
* the same directory that repo's own runs read, so what an iOS run learns about
|
|
26
|
+
* Android is there for the next Android run, and the other way round. A finding
|
|
27
|
+
* is reused only while the sha matches: a newer commit on the branch makes
|
|
28
|
+
* every older finding invisible rather than wrong.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* product-context.mjs refs --project <key> [--developed <remote>]... [--prefs <file>]
|
|
32
|
+
* product-context.mjs suggest --primary <repo-name> --repos <repo-cache.json>
|
|
33
|
+
* product-context.mjs needs-mapping --project <key> [--developed <remote>]... [--prefs <file>] exit 0 ask, 3 already answered
|
|
34
|
+
* product-context.mjs learn --project <key> --product <name> --repos <repos.json> [--prefs <file>]
|
|
35
|
+
* product-context.mjs decline --project <key> [--prefs <file>]
|
|
36
|
+
* product-context.mjs record --in <find-keys.json> [--refs <refs.json>] [--task <id>] [--knowledge <dir>]
|
|
37
|
+
* product-context.mjs recall --remote <url> --sha <sha> --keys <k1,k2> [--knowledge <dir>]
|
|
38
|
+
*
|
|
39
|
+
* Exit codes: 0 ok, 1 bad input, 2 nothing mapped (refs), 3 already answered (needs-mapping).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { execFileSync } from "node:child_process";
|
|
43
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
import { dirname, join } from "node:path";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
|
|
48
|
+
|
|
49
|
+
export const STACKS = ["ios", "android", "web", "backend", "resources"];
|
|
50
|
+
const AUTH_SCHEMES = ["basic", "bearer"];
|
|
51
|
+
const KNOWLEDGE_FILE = "product-context.jsonl";
|
|
52
|
+
|
|
53
|
+
export function canonicalRemote(remote) {
|
|
54
|
+
let host;
|
|
55
|
+
let path = String(remote ?? "").trim();
|
|
56
|
+
const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(path);
|
|
57
|
+
if (scp) {
|
|
58
|
+
host = scp[1];
|
|
59
|
+
path = scp[2];
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(path);
|
|
63
|
+
host = u.hostname;
|
|
64
|
+
path = u.pathname;
|
|
65
|
+
} catch {
|
|
66
|
+
return path.toLowerCase().replace(/\.git\/?$/, "");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const segments = path
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.replace(/\.git\/?$/, "")
|
|
72
|
+
.split("/")
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
if (segments[0] === "scm") segments.shift();
|
|
75
|
+
return [host.toLowerCase(), ...segments].join("/");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The knowledge directory name for a remote: its repository name as the remote
|
|
80
|
+
* spells it. It can differ from a local checkout's folder name; recall uses the
|
|
81
|
+
* same function, so writer and reader always agree. Never `.` or `..`.
|
|
82
|
+
*/
|
|
83
|
+
export function repoSlug(remote) {
|
|
84
|
+
const parts = canonicalRemote(remote).split("/");
|
|
85
|
+
const slug = parts[parts.length - 1].replace(/[^a-z0-9._-]/g, "_");
|
|
86
|
+
return /^\.*$/.test(slug) ? `_${slug.length}` : slug;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function validRemote(remote) {
|
|
90
|
+
const r = String(remote ?? "");
|
|
91
|
+
if (!r || r.startsWith("-") || /\s/.test(r) || r.includes("::")) return false;
|
|
92
|
+
if (/^https:\/\//i.test(r)) {
|
|
93
|
+
if (/\\|%5c/i.test(r)) return false;
|
|
94
|
+
try {
|
|
95
|
+
const u = new URL(r);
|
|
96
|
+
return !u.username && !u.password;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (/^file:\/\//i.test(r)) return process.env.MCP_TOOLKIT_REPO_ALLOW_FILE === "1";
|
|
102
|
+
return /^ssh:\/\//i.test(r) || /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^/].*/.test(r);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function reference(repo, source) {
|
|
106
|
+
const out = { stack: repo.stack || "unknown", remote: repo.remote };
|
|
107
|
+
if (repo.branch) out.branch = repo.branch;
|
|
108
|
+
if (repo.credential) out.credential = repo.credential;
|
|
109
|
+
if (repo.authScheme) out.auth_scheme = repo.authScheme;
|
|
110
|
+
if (repo.username) out.username = repo.username;
|
|
111
|
+
if (source) out.source = source;
|
|
112
|
+
const { stack: _stack, source: _source, ...spec } = out;
|
|
113
|
+
out.spec = spec;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function defaultOriginOf(path) {
|
|
118
|
+
try {
|
|
119
|
+
return (
|
|
120
|
+
execFileSync("git", ["-C", path, "remote", "get-url", "origin"], {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
123
|
+
}).trim() || null
|
|
124
|
+
);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function productFor(prefs, projectKey, developed) {
|
|
131
|
+
const products = prefs?.global?.products || {};
|
|
132
|
+
const named = prefs?.projects?.[projectKey]?.product;
|
|
133
|
+
if (named && products[named]) return named;
|
|
134
|
+
for (const [name, p] of Object.entries(products)) {
|
|
135
|
+
const remotes = new Set((p.repos || []).map((r) => canonicalRemote(r.remote)));
|
|
136
|
+
if (developed.some((d) => remotes.has(d))) return name;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Product repos minus the developed ones. Without a product, the legacy
|
|
143
|
+
* per-project keys are read so an existing setup keeps a reference set: each
|
|
144
|
+
* counterpartRoots path through its origin remote, each webRepos entry only
|
|
145
|
+
* when it is already a remote URL. Anything that cannot become a remote is
|
|
146
|
+
* returned in unresolved[] rather than dropped.
|
|
147
|
+
*/
|
|
148
|
+
export function referenceSet(
|
|
149
|
+
prefs,
|
|
150
|
+
projectKey,
|
|
151
|
+
developedRemotes = [],
|
|
152
|
+
{ originOf = defaultOriginOf } = {},
|
|
153
|
+
) {
|
|
154
|
+
const developed = developedRemotes.map(canonicalRemote);
|
|
155
|
+
const isDeveloped = (remote) => developed.includes(canonicalRemote(remote));
|
|
156
|
+
const product = productFor(prefs, projectKey, developed);
|
|
157
|
+
if (product) {
|
|
158
|
+
const repos = prefs.global.products[product].repos || [];
|
|
159
|
+
return {
|
|
160
|
+
product,
|
|
161
|
+
references: repos.filter((r) => !isDeveloped(r.remote)).map((r) => reference(r)),
|
|
162
|
+
unresolved: [],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const project = prefs?.projects?.[projectKey] || {};
|
|
166
|
+
const references = [];
|
|
167
|
+
const unresolved = [];
|
|
168
|
+
for (const path of project.counterpartRoots || []) {
|
|
169
|
+
const remote = originOf(path);
|
|
170
|
+
if (remote && validRemote(remote)) {
|
|
171
|
+
if (!isDeveloped(remote))
|
|
172
|
+
references.push(reference({ stack: "unknown", remote }, "counterpartRoots"));
|
|
173
|
+
} else unresolved.push(`counterpartRoots:${path}`);
|
|
174
|
+
}
|
|
175
|
+
for (const entry of project.webRepos || project.frontendRepos || []) {
|
|
176
|
+
if (validRemote(entry)) {
|
|
177
|
+
if (!isDeveloped(entry))
|
|
178
|
+
references.push(reference({ stack: "web", remote: entry }, "webRepos"));
|
|
179
|
+
} else unresolved.push(`webRepos:${entry}`);
|
|
180
|
+
}
|
|
181
|
+
return { product: null, references, unresolved };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const NON_CORE = new Set([
|
|
185
|
+
"ios",
|
|
186
|
+
"android",
|
|
187
|
+
"web",
|
|
188
|
+
"backend",
|
|
189
|
+
"api",
|
|
190
|
+
"app",
|
|
191
|
+
"apps",
|
|
192
|
+
"mobile",
|
|
193
|
+
"new",
|
|
194
|
+
"next",
|
|
195
|
+
"legacy",
|
|
196
|
+
"v2",
|
|
197
|
+
"v3",
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const nameCore = (name) =>
|
|
201
|
+
String(name)
|
|
202
|
+
.toLowerCase()
|
|
203
|
+
.split(/[-_.]+/)
|
|
204
|
+
.filter((t) => t && !NON_CORE.has(t));
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Repos whose name core starts with the primary repo's name core. The core is
|
|
208
|
+
* the name without platform and version words, so `acme-shop-ios-new` and
|
|
209
|
+
* `acme-shop-android` share `acme-shop`, while a repo that merely has Android
|
|
210
|
+
* markers shares nothing. A primary whose name is only such words matches
|
|
211
|
+
* nothing rather than everything.
|
|
212
|
+
*/
|
|
213
|
+
export function suggestCandidates(primaryName, repos) {
|
|
214
|
+
const core = nameCore(primaryName);
|
|
215
|
+
if (!core.length) return [];
|
|
216
|
+
return (repos || [])
|
|
217
|
+
.filter((r) => {
|
|
218
|
+
const name = String(r.name || r.fullName || "")
|
|
219
|
+
.split("/")
|
|
220
|
+
.pop();
|
|
221
|
+
if (name.toLowerCase() === String(primaryName).toLowerCase()) return false;
|
|
222
|
+
const t = nameCore(name);
|
|
223
|
+
return core.every((s, i) => t[i] === s);
|
|
224
|
+
})
|
|
225
|
+
.map((r) => ({
|
|
226
|
+
fullName: r.fullName || r.name,
|
|
227
|
+
remote: r.cloneUrl || r.remote || null,
|
|
228
|
+
provider: r.provider || null,
|
|
229
|
+
stack: "unknown",
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function httpsHost(remote) {
|
|
234
|
+
if (!/^https:\/\//i.test(String(remote))) return null;
|
|
235
|
+
try {
|
|
236
|
+
return new URL(remote).hostname.toLowerCase();
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Write a confirmed product. A repository may name a credential only when that
|
|
244
|
+
* key is already bound to the repository's https host in
|
|
245
|
+
* `prefs.global.credentialHosts`, which `/multi-agent:setup` writes with the
|
|
246
|
+
* person present. The product map never creates a binding: the repo list is
|
|
247
|
+
* assembled from picker output and model-written files, and a binding made
|
|
248
|
+
* there would let any text that reached the planner send a token anywhere.
|
|
249
|
+
*/
|
|
250
|
+
const MAX_PRODUCT_REPOS = 20;
|
|
251
|
+
const CREDENTIAL_FIELDS = ["credential", "authScheme", "username"];
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Why a repo entry may not carry its credential, or null when it may: the
|
|
255
|
+
* remote must be https and its host bound to the key in
|
|
256
|
+
* `prefs.global.credentialHosts`, which only setup writes. Checked on every
|
|
257
|
+
* entry a merge produces, so a credential kept from an earlier spelling never
|
|
258
|
+
* lands on a remote it was not bound for.
|
|
259
|
+
*/
|
|
260
|
+
function credentialProblem(prefs, r) {
|
|
261
|
+
if (!r.credential) return null;
|
|
262
|
+
const host = httpsHost(r.remote);
|
|
263
|
+
const hosts = (prefs?.global?.credentialHosts?.[r.credential] || []).map((h) =>
|
|
264
|
+
String(h).toLowerCase(),
|
|
265
|
+
);
|
|
266
|
+
if (host && hosts.includes(host)) return null;
|
|
267
|
+
return `credential ${JSON.stringify(r.credential)} is not bound to ${host || r.remote}; bind it in prefs.global.credentialHosts with /multi-agent:setup hosts first`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function learnProduct(prefs, projectKey, productName, repos) {
|
|
271
|
+
if (!productName || !/^[A-Za-z0-9._-]{1,64}$/.test(productName))
|
|
272
|
+
throw new Error("product name must be 1-64 letters, digits, . _ -");
|
|
273
|
+
const clean = (repos || []).map((r) => {
|
|
274
|
+
if (!STACKS.includes(r.stack))
|
|
275
|
+
throw new Error(`stack ${JSON.stringify(r.stack)} is not one of ${STACKS.join(", ")}`);
|
|
276
|
+
if (!validRemote(r.remote))
|
|
277
|
+
throw new Error(
|
|
278
|
+
`remote ${JSON.stringify(r.remote)} must be https://, ssh:// or user@host:path, without credentials (file:// only with MCP_TOOLKIT_REPO_ALLOW_FILE=1)`,
|
|
279
|
+
);
|
|
280
|
+
if (r.authScheme && !AUTH_SCHEMES.includes(r.authScheme))
|
|
281
|
+
throw new Error(`authScheme must be one of ${AUTH_SCHEMES.join(", ")}`);
|
|
282
|
+
const problem = credentialProblem(prefs, r);
|
|
283
|
+
if (problem) throw new Error(problem);
|
|
284
|
+
const out = { stack: r.stack, remote: r.remote };
|
|
285
|
+
for (const k of ["branch", "credential", "authScheme", "username"]) if (r[k]) out[k] = r[k];
|
|
286
|
+
return out;
|
|
287
|
+
});
|
|
288
|
+
if (!clean.length) throw new Error("a product needs at least one repo");
|
|
289
|
+
const next = structuredClone(prefs || {});
|
|
290
|
+
next.global = next.global || {};
|
|
291
|
+
const merged = new Map(
|
|
292
|
+
(next.global.products?.[productName]?.repos || []).map((r) => [canonicalRemote(r.remote), r]),
|
|
293
|
+
);
|
|
294
|
+
for (const r of clean) {
|
|
295
|
+
const key = canonicalRemote(r.remote);
|
|
296
|
+
const entry = { ...(merged.get(key) || {}), ...r };
|
|
297
|
+
if (credentialProblem(prefs, entry)) for (const k of CREDENTIAL_FIELDS) delete entry[k];
|
|
298
|
+
merged.set(key, entry);
|
|
299
|
+
}
|
|
300
|
+
if (merged.size > MAX_PRODUCT_REPOS)
|
|
301
|
+
throw new Error(
|
|
302
|
+
`a product holds at most ${MAX_PRODUCT_REPOS} repos (one repo_find_keys call reads them all); ${productName} would have ${merged.size}`,
|
|
303
|
+
);
|
|
304
|
+
next.global.products = {
|
|
305
|
+
...(next.global.products || {}),
|
|
306
|
+
[productName]: { repos: [...merged.values()] },
|
|
307
|
+
};
|
|
308
|
+
next.projects = next.projects || {};
|
|
309
|
+
next.projects[projectKey] = { ...(next.projects[projectKey] || {}), product: productName };
|
|
310
|
+
return next;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A project is asked once: until it names a product or declines, it needs mapping. */
|
|
314
|
+
export function needsMapping(prefs, projectKey, developedRemotes = []) {
|
|
315
|
+
const named = prefs?.projects?.[projectKey]?.product;
|
|
316
|
+
if (named === "") return false;
|
|
317
|
+
if (typeof named === "string" && prefs?.global?.products?.[named]) return false;
|
|
318
|
+
return productFor(prefs, projectKey, developedRemotes.map(canonicalRemote)) === null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Remember that a project is not part of a product, so the offer is not repeated. */
|
|
322
|
+
export function declineProduct(prefs, projectKey) {
|
|
323
|
+
const next = structuredClone(prefs || {});
|
|
324
|
+
next.projects = next.projects || {};
|
|
325
|
+
next.projects[projectKey] = { ...(next.projects[projectKey] || {}), product: "" };
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const STATUS = {
|
|
330
|
+
implemented: "implemented",
|
|
331
|
+
not_found_at_sha: "not-yet-implemented",
|
|
332
|
+
diverges: "diverges",
|
|
333
|
+
error: "error",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export function recordFindings(
|
|
337
|
+
findKeys,
|
|
338
|
+
{
|
|
339
|
+
taskId = null,
|
|
340
|
+
knowledgeRoot = defaultKnowledgeRoot(),
|
|
341
|
+
now = new Date().toISOString(),
|
|
342
|
+
refs = [],
|
|
343
|
+
} = {},
|
|
344
|
+
) {
|
|
345
|
+
const stackOf = new Map(refs.map((r) => [canonicalRemote(r.remote), r.stack]));
|
|
346
|
+
const productContext = [];
|
|
347
|
+
for (const repo of findKeys?.repos || []) {
|
|
348
|
+
const slug = repoSlug(repo.remote);
|
|
349
|
+
const stack = stackOf.get(canonicalRemote(repo.remote));
|
|
350
|
+
const base = { remote: repo.remote, slug, ...(stack ? { stack } : {}) };
|
|
351
|
+
if (repo.error) {
|
|
352
|
+
productContext.push({ ...base, error: repo.error });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const keys = (repo.keys || []).map((k) => ({
|
|
356
|
+
key: k.key,
|
|
357
|
+
status: STATUS[k.status] || "error",
|
|
358
|
+
...(k.note ? { note: String(k.note) } : {}),
|
|
359
|
+
hits: (k.hits || []).map((h) => ({ path: h.path, line: h.line })),
|
|
360
|
+
}));
|
|
361
|
+
productContext.push({
|
|
362
|
+
...base,
|
|
363
|
+
branch: repo.branch,
|
|
364
|
+
sha: repo.sha,
|
|
365
|
+
stale: Boolean(repo.stale),
|
|
366
|
+
keys,
|
|
367
|
+
});
|
|
368
|
+
if (repo.stale || !repo.sha) continue;
|
|
369
|
+
const dir = join(knowledgeRoot, slug);
|
|
370
|
+
mkdirSync(dir, { recursive: true });
|
|
371
|
+
const lines = keys
|
|
372
|
+
.filter((k) => k.status !== "error")
|
|
373
|
+
.map((k) =>
|
|
374
|
+
JSON.stringify({
|
|
375
|
+
remote: canonicalRemote(repo.remote),
|
|
376
|
+
branch: repo.branch,
|
|
377
|
+
sha: repo.sha,
|
|
378
|
+
key: k.key,
|
|
379
|
+
status: k.status === "diverges" ? "implemented" : k.status,
|
|
380
|
+
...(k.note ? { note: k.note } : {}),
|
|
381
|
+
hits: k.hits,
|
|
382
|
+
task: taskId,
|
|
383
|
+
capturedAt: now,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
if (lines.length) appendFileSync(join(dir, KNOWLEDGE_FILE), `${lines.join("\n")}\n`);
|
|
387
|
+
}
|
|
388
|
+
return { productContext };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function recall({ knowledgeRoot = defaultKnowledgeRoot(), remote, sha, keys = [] }) {
|
|
392
|
+
const file = join(knowledgeRoot, repoSlug(remote), KNOWLEDGE_FILE);
|
|
393
|
+
const want = canonicalRemote(remote);
|
|
394
|
+
const latest = new Map();
|
|
395
|
+
if (existsSync(file)) {
|
|
396
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
397
|
+
if (!line) continue;
|
|
398
|
+
let row;
|
|
399
|
+
try {
|
|
400
|
+
row = JSON.parse(line);
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (row.remote === want && row.sha === sha && keys.includes(row.key))
|
|
405
|
+
latest.set(row.key, row);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
found: keys.filter((k) => latest.has(k)).map((k) => latest.get(k)),
|
|
410
|
+
missing: keys.filter((k) => !latest.has(k)),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function defaultKnowledgeRoot() {
|
|
415
|
+
return process.env.MULTI_AGENT_KNOWLEDGE_ROOT || join(homedir(), ".claude", "knowledge");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function defaultPrefsPath() {
|
|
419
|
+
return (
|
|
420
|
+
process.env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json")
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read, change and replace the preferences file under the lock write-state and
|
|
426
|
+
* migrate-prefs take, so a setup or a second run writing at the same moment is
|
|
427
|
+
* never overwritten. A file that cannot be parsed stops the write instead of
|
|
428
|
+
* being replaced; a symlinked file is written through to its target, at its
|
|
429
|
+
* own mode.
|
|
430
|
+
*/
|
|
431
|
+
function updatePrefs(file, mutate) {
|
|
432
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
433
|
+
return withFileLockSync(
|
|
434
|
+
file,
|
|
435
|
+
() => {
|
|
436
|
+
const current = existsSync(file) ? readJson(file) : { global: {}, projects: {} };
|
|
437
|
+
const next = mutate(current);
|
|
438
|
+
const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
|
|
439
|
+
writeJsonAtomicSync(file, next, { mode, followSymlink: true });
|
|
440
|
+
return next;
|
|
441
|
+
},
|
|
442
|
+
{ timeoutMs: lockTimeoutFrom(process.env) },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function readJson(file) {
|
|
447
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function parseArgs(argv) {
|
|
451
|
+
const out = { developed: [] };
|
|
452
|
+
for (let i = 0; i < argv.length; i++) {
|
|
453
|
+
const a = argv[i];
|
|
454
|
+
if (!a.startsWith("--")) continue;
|
|
455
|
+
const key = a.slice(2);
|
|
456
|
+
const value = argv[i + 1];
|
|
457
|
+
if (value === undefined || value === "" || value.startsWith("--")) {
|
|
458
|
+
process.stderr.write(`product-context: --${key} needs a value\n`);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
i += 1;
|
|
462
|
+
if (key === "developed") out.developed.push(value);
|
|
463
|
+
else out[key] = value;
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function fail(message) {
|
|
469
|
+
process.stderr.write(`product-context: ${message}\n`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function main() {
|
|
474
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
475
|
+
const args = parseArgs(rest);
|
|
476
|
+
const prefsFile = args.prefs || defaultPrefsPath();
|
|
477
|
+
const loadPrefs = () =>
|
|
478
|
+
existsSync(prefsFile) ? readJson(prefsFile) : { global: {}, projects: {} };
|
|
479
|
+
switch (command) {
|
|
480
|
+
case "refs": {
|
|
481
|
+
if (!args.project) fail("refs needs --project");
|
|
482
|
+
const r = referenceSet(loadPrefs(), args.project, args.developed);
|
|
483
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
484
|
+
process.exitCode = r.references.length ? 0 : 2;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
case "suggest": {
|
|
488
|
+
if (!args.primary || !args.repos) fail("suggest needs --primary and --repos");
|
|
489
|
+
process.stdout.write(
|
|
490
|
+
`${JSON.stringify(suggestCandidates(args.primary, readJson(args.repos)))}\n`,
|
|
491
|
+
);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "learn": {
|
|
495
|
+
if (!args.project || !args.product || !args.repos)
|
|
496
|
+
fail("learn needs --project, --product and --repos");
|
|
497
|
+
let next;
|
|
498
|
+
try {
|
|
499
|
+
const repos = readJson(args.repos);
|
|
500
|
+
next = updatePrefs(prefsFile, (prefs) =>
|
|
501
|
+
learnProduct(prefs, args.project, args.product, repos),
|
|
502
|
+
);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
fail(e.message);
|
|
505
|
+
}
|
|
506
|
+
process.stdout.write(
|
|
507
|
+
`${JSON.stringify({ product: args.product, repos: next.global.products[args.product].repos.length })}\n`,
|
|
508
|
+
);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case "needs-mapping": {
|
|
512
|
+
if (!args.project) fail("needs-mapping needs --project");
|
|
513
|
+
const needed = needsMapping(loadPrefs(), args.project, args.developed);
|
|
514
|
+
process.stdout.write(`${JSON.stringify({ needsMapping: needed })}\n`);
|
|
515
|
+
process.exitCode = needed ? 0 : 3;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case "decline": {
|
|
519
|
+
if (!args.project) fail("decline needs --project");
|
|
520
|
+
try {
|
|
521
|
+
updatePrefs(prefsFile, (prefs) => declineProduct(prefs, args.project));
|
|
522
|
+
} catch (e) {
|
|
523
|
+
fail(e.message);
|
|
524
|
+
}
|
|
525
|
+
process.stdout.write(`${JSON.stringify({ project: args.project, product: "" })}\n`);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case "record": {
|
|
529
|
+
if (!args.in) fail("record needs --in");
|
|
530
|
+
const r = recordFindings(readJson(args.in), {
|
|
531
|
+
taskId: args.task || null,
|
|
532
|
+
knowledgeRoot: args.knowledge || defaultKnowledgeRoot(),
|
|
533
|
+
refs: args.refs ? readJson(args.refs).references || [] : [],
|
|
534
|
+
});
|
|
535
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case "recall": {
|
|
539
|
+
if (!args.remote || !args.sha || !args.keys) fail("recall needs --remote, --sha and --keys");
|
|
540
|
+
process.stdout.write(
|
|
541
|
+
`${JSON.stringify(recall({ knowledgeRoot: args.knowledge || defaultKnowledgeRoot(), remote: args.remote, sha: args.sha, keys: args.keys.split(",").filter(Boolean) }))}\n`,
|
|
542
|
+
);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
default:
|
|
546
|
+
fail("usage: product-context.mjs refs|suggest|needs-mapping|learn|decline|record|recall ...");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# 3. No personal-data leaks (hardcoded personal keychain keys, etc.)
|
|
11
11
|
# 4. Canonical placeholders present where required (sanity)
|
|
12
12
|
# 5. Canonical project-name vocabulary in user-facing docs
|
|
13
|
+
# 8. Product context installed on Claude Code, Copilot CLI and Codex CLI
|
|
13
14
|
#
|
|
14
15
|
# Must pass before any `sync` run that touches shared commands.
|
|
15
16
|
#
|
|
@@ -34,7 +35,7 @@ if [ -z "${CROSS_CLI_HOME:-}" ] && [ -e "$CHECKOUT/.git" ] && [ -f "$CHECKOUT/in
|
|
|
34
35
|
INSTALL_HOME=$(mktemp -d "${TMPDIR:-/tmp}/cross-cli-home.XXXXXX")
|
|
35
36
|
trap 'rm -rf "$INSTALL_HOME"' EXIT
|
|
36
37
|
if HOME="$INSTALL_HOME" MULTI_AGENT_SKIP_MCP_REGISTER=1 \
|
|
37
|
-
node "$CHECKOUT/install.js" --claude --copilot >"$INSTALL_HOME/install.log" 2>&1; then
|
|
38
|
+
node "$CHECKOUT/install.js" --claude --copilot --codex >"$INSTALL_HOME/install.log" 2>&1; then
|
|
38
39
|
pass "installed this checkout into a temporary home"
|
|
39
40
|
else
|
|
40
41
|
fail "install of this checkout failed: $(tail -3 "$INSTALL_HOME/install.log" | tr '\n' ' ')"
|
|
@@ -276,6 +277,43 @@ else
|
|
|
276
277
|
fail "triage consensus.reviewerCount does not document the 3-reviewer set"
|
|
277
278
|
fi
|
|
278
279
|
|
|
280
|
+
echo ""
|
|
281
|
+
echo "→ 8. Product context reaches every host"
|
|
282
|
+
# Planning reads the product's other repositories through the toolkit's repo_*
|
|
283
|
+
# tools on every host, so the deterministic half (product-context.mjs) and the
|
|
284
|
+
# Phase 1 step that calls it must be installed wherever the pipeline runs.
|
|
285
|
+
# Copilot installs no multi-agent-refs tree by design; it reads the refs from
|
|
286
|
+
# ~/.claude or GitHub, and its own copy of the review skill carries the step.
|
|
287
|
+
for host in .claude .copilot .codex; do
|
|
288
|
+
[ -d "$H/$host" ] || { echo " ↷ SKIP: $host not installed in this home"; continue; }
|
|
289
|
+
if [ -f "$H/$host/scripts/product-context.mjs" ]; then
|
|
290
|
+
pass "$host/scripts/product-context.mjs installed"
|
|
291
|
+
else
|
|
292
|
+
fail "$host/scripts/product-context.mjs missing"
|
|
293
|
+
fi
|
|
294
|
+
done
|
|
295
|
+
for host in .claude .codex; do
|
|
296
|
+
[ -d "$H/$host" ] || continue
|
|
297
|
+
P1="$H/$host/multi-agent-refs/phases/phase-1-plan.md"
|
|
298
|
+
if [ -f "$P1" ] && grep -q "Step 1.6 - Product context" "$P1" && grep -q "product-context.mjs refs" "$P1"; then
|
|
299
|
+
pass "$host Phase 1 carries the product-context step"
|
|
300
|
+
else
|
|
301
|
+
fail "$host Phase 1 lacks the product-context step"
|
|
302
|
+
fi
|
|
303
|
+
done
|
|
304
|
+
if [ -d "$H/.copilot" ]; then
|
|
305
|
+
if grep -q "product-context.mjs refs" "$H/.copilot/skills/multi-agent-review/SKILL.md" 2>/dev/null; then
|
|
306
|
+
pass "Copilot review skill resolves product counterparts"
|
|
307
|
+
else
|
|
308
|
+
fail "Copilot review skill does not resolve product counterparts"
|
|
309
|
+
fi
|
|
310
|
+
fi
|
|
311
|
+
if grep -q '`repo_find_keys`' "$H/.claude/multi-agent-refs/cross-cli-contract.md" 2>/dev/null; then
|
|
312
|
+
pass "cross-cli contract pins the repo_* toolkit minimum"
|
|
313
|
+
else
|
|
314
|
+
fail "cross-cli contract does not name the repo_* tools"
|
|
315
|
+
fi
|
|
316
|
+
|
|
279
317
|
echo ""
|
|
280
318
|
echo "══ cross-cli-behavior smoke: $PASS passed, $FAIL failed ══"
|
|
281
319
|
[ "$FAIL" -eq 0 ]
|
|
@@ -46,6 +46,7 @@ import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
|
46
46
|
import { attendedVerdict } from "../lib/unattended.mjs";
|
|
47
47
|
import { getAdapter, resolveStack, AdapterError } from "./_stack-adapter.mjs";
|
|
48
48
|
import { globToRegExp } from "./glob-match.mjs";
|
|
49
|
+
import { diffHeaderPath } from "./git-path.mjs";
|
|
49
50
|
import { gateContext, recordQuietly } from "./gate-ledger.mjs";
|
|
50
51
|
|
|
51
52
|
const GATE = "symbol-existence";
|
|
@@ -208,13 +209,13 @@ function keyExists(ref, { keys, idents }) {
|
|
|
208
209
|
|
|
209
210
|
function listFiles(dir, head) {
|
|
210
211
|
if (head) {
|
|
211
|
-
const r = git(dir, ["ls-tree", "-r", "--name-only", head]);
|
|
212
|
+
const r = git(dir, ["ls-tree", "-r", "-z", "--name-only", head]);
|
|
212
213
|
if (!r.ok) throw new UsageError(`cannot list ${head}: ${r.err}`);
|
|
213
|
-
return r.out.split("\
|
|
214
|
+
return r.out.split("\0").filter(Boolean);
|
|
214
215
|
}
|
|
215
|
-
const tracked = git(dir, ["ls-files", "--cached"]);
|
|
216
|
-
const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
|
|
217
|
-
return [...new Set([...tracked.out.split("\
|
|
216
|
+
const tracked = git(dir, ["ls-files", "-z", "--cached"]);
|
|
217
|
+
const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
|
|
218
|
+
return [...new Set([...tracked.out.split("\0"), ...untracked.out.split("\0")].filter(Boolean))];
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
function readAt(dir, head, file) {
|
|
@@ -236,14 +237,25 @@ function readAt(dir, head, file) {
|
|
|
236
237
|
*/
|
|
237
238
|
export function addedLines(dir, base, head) {
|
|
238
239
|
const range = head ? [base, head] : [base];
|
|
239
|
-
const d = git(dir, [
|
|
240
|
+
const d = git(dir, [
|
|
241
|
+
"diff",
|
|
242
|
+
"-U0",
|
|
243
|
+
"--no-color",
|
|
244
|
+
"--no-ext-diff",
|
|
245
|
+
"--src-prefix=a/",
|
|
246
|
+
"--dst-prefix=b/",
|
|
247
|
+
"--diff-filter=AMR",
|
|
248
|
+
"--end-of-options",
|
|
249
|
+
...range,
|
|
250
|
+
]);
|
|
240
251
|
if (!d.ok) throw new UsageError(`git diff failed: ${d.err}`);
|
|
241
252
|
const out = [];
|
|
242
253
|
let file = null;
|
|
243
254
|
let line = 0;
|
|
244
255
|
for (const raw of d.out.split("\n")) {
|
|
245
256
|
if (raw.startsWith("+++ ")) {
|
|
246
|
-
|
|
257
|
+
const path = diffHeaderPath(raw.slice(4));
|
|
258
|
+
file = path === "/dev/null" ? null : path.replace(/^b\//, "");
|
|
247
259
|
continue;
|
|
248
260
|
}
|
|
249
261
|
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
@@ -258,8 +270,8 @@ export function addedLines(dir, base, head) {
|
|
|
258
270
|
}
|
|
259
271
|
}
|
|
260
272
|
if (!head) {
|
|
261
|
-
const untracked = git(dir, ["ls-files", "--others", "--exclude-standard"]);
|
|
262
|
-
for (const f of untracked.out.split("\
|
|
273
|
+
const untracked = git(dir, ["ls-files", "-z", "--others", "--exclude-standard"]);
|
|
274
|
+
for (const f of untracked.out.split("\0").filter(Boolean)) {
|
|
263
275
|
const text = readAt(dir, null, f);
|
|
264
276
|
if (text === null || text.includes("\u0000")) continue;
|
|
265
277
|
text.split("\n").forEach((t, i) => out.push({ file: f, line: i + 1, text: t }));
|
|
@@ -207,7 +207,10 @@ function isTestPath(path) {
|
|
|
207
207
|
rules.testPathPrefixes.some((p) => basename(path).startsWith(p))
|
|
208
208
|
)
|
|
209
209
|
return true;
|
|
210
|
-
if (
|
|
210
|
+
if (
|
|
211
|
+
Array.isArray(rules.testPathContains) &&
|
|
212
|
+
rules.testPathContains.some((c) => `/${path}`.includes(c))
|
|
213
|
+
)
|
|
211
214
|
return true;
|
|
212
215
|
return false;
|
|
213
216
|
}
|