@mmerterden/multi-agent-pipeline 20.26.0 → 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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/docs/facts.json +3 -3
  4. package/install/catalog-history.json +1 -1
  5. package/manifest.json +42 -34
  6. package/package.json +5 -5
  7. package/pipeline/commands/multi-agent/channels/SKILL.md +19 -17
  8. package/pipeline/lib/issue-fetcher.sh +40 -2
  9. package/pipeline/lib/jira-remote-links.sh +99 -0
  10. package/pipeline/multi-agent-refs/analysis/intake.md +2 -0
  11. package/pipeline/multi-agent-refs/analysis/render.md +4 -2
  12. package/pipeline/multi-agent-refs/channels/jira.md +10 -8
  13. package/pipeline/multi-agent-refs/channels/pr.md +11 -8
  14. package/pipeline/multi-agent-refs/channels/test-run.md +52 -0
  15. package/pipeline/multi-agent-refs/conventions-defaults.md +2 -0
  16. package/pipeline/multi-agent-refs/features/analysis-issue-links.md +42 -0
  17. package/pipeline/multi-agent-refs/features/localization-reuse.md +25 -0
  18. package/pipeline/multi-agent-refs/features/url-enrichment.md +11 -3
  19. package/pipeline/multi-agent-refs/features/visual-evidence.md +35 -9
  20. package/pipeline/multi-agent-refs/generate-issue.md +9 -11
  21. package/pipeline/multi-agent-refs/phases/phase-0-init.md +10 -6
  22. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
  23. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  24. package/pipeline/multi-agent-refs/phases/phase-5-report.md +2 -1
  25. package/pipeline/multi-agent-refs/phases.md +1 -1
  26. package/pipeline/multi-agent-refs/platform-parity.md +1 -1
  27. package/pipeline/multi-agent-refs/tracker-contract.md +10 -6
  28. package/pipeline/schemas/agent-state.schema.json +50 -0
  29. package/pipeline/schemas/prefs.schema.json +11 -0
  30. package/pipeline/schemas/unattended-policy.json +1 -0
  31. package/pipeline/scripts/_sibling-root.mjs +38 -0
  32. package/pipeline/scripts/gen-facts.mjs +5 -3
  33. package/pipeline/scripts/plan-remote-links.mjs +339 -0
  34. package/pipeline/scripts/render-flow-run.mjs +583 -0
  35. package/pipeline/scripts/render-jira-evidence.mjs +213 -0
  36. package/pipeline/skills/.skill-manifest.json +4 -4
  37. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +8 -2
  38. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  39. package/pipeline/skills/shared/external/ios-localization/SKILL.md +5 -0
@@ -128,7 +128,7 @@ is not reported.
128
128
  | 1 | Services | Does the counterpart screen call the same endpoints? An endpoint one side calls and the other does not is the finding - including a service that exists only on one platform. |
129
129
  | 2 | Parameters | Same request fields, query parameters and headers, with the same optionality. A field sent by one side and omitted by the other is a finding even when both requests succeed. |
130
130
  | 3 | Business rules | The conditions around the call and the response: validation, eligibility, retry, empty and error handling, feature-flag checks. A rule present on one side only is the finding. |
131
- | 4 | Localization keys | The keys the changed screen uses on each side. Different key for the same string, a key one side has and the other does not, and a key that resolves to different copy. |
131
+ | 4 | Localization keys | The keys the changed screen uses on each side. Different key for the same string, a key one side has and the other does not, and a key that resolves to different copy. A key the change ADDS whose display text another key already holds on either side is a duplicate: the existing key is reused (`features/localization-reuse.md`). |
132
132
 
133
133
  Nothing else. Layout, naming, architecture and idiom differ between the
134
134
  platforms by design, and reporting them buries the four things that matter.
@@ -101,16 +101,16 @@ Register each phase:
101
101
 
102
102
  ```bash
103
103
  bash $HOME/.claude/scripts/phase-tracker.sh add 0 "Init"
104
- bash $HOME/.claude/scripts/phase-tracker.sh add 1 "Analysis"
104
+ bash $HOME/.claude/scripts/phase-tracker.sh add 1 "Plan"
105
105
  # ...
106
106
  ```
107
107
 
108
108
  ### Claude Code - also register a TaskList tile per phase
109
109
 
110
110
  ```text
111
- For each phase in the current mode, IN PHASE-NUMBER ORDER (0 → 1 → 2 → ... → 5):
111
+ For each `TaskCreate` line `phase-tracker.sh tiles` prints, IN THAT ORDER:
112
112
  TaskCreate({
113
- subject: "Phase 0: Init",
113
+ subject: "Phase 0 Init",
114
114
  description: "Repo discovery, branch, worktree, identity bind",
115
115
  activeForm: "Initializing pipeline"
116
116
  })
@@ -118,6 +118,8 @@ For each phase in the current mode, IN PHASE-NUMBER ORDER (0 → 1 → 2 → ...
118
118
  bash phase-tracker.sh meta 0 tasklist_id "<taskId>"
119
119
  ```
