@wildorder/nightshift 0.16.0 → 0.18.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 (109) hide show
  1. package/README.md +289 -1
  2. package/dist/agent-runner.d.ts +95 -5
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +442 -32
  5. package/dist/agent-runner.js.map +1 -1
  6. package/dist/agent-session.d.ts +123 -0
  7. package/dist/agent-session.d.ts.map +1 -0
  8. package/dist/agent-session.js +114 -0
  9. package/dist/agent-session.js.map +1 -0
  10. package/dist/atomic-write.d.ts +18 -0
  11. package/dist/atomic-write.d.ts.map +1 -0
  12. package/dist/atomic-write.js +74 -0
  13. package/dist/atomic-write.js.map +1 -0
  14. package/dist/author.d.ts +118 -4
  15. package/dist/author.d.ts.map +1 -1
  16. package/dist/author.js +650 -163
  17. package/dist/author.js.map +1 -1
  18. package/dist/causal-analysis.d.ts +216 -0
  19. package/dist/causal-analysis.d.ts.map +1 -0
  20. package/dist/causal-analysis.js +786 -0
  21. package/dist/causal-analysis.js.map +1 -0
  22. package/dist/cli.js +22 -2
  23. package/dist/cli.js.map +1 -1
  24. package/dist/concurrency.d.ts +24 -0
  25. package/dist/concurrency.d.ts.map +1 -0
  26. package/dist/concurrency.js +28 -0
  27. package/dist/concurrency.js.map +1 -0
  28. package/dist/decider-review.d.ts +13 -1
  29. package/dist/decider-review.d.ts.map +1 -1
  30. package/dist/decider-review.js +21 -7
  31. package/dist/decider-review.js.map +1 -1
  32. package/dist/decision-ledger.d.ts +7 -0
  33. package/dist/decision-ledger.d.ts.map +1 -1
  34. package/dist/decision-ledger.js +25 -10
  35. package/dist/decision-ledger.js.map +1 -1
  36. package/dist/detect-package-manager.d.ts +10 -0
  37. package/dist/detect-package-manager.d.ts.map +1 -1
  38. package/dist/detect-package-manager.js +12 -0
  39. package/dist/detect-package-manager.js.map +1 -1
  40. package/dist/index.d.ts +8 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +8 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/manifest.d.ts +24 -5
  45. package/dist/manifest.d.ts.map +1 -1
  46. package/dist/manifest.js +254 -108
  47. package/dist/manifest.js.map +1 -1
  48. package/dist/permits.d.ts +18 -0
  49. package/dist/permits.d.ts.map +1 -1
  50. package/dist/permits.js +155 -0
  51. package/dist/permits.js.map +1 -1
  52. package/dist/preflight.d.ts +3 -0
  53. package/dist/preflight.d.ts.map +1 -1
  54. package/dist/preflight.js +73 -57
  55. package/dist/preflight.js.map +1 -1
  56. package/dist/prompt-telemetry.d.ts +64 -0
  57. package/dist/prompt-telemetry.d.ts.map +1 -0
  58. package/dist/prompt-telemetry.js +112 -0
  59. package/dist/prompt-telemetry.js.map +1 -0
  60. package/dist/provider-telemetry.d.ts +106 -0
  61. package/dist/provider-telemetry.d.ts.map +1 -0
  62. package/dist/provider-telemetry.js +423 -0
  63. package/dist/provider-telemetry.js.map +1 -0
  64. package/dist/review-pass.d.ts +52 -10
  65. package/dist/review-pass.d.ts.map +1 -1
  66. package/dist/review-pass.js +114 -30
  67. package/dist/review-pass.js.map +1 -1
  68. package/dist/run-analytics-report.d.ts +286 -0
  69. package/dist/run-analytics-report.d.ts.map +1 -0
  70. package/dist/run-analytics-report.js +1242 -0
  71. package/dist/run-analytics-report.js.map +1 -0
  72. package/dist/run-analytics.d.ts +844 -0
  73. package/dist/run-analytics.d.ts.map +1 -0
  74. package/dist/run-analytics.js +777 -0
  75. package/dist/run-analytics.js.map +1 -0
  76. package/dist/run-log.d.ts +16 -0
  77. package/dist/run-log.d.ts.map +1 -0
  78. package/dist/run-log.js +32 -0
  79. package/dist/run-log.js.map +1 -0
  80. package/dist/run-program.d.ts +268 -4
  81. package/dist/run-program.d.ts.map +1 -1
  82. package/dist/run-program.js +2508 -762
  83. package/dist/run-program.js.map +1 -1
  84. package/dist/scheduler.d.ts +75 -0
  85. package/dist/scheduler.d.ts.map +1 -0
  86. package/dist/scheduler.js +126 -0
  87. package/dist/scheduler.js.map +1 -0
  88. package/dist/verify-cache.d.ts +64 -0
  89. package/dist/verify-cache.d.ts.map +1 -0
  90. package/dist/verify-cache.js +56 -0
  91. package/dist/verify-cache.js.map +1 -0
  92. package/dist/whole-program-review.d.ts +3 -0
  93. package/dist/whole-program-review.d.ts.map +1 -1
  94. package/dist/whole-program-review.js +8 -1
  95. package/dist/whole-program-review.js.map +1 -1
  96. package/dist/worktree.d.ts +124 -0
  97. package/dist/worktree.d.ts.map +1 -0
  98. package/dist/worktree.js +397 -0
  99. package/dist/worktree.js.map +1 -0
  100. package/dist/write-queue.d.ts +25 -0
  101. package/dist/write-queue.d.ts.map +1 -0
  102. package/dist/write-queue.js +52 -0
  103. package/dist/write-queue.js.map +1 -0
  104. package/package.json +2 -2
  105. package/skills/plan-program/SKILL.md +37 -5
  106. package/dist/worktree-guard.d.ts +0 -40
  107. package/dist/worktree-guard.d.ts.map +0 -1
  108. package/dist/worktree-guard.js +0 -91
  109. package/dist/worktree-guard.js.map +0 -1
