mandrel 1.91.0 → 1.93.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.
- package/.agents/README.md +5 -5
- package/.agents/docs/SDLC.md +75 -107
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/docs/workflows.md +4 -10
- package/.agents/instructions.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/lifecycle/README.md +2 -1
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/risk-verdict.schema.json +14 -1
- package/.agents/scripts/README.md +0 -46
- package/.agents/scripts/analyze-execution.js +76 -1
- package/.agents/scripts/check-doc-links.js +3 -0
- package/.agents/scripts/check-workflow-cli-lint.js +208 -0
- package/.agents/scripts/epic-audit-recheck.js +1 -1
- package/.agents/scripts/epic-plan-clarity.js +16 -6
- package/.agents/scripts/epic-plan-decompose.js +37 -59
- package/.agents/scripts/epic-plan-healthcheck.js +39 -6
- package/.agents/scripts/epic-plan-spec.js +41 -168
- package/.agents/scripts/epic-reconcile.js +7 -1
- package/.agents/scripts/lib/Logger.js +4 -4
- package/.agents/scripts/lib/audit-suite/index.js +4 -5
- package/.agents/scripts/lib/audit-suite/runner.js +6 -4
- package/.agents/scripts/lib/audit-suite/selector.js +2 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/command-header.js +20 -0
- package/.agents/scripts/lib/config/github.js +1 -3
- package/.agents/scripts/lib/config-settings-schema.js +4 -3
- package/.agents/scripts/lib/degraded-mode.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -3
- package/.agents/scripts/lib/label-taxonomy.js +14 -3
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
- package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
- package/.agents/scripts/lib/orchestration/file-assumptions.js +68 -7
- package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +696 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
- package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
- package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
- package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +1015 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +206 -0
- package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
- package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
- package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
- package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
- package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
- package/.agents/scripts/plan-context.js +204 -0
- package/.agents/scripts/plan-critics.js +199 -0
- package/.agents/scripts/plan-persist.js +383 -0
- package/.agents/scripts/pr-watch-with-update.js +8 -7
- package/.agents/scripts/run-lint.js +10 -11
- package/.agents/scripts/story-plan.js +19 -7
- package/.agents/scripts/sync-claude-commands.js +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
- package/.agents/workflows/audit-documentation.md +5 -7
- package/.agents/workflows/audit-lighthouse.md +1 -0
- package/.agents/workflows/audit-security.md +1 -0
- package/.agents/workflows/audit-to-stories.md +16 -5
- package/.agents/workflows/git-deliver.md +76 -243
- package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
- package/.agents/workflows/helpers/code-review.md +7 -6
- package/.agents/workflows/helpers/deliver-epic.md +5 -4
- package/.agents/workflows/helpers/plan-epic-reference.md +120 -96
- package/.agents/workflows/helpers/plan-epic.md +307 -994
- package/.agents/workflows/helpers/plan-story.md +14 -14
- package/.agents/workflows/helpers/scope-triage-gate.md +15 -4
- package/.agents/workflows/helpers/single-story-deliver.md +5 -4
- package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
- package/.agents/workflows/mandrel-update.md +144 -466
- package/.agents/workflows/plan.md +87 -145
- package/docs/CHANGELOG.md +20 -0
- package/lib/cli/registry.js +21 -3
- package/package.json +1 -1
- package/.agents/schemas/loop-unit.schema.json +0 -70
- package/.agents/scripts/assert-branch.js +0 -81
- package/.agents/scripts/check-loop-units.js +0 -204
- package/.agents/scripts/detect-merges.js +0 -111
- package/.agents/scripts/git-pr-quality-gate.js +0 -205
- package/.agents/scripts/git-rebase-and-resolve.js +0 -234
- package/.agents/scripts/hierarchy-gate.js +0 -192
- package/.agents/scripts/hydrate-context.js +0 -179
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
- package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
- package/.agents/scripts/loc-delta.js +0 -205
- package/.agents/scripts/render-manifest.js +0 -143
- package/.agents/scripts/run-audit-suite.js +0 -97
- package/.agents/scripts/select-audits.js +0 -155
- package/.agents/scripts/update-mutation-baseline.js +0 -189
- package/.agents/workflows/explain.md +0 -118
- package/.agents/workflows/git-merge-pr.md +0 -377
- package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
- package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
- package/.agents/workflows/loops/README.md +0 -65
- package/.agents/workflows/loops/fix-failing-tests.md +0 -74
- package/.agents/workflows/loops/nightly-audit.md +0 -81
- 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
|
+
}
|