@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.
- package/README.md +289 -1
- package/dist/agent-runner.d.ts +95 -5
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +442 -32
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-session.d.ts +123 -0
- package/dist/agent-session.d.ts.map +1 -0
- package/dist/agent-session.js +114 -0
- package/dist/agent-session.js.map +1 -0
- package/dist/atomic-write.d.ts +18 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +74 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/author.d.ts +118 -4
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +650 -163
- package/dist/author.js.map +1 -1
- package/dist/causal-analysis.d.ts +216 -0
- package/dist/causal-analysis.d.ts.map +1 -0
- package/dist/causal-analysis.js +786 -0
- package/dist/causal-analysis.js.map +1 -0
- package/dist/cli.js +22 -2
- package/dist/cli.js.map +1 -1
- package/dist/concurrency.d.ts +24 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +28 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/decider-review.d.ts +13 -1
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +21 -7
- package/dist/decider-review.js.map +1 -1
- package/dist/decision-ledger.d.ts +7 -0
- package/dist/decision-ledger.d.ts.map +1 -1
- package/dist/decision-ledger.js +25 -10
- package/dist/decision-ledger.js.map +1 -1
- package/dist/detect-package-manager.d.ts +10 -0
- package/dist/detect-package-manager.d.ts.map +1 -1
- package/dist/detect-package-manager.js +12 -0
- package/dist/detect-package-manager.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +24 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +254 -108
- package/dist/manifest.js.map +1 -1
- package/dist/permits.d.ts +18 -0
- package/dist/permits.d.ts.map +1 -1
- package/dist/permits.js +155 -0
- package/dist/permits.js.map +1 -1
- package/dist/preflight.d.ts +3 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +73 -57
- package/dist/preflight.js.map +1 -1
- package/dist/prompt-telemetry.d.ts +64 -0
- package/dist/prompt-telemetry.d.ts.map +1 -0
- package/dist/prompt-telemetry.js +112 -0
- package/dist/prompt-telemetry.js.map +1 -0
- package/dist/provider-telemetry.d.ts +106 -0
- package/dist/provider-telemetry.d.ts.map +1 -0
- package/dist/provider-telemetry.js +423 -0
- package/dist/provider-telemetry.js.map +1 -0
- package/dist/review-pass.d.ts +52 -10
- package/dist/review-pass.d.ts.map +1 -1
- package/dist/review-pass.js +114 -30
- package/dist/review-pass.js.map +1 -1
- package/dist/run-analytics-report.d.ts +286 -0
- package/dist/run-analytics-report.d.ts.map +1 -0
- package/dist/run-analytics-report.js +1242 -0
- package/dist/run-analytics-report.js.map +1 -0
- package/dist/run-analytics.d.ts +844 -0
- package/dist/run-analytics.d.ts.map +1 -0
- package/dist/run-analytics.js +777 -0
- package/dist/run-analytics.js.map +1 -0
- package/dist/run-log.d.ts +16 -0
- package/dist/run-log.d.ts.map +1 -0
- package/dist/run-log.js +32 -0
- package/dist/run-log.js.map +1 -0
- package/dist/run-program.d.ts +268 -4
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +2508 -762
- package/dist/run-program.js.map +1 -1
- package/dist/scheduler.d.ts +75 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +126 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/verify-cache.d.ts +64 -0
- package/dist/verify-cache.d.ts.map +1 -0
- package/dist/verify-cache.js +56 -0
- package/dist/verify-cache.js.map +1 -0
- package/dist/whole-program-review.d.ts +3 -0
- package/dist/whole-program-review.d.ts.map +1 -1
- package/dist/whole-program-review.js +8 -1
- package/dist/whole-program-review.js.map +1 -1
- package/dist/worktree.d.ts +124 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +397 -0
- package/dist/worktree.js.map +1 -0
- package/dist/write-queue.d.ts +25 -0
- package/dist/write-queue.d.ts.map +1 -0
- package/dist/write-queue.js +52 -0
- package/dist/write-queue.js.map +1 -0
- package/package.json +2 -2
- package/skills/plan-program/SKILL.md +37 -5
- package/dist/worktree-guard.d.ts +0 -40
- package/dist/worktree-guard.d.ts.map +0 -1
- package/dist/worktree-guard.js +0 -91
- package/dist/worktree-guard.js.map +0 -1
package/dist/agent-runner.js
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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({
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|