@kylecheng3146/agent-ops 0.10.2 → 0.11.1

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 (56) hide show
  1. package/README.md +13 -0
  2. package/dist/packages/cli/src/args.js +18 -44
  3. package/dist/packages/cli/src/bin.js +37 -29
  4. package/dist/packages/cli/src/cli.js +16 -14
  5. package/dist/packages/cli/src/codex-loop-process.js +0 -6
  6. package/dist/packages/cli/src/commands/review.js +5 -10
  7. package/dist/packages/cli/src/commands/run.js +3 -1
  8. package/dist/packages/cli/src/commands/task.js +27 -54
  9. package/dist/packages/cli/src/commands/verify.js +7 -3
  10. package/dist/packages/cli/src/criteria-review.js +63 -0
  11. package/dist/packages/cli/src/default-base.js +26 -0
  12. package/dist/packages/cli/src/hook-process.js +19 -83
  13. package/dist/packages/cli/src/owned-run-step.js +1 -1
  14. package/dist/packages/cli/src/run-entry.js +203 -115
  15. package/dist/runtime/src/adapters/agy/config.js +2 -3
  16. package/dist/runtime/src/adapters/claude/config.js +2 -2
  17. package/dist/runtime/src/adapters/codex/config.js +2 -5
  18. package/dist/runtime/src/hooks/completion-gate.js +0 -15
  19. package/dist/runtime/src/install/harness.js +4 -4
  20. package/dist/runtime/src/install/plan.js +3 -3
  21. package/dist/runtime/src/install/profiles.js +0 -2
  22. package/dist/runtime/src/logging/local-log.js +12 -0
  23. package/dist/runtime/src/office/snapshot.js +4 -1
  24. package/dist/runtime/src/parallel/manage.js +0 -3
  25. package/dist/runtime/src/review/criteria.js +252 -0
  26. package/dist/runtime/src/review/execute.js +68 -4
  27. package/dist/runtime/src/review/invocation.js +3 -3
  28. package/dist/runtime/src/run/verification.js +2 -2
  29. package/dist/runtime/src/schema/validate.js +10 -5
  30. package/dist/runtime/src/task/completion.js +4 -42
  31. package/dist/runtime/src/task/contract.js +11 -0
  32. package/dist/runtime/src/task/service.js +34 -57
  33. package/dist/runtime/src/task/store.js +3 -7
  34. package/dist/runtime/src/verify/acceptance-coverage.js +2 -3
  35. package/dist/runtime/src/verify/acceptance-protocol.js +4 -3
  36. package/dist/runtime/src/verify/acceptance-replay.js +4 -7
  37. package/dist/runtime/src/verify/service.js +6 -23
  38. package/dist/runtime/src/verify/source-fingerprint.js +17 -4
  39. package/dist/runtime/src/verify/spawn.js +3 -2
  40. package/docs/en/guides/task-debug.md +39 -133
  41. package/docs/en/spec/acceptance-and-evidence.md +31 -23
  42. package/docs/en/spec/harness-adapters.md +9 -11
  43. package/docs/en/spec/loop-engineering.md +11 -5
  44. package/docs/en/spec/review.md +16 -0
  45. package/docs/zh-TW/guides/task-debug.md +27 -73
  46. package/docs/zh-TW/spec/acceptance-and-evidence.md +18 -18
  47. package/docs/zh-TW/spec/harness-adapters.md +7 -8
  48. package/docs/zh-TW/spec/loop-engineering.md +9 -4
  49. package/docs/zh-TW/spec/review.md +17 -1
  50. package/package.json +1 -1
  51. package/schemas/config.schema.json +1 -21
  52. package/dist/packages/cli/src/debug-intake.js +0 -245
  53. package/dist/runtime/src/task/debug-intake.js +0 -15
  54. package/dist/runtime/src/task/debug-state.js +0 -135
  55. package/dist/runtime/src/task/debug-user-event.js +0 -160
  56. package/dist/runtime/src/task/debug.js +0 -632
@@ -6,18 +6,14 @@ import { validateEvidence, validateTask, validateTaskAgainstConfig } from "../sc
6
6
  import { renderTaskMarkdown } from "./render.js";
7
7
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
8
8
  import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
9
- import { collectChangeSurface } from "../verify/change-surface.js";
10
9
  import { resolveReviewScope } from "../review/scope.js";
11
10
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
12
11
  import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
13
12
  import { loadFindingPin } from "./pin-finding.js";
14
13
  import { findReviewAttestation } from "../review/attestation.js";
15
14
  import { advanceFailureFingerprint } from "../verify/fingerprint.js";
16
- import { taskContractHash, treeContractHash } from "./contract.js";
15
+ import { evidenceMatchesContract, executionContractHash, taskContractHash, treeContractHash } from "./contract.js";
17
16
  import { commitIdentity } from "../schema/acceptance.js";
18
- import { createDebugState, DEBUG_DEFAULTS } from "./debug-state.js";
19
- import { DebugService } from "./debug.js";
20
- import { sameDebugText } from "./debug-intake.js";
21
17
  import { isCovers, parseSurface } from "../surface/schema.js";
22
18
  import { declareEscape, resolveOrigin } from "../escape/link.js";
23
19
  import { configProposal } from "../escape/rules.js";
@@ -92,15 +88,6 @@ export class TaskService {
92
88
  this.#now = options.now ?? (() => new Date().toISOString());
93
89
  this.#completion = options.completion;
94
90
  }