@@ -0,0 +1,777 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { atomicWriteFile } from "./atomic-write.js";
6
+ /**
7
+ * The versioned, provider-neutral run-observation contract (WS-01). This
8
+ * module defines the schema, the stage-to-bucket mapping, the injectable
9
+ * clock, the recorder, and the atomic persistence boundary — it emits no
10
+ * per-stage spans of its own beyond a handful of reference spans proving the
11
+ * API end-to-end (see the WS-01/WS-02 emission-boundary decision). WS-02
12
+ * instruments the full run lifecycle against this contract; WS-03 attaches
13
+ * provider/prompt facts; WS-04 aggregates; WS-05 interprets.
14
+ */
15
+ /** Bumped only when the shape of the finalized artifact changes incompatibly. */
16
+ export const RUN_ANALYTICS_SCHEMA_VERSION = 1;
17
+ /** The closed top-level attribution taxonomy — a new bucket is a plan change. */
18
+ export const BUCKETS = [
19
+ "preflight",
20
+ "authoring",
21
+ "implementation",
22
+ "verification",
23
+ "review-and-decisions",
24
+ "recovery-and-fixes",
25
+ "git-and-persistence",
26
+ "whole-program-review",
27
+ "reporting",
28
+ "unattributed",
29
+ ];
30
+ export const bucketSchema = z.enum(BUCKETS);
31
+ /**
32
+ * The closed set of concrete operations, each mapping to exactly one bucket.
33
+ * WS-02 emits stages; WS-04 reads buckets; both import this exact table so
34
+ * they cannot drift ("same stage, different bucket" is closed by
35
+ * construction). Bar one exception, there is no `unattributed` stage:
36
+ * `unattributed` is normally a residual WS-04 computes as elapsed time minus
37
+ * the union of known top-level spans, never emitted by an instrumentation
38
+ * site. The exception is `"run"` itself (below) — a single, WS-01-owned span
39
+ * opened once per run, never an ad hoc WS-02 filler, whose entire purpose is
40
+ * to prove full temporal coverage from run start onward by parenting every
41
+ * other span; once WS-04 subtracts the union of its children, what remains
42
+ * of its own duration is, by construction, exactly the residual its bucket
43
+ * already names.
44
+ */
45
+ export const STAGE_BUCKET = {
46
+ run: "unattributed",
47
+ "prerequisite-check": "preflight",
48
+ "baseline-verification": "preflight",
49
+ author: "authoring",
50
+ "re-author": "authoring",
51
+ "spec-critique": "authoring",
52
+ implementer: "implementation",
53
+ recovery: "implementation",
54
+ "informed-retry": "implementation",
55
+ "verification-command": "verification",
56
+ "test-critique": "review-and-decisions",
57
+ "decision-review": "review-and-decisions",
58
+ "finding-triage": "review-and-decisions",
59
+ "causal-analysis": "review-and-decisions",
60
+ "failure-diagnosis": "recovery-and-fixes",
61
+ "fix-now": "recovery-and-fixes",
62
+ "test-critique-fix": "recovery-and-fixes",
63
+ "manifest-persist": "git-and-persistence",
64
+ "ledger-persist": "git-and-persistence",
65
+ "analytics-persist": "git-and-persistence",
66
+ "git-diff": "git-and-persistence",
67
+ "git-commit": "git-and-persistence",
68
+ "worktree-setup": "git-and-persistence",
69
+ integration: "git-and-persistence",
70
+ "whole-program-review": "whole-program-review",
71
+ "as-built-snapshot": "whole-program-review",
72
+ "report-assembly": "reporting",
73
+ "report-commit": "reporting",
74
+ };
75
+ const STAGES = Object.keys(STAGE_BUCKET);
76
+ export const stageSchema = z.enum(STAGES);
77
+ /** The observability label every measurement carries; absent telemetry is `unavailable`, never a measured zero. */
78
+ export const COVERAGE_VALUES = ["observed", "estimated", "unavailable", "incomplete"];
79
+ export const coverageSchema = z.enum(COVERAGE_VALUES);
80
+ /**
81
+ * Documented, non-exhaustive outcome vocabulary (SC-04): `outcome` is an open
82
+ * `z.string()` so a future outcome (e.g. a timeout) can be added without a
83
+ * schema version bump. This is documentation and a lint target, never a gate
84
+ * — an unknown outcome string still validates and round-trips.
85
+ */
86
+ export const KNOWN_OUTCOMES = [
87
+ "success",
88
+ "failed",
89
+ "discarded",
90
+ "no-op",
91
+ "skipped",
92
+ "interrupted",
93
+ ];
94
+ /**
95
+ * The fixed scheduling-observation vocabulary (WS-01 SC-10): a workstream
96
+ * becomes ready, is scheduled, or waits, and why. `workstream-ready` and
97
+ * `workstream-scheduled` are instants, emitted at most once per workstream
98
+ * per run; `workstream-waiting` is emitted once per (workstream, cause)
99
+ * pair, when the wait ends, carrying `value` = milliseconds waited. No
100
+ * emitter runs yet in this workstream — WS-04 is the first caller — but the
101
+ * vocabulary, the schema support, and the typed emitters below are final.
102
+ */
103
+ export const SCHEDULING_POINT_KINDS = [
104
+ "workstream-ready",
105
+ "workstream-scheduled",
106
+ "workstream-waiting",
107
+ ];
108
+ /** Open vocabulary, exactly like KNOWN_OUTCOMES: documentation and a lint
109
+ * target, never a gate. A future wait cause is not a schema change. */
110
+ export const KNOWN_WAIT_CAUSES = [
111
+ "dependency",
112
+ "concurrency-cap",
113
+ "integration-mutex",
114
+ /**
115
+ * WS-05: a workstream whose dependencies had all integrated but whose own
116
+ * spec was still being authored. Measured on the build item, from the
117
+ * latest settle of any roster dependency's build item (or the scheduler's
118
+ * start, for a root workstream) to `workstream-ready`. Pre-`ready`, like
119
+ * `dependency`; distinguishes a run that was authoring-bound from one that
120
+ * was dependency-bound, which the critical path (WS-06) needs to tell
121
+ * apart.
122
+ */
123
+ "spec-pending",
124
+ /**
125
+ * WS-05: the authoring half of the shared `--max-parallel` budget,
126
+ * measured on the author item (admissible to launched) rather than the
127
+ * build item — the only wait cause that answers "did the cap delay the
128
+ * *spec*?" A root workstream can accrue this with no `concurrency-cap`
129
+ * point at all, since its build item may never even queue. Nests inside
130
+ * `spec-pending`, never summed with it (see the wait-cause table in
131
+ * `docs/run-analytics.md`).
132
+ */
133
+ "author-concurrency-cap",
134
+ ];
135
+ /** The only evidence locality WS-01 defines: a build-logs reference resolves only on the machine that ran. */
136
+ export const evidenceReferenceSchema = z.object({
137
+ kind: z.enum(["transcript", "ledger-event", "commit", "diagnosis"]),
138
+ locality: z.literal("local"),
139
+ ref: z.string(),
140
+ note: z.string().optional(),
141
+ });
142
+ const agentRoleSchema = z.enum([
143
+ "agent",
144
+ "recoveryAgent",
145
+ "authorAgent",
146
+ "deciderAgent",
147
+ "reviewerAgent",
148
+ ]);
149
+ /** The stable facts attached to an interval or point. Provider/model identity is deliberately absent — WS-03's sole home. */
150
+ export const dimensionsSchema = z.object({
151
+ stage: stageSchema,
152
+ workstream: z.string().optional(),
153
+ role: agentRoleSchema.optional(),
154
+ attemptSeat: z.string().optional(),
155
+ attemptIndex: z.number().optional(),
156
+ attemptReason: z.string().optional(),
157
+ verifyCommand: z.string().optional(),
158
+ outcome: z.string().optional(),
159
+ evidence: z.array(evidenceReferenceSchema).optional(),
160
+ /** Present only on a `workstream-waiting` point (open vocabulary; see {@link KNOWN_WAIT_CAUSES}). */
161
+ waitCause: z.string().optional(),
162
+ });
163
+ const partialDimensionsSchema = dimensionsSchema.partial();
164
+ /**
165
+ * An interval observation — the unit that carries wall-clock time. The
166
+ * schema permits overlapping spans (a parent and its children overlap by
167
+ * construction); WS-01 validates only the local invariants (bucket matches
168
+ * stage, end >= start when present, parentId resolves within the artifact).
169
+ * WS-04 owns interval-union conservation and rejecting double-counted
170
+ * overlaps at the top level.
171
+ */
172
+ export const spanSchema = z
173
+ .object({
174
+ id: z.string(),
175
+ parentId: z.string().optional(),
176
+ stage: stageSchema,
177
+ bucket: bucketSchema,
178
+ startOffsetMs: z.number(),
179
+ /**
180
+ * Absent means this span's true end was not observed by finalize time.
181
+ * Usually that is a crashed/interrupted span, but the runner's own
182
+ * top-level `run` span and its `report-commit` child are open by design
183
+ * on every run, crashed or not: both close only after `finalize()` has
184
+ * already persisted this very artifact, so their real end can never be
185
+ * captured inside the file they are committed in.
186
+ */
187
+ endOffsetMs: z.number().optional(),
188
+ dimensions: dimensionsSchema,
189
+ })
190
+ .refine((span) => STAGE_BUCKET[span.stage] === span.bucket, {
191
+ message: "bucket must equal STAGE_BUCKET[stage]",
192
+ path: ["bucket"],
193
+ })
194
+ .refine((span) => span.endOffsetMs === undefined || span.endOffsetMs >= span.startOffsetMs, { message: "endOffsetMs must be >= startOffsetMs", path: ["endOffsetMs"] });
195
+ /**
196
+ * A fact measured at an instant, not an interval. `kind` is an open string
197
+ * for the same forward-compatibility reason as `outcome`.
198
+ *
199
+ * `label` and `detail` are WS-03's sole, additive amendment to this schema
200
+ * (see the `scalar-points-plus-additive-fields` decision): `label` names the
201
+ * specific metric or prompt component a point measures (e.g.
202
+ * `"input-tokens"`, `"model"`, `"roster"`), and `detail` carries a
203
+ * string-valued fact — an exact model identity, a provider label, a
204
+ * comma-joined demoted-dependency id list — when the measurement is not
205
+ * numeric. Both are optional and unconstrained by an enum, so every existing
206
+ * point (and every WS-01 fixture) round-trips unchanged.
207
+ */
208
+ export const pointObservationSchema = z.object({
209
+ id: z.string(),
210
+ atOffsetMs: z.number(),
211
+ kind: z.string(),
212
+ coverage: coverageSchema,
213
+ value: z.number().optional(),
214
+ unit: z.string().optional(),
215
+ label: z.string().optional(),
216
+ detail: z.string().optional(),
217
+ dimensions: partialDimensionsSchema.optional(),
218
+ evidence: z.array(evidenceReferenceSchema).optional(),
219
+ });
220
+ /**
221
+ * States the window the reconciled numbers cover: run start through the
222
+ * pre-render analytics snapshot taken just before `finalize()` persists this
223
+ * artifact and the report is written and committed (SC-03, SC-14). The
224
+ * report-write/commit tail is unobservable from inside the artifact — the
225
+ * `run` and `report-commit` spans stay open and the tail lands as a sidecar
226
+ * point — and publish runs after the CLI's run call returns. Adopted by
227
+ * human ruling (decisions 062f9cc308fe22fa, 987817d69c457259).
228
+ */
229
+ export const AGGREGATION_BOUNDARY = "run-start-to-pre-render-snapshot";
230
+ export const runAnalyticsSchema = z
231
+ .object({
232
+ // A literal, not z.number(): SC-01 promises one validated versioned
233
+ // contract, so an artifact stamped with any other schemaVersion — an
234
+ // unsupported future format, a corrupted file, a stale format after a
235
+ // real bump — fails validation outright rather than silently parsing as
236
+ // if it were this version. readPriorRunId relies on exactly this to
237
+ // never adopt lineage from a schema it does not understand.
238
+ schemaVersion: z.literal(RUN_ANALYTICS_SCHEMA_VERSION),
239
+ identity: z.object({
240
+ runId: z.string(),
241
+ parentRunId: z.string().optional(),
242
+ programId: z.string(),
243
+ }),
244
+ anchors: z.object({
245
+ startedAt: z.string(),
246
+ monotonicAnchorMs: z.number(),
247
+ finalizedAt: z.string().optional(),
248
+ finalizedOffsetMs: z.number().optional(),
249
+ }),
250
+ aggregationBoundary: z.literal(AGGREGATION_BOUNDARY),
251
+ spans: z.array(spanSchema),
252
+ points: z.array(pointObservationSchema),
253
+ })
254
+ .superRefine((artifact, ctx) => {
255
+ const ids = new Set(artifact.spans.map((span) => span.id));
256
+ artifact.spans.forEach((span, index) => {
257
+ if (span.parentId !== undefined && !ids.has(span.parentId)) {
258
+ ctx.addIssue({
259
+ code: z.ZodIssueCode.custom,
260
+ message: `parentId "${span.parentId}" does not resolve to another span in this artifact`,
261
+ path: ["spans", index, "parentId"],
262
+ });
263
+ }
264
+ });
265
+ const spanById = new Map(artifact.spans.map((span) => [span.id, span]));
266
+ // Cycle detection over the raw `parentId` graph, deliberately independent
267
+ // of `dimensions.workstream`. A walk that only recurses while resolving
268
+ // *effective* ownership (below) stops at the first span in the chain
269
+ // that names its own workstream — so an a<->b cycle where both spans
270
+ // explicitly name the same workstream would never be walked far enough
271
+ // to be noticed (see WS-01 test-critique). This pass never looks at
272
+ // dimensions: it only asks whether following `parentId` from a span
273
+ // returns to a span already on the current path, and is memoized via
274
+ // `resolved` so no span's chain is walked more than once overall.
275
+ const cyclicSpanIds = new Set();
276
+ const resolved = new Set();
277
+ for (const span of artifact.spans) {
278
+ if (resolved.has(span.id))
279
+ continue;
280
+ const path = [];
281
+ let currentId = span.id;
282
+ while (currentId !== undefined && spanById.has(currentId) && !resolved.has(currentId)) {
283
+ const seenAt = path.indexOf(currentId);
284
+ if (seenAt !== -1) {
285
+ for (const id of path.slice(seenAt))
286
+ cyclicSpanIds.add(id);
287
+ break;
288
+ }
289
+ path.push(currentId);
290
+ currentId = spanById.get(currentId)?.parentId;
291
+ }
292
+ for (const id of path)
293
+ resolved.add(id);
294
+ }
295
+ artifact.spans.forEach((span, index) => {
296
+ if (cyclicSpanIds.has(span.id)) {
297
+ ctx.addIssue({
298
+ code: z.ZodIssueCode.custom,
299
+ message: `span "${span.id}" is part of a parentId cycle`,
300
+ path: ["spans", index, "parentId"],
301
+ });
302
+ }
303
+ });
304
+ // The effective-workstream rule (WS-01 §3.3, shared clause of SC-09): a
305
+ // span whose own `dimensions.workstream` is defined and whose nearest
306
+ // ancestor with a *defined* workstream names a *different* one is
307
+ // invalid — closed over an unattributed intermediary too, so a
308
+ // WS-01 → unattributed → WS-02 chain is rejected, not just a direct
309
+ // WS-01 → WS-02 one. Cycle safety is handled entirely by the pass above;
310
+ // any span not in `cyclicSpanIds` has a finite, acyclic parent chain, so
311
+ // this recursion always terminates.
312
+ const effectiveCache = new Map();
313
+ const effectiveWorkstreamOf = (id) => {
314
+ if (effectiveCache.has(id))
315
+ return effectiveCache.get(id);
316
+ if (cyclicSpanIds.has(id)) {
317
+ effectiveCache.set(id, undefined);
318
+ return undefined;
319
+ }
320
+ const span = spanById.get(id);
321
+ if (span === undefined)
322
+ return undefined;
323
+ if (span.dimensions.workstream !== undefined) {
324
+ effectiveCache.set(id, span.dimensions.workstream);
325
+ return span.dimensions.workstream;
326
+ }
327
+ if (span.parentId === undefined || !spanById.has(span.parentId)) {
328
+ effectiveCache.set(id, undefined);
329
+ return undefined;
330
+ }
331
+ const parentEffective = effectiveWorkstreamOf(span.parentId);
332
+ effectiveCache.set(id, parentEffective);
333
+ return parentEffective;
334
+ };
335
+ artifact.spans.forEach((span, index) => {
336
+ if (span.parentId === undefined || !ids.has(span.parentId) || cyclicSpanIds.has(span.id)) {
337
+ return;
338
+ }
339
+ const parentEffective = effectiveWorkstreamOf(span.parentId);
340
+ if (span.dimensions.workstream !== undefined &&
341
+ parentEffective !== undefined &&
342
+ parentEffective !== span.dimensions.workstream) {
343
+ ctx.addIssue({
344
+ code: z.ZodIssueCode.custom,
345
+ message: `span "${span.id}" (workstream "${span.dimensions.workstream}") is nested ` +
346
+ `under a span whose effective workstream is "${parentEffective}"`,
347
+ path: ["spans", index, "dimensions", "workstream"],
348
+ });
349
+ }
350
+ });
351
+ });
352
+ /** Mirrors `runReportPath` — the one canonical location for a program's analytics artifact. */
353
+ export function runAnalyticsPath(root, programId) {
354
+ return join(resolve(root), "docs", "programs", `${programId}-run-analytics.json`);
355
+ }
356
+ /** The gitignored crash-durability sidecar (see the incremental-durability decision); never read back by this module. */
357
+ function runAnalyticsSidecarPath(root, programId) {
358
+ return join(resolve(root), "build-logs", programId, "run-analytics.jsonl");
359
+ }
360
+ function randomId() {
361
+ return randomBytes(8).toString("hex");
362
+ }
363
+ /**
364
+ * Reads any prior finalized artifact for `programId` purely to source
365
+ * `parentRunId` — a plain read with no durable effect, safe before the
366
+ * could-not-start gate. Absent, unreadable, or invalid prior artifacts all
367
+ * resolve to `undefined` (fail open): a missing lineage is not a reason to
368
+ * fail a run.
369
+ */
370
+ export async function readPriorRunId(root, programId) {
371
+ try {
372
+ const raw = await readFile(runAnalyticsPath(root, programId), "utf8");
373
+ const parsed = runAnalyticsSchema.safeParse(JSON.parse(raw));
374
+ return parsed.success ? parsed.data.identity.runId : undefined;
375
+ }
376
+ catch {
377
+ return undefined;
378
+ }
379
+ }
380
+ /**
381
+ * Records a run's spans and point observations and persists them
382
+ * fail-open. Created at run start so pre-gate stages (preflight, baseline
383
+ * verification) can be buffered in memory; stays disarmed — writing nothing
384
+ * durable — until `arm()` is called immediately after the could-not-start
385
+ * gate. Every method is non-throwing: a malformed observation is diagnosed
386
+ * and dropped, a persistence failure is diagnosed and swallowed, and nothing
387
+ * here can change a run's outcome, exit code, status, or report.
388
+ */
389
+ export class RunRecorder {
390
+ runId;
391
+ /** `RunRecorder` is a degenerate, unscoped `SpanScope` — always `undefined`; see the interface doc comment. */
392
+ workstream = undefined;
393
+ clock;
394
+ root;
395
+ programId;
396
+ parentRunIdValue;
397
+ log;
398
+ monotonicAnchorMs;
399
+ startedAt;
400
+ permanentlyDisarmed;
401
+ armed = false;
402
+ finalized = false;
403
+ spans = [];
404
+ points = [];
405
+ openStack = [];
406
+ sidecarQueue = Promise.resolve();
407
+ // Metadata for every span ever opened (open or closed), keyed by id — the
408
+ // effective-workstream guard (§3.3) needs to walk a parent chain even
409
+ // after an ancestor has closed. `effectiveWorkstream` is computed and
410
+ // cached once, at open time, from the span's own dimensions and its
411
+ // resolved parent's stored value — a span's parent never changes after
412
+ // it is opened, so the cached value cannot go stale.
413
+ spanMeta = new Map();
414
+ constructor(opts) {
415
+ this.clock = opts.clock;
416
+ this.root = opts.root;
417
+ this.programId = opts.programId;
418
+ this.parentRunIdValue = opts.parentRunId;
419
+ this.log = opts.log;
420
+ this.permanentlyDisarmed = opts.permanentlyDisarmed ?? false;
421
+ this.startedAt = opts.clock.now().toISOString();
422
+ this.runId = this.startedAt;
423
+ this.monotonicAnchorMs = opts.clock.monotonic();
424
+ }
425
+ static create(opts) {
426
+ return new RunRecorder(opts);
427
+ }
428
+ /**
429
+ * Enables durable writes. Idempotent; intended to be called once,
430
+ * immediately after the could-not-start gate. A no-op on a
431
+ * permanently-disarmed instance ({@link NOOP_RUN_RECORDER}) — WS-02's
432
+ * `RunProgramOptions.recorder` test seam lets a caller inject that shared
433
+ * singleton straight into a real run, and `runProgramWith` calls `arm()`
434
+ * unconditionally on whatever recorder it holds, so this guard is what
435
+ * keeps that injection from durably arming — and thereby writing through
436
+ * — the one instance every other no-recorder caller also depends on
437
+ * staying inert.
438
+ */
439
+ arm() {
440
+ if (this.permanentlyDisarmed)
441
+ return;
442
+ this.armed = true;
443
+ }
444
+ /** Opens a span; the currently open span (if any) becomes its parent automatically. */
445
+ span(dims) {
446
+ return this.openSpan(dims, this.openStack[this.openStack.length - 1]?.id);
447
+ }
448
+ point(obs) {
449
+ try {
450
+ const point = {
451
+ id: randomId(),
452
+ atOffsetMs: this.elapsedMs(),
453
+ kind: obs.kind,
454
+ coverage: obs.coverage,
455
+ ...(obs.value === undefined ? {} : { value: obs.value }),
456
+ ...(obs.unit === undefined ? {} : { unit: obs.unit }),
457
+ ...(obs.label === undefined ? {} : { label: obs.label }),
458
+ ...(obs.detail === undefined ? {} : { detail: obs.detail }),
459
+ ...(obs.dimensions === undefined ? {} : { dimensions: obs.dimensions }),
460
+ ...(obs.evidence === undefined ? {} : { evidence: obs.evidence }),
461
+ };
462
+ const parsed = pointObservationSchema.safeParse(point);
463
+ if (!parsed.success) {
464
+ this.diagnose(`dropped malformed point observation: ${parsed.error.message}`);
465
+ return;
466
+ }
467
+ this.points.push(parsed.data);
468
+ this.appendSidecar({ kind: "point", point: parsed.data });
469
+ }
470
+ catch (error) {
471
+ this.diagnose(`point record failed: ${error.message}`);
472
+ }
473
+ }
474
+ /**
475
+ * Builds the in-memory buffer (including any span still open — a
476
+ * crashed/interrupted span composes with no `endOffsetMs`) into a
477
+ * `RunAnalytics` shape at the given wall/monotonic instant. Shared by
478
+ * `finalize()` (which persists the result) and `snapshot()` (which does
479
+ * not), so the two can never independently drift on what "the observed
480
+ * artifact so far" means.
481
+ */
482
+ compose(finalizedAt, finalizedOffsetMs) {
483
+ return {
484
+ schemaVersion: RUN_ANALYTICS_SCHEMA_VERSION,
485
+ identity: {
486
+ runId: this.runId,
487
+ ...(this.parentRunIdValue === undefined ? {} : { parentRunId: this.parentRunIdValue }),
488
+ programId: this.programId,
489
+ },
490
+ anchors: {
491
+ startedAt: this.startedAt,
492
+ monotonicAnchorMs: this.monotonicAnchorMs,
493
+ finalizedAt,
494
+ finalizedOffsetMs,
495
+ },
496
+ aggregationBoundary: AGGREGATION_BOUNDARY,
497
+ spans: [...this.spans, ...this.openSpanSnapshots()],
498
+ points: this.points,
499
+ };
500
+ }
501
+ /**
502
+ * Composes the in-memory buffer into a `RunAnalytics` object, validates
503
+ * it, and atomically persists it. Fail-open: never throws. A no-op when
504
+ * not armed, or when already finalized once.
505
+ */
506
+ async finalize() {
507
+ if (!this.armed || this.finalized)
508
+ return;
509
+ this.finalized = true;
510
+ try {
511
+ await this.sidecarQueue.catch(() => undefined);
512
+ const artifact = this.compose(this.clock.now().toISOString(), this.elapsedMs());
513
+ const parsed = runAnalyticsSchema.safeParse(artifact);
514
+ if (!parsed.success) {
515
+ this.diagnose(`analytics artifact failed validation and was not written: ${parsed.error.message}`);
516
+ return;
517
+ }
518
+ await atomicWriteFile(runAnalyticsPath(this.root, this.programId), `${JSON.stringify(parsed.data, null, 2)}\n`, { tempDir: join(resolve(this.root), "build-logs", this.programId, "tmp") });
519
+ }
520
+ catch (error) {
521
+ this.diagnose(`analytics finalize failed: ${error.message}`);
522
+ }
523
+ }
524
+ /**
525
+ * A validated in-memory view of everything observed so far, for the run
526
+ * report's "Where the time went" section (WS-04) — which renders before
527
+ * `finalize()` runs, so it cannot read the file `finalize()` writes.
528
+ * Read-only: composes and validates exactly as `finalize()` does but
529
+ * writes nothing, to the canonical artifact or the sidecar, and is safe to
530
+ * call before `arm()`, after `finalize()`, or any number of times.
531
+ * Non-throwing; returns `undefined` on any failure (fail open).
532
+ */
533
+ snapshot() {
534
+ try {
535
+ const artifact = this.compose(this.clock.now().toISOString(), this.elapsedMs());
536
+ const parsed = runAnalyticsSchema.safeParse(artifact);
537
+ return parsed.success ? parsed.data : undefined;
538
+ }
539
+ catch {
540
+ return undefined;
541
+ }
542
+ }
543
+ elapsedMs() {
544
+ return this.clock.monotonic() - this.monotonicAnchorMs;
545
+ }
546
+ /**
547
+ * Resolves the parent for a new span, explicit or implicit (§3.3). Starts
548
+ * from `requestedParentId` (a scope's own anchor span, or — for the
549
+ * implicit path — whatever is currently open on the stack). While the new
550
+ * span's `workstream` is defined, the candidate's *effective* workstream
551
+ * (walking through unattributed intermediaries) is defined, and the two
552
+ * differ, walks to the candidate's own parent. The first compatible
553
+ * ancestor is the parent; if the walk runs out, the span has none. A walk
554
+ * that actually moves the parent emits one diagnostic naming both stages
555
+ * and both workstreams.
556
+ */
557
+ resolveParent(requestedParentId, dims) {
558
+ let candidateId = requestedParentId;
559
+ let walked = false;
560
+ while (dims.workstream !== undefined && candidateId !== undefined) {
561
+ const meta = this.spanMeta.get(candidateId);
562
+ if (meta === undefined ||
563
+ meta.effectiveWorkstream === undefined ||
564
+ meta.effectiveWorkstream === dims.workstream) {
565
+ break;
566
+ }
567
+ walked = true;
568
+ candidateId = meta.parentId;
569
+ }
570
+ if (walked && requestedParentId !== undefined) {
571
+ const from = this.spanMeta.get(requestedParentId);
572
+ this.diagnose(`span "${dims.stage}" (workstream "${dims.workstream}") would have nested under ` +
573
+ `"${from?.stage}" (workstream "${from?.effectiveWorkstream}") by timing accident; ` +
574
+ `reparented to the nearest compatible ancestor. A concurrent call site likely ` +
575
+ "forgot SpanHandle.scope().");
576
+ }
577
+ return candidateId;
578
+ }
579
+ openSpan(dims, parentId) {
580
+ try {
581
+ const resolvedParentId = this.resolveParent(parentId, dims);
582
+ const id = randomId();
583
+ const effectiveWorkstream = dims.workstream ??
584
+ (resolvedParentId === undefined ? undefined : this.spanMeta.get(resolvedParentId)?.effectiveWorkstream);
585
+ this.spanMeta.set(id, { parentId: resolvedParentId, stage: dims.stage, effectiveWorkstream });
586
+ const open = {
587
+ id,
588
+ ...(resolvedParentId === undefined ? {} : { parentId: resolvedParentId }),
589
+ stage: dims.stage,
590
+ bucket: STAGE_BUCKET[dims.stage],
591
+ startOffsetMs: this.elapsedMs(),
592
+ dimensions: dims,
593
+ };
594
+ this.openStack.push(open);
595
+ let closed = false;
596
+ return {
597
+ close: (result) => {
598
+ if (closed)
599
+ return;
600
+ closed = true;
601
+ this.closeSpan(open, result);
602
+ },
603
+ child: (childDims) => this.openSpan(childDims, open.id),
604
+ scope: (options) => this.makeScope(open.id, options?.workstream),
605
+ };
606
+ }
607
+ catch (error) {
608
+ this.diagnose(`span open failed: ${error.message}`);
609
+ return this.inertHandle();
610
+ }
611
+ }
612
+ inertHandle() {
613
+ return {
614
+ close: () => undefined,
615
+ child: () => this.inertHandle(),
616
+ scope: () => this.inertScope(),
617
+ };
618
+ }
619
+ /**
620
+ * Builds a `SpanScope` anchored to `anchorSpanId` (a specific span's id,
621
+ * or `undefined` for the recorder's own top-level scope), stamping
622
+ * `workstream` onto every span/point it opens whose own dimensions omit
623
+ * it — never overriding a dimension the call site supplied.
624
+ */
625
+ makeScope(anchorSpanId, workstream) {
626
+ return {
627
+ workstream,
628
+ span: (dims) => {
629
+ const stamped = workstream !== undefined && dims.workstream === undefined
630
+ ? { ...dims, workstream }
631
+ : dims;
632
+ return this.openSpan(stamped, anchorSpanId);
633
+ },
634
+ point: (obs) => {
635
+ const stamped = workstream !== undefined && obs.dimensions?.workstream === undefined
636
+ ? { ...obs, dimensions: { ...(obs.dimensions ?? {}), workstream } }
637
+ : obs;
638
+ this.point(stamped);
639
+ },
640
+ };
641
+ }
642
+ inertScope() {
643
+ return {
644
+ workstream: undefined,
645
+ span: () => this.inertHandle(),
646
+ point: () => undefined,
647
+ };
648
+ }
649
+ closeSpan(open, result) {
650
+ try {
651
+ const dimensions = {
652
+ ...open.dimensions,
653
+ ...(result?.outcome === undefined ? {} : { outcome: result.outcome }),
654
+ ...(result?.evidence === undefined ? {} : { evidence: result.evidence }),
655
+ };
656
+ const span = {
657
+ id: open.id,
658
+ ...(open.parentId === undefined ? {} : { parentId: open.parentId }),
659
+ stage: open.stage,
660
+ bucket: open.bucket,
661
+ startOffsetMs: open.startOffsetMs,
662
+ endOffsetMs: this.elapsedMs(),
663
+ dimensions,
664
+ };
665
+ const parsed = spanSchema.safeParse(span);
666
+ if (!parsed.success) {
667
+ this.diagnose(`dropped malformed span: ${parsed.error.message}`);
668
+ }
669
+ else {
670
+ this.spans.push(parsed.data);
671
+ this.appendSidecar({ kind: "span", span: parsed.data });
672
+ }
673
+ }
674
+ catch (error) {
675
+ this.diagnose(`span close failed: ${error.message}`);
676
+ }
677
+ finally {
678
+ const index = this.openStack.indexOf(open);
679
+ if (index >= 0)
680
+ this.openStack.splice(index, 1);
681
+ }
682
+ }
683
+ openSpanSnapshots() {
684
+ return this.openStack.map((open) => ({
685
+ id: open.id,
686
+ ...(open.parentId === undefined ? {} : { parentId: open.parentId }),
687
+ stage: open.stage,
688
+ bucket: open.bucket,
689
+ startOffsetMs: open.startOffsetMs,
690
+ dimensions: open.dimensions,
691
+ }));
692
+ }
693
+ /**
694
+ * Appends one JSON line to the gitignored `build-logs/` sidecar — the
695
+ * crash-durability layer for a hard kill that never reaches the flush
696
+ * seam. Queued so concurrent closes/points never interleave partial
697
+ * writes; a failing append is diagnosed and never breaks the queue for
698
+ * subsequent appends.
699
+ */
700
+ appendSidecar(entry) {
701
+ if (!this.armed)
702
+ return;
703
+ const path = runAnalyticsSidecarPath(this.root, this.programId);
704
+ const line = `${JSON.stringify(entry)}\n`;
705
+ this.sidecarQueue = this.sidecarQueue.then(async () => {
706
+ await mkdir(dirname(path), { recursive: true });
707
+ await appendFile(path, line, "utf8");
708
+ });
709
+ this.sidecarQueue = this.sidecarQueue.catch((error) => {
710
+ this.diagnose(`sidecar append failed: ${error.message}`);
711
+ });
712
+ }
713
+ diagnose(message) {
714
+ try {
715
+ this.log(`run-analytics: ${message}`);
716
+ }
717
+ catch {
718
+ // Logging must never be the reason an analytics failure escapes fail-open.
719
+ }
720
+ }
721
+ }
722
+ /**
723
+ * A shared, permanently disarmed recorder — the default for callers that
724
+ * have no run-scoped recorder to hand (a standalone test, the doctor probe,
725
+ * a fix/critique path WS-02 has not yet wired), and a valid injection
726
+ * straight into `RunProgramOptions.recorder` (WS-02, SC-13) for a test that
727
+ * wants to prove fail-open by equivalence. Buffers harmlessly in memory and
728
+ * never performs I/O: `permanentlyDisarmed` makes `arm()` a no-op even when
729
+ * a real run calls it unconditionally, so this one shared instance can never
730
+ * be armed by any caller, ever.
731
+ */
732
+ export const NOOP_RUN_RECORDER = RunRecorder.create({
733
+ clock: { now: () => new Date(0), monotonic: () => 0 },
734
+ root: "",
735
+ programId: "",
736
+ log: () => undefined,
737
+ permanentlyDisarmed: true,
738
+ });
739
+ /**
740
+ * Typed emitters for the scheduling-observation vocabulary (§3.4). They
741
+ * take the workstream id explicitly rather than reading it off `scope`: a
742
+ * workstream becomes ready, and waits, *before* its own flow scope exists —
743
+ * these are called from the run-level scope with the subject named. No
744
+ * emitter runs yet in this workstream; WS-04 is the first caller.
745
+ */
746
+ /** Emitted once per workstream per run, the instant its dependencies are integrated, its spec is complete, and it is not blocked. */
747
+ export function recordWorkstreamReady(scope, workstream) {
748
+ scope.point({
749
+ kind: "workstream-ready",
750
+ coverage: "observed",
751
+ dimensions: { workstream },
752
+ });
753
+ }
754
+ /** Emitted once per workstream per run, the instant its flow actually launches. */
755
+ export function recordWorkstreamScheduled(scope, workstream) {
756
+ scope.point({
757
+ kind: "workstream-scheduled",
758
+ coverage: "observed",
759
+ dimensions: { workstream },
760
+ });
761
+ }
762
+ /**
763
+ * Emitted when a wait *ends*, once per (workstream, cause) pair —
764
+ * `wait.waitedMs` is the milliseconds spent waiting on that cause. A
765
+ * workstream that waited on two causes emits two points; their sum should
766
+ * approximate `scheduled − ready`.
767
+ */
768
+ export function recordWorkstreamWaiting(scope, workstream, wait) {
769
+ scope.point({
770
+ kind: "workstream-waiting",
771
+ coverage: "observed",
772
+ value: wait.waitedMs,
773
+ unit: "ms",
774
+ dimensions: { workstream, waitCause: wait.cause },
775
+ });
776
+ }
777
+ //# sourceMappingURL=run-analytics.js.map