@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/docs/facts.json +3 -3
  5. package/install/catalog-history.json +1 -1
  6. package/manifest.json +50 -39
  7. package/package.json +5 -5
  8. package/pipeline/commands/multi-agent/channels/SKILL.md +23 -19
  9. package/pipeline/lib/_jira-auth.sh +3 -1
  10. package/pipeline/lib/issue-fetcher.sh +40 -2
  11. package/pipeline/lib/jira-family-write.sh +339 -0
  12. package/pipeline/lib/jira-publish.sh +28 -1
  13. package/pipeline/lib/jira-remote-links.sh +99 -0
  14. package/pipeline/multi-agent-refs/analysis/intake.md +2 -0
  15. package/pipeline/multi-agent-refs/analysis/render.md +4 -2
  16. package/pipeline/multi-agent-refs/channels/jira.md +12 -8
  17. package/pipeline/multi-agent-refs/channels/pr.md +11 -8
  18. package/pipeline/multi-agent-refs/channels/test-run.md +52 -0
  19. package/pipeline/multi-agent-refs/conventions-defaults.md +2 -0
  20. package/pipeline/multi-agent-refs/features/analysis-issue-links.md +42 -0
  21. package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
  22. package/pipeline/multi-agent-refs/features/localization-reuse.md +25 -0
  23. package/pipeline/multi-agent-refs/features/url-enrichment.md +11 -3
  24. package/pipeline/multi-agent-refs/features/visual-evidence.md +35 -9
  25. package/pipeline/multi-agent-refs/generate-issue.md +9 -11
  26. package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -7
  27. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
  28. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  29. package/pipeline/multi-agent-refs/phases/phase-5-report.md +2 -1
  30. package/pipeline/multi-agent-refs/phases.md +1 -1
  31. package/pipeline/multi-agent-refs/platform-parity.md +1 -1
  32. package/pipeline/multi-agent-refs/tracker-contract.md +10 -6
  33. package/pipeline/schemas/agent-state.schema.json +86 -0
  34. package/pipeline/schemas/prefs.schema.json +34 -0
  35. package/pipeline/schemas/unattended-policy.json +1 -0
  36. package/pipeline/scripts/_sibling-root.mjs +38 -0
  37. package/pipeline/scripts/gen-facts.mjs +5 -3
  38. package/pipeline/scripts/jira-family.mjs +947 -0
  39. package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
  40. package/pipeline/scripts/plan-remote-links.mjs +339 -0
  41. package/pipeline/scripts/render-flow-run.mjs +583 -0
  42. package/pipeline/scripts/render-jira-evidence.mjs +213 -0
  43. package/pipeline/skills/.skill-manifest.json +5 -5
  44. package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
  45. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +15 -2
  46. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  47. 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
- import { pathToFileURL } from "node:url";
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
- const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
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)));