@wildorder/nightshift 0.16.0 → 0.17.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 (50) hide show
  1. package/README.md +20 -0
  2. package/dist/agent-runner.d.ts +56 -4
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +253 -28
  5. package/dist/agent-runner.js.map +1 -1
  6. package/dist/author.d.ts +8 -0
  7. package/dist/author.d.ts.map +1 -1
  8. package/dist/author.js +84 -57
  9. package/dist/author.js.map +1 -1
  10. package/dist/causal-analysis.d.ts +212 -0
  11. package/dist/causal-analysis.d.ts.map +1 -0
  12. package/dist/causal-analysis.js +733 -0
  13. package/dist/causal-analysis.js.map +1 -0
  14. package/dist/decider-review.d.ts +6 -1
  15. package/dist/decider-review.d.ts.map +1 -1
  16. package/dist/decider-review.js +19 -7
  17. package/dist/decider-review.js.map +1 -1
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/preflight.d.ts +3 -0
  23. package/dist/preflight.d.ts.map +1 -1
  24. package/dist/preflight.js +73 -57
  25. package/dist/preflight.js.map +1 -1
  26. package/dist/prompt-telemetry.d.ts +64 -0
  27. package/dist/prompt-telemetry.d.ts.map +1 -0
  28. package/dist/prompt-telemetry.js +112 -0
  29. package/dist/prompt-telemetry.js.map +1 -0
  30. package/dist/provider-telemetry.d.ts +106 -0
  31. package/dist/provider-telemetry.d.ts.map +1 -0
  32. package/dist/provider-telemetry.js +423 -0
  33. package/dist/provider-telemetry.js.map +1 -0
  34. package/dist/run-analytics-report.d.ts +173 -0
  35. package/dist/run-analytics-report.d.ts.map +1 -0
  36. package/dist/run-analytics-report.js +650 -0
  37. package/dist/run-analytics-report.js.map +1 -0
  38. package/dist/run-analytics.d.ts +738 -0
  39. package/dist/run-analytics.d.ts.map +1 -0
  40. package/dist/run-analytics.js +545 -0
  41. package/dist/run-analytics.js.map +1 -0
  42. package/dist/run-program.d.ts +69 -1
  43. package/dist/run-program.d.ts.map +1 -1
  44. package/dist/run-program.js +1254 -714
  45. package/dist/run-program.js.map +1 -1
  46. package/dist/whole-program-review.d.ts +3 -0
  47. package/dist/whole-program-review.d.ts.map +1 -1
  48. package/dist/whole-program-review.js +8 -1
  49. package/dist/whole-program-review.js.map +1 -1
  50. package/package.json +2 -2