95
- debugService() {
96
- if (this.#completion === undefined)
97
- throw taskError("TASK_DEBUG_CONTEXT_REQUIRED", "Debug operations require repository context.");
98
- return new DebugService({ store: this.#store, tasks: this, ...this.#completion });
99
- }
100
- async assertDebugBaseline() {
101
- if (this.#completion === undefined || (await collectChangeSurface(this.#completion.gitRunner)).paths.length > 0)
102
- throw taskError("TASK_DEBUG_BASELINE_DIRTY", "Preserve user edits; debug intake needs a clean committed baseline before worktree allocation.");
103
- }
104
91
  async create(input) {
105
92
  if (input.policyConfigHash !== undefined &&
106
93
  !/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
@@ -109,12 +96,6 @@ export class TaskService {
109
96
  if (input.sessionId !== undefined) {
110
97
  assertSessionId(input.sessionId);
111
98
  }
112
- // A new session must not restart an active issue's budget and rounds.
113
- if (input.debug !== undefined && input.parentTaskId === undefined && input.goal !== undefined) {
114
- const existing = await this.activeDebugRoot(input.goal);
115
- if (existing !== null)
116
- return input.sessionId === undefined ? existing : await this.attach(input.sessionId, existing.task.id);
117
- }
118
99
  let createdSourceCommit = input.createdSourceCommit;
119
100
  if (createdSourceCommit === undefined && this.#completion !== undefined) {
120
101
  const head = await this.#completion.gitRunner.run(["rev-parse", "--verify", "HEAD"]);
@@ -142,21 +123,11 @@ export class TaskService {
142
123
  ? {}
143
124
  : { parentTaskId: input.parentTaskId })
144
125
  };
145
- let debugSettings;
146
- if (input.debug !== undefined) {
147
- if (input.goal === undefined || createdSourceCommit === undefined || this.#completion === undefined)
148
- throw taskError("TASK_DEBUG_CONTEXT_REQUIRED", "Debug intake requires the original issue and a committed baseline.");
149
- if ((await collectChangeSurface(this.#completion.gitRunner)).paths.length > 0)
150
- throw taskError("TASK_DEBUG_BASELINE_DIRTY", "Preserve original user edits; investigate read-only or ask for an explicit committed baseline.");
151
- const config = await this.#completion.loadConfig();
152
- if (config.features.debug?.enabled === false)
153
- throw taskError("TASK_DEBUG_DISABLED", "Task debug routing is explicitly disabled.");
154
- debugSettings = config.features.debug ?? DEBUG_DEFAULTS;
126
+ // DEBUG = an ordinary task whose original regression is proven red at the baseline and green on the candidate.
127
+ if (input.debug !== undefined && input.parentTaskId === undefined) {
128
+ if (!criteria.some(c => c.acceptance?.mode === "behavioral" && c.acceptance.bindings.some(b => (b.redCheckIds ?? []).length > 0)))
129
+ throw taskError("TASK_DEBUG_RED_CHECK_REQUIRED", "A DEBUG task needs a behavioral criterion whose redCheckIds fail at the baseline and pass on the candidate.");
155
130
  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;
160
131
  }
161
132
  const validation = validateTask(task);
162
133
  if (!validation.ok) {
@@ -192,13 +163,10 @@ export class TaskService {
192
163
  throw taskError("TASK_GOAL_IMMUTABLE", "Subtasks must retain their parent original goal.");
193
164
  validation.value.goal = parent.task.goal;
194
165
  }
195
- if (parent.task.debug !== undefined) {
196
- if (input.debug !== undefined)
197
- throw taskError("TASK_DEBUG_ROOT_IMMUTABLE", "A child cannot create a new issue budget.");
166
+ if (parent.task.debug !== undefined)
198
167
  validation.value.debug = structuredClone(parent.task.debug);
199
- }
200
168
  }
201
- if (input.requireSurface === true && validation.value.debug === undefined) {
169
+ if (input.requireSurface === true) {
202
170
  const parentSurface = state.tasks.find(record => record.task.id === input.parentTaskId)?.task.surface;
203
171
  const surfacedParent = parentSurface !== undefined && !isCovers(parentSurface);
204
172
  if (surface === undefined || isCovers(surface) !== surfacedParent)
@@ -208,7 +176,6 @@ export class TaskService {
208
176
  }
209
177
  const record = {
210
178
  task: validation.value,
211
- ...(input.debug === undefined ? {} : { debug: createDebugState(task.id, createdSourceCommit, debugSettings, this.#completion.root) }),
212
179
  ...(createdSourceCommit === undefined ? {} : { createdSourceCommit }),
213
180
  status: "active",
214
181
  evidence: {},
@@ -271,26 +238,28 @@ export class TaskService {
271
238
  if (taskContractHash(previous.task) !== input.expectedContractHash)
272
239
  throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
273
240
  // 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;
241
+ const rebindOnly = input.criteria === undefined && input.surface === undefined && input.origin === undefined && input.baseline === undefined;
275
242
  if (rebindOnly && this.#completion === undefined)
276
243
  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
244
  // Re-declaring the same origin only refreshes its ledger record; the contract and its proof stand.
280
245
  if (input.criteria === undefined && input.surface === undefined && input.origin !== undefined &&
281
246
  canonicalJson(input.origin) === canonicalJson(previous.task.origin ?? null))
282
247
  return previous;
283
248
  const surface = input.surface === undefined ? previous.task.surface : parseSurface(input.surface);
284
- const baseline = input.baseline ?? previous.createdSourceCommit;
285
- const criteria = input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria).map(criterion => {
249
+ // An explicit --baseline moves every typed criterion (e.g. a task opened on the wrong branch); its evidence is then void.
250
+ const moved = input.baseline === undefined ? undefined : await this.#resolveCommit(input.baseline);
251
+ const baseline = moved ?? previous.createdSourceCommit;
252
+ const criteria = (input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria)).map(criterion => {
286
253
  if (criterion.acceptance === undefined)
287
254
  return criterion;
288
- const sha = criterion.acceptance.baselineCommit ?? baseline;
255
+ if (input.criteria === undefined && moved === undefined)
256
+ return criterion;
257
+ const sha = moved ?? criterion.acceptance.baselineCommit ?? baseline;
289
258
  if (sha === undefined)
290
259
  throw taskError("BASELINE_REQUIRED", "Legacy tasks need an explicit baseline before typed acceptance.");
291
260
  const old = previous.task.criteria.find(c => c.id === criterion.id)?.acceptance;
292
- if (old !== undefined && sha !== old.baselineCommit)
293
- throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion instead of changing its baseline.");
261
+ if (moved === undefined && old !== undefined && sha !== old.baselineCommit)
262
+ throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion, or move every baseline with --baseline.");
294
263
  return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
295
264
  });
296
265
  const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
@@ -319,7 +288,13 @@ export class TaskService {
319
288
  const current = findTask(state, taskId);
320
289
  if (current.status !== "active" || JSON.stringify(current) !== JSON.stringify(previous))
321
290
  throw taskError("TASK_CONTRACT_CHANGED", "Concurrent revision preserved; retry from current task.");
322
- const result = { ...current, task: next, evidence: {}, failureFingerprint: null,
291
+ // Surface, origin or goal-only revisions keep what verification ran; review is still voided by the new contract hash.
292
+ const sameExecution = executionContractHash(next) === executionContractHash(current.task) && policyConfigHash === current.policyConfigHash;
293
+ // Review references never carry over: the new contract needs fresh review.
294
+ const verified = Object.fromEntries(Object.entries(current.evidence)
295
+ .map(([id, refs]) => [id, refs.filter(ref => !ref.startsWith("review:"))]).filter(([, refs]) => refs.length > 0));
296
+ const result = { ...current, task: next, evidence: sameExecution ? verified : {},
297
+ failureFingerprint: sameExecution ? current.failureFingerprint : null, ...(moved === undefined ? {} : { createdSourceCommit: moved }),
323
298
  policyConfigHash, completionBase: null, updatedAt: now,
324
299
  revisions: [...(current.revisions ?? []), { previousTask: current.task, previousEvidence: current.evidence,
325
300
  previousFailure: current.failureFingerprint, previousHash: input.expectedContractHash,
@@ -329,6 +304,15 @@ export class TaskService {
329
304
  return cloneRecord(withoutReview);
330
305
  });
331
306
  }
307
+ /** A moved baseline must be a real commit in the candidate's history, so red-to-green still proves this change. */
308
+ async #resolveCommit(ref) {
309
+ const git = this.#completion?.gitRunner;
310
+ const resolved = git === undefined ? undefined : await git.run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
311
+ const sha = resolved?.exitCode === 0 ? resolved.stdout.toString().trim() : "";
312
+ if (!commitIdentity(sha) || (await git.run(["merge-base", "--is-ancestor", sha, "HEAD"])).exitCode !== 0)
313
+ throw taskError("TASK_BASELINE_INVALID", "--baseline must resolve to a commit that is an ancestor of HEAD.");
314
+ return sha;
315
+ }
332
316
  async replan(taskId, input) {
333
317
  if (input.reason.trim() === "" || input.reason.includes("\0") || input.reason.length > 8192 ||
334
318
  input.tasks.length === 0 || input.tasks.length > 128)
@@ -461,8 +445,6 @@ export class TaskService {
461
445
  if (record.policyConfigHash !== parent.policyConfigHash || !validateTask(task).ok) {
462
446
  throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has an incompatible policy or definition.`);
463
447
  }
464
- if (parent.task.debug !== undefined && (task.goal !== parent.task.goal || JSON.stringify(task.debug) !== JSON.stringify(parent.task.debug) || record.debug !== undefined))
465
- throw taskError("TASK_DEBUG_ROOT_MISMATCH", "A delivery must retain the original issue identity and cannot import an independent debug budget.");
466
448
  const existing = state.tasks.find((item) => item.task.id === task.id);
467
449
  if (existing !== undefined) {
468
450
  if (JSON.stringify(existing.task) !== JSON.stringify(task)) {
@@ -488,11 +470,6 @@ export class TaskService {
488
470
  }
489
471
  });
490
472
  }
491
- /** The active DEBUG root already holding this original issue, if any. */
492
- async activeDebugRoot(goal) {
493
- return (await this.list()).find((record) => record.status === "active" && record.task.debug?.rootTaskId === record.task.id &&
494
- record.task.goal !== undefined && sameDebugText(record.task.goal, goal)) ?? null;
495
- }
496
473
  async list(filter = {}) {
497
474
  const state = await this.#store.read();
498
475
  return state.tasks
@@ -721,7 +698,7 @@ export class TaskService {
721
698
  validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
722
699
  validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint &&
723
700
  (!(snapshot.task.goal !== undefined || (snapshot.task.contractRevision ?? 0) > 0 || snapshot.task.criteria.some(c => c.acceptance !== undefined)) ||
724
- validation.value.taskContractHash === taskContractHash(snapshot.task))
701
+ evidenceMatchesContract(validation.value.taskContractHash, snapshot.task))
725
702
  ? validation.value : null;
726
703
  };
727
704
  for (const criterion of snapshot.task.criteria) {
@@ -4,7 +4,6 @@ import { AgentOpsError } from "../fs/paths.js";
4
4
  import { validateTask } from "../schema/validate.js";
5
5
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
6
6
  import { taskContractHash } from "./contract.js";
7
- import { assertDebugState } from "./debug-state.js";
8
7
  import { commitIdentity, acceptanceSourcePath } from "../schema/acceptance.js";
9
8
  const DEFAULT_MAX_BYTES = 1024 * 1024;
10
9
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
@@ -78,11 +77,9 @@ function parseTaskRecord(value) {
78
77
  if (!task.ok) {
79
78
  return invalidState("Task state contains an invalid task.");
80
79
  }
81
- if (value.debug !== undefined) {
82
- assertDebugState(value.debug);
83
- if (value.debug.rootTaskId !== task.value.id || task.value.debug?.rootTaskId !== task.value.id)
84
- return invalidState("Debug counters belong only to the original issue root.");
85
- }
80
+ // Pre-0.11 DEBUG budget/hypothesis state: accepted, then dropped on load.
81
+ if (value.debug !== undefined && !isRecord(value.debug))
82
+ return invalidState("Task state contains an invalid task record.");
86
83
  if (!["active", "archived", "complete"].includes(String(value.status)) ||
87
84
  !isTimestamp(value.createdAt) ||
88
85
  !isTimestamp(value.updatedAt) ||
@@ -188,7 +185,6 @@ function parseTaskRecord(value) {
188
185
  }
189
186
  return {
190
187
  task: task.value,
191
- ...(value.debug === undefined ? {} : { debug: value.debug }),
192
188
  ...(value.noChangePaths === undefined ? {} : { noChangePaths: value.noChangePaths }),
193
189
  ...(value.createdSourceCommit === undefined ? {} : { createdSourceCommit: value.createdSourceCommit }),
194
190
  ...(value.revisions === undefined ? {} : { revisions: value.revisions }),
@@ -2,10 +2,9 @@ import { calculateConfigHash, canonicalJson } from "../config/hash.js";
2
2
  import { readPrivateFile } from "../security/permissions.js";
3
3
  import { join } from "node:path";
4
4
  import { sha256 } from "../fs/hash.js";
5
- import { criterionContractHash, taskContractHash } from "../task/contract.js";
5
+ import { criterionContractHash, evidenceMatchesContract } from "../task/contract.js";
6
6
  /** Only current, paired check evidence discharges a mechanical contract. */
7
7
  export async function acceptanceCoverage(record, config, sourceFingerprint, store) {
8
- const contract = taskContractHash(record.task);
9
8
  const configHash = calculateConfigHash(config);
10
9
  const rows = [];
11
10
  for (const criterion of record.task.criteria) {
@@ -18,7 +17,7 @@ export async function acceptanceCoverage(record, config, sourceFingerprint, stor
18
17
  const evidence = await store.load(reference);
19
18
  if (evidence?.schemaVersion === 4 && evidence.taskId === record.task.id &&
20
19
  evidence.criterionId === criterion.id && evidence.configHash === configHash &&
21
- evidence.sourceFingerprint === sourceFingerprint && evidence.taskContractHash === contract &&
20
+ evidence.sourceFingerprint === sourceFingerprint && evidenceMatchesContract(evidence.taskContractHash, record.task) &&
22
21
  evidence.acceptance?.criterionContractHash === criterionContractHash(criterion)) {
23
22
  matching.push({ reference, evidence });
24
23
  }
@@ -167,9 +167,10 @@ export function aggregateAcceptanceRun(run, requestedCheckIds) {
167
167
  const duplicateCheckIds = sortedUnique([...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id));
168
168
  const unexpectedCheckIds = sortedUnique(run.results.filter((result) => !expected.has(result.checkId)).map((result) => result.checkId));
169
169
  const missingCheckIds = requested.filter((id) => !counts.has(id));
170
- if (duplicateCheckIds.length > 0 || unexpectedCheckIds.length > 0 || missingCheckIds.length > 0) {
171
- return unknown(run, run.results, duplicateCheckIds.length > 0 ? ACCEPTANCE_FAILURE.duplicate :
172
- missingCheckIds.length > 0 ? ACCEPTANCE_FAILURE.missing : ACCEPTANCE_FAILURE.unexpected, ["The observed checks do not match the requested checks exactly."], missingCheckIds, duplicateCheckIds, unexpectedCheckIds);
170
+ // Checks the runner emits beyond the requested ones are ignored: a binding may list only its red checks.
171
+ // An unrequested failure still surfaces through the runner's exit code, which replay reconciles.
172
+ if (duplicateCheckIds.length > 0 || missingCheckIds.length > 0) {
173
+ return unknown(run, run.results, duplicateCheckIds.length > 0 ? ACCEPTANCE_FAILURE.duplicate : ACCEPTANCE_FAILURE.missing, ["The observed checks do not include every requested check exactly once."], missingCheckIds, duplicateCheckIds, unexpectedCheckIds);
173
174
  }
174
175
  const requestedResults = requested.map((id) => run.results.find((result) => result.checkId === id));
175
176
  if (run.diagnostics.length > 0) {
@@ -8,7 +8,7 @@ import { canonicalJson } from "../config/hash.js";
8
8
  import { sha256 } from "../fs/hash.js";
9
9
  import { removeTemporary } from "../fs/temporary.js";
10
10
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
11
- import { criterionContractHash, taskContractHash } from "../task/contract.js";
11
+ import { criterionContractHash, executionContractHash } from "../task/contract.js";
12
12
  import { aggregateAcceptanceRun, makeAcceptanceCheck, makeAcceptanceRun } from "./acceptance-protocol.js";
13
13
  import { collectNodeAcceptance, collectJestAcceptance, collectVitestAcceptance, collectPytestAcceptance, collectRustAcceptance } from "./adapters/index.js";
14
14
  import { buildVerificationEvidence } from "./evidence.js";
@@ -118,12 +118,9 @@ export async function replayAcceptance(options) {
118
118
  const env = replayEnvironment(checkout, phase, executionId);
119
119
  let timedOut;
120
120
  const command = async (executable, args, cwd, stage, own) => {
121
- const remaining = await options.remainingMs?.();
122
- if (remaining === 0)
123
- throw new AgentOpsError("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
124
121
  const limit = own ?? ACCEPTANCE_TIMEOUTS[stage];
125
122
  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,
123
+ evidence: { kind: "exit-code" }, timeoutMs: limit }, { cwd, runner: options.processRunner, env, replaceEnv: true,
127
124
  outputLimitBytes: 1024 * 1024, ...(options.signal === undefined ? {} : { signal: options.signal }) });
128
125
  if (result.failureClass === "timeout" && timedOut === undefined)
129
126
  timedOut = stage === "git"
@@ -217,7 +214,7 @@ export async function replayAcceptance(options) {
217
214
  failure = aggregate?.failureClass ?? output.failureClass;
218
215
  }
219
216
  catch (error) {
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";
217
+ failure = error instanceof Error && /^[a-z-]+$/u.test(error.message) ? error.message : "replay-unavailable";
221
218
  }
222
219
  finally {
223
220
  await removeTemporary(temp);
@@ -245,7 +242,7 @@ export async function replayAcceptance(options) {
245
242
  scope: options.scope, startedAt, finishedAt, exitCode: output?.exitCode ?? null, testCount: checks.length,
246
243
  status, failureClass: status === "PASS" ? "none" : phase === "baseline" && aggregate?.status === "PASS" ? "non-discriminating" : failure,
247
244
  sourceFingerprint: options.sourceFingerprint, toolVersions: { node: process.version }, config: options.config,
248
- taskContractHash: taskContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
245
+ taskContractHash: executionContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
249
246
  bindingHash: sha256(canonicalJson({ runnerId: group.runner.id, materials: group.binding.materials })), phase, commit, pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest, executionArtifact, checks } });
250
247
  const reference = await options.evidenceStore.save(evidence);
251
248
  reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference,
@@ -1,4 +1,4 @@
1
- import { taskContractHash } from "../task/contract.js";
1
+ import { executionContractHash, taskContractHash } from "../task/contract.js";
2
2
  import { replayAcceptance } from "./acceptance-replay.js";
3
3
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
4
4
  import { validateTaskAgainstConfig } from "../schema/validate.js";
@@ -61,7 +61,9 @@ export class VerificationService {
61
61
  failureClass: result.failureClass,
62
62
  sourceFingerprint,
63
63
  toolVersions: this.#options.toolVersions ?? {},
64
- ...(task.goal === undefined && (task.contractRevision ?? 0) === 0 && task.criteria.every(c => c.acceptance === undefined) ? {} : { taskContractHash: taskContractHash(task) }),
64
+ // Surfaced tasks are always bound, so a later surface-only revision of a goal-less one keeps it; legacy tasks stay unbound.
65
+ ...(task.goal === undefined && (task.contractRevision ?? 0) === 0 && task.criteria.every(c => c.acceptance === undefined) &&
66
+ task.surfaceRequired === undefined ? {} : { taskContractHash: executionContractHash(task) }),
65
67
  config: this.#options.config
66
68
  });
67
69
  references.push(await this.#options.evidenceStore.save(evidence));
@@ -70,10 +72,7 @@ export class VerificationService {
70
72
  }
71
73
  async #runCommand(task, command) {
72
74
  const startedAt = (this.#options.now ?? (() => new Date().toISOString()))();
73
- const remaining = task.debug === undefined ? undefined : await this.#options.taskService.debugService().remaining(task.id);
74
- if (remaining === 0)
75
- throw verificationError("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
76
- const result = await executeConfiguredCommand(remaining === undefined ? command : { ...command, timeoutMs: Math.min(command.timeoutMs ?? 300_000, remaining) }, {
75
+ const result = await executeConfiguredCommand(command, {
77
76
  cwd: await this.#commandCwd(command),
78
77
  runner: this.#options.processRunner,
79
78
  trusted: this.#options.trusted
@@ -110,11 +109,6 @@ export class VerificationService {
110
109
  };
111
110
  }
112
111
  async verify(taskId) {
113
- const stored = await this.#options.taskService.status({ taskId });
114
- return stored.task.debug === undefined ? await this.#verify(taskId) :
115
- await this.#options.taskService.debugService().verification(taskId, () => this.#verify(taskId));
116
- }
117
- async #verify(taskId) {
118
112
  const stored = await this.#options.taskService.status({ taskId });
119
113
  if (stored.status === "archived") {
120
114
  throw verificationError("TASK_NOT_ACTIVE", "An archived task cannot be verified.");
@@ -153,8 +147,7 @@ export class VerificationService {
153
147
  results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
154
148
  }
155
149
  const acceptance = validation.value.criteria.some(c => c.acceptance !== undefined && c.acceptance.mode !== "review-only")
156
- ? await replayAcceptance({ ...this.#options, task: validation.value, sourceFingerprint,
157
- ...(stored.task.debug === undefined ? {} : { remainingMs: () => this.#options.taskService.debugService().remaining(taskId) }) }) : [];
150
+ ? await replayAcceptance({ ...this.#options, task: validation.value, sourceFingerprint }) : [];
158
151
  let status = aggregateVerificationStatus([...results, ...acceptance.map(a => ({ required: true, status: a.status }))]);
159
152
  let sourceChanged = false;
160
153
  const postflightScope = await resolveReviewScope({
@@ -197,16 +190,6 @@ export class VerificationService {
197
190
  }
198
191
  }
199
192
  else {
200
- if (stored.task.debug !== undefined && !sourceChanged) {
201
- const failures = {};
202
- for (const result of results) {
203
- const references = await this.#persistEvidence(validation.value, commandById(this.#options.config, result.commandId), result.startedAt, result.finishedAt, result, sourceFingerprint);
204
- relevantCriteria(validation.value, result.commandId).forEach((criterion, index) => { const reference = references[index]; if (reference !== undefined)
205
- failures[criterion.id] = [...(failures[criterion.id] ?? []), reference]; });
206
- }
207
- if (Object.keys(failures).length > 0)
208
- await this.#options.taskService.recordEvidence(taskId, failures);
209
- }
210
193
  const required = [...results.filter((result) => result.required), ...acceptance.map(a => ({ commandId: a.runnerId, required: true,
211
194
  status: a.status, failureClass: a.failureClass, exitCode: null, timedOut: false, testCount: null,
212
195
  diagnostic: a.criterionId + ":" + a.phase + ":" + a.failureClass, evidenceReferences: [a.reference], startedAt: "", finishedAt: "" }))];
@@ -3,17 +3,30 @@ import { join } from "node:path";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { resolveGitCommit } from "./change-surface.js";
6
+ /**
7
+ * Escape-ledger records are committed after the fix; a commit touching only them leaves the base-mode fingerprint alone.
8
+ * Only top-level task records qualify: rules/ steer the surface gate and stay fingerprinted.
9
+ */
10
+ const ledgerRecord = (path) => /^\.agent-ops-escapes\/task-[A-Za-z0-9-]+\.json$/u.test(path);
11
+ /** HEAD's tree without the ledger, so a ledger-only commit yields the same digest. */
12
+ async function ledgerFreeTree(runner) {
13
+ const result = await runner.run(["ls-tree", "-r", "--full-tree", "-z", "HEAD"]);
14
+ if (result.exitCode !== 0)
15
+ throw new AgentOpsError("CHANGE_SURFACE_GIT_FAILED", "Git could not list the HEAD tree.");
16
+ const entries = Buffer.from(result.stdout).toString("utf8").split("\0").filter(entry => entry !== "" && !ledgerRecord(entry.split("\t", 2)[1]));
17
+ return sha256(entries.join("\0"));
18
+ }
6
19
  export async function calculateSourceFingerprint(root, scope, runner) {
7
- const head = await resolveGitCommit(runner, "HEAD");
8
20
  if (scope.mode === "base") {
9
21
  return sha256(JSON.stringify({
10
- domain: "agent-ops-source-v1",
22
+ domain: "agent-ops-source-v2",
11
23
  mode: scope.noChange === true ? "goal-no-change" : "base",
12
- head,
24
+ tree: await ledgerFreeTree(runner),
13
25
  base: scope.resolvedBase,
14
- paths: [...scope.changedFiles]
26
+ paths: scope.changedFiles.filter(path => !ledgerRecord(path))
15
27
  }));
16
28
  }
29
+ const head = await resolveGitCommit(runner, "HEAD");
17
30
  const paths = [];
18
31
  for (const path of scope.changedFiles) {
19
32
  const absolute = join(root, ...path.split("/"));
@@ -283,7 +283,7 @@ export async function runVerificationCommand(command, options) {
283
283
  }
284
284
  const outputLimit = boundedPositiveInteger(options.outputLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, MAX_OUTPUT_LIMIT_BYTES, "outputLimitBytes");
285
285
  const terminationGrace = boundedPositiveInteger(options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, MAX_TERMINATION_GRACE_MS, "terminationGraceMs");
286
- const timeoutMs = boundedPositiveInteger(command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
286
+ const timeoutMs = options.timeoutMs === null ? null : boundedPositiveInteger(options.timeoutMs ?? command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
287
287
  const runner = options.runner ?? new NodeVerificationProcessRunner();
288
288
  let running;
289
289
  try {
@@ -304,7 +304,8 @@ export async function runVerificationCommand(command, options) {
304
304
  const stderr = captureOutput(running.stderr, outputLimit, options.onActivity);
305
305
  let timer;
306
306
  const timeout = new Promise((resolve) => {
307
- timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
307
+ if (timeoutMs !== null)
308
+ timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
308
309
  });
309
310
  let removeAbortListener = () => { };
310
311
  const aborted = new Promise((resolve) => {
@@ -1,135 +1,41 @@
1
1
  # Task DEBUG investigations
2
2
 
3
- Project `init` and `update` add `features.debug` by default:
4
- `{"enabled":true,"budgetMs":3600000,"maxRounds":5}` (sixty minutes). Explicit disabled and custom
5
- settings survive subsequent installs. Old configurations and ordinary tasks
6
- remain readable. DEBUG is an optional task intake field, not a new run mode.
7
-
8
- Claude and Codex native prompt hooks conservatively recognize investigation and
9
- bug-fix requests. They retain the issue verbatim as `goal`, record uncertain
10
- facts/unknowns, and create an existing isolated task. Explain, feature and
11
- refactor requests retain their ordinary behavior. A reliable native session ID
12
- is required; agy does not provide this prompt intake. Its native agent can use
13
- `task create --debug --goal '<original issue>'` with two to five criteria, which
14
- starts the same foreground clock when the native session is available.
15
- An original dirty checkout is refused before any worktree snapshot is created.
16
-
17
- The intake criteria are provisional. The native agent investigates via trusted
18
- configured commands, records hypotheses, replaces the criteria with relevant
19
- behavioral acceptance, fixes the product, and uses existing `verify`, `review`
20
- and worktree finish commands. There is no additional model or background worker.
21
- The default is the full repair loop. A clean HEAD fixes the baseline automatically;
22
- do not request redundant approval or offer diagnosis-only as a fallback. Failure
23
- to reproduce alone does not permit Stop: continue discriminating checks within
24
- the original budget.
25
-
26
- ## Human control
27
-
28
- Native human transcript events control the existing task; CLI actor/text fields,
29
- assistant messages and injected instructions cannot authorize a scope change.
30
- Codex supports current `response_item` human messages and native async answers,
31
- as well as legacy `event_msg` transcripts.
32
-
33
- - `只診斷` / `diagnosis only`: investigate without product repairs. Non-success
34
- Stop requires a confirmed hypothesis supported by a current sealed baseline
35
- assertion FAIL for the original regression, or an existing runtime blocker/limit.
36
- - `暫停` / `pause`: save progress and stop immediately. Native Codex `Interrupt`
37
- also pauses, with a three-second hook timeout; it never blocks or restarts an interrupt.
38
- - `繼續` / `continue`: resume the same task and scope. An exhausted time budget
39
- still needs the existing native time grant; other blockers remain authoritative.
40
- - `繼續修復` / `resume repair`: restore the full repair loop on the existing task.
41
-
42
- Pauses retain evidence, consumed time and rounds, exclude waiting time, and do not
43
- reset budgets. Diagnosis and pause cannot complete a repair task. Ambiguous free
44
- text is not inferred as permission; use an explicit command above.
45
-
46
- Use `task debug --task <id> --debug-action <action>`:
47
-
48
- - `status` displays root-wide hypotheses, receipts and remaining state.
49
- - `investigate --command-id <id>` runs a trusted configured verifier. Actual
50
- subprocess outcomes/time are recorded; reproduction-only work is not a fix round.
51
- - `hypothesis --payload-file <json>` records `id`, `statement`, `method`,
52
- `expected`, `checkId`, `expectedStatus` (`PASS` or `FAIL`). Add `reference` to
53
- resolve it from actual sealed acceptance evidence. `--action investigate` may
54
- only run a declared unresolved behavioral red check; `--action observe` may
55
- only classify the current matching runtime `UNKNOWN` receipt as inconclusive.
56
- Neither action lets an agent assign a status. A rejected hypothesis needs a
57
- new reference and `reason`.
58
- - `strategy --payload-file <json>` records a different `from`/`to` method,
59
- substantive `reason` and discriminating `reference`. Only one switch is allowed.
60
- - `origin --payload-file <json>` declares where the bug came from once a
61
- hypothesis is confirmed: `{"kind": "escape-of", "commit": "<commit>", "label":
62
- "<earliest gate>"}` (optional `"receipt"`) or `{"kind": "pre-existing" |
63
- "external", "reason": "..."}`. It revises the contract, so re-verify and
64
- re-record the confirmed hypothesis before review; finish refuses
65
- `ESCAPE_ORIGIN_MISSING` without it. `escape-of` prints the link state and
66
- `git blame` hints and writes `.agent-ops-escapes/<task-id>.json`, which is
67
- committed with the fix (see `EVIDENCE-ESCAPE-001`). A surface label also
68
- needs a tracked `.agent-ops-escapes/rules/<rule-id>.json` rule that replays
69
- at the escaped commit, and a criteria/verify label needs `"promote":
70
- "<runnerId:checkId>"`; finish refuses `ESCAPE_RULE_UNPROVEN` otherwise (see
71
- `EVIDENCE-ESCAPE-002`).
72
- - `checkpoint --session <id>` saves the current bound source/policy/stop cause.
73
- - `resume --session <id>` recovers a matching owned worktree checkpoint. Mismatches
74
- require human confirmation and remain preserved; this command cannot waive them.
75
- - `extend --session <native-id> --event-id latest` reads the current native human
76
- continue or resume-repair event. After time exhaustion it grants a full twenty
77
- minutes from that event, retaining consumed time and overrun in the grant snapshot
78
- without waiving any other blocker. No actor/text flag can
79
- authorize a grant. Missing native transcripts fail closed as unsupported.
80
-
81
- Limits belong to the original root across revisions, children and sessions.
82
- Repeating an active issue's text from a new session reattaches that root (or
83
- resumes the worktree holding it) instead of starting a fresh budget. A
84
- resolved hypothesis cannot return under a new id with the same statement and
85
- method; reopen the original with new discriminating evidence.
86
- Controlled investigation, verification and acquired review execution consume
87
- active time; observable review-slot waiting is excluded. Claude, Codex, and agy
88
- use their native session lifecycle to account foreground reasoning, tool use,
89
- and edits. Accepted idle Stop closes the foreground clock. Rejected Codex Stop
90
- keeps the clock running, including repeated Stop (`stop_hook_active: true`). OpenCode has no supported
91
- foreground lifecycle and remains unsupported. Time between stopped turns is
92
- paused. An interrupted unsealed operation remains blocked until its persisted process
93
- identity proves death; only then is an UNKNOWN environment receipt sealed and
94
- the elapsed interval retained.
95
-
96
- Only changed product candidates followed by verification count toward repair
97
- rounds. Test/fixture edits do not. New paired original-regression outcomes or
98
- new discriminating hypothesis outcomes count as progress; renaming a task or
99
- hypothesis does not. Two stagnant rounds require the strategy switch; two more
100
- stop, subject to the global round/time ceilings.
101
- The switch may cite this issue's own sealed regression evidence from an earlier
102
- contract revision, because a scope revision stales current evidence while
103
- verification stays stopped; hypotheses and delivery still need fresh evidence.
104
- At the round limit an unchanged candidate may still be reviewed, and re-verified
105
- when its last verification passed; a failing or changed candidate stays stopped.
106
-
107
- Success requires baseline assertion red and candidate green for the original
108
- regression, required related checks, a supported confirmed hypothesis, and two
109
- fresh independent reviews bound to the current source, contract, config and
110
- meaningful DEBUG proof. Missing IDs, setup failure, skip, timeout and UNKNOWN
111
- never prove reproduction. Proof changes invalidate review. Completed tasks and
112
- worktree finish retain the existing attestation/integration requirements.
113
-
114
- On Claude, agy and native Codex, Stop checks an active DEBUG issue even without Git net diff,
115
- with or without the completion gate. Without the gate this check alone runs,
116
- with one targeted task/config reload on a gate fault. A second failure emits an
117
- explicit `UNKNOWN` / incomplete diagnostic with `agent-ops doctor` recovery,
118
- permits non-success Stop and preserves the task, evidence and budgets. It never
119
- attests completion or retries indefinitely.
120
- A valid runtime stop checkpoint permits non-success Stop while retaining the
121
- active task. `allow-stop` cannot waive DEBUG. Gates are never automatically
122
- enabled for unsupported hosts: the generic Codex completion gate remains
123
- unsupported, while its DEBUG-specific Stop uses native `decision: "block"`
124
- with a continuation reason. `continue: false` would stop the harness and is not
125
- used to continue DEBUG. OpenCode Stop is unsupported. This feature
126
- persists foreground progress, and does not promise
127
- execution after the native conversation closes. Native transcripts are trusted
128
- host evidence, not protection against an attacker with the same filesystem
129
- authority.
130
-
131
- An explicit native human `no time limit` / `取消時間限制` instruction removes the
132
- original issue time cap via the same native-only `extend --event-id latest`
133
- authority. The grant records `grantedMs: null` and the spent snapshot; original
134
- budget, consumption, prior grants, iteration limits and evidence gates remain.
135
- Ordinary command and review stall timeouts remain in force.
3
+ A DEBUG task is an ordinary task whose acceptance proves the original
4
+ regression: a behavioral criterion whose `redCheckIds` fail with an assertion at
5
+ the baseline commit and pass on the candidate. Nothing else is special about it.
6
+
7
+ ```sh
8
+ agent-ops task create --debug --title "Fix cart scroll" \
9
+ --goal '<the issue, verbatim>' \
10
+ --surface-file surface.json \
11
+ --criterion-file regression.json --criterion-file related.json
12
+ ```
13
+
14
+ - `--debug` needs `--goal` and at least one behavioral criterion with
15
+ `redCheckIds`; otherwise creation fails with `TASK_DEBUG_RED_CHECK_REQUIRED`.
16
+ The surface audit is required as for any task.
17
+ - The baseline is the commit the task was created on; `task revise --baseline
18
+ <ref>` moves it (for example off the wrong branch) and voids earlier evidence.
19
+ - Then fix, commit, `verify`, two-round `review` and `worktree finish` exactly
20
+ as for any task. Setup failure, skip, timeout and UNKNOWN never prove
21
+ reproduction; the red check must fail on an assertion at the baseline.
22
+
23
+ ## Escape origin (advisory)
24
+
25
+ If you know where the bug came from, record it like any task:
26
+ `task revise --escape-of <commit> --escape-label <label>`. It writes `.agent-ops-escapes/<task-id>.json` and
27
+ prints the link state, `git blame` hints and any rule proof
28
+ (`ESCAPE_RULE_UNPROVEN` and friends). None of it blocks finish; see
29
+ `EVIDENCE-ESCAPE-001` and `EVIDENCE-ESCAPE-002`.
30
+
31
+ ## Removed in 0.11.0
32
+
33
+ `task debug` (hypotheses, strategy switches, investigate, checkpoint, resume,
34
+ extend, origin), the 60-minute budget and five-round limit, native control
35
+ words (只診斷, 暫停, 繼續, 繼續修復, 取消時間限制), automatic DEBUG intake from
36
+ prompt hooks, the DEBUG-specific Stop block and the Codex `Interrupt` hook are
37
+ gone. `task debug` now fails with `TASK_DEBUG_REMOVED`.
38
+
39
+ Older state still loads: tasks keep their `debug` tag, stored budget and
40
+ hypothesis state is ignored, `originRequired` no longer blocks finish, and a
41
+ config's `features.debug` is accepted and dropped by the next `update`.