120
120
 
121
+ **The subject is the tracker's line, verbatim.** `phase-tracker.sh tiles` prints each subject from the phase names the command registered (`0:Init 1:Plan 2:Dev 3:Review 4:Commit 5:Report` for a full run). Copy it as printed: no task-id prefix, no renamed, merged or annotated phase (`Analysis + Plan`, `Review + Verify`). What a phase does inside goes in `activeForm` or a sub-step tile, never in the phase name. A tile that disagrees with the tracker is a second, wrong progress record.
122
+
121
123
  The `tasklist_id` meta field is persisted so that `:resume` can rebuild the TaskList from the state file in a fresh session.
122
124
 
123
125
  #### The card is not the widget
@@ -310,13 +312,15 @@ fabricating numbers.
310
312
  **Completion tile suffix (Claude Code).** The native widget cannot show
311
313
  per-phase tokens, but subject edits after creation are safe (tile order is
312
314
  fixed at TaskCreate time). So on phase completion, in addition to the
313
- narration line, append the spend to the tile subject:
315
+ narration line, set the subject to what `phase-tracker.sh subjects <N>` prints,
316
+ which is the tracker's own line with model, elapsed time and spend; the suffix is
317
+ the tracker's, never typed by hand:
314
318
 
315
319
  ```text
316
- TaskUpdate({taskId: <phase_task>, subject: "Phase 2: Dev · ~35k tok · ~$0.26"})
320
+ TaskUpdate({taskId: <phase_task>, subject: "Phase 2 Dev - sonnet - 4m 12s - 35k tok - ~$0.26"})
317
321
  ```
318
322
 
319
- Skip the suffix when the phase recorded zero tokens (subject stays clean).
323
+ A phase with no recorded tokens gets no token part from `subjects`; nothing is added by hand.
320
324
 
321
325
  **Honesty note:** on Claude Code the orchestrator does not receive its own
322
326
  usage metering, so per-phase counts are content-size estimates (chars/4 for
@@ -1863,6 +1863,17 @@
1863
1863
  }
1864
1864
  }
1865
1865
  },
1866
+ "evidence": {
1867
+ "type": "object",
1868
+ "description": "Evidence gathered during the run for the report channels. Other evidence kinds live beside flowRuns.",
1869
+ "properties": {
1870
+ "flowRuns": {
1871
+ "type": "array",
1872
+ "description": "Flow-runner run ids (the runId in <RUN_ROOT>/history.jsonl) of the UI scenario runs that verify this task. Channels renders their Xray Test Execution in the 'Test Koşumu (Xray)' block; empty means channels discovers candidate runs instead.",
1873
+ "items": { "type": "string", "minLength": 1 }
1874
+ }
1875
+ }
1876
+ },
1866
1877
  "visualEvidence": {
1867
1878
  "type": ["object", "null"],
1868
1879
  "additionalProperties": false,
@@ -1987,6 +1998,45 @@
1987
1998
  }
1988
1999
  }
1989
2000
  },
2001
+ "scenarios": {
2002
+ "type": "array",
2003
+ "description": "Per-scenario evidence: one screenshot and/or one recording for each test scenario a UI run covered, attached to the Jira issue. Rendered as one evidence line under that scenario by scripts/render-jira-evidence.mjs.",
2004
+ "items": {
2005
+ "type": "object",
2006
+ "additionalProperties": false,
2007
+ "required": ["id"],
2008
+ "properties": {
2009
+ "id": {
2010
+ "type": "string",
2011
+ "description": "Scenario id, as the test scenarios section names it."
2012
+ },
2013
+ "image": {
2014
+ "type": "object",
2015
+ "additionalProperties": true,
2016
+ "properties": {
2017
+ "file": { "type": "string" },
2018
+ "jiraFilename": { "type": "string" },
2019
+ "url": {
2020
+ "type": "string",
2021
+ "description": "Jira attachment content URL, as jira-attach.sh printed it."
2022
+ }
2023
+ }
2024
+ },
2025
+ "video": {
2026
+ "type": "object",
2027
+ "additionalProperties": true,
2028
+ "properties": {
2029
+ "file": { "type": "string" },
2030
+ "jiraFilename": { "type": "string" },
2031
+ "url": {
2032
+ "type": "string",
2033
+ "description": "Jira attachment content URL, as jira-attach.sh printed it."
2034
+ }
2035
+ }
2036
+ }
2037
+ }
2038
+ }
2039
+ },
1990
2040
  "gaps": {
1991
2041
  "type": "array",
1992
2042
  "description": "Every artefact that is required and absent, with its reason. A recorded reason satisfies the Phase 4 blocker; silence does not.",
@@ -829,6 +829,11 @@
829
829
  "default": false,
830
830
  "description": "v6.1.0+ - Per-phase token cost summary (phase name, tokens_in, tokens_out, est. USD). Source: phase-tracker.sh token tallies + optional OTel spans (MULTI_AGENT_OTEL_SPANS=1). Greyed out if tracker has no token data for the task. Opt-in - off by default so baseline PR body stays unchanged."
831
831
  },