@@ -0,0 +1,733 @@
1
+ import { z } from "zod";
2
+ import { invokeAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
3
+ import { resolveSummary, summaryContract } from "./agent-summary.js";
4
+ import { NOOP_PERMITS_CONTEXT } from "./permits.js";
5
+ import { clippedInputPoint, createBriefBuilder, promptComponentSizePoints, } from "./prompt-telemetry.js";
6
+ import { NOOP_RUN_RECORDER } from "./run-analytics.js";
7
+ import { aggregateRunAnalytics } from "./run-analytics-report.js";
8
+ /**
9
+ * WS-05: an optional, independent, read-only causal-analysis pass, spawned
10
+ * through the existing `reviewerAgent`, that traces measured run costs
11
+ * (WS-04's `RunAnalyticsSummary`) toward planner-actionable causes — every
12
+ * step cited against retained evidence the run actually observed, and an
13
+ * unresolved result whenever the evidence runs out. Structured after
14
+ * `whole-program-review.ts`: a brief builder, a total non-throwing response
15
+ * parser, a deterministic evidence gate, a fail-open runner, and a pure
16
+ * renderer. Nothing here computes a duration, grades the run, or mutates
17
+ * anything — it consumes WS-04's numbers and cites them by handle.
18
+ */
19
+ // ---------------------------------------------------------------------------
20
+ // Shared formatting (duplicated from run-analytics-report.ts — that module is
21
+ // WS-04's, not this workstream's, and importing its private, unexported
22
+ // helpers is not an option; the same duplication precedent already exists
23
+ // between whole-program-review.ts and run-program.ts for TEST_CRITIQUE_DIFF_LIMIT).
24
+ // ---------------------------------------------------------------------------
25
+ function formatDuration(ms) {
26
+ if (!Number.isFinite(ms) || ms < 0)
27
+ ms = 0;
28
+ if (ms < 1000)
29
+ return `${Math.round(ms)}ms`;
30
+ const totalSeconds = ms / 1000;
31
+ if (totalSeconds < 60)
32
+ return `${totalSeconds.toFixed(1)}s`;
33
+ const minutes = Math.floor(totalSeconds / 60);
34
+ const seconds = Math.round(totalSeconds - minutes * 60);
35
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
36
+ }
37
+ function plural(count, noun) {
38
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
39
+ }
40
+ /** Combines every workstream's spec content into one labelled block, ready for the caller to clip like the diff and program narrative. Pure. */
41
+ export function combineWorkstreamSpecs(specs) {
42
+ if (specs.length === 0)
43
+ return "";
44
+ return specs
45
+ .map((entry) => `#### ${entry.id} ${entry.name} (\`${entry.taskFile}\`)\n\n` +
46
+ (entry.spec && entry.spec.trim() !== ""
47
+ ? entry.spec.trim()
48
+ : "(this workstream's spec file could not be read)"))
49
+ .join("\n\n");
50
+ }
51
+ /** span, point, evidence, and ledger-event handles are concrete observations; bucket/workstream/verify-command are category labels — context, never support on their own (D2). */
52
+ export function isConcreteHandleKind(kind) {
53
+ return kind === "span" || kind === "point" || kind === "evidence" || kind === "ledger-event";
54
+ }
55
+ function spanDescription(span) {
56
+ const duration = span.endOffsetMs === undefined
57
+ ? "open (no recorded end)"
58
+ : formatDuration(span.endOffsetMs - span.startOffsetMs);
59
+ const parts = [`stage=${span.stage}`, `bucket=${span.bucket}`, `duration=${duration}`];
60
+ const dims = span.dimensions;
61
+ if (dims.workstream !== undefined)
62
+ parts.push(`workstream=${dims.workstream}`);
63
+ if (dims.role !== undefined)
64
+ parts.push(`role=${dims.role}`);
65
+ if (dims.attemptSeat !== undefined)
66
+ parts.push(`attemptSeat=${dims.attemptSeat}`);
67
+ if (dims.attemptIndex !== undefined)
68
+ parts.push(`attemptIndex=${dims.attemptIndex}`);
69
+ if (dims.attemptReason !== undefined)
70
+ parts.push(`attemptReason=${dims.attemptReason}`);
71
+ if (dims.verifyCommand !== undefined)
72
+ parts.push(`verifyCommand=${dims.verifyCommand}`);
73
+ if (dims.outcome !== undefined)
74
+ parts.push(`outcome=${dims.outcome}`);
75
+ return parts.join(", ");
76
+ }
77
+ function pointDescription(point) {
78
+ const parts = [`kind=${point.kind}`];
79
+ if (point.label !== undefined)
80
+ parts.push(`label=${point.label}`);
81
+ if (point.value !== undefined)
82
+ parts.push(`value=${point.value}${point.unit ? point.unit : ""}`);
83
+ if (point.detail !== undefined)
84
+ parts.push(`detail=${point.detail}`);
85
+ parts.push(`coverage=${point.coverage}`);
86
+ return parts.join(", ");
87
+ }
88
+ /**
89
+ * Builds the citable-handle registry from what this run actually observed
90
+ * (D2): every span and point id in `snapshot`, every `EvidenceReference.ref`
91
+ * carried on them, every ledger event id, and — only when the run actually
92
+ * has a span under them — bucket, workstream, and verify-command category
93
+ * labels sourced from `summary`'s own already-observed groupings. Pure,
94
+ * deterministic; never a static vocabulary dump.
95
+ */
96
+ export function buildHandleRegistry(snapshot, summary, ledger) {
97
+ const registry = new Map();
98
+ for (const span of snapshot.spans) {
99
+ registry.set(span.id, { kind: "span", value: spanDescription(span) });
100
+ for (const ref of span.dimensions.evidence ?? []) {
101
+ registry.set(ref.ref, {
102
+ kind: "evidence",
103
+ value: `${ref.kind} evidence (local)${ref.note ? ` — ${ref.note}` : ""}`,
104
+ });
105
+ }
106
+ }
107
+ for (const point of snapshot.points) {
108
+ registry.set(point.id, { kind: "point", value: pointDescription(point) });
109
+ for (const ref of point.evidence ?? []) {
110
+ registry.set(ref.ref, {
111
+ kind: "evidence",
112
+ value: `${ref.kind} evidence (local)${ref.note ? ` — ${ref.note}` : ""}`,
113
+ });
114
+ }
115
+ }
116
+ for (const decision of ledger.decisions) {
117
+ registry.set(decision.id, {
118
+ kind: "ledger-event",
119
+ value: `decision "${decision.decision.title}" (${decision.status})`,
120
+ });
121
+ }
122
+ for (const findingRecord of ledger.findings) {
123
+ registry.set(findingRecord.id, {
124
+ kind: "ledger-event",
125
+ value: `finding "${findingRecord.subject}" (${findingRecord.severity}, ${findingRecord.status})`,
126
+ });
127
+ }
128
+ for (const denial of ledger.denials) {
129
+ registry.set(denial.id, {
130
+ kind: "ledger-event",
131
+ value: `denied command \`${denial.command}\` (${denial.source}) in ${denial.workstream}/${denial.attempt}`,
132
+ });
133
+ }
134
+ for (const bucket of summary.buckets) {
135
+ if (bucket.spanCount === 0)
136
+ continue;
137
+ registry.set(bucket.bucket, {
138
+ kind: "bucket",
139
+ value: `${formatDuration(bucket.durationMs)} across ${plural(bucket.spanCount, "span")}`,
140
+ });
141
+ }
142
+ for (const workstream of summary.rankings.byWorkstream) {
143
+ registry.set(workstream.key, {
144
+ kind: "workstream",
145
+ value: `${formatDuration(workstream.durationMs)} across ${plural(workstream.count, "span")}`,
146
+ });
147
+ }
148
+ for (const command of summary.rankings.byVerifyCommand) {
149
+ registry.set(command.command, {
150
+ kind: "verify-command",
151
+ value: `${formatDuration(command.totalMs)} across ${plural(command.invocations, "invocation")}`,
152
+ });
153
+ }
154
+ return registry;
155
+ }
156
+ function registryLines(registry, kind) {
157
+ return [...registry.entries()]
158
+ .filter(([, entry]) => entry.kind === kind)
159
+ .sort(([a], [b]) => a.localeCompare(b))
160
+ .map(([handle, entry]) => `- \`${handle}\`: ${entry.value}`);
161
+ }
162
+ const causalStepSchema = z.object({
163
+ claim: z.string().min(1),
164
+ cites: z.array(z.string()).default([]),
165
+ from: z.string().optional(),
166
+ to: z.string().optional(),
167
+ });
168
+ const planningConstraintSchema = z.object({
169
+ constraint: z.string().min(1),
170
+ cites: z.array(z.string()).default([]),
171
+ });
172
+ const INTERPRETATION_PATTERN = /```interpretation[^\S\r\n]*\r?\n([\s\S]*?)```/gu;
173
+ const CONSTRAINTS_PATTERN = /```constraints[^\S\r\n]*\r?\n([\s\S]*?)```/gu;
174
+ const UNRESOLVED_PATTERN = /```unresolved[^\S\r\n]*\r?\n([\s\S]*?)```/gu;
175
+ /** The last non-empty fenced block's body, or undefined when none appeared — the same echo guard used throughout (extractSummary, parseSnapshotHalf). */
176
+ function lastNonEmptyBlock(output, pattern) {
177
+ const bodies = [...output.matchAll(pattern)]
178
+ .map((match) => match[1]?.trim() ?? "")
179
+ .filter((body) => body !== "");
180
+ return bodies.length > 0 ? bodies[bodies.length - 1] : undefined;
181
+ }
182
+ function parseJsonArray(body, schema, label, errors) {
183
+ if (body === undefined)
184
+ return [];
185
+ let json;
186
+ try {
187
+ json = JSON.parse(body);
188
+ }
189
+ catch (error) {
190
+ errors.push(`${label} block is not valid JSON: ${error.message}`);
191
+ return [];
192
+ }
193
+ if (!Array.isArray(json)) {
194
+ errors.push(`${label} block is not a JSON array`);
195
+ return [];
196
+ }
197
+ const out = [];
198
+ json.forEach((element, index) => {
199
+ const result = schema.safeParse(element);
200
+ if (!result.success) {
201
+ const issues = result.error.issues
202
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
203
+ .join("; ");
204
+ errors.push(`${label}[${index}] has the wrong shape: ${issues}`);
205
+ return;
206
+ }
207
+ out.push(result.data);
208
+ });
209
+ return out;
210
+ }
211
+ function parseUnresolved(body) {
212
+ if (body === undefined)
213
+ return [];
214
+ try {
215
+ const json = JSON.parse(body);
216
+ if (Array.isArray(json)) {
217
+ return json
218
+ .filter((item) => typeof item === "string" && item.trim() !== "")
219
+ .map((item) => item.trim());
220
+ }
221
+ }
222
+ catch {
223
+ // Not JSON — treat the block as bulleted prose below.
224
+ }
225
+ return body
226
+ .split(/\r?\n/u)
227
+ .map((line) => line.replace(/^[-*]\s*/u, "").trim())
228
+ .filter((line) => line !== "");
229
+ }
230
+ /** Parses the analyzer's three-layer reply. Total: a missing block is empty, malformed JSON is a recorded error and an empty layer, and a reply with none of the three blocks is `blockless` — the analyzer-error path (§3.4). */
231
+ export function parseCausalAnalysis(output) {
232
+ const errors = [];
233
+ const interpretationBody = lastNonEmptyBlock(output, INTERPRETATION_PATTERN);
234
+ const constraintsBody = lastNonEmptyBlock(output, CONSTRAINTS_PATTERN);
235
+ const unresolvedBody = lastNonEmptyBlock(output, UNRESOLVED_PATTERN);
236
+ const interpretation = parseJsonArray(interpretationBody, causalStepSchema, "interpretation", errors).map((step) => ({
237
+ claim: step.claim,
238
+ cites: step.cites,
239
+ ...(step.from === undefined ? {} : { from: step.from }),
240
+ ...(step.to === undefined ? {} : { to: step.to }),
241
+ }));
242
+ const constraints = parseJsonArray(constraintsBody, planningConstraintSchema, "constraints", errors).map((constraint) => ({ constraint: constraint.constraint, cites: constraint.cites }));
243
+ const unresolved = parseUnresolved(unresolvedBody);
244
+ return {
245
+ interpretation,
246
+ constraints,
247
+ unresolved,
248
+ errors,
249
+ blockless: interpretationBody === undefined && constraintsBody === undefined && unresolvedBody === undefined,
250
+ };
251
+ }
252
+ function validateCites(cites, registry) {
253
+ const resolved = [];
254
+ const unresolvedCites = [];
255
+ for (const cite of cites) {
256
+ const entry = registry.get(cite);
257
+ if (entry === undefined) {
258
+ unresolvedCites.push(cite);
259
+ continue;
260
+ }
261
+ resolved.push({ handle: cite, kind: entry.kind, value: entry.value });
262
+ }
263
+ const supported = resolved.some((citation) => isConcreteHandleKind(citation.kind));
264
+ return { resolved, unresolvedCites, supported };
265
+ }
266
+ /** The deterministic gate: proves every rendered causal step cites at least one concrete observation the run actually retained, and no cited handle is fabricated. Never judges whether a resolving citation logically supports its claim (D2) — that is left to the human. Pure. */
267
+ export function validateCausalEvidence(reply, registry) {
268
+ const supportedSteps = [];
269
+ const unsupportedSteps = [];
270
+ for (const step of reply.interpretation) {
271
+ const validation = validateCites(step.cites, registry);
272
+ const claim = { source: step, ...validation };
273
+ (validation.supported ? supportedSteps : unsupportedSteps).push(claim);
274
+ }
275
+ const supportedConstraints = [];
276
+ const unsupportedConstraints = [];
277
+ for (const constraint of reply.constraints) {
278
+ const validation = validateCites(constraint.cites, registry);
279
+ const claim = { source: constraint, ...validation };
280
+ (validation.supported ? supportedConstraints : unsupportedConstraints).push(claim);
281
+ }
282
+ return { supportedSteps, unsupportedSteps, supportedConstraints, unsupportedConstraints };
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // Observed-measurements: rendered deterministically from `RunAnalyticsSummary`
286
+ // (D4) — shared, byte-for-byte, between the analyzer's brief and the report's
287
+ // own observed layer. The analyzer never authors a number that reaches
288
+ // either.
289
+ // ---------------------------------------------------------------------------
290
+ function observedMeasurementLines(summary) {
291
+ const lines = [];
292
+ lines.push(`- Total elapsed: ${formatDuration(summary.totalElapsedMs)}.`);
293
+ lines.push("- By bucket:");
294
+ for (const bucket of summary.buckets) {
295
+ if (bucket.spanCount === 0)
296
+ continue;
297
+ lines.push(` - \`${bucket.bucket}\`: ${formatDuration(bucket.durationMs)} (${bucket.percent.toFixed(1)}%, ${plural(bucket.spanCount, "span")})`);
298
+ }
299
+ const topStage = summary.rankings.byStage[0];
300
+ if (topStage) {
301
+ lines.push(`- Largest stage: \`${topStage.key}\` — ${formatDuration(topStage.durationMs)} across ${plural(topStage.count, "span")}.`);
302
+ }
303
+ const topWorkstream = summary.rankings.byWorkstream[0];
304
+ if (topWorkstream) {
305
+ lines.push(`- Largest workstream: \`${topWorkstream.key}\` — ${formatDuration(topWorkstream.durationMs)}.`);
306
+ }
307
+ const attempt = summary.rankings.largestAgentAttempt;
308
+ if (attempt) {
309
+ lines.push(`- Largest single agent attempt: \`${attempt.stage}\`` +
310
+ (attempt.workstream ? ` (${attempt.workstream}` : " (") +
311
+ `${attempt.attemptSeat ? `, ${attempt.attemptSeat}` : ""}` +
312
+ `${attempt.attemptIndex !== undefined ? ` #${attempt.attemptIndex}` : ""}) — ${formatDuration(attempt.durationMs)}.`);
313
+ }
314
+ const topCommand = summary.rankings.byVerifyCommand[0];
315
+ if (topCommand) {
316
+ lines.push(`- Largest verification command: \`${topCommand.command}\` — ${formatDuration(topCommand.totalMs)} across ${plural(topCommand.invocations, "invocation")}.`);
317
+ }
318
+ lines.push(`- Attempts by disposition: ${summary.dispositions.map((d) => `${d.outcome} ${plural(d.count, "attempt")} (${formatDuration(d.durationMs)})`).join(", ")}.`);
319
+ lines.push(`- Agent rerun time: ${formatDuration(summary.rerun.agent.totalMs)}` +
320
+ (summary.rerun.agent.byReason.length > 0
321
+ ? `, by trigger: ${summary.rerun.agent.byReason.map((r) => `${r.key} ${plural(r.count, "attempt")} (${formatDuration(r.durationMs)})`).join(", ")}`
322
+ : "") +
323
+ ".");
324
+ lines.push(`- Verification rerun time: ${formatDuration(summary.rerun.verification.totalMs)}` +
325
+ (summary.rerun.verification.byPhase.length > 0
326
+ ? `, by phase: ${summary.rerun.verification.byPhase.map((p) => `${p.key} ${plural(p.count, "invocation")} (${formatDuration(p.durationMs)})`).join(", ")}`
327
+ : "") +
328
+ ".");
329
+ lines.push(`- Observability coverage: ${summary.coverage.counts.observed} observed, ${summary.coverage.counts.estimated} estimated, ` +
330
+ `${summary.coverage.counts.unavailable} unavailable, ${summary.coverage.counts.incomplete} incomplete` +
331
+ (summary.coverage.unavailableRoles.length > 0
332
+ ? ` (unavailable roles: ${summary.coverage.unavailableRoles.join(", ")} — unmeasured, not zero)`
333
+ : "") +
334
+ ".");
335
+ return lines;
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // The brief (§3.3)
339
+ // ---------------------------------------------------------------------------
340
+ function successCriteriaLines(manifest) {
341
+ if (manifest.successCriteria.length === 0)
342
+ return ["None recorded in the manifest."];
343
+ return manifest.successCriteria.map((criterion) => `- **${criterion.id}**: ${criterion.description}`);
344
+ }
345
+ function rosterLines(manifest) {
346
+ return manifest.workstreams.map((workstream) => `- ${workstream.id} ${workstream.name} (spec: \`${workstream.taskFile}\`): ` +
347
+ `${workstream.scope?.summary ?? workstream.name}`);
348
+ }
349
+ function ledgerLines(ledger) {
350
+ if (ledger.decisions.length === 0 && ledger.findings.length === 0 && ledger.denials.length === 0) {
351
+ return ["No decisions, findings, or denials were journaled this run."];
352
+ }
353
+ const lines = [];
354
+ if (ledger.decisions.length > 0) {
355
+ lines.push("Decisions:");
356
+ for (const decision of ledger.decisions) {
357
+ lines.push(`- \`${decision.id}\` **${decision.decision.title}** (${decision.status}): ${decision.decision.rationale}`);
358
+ }
359
+ }
360
+ if (ledger.findings.length > 0) {
361
+ lines.push("", "Findings:");
362
+ for (const findingRecord of ledger.findings) {
363
+ lines.push(`- \`${findingRecord.id}\` **${findingRecord.subject}** (${findingRecord.severity}, ${findingRecord.status}): ${findingRecord.message}`);
364
+ }
365
+ }
366
+ if (ledger.denials.length > 0) {
367
+ lines.push("", "Denied commands:");
368
+ for (const denial of ledger.denials) {
369
+ lines.push(`- \`${denial.id}\` \`${denial.command}\` (${denial.source}) in ${denial.workstream}/${denial.attempt}`);
370
+ }
371
+ }
372
+ return lines;
373
+ }
374
+ function timelineLines(workstreams) {
375
+ if (workstreams.length === 0)
376
+ return ["No workstream results were recorded."];
377
+ const lines = [];
378
+ for (const workstream of workstreams) {
379
+ lines.push(`- **${workstream.id} ${workstream.name}** — ${workstream.status}${workstream.reason ? `: ${workstream.reason}` : ""}`);
380
+ for (const diagnosis of workstream.failureDiagnoses ?? []) {
381
+ lines.push(` - after the ${diagnosis.attempt} attempt, the reviewer diagnosed: ${diagnosis.verdict}`);
382
+ }
383
+ if (workstream.testCritique) {
384
+ const tc = workstream.testCritique;
385
+ lines.push(` - test critique: ${tc.stopReason} after ${plural(tc.roundsRun, "round")}` +
386
+ (tc.openSubjects.length > 0 ? `; still open: ${tc.openSubjects.join(", ")}` : "") +
387
+ (tc.resolvedSubjects.length > 0 ? `; fixed: ${tc.resolvedSubjects.join(", ")}` : ""));
388
+ }
389
+ }
390
+ return lines;
391
+ }
392
+ const CAUSAL_ANALYSIS_CLIP_LIMIT = 60_000;
393
+ function clipForCausalAnalysis(text, label) {
394
+ if (text.length <= CAUSAL_ANALYSIS_CLIP_LIMIT)
395
+ return { text, clipped: false };
396
+ return {
397
+ text: `${text.slice(0, CAUSAL_ANALYSIS_CLIP_LIMIT)}\n… (${label} clipped at ` +
398
+ `${CAUSAL_ANALYSIS_CLIP_LIMIT} characters — the analyzer saw a partial ${label})`,
399
+ clipped: true,
400
+ };
401
+ }
402
+ const CAUSAL_CONTRACT = `
403
+ ## Causal analysis (how to report your read)
404
+
405
+ End your reply with three fenced blocks, in this exact order:
406
+ \`\`\`interpretation\`\`\`, \`\`\`constraints\`\`\`, \`\`\`unresolved\`\`\`. There is
407
+ deliberately no \`observed\` block — the observed measurements above are
408
+ rendered by software from the run's own analytics; restating their values is
409
+ not your job, and nothing you write there would reach the report.
410
+
411
+ \`\`\`interpretation
412
+ [
413
+ {
414
+ "claim": "<a sentence tracing a measured cost toward a planner-actionable cause>",
415
+ "cites": ["<handle>", "<handle>"],
416
+ "from": "<optional handle — the measured cost this step starts from>",
417
+ "to": "<optional handle — the cause this step points toward>"
418
+ }
419
+ ]
420
+ \`\`\`
421
+
422
+ Every \`cites\` entry must be an exact handle string copied from one of the
423
+ sections above (a span id, a point id, a ledger event id, or an evidence
424
+ ref — plus, for context only, a bucket name, workstream id, or
425
+ verify-command name). A step is only ever shown as a cause when at least one
426
+ of its citations is a concrete handle — a span, a point, a ledger event, or
427
+ an evidence ref. Citing only a bucket/workstream/verify-command name, citing
428
+ nothing, or citing a handle that does not appear above is checked
429
+ mechanically and never rendered as a fact. Fabricating a handle is pointless:
430
+ it will not resolve.
431
+
432
+ \`\`\`constraints
433
+ [
434
+ { "constraint": "<a rule a future plan should carry forward>", "cites": ["<handle>"] }
435
+ ]
436
+ \`\`\`
437
+
438
+ Gated the same way as interpretation steps.
439
+
440
+ \`\`\`unresolved
441
+ [
442
+ "<a plain-language question naming the missing or conflicting evidence>"
443
+ ]
444
+ \`\`\`
445
+
446
+ Where two evidence items disagree, where telemetry is unavailable, or where
447
+ closing a chain would require a guess, put the question here instead of
448
+ picking a side in \`interpretation\`. An empty array in any block is a fine
449
+ answer — this is a second opinion, not an obligation to manufacture a cause.
450
+ `.trim();
451
+ /** The ```interpretation/```constraints/```unresolved block-format instructions. */
452
+ export function causalAnalysisContract() {
453
+ return CAUSAL_CONTRACT;
454
+ }
455
+ /**
456
+ * Assembles the analyzer's read-only brief (§3.3): framing, the
457
+ * software-rendered observed measurements, the citable span/point/evidence/
458
+ * ledger handles (drawn straight from `registry`, so what is shown is
459
+ * exactly what is citable), the plan, the ledger, the verification/retry
460
+ * timeline, the clipped final diff, and the response contract. Pure;
461
+ * returns the per-component byte sizes for SC-07 prompt telemetry.
462
+ */
463
+ export function causalAnalysisBrief(input) {
464
+ const builder = createBriefBuilder();
465
+ builder.push("framing", `# Causal analysis: ${input.manifest.program.name} (${input.programId})`, "", "The run for this program is finished. You are an optional, independent,", "read-only second opinion — not a re-measurement. Every number below is", "already final; your job is to interpret it with cited evidence, or say", "plainly where you cannot.", "", "Build a causal chain only as far as retained evidence carries it. Where", "telemetry is unavailable, where two evidence items conflict, or where", "closing the chain would require a guess, record an unresolved question", "instead of picking a side or inventing a cause — an unresolved result is", "the correct answer here, not a failure.", "", "This analysis runs before the run report is assembled, so it reasons", "over a snapshot taken just before you were spawned: your own cost is", "not in any of the figures below.", "");
466
+ builder.push("observed-measurements", "## Observed measurements", "", ...observedMeasurementLines(input.summary), "", "Cite these by the exact handle shown — do not restate their values.", "");
467
+ const spanLines = registryLines(input.registry, "span");
468
+ const pointLines = registryLines(input.registry, "point");
469
+ builder.push("span-point-handles", "## Span and point handles", "", spanLines.length === 0 ? "No spans were recorded." : spanLines.join("\n"), "", pointLines.length === 0 ? "No points were recorded." : pointLines.join("\n"), "");
470
+ const evidenceLines = registryLines(input.registry, "evidence");
471
+ builder.push("evidence-index", "## Retained evidence index", "", evidenceLines.length === 0
472
+ ? "No local evidence references were recorded for this run."
473
+ : evidenceLines.join("\n"), "", "Every reference above resolves only on the machine that ran this build.", "");
474
+ builder.push("plan", "## Program plan", "", "### Program narrative", "", input.programNarrative && input.programNarrative.trim() !== ""
475
+ ? input.programNarrative.trim()
476
+ : "(the program document could not be read; only the manifest's success criteria and roster below are available)", "", "### Success criteria", "", ...successCriteriaLines(input.manifest), "", "### Workstream roster", "", "Each entry names its spec file by path; the full text of every spec is", "reproduced below. Trust the repository's spec file and the diff below", "over this narrative and roster where they disagree.", "", ...rosterLines(input.manifest), "");
477
+ builder.push("workstream-specs", "### Workstream specifications", "", input.workstreamSpecsText.trim() === ""
478
+ ? "(no workstream spec content was available)"
479
+ : input.workstreamSpecsText, "");
480
+ builder.push("ledger", "## Decision ledger", "", ...ledgerLines(input.ledger), "");
481
+ builder.push("timeline", "## Verification/retry timeline", "", ...timelineLines(input.workstreams), "");
482
+ builder.push("diff", "## Final diff", "", input.baseCommit ? `Base commit: ${input.baseCommit}` : "(no base commit)", "", "```diff", input.diff.trim() === "" ? "(no diff available)" : input.diff, "```", "");
483
+ builder.push("contract", CAUSAL_CONTRACT, "", summaryContract());
484
+ return { brief: builder.join(), components: builder.components() };
485
+ }
486
+ /** The outcome recorded when no reviewerAgent is configured. */
487
+ export function causalAnalysisReviewerAbsent() {
488
+ return {
489
+ ran: false,
490
+ status: "no-reviewer",
491
+ reason: "causal analysis is disabled — no reviewerAgent is configured, and the pass was " +
492
+ "not substituted with the implementer or skipped silently.",
493
+ unresolvedQuestions: [],
494
+ parseErrors: [],
495
+ inputClipped: false,
496
+ };
497
+ }
498
+ function noAnalyticsOutcome() {
499
+ return {
500
+ ran: false,
501
+ status: "no-analytics",
502
+ reason: "causal analysis found no analytics to interpret — the run recorder produced no " +
503
+ "usable snapshot at the point this stage ran.",
504
+ unresolvedQuestions: [],
505
+ parseErrors: [],
506
+ inputClipped: false,
507
+ };
508
+ }
509
+ function analyzerErrorOutcome(reason, inputClipped, note) {
510
+ return {
511
+ ran: false,
512
+ status: "analyzer-error",
513
+ reason,
514
+ unresolvedQuestions: [],
515
+ parseErrors: [],
516
+ inputClipped,
517
+ ...(note === undefined ? {} : { note }),
518
+ };
519
+ }
520
+ /**
521
+ * Runs the pass once: resolve reviewer → build brief → spawn → parse →
522
+ * validate → outcome. Never throws — every failure becomes a status and a
523
+ * `reason` sentence on the returned outcome (§3.5).
524
+ */
525
+ export async function runCausalAnalysis(options) {
526
+ const reviewerAgent = resolveReviewerAgent(options.config);
527
+ if (!reviewerAgent)
528
+ return causalAnalysisReviewerAbsent();
529
+ if (options.snapshot === undefined)
530
+ return noAnalyticsOutcome();
531
+ let summary;
532
+ try {
533
+ summary = aggregateRunAnalytics(options.snapshot);
534
+ }
535
+ catch {
536
+ return noAnalyticsOutcome();
537
+ }
538
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
539
+ const log = options.log ?? ((_line) => { });
540
+ const permits = options.permits ?? NOOP_PERMITS_CONTEXT;
541
+ const registry = buildHandleRegistry(options.snapshot, summary, options.ledger);
542
+ const diffClip = clipForCausalAnalysis(options.diff, "diff");
543
+ recorder.point(clippedInputPoint("causal-analysis-diff", options.diff, diffClip.text, { role: "reviewerAgent" }));
544
+ const narrativeClip = clipForCausalAnalysis(options.programNarrative ?? "", "program narrative");
545
+ recorder.point(clippedInputPoint("causal-analysis-plan", options.programNarrative ?? "", narrativeClip.text, {
546
+ role: "reviewerAgent",
547
+ }));
548
+ const specsText = combineWorkstreamSpecs(options.workstreamSpecs ?? []);
549
+ const specsClip = clipForCausalAnalysis(specsText, "workstream specs");
550
+ recorder.point(clippedInputPoint("causal-analysis-specs", specsText, specsClip.text, { role: "reviewerAgent" }));
551
+ const inputClipped = diffClip.clipped || narrativeClip.clipped || specsClip.clipped;
552
+ const { brief, components } = causalAnalysisBrief({
553
+ manifest: options.manifest,
554
+ programId: options.programId,
555
+ summary,
556
+ registry,
557
+ ledger: options.ledger,
558
+ workstreams: options.workstreams,
559
+ diff: diffClip.text,
560
+ workstreamSpecsText: specsClip.text,
561
+ ...(options.baseCommit === undefined ? {} : { baseCommit: options.baseCommit }),
562
+ ...(options.programNarrative === undefined ? {} : { programNarrative: narrativeClip.text }),
563
+ });
564
+ for (const point of promptComponentSizePoints(components, { role: "reviewerAgent" })) {
565
+ recorder.point(point);
566
+ }
567
+ log(`causal analysis: briefing reviewer (${brief.length} bytes)`);
568
+ const observe = {
569
+ root: options.root,
570
+ programId: options.programId,
571
+ label: "causal-analyzer",
572
+ log,
573
+ };
574
+ let invocation;
575
+ try {
576
+ invocation = await invokeAgent(options.agentRunner, reviewerAgent, brief, options.root, permits, "reviewerAgent", observe, recorder, { stage: "causal-analysis" });
577
+ }
578
+ catch (error) {
579
+ return analyzerErrorOutcome(`the causal analyzer invocation threw: ${error.message}`, inputClipped);
580
+ }
581
+ const agentSummary = resolveSummary(invocation.output);
582
+ if (invocation.exitCode !== 0) {
583
+ return analyzerErrorOutcome(`the causal analyzer exited with code ${invocation.exitCode}: ${tail(invocation.output)}`, inputClipped, agentSummary.text);
584
+ }
585
+ const reply = parseCausalAnalysis(invocation.output);
586
+ if (reply.blockless) {
587
+ return analyzerErrorOutcome(`the causal analyzer's reply carried no interpretation, constraints, or unresolved ` +
588
+ `block: ${tail(invocation.output)}`, inputClipped, agentSummary.text);
589
+ }
590
+ const validated = validateCausalEvidence(reply, registry);
591
+ const status = validated.supportedSteps.length > 0 ? "analyzed" : "unresolved";
592
+ return {
593
+ ran: true,
594
+ status,
595
+ ...(status === "unresolved"
596
+ ? {
597
+ reason: "the analyzer ran, but no claim it made cited retained evidence the gate could " +
598
+ "confirm — this is a valid answer when the evidence does not support a cause.",
599
+ }
600
+ : {}),
601
+ validated,
602
+ unresolvedQuestions: reply.unresolved,
603
+ parseErrors: reply.errors,
604
+ inputClipped,
605
+ note: agentSummary.text,
606
+ };
607
+ }
608
+ // ---------------------------------------------------------------------------
609
+ // Rendering (§3.6)
610
+ // ---------------------------------------------------------------------------
611
+ function renderClaimLines(claim, text) {
612
+ const lines = [`- ${text}`];
613
+ if (claim.resolved.length > 0) {
614
+ lines.push(` Cites: ${claim.resolved.map((citation) => `\`${citation.handle}\` [${citation.kind}] — ${citation.value}`).join("; ")}.`);
615
+ }
616
+ if (claim.unresolvedCites.length > 0) {
617
+ lines.push(` Unresolved citations (not shown as evidence): ${claim.unresolvedCites.map((c) => `\`${c}\``).join(", ")}.`);
618
+ }
619
+ return lines;
620
+ }
621
+ /**
622
+ * A human-facing render of one completed (or absent) causal analysis, for
623
+ * the run report. Markdown lines, no heading — the caller splices them
624
+ * under its own `## Why the time went there` heading, immediately after
625
+ * WS-04's own section. Total, never-throwing.
626
+ */
627
+ export function renderCausalAnalysis(outcome, summary) {
628
+ if (outcome.status === "no-reviewer") {
629
+ return [
630
+ "**Causal analysis:** disabled — no reviewer is configured, and the pass was not",
631
+ "substituted with the implementer or skipped silently.",
632
+ ];
633
+ }
634
+ if (outcome.status === "no-analytics") {
635
+ return ["**Causal analysis:** no analytics were available to interpret.", outcome.reason ?? ""];
636
+ }
637
+ if (outcome.status === "analyzer-error") {
638
+ const lines = ["**Causal analysis:** the analyzer did not produce a readable reply.", outcome.reason ?? ""];
639
+ if (outcome.note !== undefined)
640
+ lines.push(`Analyzer's note: ${outcome.note}`);
641
+ return lines;
642
+ }
643
+ const lines = [
644
+ "This section traces measured run costs toward planner-actionable causes,",
645
+ "using an independent, read-only pass over the numbers above. Boundary:",
646
+ "it analyzes the run from start to the moment this analysis began — its",
647
+ "own cost is in the deterministic buckets above, but not in the figures",
648
+ "it cites below.",
649
+ "",
650
+ "### Observed measurements",
651
+ "",
652
+ ...observedMeasurementLines(summary),
653
+ "",
654
+ "### Interpretation",
655
+ "",
656
+ ];
657
+ const validated = outcome.validated;
658
+ if (outcome.status === "unresolved" || validated === undefined || validated.supportedSteps.length === 0) {
659
+ lines.push("No evidence-backed causal step could be established this run.", "");
660
+ }
661
+ else {
662
+ for (const step of validated.supportedSteps) {
663
+ lines.push(...renderClaimLines(step, step.source.claim));
664
+ }
665
+ lines.push("");
666
+ }
667
+ lines.push("### Reusable planning constraints", "");
668
+ if (validated === undefined || validated.supportedConstraints.length === 0) {
669
+ lines.push("None with resolving evidence.", "");
670
+ }
671
+ else {
672
+ for (const constraint of validated.supportedConstraints) {
673
+ lines.push(...renderClaimLines(constraint, constraint.source.constraint));
674
+ }
675
+ lines.push("");
676
+ }
677
+ lines.push("### Unresolved questions", "");
678
+ if (outcome.unresolvedQuestions.length === 0) {
679
+ lines.push("None recorded.", "");
680
+ }
681
+ else {
682
+ for (const question of outcome.unresolvedQuestions)
683
+ lines.push(`- ${question}`);
684
+ lines.push("");
685
+ }
686
+ const unsupportedSteps = validated?.unsupportedSteps ?? [];
687
+ const unsupportedConstraints = validated?.unsupportedConstraints ?? [];
688
+ if (unsupportedSteps.length > 0 || unsupportedConstraints.length > 0) {
689
+ lines.push("Claims the analyzer made without retained evidence (reported for transparency,", "never treated as findings):", "");
690
+ for (const step of unsupportedSteps)
691
+ lines.push(`- ${step.source.claim}`);
692
+ for (const constraint of unsupportedConstraints)
693
+ lines.push(`- ${constraint.source.constraint}`);
694
+ lines.push("");
695
+ }
696
+ if (outcome.parseErrors.length > 0) {
697
+ lines.push(`Parse errors: ${outcome.parseErrors.join("; ")}`, "");
698
+ }
699
+ if (outcome.inputClipped) {
700
+ lines.push("Input was clipped for length before the analyzer saw it.", "");
701
+ }
702
+ if (outcome.note !== undefined) {
703
+ lines.push(`Analyzer's note: ${outcome.note}`, "");
704
+ }
705
+ return lines;
706
+ }
707
+ /**
708
+ * The run's own fail-open isolation boundary for the causal-analysis section
709
+ * (mirrors `renderAnalyticsSection` in run-analytics-report.ts): always
710
+ * returns a `## Why the time went there` section, never throws. `artifact`
711
+ * accepts a thunk so an evaluation failure of `recorder.snapshot()` itself
712
+ * stays inside this function's own try/catch rather than escaping before it
713
+ * is reached.
714
+ */
715
+ export function renderCausalAnalysisSection(outcome, artifact, deps = {}) {
716
+ const aggregate = deps.aggregate ?? aggregateRunAnalytics;
717
+ const heading = ["## Why the time went there", ""];
718
+ try {
719
+ const resolved = typeof artifact === "function" ? artifact() : artifact;
720
+ if (resolved === undefined) {
721
+ return [
722
+ ...heading,
723
+ "Causal analysis unavailable — no analytics snapshot was available when the report was rendered.",
724
+ "",
725
+ ];
726
+ }
727
+ return [...heading, ...renderCausalAnalysis(outcome, aggregate(resolved)), ""];
728
+ }
729
+ catch (error) {
730
+ return [...heading, `Causal analysis unavailable — ${error.message}.`, ""];
731
+ }
732
+ }
733
+ //# sourceMappingURL=causal-analysis.js.map