@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.
- package/dist/packages/cli/src/args.js +14 -3
- package/dist/packages/cli/src/bin.js +15 -2
- package/dist/packages/cli/src/commands/review.js +78 -5
- package/dist/runtime/src/hooks/codex-loop.js +232 -9
- package/dist/runtime/src/review/attestation.js +117 -1
- package/dist/runtime/src/review/execute.js +166 -12
- package/dist/runtime/src/review/probe.js +11 -1
- package/dist/runtime/src/review/render.js +15 -1
- package/dist/runtime/src/review/runner.js +76 -2
- package/dist/runtime/src/review/usage.js +71 -0
- package/dist/runtime/src/task/render.js +89 -0
- package/dist/runtime/src/task/store.js +8 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|