mandrel 1.90.0 → 1.92.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 (115) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +75 -107
  3. package/.agents/docs/configuration.md +1 -1
  4. package/.agents/docs/quality-gates.md +8 -7
  5. package/.agents/docs/workflows.md +4 -10
  6. package/.agents/instructions.md +2 -2
  7. package/.agents/schemas/agentrc.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/README.md +2 -1
  9. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  10. package/.agents/schemas/risk-verdict.schema.json +14 -1
  11. package/.agents/scripts/README.md +0 -46
  12. package/.agents/scripts/analyze-execution.js +76 -1
  13. package/.agents/scripts/check-doc-links.js +3 -0
  14. package/.agents/scripts/check-workflow-cli-lint.js +208 -0
  15. package/.agents/scripts/epic-audit-recheck.js +1 -1
  16. package/.agents/scripts/epic-deliver-preflight.js +37 -1
  17. package/.agents/scripts/epic-plan-clarity.js +16 -6
  18. package/.agents/scripts/epic-plan-decompose.js +37 -59
  19. package/.agents/scripts/epic-plan-healthcheck.js +39 -6
  20. package/.agents/scripts/epic-plan-spec.js +41 -168
  21. package/.agents/scripts/epic-reconcile.js +7 -1
  22. package/.agents/scripts/lib/Logger.js +4 -4
  23. package/.agents/scripts/lib/audit-suite/index.js +4 -5
  24. package/.agents/scripts/lib/audit-suite/runner.js +6 -4
  25. package/.agents/scripts/lib/audit-suite/selector.js +2 -3
  26. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  27. package/.agents/scripts/lib/command-header.js +20 -0
  28. package/.agents/scripts/lib/config/github.js +1 -3
  29. package/.agents/scripts/lib/config-settings-schema.js +4 -3
  30. package/.agents/scripts/lib/degraded-mode.js +1 -1
  31. package/.agents/scripts/lib/label-constants.js +18 -3
  32. package/.agents/scripts/lib/label-taxonomy.js +14 -3
  33. package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
  34. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
  35. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
  36. package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
  37. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
  38. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
  39. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
  40. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
  41. package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
  42. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
  43. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
  44. package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
  45. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
  46. package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
  48. package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +978 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -0
  51. package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
  52. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
  53. package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
  54. package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  56. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  57. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
  58. package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
  59. package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
  60. package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
  61. package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
  62. package/.agents/scripts/plan-context.js +186 -0
  63. package/.agents/scripts/plan-critics.js +227 -0
  64. package/.agents/scripts/plan-persist.js +383 -0
  65. package/.agents/scripts/pr-watch-with-update.js +8 -7
  66. package/.agents/scripts/run-lint.js +10 -11
  67. package/.agents/scripts/single-story-init.js +22 -0
  68. package/.agents/scripts/story-plan.js +19 -7
  69. package/.agents/scripts/sync-claude-commands.js +10 -5
  70. package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
  71. package/.agents/workflows/audit-documentation.md +5 -7
  72. package/.agents/workflows/audit-lighthouse.md +1 -0
  73. package/.agents/workflows/audit-security.md +1 -0
  74. package/.agents/workflows/audit-to-stories.md +16 -5
  75. package/.agents/workflows/deliver.md +8 -0
  76. package/.agents/workflows/git-deliver.md +76 -243
  77. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  78. package/.agents/workflows/helpers/code-review.md +7 -6
  79. package/.agents/workflows/helpers/deliver-epic.md +16 -4
  80. package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
  81. package/.agents/workflows/helpers/plan-epic.md +246 -999
  82. package/.agents/workflows/helpers/plan-story.md +14 -14
  83. package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
  84. package/.agents/workflows/helpers/single-story-deliver.md +13 -4
  85. package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
  86. package/.agents/workflows/mandrel-update.md +144 -466
  87. package/.agents/workflows/plan.md +73 -143
  88. package/docs/CHANGELOG.md +20 -0
  89. package/lib/cli/registry.js +21 -3
  90. package/package.json +1 -1
  91. package/.agents/schemas/loop-unit.schema.json +0 -70
  92. package/.agents/scripts/assert-branch.js +0 -81
  93. package/.agents/scripts/check-loop-units.js +0 -204
  94. package/.agents/scripts/detect-merges.js +0 -111
  95. package/.agents/scripts/git-pr-quality-gate.js +0 -205
  96. package/.agents/scripts/git-rebase-and-resolve.js +0 -234
  97. package/.agents/scripts/hierarchy-gate.js +0 -192
  98. package/.agents/scripts/hydrate-context.js +0 -179
  99. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
  100. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
  101. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
  102. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
  103. package/.agents/scripts/loc-delta.js +0 -205
  104. package/.agents/scripts/render-manifest.js +0 -143
  105. package/.agents/scripts/run-audit-suite.js +0 -97
  106. package/.agents/scripts/select-audits.js +0 -155
  107. package/.agents/scripts/update-mutation-baseline.js +0 -189
  108. package/.agents/workflows/explain.md +0 -118
  109. package/.agents/workflows/git-merge-pr.md +0 -377
  110. package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
  111. package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
  112. package/.agents/workflows/loops/README.md +0 -65
  113. package/.agents/workflows/loops/fix-failing-tests.md +0 -74
  114. package/.agents/workflows/loops/nightly-audit.md +0 -81
  115. package/.agents/workflows/loops/watch-ci.md +0 -68
