@kylecheng3146/agent-ops 0.9.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +3 -2
  2. package/dist/packages/cli/src/args.js +58 -14
  3. package/dist/packages/cli/src/bin.js +10 -3
  4. package/dist/packages/cli/src/cli.js +30 -6
  5. package/dist/packages/cli/src/commands/advance.js +2 -1
  6. package/dist/packages/cli/src/commands/escapes.js +109 -0
  7. package/dist/packages/cli/src/commands/parallel.js +1 -1
  8. package/dist/packages/cli/src/commands/review-show.js +1 -1
  9. package/dist/packages/cli/src/commands/review.js +63 -11
  10. package/dist/packages/cli/src/commands/run.js +10 -3
  11. package/dist/packages/cli/src/commands/task.js +41 -3
  12. package/dist/packages/cli/src/commands/update.js +16 -4
  13. package/dist/packages/cli/src/commands/verify.js +4 -1
  14. package/dist/packages/cli/src/context.js +22 -4
  15. package/dist/packages/cli/src/debug-intake.js +1 -1
  16. package/dist/packages/cli/src/office-entry.js +8 -4
  17. package/dist/packages/cli/src/owned-run-step.js +4 -4
  18. package/dist/packages/cli/src/parallel-deps.js +0 -2
  19. package/dist/packages/cli/src/run-deps.js +12 -9
  20. package/dist/packages/cli/src/run-entry.js +76 -17
  21. package/dist/packages/cli/src/run-setup-entry.js +2 -2
  22. package/dist/runtime/src/escape/link.js +317 -0
  23. package/dist/runtime/src/escape/origin.js +20 -0
  24. package/dist/runtime/src/escape/rules.js +252 -0
  25. package/dist/runtime/src/fs/temporary.js +10 -0
  26. package/dist/runtime/src/install/harness.js +7 -4
  27. package/dist/runtime/src/logging/local-log.js +44 -1
  28. package/dist/runtime/src/office/scene.js +1 -1
  29. package/dist/runtime/src/office/server.js +43 -16
  30. package/dist/runtime/src/office/snapshot.js +1 -1
  31. package/dist/runtime/src/parallel/finish.js +139 -65
  32. package/dist/runtime/src/parallel/manage.js +51 -1
  33. package/dist/runtime/src/parallel/receipt.js +3 -1
  34. package/dist/runtime/src/parallel/service.js +5 -1
  35. package/dist/runtime/src/review/execute.js +20 -12
  36. package/dist/runtime/src/review/packet.js +20 -7
  37. package/dist/runtime/src/review/probe.js +3 -2
  38. package/dist/runtime/src/review/runner.js +26 -3
  39. package/dist/runtime/src/review/slots.js +1 -1
  40. package/dist/runtime/src/run/controls.js +99 -1
  41. package/dist/runtime/src/run/hosts/codex.js +28 -1
  42. package/dist/runtime/src/run/hosts/protocol.js +5 -2
  43. package/dist/runtime/src/run/integration.js +35 -13
  44. package/dist/runtime/src/run/policy.js +65 -10
  45. package/dist/runtime/src/run/scheduler.js +10 -5
  46. package/dist/runtime/src/run/service.js +68 -21
  47. package/dist/runtime/src/run/supervisor.js +11 -2
  48. package/dist/runtime/src/run/transport.js +1 -0
  49. package/dist/runtime/src/schema/validate.js +6 -1
  50. package/dist/runtime/src/security/permissions.js +85 -0
  51. package/dist/runtime/src/surface/discovery.js +79 -2
  52. package/dist/runtime/src/surface/schema.js +43 -2
  53. package/dist/runtime/src/task/completion.js +20 -0
  54. package/dist/runtime/src/task/contract.js +3 -1
  55. package/dist/runtime/src/task/debug-state.js +9 -3
  56. package/dist/runtime/src/task/debug-user-event.js +82 -17
  57. package/dist/runtime/src/task/debug.js +59 -16
  58. package/dist/runtime/src/task/service.js +58 -6
  59. package/dist/runtime/src/verify/acceptance-replay.js +24 -11
  60. package/docs/en/guides/configuration.md +24 -5
  61. package/docs/en/guides/task-debug.md +26 -2
  62. package/docs/en/spec/acceptance-and-evidence.md +104 -2
  63. package/docs/en/spec/harness-adapters.md +26 -1
  64. package/docs/en/spec/loop-engineering.md +34 -6
  65. package/docs/en/spec/review.md +6 -0
  66. package/docs/zh-TW/guides/configuration.md +20 -4
  67. package/docs/zh-TW/guides/task-debug.md +19 -2
  68. package/docs/zh-TW/spec/acceptance-and-evidence.md +84 -4
  69. package/docs/zh-TW/spec/harness-adapters.md +21 -1
  70. package/docs/zh-TW/spec/loop-engineering.md +9 -3
  71. package/docs/zh-TW/spec/review.md +5 -0
  72. package/package.json +1 -1
  73. package/schemas/task.schema.json +8 -1
@@ -1,10 +1,13 @@
1
- import { lstat, readFile, readdir, realpath } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { isAbsolute, join, relative, sep } from "node:path";
4
5
  import { sha256 } from "../fs/hash.js";
5
6
  import { AgentOpsError } from "../fs/paths.js";
