@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
@@ -1,8 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { createWriteStream } from "node:fs";
3
4
  import { mkdir, writeFile } from "node:fs/promises";
4
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
5
6
  import crossSpawn from "cross-spawn";
7
+ import { deriveResumeInvocation, hasPinnedSessionFlag, } from "./permits.js";
8
+ import { createTelemetryAccumulator, parseConfiguredModel, providerLabelFromCommand, providerTelemetryPoints, } from "./provider-telemetry.js";
9
+ import { NOOP_RUN_RECORDER, } from "./run-analytics.js";
10
+ import { describeSessionDecline, } from "./agent-session.js";
6
11
  // In-memory output is kept to a bounded tail; full output belongs in the
7
12
  // per-workstream log via onOutput streaming.
8
13
  const OUTPUT_TAIL_LIMIT = 200_000;
@@ -54,7 +59,10 @@ export function runProcess(command, args, options) {
54
59
  push(chunk);
55
60
  options.onStdout?.(chunk);
56
61
  });
57
- child.stderr.on("data", push);
62
+ child.stderr.on("data", (chunk) => {
63
+ push(chunk);
64
+ options.onStderr?.(chunk);
65
+ });
58
66
  child.on("error", rejectPromise);
59
67
  let inputError;
60
68
  child.stdin.on("error", (error) => {
@@ -169,7 +177,7 @@ function toolResultText(block) {
169
177
  * transcript's `commands` list therefore means "ran", never "asked to run" —
170
178
  * a denied command appears under `denials` only.
171
179
  */
172
- function createClaudeStreamJsonDemultiplexer() {
180
+ function createClaudeStreamJsonDemultiplexer(telemetryInputs) {
173
181
  let carry = "";
174
182
  let firstLineSeen = false;
175
183
  let isNdjson = true;
@@ -180,10 +188,30 @@ function createClaudeStreamJsonDemultiplexer() {
180
188
  const errorResults = new Map();
181
189
  let resultText;
182
190
  let assistantText = "";
191
+ // WS-08: the authoritative source is the `system`/`init` event; any other
192
+ // event carrying a `session_id` sets it only if nothing is held yet, so a
193
+ // stream that lost its init line (an operator who pinned
194
+ // `--output-format=stream-json` without `--verbose`) still yields an id.
195
+ let sessionId;
196
+ const telemetry = createTelemetryAccumulator({
197
+ configuredModel: parseConfiguredModel(telemetryInputs?.configuredArgs ?? []),
198
+ commandProviderGuess: providerLabelFromCommand(telemetryInputs?.command),
199
+ });
183
200
  function routeEvent(event) {
184
201
  if (event === null || typeof event !== "object")
185
202
  return;
186
203
  const record = event;
204
+ // WS-03 rides the same already-parsed event this function routes for
205
+ // commands/denials — never a second parse of the underlying text.
206
+ telemetry.observeEvent(record);
207
+ if (typeof record.session_id === "string") {
208
+ if (record.type === "system" && record.subtype === "init") {
209
+ sessionId = record.session_id;
210
+ }
211
+ else if (sessionId === undefined) {
212
+ sessionId = record.session_id;
213
+ }
214
+ }
187
215
  if (record.type === "assistant") {
188
216
  const content = record.message?.content;
189
217
  if (Array.isArray(content)) {
@@ -326,9 +354,13 @@ function createClaudeStreamJsonDemultiplexer() {
326
354
  commands,
327
355
  denials: finalDenials,
328
356
  ...(corrupted ? { corrupted: true } : {}),
357
+ ...(sessionId === undefined ? {} : { sessionId }),
329
358
  },
330
359
  };
331
360
  },
361
+ finishTelemetry() {
362
+ return telemetry.finish({ observed: firstLineSeen && isNdjson, corrupted });
363
+ },
332
364
  };
333
365
  }
334
366
  /**
@@ -341,13 +373,84 @@ export function demultiplexClaudeStreamJson(raw) {
341
373
  demux.feed(raw);
342
374
  return demux.finish();
343
375
  }
376
+ // ---------------------------------------------------------------------------
377
+ // Codex session id capture (WS-08 §2.3/§2.4) — verified against codex
378
+ // v0.149.0 on 2026-09-11. Codex writes its startup header, including the
379
+ // `session id:` line, and its entire running transcript to stderr, writing
380
+ // only the agent's final message to stdout; with `--json` (never added by
381
+ // nightshift, but an operator may configure it) the id instead arrives as
382
+ // `thread_id` on a stdout `thread.started` event, and a resumed run reprints
383
+ // the same value on its `session id:` line either way.
384
+ // ---------------------------------------------------------------------------
385
+ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
386
+ const CODEX_SESSION_ID_LINE = new RegExp(`^\\s*session id:\\s*(${UUID_PATTERN})\\s*$`, "iu");
387
+ const UUID_ONLY = new RegExp(`^${UUID_PATTERN}$`, "iu");
388
+ /** The session id from one codex stderr line, when it is the anchored
389
+ * `session id: <uuid>` header line — an embedded or prefixed occurrence
390
+ * inside ordinary prose does not match. */
391
+ export function captureCodexSessionIdFromStderr(line) {
392
+ return CODEX_SESSION_ID_LINE.exec(line)?.[1];
393
+ }
394
+ /** The `thread_id` from one codex `--json`-mode stdout line, when it is a
395
+ * well-formed `thread.started` event. */
396
+ export function captureCodexSessionIdFromStdout(line) {
397
+ let parsed;
398
+ try {
399
+ parsed = JSON.parse(line);
400
+ }
401
+ catch {
402
+ return undefined;
403
+ }
404
+ if (parsed === null || typeof parsed !== "object")
405
+ return undefined;
406
+ const record = parsed;
407
+ if (record.type !== "thread.started")
408
+ return undefined;
409
+ const threadId = record.thread_id;
410
+ return typeof threadId === "string" && UUID_ONLY.test(threadId) ? threadId : undefined;
411
+ }
412
+ /** Line-buffered, like the Claude demultiplexer; stops at the first match —
413
+ * codex prints its header before any model output, so the first match is
414
+ * structurally the header's, never the agent's own prose. */
415
+ function createCodexSessionScanner() {
416
+ let sessionId;
417
+ let stderrCarry = "";
418
+ let stdoutCarry = "";
419
+ function scanLines(carry, chunk, setCarry, capture) {
420
+ if (sessionId !== undefined)
421
+ return;
422
+ let buffer = carry + chunk;
423
+ let newline;
424
+ while (sessionId === undefined && (newline = buffer.indexOf("\n")) !== -1) {
425
+ const line = buffer.slice(0, newline);
426
+ buffer = buffer.slice(newline + 1);
427
+ const found = capture(line);
428
+ if (found !== undefined)
429
+ sessionId = found;
430
+ }
431
+ setCarry(sessionId === undefined ? buffer : "");
432
+ }
433
+ return {
434
+ feedStderr(chunk) {
435
+ scanLines(stderrCarry, chunk, (value) => (stderrCarry = value), captureCodexSessionIdFromStderr);
436
+ },
437
+ feedStdout(chunk) {
438
+ scanLines(stdoutCarry, chunk, (value) => (stdoutCarry = value), captureCodexSessionIdFromStdout);
439
+ },
440
+ sessionId: () => sessionId,
441
+ };
442
+ }
344
443
  export const defaultAgentRunner = async (invocation) => {
345
444
  const finalArgs = invocation.promptMode === "argument"
346
445
  ? [...invocation.args, invocation.prompt]
347
446
  : invocation.args;
348
447
  const demux = claudeStreamJsonRequested(invocation.args)
349
- ? createClaudeStreamJsonDemultiplexer()
448
+ ? createClaudeStreamJsonDemultiplexer({ configuredArgs: invocation.args, command: invocation.command })
350
449
  : undefined;
450
+ // Gated on the command so a non-codex spawn's stderr is never scanned —
451
+ // an operator's chatty stderr elsewhere must never be mistaken for a
452
+ // codex session header (WS-08).
453
+ const codexScanner = providerLabelFromCommand(invocation.command).value === "codex" ? createCodexSessionScanner() : undefined;
351
454
  const result = await runProcess(invocation.command, finalArgs, {
352
455
  cwd: invocation.cwd,
353
456
  // Never a shell: agent args are structured argv from the config, and
@@ -362,21 +465,39 @@ export const defaultAgentRunner = async (invocation) => {
362
465
  // Stdout-only: stderr can carry plain-text CLI warnings ahead of or
363
466
  // interleaved with the NDJSON stream, and feeding those into the line
364
467
  // parser would corrupt or invalidate a stream that is, on stdout alone,
365
- // perfectly well-formed.
366
- onStdout: (chunk) => demux?.feed(chunk),
468
+ // perfectly well-formed. The raw tee (when the caller wants one
469
+ // retained, WS-03) rides the same stdout-only callback, in arrival
470
+ // order, so the sidecar and the demultiplexer never disagree about what
471
+ // the process actually printed.
472
+ onStdout: (chunk) => {
473
+ demux?.feed(chunk);
474
+ codexScanner?.feedStdout(chunk);
475
+ try {
476
+ invocation.rawTranscript?.write(chunk);
477
+ }
478
+ catch (error) {
479
+ invocation.rawTranscript?.onError(error);
480
+ }
481
+ },
482
+ onStderr: (chunk) => {
483
+ codexScanner?.feedStderr(chunk);
484
+ },
367
485
  ...(invocation.promptMode === "stdin"
368
486
  ? { input: invocation.prompt }
369
487
  : {}),
370
488
  });
371
- if (!demux)
372
- return result;
489
+ const codexSessionId = codexScanner?.sessionId();
490
+ if (!demux) {
491
+ return codexSessionId === undefined ? result : { ...result, sessionId: codexSessionId };
492
+ }
373
493
  const demultiplexed = demux.finish();
494
+ const telemetry = demux.finishTelemetry();
374
495
  if (demultiplexed === undefined) {
375
496
  // Not NDJSON at all (an older CLI ignored the flag, or the process
376
497
  // crashed before its first event) — the raw tail stays `output` exactly
377
498
  // as it works today; `observed: false` distinguishes this from a spawn
378
499
  // this module never asked to be observed in the first place.
379
- return { ...result, transcript: { observed: false, commands: [], denials: [] } };
500
+ return { ...result, transcript: { observed: false, commands: [], denials: [], telemetry } };
380
501
  }
381
502
  return {
382
503
  ...result,
@@ -385,7 +506,10 @@ export const defaultAgentRunner = async (invocation) => {
385
506
  // falls back to the raw tail rather than handing the parsers an empty
386
507
  // string.
387
508
  output: demultiplexed.output !== "" ? demultiplexed.output : result.output,
388
- transcript: demultiplexed.transcript,
509
+ transcript: { ...demultiplexed.transcript, telemetry },
510
+ ...(demultiplexed.transcript.sessionId === undefined
511
+ ? {}
512
+ : { sessionId: demultiplexed.transcript.sessionId }),
389
513
  };
390
514
  };
391
515
  export const defaultVerifyRunner = (command, cwd) => runProcess(command, [], { cwd, shell: true });
@@ -393,13 +517,17 @@ export function tail(output, limit = 2000) {
393
517
  return output.length > limit ? output.slice(-limit) : output;
394
518
  }
395
519
  /** JSONL body: a header line naming role/label, then one line per command and one per denial. */
396
- async function writeTranscript(sink, role, transcript) {
520
+ async function writeTranscript(sink, role, transcript, nonce) {
397
521
  const dir = join(sink.root, "build-logs", sink.programId);
398
522
  await mkdir(dir, { recursive: true });
399
- const nonce = randomBytes(4).toString("hex");
400
523
  const fileName = `${sink.label}-${nonce}.jsonl`;
401
524
  const lines = [
402
- JSON.stringify({ role, label: sink.label, ...(transcript.corrupted ? { corrupted: true } : {}) }),
525
+ JSON.stringify({
526
+ role,
527
+ label: sink.label,
528
+ ...(transcript.corrupted ? { corrupted: true } : {}),
529
+ ...(transcript.sessionId === undefined ? {} : { sessionId: transcript.sessionId }),
530
+ }),
403
531
  ...transcript.commands.map((command) => JSON.stringify({ kind: "command", ...command })),
404
532
  ...transcript.denials.map((denial) => JSON.stringify({ kind: "denial", ...denial })),
405
533
  ];
@@ -413,21 +541,156 @@ async function writeTranscript(sink, role, transcript) {
413
541
  * invocation and so cannot go through `invokeAgent` itself). A no-op when the
414
542
  * result carries no transcript at all (a non-Claude spawn, an operator-
415
543
  * configured or operator-`--output-format` role — never asked to be
416
- * observed).
544
+ * observed). Returns the repo-relative path actually written, so a caller
545
+ * (WS-03) can cite it as evidence; `undefined` when nothing was written.
546
+ *
547
+ * `nonce`, when given, names the file so it shares its stem with a raw
548
+ * sidecar `invokeAgent` allocated up front (WS-03); the probe has no such
549
+ * sidecar and lets this mint its own, exactly as it did before this
550
+ * parameter existed.
417
551
  */
418
- export async function recordAgentTranscript(observe, role, result) {
552
+ export async function recordAgentTranscript(observe, role, result, nonce = randomBytes(4).toString("hex")) {
419
553
  if (!result.transcript)
420
- return;
554
+ return undefined;
421
555
  if (result.transcript.observed) {
422
- const path = await writeTranscript(observe, role, result.transcript);
556
+ const path = await writeTranscript(observe, role, result.transcript, nonce);
423
557
  observe.log(result.transcript.corrupted
424
558
  ? `transcript: ${path} (warning: a line mid-stream could not be parsed — ` +
425
559
  "this transcript may be missing a command or denial)"
426
560
  : `transcript: ${path}`);
561
+ return path;
427
562
  }
428
- else {
429
- observe.log(`transcript: ${observe.label}'s stream-json output did not parse as NDJSON — falling back to raw text`);
563
+ observe.log(`transcript: ${observe.label}'s stream-json output did not parse as NDJSON — falling back to raw text`);
564
+ return undefined;
565
+ }
566
+ /**
567
+ * Ties a spawn's normalized transcript and raw sidecar to one `<label>-
568
+ * <nonce>` stem, allocated before the process starts (WS-03) — the nonce
569
+ * `writeTranscript` used to mint itself, after the process had already
570
+ * exited, could never be shared with a raw tee that must be live while the
571
+ * stream is still arriving.
572
+ */
573
+ function allocateTranscriptStem(sink) {
574
+ const nonce = randomBytes(4).toString("hex");
575
+ const base = `${sink.label}-${nonce}`;
576
+ return {
577
+ nonce,
578
+ rawPath: join(sink.root, "build-logs", sink.programId, `${base}.raw.jsonl`),
579
+ rawRelPath: `build-logs/${sink.programId}/${base}.raw.jsonl`,
580
+ };
581
+ }
582
+ /**
583
+ * A gitignored write-stream sidecar for one spawn's raw stdout (WS-03,
584
+ * SC-08) — fail-open throughout: an open/write/close failure is logged and
585
+ * flips `failed()`, but never throws into the run.
586
+ */
587
+ function createRawTranscriptSink(path, log) {
588
+ let failed = false;
589
+ let stream;
590
+ function fail(error) {
591
+ failed = true;
592
+ try {
593
+ log(`run-analytics: raw transcript sidecar failed: ${error.message}`);
594
+ }
595
+ catch {
596
+ // Logging must never be the reason this failure escapes fail-open.
597
+ }
430
598
  }
599
+ try {
600
+ stream = createWriteStream(path);
601
+ stream.on("error", fail);
602
+ }
603
+ catch (error) {
604
+ fail(error);
605
+ }
606
+ return {
607
+ write(chunk) {
608
+ if (failed || !stream)
609
+ return;
610
+ try {
611
+ stream.write(chunk);
612
+ }
613
+ catch (error) {
614
+ fail(error);
615
+ }
616
+ },
617
+ async close() {
618
+ if (failed || !stream)
619
+ return;
620
+ try {
621
+ await new Promise((resolvePromise) => {
622
+ stream.once("error", (error) => {
623
+ fail(error);
624
+ resolvePromise();
625
+ });
626
+ stream.end(() => resolvePromise());
627
+ });
628
+ }
629
+ catch (error) {
630
+ // A synchronous throw out of `stream.end()` itself (a corrupted
631
+ // stream state, not just an emitted "error" event) rejects the
632
+ // executor's promise; caught here so `close()` never throws into
633
+ // `invokeAgent` (SC-13) and the raw evidence ref is correctly
634
+ // omitted via `failed()`.
635
+ fail(error);
636
+ }
637
+ },
638
+ onError: fail,
639
+ failed: () => failed,
640
+ };
641
+ }
642
+ /**
643
+ * A defensible, not-exhaustive default stage per spawn seat — enough for a
644
+ * WS-01 reference span proving the recorder API end-to-end (see the
645
+ * WS-01/WS-02 emission-boundary decision). WS-02 owns the full dimension set
646
+ * (attempt seat, index, reason, workstream) that distinguishes an
647
+ * implementer attempt from a recovery or informed-retry one; this mapping is
648
+ * not meant to survive that instrumentation unchanged.
649
+ */
650
+ const STAGE_FOR_ROLE = {
651
+ agent: "implementer",
652
+ recoveryAgent: "recovery",
653
+ authorAgent: "author",
654
+ deciderAgent: "decision-review",
655
+ reviewerAgent: "test-critique",
656
+ };
657
+ function sameArgs(a, b) {
658
+ return a.length === b.length && a.every((value, index) => value === b[index]);
659
+ }
660
+ /**
661
+ * The §3.3.2 checks, in order, and the §3.4 resume derivation (WS-08).
662
+ * `originalAgent` is the operator's configured agent, pre-`permits.derive` —
663
+ * the pinned-session-flag check reads that, never the derived args, because
664
+ * an operator-authored session flag is what it exists to detect. Every other
665
+ * comparison is on the derived, pre-resume invocation, which is stable for a
666
+ * given role/config across a run (`permits.derive` is deterministic).
667
+ */
668
+ function resolveSessionContinuity(request, originalAgent, derivedAgent, cwd) {
669
+ const decline = (reason) => ({
670
+ continuity: { mode: "fresh", declineReason: reason },
671
+ spawnAgent: derivedAgent,
672
+ });
673
+ const handle = request.handle;
674
+ if (handle === undefined)
675
+ return decline("no-session-captured");
676
+ if (handle.command !== derivedAgent.command)
677
+ return decline("provider-mismatch");
678
+ if (!sameArgs(handle.args, derivedAgent.args) || handle.promptMode !== derivedAgent.promptMode) {
679
+ return decline("invocation-mismatch");
680
+ }
681
+ if (handle.cwd !== cwd)
682
+ return decline("cwd-mismatch");
683
+ if (hasPinnedSessionFlag(originalAgent))
684
+ return decline("operator-pinned-session-flag");
685
+ const resumeDerivation = deriveResumeInvocation(derivedAgent, handle.id);
686
+ if (!resumeDerivation.supported)
687
+ return decline("unsupported-resume-argv");
688
+ return { continuity: { mode: "resumed" }, spawnAgent: resumeDerivation.agent };
689
+ }
690
+ /** First 8 characters of a session id, for a log line — enough to tell two
691
+ * sessions apart at a glance without printing the whole machine-local id. */
692
+ function shortSessionId(id) {
693
+ return id.slice(0, 8);
431
694
  }
432
695
  /**
433
696
  * Spawns a configured agent role with a prompt; shared by every brief
@@ -439,20 +702,167 @@ export async function recordAgentTranscript(observe, role, result) {
439
702
  *
440
703
  * `observe`, when given, writes the spawn's transcript (if one was observed)
441
704
  * as JSONL under `build-logs/<programId>/` and logs the path — the one place
442
- * this happens, rather than duplicated across every spawning call site.
705
+ * this happens, rather than duplicated across every spawning call site. It
706
+ * also names a raw stdout sidecar (WS-03, SC-08), teed during the spawn and
707
+ * `close()`-awaited before any evidence reference resolves to it.
708
+ *
709
+ * `recorder`, when given, opens a span around the spawn (WS-01) and — after
710
+ * the spawn returns — receives one `provider-telemetry` point per
711
+ * SC-08-enumerated metric (WS-03), correlated by the `role` dimension and
712
+ * the normalized/raw transcript paths as evidence. A caller with no
713
+ * run-scoped recorder to hand gets a shared, permanently disarmed default
714
+ * that buffers harmlessly and performs no I/O — telemetry emission is then
715
+ * simply a no-op, never a thrown error.
716
+ *
717
+ * `spanDims`, when given, overrides the role-derived default stage/dimensions
718
+ * (WS-02) — the call site is the only party that knows which of a reused
719
+ * role's several stages (e.g. `reviewerAgent` driving `test-critique`,
720
+ * `failure-diagnosis`, or `whole-program-review`) this particular spawn is,
721
+ * plus the workstream/attempt dimensions. A caller that passes nothing keeps
722
+ * today's `STAGE_FOR_ROLE` default, so every pre-WS-02 call site (the doctor
723
+ * probe, standalone tests) is unaffected.
443
724
  */
444
- export async function invokeAgent(runner, agent, prompt, cwd, permits, role, observe) {
445
- const derived = await permits.derive(role, agent);
446
- const result = await runner({
447
- command: derived.agent.command,
448
- args: derived.agent.args,
449
- prompt,
450
- promptMode: derived.agent.promptMode,
451
- cwd,
452
- });
453
- if (observe)
454
- await recordAgentTranscript(observe, role, result);
455
- return result;
725
+ export async function invokeAgent(runner, agent, prompt, cwd, permits, role, observe, recorder = NOOP_RUN_RECORDER, spanDims,
726
+ /** Present only for the three seats that may continue a session (SC-13,
727
+ * WS-08); its presence is the permission and its `handle` is the
728
+ * candidate. Omitted everywhere else, which is how reviewer, decider,
729
+ * recovery and informed-retry seats are structurally incapable of
730
+ * resuming — not by a runtime check they could forget. */
731
+ continuityRequest) {
732
+ const span = recorder.span({ stage: STAGE_FOR_ROLE[role], role, ...spanDims });
733
+ try {
734
+ const derived = await permits.derive(role, agent);
735
+ // WS-08: resolved *after* `permits.derive` (so a resumed Claude spawn
736
+ // still carries `observabilityFlags`'s stream-json/verbose pair) and
737
+ // applied to whatever `permits.derive` returned — never to `agent`
738
+ // directly — so this works identically under the real context, the
739
+ // no-op context, and a test double (§2.7).
740
+ let spawnAgent = derived.agent;
741
+ let continuity;
742
+ if (continuityRequest) {
743
+ const resolved = resolveSessionContinuity(continuityRequest, agent, derived.agent, cwd);
744
+ continuity = resolved.continuity;
745
+ spawnAgent = resolved.spawnAgent;
746
+ if (continuity.mode === "resumed" && continuityRequest.handle) {
747
+ observe?.log(`resumed session ${shortSessionId(continuityRequest.handle.id)}`);
748
+ }
749
+ else if (continuity.declineReason) {
750
+ observe?.log(`session continuity declined for ${role}: ${describeSessionDecline(continuity.declineReason)}`);
751
+ }
752
+ }
753
+ const stem = observe ? allocateTranscriptStem(observe) : undefined;
754
+ // Only a spawn that actually requests structured stream-json output has
755
+ // raw provider events worth retaining — a codex (or any non-Claude)
756
+ // spawn writes no plaintext sidecar, matching its already-`unavailable`
757
+ // telemetry rather than littering build-logs/ with an empty file.
758
+ let rawSink;
759
+ if (observe && stem && claudeStreamJsonRequested(spawnAgent.args)) {
760
+ try {
761
+ await mkdir(dirname(stem.rawPath), { recursive: true });
762
+ rawSink = createRawTranscriptSink(stem.rawPath, observe.log);
763
+ }
764
+ catch (error) {
765
+ observe.log(`run-analytics: raw transcript sidecar could not be opened: ${error.message}`);
766
+ }
767
+ }
768
+ let result;
769
+ try {
770
+ result = await runner({
771
+ command: spawnAgent.command,
772
+ args: spawnAgent.args,
773
+ prompt,
774
+ promptMode: spawnAgent.promptMode,
775
+ cwd,
776
+ ...(rawSink ? { rawTranscript: rawSink } : {}),
777
+ });
778
+ }
779
+ finally {
780
+ // Awaited whether the runner resolved or rejected — a raw sink opened
781
+ // before the spawn must never be left dangling on a rejected promise
782
+ // (SC-13: fail-open, not leak-open), and awaited before the raw path
783
+ // is ever cited as evidence, so a reference to it never resolves
784
+ // before the bytes are durable (WS-03). `createRawTranscriptSink`'s
785
+ // own `close()` never throws, but this call site guards independently
786
+ // (defense in depth, SC-13) so a future sink implementation's close
787
+ // failure cannot escape into the run's outcome.
788
+ try {
789
+ await rawSink?.close();
790
+ }
791
+ catch (error) {
792
+ observe?.log(`run-analytics: raw transcript sidecar close failed: ${error.message}`);
793
+ }
794
+ }
795
+ let transcriptPath;
796
+ if (observe && stem) {
797
+ // A normalized-transcript write failure (full disk, EACCES) is a
798
+ // persistence defect, not a spawn failure — diagnosed and swallowed so
799
+ // it never flips a successful attempt's outcome (SC-13); the point
800
+ // still emits below, simply without this evidence ref.
801
+ try {
802
+ transcriptPath = await recordAgentTranscript(observe, role, result, stem.nonce);
803
+ }
804
+ catch (error) {
805
+ observe.log(`run-analytics: normalized transcript persistence failed: ${error.message}`);
806
+ }
807
+ }
808
+ const evidence = [];
809
+ if (transcriptPath)
810
+ evidence.push({ kind: "transcript", locality: "local", ref: transcriptPath });
811
+ if (stem && rawSink && !rawSink.failed()) {
812
+ evidence.push({ kind: "transcript", locality: "local", ref: stem.rawRelPath, note: "raw stdout" });
813
+ }
814
+ // Every spawn gets its enumerated provider-telemetry point set — a
815
+ // spawn whose runner attached no transcript at all (a non-Claude
816
+ // command, never asked for stream-json) still normalizes to a fully
817
+ // `unavailable` record rather than emitting nothing (the codex dogfood
818
+ // signal, SC-08). `recorder.point()` itself is guaranteed non-throwing
819
+ // (WS-01), but the telemetry it is fed here — a transcript's `.telemetry`
820
+ // shape, and the fan-out over it — comes from this workstream and is not;
821
+ // wrapped so a normalizer or fan-out defect is diagnosed and swallowed
822
+ // rather than propagating out of invokeAgent as if the agent itself had
823
+ // failed to spawn (SC-13).
824
+ try {
825
+ const telemetry = result.transcript?.telemetry ??
826
+ createTelemetryAccumulator({
827
+ configuredModel: parseConfiguredModel(spawnAgent.args),
828
+ commandProviderGuess: providerLabelFromCommand(spawnAgent.command),
829
+ }).finish({ observed: false, corrupted: false });
830
+ for (const point of providerTelemetryPoints(telemetry, { role }, evidence)) {
831
+ recorder.point(point);
832
+ }
833
+ }
834
+ catch (error) {
835
+ observe?.log(`run-analytics: provider telemetry emission failed: ${error.message}`);
836
+ }
837
+ span.close({
838
+ outcome: result.exitCode === 0 ? "success" : "failed",
839
+ ...(evidence.length === 0 ? {} : { evidence }),
840
+ });
841
+ // WS-08: assembled from the spawn's own reported id and the *pre-resume*
842
+ // derived invocation (`derived`, never `spawnAgent`) — so a handle
843
+ // captured from a resumed spawn compares equal to one captured from a
844
+ // fresh spawn of the same seat, which is what lets a fix-now fix resume
845
+ // a test-critique fix's session rather than re-declining on its own
846
+ // `--resume` argument (§3.3.3).
847
+ const session = result.sessionId
848
+ ? {
849
+ id: result.sessionId,
850
+ command: derived.agent.command,
851
+ cwd,
852
+ args: derived.agent.args,
853
+ promptMode: derived.agent.promptMode,
854
+ }
855
+ : undefined;
856
+ return {
857
+ ...result,
858
+ ...(session ? { session } : {}),
859
+ ...(continuityRequest ? { continuity: continuity ?? { mode: "fresh", declineReason: "no-session-captured" } } : {}),
860
+ };
861
+ }
862
+ catch (error) {
863
+ span.close({ outcome: "failed" });
864
+ throw error;
865
+ }
456
866
  }
457
867
  /**
458
868
  * The implementer: the `agent` block, or the environment fallback for hosts