@@ -0,0 +1,445 @@
1
+ /**
2
+ * plan-metrics.js — append-only plan-invocation ledger (Epic #4474, PR1).
3
+ *
4
+ * The `/plan` collapse (#4474) is measured, not asserted: before any pipeline
5
+ * phase is removed, every plan CLI invocation stamps an entry/exit record so
6
+ * the current 12-phase baseline is captured on disk. Each record is one
7
+ * newline-terminated JSON line appended to
8
+ * `temp/epic-<id>/plan-metrics.json` (per-Epic plan CLIs) or
9
+ * `temp/standalone/plan-metrics.json` (the standalone `story-plan.js` path
10
+ * and Epic-less healthcheck runs — same standalone routing the friction
11
+ * ledger uses).
12
+ *
13
+ * Record shape (v1):
14
+ *
15
+ * ```json
16
+ * { "v": 1, "cli": "plan-context", "mode": "emit",
17
+ * "epicId": 4474, "startedAt": "...", "endedAt": "...",
18
+ * "durationMs": 1234, "ok": true }
19
+ * ```
20
+ *
21
+ * Critic-skip records (Epic #4474 PR6 — additive `kind` extension): the
22
+ * conditional-critic layer logs every skip decision so under-firing is
23
+ * auditable from the same stream:
24
+ *
25
+ * ```json
26
+ * { "v": 1, "kind": "critic-skip", "cli": "plan-critics",
27
+ * "critic": "pre-mortem", "reasons": ["..."], "epicId": 4474,
28
+ * "at": "..." }
29
+ * ```
30
+ *
31
+ * Records without a `kind` field are invocation records (the PR1 shape);
32
+ * readers key on `kind`, never on the absence of other fields.
33
+ *
34
+ * Attribution note (#4474 PR1 risk register): these records count **CLI
35
+ * invocations from the parent session's perspective**. Sub-agent sessions
36
+ * spawned by the workflow do not write here; they are attributed separately
37
+ * by the host's session accounting. Do not read `invocations` as "turns".
38
+ *
39
+ * Robustness contract (mirrors `lib/observability/signals-writer.js`):
40
+ * - **Best-effort writes.** A failed append is a missing metric, not a
41
+ * failed plan phase — fs errors are swallowed after a `Logger.warn`.
42
+ * - **No buffering.** Each append opens, writes one line, closes.
43
+ * - **Malformed-line tolerance on read.** The reader skips unparseable
44
+ * lines (counting them) instead of throwing, so a torn write can never
45
+ * wedge the analyzer.
46
+ * - **Rotation.** When an append would push the ledger past
47
+ * `MAX_LEDGER_BYTES`, the current file is renamed to
48
+ * `plan-metrics.json.1` (replacing any prior rollover) and the append
49
+ * starts a fresh ledger, so a long-lived Epic cannot grow the file
50
+ * unboundedly. Readers only consume the active generation — the
51
+ * rollover exists for manual archaeology.
52
+ *
53
+ * `plan-metrics.json` is intentionally NOT in
54
+ * `lib/plan-phase-cleanup.js#PHASE_TEMP_BASENAMES`: the ledger must survive
55
+ * phase cleanup so the whole plan run (spec → decompose → healthcheck) is
56
+ * visible in one stream.
57
+ */
58
+
59
+ import fs from 'node:fs/promises';
60
+ import path from 'node:path';
61
+
62
+ import {
63
+ anchorTempRoot,
64
+ epicArtifactPath,
65
+ tempRootFrom,
66
+ } from '../config/temp-paths.js';
67
+ import { Logger } from '../Logger.js';
68
+
69
+ export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
70
+ export const PLAN_METRICS_SCHEMA_VERSION = 1;
71
+
72
+ /** Record kind for a logged critic skip decision (Epic #4474 PR6). */
73
+ export const PLAN_METRICS_KIND_CRITIC_SKIP = 'critic-skip';
74
+
75
+ /**
76
+ * Rotation threshold. At ~200 bytes per record this is ~5000 invocations —
77
+ * far beyond any real plan run, so rotation only fires on pathological
78
+ * accumulation.
79
+ */
80
+ export const MAX_LEDGER_BYTES = 1024 * 1024;
81
+
82
+ /**
83
+ * Resolve the ledger path for an Epic (or the standalone stream when
84
+ * `epicId` is `null` — the `story-plan.js` / Epic-less healthcheck case).
85
+ *
86
+ * @param {number|null} epicId
87
+ * @param {object} [config] Resolved config (threads `project.paths.tempRoot`).
88
+ * @returns {string}
89
+ */
90
+ export function planMetricsPath(epicId, config) {
91
+ if (epicId === null || epicId === undefined) {
92
+ return path.join(
93
+ anchorTempRoot(tempRootFrom(config)),
94
+ 'standalone',
95
+ PLAN_METRICS_BASENAME,
96
+ );
97
+ }
98
+ return epicArtifactPath(epicId, PLAN_METRICS_BASENAME, config);
99
+ }
100
+
101
+ /**
102
+ * Rotate the ledger when appending `incomingBytes` would exceed
103
+ * `maxBytes`. Single-generation rollover: `plan-metrics.json` →
104
+ * `plan-metrics.json.1` (any prior `.1` is replaced).
105
+ *
106
+ * @param {string} filePath
107
+ * @param {number} incomingBytes
108
+ * @param {number} [maxBytes]
109
+ * @returns {Promise<boolean>} true when a rotation happened.
110
+ */
111
+ async function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
112
+ let size = 0;
113
+ try {
114
+ size = (await fs.stat(filePath)).size;
115
+ } catch {
116
+ return false; // No existing ledger — nothing to rotate.
117
+ }
118
+ if (size + incomingBytes <= maxBytes) return false;
119
+ await fs.rename(filePath, `${filePath}.1`);
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * Append one invocation record to the ledger. Best-effort: returns `false`
125
+ * (after a `Logger.warn`) instead of throwing on any fs failure, so metric
126
+ * capture can never fail a plan phase.
127
+ *
128
+ * @param {{
129
+ * cli: string,
130
+ * mode: string,
131
+ * epicId?: number|null,
132
+ * startedAt: string,
133
+ * endedAt: string,
134
+ * ok: boolean,
135
+ * }} entry
136
+ * @param {object} [config]
137
+ * @param {{ maxBytes?: number }} [opts] Test seam for the rotation threshold.
138
+ * @returns {Promise<boolean>} true when the line was written.
139
+ */
140
+ export async function appendPlanMetric(entry, config, opts = {}) {
141
+ try {
142
+ if (!entry || typeof entry !== 'object') {
143
+ throw new TypeError('appendPlanMetric requires an entry object');
144
+ }
145
+ if (typeof entry.cli !== 'string' || entry.cli.length === 0) {
146
+ throw new TypeError('appendPlanMetric requires a non-empty entry.cli');
147
+ }
148
+ if (typeof entry.mode !== 'string' || entry.mode.length === 0) {
149
+ throw new TypeError('appendPlanMetric requires a non-empty entry.mode');
150
+ }
151
+ const epicId = entry.epicId ?? null;
152
+ const record = {
153
+ v: PLAN_METRICS_SCHEMA_VERSION,
154
+ cli: entry.cli,
155
+ mode: entry.mode,
156
+ epicId,
157
+ startedAt: entry.startedAt,
158
+ endedAt: entry.endedAt,
159
+ durationMs:
160
+ typeof entry.durationMs === 'number'
161
+ ? entry.durationMs
162
+ : Math.max(
163
+ 0,
164
+ Date.parse(entry.endedAt) - Date.parse(entry.startedAt),
165
+ ) || 0,
166
+ ok: entry.ok === true,
167
+ };
168
+ const filePath = planMetricsPath(epicId, config);
169
+ const line = `${JSON.stringify(record)}\n`;
170
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
171
+ await rotateIfNeeded(
172
+ filePath,
173
+ Buffer.byteLength(line),
174
+ opts.maxBytes ?? MAX_LEDGER_BYTES,
175
+ );
176
+ await fs.appendFile(filePath, line, 'utf8');
177
+ return true;
178
+ } catch (err) {
179
+ Logger.warn(
180
+ `[plan-metrics] append failed (non-fatal): ${err?.message ?? err}`,
181
+ );
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Append one critic-skip audit record (Epic #4474 PR6). Every conditional
188
+ * critic that decides NOT to dispatch logs the decision here — with the
189
+ * deterministic reasons — so an under-firing critic layer (a distorted
190
+ * plan sailing through) is auditable after the fact. Best-effort with the
191
+ * same contract as `appendPlanMetric`: a failed append can never fail the
192
+ * plan step.
193
+ *
194
+ * @param {{
195
+ * critic: string,
196
+ * reasons: string[],
197
+ * cli: string,
198
+ * epicId?: number|null,
199
+ * }} entry
200
+ * @param {object} [config]
201
+ * @returns {Promise<boolean>} true when the line was written.
202
+ */
203
+ export async function appendCriticSkip(entry, config) {
204
+ try {
205
+ if (!entry || typeof entry !== 'object') {
206
+ throw new TypeError('appendCriticSkip requires an entry object');
207
+ }
208
+ if (typeof entry.critic !== 'string' || entry.critic.length === 0) {
209
+ throw new TypeError('appendCriticSkip requires a non-empty entry.critic');
210
+ }
211
+ if (typeof entry.cli !== 'string' || entry.cli.length === 0) {
212
+ throw new TypeError('appendCriticSkip requires a non-empty entry.cli');
213
+ }
214
+ const epicId = entry.epicId ?? null;
215
+ const record = {
216
+ v: PLAN_METRICS_SCHEMA_VERSION,
217
+ kind: PLAN_METRICS_KIND_CRITIC_SKIP,
218
+ cli: entry.cli,
219
+ critic: entry.critic,
220
+ reasons: Array.isArray(entry.reasons)
221
+ ? entry.reasons.filter((r) => typeof r === 'string')
222
+ : [],
223
+ epicId,
224
+ at: new Date().toISOString(),
225
+ };
226
+ const filePath = planMetricsPath(epicId, config);
227
+ const line = `${JSON.stringify(record)}\n`;
228
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
229
+ await rotateIfNeeded(filePath, Buffer.byteLength(line), MAX_LEDGER_BYTES);
230
+ await fs.appendFile(filePath, line, 'utf8');
231
+ return true;
232
+ } catch (err) {
233
+ Logger.warn(
234
+ `[plan-metrics] critic-skip append failed (non-fatal): ${err?.message ?? err}`,
235
+ );
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Wrap one plan CLI invocation: stamp `startedAt`, run `fn`, stamp
242
+ * `endedAt` + `ok`, append the record, and re-throw the original error on
243
+ * failure. The metric write itself is best-effort and can never mask or
244
+ * replace the wrapped function's outcome.
245
+ *
246
+ * @template T
247
+ * @param {{ cli: string, mode: string, epicId?: number|null, config?: object }} meta
248
+ * @param {() => Promise<T>} fn
249
+ * @returns {Promise<T>}
250
+ */
251
+ export async function recordPlanInvocation(meta, fn) {
252
+ const startedAt = new Date().toISOString();
253
+ const startMs = Date.now();
254
+ let ok = false;
255
+ try {
256
+ const result = await fn();
257
+ ok = true;
258
+ return result;
259
+ } finally {
260
+ await appendPlanMetric(
261
+ {
262
+ cli: meta.cli,
263
+ mode: meta.mode,
264
+ epicId: meta.epicId ?? null,
265
+ startedAt,
266
+ endedAt: new Date().toISOString(),
267
+ durationMs: Date.now() - startMs,
268
+ ok,
269
+ },
270
+ meta.config,
271
+ );
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Read the active ledger generation. Missing file → `{ entries: [],
277
+ * malformedLines: 0, missing: true }`. Malformed lines are skipped and
278
+ * counted, never thrown.
279
+ *
280
+ * @param {number|null} epicId
281
+ * @param {object} [config]
282
+ * @returns {Promise<{ entries: object[], malformedLines: number, missing: boolean }>}
283
+ */
284
+ export async function readPlanMetrics(epicId, config) {
285
+ const filePath = planMetricsPath(epicId, config);
286
+ let raw;
287
+ try {
288
+ raw = await fs.readFile(filePath, 'utf8');
289
+ } catch {
290
+ return { entries: [], malformedLines: 0, missing: true };
291
+ }
292
+ const entries = [];
293
+ let malformedLines = 0;
294
+ for (const line of raw.split('\n')) {
295
+ const trimmed = line.trim();
296
+ if (!trimmed) continue;
297
+ try {
298
+ const parsed = JSON.parse(trimmed);
299
+ if (
300
+ parsed &&
301
+ typeof parsed === 'object' &&
302
+ typeof parsed.cli === 'string'
303
+ ) {
304
+ entries.push(parsed);
305
+ } else {
306
+ malformedLines += 1;
307
+ }
308
+ } catch {
309
+ malformedLines += 1;
310
+ }
311
+ }
312
+ return { entries, malformedLines, missing: false };
313
+ }
314
+
315
+ /**
316
+ * Roll a read ledger up into the compact summary surfaced by the persist
317
+ * summary and `analyze-execution.js`. Returns `null` when there is nothing
318
+ * to summarize (missing ledger or zero parseable entries).
319
+ *
320
+ * Critic-skip records (kind: 'critic-skip') are counted separately from
321
+ * invocations — `criticSkips` totals them and `criticSkipsByCritic` breaks
322
+ * them down, so the skip-audit trail is visible in the persist summary
323
+ * without inflating the turns-per-plan proxy.
324
+ *
325
+ * @param {{ entries: object[], malformedLines?: number }} ledger
326
+ * @returns {{
327
+ * invocations: number,
328
+ * failures: number,
329
+ * byCli: Record<string, number>,
330
+ * byMode: Record<string, number>,
331
+ * criticSkips: number,
332
+ * criticSkipsByCritic: Record<string, number>,
333
+ * firstStartedAt: string|null,
334
+ * lastEndedAt: string|null,
335
+ * spanMs: number|null,
336
+ * totalDurationMs: number,
337
+ * malformedLines: number,
338
+ * }|null}
339
+ */
340
+ export function summarizePlanMetrics(ledger) {
341
+ const entries = ledger?.entries ?? [];
342
+ if (entries.length === 0) return null;
343
+ const byCli = {};
344
+ const byMode = {};
345
+ const criticSkipsByCritic = {};
346
+ let criticSkips = 0;
347
+ let failures = 0;
348
+ let totalDurationMs = 0;
349
+ let firstStartedAt = null;
350
+ let lastEndedAt = null;
351
+ const invocationEntries = [];
352
+ for (const e of entries) {
353
+ if (e.kind === PLAN_METRICS_KIND_CRITIC_SKIP) {
354
+ criticSkips += 1;
355
+ if (typeof e.critic === 'string') {
356
+ criticSkipsByCritic[e.critic] =
357
+ (criticSkipsByCritic[e.critic] ?? 0) + 1;
358
+ }
359
+ continue;
360
+ }
361
+ invocationEntries.push(e);
362
+ byCli[e.cli] = (byCli[e.cli] ?? 0) + 1;
363
+ if (typeof e.mode === 'string') byMode[e.mode] = (byMode[e.mode] ?? 0) + 1;
364
+ if (e.ok !== true) failures += 1;
365
+ if (typeof e.durationMs === 'number') totalDurationMs += e.durationMs;
366
+ if (typeof e.startedAt === 'string') {
367
+ if (firstStartedAt === null || e.startedAt < firstStartedAt) {
368
+ firstStartedAt = e.startedAt;
369
+ }
370
+ }
371
+ if (typeof e.endedAt === 'string') {
372
+ if (lastEndedAt === null || e.endedAt > lastEndedAt) {
373
+ lastEndedAt = e.endedAt;
374
+ }
375
+ }
376
+ }
377
+ let spanMs = null;
378
+ if (firstStartedAt !== null && lastEndedAt !== null) {
379
+ const span = Date.parse(lastEndedAt) - Date.parse(firstStartedAt);
380
+ if (Number.isFinite(span)) spanMs = Math.max(0, span);
381
+ }
382
+ return {
383
+ invocations: invocationEntries.length,
384
+ failures,
385
+ byCli,
386
+ byMode,
387
+ criticSkips,
388
+ criticSkipsByCritic,
389
+ firstStartedAt,
390
+ lastEndedAt,
391
+ spanMs,
392
+ totalDurationMs,
393
+ malformedLines: ledger?.malformedLines ?? 0,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Render the one-line human summary (snapshot-tested). Example:
399
+ *
400
+ * `plan-metrics: 3 invocation(s) (1 failed) across plan-context ×1,
401
+ * plan-critics ×1, plan-persist ×1 — span 12m 3s`
402
+ *
403
+ * @param {ReturnType<typeof summarizePlanMetrics>} summary
404
+ * @returns {string}
405
+ */
406
+ export function renderPlanMetricsSummaryLine(summary) {
407
+ if (!summary) return 'plan-metrics: no invocations recorded';
408
+ const cliParts = Object.entries(summary.byCli)
409
+ .map(([cli, count]) => `${cli} ×${count}`)
410
+ .join(', ');
411
+ const failed = summary.failures > 0 ? ` (${summary.failures} failed)` : '';
412
+ const span = summary.spanMs === null ? 'n/a' : formatSpan(summary.spanMs);
413
+ const malformed =
414
+ summary.malformedLines > 0
415
+ ? `; ${summary.malformedLines} malformed line(s) skipped`
416
+ : '';
417
+ const skips =
418
+ (summary.criticSkips ?? 0) > 0
419
+ ? `; ${summary.criticSkips} critic skip(s) logged (${Object.entries(
420
+ summary.criticSkipsByCritic ?? {},
421
+ )
422
+ .map(([critic, count]) => `${critic} ×${count}`)
423
+ .join(', ')})`
424
+ : '';
425
+ return (
426
+ `plan-metrics: ${summary.invocations} invocation(s)${failed} across ` +
427
+ `${cliParts || 'no plan CLIs'} — span ${span}${skips}${malformed}`
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Compact duration formatter: `45s`, `12m 3s`, `2h 5m`.
433
+ *
434
+ * @param {number} ms
435
+ * @returns {string}
436
+ */
437
+ function formatSpan(ms) {
438
+ const totalSeconds = Math.round(ms / 1000);
439
+ if (totalSeconds < 60) return `${totalSeconds}s`;
440
+ const totalMinutes = Math.floor(totalSeconds / 60);
441
+ const seconds = totalSeconds % 60;
442
+ if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
443
+ const hours = Math.floor(totalMinutes / 60);
444
+ return `${hours}h ${totalMinutes % 60}m`;
445
+ }