@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
@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * render-flow-run.mjs - the "Test Run (Xray)" content block of /multi-agent:channels.
4
+ *
5
+ * A flow runner records every UI scenario run in `<RUN_ROOT>/history.jsonl`:
6
+ * a "run" line with per-scenario results, and, once the run is delivered to
7
+ * Xray, a "delivery" line with the same runId naming the Test Execution it
8
+ * created per domain. This script reads that file and renders the block that
9
+ * lets a reader get from the dev task to the run that proves it.
10
+ *
11
+ * It is a read-only consumer. It never calls the tracker, never creates or
12
+ * updates an execution and never invents a key: a domain with no execution in
13
+ * the delivery line is reported as such. The only process it starts is `git`,
14
+ * to list the task branch's commits for discovery.
15
+ *
16
+ * Run resolution, first hit wins:
17
+ * 1. --flow-run <runId>[,<runId>...]
18
+ * 2. state.evidence.flowRuns[] in agent-state.json
19
+ * 3. discovery: runs whose recorded commit is on the task branch (in
20
+ * base..branch) or whose results carry ticket == the task's Jira key.
21
+ * Discovery is a suggestion; the caller shows the candidates for the user
22
+ * to untick.
23
+ *
24
+ * Usage:
25
+ * node render-flow-run.mjs [--flow-run id,id] [--state agent-state.json]
26
+ * [--history path] [--run-root dir] [--config scenario.config.yaml]
27
+ * [--repo dir] [--branch b] [--base b] [--ticket KEY]
28
+ * [--jira-base url] [--lang tr|en] [--heading-level 2|3] [--json]
29
+ *
30
+ * Without --json it prints the markdown block, or nothing when the block is
31
+ * unavailable. Exit 0 in both cases; exit 2 on a usage error.
32
+ */
33
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
34
+ import { dirname, isAbsolute, join, resolve } from "node:path";
35
+ import { homedir } from "node:os";
36
+ import { spawnSync } from "node:child_process";
37
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
38
+
39
+ export const RUN = "run";
40
+ export const DELIVERY = "delivery";
41
+ const CONFIG_NAME = "scenario.config.yaml";
42
+ const XRAY_STATUS = {
43
+ passed: "PASS",
44
+ expectedFailure: "PASS",
45
+ failed: "FAIL",
46
+ skipped: "TODO",
47
+ indeterminate: "ABORTED",
48
+ };
49
+ const ALWAYS_COUNTED = ["PASS", "FAIL", "ABORTED"];
50
+
51
+ const LABELS = {
52
+ tr: {
53
+ heading: "Test Koşumu",
54
+ field: "Alan",
55
+ value: "Değer",
56
+ execution: "Test Execution",
57
+ setPlan: "Test Set / Plan",
58
+ run: "Koşu",
59
+ result: "Sonuç",
60
+ scenario: "Senaryo",
61
+ xrayTest: "Xray testi",
62
+ status: "Durum",
63
+ notDelivered: "Xray'e teslim edilmedi",
64
+ deliverHint: "Teslim etmek için",
65
+ skipped: "atlandı",
66
+ noExecution: "execution oluşturulmadı",
67
+ noTestKey: "Xray testi yok (xray-sync)",
68
+ missingHistory: (path) => `koşu geçmişi yok: ${path}`,
69
+ noMatch: (what) => `bu iş için koşu bulunamadı (${what})`,
70
+ },
71
+ en: {
72
+ heading: "Test Run",
73
+ field: "Field",
74
+ value: "Value",
75
+ execution: "Test Execution",
76
+ setPlan: "Test Set / Plan",
77
+ run: "Run",
78
+ result: "Result",
79
+ scenario: "Scenario",
80
+ xrayTest: "Xray test",
81
+ status: "Status",
82
+ notDelivered: "Not delivered to Xray",
83
+ deliverHint: "To deliver it",
84
+ skipped: "skipped",
85
+ noExecution: "no execution created",
86
+ noTestKey: "No Xray test (xray-sync)",
87
+ missingHistory: (path) => `no run history: ${path}`,
88
+ noMatch: (what) => `no run found for this task (${what})`,
89
+ },
90
+ };
91
+
92
+ export function readHistory(path) {
93
+ let text;
94
+ try {
95
+ text = readFileSync(path, "utf-8");
96
+ } catch {
97
+ return { exists: false, entries: [] };
98
+ }
99
+ const entries = [];
100
+ for (const line of text.split(/\r?\n/)) {
101
+ let value;
102
+ try {
103
+ value = JSON.parse(line);
104
+ } catch {
105
+ continue;
106
+ }
107
+ // A run id is a path segment and a command argument; anything else is not a run.
108
+ if (
109
+ value &&
110
+ typeof value === "object" &&
111
+ !Array.isArray(value) &&
112
+ typeof value.runId === "string" &&
113
+ RUN_ID.test(value.runId) &&
114
+ !/^\.+$/.test(value.runId)
115
+ )
116
+ entries.push(value);
117
+ }
118
+ return { exists: true, entries };
119
+ }
120
+
121
+ const RUN_ID = /^[A-Za-z0-9._-]+$/;
122
+
123
+ /** runId -> { run, delivery }; delivery is the latest delivery line for that runId, or null. */
124
+ export function pairRuns(entries) {
125
+ const deliveries = new Map();
126
+ entries.forEach((entry, index) => {
127
+ if (entry.type !== DELIVERY) return;
128
+ const current = deliveries.get(entry.runId);
129
+ const order = [entry.at || "", index];
130
+ if (!current || compareOrder(order, current.order) >= 0)
131
+ deliveries.set(entry.runId, { entry, order });
132
+ });
133
+ const paired = new Map();
134
+ for (const entry of entries) {
135
+ if (entry.type !== RUN) continue;
136
+ const delivery = deliveries.get(entry.runId)?.entry;
137
+ paired.set(entry.runId, {
138
+ run: entry,
139
+ delivery: delivery
140
+ ? {
141
+ executions: delivery.executions || {},
142
+ skipped: delivery.skipped || {},
143
+ at: delivery.at || null,
144
+ }
145
+ : null,
146
+ });
147
+ }
148
+ return paired;
149
+ }
150
+
151
+ function compareOrder([atA, indexA], [atB, indexB]) {
152
+ if (atA !== atB) return atA < atB ? -1 : 1;
153
+ return indexA - indexB;
154
+ }
155
+
156
+ function commitMatches(recorded, branchCommits) {
157
+ if (!recorded) return false;
158
+ const short = String(recorded).toLowerCase();
159
+ if (short.length < 7) return false;
160
+ return branchCommits.some((sha) => {
161
+ const full = String(sha).toLowerCase();
162
+ return full.startsWith(short) || short.startsWith(full);
163
+ });
164
+ }
165
+
166
+ function carriesTicket(run, ticket) {
167
+ if (!ticket) return false;
168
+ if (run.ticket === ticket) return true;
169
+ return (run.results || []).some((result) => result.ticket === ticket);
170
+ }
171
+
172
+ export function resolveRuns({
173
+ flag = [],
174
+ stateRuns = [],
175
+ entries = [],
176
+ branchCommits = [],
177
+ ticket = null,
178
+ }) {
179
+ if (flag.length) return { source: "flag", runIds: [...flag] };
180
+ if (stateRuns.length) return { source: "state", runIds: [...stateRuns] };
181
+ const runIds = entries
182
+ .filter((entry) => entry.type === RUN)
183
+ .filter((run) => commitMatches(run.commit, branchCommits) || carriesTicket(run, ticket))
184
+ .map((run) => run.runId);
185
+ return runIds.length
186
+ ? { source: "discovery", runIds: [...new Set(runIds)] }
187
+ : { source: null, runIds: [] };
188
+ }
189
+
190
+ /** Scalars from a block-style YAML mapping, keyed by dotted path. Lists and flow style are skipped. */
191
+ export function yamlScalars(text) {
192
+ const values = {};
193
+ const stack = [];
194
+ for (const raw of text.split(/\r?\n/)) {
195
+ if (!raw.trim() || raw.trimStart().startsWith("#") || raw.trimStart().startsWith("- "))
196
+ continue;
197
+ const match = raw.match(/^(\s*)([A-Za-z0-9_.-]+):(?:\s+(.*))?$/);
198
+ if (!match) continue;
199
+ const indent = match[1].length;
200
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
201
+ const path = [...stack.map((item) => item.key), match[2]].join(".");
202
+ const value = (match[3] || "").replace(/\s+#.*$/, "").trim();
203
+ if (value === "") {
204
+ stack.push({ indent, key: match[2] });
205
+ } else {
206
+ values[path] = value.replace(/^(["'])(.*)\1$/, "$2");
207
+ }
208
+ }
209
+ return values;
210
+ }
211
+
212
+ function findConfig(start) {
213
+ let dir = resolve(start);
214
+ for (;;) {
215
+ const candidate = join(dir, CONFIG_NAME);
216
+ if (existsSync(candidate)) return candidate;
217
+ const parent = dirname(dir);
218
+ if (parent === dir) return null;
219
+ dir = parent;
220
+ }
221
+ }
222
+
223
+ function newestPluginFile(relative, home) {
224
+ const cache = join(home, ".claude", "plugins", "cache");
225
+ let best = null;
226
+ const visit = (dir, depth) => {
227
+ if (depth > 3) return;
228
+ let names;
229
+ try {
230
+ names = readdirSync(dir);
231
+ } catch {
232
+ return;
233
+ }
234
+ for (const name of names) {
235
+ const child = join(dir, name);
236
+ const candidate = join(child, relative);
237
+ if (existsSync(candidate)) {
238
+ const mtime = statSync(candidate).mtimeMs;
239
+ if (!best || mtime > best.mtime) best = { path: candidate, mtime };
240
+ } else {
241
+ visit(child, depth + 1);
242
+ }
243
+ }
244
+ };
245
+ visit(cache, 0);
246
+ return best?.path || null;
247
+ }
248
+
249
+ function resolveHook(value, configDir, env, home) {
250
+ if (!value) return null;
251
+ if (value.startsWith("plugin:")) {
252
+ const relative = value.slice("plugin:".length);
253
+ if (env.FLOW_HOOKS_HOME) {
254
+ const direct = join(env.FLOW_HOOKS_HOME, relative.replace(/^hooks\//, ""));
255
+ if (existsSync(direct)) return direct;
256
+ }
257
+ return newestPluginFile(relative, home);
258
+ }
259
+ return isAbsolute(value) ? value : join(configDir, value);
260
+ }
261
+
262
+ /** jiraBase, the xray deliver hook and each domain's setKey / planKey, from the runner's own settings. */
263
+ export function readFlowSettings({ configPath, env = process.env, home = homedir() }) {
264
+ const settings = { jiraBase: null, xrayHook: null, xray: {} };
265
+ let values = {};
266
+ if (configPath && existsSync(configPath)) values = yamlScalars(readFileSync(configPath, "utf-8"));
267
+ const base = env.JIRA_BASE || values["deliver.jiraBase"] || null;
268
+ settings.jiraBase = base ? base.replace(/\/+$/, "") : null;
269
+ if (!configPath) return settings;
270
+ const configDir = dirname(configPath);
271
+ settings.xrayHook = resolveHook(values["deliver.hooks.xray"], configDir, env, home);
272
+ const data = values["catalog.data"];
273
+ if (!data) return settings;
274
+ const xrayDir = join(isAbsolute(data) ? data : join(configDir, data), "xray");
275
+ let files;
276
+ try {
277
+ files = readdirSync(xrayDir).filter((name) => name.endsWith(".yaml"));
278
+ } catch {
279
+ return settings;
280
+ }
281
+ for (const name of files) {
282
+ const domain = name.slice(0, -".yaml".length);
283
+ const domainValues = yamlScalars(readFileSync(join(xrayDir, name), "utf-8"));
284
+ const keys = {};
285
+ if (domainValues.setKey) keys.setKey = domainValues.setKey;
286
+ if (domainValues.planKey) keys.planKey = domainValues.planKey;
287
+ if (Object.keys(keys).length) settings.xray[domain] = keys;
288
+ }
289
+ return settings;
290
+ }
291
+
292
+ const ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/;
293
+
294
+ // History values are written by another tool; every one is escaped for a table cell,
295
+ // and only a well-formed issue key becomes a link.
296
+ function cell(text) {
297
+ return String(text)
298
+ .replace(/\r?\n/g, " ")
299
+ .replace(/[\\`*_[\]()!<>|#~]/g, "\\$&");
300
+ }
301
+
302
+ // A value made only of safe characters reads best as code; anything else is
303
+ // escaped plain text, because a backtick in it would end the code span.
304
+ function code(value, safe = /^[A-Za-z0-9._/:-]+$/) {
305
+ const text = String(value);
306
+ return safe.test(text) ? `\`${text}\`` : cell(text);
307
+ }
308
+
309
+ function keyRef(key, jiraBase) {
310
+ return jiraBase && ISSUE_KEY.test(String(key))
311
+ ? `[${key}](${jiraBase}/browse/${key})`
312
+ : cell(key);
313
+ }
314
+
315
+ function shellQuote(value) {
316
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
317
+ }
318
+
319
+ function runDomains(run) {
320
+ const named = run.domains || [];
321
+ const fromResults = (run.results || []).map((result) => result.domain).filter(Boolean);
322
+ return [...new Set([...named, ...fromResults])].sort();
323
+ }
324
+
325
+ function executionCell(entry, labels, jiraBase) {
326
+ const { run, delivery } = entry;
327
+ if (!delivery) return labels.notDelivered;
328
+ const parts = runDomains(run).map((domain) => {
329
+ const key = delivery.executions[domain];
330
+ if (key) return `${keyRef(key, jiraBase)} (${cell(domain)})`;
331
+ const reason = delivery.skipped[domain];
332
+ if (reason) return `${cell(domain)}: ${labels.skipped} (${code(reason)})`;
333
+ return `${cell(domain)}: ${labels.noExecution} (lane: ${cell(run.lane || "?")})`;
334
+ });
335
+ for (const [domain, key] of Object.entries(delivery.executions)) {
336
+ if (!runDomains(run).includes(domain)) parts.push(`${keyRef(key, jiraBase)} (${cell(domain)})`);
337
+ }
338
+ return parts.join(" · ") || labels.noExecution;
339
+ }
340
+
341
+ function setPlanCell(run, xray, jiraBase) {
342
+ const parts = [];
343
+ for (const domain of runDomains(run)) {
344
+ const keys = xray[domain];
345
+ if (!keys) continue;
346
+ const named = [];
347
+ if (keys.setKey) named.push(`Set ${keyRef(keys.setKey, jiraBase)}`);
348
+ if (keys.planKey) named.push(`Plan ${keyRef(keys.planKey, jiraBase)}`);
349
+ parts.push(
350
+ runDomains(run).length > 1 ? `${cell(domain)}: ${named.join(" · ")}` : named.join(" · "),
351
+ );
352
+ }
353
+ return parts.join("; ");
354
+ }
355
+
356
+ function runCell(run) {
357
+ const parts = [code(run.runId)];
358
+ if (run.lane) parts.push(cell(run.lane));
359
+ if (run.commit) parts.push(code(String(run.commit).slice(0, 7), /^[0-9a-fA-F]+$/));
360
+ if (run.branch) parts.push(code(run.branch));
361
+ return parts.join(" · ");
362
+ }
363
+
364
+ function resultCell(run) {
365
+ const counts = {};
366
+ for (const result of run.results || []) {
367
+ const status = XRAY_STATUS[result.status] || "TODO";
368
+ counts[status] = (counts[status] || 0) + 1;
369
+ }
370
+ const extra = Object.keys(counts)
371
+ .filter((status) => !ALWAYS_COUNTED.includes(status))
372
+ .sort();
373
+ return [...ALWAYS_COUNTED, ...extra]
374
+ .map((status) => `${status} ${counts[status] || 0}`)
375
+ .join(" · ");
376
+ }
377
+
378
+ function deliverCommand(run, runRoot, xrayHook) {
379
+ const hook = xrayHook ? shellQuote(xrayHook) : "<xray_deliver.py>";
380
+ return `python3 ${hook} --targets xray --run-dir ${shellQuote(join(runRoot, String(run.runId)))}`;
381
+ }
382
+
383
+ /** One block for the resolved runs. `runs` are pairRuns() values; unknown ids are filtered by the caller. */
384
+ export function renderBlock(
385
+ runs,
386
+ { lang = "tr", jiraBase = null, xray = {}, runRoot, xrayHook = null, headingLevel = 3 } = {},
387
+ ) {
388
+ const labels = LABELS[lang] || LABELS.en;
389
+ const lines = [`${"#".repeat(headingLevel)} ${labels.heading}`];
390
+ for (const entry of runs) {
391
+ const { run } = entry;
392
+ lines.push("");
393
+ if (runs.length > 1) lines.push(`**${labels.run} ${cell(run.runId)}**`, "");
394
+ lines.push(`| ${labels.field} | ${labels.value} |`, "| --- | --- |");
395
+ lines.push(`| ${labels.execution} | ${executionCell(entry, labels, jiraBase)} |`);
396
+ const setPlan = setPlanCell(run, xray, jiraBase);
397
+ if (setPlan) lines.push(`| ${labels.setPlan} | ${setPlan} |`);
398
+ lines.push(`| ${labels.run} | ${runCell(run)} |`);
399
+ lines.push(`| ${labels.result} | ${resultCell(run)} |`);
400
+ const results = run.results || [];
401
+ if (results.length) {
402
+ lines.push(
403
+ "",
404
+ `| ${labels.scenario} | ${labels.xrayTest} | ${labels.status} |`,
405
+ "| --- | --- | --- |",
406
+ );
407
+ for (const result of results) {
408
+ const test = result.testKey ? keyRef(result.testKey, jiraBase) : labels.noTestKey;
409
+ lines.push(`| ${cell(result.id)} | ${test} | ${cell(result.status || "?")} |`);
410
+ }
411
+ }
412
+ if (!entry.delivery) {
413
+ lines.push(
414
+ "",
415
+ `${labels.deliverHint}:`,
416
+ "",
417
+ "```",
418
+ deliverCommand(run, runRoot, xrayHook),
419
+ "```",
420
+ );
421
+ }
422
+ }
423
+ return lines.join("\n") + "\n";
424
+ }
425
+
426
+ export function branchCommitsFor({ repo, branch, base }) {
427
+ if (!repo || !branch || !base) return [];
428
+ // A ref starting with "-" would be read by git as an option.
429
+ if (String(base).startsWith("-") || String(branch).startsWith("-")) return [];
430
+ const listed = spawnSync(
431
+ "git",
432
+ ["-C", repo, "rev-list", "--end-of-options", `${base}..${branch}`],
433
+ {
434
+ encoding: "utf-8",
435
+ },
436
+ );
437
+ if (listed.status !== 0) return [];
438
+ return listed.stdout.split("\n").filter(Boolean);
439
+ }
440
+
441
+ export function buildReport({
442
+ historyPath,
443
+ flag = [],
444
+ stateRuns = [],
445
+ branchCommits = [],
446
+ ticket = null,
447
+ lang = "tr",
448
+ jiraBase = null,
449
+ xray = {},
450
+ xrayHook = null,
451
+ runRoot = null,
452
+ headingLevel = 3,
453
+ }) {
454
+ const labels = LABELS[lang] || LABELS.en;
455
+ const root = runRoot || dirname(historyPath);
456
+ const history = readHistory(historyPath);
457
+ const empty = {
458
+ available: false,
459
+ source: null,
460
+ runIds: [],
461
+ missing: [],
462
+ suggestion: false,
463
+ candidates: [],
464
+ markdown: "",
465
+ };
466
+ if (!history.exists) return { ...empty, reason: labels.missingHistory(historyPath) };
467
+ const paired = pairRuns(history.entries);
468
+ const resolved = resolveRuns({
469
+ flag,
470
+ stateRuns,
471
+ entries: history.entries,
472
+ branchCommits,
473
+ ticket,
474
+ });
475
+ const found = resolved.runIds.filter((id) => paired.has(id));
476
+ const missing = resolved.runIds.filter((id) => !paired.has(id));
477
+ if (!found.length) {
478
+ const what =
479
+ [ticket, missing.length ? missing.join(", ") : null].filter(Boolean).join(" · ") ||
480
+ historyPath;
481
+ return { ...empty, source: resolved.source, missing, reason: labels.noMatch(what) };
482
+ }
483
+ const runs = found.map((id) => paired.get(id));
484
+ return {
485
+ available: true,
486
+ reason: null,
487
+ source: resolved.source,
488
+ suggestion: resolved.source === "discovery",
489
+ runIds: found,
490
+ missing,
491
+ candidates: runs.map(({ run, delivery }) => ({
492
+ runId: run.runId,
493
+ commit: run.commit || null,
494
+ lane: run.lane || null,
495
+ delivered: Boolean(delivery && Object.keys(delivery.executions).length),
496
+ })),
497
+ markdown: renderBlock(runs, { lang, jiraBase, xray, runRoot: root, xrayHook, headingLevel }),
498
+ };
499
+ }
500
+
501
+ function parseArgs(argv) {
502
+ const opts = { flowRuns: [], json: false };
503
+ const valued = new Set([
504
+ "--flow-run",
505
+ "--state",
506
+ "--history",
507
+ "--run-root",
508
+ "--config",
509
+ "--repo",
510
+ "--branch",
511
+ "--base",
512
+ "--ticket",
513
+ "--jira-base",
514
+ "--lang",
515
+ "--heading-level",
516
+ ]);
517
+ for (let i = 0; i < argv.length; i++) {
518
+ const arg = argv[i];
519
+ if (arg === "--json") {
520
+ opts.json = true;
521
+ } else if (valued.has(arg)) {
522
+ const value = argv[++i];
523
+ if (value === undefined) throw new Error(`${arg} needs a value`);
524
+ if (arg === "--flow-run")
525
+ opts.flowRuns.push(
526
+ ...value
527
+ .split(",")
528
+ .map((id) => id.trim())
529
+ .filter(Boolean),
530
+ );
531
+ else opts[arg.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase())] = value;
532
+ } else {
533
+ throw new Error(`unknown argument: ${arg}`);
534
+ }
535
+ }
536
+ return opts;
537
+ }
538
+
539
+ function readState(path) {
540
+ if (!path) return {};
541
+ try {
542
+ return JSON.parse(readFileSync(path, "utf-8"));
543
+ } catch {
544
+ return {};
545
+ }
546
+ }
547
+
548
+ function main(argv) {
549
+ let opts;
550
+ try {
551
+ opts = parseArgs(argv);
552
+ } catch (error) {
553
+ process.stderr.write(`render-flow-run: ${error.message}\n`);
554
+ return 2;
555
+ }
556
+ const state = readState(opts.state);
557
+ const runRoot = opts.runRoot || join(homedir(), "FlowRuns");
558
+ const historyPath = opts.history || join(runRoot, "history.jsonl");
559
+ const repo = opts.repo || state.worktreePath || state.projectRoot || process.cwd();
560
+ const configPath = opts.config || process.env.FLOW_CONFIG || findConfig(repo);
561
+ const settings = readFlowSettings({ configPath, env: process.env });
562
+ const ticket =
563
+ opts.ticket || (/^[A-Z][A-Z0-9]+-\d+$/.test(state.taskId || "") ? state.taskId : null);
564
+ const branch = opts.branch || state.branch || null;
565
+ const base = opts.base || state.baseBranch || null;
566
+ const report = buildReport({
567
+ historyPath,
568
+ flag: opts.flowRuns,
569
+ stateRuns: Array.isArray(state.evidence?.flowRuns) ? state.evidence.flowRuns : [],
570
+ branchCommits: branchCommitsFor({ repo, branch, base }),
571
+ ticket,
572
+ lang: opts.lang || "tr",
573
+ jiraBase: opts.jiraBase ? opts.jiraBase.replace(/\/+$/, "") : settings.jiraBase,
574
+ xray: settings.xray,
575
+ xrayHook: settings.xrayHook,
576
+ runRoot: opts.history && !opts.runRoot ? dirname(historyPath) : runRoot,
577
+ headingLevel: Number(opts.headingLevel) === 2 ? 2 : 3,
578
+ });
579
+ process.stdout.write(opts.json ? `${JSON.stringify(report, null, 2)}\n` : report.markdown);
580
+ return 0;
581
+ }
582
+
583
+ if (invokedDirectly(import.meta.url)) process.exit(main(process.argv.slice(2)));