@worca/app 1.2.0 → 1.3.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 (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -0,0 +1,896 @@
1
+ // src/core/run-report.mjs
2
+ // Builds the "report this run" payload: a METADATA-ONLY description of one finished
3
+ // pipeline, safe to paste into a public GitHub issue.
4
+ //
5
+ // Surface-agnostic ON PURPOSE. It takes a run id + options and returns an object;
6
+ // it knows nothing about express or the DOM. Today the only caller is
7
+ // POST /api/pipelines/:id/report; a follow-up run adds a second caller from the Ask
8
+ // chat (which reaches a pipeline id through findRunLinksByPipeline, ask/store.mjs:438).
9
+ // One builder, two callers — keep it that way.
10
+ //
11
+ // ── The redaction contract ───────────────────────────────────────────────────
12
+ // DEFAULT = metadata and NAMES. Two classes can be opted back in (paths / prompt,
13
+ // see src/shared/report-reasons.mjs). The unified diff and the run's log lines are
14
+ // NOT opt-in-able: there is no code path here that reads either one.
15
+ //
16
+ // Fields are ADDED for an opt-in, never nulled — so `'prompt' in payload.run` is a
17
+ // true test of whether the prompt is present.
18
+ //
19
+ // Three specific leaks this module exists to prevent:
20
+ // 1. A review issue's `location` IS a file path -> counts by default, issue
21
+ // titles/locations only behind `paths`.
22
+ // 2. manifest.mjs:133 stores `config: {...node.config}` — the AUTHORED config,
23
+ // verbatim and complete, unknown keys included. The node shape here is a
24
+ // WHITELIST so a future manifest key cannot leak by default.
25
+ // 3. outcome.tokens[*].path and branch.worktreeDir are absolute filesystem paths
26
+ // (verified in live data). Neither is ever read.
27
+ // 4. workflow.template.name on a STOCK recipe is read from the shipped constants,
28
+ // never from the manifest: the manifest copies the workflow ROW's name and a
29
+ // seed row is renameable, so a user's typed name would ride a "builtin" field.
30
+ // See templateShape below.
31
+ //
32
+ // NOT used: readRunContextBundle (results.mjs:179). Its line 183 is
33
+ // `diffPatch: await read(DIFF_PATCH_FILE)` — it eagerly loads the diff, which is
34
+ // exactly what must never enter this payload.
35
+
36
+ import { createRequire } from 'node:module';
37
+ import { readFile } from 'node:fs/promises';
38
+ import { join } from 'node:path';
39
+
40
+ import {
41
+ findPipelineRowById, readPipelineStateById, readPipelineExtras,
42
+ listSubAgents, runDirForRow, totalsFor,
43
+ } from './artifacts.mjs';
44
+ import { RESULTS_FILE, OVERVIEW_FILE } from './results.mjs';
45
+ import { SEVERITIES, normalizeSeverity } from '../shared/graph/verdict.mjs';
46
+ import { budgetStatus, readCostCapOverride } from './cost-budget.mjs';
47
+ import { readGuardrailSet, isBuiltinGuardrailSetId } from './guardrail-store.mjs';
48
+ import { GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME }
49
+ from './graph/builtin-workflows.mjs';
50
+ import { SEED_TEMPLATES } from './graph/seed-templates.mjs';
51
+ import { describePauseReason } from './failure-policy.mjs';
52
+ import { REPORT_REASON_IDS, reasonById, normalizeInclude } from '../shared/report-reasons.mjs';
53
+
54
+ const require = createRequire(import.meta.url);
55
+ const PKG = require('../../package.json');
56
+
57
+ export const REPORT_SCHEMA_VERSION = 1;
58
+ export const WORCA_VERSION = PKG.version || '';
59
+ /** package.json bugs.url — the single source for every issue link in the app. */
60
+ export const BUGS_URL = (PKG.bugs && PKG.bugs.url) || '';
61
+
62
+ export const EXPECTATION_MAX = 2000;
63
+
64
+ /** Pipeline statuses that mean the run is over. 'paused' is parked, not terminal. */
65
+ const TERMINAL_STATUSES = new Set(['done', 'error', 'stopped', 'interrupted']);
66
+
67
+ // The ONLY stepper node fields that ever ship. A whitelist, not a blacklist: when
68
+ // buildGraphManifest grows a key, this fails closed instead of leaking it.
69
+ const NODE_STRINGS = ['model', 'effort', 'subagentModel'];
70
+ const NODE_BOOLS = ['fanOut', 'askQuestions', 'awaitAll'];
71
+
72
+ // Every workflow id worca itself ships: wf_default, wf_auto and the seven V17 seed
73
+ // recipes, mapped to the name worca ships it under. FIXED vocabulary, zero user text.
74
+ // Every other id is `wf_<slug of a name>` (workflows.mjs:303, auto/proposal.mjs:127)
75
+ // and reports as non-builtin. Read from the constants, not copied, so a new shipped
76
+ // recipe classifies itself.
77
+ //
78
+ // The NAME must come from here too, never from the manifest. writeGraphWorkflow
79
+ // reserves only wf_default and wf_auto (workflows.mjs:295-345) — the seven seed ids are
80
+ // ordinary rows whose upsert does `ON CONFLICT(id) DO UPDATE SET name = excluded.name`,
81
+ // so an ordinary composer Save over `wf_full` keeps the stock id and replaces its name
82
+ // with user text. Keying off the id is what keeps the builtin arm free of user text.
83
+ const STOCK_WORKFLOW_NAMES = new Map([
84
+ [GRAPH_DEFAULT_WORKFLOW.id, GRAPH_DEFAULT_WORKFLOW.name],
85
+ [AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME],
86
+ ...SEED_TEMPLATES.map((t) => [t.id, t.name]),
87
+ ]);
88
+
89
+ // ── small pure helpers (exported for direct unit tests) ───────────────────────
90
+
91
+ /** Tally review issues by severity. Unknown severities fold to 'minor', never drop. */
92
+ export function countIssues(reviews) {
93
+ const counts = {};
94
+ for (const s of SEVERITIES) counts[s] = 0;
95
+ for (const review of reviews || []) {
96
+ for (const issue of review.issues || []) counts[normalizeSeverity(issue.severity)] += 1;
97
+ }
98
+ return counts;
99
+ }
100
+
101
+ /**
102
+ * max(step.cycle) per node id. On v2 rows pipeline_steps.cycle holds the execution
103
+ * ORDINAL (orchestrator.mjs:1107), so this reads as "how many times this node ran" —
104
+ * which is the signal a cost/quality report wants (D22).
105
+ */
106
+ export function cyclesByNode(steps) {
107
+ const out = {};
108
+ for (const step of steps || []) {
109
+ const nodeId = step.nodeId;
110
+ if (!nodeId) continue;
111
+ const cycle = Number(step.cycle);
112
+ if (!Number.isFinite(cycle)) continue;
113
+ if (!(nodeId in out) || cycle > out[nodeId]) out[nodeId] = cycle;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function pickNode(node, cyclesUsed, kind = node.kind) {
119
+ const out = { id: node.id, kind: kind || 'agent' };
120
+ if (node.key) out.key = node.key;
121
+ if (node.label) out.label = node.label;
122
+ // Falsy-guarded on purpose: subagentModel '' means "inherit", not a value.
123
+ for (const f of NODE_STRINGS) if (node[f]) out[f] = node[f];
124
+ for (const f of NODE_BOOLS) if (typeof node[f] === 'boolean') out[f] = node[f];
125
+ if (Number.isInteger(node.arity)) out.arity = node.arity;
126
+ const used = cyclesUsed[node.id];
127
+ if (Number.isFinite(used)) out.cyclesUsed = used;
128
+ return out;
129
+ }
130
+
131
+ function pickWire(wire) {
132
+ const out = {
133
+ id: wire.id,
134
+ from: { node: wire.from?.node ?? null, port: wire.from?.port ?? null },
135
+ to: { node: wire.to?.node ?? null, port: wire.to?.port ?? null },
136
+ loop: !!wire.loop,
137
+ };
138
+ if (out.loop && Number.isFinite(Number(wire.maxCycles))) out.maxCycles = Number(wire.maxCycles);
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * The template block: a CLASS discriminator plus the template's identity.
144
+ *
145
+ * D2 ships node keys and labels verbatim as workflow design, but it does NOT cover
146
+ * this field. On an Auto run the classifier WRITES the template name from the task
147
+ * text (auto/classify.mjs:156 -> orchestrator.mjs:361) and the id is a slug of that
148
+ * same name (auto/proposal.mjs:127) — a paraphrase of the prompt, and it ships, on
149
+ * the same footing as the run title, which is prompt-derived in exactly that way.
150
+ * `builtin` is still computed rather than inferred from the string, because it is
151
+ * the fact a maintainer needs: did this run use a recipe worca ships?
152
+ *
153
+ * On the builtin arm the name is looked up by id in STOCK_WORKFLOW_NAMES rather than
154
+ * read off the manifest, because `template.name` is a copy of the workflow ROW's name
155
+ * and a seed row is renameable — see the map's comment. The id classifies; the
156
+ * constants name.
157
+ *
158
+ * `auto.via === 'created'` overrides the id test: mintAutoWorkflowId avoids the two
159
+ * reserved ids but not a seed id, and on a fresh home no seed row exists at all
160
+ * (db.mjs:1311 seeds pre-existing DBs only), so a minted id CAN slug onto a stock
161
+ * one. What the run did is authoritative over what the string looks like.
162
+ */
163
+ function templateShape(template, auto) {
164
+ if (!template) return null;
165
+ const id = template.id || '';
166
+ const name = template.name || '';
167
+ const builtin = STOCK_WORKFLOW_NAMES.has(id) && auto?.via !== 'created';
168
+ const out = { builtin, id: builtin ? id : null };
169
+ if (builtin) out.name = STOCK_WORKFLOW_NAMES.get(id) ?? '';
170
+ else { out.id = id; out.name = name; }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * The workflow shape, whitelisted. Handles BOTH manifest generations: v2 carries
176
+ * `graph.nodes`/`graph.wires`; the legacy v1 (73 of 146 persisted steppers) carries
177
+ * only `{version, steps, feedbacks}`. The v2 guard mirrors
178
+ * ui/public/graph/run-decor.mjs:58.
179
+ */
180
+ export function workflowShape(stepper, cyclesUsed = {}) {
181
+ if (!stepper || typeof stepper !== 'object') return null;
182
+ const isV2 = stepper.version === 2 && stepper.graph && Array.isArray(stepper.graph.nodes);
183
+ const hasV1 = Array.isArray(stepper.steps) && stepper.steps.length > 0;
184
+ if (!isV2 && !hasV1) return null;
185
+
186
+ const nodes = isV2
187
+ ? stepper.graph.nodes.map((n) => pickNode(n, cyclesUsed))
188
+ : stepper.steps.flatMap((step) => (step.nodes || []).map(
189
+ (n) => pickNode(n, cyclesUsed, step.kind === 'agent' || n.key ? 'agent' : step.kind)));
190
+
191
+ const wires = isV2
192
+ ? (stepper.graph.wires || []).map(pickWire)
193
+ : (stepper.feedbacks || []).map((fb) => pickWire({
194
+ id: fb.id, from: { node: fb.from, port: null }, to: { node: fb.to, port: null },
195
+ loop: true, maxCycles: fb.maxCycles,
196
+ }));
197
+
198
+ return {
199
+ manifestVersion: Number(stepper.version) || 1,
200
+ template: isV2 ? templateShape(stepper.template, stepper.auto) : null,
201
+ auto: stepper.auto
202
+ ? { status: stepper.auto.status || '', via: stepper.auto.via || '',
203
+ rounds: stepper.auto.rounds ?? null, humanInLoop: !!stepper.auto.humanInLoop }
204
+ : null,
205
+ nodes,
206
+ wires,
207
+ };
208
+ }
209
+
210
+ /** maxCycles of the loop wire landing on a node, or null. */
211
+ function maxCyclesFor(workflow, nodeId) {
212
+ for (const wire of workflow?.wires || []) {
213
+ if (wire.loop && wire.to.node === nodeId && Number.isFinite(wire.maxCycles)) return wire.maxCycles;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // ── fact extractors ───────────────────────────────────────────────────────────
219
+
220
+ function stepFacts(steps) {
221
+ // No `key`: on v2 rows it is the executionId, which can embed a decomposed task
222
+ // id. nodeId + cycle identifies a step just as well and carries nothing free-text.
223
+ // stepRowToStep OMITS nodeId/agentKey/kind/endedAt when the column is NULL
224
+ // (artifacts.mjs:1730-1747), hence `?? null` on every one.
225
+ //
226
+ // `agentKey ?? phase` is deliberate: agent_key is the strict field but is a later
227
+ // column, so older v2 rows carry the key only in `phase`. Caveat for a reader — on a
228
+ // FLOW node (and/or/combine) `agentKey` is null and `phase` holds the node KIND
229
+ // (orchestrator.mjs:1109), so this field can read 'and'. Not a leak (both are
230
+ // workflow vocabulary, D2), just not always an agent name.
231
+ return (steps || []).map((s) => ({
232
+ nodeId: s.nodeId ?? null,
233
+ agentKey: s.agentKey ?? s.phase ?? null,
234
+ kind: s.kind ?? null,
235
+ cycle: Number.isFinite(Number(s.cycle)) ? Number(s.cycle) : null,
236
+ status: s.status ?? null,
237
+ costUsd: Number(s.costUsd) || 0,
238
+ activeMs: Number(s.activeMs) || 0,
239
+ startedAt: s.startedAt ?? null,
240
+ endedAt: s.endedAt ?? null,
241
+ }));
242
+ }
243
+
244
+ function subAgentFacts(rows) {
245
+ // Dropped (D13): `label` (free text, e.g. "investigate auth"), `skills` (user and
246
+ // plugin skill names), `id` (a tool_use id), `stepKey` (an executionId),
247
+ // `uiPhase` (redundant with nodeId).
248
+ return (rows || []).map((s) => ({
249
+ nodeId: s.nodeId ?? null,
250
+ cycle: Number.isFinite(Number(s.cycle)) ? Number(s.cycle) : null,
251
+ status: s.status ?? null,
252
+ subagentType: s.subagentType ?? null,
253
+ runModel: s.runModel ?? null,
254
+ tokens: s.tokens ?? null,
255
+ costUsd: s.costUsd ?? null,
256
+ durationMs: s.durationMs ?? null,
257
+ graphifyCount: s.graphifyCount ?? null,
258
+ }));
259
+ }
260
+
261
+ function reviewFacts(reviews) {
262
+ const counts = countIssues(reviews);
263
+ return {
264
+ reviewCount: (reviews || []).length,
265
+ blockingIssues: counts.critical + counts.major,
266
+ issueCounts: counts,
267
+ byReview: (reviews || []).map((r) => ({
268
+ kind: r.kind ?? null, cycle: r.cycle ?? null, counts: countIssues([r]),
269
+ })),
270
+ };
271
+ }
272
+
273
+ const SUMMARY_KEYS = ['filesNew', 'filesChanged', 'filesDeleted',
274
+ 'linesAdded', 'linesRemoved', 'blockingIssues', 'nitpicks'];
275
+
276
+ function fileFacts(results) {
277
+ const summary = results && typeof results.summary === 'object' ? results.summary : null;
278
+ if (!summary) return null;
279
+ const out = {};
280
+ for (const k of SUMMARY_KEYS) {
281
+ const n = Number(summary[k]);
282
+ if (Number.isFinite(n)) out[k] = n;
283
+ }
284
+ // A WORKSPACE run's results.json is { summary, perProject } (run-harness.mjs:2810)
285
+ // and perProject is keyed BY PROJECT KEY. Ship the COUNT, never the keys (D9).
286
+ if (results.perProject && typeof results.perProject === 'object') {
287
+ out.projectCount = Object.keys(results.perProject).length;
288
+ }
289
+ return out;
290
+ }
291
+
292
+ function pickFile(f) {
293
+ return { path: f.path ?? null, status: f.status ?? null,
294
+ added: Number(f.added) || 0, removed: Number(f.removed) || 0 };
295
+ }
296
+
297
+ function toolFacts(tools) {
298
+ if (!tools || typeof tools !== 'object') return null;
299
+ // `instruction` is ~700 chars of fixed English boilerplate — dropped (D12).
300
+ return {
301
+ tool: tools.tool ?? null,
302
+ kind: tools.kind ?? null,
303
+ graphify: !!tools.graphify,
304
+ codeReviewGraph: !!tools.codeReviewGraph,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Guardrails as COUNTS, not contents: protectedPaths are user globs and envAllowlist
310
+ * are env var names, and neither ever ships. The set's own id and name do — they are
311
+ * names, and the contents stay behind either way. readGuardrailSet is async and never
312
+ * throws for a bad id — it returns null (guardrail-store.mjs:66).
313
+ */
314
+ async function guardrailFacts(guardrailsId) {
315
+ if (!guardrailsId) return { legacy: true, id: null, builtin: null, origin: null };
316
+ const builtin = isBuiltinGuardrailSetId(guardrailsId);
317
+ const set = await readGuardrailSet(guardrailsId);
318
+ const s = set && set.settings ? set.settings : null;
319
+ // origin is 'builtin' | 'plugin:<name>' | null (guardrail-store.mjs:61) — reduce
320
+ // it to a class so a plugin's name never rides along.
321
+ const origin = set
322
+ ? (set.origin === 'builtin' ? 'builtin'
323
+ : String(set.origin || '').startsWith('plugin:') ? 'plugin' : 'user')
324
+ : null;
325
+ const facts = {
326
+ legacy: false,
327
+ id: builtin ? guardrailsId : null,
328
+ builtin,
329
+ origin,
330
+ envScrub: s ? !!s.envScrub : null,
331
+ honorProjectSettings: s ? !!s.honorProjectSettings : null,
332
+ denyCount: s ? (s.deny || []).length : null,
333
+ protectedPathCount: s ? (s.protectedPaths || []).length : null,
334
+ envAllowlistCount: s ? (s.envAllowlist || []).length : null,
335
+ };
336
+ if (!builtin) {
337
+ facts.id = guardrailsId;
338
+ facts.name = set ? (set.name ?? null) : null;
339
+ }
340
+ return facts;
341
+ }
342
+
343
+ // ── evidence blocks ───────────────────────────────────────────────────────────
344
+
345
+ function costEvidence({ row, steps, subAgents, workflow }) {
346
+ const budget = budgetStatus();
347
+ const totals = subAgents.reduce((acc, s) => ({
348
+ count: acc.count + 1,
349
+ tokens: acc.tokens + (Number(s.tokens) || 0),
350
+ costUsd: Math.round((acc.costUsd + (Number(s.costUsd) || 0)) * 1e4) / 1e4,
351
+ }), { count: 0, tokens: 0, costUsd: 0 });
352
+
353
+ return {
354
+ topSteps: [...steps].sort((a, b) => b.costUsd - a.costUsd).slice(0, 5),
355
+ subAgentTotals: totals,
356
+ perNode: (workflow?.nodes || []).filter((n) => n.kind === 'agent').map((n) => ({
357
+ key: n.key ?? null, model: n.model ?? null, effort: n.effort ?? null,
358
+ subagentModel: n.subagentModel ?? null, fanOut: n.fanOut ?? null,
359
+ cyclesUsed: n.cyclesUsed ?? null, maxCycles: maxCyclesFor(workflow, n.id),
360
+ })),
361
+ budget: {
362
+ pipelineLimitUsd: budget.pipelineLimitUsd,
363
+ totalLimitUsd: budget.totalLimitUsd,
364
+ resetPeriod: budget.resetPeriod,
365
+ windowSpendUsd: budget.windowSpendUsd,
366
+ remainingUsd: budget.remainingUsd,
367
+ blocked: budget.blocked,
368
+ },
369
+ costCapOverride: readCostCapOverride(row.id),
370
+ };
371
+ }
372
+
373
+ function speedEvidence({ steps, workflow, tools, wallClockMs, activeMs }) {
374
+ const byActive = [...steps].sort((a, b) => b.activeMs - a.activeMs);
375
+ const top = byActive[0] || null;
376
+ const share = (ms) => (wallClockMs ? Math.round((ms / wallClockMs) * 1000) / 1000 : null);
377
+ // `activeMs` is the SUM of per-step active time; `wallClockMs` is elapsed time. With
378
+ // fan-out — a first-class feature, and fanOutNodes sits in this very block — the sum
379
+ // EXCEEDS the elapsed time, so the gap is not occupancy and `Math.max(0, …)` would
380
+ // publish a 60s run as "0s waiting" with an activeShare above 1. Detect it and refuse
381
+ // to assert: a null reads as "not measured", a clamped 0 reads as a finding.
382
+ // The exact figure is the union of the step startedAt→endedAt intervals, which the
383
+ // payload already carries per step for anyone who wants to compute it.
384
+ const overSubscribed = wallClockMs != null && activeMs != null && activeMs > wallClockMs;
385
+ return {
386
+ wallClockMs,
387
+ activeMs,
388
+ overSubscribed,
389
+ // The GAP is the finding: time the run was waiting rather than working.
390
+ waitingMs: (!overSubscribed && wallClockMs != null && activeMs != null)
391
+ ? Math.max(0, wallClockMs - activeMs) : null,
392
+ activeShare: (!overSubscribed && activeMs != null) ? share(activeMs) : null,
393
+ dominatingStep: top
394
+ ? { nodeId: top.nodeId, agentKey: top.agentKey, cycle: top.cycle,
395
+ activeMs: top.activeMs, share: share(top.activeMs) }
396
+ : null,
397
+ slowestSteps: byActive.slice(0, 5),
398
+ fanOutNodes: (workflow?.nodes || []).filter((n) => n.fanOut).map((n) => n.key || n.id),
399
+ tools,
400
+ };
401
+ }
402
+
403
+ function failureEvidence({ row, state, steps }) {
404
+ const status = row.status || '';
405
+ const failed = steps.filter((s) => s.status === 'error' || s.status === 'stopped');
406
+ const last = steps.length ? steps[steps.length - 1] : null;
407
+ const pauseReason = state && state.pauseReason ? state.pauseReason : null;
408
+ return {
409
+ status,
410
+ terminal: TERMINAL_STATUSES.has(status),
411
+ interrupted: status === 'interrupted',
412
+ // rowToState sets endReached/warnings ONLY inside the outcome branch
413
+ // (artifacts.mjs:1798-1801), so probe with `in`, not for truthiness.
414
+ endReached: state && 'endReached' in state ? !!state.endReached : null,
415
+ warningCount: Array.isArray(state?.warnings) ? state.warnings.length : null,
416
+ failedSteps: failed.map((s) => ({ nodeId: s.nodeId, agentKey: s.agentKey,
417
+ cycle: s.cycle, status: s.status })),
418
+ lastStepStatus: last ? last.status : null,
419
+ // pauseDetail is a clipped RAW ERROR MESSAGE (errorDetail, run-harness.mjs:514)
420
+ // and routinely embeds absolute paths — only the code and its fixed label ever
421
+ // ship (D10).
422
+ //
423
+ // `done` (run-harness.mjs:1133) and `stopped` (:1166) NULL out resume_point, so
424
+ // pauseReason is null there and failedSteps + status carry the signal. The `error`
425
+ // branch does NOT clear it (its own comment at :1196 says so), so a failed run —
426
+ // exactly the run this evidence block exists for — often DOES carry a reason code.
427
+ pauseReason,
428
+ // describePauseReason returns null ONLY for null/'' (a manual pause). An UNKNOWN
429
+ // code falls through to the usage-limit row (failure-policy.mjs:187) and returns
430
+ // 'session/usage limit reached', NOT null — its own JSDoc at :198 is wrong about
431
+ // this. Never write `describePauseReason(x) ?? fallback`: an unrecognised legacy
432
+ // code would be silently mislabelled as a usage limit rather than falling back.
433
+ pauseReasonLabel: pauseReason ? describePauseReason(pauseReason) : null,
434
+ };
435
+ }
436
+
437
+ // ── disk ──────────────────────────────────────────────────────────────────────
438
+
439
+ async function readRunArtifacts(row) {
440
+ let dir;
441
+ try { dir = await runDirForRow(row); } catch { return { results: null, overview: null }; }
442
+ const readJson = async (name) => {
443
+ try { return JSON.parse(await readFile(join(dir, name), 'utf8')); } catch { return null; }
444
+ };
445
+ // results.json and overview.json ONLY. The diff patch is never opened.
446
+ return { results: await readJson(RESULTS_FILE), overview: await readJson(OVERVIEW_FILE) };
447
+ }
448
+
449
+ // ── the builder ───────────────────────────────────────────────────────────────
450
+
451
+ function normalizeReason(raw) {
452
+ const id = typeof raw === 'string' ? raw.trim() : '';
453
+ return REPORT_REASON_IDS.includes(id) ? id : 'something-else';
454
+ }
455
+
456
+ function parseJson(value) {
457
+ if (value == null) return null;
458
+ if (typeof value === 'object') return value;
459
+ try { return JSON.parse(value); } catch { return null; }
460
+ }
461
+
462
+ /**
463
+ * Build the report payload for one run.
464
+ *
465
+ * @param {string} pipelineId short 8-hex id, or the `<date>-<slug>-<8hex>` dir name
466
+ * @param {object} [opts]
467
+ * @param {string} [opts.reason] one of REPORT_REASON_IDS (unknown -> 'something-else')
468
+ * @param {string} [opts.expectation] the reporter's free text, clamped to EXPECTATION_MAX
469
+ * @param {object} [opts.include] { paths?, prompt? } — both default false
470
+ * @param {Date} [opts.now] injectable clock, for deterministic tests
471
+ * @returns {Promise<object|null>} the payload, or null when the id is unknown
472
+ */
473
+ export async function buildRunReport(pipelineId, opts = {}) {
474
+ // findPipelineRowById is SYNC and returns the RAW snake_case row (artifacts.mjs:1882).
475
+ const row = findPipelineRowById(pipelineId);
476
+ if (!row) return null;
477
+
478
+ const reason = normalizeReason(opts.reason);
479
+ const include = normalizeInclude(opts.include);
480
+ const expectation = typeof opts.expectation === 'string' && opts.expectation.trim()
481
+ ? opts.expectation.trim().slice(0, EXPECTATION_MAX)
482
+ : null;
483
+
484
+ // All four of these are synchronous; only runDirForRow (inside readRunArtifacts)
485
+ // is async. See the reader table in the plan's §2.2.
486
+ const state = readPipelineStateById(row.id);
487
+ const steps = stepFacts(state && state.steps);
488
+ const subAgents = subAgentFacts(listSubAgents(row.id));
489
+ const extras = readPipelineExtras(row.id);
490
+ // totalsFor returns {cost, active} with each field independently number|null —
491
+ // the function itself never returns null, so this destructure is safe.
492
+ const totals = totalsFor(row);
493
+ const { results, overview } = await readRunArtifacts(row);
494
+
495
+ const startedMs = Date.parse(row.started_at || row.updated_at || '') || null;
496
+ const endedMs = Date.parse(row.updated_at || row.started_at || '') || null;
497
+ const wallClockMs = (startedMs && endedMs) ? Math.max(0, endedMs - startedMs) : null;
498
+
499
+ const workflow = workflowShape(parseJson(row.stepper), cyclesByNode(steps));
500
+ const tools = toolFacts(parseJson(row.tools));
501
+ const review = reviewFacts(extras.reviews);
502
+ const files = fileFacts(results);
503
+
504
+ const payload = {
505
+ schemaVersion: REPORT_SCHEMA_VERSION,
506
+ generatedAt: (opts.now instanceof Date ? opts.now : new Date()).toISOString(),
507
+ reason,
508
+ expectation,
509
+ included: include,
510
+ app: {
511
+ worca: WORCA_VERSION,
512
+ node: process.version,
513
+ platform: process.platform,
514
+ arch: process.arch,
515
+ },
516
+ run: {
517
+ id: row.id,
518
+ target: row.target || 'project',
519
+ status: row.status || '',
520
+ phase: row.phase || '',
521
+ cycle: Number(row.cycle) || 0,
522
+ sourceType: row.source_type || 'prompt',
523
+ engine: state && state.engine === 2 ? 2 : 1,
524
+ startedAt: row.started_at || null,
525
+ updatedAt: row.updated_at || null,
526
+ wallClockMs,
527
+ activeMs: totals.active,
528
+ costUsd: totals.cost,
529
+ costCapOverride: !!row.cost_cap_override,
530
+ },
531
+ workflow,
532
+ tools,
533
+ steps,
534
+ subAgents,
535
+ review,
536
+ files,
537
+ evidence: null,
538
+ };
539
+
540
+ switch (reason) {
541
+ case 'too-expensive':
542
+ payload.evidence = costEvidence({ row, steps, subAgents, workflow });
543
+ break;
544
+ case 'too-slow':
545
+ payload.evidence = speedEvidence({ steps, workflow, tools, wallClockMs, activeMs: totals.active });
546
+ break;
547
+ case 'poor-quality':
548
+ // `files` is COPIED, not aliased: the `paths` opt-in below mutates
549
+ // payload.files, and the evidence block must stay counts-only (§4.1).
550
+ payload.evidence = { ...review, cyclesUsed: cyclesByNode(steps), files: files ? { ...files } : null };
551
+ break;
552
+ case 'wrong-or-unsafe':
553
+ payload.evidence = { guardrails: await guardrailFacts(row.guardrails_id), tools };
554
+ break;
555
+ case 'failed-or-stuck':
556
+ payload.evidence = failureEvidence({ row, state, steps });
557
+ break;
558
+ default:
559
+ payload.evidence = null; // 'something-else' leans on the always-on set
560
+ }
561
+
562
+ // ── opt-ins: ADD fields, never null them ────────────────────────────────────
563
+ if (include.paths) {
564
+ payload.review.issues = (extras.reviews || []).flatMap((r) => (r.issues || []).map((i) => ({
565
+ severity: normalizeSeverity(i.severity),
566
+ title: i.title ?? '',
567
+ location: i.location ?? '', // a file path — this is why it is gated
568
+ kind: r.kind ?? null,
569
+ cycle: r.cycle ?? null,
570
+ })));
571
+ if (payload.files && results) {
572
+ if (Array.isArray(results.newFiles)) payload.files.newFiles = results.newFiles.map(pickFile);
573
+ if (Array.isArray(results.changedFiles)) payload.files.changedFiles = results.changedFiles.map(pickFile);
574
+ }
575
+ // The cached narrative rides `paths` because overview-agent generates it FROM
576
+ // the diff and routinely names files and identifiers (D7). Never generated here
577
+ // — a miss is simply absent, and costs nothing.
578
+ if (overview && typeof overview.narrative === 'string' && overview.narrative.trim()) {
579
+ payload.narrative = overview.narrative.trim();
580
+ }
581
+ }
582
+
583
+ if (include.prompt && row.prompt) payload.run.prompt = row.prompt;
584
+
585
+ // ── names: unconditional ────────────────────────────────────────────────────
586
+ // Identity is what makes a report actionable. Still ADDED, never nulled, so
587
+ // `'title' in payload.run` keeps meaning "this run had one".
588
+ if (row.title) payload.run.title = row.title;
589
+ if (row.project_key) payload.run.projectKey = row.project_key;
590
+ const branch = parseJson(row.branch);
591
+ if (branch) {
592
+ // worktreeDir is an absolute path — NEVER, under any option. This whitelist is
593
+ // the only reason it stays out, so do not spread `branch` here.
594
+ payload.run.branch = { source: branch.source ?? null, feature: branch.feature ?? null };
595
+ }
596
+ const meta = parseJson(row.workspace_meta);
597
+ if (row.target === 'workspace' && meta) {
598
+ payload.run.workspace = {
599
+ name: meta.workspaceName ?? null,
600
+ projectKeys: Array.isArray(meta.projectKeys) ? meta.projectKeys : [],
601
+ };
602
+ }
603
+
604
+ return payload;
605
+ }
606
+
607
+ export { reasonById };
608
+
609
+ // ── the GitHub issue body + URL ───────────────────────────────────────────────
610
+ // TWO bodies, because there are two ways an issue gets filed:
611
+ //
612
+ // * renderIssueBodyFull + `gh issue create --body-file` (the normal path) — no URL
613
+ // and so no cap worth speaking of, and the whole JSON report ships inside the
614
+ // issue. That call lives in git-info.mjs#createIssue; nothing here spawns.
615
+ // * renderIssueBody + buildIssueUrl (the fallback, when gh cannot file it) — a
616
+ // prefilled issues/new URL, which browser and server URL limits truncate at
617
+ // roughly 8 KB. Measured across 152 real runs, a metadata-only report is a
618
+ // median 6.3 KB and a p90 of 20 KB once encoded, so the URL cannot carry the
619
+ // report at all: the body is a SHORT narrative plus a compact metrics table, and
620
+ // the FULL JSON goes on the clipboard with the body ASKING for the paste.
621
+ //
622
+ // Either way worca needs no GitHub token of its own — the fallback opens a URL, and
623
+ // the normal path borrows the gh CLI's login.
624
+ //
625
+ // THE CAP (fallback path). Over it we trim by binary-searching the longest fitting
626
+ // prefix (exact and O(log n); a fixed-step shrink loop is O(n) on a long body and
627
+ // can overshoot).
628
+
629
+ export const ISSUE_URL_MAX = 8000;
630
+
631
+ const TRUNCATION_NOTICE =
632
+ '\n\n_This prefilled report was truncated to fit a URL. Press **Copy JSON** in Worca and paste the full report here._';
633
+
634
+ // An UNPAIRED surrogate: a high one not followed by a low, or a low one not preceded
635
+ // by a high. Matching the whole [\uD800-\uDFFF] range instead would replace BOTH halves
636
+ // of every VALID pair as well, so one split emoji at the truncation index would turn
637
+ // every other emoji in the body into a pair of replacement characters — and would make
638
+ // enc()'s length non-monotonic in the slice index, costing the binary search its
639
+ // exactness. Only the genuinely broken code unit is replaced.
640
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
641
+
642
+ /**
643
+ * encodeURIComponent that CANNOT throw. The truncator slices `body` at an arbitrary
644
+ * index, which can cut a surrogate pair in half, and encodeURIComponent raises
645
+ * `URIError: URI malformed` on a lone surrogate — one emoji in the reporter's free
646
+ * text (or in a `paths`-opted narrative) would otherwise 500 the route. Both the
647
+ * length probe and the final encode go through here, so the two always agree.
648
+ */
649
+ function enc(text) {
650
+ const s = String(text);
651
+ try {
652
+ return encodeURIComponent(s);
653
+ } catch {
654
+ return encodeURIComponent(s.replace(LONE_SURROGATE, '�'));
655
+ }
656
+ }
657
+
658
+ function fmtUsd(n) { return n == null ? '—' : `$${Number(n).toFixed(4)}`; }
659
+
660
+ function fmtMs(n) {
661
+ if (n == null) return '—';
662
+ const secs = Math.round(Number(n) / 1000);
663
+ if (secs < 60) return `${secs}s`;
664
+ const mins = Math.floor(secs / 60);
665
+ if (mins < 60) return `${mins}m ${secs % 60}s`;
666
+ return `${Math.floor(mins / 60)}h ${mins % 60}m`;
667
+ }
668
+
669
+ function metricRows(p) {
670
+ const rows = [
671
+ ['worca', p.app.worca],
672
+ ['node', p.app.node],
673
+ ['platform', `${p.app.platform}/${p.app.arch}`],
674
+ ['status', `${p.run.status} (phase ${p.run.phase}, cycle ${p.run.cycle})`],
675
+ ['cost', fmtUsd(p.run.costUsd)],
676
+ ['active time', fmtMs(p.run.activeMs)],
677
+ ['wall clock', fmtMs(p.run.wallClockMs)],
678
+ ['steps / sub-agents', `${p.steps.length} / ${p.subAgents.length}`],
679
+ ];
680
+ if (p.workflow) {
681
+ // Every v2 template names itself (templateShape); a v1 manifest has no template
682
+ // block at all, and only that reads as 'custom' here.
683
+ const tpl = p.workflow.template;
684
+ rows.push(['workflow', `${(tpl && tpl.name) || 'custom'} ` +
685
+ `(manifest v${p.workflow.manifestVersion}, ${p.workflow.nodes.length} nodes)`]);
686
+ }
687
+ if (p.review) {
688
+ rows.push(['review issues',
689
+ SEVERITIES.map((s) => `${p.review.issueCounts[s]} ${s}`).join(', ')]);
690
+ }
691
+ if (p.files) {
692
+ const touched = (p.files.filesNew ?? 0) + (p.files.filesChanged ?? 0);
693
+ rows.push(['files', `+${p.files.linesAdded ?? 0} / −${p.files.linesRemoved ?? 0} across ${touched} files`]);
694
+ }
695
+ const ev = p.evidence || {};
696
+ if (p.reason === 'too-slow' && ev.waitingMs != null) {
697
+ rows.push(['waiting vs working', `${fmtMs(ev.waitingMs)} waiting / ${fmtMs(ev.activeMs)} working`]);
698
+ }
699
+ if (p.reason === 'too-expensive' && ev.budget) {
700
+ rows.push(['per-run cost cap',
701
+ ev.budget.pipelineLimitUsd == null ? 'none set' : fmtUsd(ev.budget.pipelineLimitUsd)]);
702
+ }
703
+ if (p.reason === 'failed-or-stuck' && Array.isArray(ev.failedSteps)) {
704
+ rows.push(['failed steps', String(ev.failedSteps.length)]);
705
+ }
706
+ if (p.reason === 'wrong-or-unsafe' && ev.guardrails) {
707
+ rows.push(['guardrails', ev.guardrails.legacy ? 'not recorded (legacy run)'
708
+ : ev.guardrails.builtin ? ev.guardrails.id : 'a custom set']);
709
+ }
710
+ return rows;
711
+ }
712
+
713
+ /** A title with no user text in it — the title rides the URL and must stay short. */
714
+ export function issueTitle(payload) {
715
+ const reason = reasonById(payload.reason);
716
+ return `Run report: ${reason ? reason.label.toLowerCase() : payload.reason} (worca ${payload.app.worca})`;
717
+ }
718
+
719
+ /**
720
+ * The narrative + metrics table both bodies open with. Shared so the prefilled-URL
721
+ * body and the `gh`-created body can never disagree about the facts — they differ
722
+ * only in how the full JSON reaches the issue (a paste-ask vs. an embedded block).
723
+ */
724
+ function bodyHead(payload) {
725
+ const reason = reasonById(payload.reason);
726
+ const lines = [
727
+ `**What went wrong:** ${reason ? reason.label : payload.reason}`,
728
+ '',
729
+ `Reported from Worca ${payload.app.worca} about run \`${payload.run.id}\`.`,
730
+ ];
731
+ if (payload.expectation) {
732
+ lines.push('', '**What I expected:**', '', payload.expectation);
733
+ }
734
+ lines.push('', '| Metric | Value |', '| --- | --- |');
735
+ for (const [key, value] of metricRows(payload)) lines.push(`| ${key} | ${value} |`);
736
+ if (payload.narrative) {
737
+ lines.push('', '**What this run did** (from its cached overview):', '', payload.narrative);
738
+ }
739
+ return lines;
740
+ }
741
+
742
+ /** The closing paragraph for a body that could NOT carry the JSON itself. */
743
+ function pasteTail(payload) {
744
+ return [
745
+ '',
746
+ '---',
747
+ '',
748
+ `**Paste the full JSON report below** (schema v${payload.schemaVersion}) — Worca put it on ` +
749
+ 'your clipboard when you opened this link. If the paste comes up empty, press ' +
750
+ '**Copy JSON** in the Worca report dialog and try again.',
751
+ '',
752
+ '<!-- paste the copied JSON here -->',
753
+ ];
754
+ }
755
+
756
+ /**
757
+ * The prefilled issue body: a short narrative + a compact metrics table.
758
+ *
759
+ * The closing paragraph ASKS for the paste (D23). v1 asserted the JSON was already
760
+ * on the clipboard, which was a lie for anyone who clicked the issue link without
761
+ * pressing Copy JSON first; the modal now copies on that click, and this wording
762
+ * still reads correctly when the copy was blocked.
763
+ *
764
+ * This is the FALLBACK body now: it ships only when `gh` cannot create the issue for
765
+ * us (renderIssueBodyFull below is the normal path).
766
+ */
767
+ export function renderIssueBody(payload) {
768
+ return [...bodyHead(payload), ...pasteTail(payload)].join('\n');
769
+ }
770
+
771
+ // ── the full body, for `gh issue create --body-file` ──────────────────────────
772
+ // No URL, so no 8 KB cap: the only ceiling is GitHub's own issue-body limit. A
773
+ // measured worst case across 152 real runs was 40 007 chars pretty-printed, so the
774
+ // pretty form nearly always fits; the two degradations below exist for the tail.
775
+
776
+ /** GitHub rejects an issue body longer than this many characters. */
777
+ export const ISSUE_BODY_MAX = 65536;
778
+
779
+ /**
780
+ * A fence long enough to survive the payload. JSON strings carry the reporter's own
781
+ * text under the `prompt` opt-in, and that text routinely contains ``` — a fixed
782
+ * three-backtick fence would be closed early and the rest of the report would land
783
+ * as prose outside the collapsed block.
784
+ */
785
+ function fenceFor(json) {
786
+ let longest = 0;
787
+ for (const run of json.match(/`+/g) || []) longest = Math.max(longest, run.length);
788
+ return '`'.repeat(Math.max(3, longest + 1));
789
+ }
790
+
791
+ function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
792
+
793
+ function detailsBlock(payload, json) {
794
+ const fence = fenceFor(json);
795
+ const counts = `${plural((payload.steps || []).length, 'step')}, ` +
796
+ `${plural((payload.subAgents || []).length, 'sub-agent')}`;
797
+ return [
798
+ '',
799
+ '---',
800
+ '',
801
+ '<details>',
802
+ `<summary>Full JSON report (schema v${payload.schemaVersion} — ${counts})</summary>`,
803
+ '',
804
+ `${fence}json`,
805
+ json,
806
+ fence,
807
+ '',
808
+ '</details>',
809
+ ];
810
+ }
811
+
812
+ /**
813
+ * The issue body Worca writes to a file and hands to `gh issue create`.
814
+ *
815
+ * Pretty first (a maintainer reads this in a browser), minified if indentation is
816
+ * the only thing pushing it over, and the paste-ask as the last resort — a report
817
+ * too big for a GitHub issue at all still opens a usable issue.
818
+ */
819
+ export function renderIssueBodyFull(payload) {
820
+ const head = bodyHead(payload);
821
+ for (const json of [JSON.stringify(payload, null, 2), JSON.stringify(payload)]) {
822
+ const body = [...head, ...detailsBlock(payload, json)].join('\n');
823
+ if (body.length <= ISSUE_BODY_MAX) return body;
824
+ }
825
+ return [...head, ...pasteTail(payload)].join('\n');
826
+ }
827
+
828
+ /**
829
+ * `https://github.com/OWNER/REPO/issues` -> `OWNER/REPO`, for `gh --repo`.
830
+ * Empty for anything that is not a github.com URL: `gh` addresses nothing else, and
831
+ * a wrong slug would file the report in a stranger's repo.
832
+ */
833
+ export function repoSlugFromBugsUrl(bugsUrl) {
834
+ const m = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/(?:issues|pulls)?\/?)?$/
835
+ .exec(String(bugsUrl || '').trim());
836
+ return m ? `${m[1]}/${m[2]}` : '';
837
+ }
838
+
839
+ /** The repo `gh issue create` targets, derived once from package.json bugs.url. */
840
+ export const BUGS_REPO = repoSlugFromBugsUrl(BUGS_URL);
841
+
842
+ /**
843
+ * The prefilled issues/new URL, capped.
844
+ *
845
+ * @returns {{url:string, title:string, body:string, truncated:boolean, length:number}}
846
+ */
847
+ export function buildIssueUrl(payload, { bugsUrl = BUGS_URL, maxLength = ISSUE_URL_MAX } = {}) {
848
+ const root = String(bugsUrl || '').replace(/\/+$/, '');
849
+ const title = issueTitle(payload);
850
+ const full = renderIssueBody(payload);
851
+ // No bugs.url anywhere => no link. A relative "/new?…" would navigate the SPA.
852
+ if (!root) return { url: '', title, body: full, truncated: false, length: 0 };
853
+
854
+ const base = `${root}/new`;
855
+ const head = `${base}?labels=${enc(payload.reason)}&title=${enc(title)}&body=`;
856
+ const fit = (text) => head.length + enc(text).length;
857
+
858
+ if (fit(full) <= maxLength) {
859
+ return { url: head + enc(full), title, body: full, truncated: false, length: fit(full) };
860
+ }
861
+ // Even the notice alone will not fit: drop the body parameter rather than overflow.
862
+ // Then keep degrading — title, then the query, then the URL itself — because the cap
863
+ // is unconditional. Dropping only `body=` still overflowed every maxLength below the
864
+ // ~131-char labels+title head; no production caller gets there (ISSUE_URL_MAX is
865
+ // 8000 and both labels and title are bounded), but the contract is what is tested.
866
+ if (fit(TRUNCATION_NOTICE) > maxLength) {
867
+ const shed = (url) => ({ url, title, body: '', truncated: true, length: url.length });
868
+ const withTitle = `${base}?labels=${enc(payload.reason)}&title=${enc(title)}`;
869
+ if (withTitle.length <= maxLength) return shed(withTitle);
870
+ const withLabel = `${base}?labels=${enc(payload.reason)}`;
871
+ if (withLabel.length <= maxLength) return shed(withLabel);
872
+ // A bare base is still a usable "open a new issue" link; below that, nothing is.
873
+ return shed(base.length <= maxLength ? base : '');
874
+ }
875
+ // Longest prefix of `full` whose encoded length + the notice still fits.
876
+ // `fit` is monotonic non-decreasing in `mid` (a split pair costs 9 encoded chars as
877
+ // one U+FFFD, a whole pair 12, neither one 0 — and enc() only rewrites the UNPAIRED
878
+ // unit), so this is a plain "last true" binary search. Note the safety property that
879
+ // holds even if that monotonicity were ever lost: `lo` is only ever assigned a `mid`
880
+ // whose `fit` was TESTED to fit, so the returned body always satisfies the cap.
881
+ let lo = 0;
882
+ let hi = full.length;
883
+ while (lo < hi) {
884
+ const mid = Math.ceil((lo + hi) / 2);
885
+ if (fit(full.slice(0, mid) + TRUNCATION_NOTICE) <= maxLength) lo = mid; else hi = mid - 1;
886
+ }
887
+ const body = full.slice(0, lo) + TRUNCATION_NOTICE;
888
+ return { url: head + enc(body), title, body, truncated: true, length: fit(body) };
889
+ }
890
+
891
+ /** Stable, path-safe filename for the Download JSON button. Shared with the UI. */
892
+ export function reportFilename(payload) {
893
+ const id = String(payload?.run?.id || 'run').replace(/[^a-zA-Z0-9._-]/g, '-');
894
+ const reason = String(payload?.reason || 'report').replace(/[^a-zA-Z0-9._-]/g, '-');
895
+ return `worca-run-report-${id}-${reason}.json`;
896
+ }