@worca/app 1.2.0 → 1.3.0-rc.2

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,389 @@
1
+ // Team metrics RunRecord v1 (team-metrics-design.md §4.4). The builder is PURE over a
2
+ // normalized snapshot; snapshotFromHarness() gathers that snapshot from a finished
3
+ // harness, and recordRunMetrics() is the fail-soft terminal hook (§4.5).
4
+ import { createRequire } from 'node:module';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+ import { roundUsd } from '../cost-budget.mjs';
9
+ import { prepare } from '../db.mjs';
10
+ import { readPrState } from '../artifacts.mjs';
11
+ import { RESULTS_FILE } from '../results.mjs';
12
+ import { UI_PHASE } from '../../shared/graph/manifest.mjs';
13
+ import {
14
+ projectSlug, gitUserName, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
15
+ } from './sync.mjs';
16
+ import { writeRunLedger } from './ledger.mjs';
17
+
18
+ export const RECORD_VERSION = 1;
19
+ export const TEXT_MAX = 200;
20
+ export const WORCA_VERSION = createRequire(import.meta.url)('../../../package.json').version;
21
+
22
+ /** Serialised key order of a v1 record — diffs stay readable (§4.4). */
23
+ export const RECORD_FIELDS = Object.freeze([
24
+ 'v', 'id', 'worca', 'recordedAt',
25
+ 'startedAt', 'endedAt', 'wallMs', 'activeMs',
26
+ 'result', 'failure',
27
+ 'workflow', 'target', 'title', 'source',
28
+ 'cost', 'agents', 'steps', 'cycles', 'interventions',
29
+ 'pr', 'git', 'actor',
30
+ ]);
31
+
32
+ const RESULT_OF = Object.freeze({ done: 'done', error: 'failed', stopped: 'stopped' });
33
+ const BUDGET_RE = /budget|cost cap|cost limit/i;
34
+ // Absolute POSIX/Windows/home paths, but never URLs (the lookbehind skips "https://host/…").
35
+ // §4.12 promises a record carries no local paths, and harness error texts routinely do:
36
+ // run-harness.mjs:~1333 `worktree missing: ${wt} — cannot resume`, git stderr, ENOENT texts
37
+ // (verified in a real resume-error run). cleanText alone only strips control characters.
38
+ const ABS_PATH_RE = /(?<![\w:/\\.~-])(?:[A-Za-z]:[\\/]|~?[\\/])[^\s'"`<>|\\/]+(?:[\\/][^\s'"`<>|]*)?/g;
39
+ /** Replace absolute paths with `<path>` (decision 34). Applied to failure.message. */
40
+ export const redactPaths = (v) => (v == null ? v : String(v).replace(ABS_PATH_RE, '<path>'));
41
+ // C0, DEL, C1, and the two JS line separators — one record is always one line (§4.12).
42
+ const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g;
43
+
44
+ /** Strip control chars/newlines, collapse spaces, truncate to `max` code points. */
45
+ export function cleanText(value, max = TEXT_MAX) {
46
+ if (value == null) return null;
47
+ const flat = String(value).replace(CONTROL_RE, ' ').replace(/ {2,}/g, ' ').trim();
48
+ const chars = Array.from(flat);
49
+ return chars.length > max ? chars.slice(0, max).join('') : flat;
50
+ }
51
+
52
+ function isoSec(v) {
53
+ const ms = v instanceof Date ? v.getTime() : Date.parse(v);
54
+ return Number.isFinite(ms) ? new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z') : null;
55
+ }
56
+
57
+ const num = (v) => (Number.isFinite(v) ? v : null);
58
+ const unique = (arr) => [...new Set(arr.filter((x) => typeof x === 'string' && x))];
59
+
60
+ // failure-policy REASON codes of a cost-cap pause (src/core/failure-policy.mjs).
61
+ const BUDGET_PAUSE = new Set(['cost_pipeline', 'cost_total']);
62
+
63
+ /**
64
+ * failure (§4.4). Cost caps and setup failures PAUSE the run, so the last pause reason is
65
+ * part of the evidence. `commit` is never emitted in v1: commitFailed is only known after
66
+ * teardown, which runs after the hook (plan §1).
67
+ */
68
+ function buildFailure(snap, result, agentSteps) {
69
+ const pauseBudget = BUDGET_PAUSE.has(snap.lastPause?.reason);
70
+ if (result === 'stopped') {
71
+ return pauseBudget ? { kind: 'budget', message: cleanText(redactPaths(snap.lastPause.detail || snap.lastPause.reason)) } : null;
72
+ }
73
+ if (result !== 'failed') return null;
74
+ const raw = snap.error == null ? '' : String(snap.error);
75
+ // Budget first: the Auto classifier's cost sits on the preflight row, so a cap can trip before any agent step.
76
+ const kind = pauseBudget || BUDGET_RE.test(raw) ? 'budget'
77
+ : agentSteps.length === 0 ? 'preflight' : 'error';
78
+ return { kind, message: cleanText(redactPaths(raw || snap.lastPause?.detail || '')) };
79
+ }
80
+
81
+ function buildTarget(t) {
82
+ if (t?.kind === 'workspace') {
83
+ return {
84
+ kind: 'workspace',
85
+ workspace: cleanText(t.workspace),
86
+ // Stable identity for the reader (workspace-match.mjs); the name above stays for display.
87
+ workspaceId: typeof t.workspaceId === 'string' && t.workspaceId ? t.workspaceId : null,
88
+ projects: unique(t.projects || []),
89
+ touched: unique(t.touched || []),
90
+ // Files changed per touched member (additive; older records lack it → the reader shows
91
+ // "–" rather than attributing the run's total to every project it touched).
92
+ touchedFiles: touchedFilesOf(t.touchedFiles),
93
+ };
94
+ }
95
+ return { kind: 'project', project: t?.project ?? null };
96
+ }
97
+
98
+ function touchedFilesOf(m) {
99
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return {};
100
+ const out = {};
101
+ for (const [slug, n] of Object.entries(m)) if (typeof slug === 'string' && slug && Number.isInteger(n) && n >= 0) out[slug] = n;
102
+ return out;
103
+ }
104
+
105
+ function buildSource(src) {
106
+ if (!src || !src.type) return null;
107
+ return {
108
+ type: cleanText(src.type, 80),
109
+ ref: cleanText(src.ref),
110
+ url: cleanText(src.url, 2000),
111
+ title: cleanText(src.title),
112
+ };
113
+ }
114
+
115
+ function buildCost(steps, totalCostUsd) {
116
+ const byPhase = {};
117
+ let sum = 0;
118
+ for (const s of steps) {
119
+ const c = Number(s?.costUsd) || 0;
120
+ sum += c;
121
+ if (s?.phase && c) byPhase[s.phase] = (byPhase[s.phase] || 0) + c;
122
+ }
123
+ for (const k of Object.keys(byPhase)) byPhase[k] = roundUsd(byPhase[k]);
124
+ return { usd: roundUsd(Math.max(Number(totalCostUsd) || 0, sum)), byPhase };
125
+ }
126
+
127
+ function maxCyclePerPhase(agentSteps) {
128
+ const out = {};
129
+ for (const s of agentSteps) {
130
+ if (!s.phase || !Number.isInteger(s.cycle)) continue;
131
+ out[s.phase] = Math.max(out[s.phase] ?? 0, s.cycle);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * Build a v1 RunRecord from a normalized snapshot (see snapshotFromHarness).
138
+ * @param {object} snap
139
+ * @param {{attribution?:'git-user'|'none', now?:Date}} [opts]
140
+ */
141
+ export function buildRunRecord(snap, { attribution = 'git-user', now = new Date() } = {}) {
142
+ const result = RESULT_OF[snap?.status];
143
+ if (!result) throw new RangeError(`not a terminal run status: ${snap?.status}`);
144
+ const steps = Array.isArray(snap.steps) ? snap.steps : [];
145
+ const agentSteps = steps.filter((s) => s && s.agentKey);
146
+ const startMs = Date.parse(snap.startedAt);
147
+ const endMs = Date.parse(snap.endedAt);
148
+ const recordedAt = isoSec(now);
149
+ const iv = snap.interventions || {};
150
+ const keys = unique(snap.agentKeys?.length ? [...snap.agentKeys] : agentSteps.map((s) => s.agentKey));
151
+ // Full model ids from result frames only. subAgents[].runModel holds aliases ('haiku',
152
+ // run-harness.mjs:~3397) and resume() does not restore state.subAgents, so mixing it in would
153
+ // give an inconsistent, resume-dependent model mix.
154
+ const models = unique(agentSteps.map((s) => s.modelUsed)).sort();
155
+ const g = snap.git || {};
156
+ return {
157
+ v: RECORD_VERSION,
158
+ id: String(snap.runId),
159
+ worca: snap.worcaVersion ?? WORCA_VERSION,
160
+ recordedAt,
161
+ startedAt: isoSec(snap.startedAt) ?? recordedAt,
162
+ endedAt: isoSec(snap.endedAt) ?? recordedAt,
163
+ wallMs: Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.max(0, endMs - startMs) : null,
164
+ activeMs: num(snap.totalActiveMs),
165
+ result,
166
+ failure: buildFailure(snap, result, agentSteps),
167
+ workflow: snap.workflow
168
+ ? {
169
+ id: snap.workflow.id ?? null,
170
+ name: cleanText(snap.workflow.name),
171
+ version: Number.isInteger(snap.workflow.version) ? snap.workflow.version : null,
172
+ rev: /^[0-9a-f]{8}$/.test(String(snap.workflow.rev || '')) ? snap.workflow.rev : null, // additive (§4.4 versioning)
173
+ }
174
+ : null,
175
+ target: buildTarget(snap.target),
176
+ title: cleanText(snap.title),
177
+ source: buildSource(snap.source),
178
+ cost: buildCost(steps, snap.totalCostUsd),
179
+ agents: { count: keys.length, keys, models },
180
+ steps: agentSteps.length,
181
+ cycles: maxCyclePerPhase(agentSteps),
182
+ interventions: { questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: iv.resumes | 0 },
183
+ pr: snap.pr && (snap.pr.url || snap.pr.number != null)
184
+ ? { number: Number.isInteger(snap.pr.number) ? snap.pr.number : null, url: cleanText(snap.pr.url, 2000), base: snap.prBase ?? null }
185
+ : null,
186
+ git: {
187
+ branch: cleanText(g.branch),
188
+ head: g.head ?? null,
189
+ base: cleanText(g.base),
190
+ filesChanged: num(g.filesChanged),
191
+ insertions: num(g.insertions),
192
+ deletions: num(g.deletions),
193
+ },
194
+ actor: attribution === 'none' ? null : cleanText(snap.actor),
195
+ };
196
+ }
197
+
198
+ const TERMINAL = new Set(['done', 'error', 'stopped']);
199
+ const MOCK_ENV_RE = /^(1|true|yes|on)$/i;
200
+
201
+ export function isMockRun(harness) {
202
+ return !!harness?.claude?.mock || MOCK_ENV_RE.test(String(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK ?? ''));
203
+ }
204
+
205
+ function log(harness, level, text) {
206
+ try { harness?._log?.('metrics', level, text); } catch { /* logging must never throw */ }
207
+ }
208
+
209
+ async function readResults(pipelineDir) {
210
+ if (!pipelineDir) return null;
211
+ try { return JSON.parse(await readFile(join(pipelineDir, RESULTS_FILE), 'utf8')); } catch { return null; }
212
+ }
213
+
214
+ // results.mjs: summary.filesChanged already includes deletions (filesDeleted counts the same rows).
215
+ const changedFiles = (s) => (s ? (s.filesNew | 0) + (s.filesChanged | 0) : 0);
216
+
217
+ /** Graph engine: step.phase is the agent key (orchestrator.mjs "legacy column"). Map to the UI phase. */
218
+ function withUiPhases(steps, graph) {
219
+ const uiPhaseOf = new Map((graph?.nodes || []).map((n) => [n.id, n.uiPhase]));
220
+ return steps.map((s) => (s && s.agentKey
221
+ ? { ...s, phase: uiPhaseOf.get(s.nodeId) || UI_PHASE[s.agentKey] || s.phase || s.agentKey }
222
+ : s));
223
+ }
224
+
225
+ /** Structural revision of the workflow graph: layout, labels and per-run overlays excluded (decision 5). */
226
+ function graphRev(graph) {
227
+ if (!graph || typeof graph !== 'object') return null;
228
+ try {
229
+ const shape = {
230
+ nodes: (Array.isArray(graph.nodes) ? graph.nodes : []).map((n) => ({ id: n?.id ?? null, kind: n?.kind ?? null, key: n?.key ?? null, config: n?.config ?? null })),
231
+ wires: (Array.isArray(graph.wires) ? graph.wires : []).map((w) => ({ id: w?.id ?? null, from: w?.from ?? null, to: w?.to ?? null, maxCycles: w?.maxCycles ?? null })),
232
+ };
233
+ return createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 8);
234
+ } catch { return null; }
235
+ }
236
+
237
+ /** Branch commit stamped by _commitWork, if any. At hook time it normally is not yet (decision 3). */
238
+ const shortCommit = (c) => (typeof c === 'string' && /^[0-9a-f]{7,40}$/i.test(c) ? c.slice(0, 8).toLowerCase() : null);
239
+
240
+ /** state.title may still be the provisional first prompt line; give the LLM title a short grace. */
241
+ async function settledTitle(harness) {
242
+ const st = harness.state || {};
243
+ if (st.titleProvisional !== false && harness._titlePromise && typeof harness._titlePromise.then === 'function') {
244
+ await Promise.race([harness._titlePromise.catch(() => {}), new Promise((r) => setTimeout(r, 5_000).unref?.())]);
245
+ }
246
+ return st.title;
247
+ }
248
+
249
+ function readSource(runId) {
250
+ let row;
251
+ try { row = prepare('SELECT source_type, source_ref FROM pipelines WHERE id = ?').get(runId); } catch { return null; }
252
+ if (!row || !row.source_type || !row.source_ref) return null;
253
+ let meta;
254
+ try { meta = JSON.parse(row.source_ref); } catch { return null; }
255
+ if (!meta || (meta.taskId == null && !meta.url)) return null;
256
+ return { type: meta.sourceId || row.source_type, ref: meta.taskId != null ? String(meta.taskId) : null, url: meta.url ?? null, title: meta.title ?? null };
257
+ }
258
+
259
+ /** Normalize a finished harness into the buildRunRecord() snapshot. Best-effort per field. */
260
+ export async function snapshotFromHarness(harness, { status, error = null } = {}) {
261
+ const st = harness.state || {};
262
+ const runId = harness.pipeline?.id || st.id;
263
+ const members = Array.isArray(harness.members) && harness.members.length
264
+ ? harness.members
265
+ : [{ projectKey: null, projectDir: harness.projectDir }];
266
+ const slugOf = new Map();
267
+ for (const m of members) slugOf.set(m.projectKey ?? m.projectDir, (await projectSlug(m.projectDir)).slug);
268
+ const results = await readResults(harness.pipeline?.dir);
269
+ const summary = results?.summary || null;
270
+ const branch = st.branch || null; // mirrors the primary member in workspace runs (plan §1)
271
+ const iv = harness._metricsIv || {};
272
+ const tpl = harness.resolved?.template || null;
273
+ const stepperTpl = st.stepper?.template || null;
274
+ const target = harness.isWorkspace
275
+ ? {
276
+ kind: 'workspace',
277
+ workspace: harness.workspace?.name ?? st.workspaceName ?? null,
278
+ workspaceId: harness.workspace?.id ?? st.workspaceId ?? null,
279
+ projects: [...slugOf.values()].sort(),
280
+ touched: Object.entries(results?.perProject || {})
281
+ .filter(([, r]) => changedFiles(r?.summary) > 0)
282
+ .map(([key]) => slugOf.get(key)).filter(Boolean).sort(),
283
+ // The same per-member summaries, as counts: what "Files changed" per project is made of.
284
+ touchedFiles: Object.fromEntries(Object.entries(results?.perProject || {})
285
+ .filter(([key, r]) => slugOf.has(key) && changedFiles(r?.summary) > 0)
286
+ .map(([key, r]) => [slugOf.get(key), changedFiles(r.summary)])),
287
+ }
288
+ : { kind: 'project', project: slugOf.values().next().value };
289
+ return {
290
+ status,
291
+ error: error == null ? null : String(error?.message || error),
292
+ runId,
293
+ startedAt: st.startedAt,
294
+ endedAt: st.updatedAt,
295
+ totalActiveMs: st.totalActiveMs,
296
+ totalCostUsd: st.totalCostUsd,
297
+ steps: withUiPhases(st.steps || [], st.stepper?.graph),
298
+ subAgents: st.subAgents || [],
299
+ workflow: {
300
+ // `||`, NOT `??`: buildGraphManifest stores `template: { id: tpl?.id ?? '', name: tpl?.name ?? '' }`
301
+ // (shared/graph/manifest.mjs:~195) and manifestTemplate repeats the `?? ''`. An EMPTY STRING is
302
+ // not nullish, so with `??` a run that died while the stepper still held the Auto bootstrap
303
+ // manifest recorded `{id:'', name:''}` and never fell through to harness.workflowId. The
304
+ // orchestrator guards the same value the same way (orchestrator.mjs:~418, `… .name || name`).
305
+ id: tpl?.id || stepperTpl?.id || harness.workflowId || null,
306
+ name: tpl?.name || stepperTpl?.name || null,
307
+ version: tpl?.version ?? null,
308
+ rev: graphRev(st.stepper?.graph),
309
+ },
310
+ agentKeys: harness.resolved?.agentKeys ? Array.from(harness.resolved.agentKeys) : null,
311
+ target,
312
+ title: await settledTitle(harness),
313
+ source: readSource(runId),
314
+ pr: (() => { try { return readPrState(runId); } catch { return null; } })(),
315
+ prBase: branch?.source ?? null,
316
+ git: {
317
+ branch: branch?.feature ?? null,
318
+ head: shortCommit(branch?.commit), // pre-teardown: normally null (never the pre-run checkpoint HEAD)
319
+ base: branch?.source ?? null,
320
+ filesChanged: summary ? changedFiles(summary) : null,
321
+ insertions: summary ? summary.linesAdded ?? null : null,
322
+ deletions: summary ? summary.linesRemoved ?? null : null,
323
+ },
324
+ interventions: { questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: iv.resumes | 0 },
325
+ // _completePaused stamps iv. A stop/error that lands while a forced pause is still unwinding
326
+ // never reaches it (pause → stop before the unwind finishes → site B), so fall back to this
327
+ // instance's live reason; resume() clears both at rehydration (decision 2).
328
+ lastPause: iv.lastPauseReason
329
+ ? { reason: iv.lastPauseReason, detail: iv.lastPauseDetail ?? null }
330
+ : harness.pauseReason ? { reason: harness.pauseReason, detail: harness.pauseDetail ?? null } : null,
331
+ actor: await gitUserName(harness.projectDir),
332
+ };
333
+ }
334
+
335
+ let _recorder = null;
336
+ let scheduleFlush = realScheduleFlush;
337
+
338
+ /**
339
+ * Terminal hook (§4.5). Resolve the sink → build the record → write the outbox (durability
340
+ * point) → schedule a flush. Never throws; returns {recorded, reason?, slug?, file?}.
341
+ */
342
+ export async function recordRunMetrics(harness, opts = {}) {
343
+ try {
344
+ if (_recorder) return await _recorder(harness, opts);
345
+ return await recordImpl(harness, opts);
346
+ } catch (err) {
347
+ log(harness, 'warn', `team metrics: ${err?.message || err}`);
348
+ return { recorded: false, reason: 'error', error: String(err?.message || err) };
349
+ }
350
+ }
351
+
352
+ async function recordImpl(harness, { status, error = null, now = new Date() }) {
353
+ if (!TERMINAL.has(status)) return { recorded: false, reason: 'non-terminal' };
354
+ if (!harness?.pipeline?.id) return { recorded: false, reason: 'no-pipeline' }; // preflight-only: nothing started
355
+ if (isMockRun(harness)) return { recorded: false, reason: 'mock' };
356
+ const runId = harness.pipeline.id;
357
+ // Decision 25: cached discovery only (one bounded discovery if there is no cache at all) —
358
+ // the terminal `done` event must never wait on ls-remote/fetch.
359
+ const sink = harness.isWorkspace
360
+ ? await resolveWorkspaceSink(harness.workspace?.id, { discover: 'if-missing' })
361
+ : await resolveProjectSink(harness.projectDir, { discover: 'if-missing' });
362
+ if (!sink.ok) {
363
+ if (sink.reason === 'delegate-invalid' || sink.reason === 'home-stale' || sink.code === 'CONFIG_UNKNOWN') {
364
+ log(harness, 'warn', `team metrics: not recorded — ${sink.detail}`);
365
+ // The header prints the reason; 'not-enabled' would read "(not enabled)" for a branch that
366
+ // simply could not be fetched yet.
367
+ const reason = sink.code === 'CONFIG_UNKNOWN' ? 'config-unknown' : sink.reason;
368
+ writeRunLedger(runId, { state: 'skipped', reason, detail: sink.detail });
369
+ }
370
+ return { recorded: false, reason: sink.reason };
371
+ }
372
+ if (!sink.record) {
373
+ writeRunLedger(runId, { state: 'skipped', slug: sink.slug, reason: 'opted-out' });
374
+ return { recorded: false, reason: 'opted-out' };
375
+ }
376
+ const snap = await snapshotFromHarness(harness, { status, error });
377
+ const record = buildRunRecord(snap, { attribution: sink.attribution, now });
378
+ const file = await writeOutbox(sink.slug, record);
379
+ writeRunLedger(runId, { state: 'pending', slug: sink.slug, file });
380
+ scheduleFlush(sink.slug);
381
+ log(harness, 'info', `team metrics: recorded to ${sink.slug} (${file}); push scheduled`);
382
+ return { recorded: true, slug: sink.slug, file };
383
+ }
384
+
385
+ export const _testing = {
386
+ setRecorder(fn) { _recorder = fn; },
387
+ setScheduleFlush(fn) { scheduleFlush = fn; },
388
+ reset() { _recorder = null; scheduleFlush = realScheduleFlush; },
389
+ };