6
7
  export function debugUserControl(text) {
7
8
  const value = text.trim();
9
+ if (/^(?:不要給預算時間(?:\s+直接完整驗證與review)?|取消時間限制|no time limit)[。.!!]?$/iu.test(value))
10
+ return "unlimited";
8
11
  if (/^(?:暫停|暂停|先暫停|先暂停|停止|stop|pause)[。.!!]?$/iu.test(value))
9
12
  return "pause";
10
13
  if (/^(?:先)?只診斷(?:,等我補失敗時證據)?[。.!!]?$/u.test(value) || /^(?:先)?只诊断[。.!!]?$/u.test(value) || /^diagnos(?:e|is)[ -]only[.!]?$/iu.test(value))
@@ -37,6 +40,57 @@ function codexUserText(row) {
37
40
  catch { /* An ambiguous reply cannot authorize a control change. */ }
38
41
  return undefined;
39
42
  }
43
+ /** Bound each row, not the transcript: assistant/tool output must not hide later human input. */
44
+ async function* nativeLines(path) {
45
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ try {
47
+ if (!(await file.stat()).isFile())
48
+ return;
49
+ let pending = Buffer.alloc(0), oversized = false, index = 0;
50
+ const started = Date.now();
51
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
52
+ if (Date.now() - started > 10_000)
53
+ throw new Error("Native transcript read timed out");
54
+ const bytes = chunk;
55
+ let start = 0;
56
+ while (start < bytes.length) {
57
+ const end = bytes.indexOf(10, start), stop = end < 0 ? bytes.length : end;
58
+ if (!oversized) {
59
+ if (pending.length + stop - start > 1024 * 1024) {
60
+ pending = Buffer.alloc(0);
61
+ oversized = true;
62
+ }
63
+ else
64
+ pending = Buffer.concat([pending, bytes.subarray(start, stop)]);
65
+ }
66
+ if (end < 0)
67
+ break;
68
+ if (!oversized)
69
+ yield { line: pending.toString("utf8"), index };
70
+ index++;
71
+ pending = Buffer.alloc(0);
72
+ oversized = false;
73
+ start = end + 1;
74
+ }
75
+ }
76
+ if (!oversized && pending.length > 0)
77
+ yield { line: pending.toString("utf8"), index };
78
+ }
79
+ finally {
80
+ await file.close();
81
+ }
82
+ }
83
+ async function ownedNativeSession(root, worktreeRoot, sessionId) {
84
+ if (process.env.CODEX_THREAD_ID !== sessionId || worktreeRoot === undefined)
85
+ return false;
86
+ const { readWorktreeRecord } = await import("../parallel/service.js");
87
+ const record = await readWorktreeRecord(worktreeRoot);
88
+ if (record === null || record.sessionId !== sessionId || record.agentId !== undefined || record.runId !== undefined)
89
+ return false;
90
+ const [main, worktree, recordMain, recordPath] = await Promise.all([root, worktreeRoot, record.mainRoot, record.path].map(path => realpath(path)));
91
+ const inside = relative(join(main, ".worktrees"), worktree);
92
+ return main === recordMain && worktree === recordPath && inside.length > 0 && !inside.startsWith("..") && !isAbsolute(inside);
93
+ }
40
94
  /** Host transcripts are the user-event authority; CLI actor/text fields are never accepted. */
41
95
  /** `worktreeRoot` is the session's owned worktree: Claude stamps each row with the shell's cwd, which moves there after `cd`. */