832
+ "testRun": {
833
+ "type": "boolean",
834
+ "default": false,
835
+ "description": "'Test Koşumu (Xray)' block: the Xray Test Execution of the task's UI scenario run, read from the flow runner's history.jsonl by render-flow-run.mjs. Ticked automatically when the run comes from --flow-run or state.evidence.flowRuns; greyed out when no run resolves."
836
+ },
832
837
  "workSummary": {
833
838
  "type": "boolean",
834
839
  "default": false,
@@ -2231,6 +2236,12 @@
2231
2236
  "default": true,
2232
2237
  "description": "Off disables capture, upload and both render sections. The Phase 4 blocker goes with it."
2233
2238
  },
2239
+ "jiraMedia": {
2240
+ "type": "string",
2241
+ "enum": ["links", "thumbnails"],
2242
+ "default": "links",
2243
+ "description": "How the Jira comment references screenshots: links (one evidence line per scenario, default) or thumbnails (small embedded images). Recordings are always links. Consumed by scripts/render-jira-evidence.mjs --mode."
2244
+ },
2234
2245
  "maxAttachmentMb": {
2235
2246
  "type": "integer",
2236
2247
  "minimum": 1,
@@ -128,6 +128,7 @@
128
128
  "update-issue-progress.sh": null,
129
129
  "analysis-jira-write.sh": null,
130
130
  "jira-attach.sh": null,
131
+ "jira-remote-links.sh": ["apply"],
131
132
  "md2confluence-v3.py": ["create", "update"],
132
133
  "multi-repo-pipeline.sh": ["push", "pr"],
133
134
  "keychain-save.sh": null,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file _sibling-root.mjs - the directory that holds this repo's sibling
3
+ * checkouts (multi-agent-toolkit-mcp, multi-agent-plugins).
4
+ *
5
+ * Siblings sit beside the MAIN checkout. A linked worktree lives inside it, at
6
+ * `<main>/.worktrees/<name>`, so `<root>/..` from there is `.worktrees/` and no
7
+ * sibling is found. The main checkout is the directory that holds the shared
8
+ * git dir, so its parent is the answer for every worktree of the repo.
9
+ *
10
+ * Outside git, or when the shared git dir is not a `.git` directory (a bare
11
+ * repo, a submodule's `.git/modules/<name>`), it falls back to `<root>/..`.
12
+ *
13
+ * Usage as a command: `node _sibling-root.mjs <root>` prints the directory.
14
+ *
15
+ * Zero runtime dependencies (ADR-0004).
16
+ */
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import { basename, dirname, resolve } from "node:path";
20
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
21
+
22
+ /** @param {string} root */
23
+ export function siblingsParent(root) {
24
+ const fallback = resolve(root, "..");
25
+ const r = spawnSync(
26
+ "git",
27
+ ["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"],
28
+ { encoding: "utf8" },
29
+ );
30
+ if (r.status !== 0) return fallback;
31
+ const commonDir = r.stdout.trim();
32
+ if (basename(commonDir) !== ".git") return fallback;
33
+ return dirname(dirname(commonDir));
34
+ }
35
+
36
+ if (invokedDirectly(import.meta.url)) {
37
+ process.stdout.write(`${siblingsParent(process.argv[2] || process.cwd())}\n`);
38
+ }
@@ -37,6 +37,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
37
37
  import { join, dirname } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
  import { spawnSync } from "node:child_process";
40
+ import { siblingsParent } from "./_sibling-root.mjs";
40
41
 
41
42
  const HERE = dirname(fileURLToPath(import.meta.url));
42
43
  const ROOT = join(HERE, "..", "..");
@@ -94,9 +95,10 @@ try {
94
95
  let toolCount = null;
95
96
  let toolCategories = null;
96
97
  let toolkitVersion = null;
97
- // Sibling checkouts are looked up beside this repo; GEN_FACTS_SIBLINGS_DIR
98
- // names another parent directory (a test points it at an empty one).
99
- const siblingsDir = process.env.GEN_FACTS_SIBLINGS_DIR || join(ROOT, "..");
98
+ // Sibling checkouts are looked up beside the main checkout, also from a worktree
99
+ // under .worktrees/; GEN_FACTS_SIBLINGS_DIR names another parent directory (a
100
+ // test points it at an empty one).
101
+ const siblingsDir = process.env.GEN_FACTS_SIBLINGS_DIR || siblingsParent(ROOT);
100
102
  const toolkitDir = join(siblingsDir, "multi-agent-toolkit-mcp");
101
103
  if (existsSync(join(toolkitDir, "package.json"))) {
102
104
  try {
@@ -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)));