@mmerterden/multi-agent-pipeline 20.25.1 → 20.27.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 +29 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/manifest.json +50 -39
- package/package.json +5 -5
- package/pipeline/commands/multi-agent/channels/SKILL.md +23 -19
- package/pipeline/lib/_jira-auth.sh +3 -1
- package/pipeline/lib/issue-fetcher.sh +40 -2
- package/pipeline/lib/jira-family-write.sh +339 -0
- package/pipeline/lib/jira-publish.sh +28 -1
- package/pipeline/lib/jira-remote-links.sh +99 -0
- package/pipeline/multi-agent-refs/analysis/intake.md +2 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -2
- package/pipeline/multi-agent-refs/channels/jira.md +12 -8
- package/pipeline/multi-agent-refs/channels/pr.md +11 -8
- package/pipeline/multi-agent-refs/channels/test-run.md +52 -0
- package/pipeline/multi-agent-refs/conventions-defaults.md +2 -0
- package/pipeline/multi-agent-refs/features/analysis-issue-links.md +42 -0
- package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
- package/pipeline/multi-agent-refs/features/localization-reuse.md +25 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +11 -3
- package/pipeline/multi-agent-refs/features/visual-evidence.md +35 -9
- package/pipeline/multi-agent-refs/generate-issue.md +9 -11
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -7
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +2 -1
- package/pipeline/multi-agent-refs/phases.md +1 -1
- package/pipeline/multi-agent-refs/platform-parity.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +10 -6
- package/pipeline/schemas/agent-state.schema.json +86 -0
- package/pipeline/schemas/prefs.schema.json +34 -0
- package/pipeline/schemas/unattended-policy.json +1 -0
- package/pipeline/scripts/_sibling-root.mjs +38 -0
- package/pipeline/scripts/gen-facts.mjs +5 -3
- package/pipeline/scripts/jira-family.mjs +947 -0
- package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
- package/pipeline/scripts/plan-remote-links.mjs +339 -0
- package/pipeline/scripts/render-flow-run.mjs +583 -0
- package/pipeline/scripts/render-jira-evidence.mjs +213 -0
- package/pipeline/skills/.skill-manifest.json +5 -5
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +15 -2
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +5 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
4
5
|
|
|
5
6
|
const SEQUENCES = [
|
|
6
7
|
":)",
|
|
@@ -118,8 +119,7 @@ async function readStdin() {
|
|
|
118
119
|
return Buffer.concat(chunks).toString("utf8");
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
if (isMain) {
|
|
122
|
+
if (invokedDirectly(import.meta.url)) {
|
|
123
123
|
const args = process.argv.slice(2);
|
|
124
124
|
const check = args.includes("--check");
|
|
125
125
|
const file = args.find((a) => !a.startsWith("--"));
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* plan-remote-links.mjs - the Issue Links an analysis puts on its Jira issue.
|
|
4
|
+
*
|
|
5
|
+
* Jira's "Issue Links" panel holds remote links: a URL, a title and a group
|
|
6
|
+
* name (`relationship`). Links there survive any later edit of the description
|
|
7
|
+
* and can be read back through the API, so the sources an analysis used belong
|
|
8
|
+
* there as well as in the body.
|
|
9
|
+
*
|
|
10
|
+
* This file only PLANS. It reads the analysis spec and the issue's current
|
|
11
|
+
* remote links and prints what to write; `lib/jira-remote-links.sh` writes it.
|
|
12
|
+
* It never plans a delete.
|
|
13
|
+
*
|
|
14
|
+
* - One link per Figma frame, Confluence page, API contract and published
|
|
15
|
+
* analysis page, deduplicated by URL.
|
|
16
|
+
* - Every link carries a stable `globalId`. Jira treats a POST with a known
|
|
17
|
+
* globalId as an update, so a re-run rewrites its own links in place.
|
|
18
|
+
* Own ids start with `ma-`; a Confluence page linked through the
|
|
19
|
+
* application link uses Jira's native `appId=<id>&pageId=<n>` form.
|
|
20
|
+
* - A URL already linked under another globalId (by a person or another tool)
|
|
21
|
+
* is skipped, never duplicated or overwritten.
|
|
22
|
+
* - A Confluence page becomes a native "Wiki Page" only when the application
|
|
23
|
+
* id is known: passed in, or read from a Wiki Page link already on the
|
|
24
|
+
* issue for the same host. It is never guessed; without it the page is a
|
|
25
|
+
* plain link.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node plan-remote-links.mjs --spec <analysis-spec.json | agent-state.json>
|
|
29
|
+
* [--existing <remotelink GET response>] [--app-id <host>=<id>] [--lang tr|en]
|
|
30
|
+
*
|
|
31
|
+
* Output: {"write": [<remotelink payload>], "skip": [{globalId, url, reason}]}
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
36
|
+
|
|
37
|
+
const CONFLUENCE_APP = { type: "com.atlassian.confluence", name: "Confluence" };
|
|
38
|
+
|
|
39
|
+
const LABELS = {
|
|
40
|
+
en: {
|
|
41
|
+
figma: "Figma",
|
|
42
|
+
confluence: "Confluence",
|
|
43
|
+
api: "API Contract",
|
|
44
|
+
analysis: "Analysis",
|
|
45
|
+
wiki: "Wiki Page",
|
|
46
|
+
},
|
|
47
|
+
tr: {
|
|
48
|
+
figma: "Figma",
|
|
49
|
+
confluence: "Confluence",
|
|
50
|
+
api: "API Sözleşmesi",
|
|
51
|
+
analysis: "Analiz",
|
|
52
|
+
wiki: "Wiki Page",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function normalizeUrl(url) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(String(url).trim());
|
|
60
|
+
} catch {
|
|
61
|
+
return String(url).trim();
|
|
62
|
+
}
|
|
63
|
+
parsed.hash = "";
|
|
64
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
65
|
+
const nodeId = parsed.searchParams.get("node-id");
|
|
66
|
+
if (/(^|\.)figma\.com$/.test(parsed.hostname)) {
|
|
67
|
+
// A Figma share link carries per-share parameters (t=, m=dev) and a renamable file
|
|
68
|
+
// slug; the frame is the file key plus the node id.
|
|
69
|
+
const { kind, fileKey } = figmaParts(parsed.toString());
|
|
70
|
+
if (fileKey) parsed.pathname = `/${kind}/${fileKey}`;
|
|
71
|
+
parsed.search = "";
|
|
72
|
+
if (nodeId) parsed.searchParams.set("node-id", nodeId.replace(/:/g, "-"));
|
|
73
|
+
} else {
|
|
74
|
+
parsed.searchParams.sort();
|
|
75
|
+
}
|
|
76
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
77
|
+
return parsed.toString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hostOf(url) {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(url).host.toLowerCase();
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shortHash(text) {
|
|
89
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 12);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pageIdOf(page) {
|
|
93
|
+
if (page.pageId) return String(page.pageId);
|
|
94
|
+
const match = String(page.url || "").match(/[?&]pageId=(\d+)|\/pages\/(\d+)(?:\/|$)/);
|
|
95
|
+
return match ? match[1] || match[2] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function figmaParts(url) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(url);
|
|
101
|
+
// A branch has its own key and its own frames; the branch key identifies them.
|
|
102
|
+
const key = parsed.pathname.match(
|
|
103
|
+
/^\/(design|file|proto|board)\/([^/]+)(?:\/branch\/([^/]+))?/,
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
kind: key ? key[1] : null,
|
|
107
|
+
fileKey: key ? key[3] || key[2] : null,
|
|
108
|
+
nodeId: parsed.searchParams.get("node-id"),
|
|
109
|
+
};
|
|
110
|
+
} catch {
|
|
111
|
+
return { kind: null, fileKey: null, nodeId: null };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The application id of the Confluence link Jira already uses for pages on `host`, or null. */
|
|
116
|
+
export function confluenceAppId(existing, host) {
|
|
117
|
+
for (const link of existing || []) {
|
|
118
|
+
if (link?.application?.type !== CONFLUENCE_APP.type) continue;
|
|
119
|
+
const match = String(link.globalId || "").match(/^appId=([^&]+)&pageId=/);
|
|
120
|
+
if (match && hostOf(link.object?.url) === String(host).toLowerCase()) return match[1];
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function specOf(input) {
|
|
126
|
+
return input?.analysisSpec || input || {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The links the spec asks for, in a stable order, one per URL. */
|
|
130
|
+
export function wantedLinks(input, { lang = "en" } = {}) {
|
|
131
|
+
const labels = LABELS[lang] || LABELS.en;
|
|
132
|
+
const spec = specOf(input);
|
|
133
|
+
const evidence = spec.evidence || {};
|
|
134
|
+
const wanted = [];
|
|
135
|
+
for (let frame of evidence.figma || []) {
|
|
136
|
+
if (!frame?.url) continue;
|
|
137
|
+
const fromUrl = figmaParts(frame.url);
|
|
138
|
+
frame = {
|
|
139
|
+
...frame,
|
|
140
|
+
fileKey: frame.fileKey || fromUrl.fileKey,
|
|
141
|
+
nodeId: frame.nodeId || fromUrl.nodeId,
|
|
142
|
+
};
|
|
143
|
+
const nodeId = frame.nodeId ? String(frame.nodeId).replace(/-/g, ":") : null;
|
|
144
|
+
const name = frame.frames?.find((f) => f?.name)?.name;
|
|
145
|
+
const title = nodeId
|
|
146
|
+
? name
|
|
147
|
+
? `${labels.figma} · ${name} (${nodeId})`
|
|
148
|
+
: `${labels.figma} · ${nodeId}`
|
|
149
|
+
: `${labels.figma} · ${name || frame.fileKey || "design"}`;
|
|
150
|
+
const globalId =
|
|
151
|
+
frame.fileKey && nodeId
|
|
152
|
+
? `ma-figma-${frame.fileKey}-${nodeId.replace(/:/g, "-")}`
|
|
153
|
+
: `ma-figma-${shortHash(normalizeUrl(frame.url))}`;
|
|
154
|
+
wanted.push({ kind: "figma", url: frame.url, title, relationship: labels.figma, globalId });
|
|
155
|
+
}
|
|
156
|
+
for (const page of evidence.confluence || []) {
|
|
157
|
+
if (!page?.url) continue;
|
|
158
|
+
const pageId = pageIdOf(page);
|
|
159
|
+
wanted.push({
|
|
160
|
+
kind: "confluence",
|
|
161
|
+
url: page.url,
|
|
162
|
+
title: page.title || `${labels.confluence} · ${pageId || hostOf(page.url)}`,
|
|
163
|
+
relationship: labels.confluence,
|
|
164
|
+
pageId,
|
|
165
|
+
globalId: `ma-confluence-${shortHash(normalizeUrl(page.url))}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const contract of evidence.swagger || []) {
|
|
169
|
+
if (!contract?.url) continue;
|
|
170
|
+
let leaf = hostOf(contract.url);
|
|
171
|
+
try {
|
|
172
|
+
leaf = new URL(contract.url).pathname.split("/").filter(Boolean).pop() || leaf;
|
|
173
|
+
} catch {
|
|
174
|
+
/* keep the host */
|
|
175
|
+
}
|
|
176
|
+
wanted.push({
|
|
177
|
+
kind: "api",
|
|
178
|
+
url: contract.url,
|
|
179
|
+
title: `API · ${leaf}`,
|
|
180
|
+
relationship: labels.api,
|
|
181
|
+
globalId: `ma-api-${shortHash(normalizeUrl(contract.url))}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const page of spec.outputs?.confluencePages || []) {
|
|
185
|
+
if (!page?.url) continue;
|
|
186
|
+
const pageId = pageIdOf(page);
|
|
187
|
+
wanted.push({
|
|
188
|
+
kind: "analysis",
|
|
189
|
+
url: page.url,
|
|
190
|
+
title: `${labels.analysis} · ${page.title || pageId || hostOf(page.url)}`,
|
|
191
|
+
relationship: labels.analysis,
|
|
192
|
+
pageId,
|
|
193
|
+
globalId: `ma-analysis-${shortHash(normalizeUrl(page.url))}`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
return wanted.filter((link) => {
|
|
198
|
+
const key = normalizeUrl(link.url);
|
|
199
|
+
if (seen.has(key)) return false;
|
|
200
|
+
seen.add(key);
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function payloadFor(link, appIdFor, lang) {
|
|
206
|
+
const labels = LABELS[lang] || LABELS.en;
|
|
207
|
+
const isPage = link.kind === "confluence" || link.kind === "analysis";
|
|
208
|
+
const appId = isPage && link.pageId ? appIdFor(hostOf(link.url)) : null;
|
|
209
|
+
if (appId) {
|
|
210
|
+
return {
|
|
211
|
+
globalId: `appId=${appId}&pageId=${link.pageId}`,
|
|
212
|
+
application: { ...CONFLUENCE_APP },
|
|
213
|
+
relationship: labels.wiki,
|
|
214
|
+
object: { url: link.url, title: link.title },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
globalId: link.globalId,
|
|
219
|
+
relationship: link.relationship,
|
|
220
|
+
object: { url: link.url, title: link.title },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sameLink(a, b) {
|
|
225
|
+
return (
|
|
226
|
+
a.relationship === b.relationship &&
|
|
227
|
+
normalizeUrl(a.object?.url) === normalizeUrl(b.object?.url) &&
|
|
228
|
+
a.object?.title === b.object?.title
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function planRemoteLinks({ spec, existing = [], appId = null, lang = "en" }) {
|
|
233
|
+
const current = Array.isArray(existing) ? existing : [];
|
|
234
|
+
const byGlobalId = new Map(current.filter((l) => l?.globalId).map((l) => [l.globalId, l]));
|
|
235
|
+
// An explicit id belongs to one Confluence host, like one read from an existing link.
|
|
236
|
+
const at = typeof appId === "string" ? appId.indexOf("=") : -1;
|
|
237
|
+
const explicit = at > 0 ? [appId.slice(0, at), appId.slice(at + 1)] : null;
|
|
238
|
+
const appIdFor = (host) =>
|
|
239
|
+
explicit && explicit[0].toLowerCase() === host ? explicit[1] : confluenceAppId(current, host);
|
|
240
|
+
const write = [];
|
|
241
|
+
const skip = [];
|
|
242
|
+
const planned = new Set();
|
|
243
|
+
for (const link of wantedLinks(spec, { lang })) {
|
|
244
|
+
let payload = payloadFor(link, appIdFor, lang);
|
|
245
|
+
const target = normalizeUrl(link.url);
|
|
246
|
+
// A link this tool wrote earlier under its plain id stays the link for that URL:
|
|
247
|
+
// a global id cannot change without a delete, and nothing here deletes.
|
|
248
|
+
const ownPlain = current.find(
|
|
249
|
+
(l) =>
|
|
250
|
+
String(l?.globalId || "").startsWith("ma-") &&
|
|
251
|
+
l.globalId !== payload.globalId &&
|
|
252
|
+
normalizeUrl(l.object?.url) === target,
|
|
253
|
+
);
|
|
254
|
+
if (ownPlain)
|
|
255
|
+
payload = {
|
|
256
|
+
globalId: ownPlain.globalId,
|
|
257
|
+
relationship: link.relationship,
|
|
258
|
+
object: { url: link.url, title: link.title },
|
|
259
|
+
};
|
|
260
|
+
if (planned.has(payload.globalId)) {
|
|
261
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "duplicate-global-id" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
planned.add(payload.globalId);
|
|
265
|
+
const own = byGlobalId.get(payload.globalId);
|
|
266
|
+
if (own) {
|
|
267
|
+
// Only an `ma-` link is ours to rewrite; a native Wiki Page link with the same id
|
|
268
|
+
// may be a person's, so it is left exactly as it is.
|
|
269
|
+
if (sameLink(own, payload))
|
|
270
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "unchanged" });
|
|
271
|
+
else if (!String(own.globalId).startsWith("ma-"))
|
|
272
|
+
skip.push({ globalId: payload.globalId, url: link.url, reason: "already-linked" });
|
|
273
|
+
else write.push(payload);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const taken = current.some(
|
|
277
|
+
(l) => l?.globalId !== payload.globalId && normalizeUrl(l?.object?.url) === target,
|
|
278
|
+
);
|
|
279
|
+
if (taken) skip.push({ globalId: payload.globalId, url: link.url, reason: "already-linked" });
|
|
280
|
+
else write.push(payload);
|
|
281
|
+
}
|
|
282
|
+
return { write, skip };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readJson(path, fallback) {
|
|
286
|
+
if (!path) return fallback;
|
|
287
|
+
if (!existsSync(path)) throw new Error(`no such file: ${path}`);
|
|
288
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function main(argv) {
|
|
292
|
+
const opts = {};
|
|
293
|
+
for (let i = 0; i < argv.length; i++) {
|
|
294
|
+
const arg = argv[i];
|
|
295
|
+
if (
|
|
296
|
+
!["--spec", "--existing", "--app-id", "--lang"].includes(arg) ||
|
|
297
|
+
argv[i + 1] === undefined
|
|
298
|
+
) {
|
|
299
|
+
process.stderr.write(`plan-remote-links: unknown or incomplete argument: ${arg}\n`);
|
|
300
|
+
return 2;
|
|
301
|
+
}
|
|
302
|
+
opts[arg.slice(2)] = argv[++i];
|
|
303
|
+
}
|
|
304
|
+
if (!opts.spec) {
|
|
305
|
+
process.stderr.write("plan-remote-links: --spec is required\n");
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
let spec, existing;
|
|
309
|
+
try {
|
|
310
|
+
spec = readJson(opts.spec, {});
|
|
311
|
+
existing = readJson(opts.existing, []);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
process.stderr.write(`plan-remote-links: ${error.message}\n`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
const appId = opts["app-id"];
|
|
317
|
+
if (appId !== undefined && !(appId.indexOf("=") > 0 && appId.indexOf("=") < appId.length - 1)) {
|
|
318
|
+
process.stderr.write("plan-remote-links: --app-id must be <host>=<id>\n");
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
if (appId) {
|
|
322
|
+
const host = appId.slice(0, appId.indexOf("=")).toLowerCase();
|
|
323
|
+
const hosts = wantedLinks(spec)
|
|
324
|
+
.filter((link) => link.pageId)
|
|
325
|
+
.map((link) => hostOf(link.url));
|
|
326
|
+
if (!hosts.includes(host))
|
|
327
|
+
process.stderr.write(`plan-remote-links: --app-id host ${host} matches no Confluence page\n`);
|
|
328
|
+
}
|
|
329
|
+
const plan = planRemoteLinks({
|
|
330
|
+
spec,
|
|
331
|
+
existing,
|
|
332
|
+
appId: opts["app-id"] || null,
|
|
333
|
+
lang: opts.lang || "en",
|
|
334
|
+
});
|
|
335
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (invokedDirectly(import.meta.url)) process.exit(main(process.argv.slice(2)));
|