42
96
  export async function readDebugUserEvent(root, sessionId, home = homedir(), kind = "latest", worktreeRoot) {
@@ -59,35 +113,46 @@ export async function readDebugUserEvent(root, sessionId, home = homedir(), kind
59
113
  catch { /* The host need not be installed. */ }
60
114
  }
61
115
  await visit(codex, 3);
116
+ const owned = await ownedNativeSession(root, worktreeRoot, sessionId).catch(() => false);
62
117
  for (const path of paths) {
63
118
  try {
64
119
  const stat = await lstat(path), canonical = await realpath(path);
65
120
  const fromHome = relative(canonicalHome, canonical);
66
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024 || fromHome.startsWith("..") || isAbsolute(fromHome))
67
- continue;
68
- const lines = (await readFile(path, "utf8")).split("\n");
69
- const rows = lines.map(line => { try {
70
- return JSON.parse(line);
71
- }
72
- catch {
73
- return null;
74
- } });
75
121
  const codexSource = path.startsWith(codex + sep);
76
- if (codexSource && !rows.some(row => row?.type === "session_meta" && row.payload?.id === sessionId && row.payload?.cwd === root))
122
+ if (!stat.isFile() || stat.isSymbolicLink() || (!codexSource && stat.size > 16 * 1024 * 1024) || fromHome.startsWith("..") || isAbsolute(fromHome))
77
123
  continue;
78
- for (let index = rows.length - 1; index >= 0; index--) {
79
- const row = rows[index];
124
+ let matchedSession = false, event;
125
+ for await (const { line, index } of nativeLines(path)) {
126
+ let row;
127
+ try {
128
+ row = JSON.parse(line);
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ if (codexSource && row?.type === "session_meta") {
134
+ if (row.payload?.id !== sessionId || (row.payload?.cwd !== root && !owned)) {
135
+ event = undefined;
136
+ matchedSession = false;
137
+ break;
138
+ }
139
+ matchedSession = true;
140
+ }
80
141
  const text = codexSource ? codexUserText(row) :
81
142
  row?.type === "user" && row.sessionId === sessionId && (row.cwd === root || (worktreeRoot !== undefined && row.cwd === worktreeRoot)) && row.message?.role === "user" &&
82
143
  row.isMeta !== true && (row.origin === undefined || row.origin?.kind === "human") ? row.message.content : undefined;
83
144
  if (typeof text !== "string" || text.length > 64 * 1024 || (kind === "control" && debugUserControl(text) === null))
84
145
  continue;
85
146
  const timestamp = Date.parse(row.timestamp);
86
- if (!Number.isFinite(timestamp))
87
- break;
88
- const sourceDigest = sha256(lines[index]);
89
- return { id: sha256(relative(home, path) + ":" + index + ":" + sourceDigest), sourceDigest, text, timestamp };
147
+ if (!Number.isFinite(timestamp)) {
148
+ event = undefined;
149
+ continue;
150
+ }
151
+ const sourceDigest = sha256(line);
152
+ event = { id: sha256(relative(home, path) + ":" + index + ":" + sourceDigest), sourceDigest, text, timestamp };
90
153
  }
154
+ if (event !== undefined && (!codexSource || matchedSession))
155
+ return event;
91
156
  }
92
157
  catch { /* An unavailable native source cannot grant time. */ }
93
158
  }
@@ -14,13 +14,14 @@ import { resolveReviewScope } from "../review/scope.js";
14
14
  import { runVerificationCommand } from "../verify/spawn.js";
15
15
  import { checkTaskVerificationEvidence } from "./completion.js";
16
16
  import { taskContractHash, criterionContractHash } from "./contract.js";
17
- import { DEBUG_EXTENSION_MS, debugProofDigest, debugRemainingMs, debugRounds, debugStopCause } from "./debug-state.js";
17
+ import { DEBUG_EXTENSION_MS, debugSpentMs, debugProofDigest, debugRemainingMs, debugRounds, debugStopCause } from "./debug-state.js";
18
18
  import { FileTaskStore } from "./store.js";
19
19
  import { debugUserControl, readDebugUserEvent } from "./debug-user-event.js";
20
20
  import { readWorktreeRecord } from "../parallel/service.js";
21
21
  import { evaluateGuardrail } from "../guardrails/evaluate.js";
22
22
  import { sameDebugText } from "./debug-intake.js";
23
23
  import { nativeProcessIdentity } from "../run/transport.js";
24
+ import { declareEscape, LEDGER_DIRECTORY, removeEscapeRecord, resolveOrigin } from "../escape/link.js";
24
25
  function fail(code, message) { throw new AgentOpsError(code, message); }
25
26
  const bounded = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 4096 && !v.includes("\0");
26
27
  const session = (v) => bounded(v) && v.length <= 256;
@@ -93,7 +94,8 @@ export class DebugService {
93
94
  const records = await this.options.tasks.list();
94
95
  const materials = new Set(records.flatMap(r => r.task.criteria.flatMap(c => c.acceptance?.bindings.flatMap(b => b.materials.map(m => m.path)) ?? [])));
95
96
  const paths = new Set([...await collectBaseChangePaths(this.options.gitRunner, state.baselineCommit), ...(await collectChangeSurface(this.options.gitRunner)).paths]);
96
- const product = [...paths].filter(path => !materials.has(path) && !/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort();
97
+ // The escape ledger is data about the fix, not a repair candidate.
98
+ const product = [...paths].filter(path => !materials.has(path) && !path.startsWith(LEDGER_DIRECTORY + "/") && !/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort();
97
99
  const entries = [];
98
100
  for (const path of product) {
99
101
  try {
@@ -142,7 +144,13 @@ export class DebugService {
142
144
  await this.#mutate(root, next);
143
145
  return await this.root(root.task.id);
144
146
  }
145
- async #evidence(taskId, reference) {
147
+ /**
148
+ * `sealed` admits a strategy switch's citation of this issue's own sealed
149
+ * regression history: a scope revision stales every current reference while
150
+ * verification stays stopped until the switch is recorded. Hypotheses and
151
+ * delivery still need evidence for the current contract and candidate.
152
+ */
153
+ async #evidence(taskId, reference, sealed = false) {
146
154
  const { task, config, sourceFingerprint } = await this.#scope(taskId);
147
155
  const store = new FileEvidenceStore(this.options.root, this.options.root);
148
156
  const checked = validateEvidence(await store.load(reference));
@@ -152,9 +160,16 @@ export class DebugService {
152
160
  const root = await this.root(taskId);
153
161
  const head = await this.options.gitRunner.run(["rev-parse", "HEAD"]);
154
162
  const candidate = new TextDecoder().decode(head.stdout).trim();
155
- if (head.exitCode !== 0 || e.taskId !== taskId || e.taskContractHash !== taskContractHash(task.task) || e.configHash !== calculateConfigHash(config) ||
156
- e.sourceFingerprint !== sourceFingerprint || p === undefined || p.pairedCommit !== (p.phase === "baseline" ? candidate : root.debug.baselineCommit) || p.commit !== (p.phase === "baseline" ? root.debug.baselineCommit : candidate) ||
157
- !task.task.criteria.some(c => c.id === e.criterionId && p.criterionContractHash === criterionContractHash(c) && c.acceptance?.bindings.some(b => b.runnerId === e.commandId && sha256(canonicalJson({ runnerId: b.runnerId, materials: b.materials })) === p.bindingHash) && c.acceptance?.mode === "behavioral" && c.acceptance.baselineCommit === root.debug.baselineCommit))
163
+ const current = p !== undefined && e.taskContractHash === taskContractHash(task.task) && e.configHash === calculateConfigHash(config) &&
164
+ e.sourceFingerprint === sourceFingerprint && p.pairedCommit === (p.phase === "baseline" ? candidate : root.debug.baselineCommit) &&
165
+ p.commit === (p.phase === "baseline" ? root.debug.baselineCommit : candidate);
166
+ const historical = sealed && p !== undefined && (p.phase === "baseline" ? p.commit : p.pairedCommit) === root.debug.baselineCommit &&
167
+ root.debug.receipts.some(r => r.kind === "verify" && r.taskId === taskId && r.references.includes(reference));
168
+ if (head.exitCode !== 0 || e.taskId !== taskId || p === undefined || !(current || historical) ||
169
+ !task.task.criteria.some(c => c.id === e.criterionId && c.acceptance?.mode === "behavioral" && c.acceptance.baselineCommit === root.debug.baselineCommit &&
170
+ (current
171
+ ? p.criterionContractHash === criterionContractHash(c) && c.acceptance.bindings.some(b => b.runnerId === e.commandId && sha256(canonicalJson({ runnerId: b.runnerId, materials: b.materials })) === p.bindingHash)
172
+ : c.acceptance.bindings.some(b => b.runnerId === e.commandId))))
158
173
  return fail("TASK_DEBUG_EVIDENCE_STALE", "Evidence must discriminate the original issue at its immutable baseline and current candidate.");
159
174
  const source = await readPrivateFile(join(this.options.root, p.executionArtifact), this.options.root);
160
175
  if (source === null || p.executionArtifact !== ".agent-ops/tasks/acceptance/" + sha256(source) + ".json")
@@ -240,9 +255,30 @@ export class DebugService {
240
255
  fail("TASK_DEBUG_STRATEGY_INVALID", "Document a substantive different verification method and evidence-backed reason.");
241
256
  if (debugStopCause(root.debug) !== "strategy-required" || root.debug.strategy !== null || root.debug.operation !== null)
242
257
  fail("TASK_DEBUG_STRATEGY_NOT_ALLOWED", "Exactly one strategy switch is allowed after two stagnant repair rounds.");
243
- await this.#evidence(taskId, input.reference);
258
+ await this.#evidence(taskId, input.reference, true);
244
259
  await this.#mutate(root, { ...root.debug, strategy: { from: input.from, to: input.to, reason: input.reason, references: [input.reference], afterRound: debugRounds(root.debug) }, checkpoint: null });
245
260
  }
261
+ /** Escape Loop: where the confirmed cause came from, bound into the contract before review. */
262
+ async origin(taskId, input) {
263
+ const root = await this.root(taskId);
264
+ if (root.task.id !== taskId)
265
+ fail("ESCAPE_ORIGIN_INVALID", "Declare the origin on the original issue task.");
266
+ if (root.status !== "active" || root.debug.operation !== null)
267
+ fail("TASK_DEBUG_BUSY", "Origin needs an active idle issue.");
268
+ if (debugRemainingMs(root.debug) === 0)
269
+ fail("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
270
+ if (!root.debug.hypotheses.some(h => h.status === "confirmed"))
271
+ fail("ESCAPE_ORIGIN_EARLY", "Declare the origin only after discriminating evidence confirms a hypothesis.");
272
+ const origin = await resolveOrigin(this.options.gitRunner, input);
273
+ const record = await this.options.tasks.revise(taskId, { expectedContractHash: taskContractHash(root.task), origin, debugOrigin: true,
274
+ reason: `Declare issue origin: ${origin.kind === "escape-of" ? "escape-of " + origin.commit : origin.kind}.` });
275
+ if (origin.kind !== "escape-of") {
276
+ await removeEscapeRecord(this.options.root, taskId);
277
+ return { record };
278
+ }
279
+ return { record, escape: await declareEscape({ root: this.options.root, git: this.options.gitRunner, taskId, origin,
280
+ base: root.debug.baselineCommit, now: new Date().toISOString() }) };
281
+ }
246
282
  async remaining(taskId) { return debugRemainingMs((await this.root(taskId)).debug); }
247
283
  async #userEvent(sessionId, home, kind = "latest") {
248
284
  const record = await readWorktreeRecord(this.options.root);
@@ -277,9 +313,9 @@ export class DebugService {
277
313
  const attached = await this.options.tasks.status({ sessionId });
278
314
  if (attached.task.debug?.rootTaskId !== root.task.id || root.status !== "active")
279
315
  fail("TASK_DEBUG_CONTROL_INVALID", "Human control must belong to the active attached original issue.");
280
- if (action === "resume" && debugRemainingMs(root.debug) === 0) {
316
+ if (action === "unlimited" || ((action === "resume" || action === "repair") && debugRemainingMs(root.debug) === 0)) {
281
317
  try {
282
- await this.extend(taskId, sessionId, event.id);
318
+ await this.extend(taskId, sessionId, event.id, home);
283
319
  }
284
320
  catch (error) {
285
321
  if (!(error instanceof AgentOpsError) || !["TASK_DEBUG_GRANT_INVALID", "TASK_DEBUG_GRANT_UNSUPPORTED"].includes(error.code))
@@ -351,10 +387,14 @@ export class DebugService {
351
387
  // EVIDENCE-SURFACE-001: intake may start without a surface, the first repair may not.
352
388
  if (kind === "verify" && task.task.surfaceRequired === true && task.task.surface === undefined && productDigest !== sha256(canonicalJson([])))
353
389
  fail("SURFACE_MISSING", "Add the surface audit with task revise --surface-file before verifying the first repair.");
354
- const previousCandidate = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1)?.productDigest ?? sha256("[]");
390
+ const previousVerify = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1);
391
+ const previousCandidate = previousVerify?.productDigest ?? sha256("[]");
355
392
  const cause = debugStopCause(root.debug);
356
393
  // Recovery reruns the original verifier; changed product content still needs fresh proof.
357
- if (cause !== null && !(["environment", "permission"].includes(cause) && (kind === "investigate" || kind === "verify")) && !(cause === "iterations" && kind === "review" && productDigest === previousCandidate))
394
+ // At the round limit an unchanged PASS candidate may be re-verified (not a new round), e.g. after an
395
+ // escape-ledger update moved the source fingerprint; a FAIL cannot be re-run into proof at the cap.
396
+ if (cause !== null && !(["environment", "permission"].includes(cause) && (kind === "investigate" || kind === "verify")) &&
397
+ !(cause === "iterations" && productDigest === previousCandidate && (kind === "review" || (kind === "verify" && previousVerify?.outcome === "PASS"))))
358
398
  fail("TASK_DEBUG_STOP_REQUIRED", `Runtime stop predicate: ${cause}. Record a checkpoint; resume alone cannot reset it.`);
359
399
  const processIdentity = await nativeProcessIdentity(process.pid);
360
400
  if (processIdentity === null)
@@ -564,17 +604,20 @@ export class DebugService {
564
604
  fail("TASK_DEBUG_RECOVERY_CONFIRMATION_REQUIRED", "Owned debug checkpoint/path/source/contract/config mismatch. Preserve the checkout and obtain user confirmation before recovery.");
565
605
  await this.options.tasks.attach(sessionId, root.task.id);
566
606
  }
567
- async extend(taskId, sessionId, eventId) {
607
+ async extend(taskId, sessionId, eventId, home) {
568
608
  const root = await this.root(taskId), attached = await this.options.tasks.status({ sessionId });
569
609
  if (attached.task.debug?.rootTaskId !== root.task.id || root.debug.operation !== null)
570
610
  fail("TASK_DEBUG_GRANT_INVALID", "Extension belongs to the original issue and its native session; a running operation cannot be extended.");
571
- const event = await this.#userEvent(sessionId);
611
+ const event = await this.#userEvent(sessionId, home);
572
612
  if (eventId !== "latest" && eventId !== event.id)
573
613
  fail("TASK_DEBUG_GRANT_INVALID", "The requested event is not the current native human event.");
574
614
  if (root.debug.grants.some(g => g.eventId === event.id))
575
615
  return;
576
- const otherCause = debugStopCause({ ...root.debug, consumedMs: 0, budgetMs: 86_400_000,
616
+ const otherCause = debugStopCause({ ...root.debug, consumedMs: 0, foreground: null, operation: null, budgetMs: 86_400_000,
577
617
  ...(root.debug.control === undefined ? {} : { control: { ...root.debug.control, paused: false } }) });
618
+ const unlimited = debugUserControl(event.text) === "unlimited";
619
+ if (unlimited && (root.status !== "active" || event.timestamp < Date.parse(root.createdAt)))
620
+ fail("TASK_DEBUG_GRANT_INVALID", "Only a current native human event can waive the active original issue time limit.");
578
621
  const foregroundActive = root.debug.foreground !== undefined && root.debug.foreground !== null;
579
622
  const remainingAtEvent = debugRemainingMs(root.debug, event.timestamp);
580
623
  const sealedAtCandidates = [Date.parse(root.updatedAt), root.debug.receipts.at(-1)?.finishedAt ?? Number.NaN].filter(Number.isFinite);
@@ -582,8 +625,8 @@ export class DebugService {
582
625
  const exhaustedAtEvent = foregroundActive
583
626
  ? remainingAtEvent === 0
584
627
  : debugRemainingMs(root.debug) === 0 && event.timestamp > lastSealedAt;
585
- if (!exhaustedAtEvent || (otherCause !== null && !["environment", "permission"].includes(otherCause)) || !/^(?:繼續|继续|continue)[。.!!]?$/iu.test(event.text.trim()))
628
+ if ((!unlimited && !exhaustedAtEvent) || (otherCause !== null && !["environment", "permission"].includes(otherCause)) || !["resume", "repair", "unlimited"].includes(debugUserControl(event.text) ?? ""))
586
629
  fail("TASK_DEBUG_GRANT_INVALID", "Only a contemporaneous human continue event extends exhausted time; other blockers remain authoritative.");
587
- await this.#mutate(root, { ...root.debug, grants: [...root.debug.grants, { eventId: event.id, sessionId, sourceDigest: event.sourceDigest, grantedMs: DEBUG_EXTENSION_MS }], checkpoint: null });
630
+ await this.#mutate(root, { ...root.debug, grants: [...root.debug.grants, { eventId: event.id, sessionId, sourceDigest: event.sourceDigest, grantedMs: unlimited ? null : DEBUG_EXTENSION_MS, spentAtGrantMs: debugSpentMs(root.debug, event.timestamp) }], checkpoint: null });
588
631
  }
589
632
  }
@@ -19,6 +19,9 @@ import { createDebugState, DEBUG_DEFAULTS } from "./debug-state.js";
19
19
  import { DebugService } from "./debug.js";
20
20
  import { sameDebugText } from "./debug-intake.js";
21
21
  import { isCovers, parseSurface } from "../surface/schema.js";
22
+ import { declareEscape, resolveOrigin } from "../escape/link.js";
23
+ import { configProposal } from "../escape/rules.js";
24
+ import { logGateCatch } from "../logging/local-log.js";
22
25
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
23
26
  function defaultTaskId() {
24
27
  return `task-${randomUUID()}`;
@@ -130,6 +133,7 @@ export class TaskService {
130
133
  id: this.#generateId(),
131
134
  ...(surface === undefined ? {} : { surface }),
132
135
  ...(input.requireSurface === true ? { surfaceRequired: true } : {}),
136
+ ...(input.origin === undefined ? {} : { origin: structuredClone(input.origin) }),
133
137
  title: input.title,
134
138
  ...(input.intent === undefined ? {} : { intent: input.intent }),
135
139
  criteria,
@@ -149,6 +153,10 @@ export class TaskService {
149
153
  throw taskError("TASK_DEBUG_DISABLED", "Task debug routing is explicitly disabled.");
150
154
  debugSettings = config.features.debug ?? DEBUG_DEFAULTS;
151
155
  task.debug = { rootTaskId: task.id, facts: [...(input.debug.facts ?? [])], unknowns: [...(input.debug.unknowns ?? [])] };
156
+ if (input.origin !== undefined)
157
+ throw taskError("ESCAPE_ORIGIN_DEBUG_ACTION", "A DEBUG issue declares its origin with task debug --debug-action origin after a confirmed hypothesis.");
158
+ if (input.parentTaskId === undefined)
159
+ task.originRequired = true;
152
160
  }
153
161
  const validation = validateTask(task);
154
162
  if (!validation.ok) {
@@ -262,8 +270,16 @@ export class TaskService {
262
270
  throw taskError("TASK_NOT_ACTIVE", "Only active tasks can revise acceptance.");
263
271
  if (taskContractHash(previous.task) !== input.expectedContractHash)
264
272
  throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
265
- if (input.criteria === undefined && input.surface === undefined)
266
- throw taskError("TASK_REVISION_INVALID", "Revision requires criteria, a surface, or both.");
273
+ // With none of them, a revision only rebinds the task to the current configuration (checked below).
274
+ const rebindOnly = input.criteria === undefined && input.surface === undefined && input.origin === undefined;
275
+ if (rebindOnly && this.#completion === undefined)
276
+ throw taskError("TASK_REVISION_INVALID", "Revision requires criteria, a surface, an origin, or a combination.");
277
+ if (input.origin !== undefined && previous.task.debug !== undefined && input.debugOrigin !== true)
278
+ throw taskError("ESCAPE_ORIGIN_DEBUG_ACTION", "A DEBUG issue declares its origin with task debug --debug-action origin after a confirmed hypothesis.");
279
+ // Re-declaring the same origin only refreshes its ledger record; the contract and its proof stand.
280
+ if (input.criteria === undefined && input.surface === undefined && input.origin !== undefined &&
281
+ canonicalJson(input.origin) === canonicalJson(previous.task.origin ?? null))
282
+ return previous;
267
283
  const surface = input.surface === undefined ? previous.task.surface : parseSurface(input.surface);
268
284
  const baseline = input.baseline ?? previous.createdSourceCommit;
269
285
  const criteria = input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria).map(criterion => {
@@ -278,7 +294,8 @@ export class TaskService {
278
294
  return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
279
295
  });
280
296
  const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
281
- contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria, ...(surface === undefined ? {} : { surface }) };
297
+ contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria, ...(surface === undefined ? {} : { surface }),
298
+ ...(input.origin === undefined ? {} : { origin: structuredClone(input.origin) }) };
282
299
  const checked = validateTask(next);
283
300
  if (!checked.ok)
284
301
  throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid criterion revision.");
@@ -293,6 +310,8 @@ export class TaskService {
293
310
  throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid binding.");
294
311
  policyConfigHash = calculateConfigHash(config);
295
312
  }
313
+ if (rebindOnly && policyConfigHash === previous.policyConfigHash)
314
+ throw taskError("TASK_REVISION_INVALID", "Nothing to revise: criteria, surface, origin and configuration are unchanged.");
296
315
  if (criteria.some(c => c.acceptance !== undefined))
297
316
  await this.#retainBaselines(next);
298
317
  const now = assertTimestamp(this.#now());
@@ -680,7 +699,10 @@ export class TaskService {
680
699
  async recordVerificationEvidence(snapshot, evidenceInput, sourceFingerprint) {
681
700
  if (snapshot.status === "active") {
682
701
  const result = await this.recordEvidence(snapshot.task.id, evidenceInput);
683
- await this.clearFailure(snapshot.task.id);
702
+ // A verified red turning green is a gate catch (advisory, fail-open).
703
+ if (await this.clearFailure(snapshot.task.id) &&
704
+ (await this.#completion?.loadConfig().catch(() => undefined))?.profiles.includes("advisory") === true)
705
+ await logGateCatch("VERIFY_RED_GREEN", snapshot.task.id);
684
706
  return result;
685
707
  }
686
708
  const completion = this.#completion;
@@ -804,18 +826,48 @@ export class TaskService {
804
826
  return structuredClone(advanced);
805
827
  });
806
828
  }
829
+ /** Escape Loop: resolve a declared commit-ish through this repository's Git (argv only). */
830
+ async resolveOrigin(value) {
831
+ if (this.#completion === undefined)
832
+ throw taskError("ESCAPE_CONTEXT_REQUIRED", "Escape linking requires repository context.");
833
+ return await resolveOrigin(this.#completion.gitRunner, value);
834
+ }
835
+ /** Link a normal task's escape-of origin and write its tracked ledger record. */
836
+ async declareEscape(taskId) {
837
+ const completion = this.#completion;
838
+ const record = await this.status({ taskId });
839
+ const origin = record.task.origin;
840
+ if (completion === undefined || origin?.kind !== "escape-of" || record.createdSourceCommit === undefined)
841
+ throw taskError("ESCAPE_CONTEXT_REQUIRED", "Escape linking needs an escape-of origin, a creation commit and repository context.");
842
+ return await declareEscape({ root: completion.root, git: completion.gitRunner, taskId, origin,
843
+ base: record.createdSourceCommit, now: assertTimestamp(this.#now()) });
844
+ }
845
+ /** Escape Loop R2: printed only, never written; the promoted check becomes a required verifier through update. */
846
+ async escapeConfigProposal(check) {
847
+ let config;
848
+ try {
849
+ config = await this.#completion?.loadConfig();
850
+ }
851
+ catch {
852
+ config = undefined;
853
+ }
854
+ return configProposal(check, config);
855
+ }
856
+ /** True when a recorded verification failure was cleared (red to green). */
807
857
  async clearFailure(taskId) {
808
858
  const now = assertTimestamp(this.#now());
809
- await this.#store.mutate((state) => {
859
+ return await this.#store.mutate((state) => {
810
860
  const current = findTask(state, taskId);
811
861
  if (current.failureFingerprint === null) {
812
- return;
862
+ return false;
813
863
  }
814
864
  replaceTask(state, {
815
865
  ...current,
816
866
  updatedAt: now,
817
867
  failureFingerprint: null
818
868
  });
869
+ // A source change mid-run is not a red the code fixed.
870
+ return current.failureFingerprint.commandId !== "source-snapshot";
819
871
  });
820
872
  }
821
873
  async coverage(taskId) {
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile, chmod } from "node:fs/promises";
1
+ import { lstat, mkdir, mkdtemp, readFile, realpath, writeFile, chmod } from "node:fs/promises";
2
2
  import { join, delimiter, isAbsolute } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { writePrivateFile } from "../security/permissions.js";
@@ -6,6 +6,7 @@ import { redactSecrets } from "../security/redact.js";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { canonicalJson } from "../config/hash.js";
8
8
  import { sha256 } from "../fs/hash.js";
9
+ import { removeTemporary } from "../fs/temporary.js";
9
10
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
10
11
  import { criterionContractHash, taskContractHash } from "../task/contract.js";
11
12
  import { aggregateAcceptanceRun, makeAcceptanceCheck, makeAcceptanceRun } from "./acceptance-protocol.js";
@@ -13,6 +14,8 @@ import { collectNodeAcceptance, collectJestAcceptance, collectVitestAcceptance,
13
14
  import { buildVerificationEvidence } from "./evidence.js";
14
15
  import { collectChangeSurface, resolveGitCommit } from "./change-surface.js";
15
16
  import { runVerificationCommand } from "./spawn.js";
17
+ /** Limits for runner steps without their own timeoutMs: installs are slow, git plumbing is not. */
18
+ export const ACCEPTANCE_TIMEOUTS = { setup: 600_000, build: 300_000, testBuild: 300_000, run: 300_000, git: 60_000 };
16
19
  async function executableIdentity(command, cwd, env) {
17
20
  const paths = isAbsolute(command) ? [command] : command.includes("/") ? [join(cwd, command)] :
18
21
  (env.PATH ?? "").split(delimiter).filter(Boolean).map(path => join(path, command));
@@ -113,13 +116,21 @@ export async function replayAcceptance(options) {
113
116
  const temp = await mkdtemp(join(tmpdir(), "agent-ops-acceptance-"));
114
117
  const checkout = join(temp, "repository");
115
118
  const env = replayEnvironment(checkout, phase, executionId);
116
- const command = async (executable, args, cwd, timeoutMs = 60_000) => {
119
+ let timedOut;
120
+ const command = async (executable, args, cwd, stage, own) => {
117
121
  const remaining = await options.remainingMs?.();
118
122
  if (remaining === 0)
119
123
  throw new AgentOpsError("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
120
- return await runVerificationCommand({ id: group.runner.id, command: executable, args: [...args], cwd: ".", required: true,
121
- evidence: { kind: "exit-code" }, timeoutMs: Math.min(timeoutMs, remaining ?? Infinity) }, { cwd, runner: options.processRunner, env, replaceEnv: true,
124
+ const limit = own ?? ACCEPTANCE_TIMEOUTS[stage];
125
+ const result = await runVerificationCommand({ id: group.runner.id, command: executable, args: [...args], cwd: ".", required: true,
126
+ evidence: { kind: "exit-code" }, timeoutMs: Math.min(limit, remaining ?? Infinity) }, { cwd, runner: options.processRunner, env, replaceEnv: true,
122
127
  outputLimitBytes: 1024 * 1024, ...(options.signal === undefined ? {} : { signal: options.signal }) });
128
+ if (result.failureClass === "timeout" && timedOut === undefined)
129
+ timedOut = stage === "git"
130
+ ? `Runner ${group.runner.id} ${phase}: git checkout exceeded ${limit} ms.`
131
+ : `Runner ${group.runner.id} ${phase}: ${stage} step ${executable} exceeded ${limit} ms; raise ` +
132
+ (stage === "run" ? "verification.acceptanceRunners[].timeoutMs" : `the step's timeoutMs in verification.acceptanceRunners[].${stage}`) + ".";
133
+ return result;
123
134
  };
124
135
  let aggregate;
125
136
  let failure = "replay-unavailable";
@@ -133,13 +144,14 @@ export async function replayAcceptance(options) {
133
144
  try {
134
145
  for (const [cwd, args] of [[temp, ["clone", "--no-hardlinks", "--quiet", "--", options.root, checkout]],
135
146
  [checkout, ["fetch", "--quiet", "--", options.root, commit]], [checkout, ["checkout", "--detach", "--quiet", commit]]]) {
136
- const result = await command("git", args, cwd);
147
+ const result = await command("git", args, cwd, "git");
137
148
  if (result.status !== "PASS")
138
149
  throw new Error("checkout-unavailable");
139
150
  }
140
151
  const cwd = group.runner.cwd === "." ? checkout : await resolveContainedPath(checkout, group.runner.cwd);
141
- for (const step of [...(group.runner.setup ?? []), ...(group.runner.build ?? [])]) {
142
- const result = await command(step.command, step.args, cwd, step.timeoutMs);
152
+ for (const [stage, step] of [...(group.runner.setup ?? []).map(step => ["setup", step]),
153
+ ...(group.runner.build ?? []).map(step => ["build", step])]) {
154
+ const result = await command(step.command, step.args, cwd, stage, step.timeoutMs);
143
155
  steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
144
156
  if (result.status !== "PASS")
145
157
  throw new Error("setup-or-build-unavailable");
@@ -172,7 +184,7 @@ export async function replayAcceptance(options) {
172
184
  await chmod(path, material.mode);
173
185
  }
174
186
  for (const step of group.runner.testBuild ?? []) {
175
- const result = await command(step.command, step.args, cwd, step.timeoutMs);
187
+ const result = await command(step.command, step.args, cwd, "testBuild", step.timeoutMs);
176
188
  steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
177
189
  if (result.status !== "PASS")
178
190
  throw new Error("test-build-unavailable");
@@ -194,7 +206,7 @@ export async function replayAcceptance(options) {
194
206
  throw new Error("missing-executable");
195
207
  executionDigest = sha256(canonicalJson({ commit, materialDigest, runner: group.runner, argv, dependencies,
196
208
  executable: identity, platform: process.platform, arch: process.arch, environment: sha256(canonicalJson(env)) }));
197
- output = await command(group.runner.command, argv, cwd, group.runner.timeoutMs);
209
+ output = await command(group.runner.command, argv, cwd, "run", group.runner.timeoutMs);
198
210
  run = collected(group.runner, output, checkout, { executionId, phase,
199
211
  frameworkVersion: group.runner.adapter === "node" ? process.version : "dependency-lock:" + sha256(canonicalJson(dependencies)) });
200
212
  aggregate = aggregateAcceptanceRun(run, requested);
@@ -208,7 +220,7 @@ export async function replayAcceptance(options) {
208
220
  failure = error instanceof AgentOpsError && error.code === "TASK_DEBUG_BUDGET_EXHAUSTED" ? "debug-budget-exhausted" : error instanceof Error && /^[a-z-]+$/u.test(error.message) ? error.message : "replay-unavailable";
209
221
  }
210
222
  finally {
211
- await rm(temp, { recursive: true, force: true });
223
+ await removeTemporary(temp);
212
224
  }
213
225
  const finishedAt = new Date().toISOString();
214
226
  const artifactContent = redactSecrets(JSON.stringify({ schemaVersion: 1, executionId, phase, commit,
@@ -236,7 +248,8 @@ export async function replayAcceptance(options) {
236
248
  taskContractHash: taskContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
237
249
  bindingHash: sha256(canonicalJson({ runnerId: group.runner.id, materials: group.binding.materials })), phase, commit, pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest, executionArtifact, checks } });
238
250
  const reference = await options.evidenceStore.save(evidence);
239
- reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference });
251
+ reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference,
252
+ ...(timedOut === undefined || status === "PASS" ? {} : { detail: timedOut }) });
240
253
  }
241
254
  }
242
255
  }
@@ -115,7 +115,14 @@ Capability checks and model starts are reported on stderr, including under
115
115
  `--json`; stdout remains one final JSON envelope and raw reviewer output is
116
116
  never streamed. SIGINT or SIGTERM terminates the active reviewer process tree,
117
117
  does not advance the fallback chain, and never writes an attestation. An
118
- exhausted timeout chain reports `timeout`, not `missing-cli`.
118
+ exhausted timeout chain reports `timeout`, not `missing-cli`. Each reviewer
119
+ round may take 30 minutes by default (`reviewRoles[].timeoutMs` overrides it);
120
+ a reviewer that prints nothing for the stall window is still stopped early.
121
+
122
+ Acceptance runner steps without their own `timeoutMs` get 10 minutes for
123
+ `setup`, 5 minutes each for `build`, `testBuild` and the runner itself, and
124
+ 1 minute for the git checkout. A step that times out is reported as
125
+ `UNKNOWN` with the step, its limit and the `timeoutMs` field that raises it.
119
126
 
120
127
  The task ID is explicit and its criteria are always used; no session fallback
121
128
  or criterion filter can replace the original task requirements. Review rechecks
@@ -200,6 +207,14 @@ evidence. Give each editing conversation its own worktree instead:
200
207
  `.env` or `local.properties`); a file Git already checked out is never
201
208
  overwritten. Trust is inherited only when the main checkout is trusted and
202
209
  the worktree's effective config is identical.
210
+ - An approved `agent-ops update` in the main checkout copies the new config
211
+ into every session worktree whose copy is still the previously trusted one,
212
+ and trusts it for the same commands; the plan lists the session worktrees
213
+ before you confirm. A hand-edited worktree config is left alone, and run
214
+ worktrees keep their own authorization. A task created under the old config is
215
+ not rebound automatically: review names the
216
+ `agent-ops task revise --task <id> --expected-contract <hash> --reason <text>`
217
+ that rebinds it (criteria and surface unchanged), then verify again.
203
218
  - `setup` commands run in the new worktree (default timeout ten minutes) for
204
219
  what Git does not carry, such as installed dependencies. They are part of
205
220
  the trusted config, so they need no separate approval; a failing step
@@ -260,7 +275,7 @@ evidence. Give each editing conversation its own worktree instead:
260
275
  (Claude Code refuses it inside an isolated worktree session). `agent-ops
261
276
  worktree finish <name>` completes every non-archived task in the worktree,
262
277
  subtasks before their parents, each against the base its PASS review
263
- recorded (the worktree's base when none was recorded), before any rebase.
278
+ recorded (the worktree's base when none was recorded), before merging.
264
279
  If one cannot complete, nothing merges and the error names the task.
265
280
  Creating a subtask keeps the session attached to the top of its tree, and
266
281
  each task keeps its own review record, so a parent and a subtask reviewed
@@ -280,9 +295,13 @@ evidence. Give each editing conversation its own worktree instead:
280
295
  its files, so uncommitted changes there block it. When the main checkout is
281
296
  on another branch or detached, only the target's ref moves: the checkout, its
282
297
  files and its uncommitted changes are left alone. A target checked out in
283
- another worktree is refused, naming that path. If the target moved, it rebases; a clean rebase whose own patch
284
- is unchanged is re-verified before merging, and a conflict is reported with
285
- the `refs/notes/agent-ops` intent of the work that landed first. The note
298
+ another worktree is refused, naming that path. If the target moved, it rebases the worktree and
299
+ stops with `WORKTREE_REBASED` before completing any task: earlier evidence
300
+ covered the old base, not the combination that would merge. Verify and
301
+ review again with the printed `--base`, then finish again; a task that was
302
+ already complete cannot take new evidence, so cover the rebased change with
303
+ a follow-up task. A conflict is reset and reported with the
304
+ `refs/notes/agent-ops` intent of the work that landed first. The note
286
305
  records every task of the worktree, subtasks indented under their parent,
287
306
  and a `session: <id>` line naming the session that finished it. Finish
288
307
  also resets that session's gate baseline to the merged source, so the