@kylecheng3146/agent-ops 0.2.6 → 0.2.8

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.
@@ -79,6 +79,7 @@ export function parseArgs(argv) {
79
79
  let dryRun = false;
80
80
  let json = false;
81
81
  let yes = false;
82
+ let rerun = false;
82
83
  let helpSeen = false;
83
84
  let versionSeen = false;
84
85
  for (let index = 0; index < argv.length; index += 1) {
@@ -242,6 +243,12 @@ export function parseArgs(argv) {
242
243
  }
243
244
  yes = true;
244
245
  break;
246
+ case "--rerun":
247
+ if (rerun) {
248
+ duplicate(token);
249
+ }
250
+ rerun = true;
251
+ break;
245
252
  case "--help":
246
253
  case "-h":
247
254
  if (helpSeen) {
@@ -313,7 +320,8 @@ export function parseArgs(argv) {
313
320
  profiles: [],
314
321
  dryRun: false,
315
322
  json,
316
- yes: false
323
+ yes: false,
324
+ rerun: false
317
325
  };
318
326
  }
319
327
  if (versionSeen && command !== undefined) {
@@ -337,7 +345,8 @@ export function parseArgs(argv) {
337
345
  checkAuth ||
338
346
  checkAuthTargets.length > 0 ||
339
347
  dryRun ||
340
- yes) {
348
+ yes ||
349
+ rerun) {
341
350
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
342
351
  }
343
352
  command = helpSeen ? "help" : "version";
@@ -399,7 +408,8 @@ export function parseArgs(argv) {
399
408
  if (harness !== undefined ||
400
409
  profiles.length > 0 ||
401
410
  dryRun ||
402
- yes) {
411
+ yes ||
412
+ rerun) {
403
413
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task commands do not accept harness, profile, dry-run, or yes options.");
404
414
  }
405
415
  const taskOptionInvalid = (action === "create" &&
@@ -492,6 +502,7 @@ export function parseArgs(argv) {
492
502
  ...(base === undefined ? {} : { base }),
493
503
  ...(checkAuth ? { checkAuth } : {}),
494
504
  ...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
505
+ rerun,
495
506
  dryRun,
496
507
  json,
497
508
  yes
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
9
9
  import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
10
10
  import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
11
+ import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js";
11
12
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
12
13
  import { TaskService } from "../../../runtime/src/task/service.js";
13
14
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -378,7 +379,11 @@ else {
378
379
  });
379
380
  }
380
381
  if (args.command === "task") {
381
- const sessionId = process.env.AGENT_OPS_SESSION_ID;
382
+ // Explicit `--session` wins, then an injected identity, then the
383
+ // id a SessionStart hook recorded for this checkout: a command
384
+ // run inside a session is never told which session it is in.
385
+ const sessionId = process.env.AGENT_OPS_SESSION_ID ??
386
+ await readRecordedSessionId(root);
382
387
  const policyConfigHash = args.action === "create"
383
388
  ? calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config)
384
389
  : undefined;
@@ -436,7 +441,15 @@ else {
436
441
  ...(reviewRole?.timeoutMs === undefined
437
442
  ? {}
438
443
  : { timeoutMs: reviewRole.timeoutMs }),
439
- preflightTarget: async (target) => await probeReviewTarget(target, { cwd: root, deep: true }),
444
+ preflightTarget: async (target, budget) => await probeReviewTarget(target, {
445
+ cwd: root,
446
+ deep: true,
447
+ // The probe answers within the chain's remaining budget
448
+ // or not at all; its own default would outlast it.
449
+ ...(budget === undefined
450
+ ? {}
451
+ : { timeoutMs: budget.timeoutMs })
452
+ }),
440
453
  verifySourceFingerprint: async (expected) => {
441
454
  const currentScope = await resolveReviewScope({
442
455
  root,
@@ -1,7 +1,8 @@
1
1
  import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { renderReviewResult } from "../../../../runtime/src/review/render.js";
4
- import { invalidateReviewAttestation, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
4
+ import { reviewReportResults } from "../../../../runtime/src/review/report.js";
5
+ import { findPriorFailingFindings, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
5
6
  import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
6
7
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
7
8
  import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
@@ -263,6 +264,55 @@ function hasCompleteReviewEvidence(result) {
263
264
  attempt.status === "PASS" &&
264
265
  attempt.sessionId !== undefined);
265
266
  }
267
+ /**
268
+ * The recorded verdict for this exact source, when it is still the right one:
269
+ * same source fingerprint, same task, and a stored report that still matches
270
+ * its attestation. Anything else returns null and the chain runs.
271
+ */
272
+ async function reusableReview(options, sourceFingerprint, context) {
273
+ if (options.root === undefined) {
274
+ return null;
275
+ }
276
+ const attestation = await findReviewAttestation(options.root, sourceFingerprint);
277
+ if (attestation === null || attestation.taskId !== context?.taskId) {
278
+ return null;
279
+ }
280
+ // The task's own policy baseline must still be the active one. This is
281
+ // checked before this point too; repeating it here keeps the reuse path
282
+ // safe wherever it is called from.
283
+ if (options.policyConfigHash !== undefined &&
284
+ context?.policyConfigHash !== options.policyConfigHash) {
285
+ return null;
286
+ }
287
+ const artifact = await readReviewReportArtifact(options.root, sourceFingerprint);
288
+ if (artifact === null ||
289
+ artifact.taskId !== attestation.taskId ||
290
+ artifact.report === undefined ||
291
+ artifact.adversarial === undefined) {
292
+ return null;
293
+ }
294
+ return {
295
+ status: "PASS",
296
+ harness: artifact.harness,
297
+ model: options.model ?? "configured",
298
+ effort: options.effort ?? "configured",
299
+ prompt: "",
300
+ results: reviewReportResults(artifact.report),
301
+ report: artifact.report,
302
+ adversarial: artifact.adversarial,
303
+ attempts: artifact.attempts,
304
+ preflight: artifact.preflight,
305
+ plannedTargets: artifact.plannedTargets,
306
+ sourceFingerprint,
307
+ reused: true,
308
+ ...(artifact.independence === undefined
309
+ ? {}
310
+ : { independence: artifact.independence }),
311
+ ...(artifact.sessionIsolation === undefined
312
+ ? {}
313
+ : { sessionIsolation: artifact.sessionIsolation })
314
+ };
315
+ }
266
316
  async function persistReviewEvidence(options, result, sourceFingerprint, context) {
267
317
  if (options.root === undefined || sourceFingerprint === undefined) {
268
318
  return result;
@@ -391,9 +441,7 @@ export async function runReviewCommand(options) {
391
441
  });
392
442
  }
393
443
  sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
394
- if (options.authorized) {
395
- await invalidateReviewAttestation(options.root, sourceFingerprint);
396
- }
444
+ let pendingInvalidation = options.authorized;
397
445
  if (context !== undefined && options.policyConfigHash !== undefined) {
398
446
  if (context.policyConfigHash === null) {
399
447
  return notRunEnvelope({
@@ -422,6 +470,27 @@ export async function runReviewCommand(options) {
422
470
  }
423
471
  verification = preflight.summary;
424
472
  }
473
+ if (pendingInvalidation) {
474
+ // Last of all the checks, never before them. A recorded PASS proves the
475
+ // source was reviewed; it says nothing about the policy in force now or
476
+ // about verification that has failed since, and both of those are
477
+ // decided above.
478
+ const reused = options.args.rerun
479
+ ? null
480
+ : await reusableReview(options, sourceFingerprint, context);
481
+ if (reused !== null) {
482
+ return okEnvelope("REVIEW_RESULT", {
483
+ message: "Independent review passed (reused recorded evidence).",
484
+ result: reused,
485
+ text: renderReviewResult({
486
+ ...reused,
487
+ ...(verification === undefined ? {} : { verification })
488
+ })
489
+ });
490
+ }
491
+ await invalidateReviewAttestation(options.root, sourceFingerprint);
492
+ pendingInvalidation = false;
493
+ }
425
494
  }
426
495
  const criteria = [
427
496
  ...(context?.criteria ?? GENERIC_CRITERIA)
@@ -452,6 +521,9 @@ export async function runReviewCommand(options) {
452
521
  }
453
522
  throw error;
454
523
  }
524
+ const priorFindings = options.root === undefined || context === undefined
525
+ ? []
526
+ : await findPriorFailingFindings(options.root, context.taskId);
455
527
  const result = await runIndependentReview({
456
528
  invocation: {
457
529
  harness: target,
@@ -462,7 +534,8 @@ export async function runReviewCommand(options) {
462
534
  effort: role?.effort ?? options.effort ?? "configured",
463
535
  packet,
464
536
  ...(scope === undefined ? {} : { scope }),
465
- ...(verification === undefined ? {} : { verification })
537
+ ...(verification === undefined ? {} : { verification }),
538
+ ...(priorFindings.length === 0 ? {} : { priorFindings })
466
539
  },
467
540
  authorized: options.authorized,
468
541
  execute: options.execute ?? (async () => ({
@@ -7,12 +7,20 @@ import { evaluateGuardrail } from "../guardrails/evaluate.js";
7
7
  import { appendLocalLog } from "../logging/local-log.js";
8
8
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
9
9
  import { redactSecrets } from "../security/redact.js";
10
+ import { DEFAULT_CHECKPOINT_BUDGET, renderTaskCheckpoint } from "../task/render.js";
11
+ import { parseTaskStateSource } from "../task/store.js";
10
12
  import { normalizeShellHookEvent } from "./shell.js";
11
13
  const execFile = promisify(execFileCallback);
12
14
  const MAX_CONTEXT_CHARS = 1_200;
13
15
  const MAX_GIT_STATUS_CHARS = 4_096;
14
16
  const DEFAULT_TELEMETRY_MAX_BYTES = 64 * 1024;
15
17
  const LOOP_SNAPSHOT_ID = "loop-snapshot";
18
+ const LOOP_SESSION_ID = "loop-session";
19
+ /** Task state outranks the goal when both compete for the same ceiling. */
20
+ const MAX_CHECKPOINT_CHARS = DEFAULT_CHECKPOINT_BUDGET;
21
+ const MAX_GOAL_CHARS = 400;
22
+ const MIN_GOAL_CHARS = 40;
23
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
16
24
  export const PROJECT_LOOP_EVENTS = [
17
25
  "SessionStart",
18
26
  "UserPromptSubmit",
@@ -199,6 +207,15 @@ function boundedContext(value) {
199
207
  ? normalized
200
208
  : `${normalized.slice(0, MAX_CONTEXT_CHARS - 14)}\n[truncated]`;
201
209
  }
210
+ /**
211
+ * Task titles and criterion descriptions are ordinary user text, so they get
212
+ * the same treatment as the goal file: secret-shaped content is dropped
213
+ * rather than injected, and everything else is redacted.
214
+ */
215
+ function safeStateContext(value, scope) {
216
+ const decision = evaluateGuardrail({ kind: "content", content: value, scope });
217
+ return decision.action === "block" ? undefined : redactSecrets(value);
218
+ }
202
219
  function safeGoalContext(source) {
203
220
  if (source === null || source.trim().length === 0) {
204
221
  return "No project loop goal is recorded.";
@@ -342,15 +359,183 @@ async function writeCompactSnapshot(options) {
342
359
  }), options.root);
343
360
  });
344
361
  }
345
- function sessionContext(goal, telemetryEntries) {
346
- return boundedContext([
362
+ function inputField(input, field) {
363
+ if (typeof input !== "object" || input === null) {
364
+ return undefined;
365
+ }
366
+ const value = input[field];
367
+ return typeof value === "string" && value.length > 0 ? value : undefined;
368
+ }
369
+ /**
370
+ * Claude Code publishes `source` on SessionStart: `compact` after a
371
+ * compaction, `resume` when the transcript is still present, `startup` and
372
+ * `clear` for an empty one. A host that publishes nothing is treated as
373
+ * `fresh`, which injects the least.
374
+ */
375
+ function restoreMode(input) {
376
+ switch (inputField(input, "source")) {
377
+ case "compact":
378
+ return "compact";
379
+ case "resume":
380
+ return "resume";
381
+ default:
382
+ return "fresh";
383
+ }
384
+ }
385
+ function sessionIdOf(input) {
386
+ const value = inputField(input, "session_id");
387
+ return value !== undefined && SESSION_ID_PATTERN.test(value)
388
+ ? value
389
+ : undefined;
390
+ }
391
+ function parseSessionMarker(source) {
392
+ if (source === null) {
393
+ return {};
394
+ }
395
+ const session = source.match(/^Session: (\S+)$/mu)?.[1];
396
+ const task = source.match(/^Task: (\S+)$/mu)?.[1];
397
+ return {
398
+ ...(session === undefined ? {} : { sessionId: session }),
399
+ ...(task === undefined ? {} : { taskId: task })
400
+ };
401
+ }
402
+ /**
403
+ * The bridge the CLI cannot build for itself: a hook is told the session id,
404
+ * a command run inside that session is not. Recording it here lets
405
+ * `agent-ops task attach` default to the session actually running.
406
+ *
407
+ * ponytail: last writer wins. Two concurrent sessions in one checkout already
408
+ * violate the one-writer rule; pass `--session` explicitly if you need that.
409
+ */
410
+ async function writeSessionMarker(options) {
411
+ const path = loopPath(options.root, options.harness, "loop-state.md");
412
+ await withPrivateFileLock(path, options.root, async () => {
413
+ const source = await readPrivateFile(path, options.root);
414
+ const baseline = source ?? "# Loop state\n";
415
+ const previous = parseSessionMarker(source);
416
+ const taskId = options.taskId ?? previous.taskId;
417
+ await writePrivateFile(path, applyManagedBlock(baseline, {
418
+ id: LOOP_SESSION_ID,
419
+ version: 1,
420
+ content: [
421
+ `Session: ${options.sessionId}`,
422
+ ...(taskId === undefined ? [] : [`Task: ${taskId}`])
423
+ ].join("\n")
424
+ }), options.root);
425
+ });
426
+ }
427
+ /**
428
+ * The session id a hook recorded for this checkout, for commands the host
429
+ * never tells. Both harness directories are considered and the most recently
430
+ * written marker wins, because a checkout may carry both.
431
+ */
432
+ export async function readRecordedSessionId(root) {
433
+ const candidates = [];
434
+ for (const harness of ["claude", "codex"]) {
435
+ const path = loopPath(root, harness, "loop-state.md");
436
+ try {
437
+ const marker = parseSessionMarker(await readPrivateFile(path, root));
438
+ if (marker.sessionId === undefined) {
439
+ continue;
440
+ }
441
+ const status = await lstat(path);
442
+ candidates.push({ sessionId: marker.sessionId, at: status.mtimeMs });
443
+ }
444
+ catch {
445
+ continue;
446
+ }
447
+ }
448
+ return candidates.sort((left, right) => right.at - left.at)[0]?.sessionId;
449
+ }
450
+ /**
451
+ * The task attached to this session, never merely the most recent one: a
452
+ * checkpoint from somebody else's task is worse than none. When this session
453
+ * is unknown, the previous task is offered as a one-line pointer instead, so
454
+ * the agent can attach to it deliberately.
455
+ */
456
+ async function restoreState(options) {
457
+ const marker = parseSessionMarker(await readPrivateFile(loopPath(options.root, options.harness, "loop-state.md"), options.root));
458
+ const state = parseTaskStateSource(await readPrivateFile(join(options.root, ".agent-ops", "tasks", "state.json"), options.root));
459
+ const attachment = options.sessionId === undefined
460
+ ? undefined
461
+ : state.sessions.find((candidate) => candidate.sessionId === options.sessionId);
462
+ const attached = attachment === undefined
463
+ ? undefined
464
+ : state.tasks.find((record) => record.task.id === attachment.taskId);
465
+ if (attached !== undefined) {
466
+ return {
467
+ checkpoint: renderTaskCheckpoint(attached, DEFAULT_CHECKPOINT_BUDGET, (value) => safeStateContext(value, "task-checkpoint")),
468
+ // A fresh start of an attached session — `/clear` keeps the id — is told
469
+ // the task exists without being handed its contents.
470
+ pointer: `Attached task here: ${attached.task.id} — its state was not ` +
471
+ `injected; read it with \`agent-ops task status --task ` +
472
+ `${attached.task.id}\`.`,
473
+ taskId: attached.task.id,
474
+ outcome: "hit"
475
+ };
476
+ }
477
+ const previous = marker.taskId === undefined
478
+ ? undefined
479
+ : state.tasks.find((record) => record.task.id === marker.taskId && record.status === "active");
480
+ if (previous === undefined) {
481
+ return { outcome: "miss" };
482
+ }
483
+ return {
484
+ pointer: `Last active task here: ${previous.task.id} — ` +
485
+ `attach it with \`agent-ops task attach --task ${previous.task.id}\` ` +
486
+ "to restore its state, or start a new one.",
487
+ taskId: previous.task.id,
488
+ outcome: "pointer"
489
+ };
490
+ }
491
+ function clamp(value, limit) {
492
+ return value.length <= limit ? value : `${value.slice(0, limit - 14)}\n[truncated]`;
493
+ }
494
+ function sessionContext(options) {
495
+ // Task state is assembled first and clamped on its own budget, so a long
496
+ // goal can no longer crowd out the one thing recovery exists to carry.
497
+ // Strictly one shape per mode. A resumed transcript still holds the state,
498
+ // and a fresh session is offered the previous task rather than handed its
499
+ // contents: `/clear` keeps the session id, so an attachment alone must not
500
+ // be read as "this context still knows the task".
501
+ const restored = (() => {
502
+ if (options.mode === "compact") {
503
+ // Already sanitized field by field, and never clamped here: the
504
+ // criterion ids and the evidence pointer are required, and slicing the
505
+ // assembled text is what would cut through them.
506
+ return options.restore.checkpoint === undefined
507
+ ? []
508
+ : [options.restore.checkpoint, ""];
509
+ }
510
+ if (options.mode === "fresh") {
511
+ const pointer = options.restore.pointer === undefined
512
+ ? undefined
513
+ : safeStateContext(options.restore.pointer, "task-pointer");
514
+ return pointer === undefined ? [] : [pointer, ""];
515
+ }
516
+ return [];
517
+ })();
518
+ const telemetry = `Telemetry: ${options.telemetryEntries} recent redacted event(s).`;
519
+ const fixed = [
347
520
  "agent-ops project loop is active.",
348
521
  "",
349
- "Current goal:",
350
- goal,
351
- "",
352
- `Telemetry: ${telemetryEntries} recent redacted event(s).`
353
- ].join("\n"));
522
+ ...restored,
523
+ telemetry
524
+ ];
525
+ // The goal takes what is left rather than a fixed share: a task at the
526
+ // schema's limits needs most of the ceiling, and dropping the goal line is
527
+ // better than truncating the state the session is being restored with.
528
+ const spent = fixed.reduce((total, line) => total + line.length + 1, 0);
529
+ const goalRoom = Math.min(MAX_GOAL_CHARS, MAX_CONTEXT_CHARS - spent - "Current goal:".length - 2);
530
+ return boundedContext((goalRoom < MIN_GOAL_CHARS
531
+ ? fixed
532
+ : [
533
+ ...fixed.slice(0, -1),
534
+ "Current goal:",
535
+ clamp(options.goal, goalRoom),
536
+ "",
537
+ telemetry
538
+ ]).join("\n"));
354
539
  }
355
540
  function sessionOutput(harness, context) {
356
541
  return {
@@ -398,7 +583,9 @@ export async function runProjectLoop(options) {
398
583
  return noOutput();
399
584
  }
400
585
  const root = await resolveLoopRoot(options.input, options.root, options.harness).catch(() => null);
401
- if (root !== null) {
586
+ // SessionStart records its own event naming the restore outcome, so the
587
+ // generic record is skipped rather than written twice.
588
+ if (root !== null && options.event !== "SessionStart") {
402
589
  await appendTelemetry({
403
590
  root,
404
591
  harness: options.harness,
@@ -424,12 +611,48 @@ export async function runProjectLoop(options) {
424
611
  }).catch(() => undefined);
425
612
  }
426
613
  if (options.event === "SessionStart" && root !== null) {
614
+ const sessionId = sessionIdOf(options.input);
615
+ const restore = await restoreState({
616
+ root,
617
+ harness: options.harness,
618
+ sessionId
619
+ }).catch(() => ({ outcome: "miss" }));
620
+ // Exactly one event per SessionStart, written before the context is built
621
+ // so a later read failure cannot lose the outcome.
622
+ await appendTelemetry({
623
+ root,
624
+ harness: options.harness,
625
+ event: "SessionStart",
626
+ decision: {
627
+ blocked: false,
628
+ outcome: "observed",
629
+ code: `restore-${restore.outcome}`,
630
+ denial: "none"
631
+ },
632
+ ...(options.now === undefined ? {} : { now: options.now }),
633
+ ...(options.telemetryMaxBytes === undefined
634
+ ? {}
635
+ : { maxBytes: options.telemetryMaxBytes })
636
+ }).catch(() => undefined);
637
+ if (sessionId !== undefined) {
638
+ await writeSessionMarker({
639
+ root,
640
+ harness: options.harness,
641
+ sessionId,
642
+ ...(restore.taskId === undefined ? {} : { taskId: restore.taskId })
643
+ }).catch(() => undefined);
644
+ }
427
645
  try {
428
646
  const [goal, telemetryEntries] = await Promise.all([
429
647
  readPrivateFile(loopPath(root, options.harness, "loop-goal.md"), root),
430
648
  telemetryCount(root, options.harness)
431
649
  ]);
432
- return sessionOutput(options.harness, sessionContext(safeGoalContext(goal), telemetryEntries));
650
+ return sessionOutput(options.harness, sessionContext({
651
+ goal: safeGoalContext(goal),
652
+ telemetryEntries,
653
+ mode: restoreMode(options.input),
654
+ restore
655
+ }));
433
656
  }
434
657
  catch {
435
658
  return noOutput();
@@ -1,3 +1,4 @@
1
+ import { readdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import { AgentOpsError } from "../fs/paths.js";
3
4
  import { sha256 } from "../fs/hash.js";
@@ -25,7 +26,9 @@ function safeAttempt(attempt) {
25
26
  status: attempt.status,
26
27
  ...(attempt.sessionId === undefined ? {} : { sessionId: safeText(attempt.sessionId) }),
27
28
  ...(attempt.reason === undefined ? {} : { reason: safeText(attempt.reason) }),
28
- ...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
29
+ ...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) }),
30
+ // Numbers only, so the cost of a round survives in the record too.
31
+ ...(attempt.metrics === undefined ? {} : { metrics: attempt.metrics })
29
32
  };
30
33
  }
31
34
  function safePreflight(attempt) {
@@ -239,6 +242,33 @@ export async function findReviewAttestation(root, sourceFingerprint) {
239
242
  return null;
240
243
  }
241
244
  }
245
+ /**
246
+ * The stored report behind an attestation, for a caller that wants to reuse
247
+ * the recorded verdict rather than run the chain again. Returns null unless it
248
+ * parses, matches this exact source and records a PASS: a reuse path must fail
249
+ * closed exactly like the gates that read the attestation.
250
+ */
251
+ export async function readReviewReportArtifact(root, sourceFingerprint) {
252
+ if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
253
+ return null;
254
+ }
255
+ const source = await readPrivateFile(artifactPath(root, sourceFingerprint), root);
256
+ if (source === null) {
257
+ return null;
258
+ }
259
+ try {
260
+ const value = JSON.parse(source);
261
+ return value.sourceFingerprint === sourceFingerprint &&
262
+ value.status === "PASS" &&
263
+ value.report !== undefined &&
264
+ value.adversarial !== undefined
265
+ ? value
266
+ : null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
242
272
  /** A new authorized attempt supersedes any earlier PASS for this source. */
243
273
  export async function invalidateReviewAttestation(root, sourceFingerprint) {
244
274
  if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
@@ -249,3 +279,89 @@ export async function invalidateReviewAttestation(root, sourceFingerprint) {
249
279
  await writePrivateFile(path, "null\n", root);
250
280
  }
251
281
  }
282
+ /** Prior findings stay a short checklist, never a second report to digest. */
283
+ export const MAX_PRIOR_FINDINGS = 20;
284
+ export const MAX_PRIOR_FINDINGS_BYTES = 16 * 1024;
285
+ function blockingFindings(artifact) {
286
+ // A refuted PASS failed on the adversarial report; otherwise the primary
287
+ // report is the one that failed.
288
+ const report = artifact.adversarial?.refuted === true
289
+ ? artifact.adversarial.report
290
+ : artifact.report;
291
+ const findings = [];
292
+ let bytes = 2;
293
+ for (const finding of redactReviewReport(report).findings) {
294
+ if (!finding.blocking) {
295
+ continue;
296
+ }
297
+ const prior = {
298
+ severity: finding.severity,
299
+ title: finding.title,
300
+ details: finding.details,
301
+ locations: finding.locations,
302
+ criterionIds: finding.criterionIds
303
+ };
304
+ bytes += Buffer.byteLength(JSON.stringify(prior), "utf8") + 1;
305
+ if (findings.length === MAX_PRIOR_FINDINGS || bytes > MAX_PRIOR_FINDINGS_BYTES) {
306
+ break;
307
+ }
308
+ findings.push(prior);
309
+ }
310
+ return findings;
311
+ }
312
+ /**
313
+ * Blocking findings of this task's most recent decided review, when that
314
+ * review failed. NOT_RUN artifacts decide nothing and are skipped; a newer
315
+ * PASS settles the earlier findings, so none are returned. Any unreadable
316
+ * record is ignored: these are a hint for the next reviewer, and without them
317
+ * the review simply runs as it always has.
318
+ */
319
+ export async function findPriorFailingFindings(root, taskId) {
320
+ const directory = join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"));
321
+ let names;
322
+ try {
323
+ names = await readdir(directory);
324
+ }
325
+ catch {
326
+ return [];
327
+ }
328
+ // ponytail: linear scan of every report; index by task if the directory grows large.
329
+ let latest;
330
+ for (const name of names) {
331
+ const fingerprint = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
332
+ if (!name.endsWith(REVIEW_REPORT_ARTIFACT_SUFFIX) || !FINGERPRINT_PATTERN.test(fingerprint)) {
333
+ continue;
334
+ }
335
+ try {
336
+ const source = await readPrivateFile(artifactPath(root, fingerprint), root);
337
+ if (source === null) {
338
+ continue;
339
+ }
340
+ const value = JSON.parse(source);
341
+ const decided = value.status === "PASS" ||
342
+ (value.status === "FAIL" && value.report !== undefined);
343
+ if (value?.schemaVersion !== 1 ||
344
+ value.sourceFingerprint !== fingerprint ||
345
+ value.taskId !== taskId ||
346
+ !decided ||
347
+ !Number.isFinite(Date.parse(value.createdAt))) {
348
+ continue;
349
+ }
350
+ if (latest === undefined || Date.parse(value.createdAt) > Date.parse(latest.createdAt)) {
351
+ latest = value;
352
+ }
353
+ }
354
+ catch {
355
+ continue;
356
+ }
357
+ }
358
+ if (latest?.status !== "FAIL") {
359
+ return [];
360
+ }
361
+ try {
362
+ return blockingFindings(latest);
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ }