@kylecheng3146/agent-ops 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/packages/cli/src/args.js +56 -4
  2. package/dist/packages/cli/src/bin.js +10 -3
  3. package/dist/packages/cli/src/cli.js +18 -2
  4. package/dist/packages/cli/src/commands/escapes.js +109 -0
  5. package/dist/packages/cli/src/commands/parallel.js +5 -0
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +145 -4
  8. package/dist/packages/cli/src/commands/task.js +58 -7
  9. package/dist/packages/cli/src/commands/update.js +16 -4
  10. package/dist/packages/cli/src/commands/verify.js +4 -1
  11. package/dist/packages/cli/src/debug-intake.js +2 -2
  12. package/dist/packages/cli/src/run-entry.js +7 -5
  13. package/dist/runtime/src/escape/link.js +317 -0
  14. package/dist/runtime/src/escape/origin.js +20 -0
  15. package/dist/runtime/src/escape/rules.js +252 -0
  16. package/dist/runtime/src/fs/temporary.js +10 -0
  17. package/dist/runtime/src/install/harness.js +2 -2
  18. package/dist/runtime/src/logging/local-log.js +61 -1
  19. package/dist/runtime/src/parallel/finish.js +20 -3
  20. package/dist/runtime/src/parallel/manage.js +51 -1
  21. package/dist/runtime/src/parallel/receipt.js +9 -1
  22. package/dist/runtime/src/review/execute.js +12 -6
  23. package/dist/runtime/src/review/packet.js +15 -1
  24. package/dist/runtime/src/review/probe.js +3 -2
  25. package/dist/runtime/src/review/runner.js +45 -0
  26. package/dist/runtime/src/review/slots.js +1 -1
  27. package/dist/runtime/src/run/planning.js +5 -2
  28. package/dist/runtime/src/schema/validate.js +17 -1
  29. package/dist/runtime/src/surface/discovery.js +298 -0
  30. package/dist/runtime/src/surface/schema.js +168 -0
  31. package/dist/runtime/src/task/completion.js +20 -0
  32. package/dist/runtime/src/task/contract.js +5 -1
  33. package/dist/runtime/src/task/debug.js +27 -1
  34. package/dist/runtime/src/task/service.js +72 -5
  35. package/dist/runtime/src/verify/acceptance-replay.js +24 -11
  36. package/docs/en/guides/configuration.md +16 -1
  37. package/docs/en/guides/task-debug.md +12 -0
  38. package/docs/en/spec/acceptance-and-evidence.md +152 -0
  39. package/docs/en/spec/judgment.md +7 -0
  40. package/docs/en/spec/loop-engineering.md +2 -2
  41. package/docs/en/spec/review.md +5 -0
  42. package/docs/zh-TW/guides/configuration.md +14 -1
  43. package/docs/zh-TW/guides/task-debug.md +10 -0
  44. package/docs/zh-TW/spec/acceptance-and-evidence.md +127 -0
  45. package/docs/zh-TW/spec/judgment.md +6 -0
  46. package/docs/zh-TW/spec/loop-engineering.md +2 -2
  47. package/docs/zh-TW/spec/review.md +1 -0
  48. package/package.json +1 -1
  49. package/schemas/task.schema.json +14 -0
@@ -5,6 +5,8 @@ import { isPassingVerificationEvidence } from "../verify/evidence.js";
5
5
  import { taskContractHash } from "./contract.js";
6
6
  import { acceptanceCoverage, coverageDigest } from "../verify/acceptance-coverage.js";
7
7
  import { loadDebugProof, debugProofDigest, debugRemainingMs } from "./debug-state.js";
8
+ import { escapeCompletionProblem } from "../escape/link.js";
9
+ import { RULES_DIRECTORY } from "../escape/rules.js";
8
10
  export async function checkTaskCompletionEvidence(stored, options) {
9
11
  const debug = stored.task.debug === undefined ? undefined : await loadDebugProof(options.root, stored.task.debug.rootTaskId);
10
12
  if (debug?.control?.paused === true || debug?.control?.mode === "diagnose")
@@ -28,6 +30,24 @@ export async function checkTaskCompletionEvidence(stored, options) {
28
30
  attestation?.debugProofs?.[stored.task.id] !== debugProofDigest(debug))
29
31
  return { status: "FAIL", code: "TASK_DEBUG_PROOF_REQUIRED", remedy: "Confirm the original cause with current discriminating regression proof, then obtain two fresh independent reviews bound to that debug proof." };
30
32
  }
33
+ // Fails closed: the Escape Loop needs every DEBUG fix to say where the bug came from.
34
+ if (stored.task.originRequired === true && stored.task.origin === undefined)
35
+ return { status: "FAIL", code: "ESCAPE_ORIGIN_MISSING", remedy: "Declare the issue origin with task debug --debug-action origin --payload-file <json> (escape-of <commit> + label, pre-existing or external + reason) after the hypothesis is confirmed, then re-verify, re-record the hypothesis and obtain review." };
36
+ // Escape Loop R2: a declared escape finishes only with its proof (rule replay + backtest, or a promoted red-to-green check).
37
+ const origin = stored.task.origin, base = debug?.baselineCommit ?? stored.createdSourceCommit;
38
+ if (origin?.kind === "escape-of" && base !== undefined) {
39
+ const escape = await escapeCompletionProblem({ root: options.root, taskId: stored.task.id, origin, base });
40
+ if (escape !== null && "code" in escape)
41
+ return { status: "FAIL", code: escape.code, remedy: `${escape.reason} Add or fix a tracked ${RULES_DIRECTORY}/<id>.json rule, re-declare the origin and commit the record.` };
42
+ if (escape !== null) {
43
+ const [runnerId, checkId] = escape.check.split(":");
44
+ const proven = new Set((await acceptanceCoverage(stored, options.config, options.sourceFingerprint, options.evidenceStore))
45
+ .filter(row => row.status === "proven").map(row => row.criterionId));
46
+ if (!stored.task.criteria.some(c => proven.has(c.id) && c.acceptance?.mode === "behavioral" &&
47
+ c.acceptance.bindings.some(b => b.runnerId === runnerId && (b.redCheckIds ?? []).includes(checkId))))
48
+ return { status: "FAIL", code: "ESCAPE_RULE_UNPROVEN", remedy: `The promoted check ${escape.check} must be a redCheckId of a proven behavioral criterion of this task (red baseline, green candidate).` };
49
+ }
50
+ }
31
51
  if (attestation === null || attestation.taskId !== stored.task.id ||
32
52
  ((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
33
53
  (attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||
@@ -9,7 +9,11 @@ export function criterionContractHash(criterion) {
9
9
  export function taskContractHash(task) {
10
10
  return sha256(canonicalJson({ goalHash: goalHash(task), title: task.title,
11
11
  intent: task.intent ?? null, revision: task.contractRevision ?? 0,
12
- criteria: task.criteria.map(criterionContractHash), ...(task.debug === undefined ? {} : { debug: task.debug }) }));
12
+ criteria: task.criteria.map(criterionContractHash), ...(task.debug === undefined ? {} : { debug: task.debug }),
13
+ ...(task.surface === undefined ? {} : { surface: sha256(canonicalJson(task.surface)) }),
14
+ ...(task.surfaceRequired === undefined ? {} : { surfaceRequired: true }),
15
+ ...(task.origin === undefined ? {} : { origin: task.origin }),
16
+ ...(task.originRequired === undefined ? {} : { originRequired: true }) }));
13
17
  }
14
18
  export function treeContractHash(tasks) {
15
19
  return sha256(canonicalJson(tasks.map(task => ({ id: task.id, hash: taskContractHash(task) }))
@@ -21,6 +21,7 @@ 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 {
@@ -243,6 +245,27 @@ export class DebugService {
243
245
  await this.#evidence(taskId, input.reference);
244
246
  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
247
  }
248
+ /** Escape Loop: where the confirmed cause came from, bound into the contract before review. */
249
+ async origin(taskId, input) {
250
+ const root = await this.root(taskId);
251
+ if (root.task.id !== taskId)
252
+ fail("ESCAPE_ORIGIN_INVALID", "Declare the origin on the original issue task.");
253
+ if (root.status !== "active" || root.debug.operation !== null)
254
+ fail("TASK_DEBUG_BUSY", "Origin needs an active idle issue.");
255
+ if (debugRemainingMs(root.debug) === 0)
256
+ fail("TASK_DEBUG_BUDGET_EXHAUSTED", "The original issue's active budget is exhausted.");
257
+ if (!root.debug.hypotheses.some(h => h.status === "confirmed"))
258
+ fail("ESCAPE_ORIGIN_EARLY", "Declare the origin only after discriminating evidence confirms a hypothesis.");
259
+ const origin = await resolveOrigin(this.options.gitRunner, input);
260
+ const record = await this.options.tasks.revise(taskId, { expectedContractHash: taskContractHash(root.task), origin, debugOrigin: true,
261
+ reason: `Declare issue origin: ${origin.kind === "escape-of" ? "escape-of " + origin.commit : origin.kind}.` });
262
+ if (origin.kind !== "escape-of") {
263
+ await removeEscapeRecord(this.options.root, taskId);
264
+ return { record };
265
+ }
266
+ return { record, escape: await declareEscape({ root: this.options.root, git: this.options.gitRunner, taskId, origin,
267
+ base: root.debug.baselineCommit, now: new Date().toISOString() }) };
268
+ }
246
269
  async remaining(taskId) { return debugRemainingMs((await this.root(taskId)).debug); }
247
270
  async #userEvent(sessionId, home, kind = "latest") {
248
271
  const record = await readWorktreeRecord(this.options.root);
@@ -348,6 +371,9 @@ export class DebugService {
348
371
  if (root.debug.receipts.length >= 256)
349
372
  fail("TASK_DEBUG_HISTORY_LIMIT", "Execution history is full; retain it and report the blocker.");
350
373
  const productDigest = await this.#productDigest(root.debug);
374
+ // EVIDENCE-SURFACE-001: intake may start without a surface, the first repair may not.
375
+ if (kind === "verify" && task.task.surfaceRequired === true && task.task.surface === undefined && productDigest !== sha256(canonicalJson([])))
376
+ fail("SURFACE_MISSING", "Add the surface audit with task revise --surface-file before verifying the first repair.");
351
377
  const previousCandidate = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1)?.productDigest ?? sha256("[]");
352
378
  const cause = debugStopCause(root.debug);
353
379
  // Recovery reruns the original verifier; changed product content still needs fresh proof.
@@ -18,6 +18,10 @@ import { commitIdentity } from "../schema/acceptance.js";
18
18
  import { createDebugState, DEBUG_DEFAULTS } from "./debug-state.js";
19
19
  import { DebugService } from "./debug.js";
20
20
  import { sameDebugText } from "./debug-intake.js";
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";
21
25
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
22
26
  function defaultTaskId() {
23
27
  return `task-${randomUUID()}`;
@@ -123,9 +127,13 @@ export class TaskService {
123
127
  const criteria = structuredClone(input.criteria).map(criterion => ({ ...criterion,
124
128
  ...(criterion.acceptance === undefined ? {} : { acceptance: { ...criterion.acceptance,
125
129
  baselineCommit: criterion.acceptance.baselineCommit ?? createdSourceCommit } }) }));
130
+ const surface = input.surface === undefined ? undefined : parseSurface(input.surface);
126
131
  const task = {
127
132
  schemaVersion: TASK_SCHEMA_VERSION,
128
133
  id: this.#generateId(),
134
+ ...(surface === undefined ? {} : { surface }),
135
+ ...(input.requireSurface === true ? { surfaceRequired: true } : {}),
136
+ ...(input.origin === undefined ? {} : { origin: structuredClone(input.origin) }),
129
137
  title: input.title,
130
138
  ...(input.intent === undefined ? {} : { intent: input.intent }),
131
139
  criteria,
@@ -145,6 +153,10 @@ export class TaskService {
145
153
  throw taskError("TASK_DEBUG_DISABLED", "Task debug routing is explicitly disabled.");
146
154
  debugSettings = config.features.debug ?? DEBUG_DEFAULTS;
147
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;
148
160
  }
149
161
  const validation = validateTask(task);
150
162
  if (!validation.ok) {
@@ -186,6 +198,14 @@ export class TaskService {
186
198
  validation.value.debug = structuredClone(parent.task.debug);
187
199
  }
188
200
  }
201
+ if (input.requireSurface === true && validation.value.debug === undefined) {
202
+ const parentSurface = state.tasks.find(record => record.task.id === input.parentTaskId)?.task.surface;
203
+ const surfacedParent = parentSurface !== undefined && !isCovers(parentSurface);
204
+ if (surface === undefined || isCovers(surface) !== surfacedParent)
205
+ throw taskError("SURFACE_MISSING", surfacedParent
206
+ ? "A subtask of a surfaced parent declares --surface-file {\"covers\": [surface-id]}."
207
+ : "A new task needs --surface-file with the four-axis surface audit (EVIDENCE-SURFACE-001).");
208
+ }
189
209
  const record = {
190
210
  task: validation.value,
191
211
  ...(input.debug === undefined ? {} : { debug: createDebugState(task.id, createdSourceCommit, debugSettings, this.#completion.root) }),
@@ -250,8 +270,19 @@ export class TaskService {
250
270
  throw taskError("TASK_NOT_ACTIVE", "Only active tasks can revise acceptance.");
251
271
  if (taskContractHash(previous.task) !== input.expectedContractHash)
252
272
  throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
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;
283
+ const surface = input.surface === undefined ? previous.task.surface : parseSurface(input.surface);
253
284
  const baseline = input.baseline ?? previous.createdSourceCommit;
254
- const criteria = structuredClone(input.criteria).map(criterion => {
285
+ const criteria = input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria).map(criterion => {
255
286
  if (criterion.acceptance === undefined)
256
287
  return criterion;
257
288
  const sha = criterion.acceptance.baselineCommit ?? baseline;
@@ -263,7 +294,8 @@ export class TaskService {
263
294
  return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
264
295
  });
265
296
  const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
266
- contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria };
297
+ contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria, ...(surface === undefined ? {} : { surface }),
298
+ ...(input.origin === undefined ? {} : { origin: structuredClone(input.origin) }) };
267
299
  const checked = validateTask(next);
268
300
  if (!checked.ok)
269
301
  throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid criterion revision.");
@@ -278,6 +310,8 @@ export class TaskService {
278
310
  throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid binding.");
279
311
  policyConfigHash = calculateConfigHash(config);
280
312
  }
313
+ if (rebindOnly && policyConfigHash === previous.policyConfigHash)
314
+ throw taskError("TASK_REVISION_INVALID", "Nothing to revise: criteria, surface, origin and configuration are unchanged.");
281
315
  if (criteria.some(c => c.acceptance !== undefined))
282
316
  await this.#retainBaselines(next);
283
317
  const now = assertTimestamp(this.#now());
@@ -665,7 +699,10 @@ export class TaskService {
665
699
  async recordVerificationEvidence(snapshot, evidenceInput, sourceFingerprint) {
666
700
  if (snapshot.status === "active") {
667
701
  const result = await this.recordEvidence(snapshot.task.id, evidenceInput);
668
- 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);
669
706
  return result;
670
707
  }
671
708
  const completion = this.#completion;
@@ -789,18 +826,48 @@ export class TaskService {
789
826
  return structuredClone(advanced);
790
827
  });
791
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). */
792
857
  async clearFailure(taskId) {
793
858
  const now = assertTimestamp(this.#now());
794
- await this.#store.mutate((state) => {
859
+ return await this.#store.mutate((state) => {
795
860
  const current = findTask(state, taskId);
796
861
  if (current.failureFingerprint === null) {
797
- return;
862
+ return false;
798
863
  }
799
864
  replaceTask(state, {
800
865
  ...current,
801
866
  updatedAt: now,
802
867
  failureFingerprint: null
803
868
  });
869
+ // A source change mid-run is not a red the code fixed.
870
+ return current.failureFingerprint.commandId !== "source-snapshot";
804
871
  });
805
872
  }
806
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
@@ -57,6 +57,18 @@ Use `task debug --task <id> --debug-action <action>`:
57
57
  new reference and `reason`.
58
58
  - `strategy --payload-file <json>` records a different `from`/`to` method,
59
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`).
60
72
  - `checkpoint --session <id>` saves the current bound source/policy/stop cause.
61
73
  - `resume --session <id>` recovers a matching owned worktree checkpoint. Mismatches
62
74
  require human confirmation and remain preserved; this command cannot waive them.
@@ -68,6 +68,158 @@ Each child worktree MUST record its modification intent before editing.
68
68
  - Positive: `A child records its planned API change, commits it, and the final receipt includes that intent and integrated verification.`
69
69
  - Negative: `A child edits first and supplies a retrospective one-line intent at delivery.`
70
70
 
71
+ ## EVIDENCE-SURFACE-001
72
+
73
+ Every new task MUST record a surface audit of where else its change lands before writing criteria, and review MUST NOT run while discovery finds an unclassified place.
74
+
75
+ - Trigger: Creating a task (`task create`, a run plan, or before a DEBUG task's first repair), revising its surface, or reviewing it.
76
+ - Action: Search four axes with recorded fixed-string queries or `n/a: reason`, classify every hit as included, excluded (glob + reason) or UNKNOWN, give every surface row an observable `behavior`, and declare each shared contract compatible or breaking. Pass it with `task create|revise --surface-file <json>`.
77
+ - Evidence: The surface is stored on the task, bound into its contract hash with revision history, replayed by `git grep -F` before review, and handed to both reviewers under the `surface-coverage` criterion.
78
+ - Positive: `A diff token hits three copy-pasted sibling pages; the audit includes them with their behavior before review runs.`
79
+ - Negative: `Exclude src/** because it is "out of scope", or list only the files the diff already touches.`
80
+
81
+ - Axes: `dependents` (importers, callers, readers), `duplicates` (copied
82
+ markup, strings, logic that imports nothing), `otherPaths` (other routes,
83
+ modes, entry points or re-entries to the same concept) and `sharedState`
84
+ (storage, cache, messages, external APIs, client storage, issued URLs). The
85
+ design is stack-agnostic: the same axes cover frontend, backend, database,
86
+ cache and message flows.
87
+ - `included` rows carry `id`, `path` glob and `behavior`; `excluded` rows carry
88
+ `glob` and a reason saying why the surface is unaffected ("out of scope" is
89
+ not a reason); `unknown` rows name a reader the repository cannot see.
90
+ `contracts` declare `compatible` or `breaking`. A single surface is allowed.
91
+ - `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]`
92
+ list two hops whenever a value's semantics change (clear, redact, default,
93
+ normalize, unit/format).
94
+ - `scenarios` cover five generic dimensions — `initialState` (empty vs
95
+ existing), `redo` (reload, retry, redelivery, idempotency), `timeBoundary`
96
+ (day, TTL, timezone), `identitySwitch` (store, role, tenant) and
97
+ `concurrency` — each `{applies: true, behavior}` or `{na: reason}`.
98
+ - A parent holds the surface; its children declare `{"covers": [surface-id]}`
99
+ and their union must equal the parent's included list.
100
+ - Before any reviewer runs, discovery replays with `git grep -F` through argv
101
+ (never a shell or a regex built from task text), cheapest source first:
102
+ recorded queries; enclosing function names from diff hunk headers;
103
+ template-literal fixed fragments (at least 4 characters, separator-bounded,
104
+ such as `order:`, `user_`, `menu.`); compound identifiers (snake_case,
105
+ camelCase or containing digits, so prose words are not queries) and string
106
+ literals from added and removed lines (at least 6 characters; skipped as too
107
+ generic above 10 file hits, changed files included). Every hit outside the diff and the surface lists stops review.
108
+ - A derived query (diff token, template fragment or hunk-header name) whose hits
109
+ are incidental may be dismissed once in the surface's optional `dismissed`
110
+ list (`{query, reason}`, reason at most 256 characters, "out of scope" is
111
+ not a reason) instead of one exclusion per file. Recorded axis queries and
112
+ escape-rule hits cannot be dismissed, nor can a query reaching fewer than 4
113
+ files outside the diff (the likeliest sibling signal); at most 128
114
+ dismissals. Reviewers see each dismissal with its hit count, flagged when it
115
+ hid a file an earlier `SURFACE_UNCLASSIFIED` block named.
116
+ Results are cached by HEAD, base, diff, query set and config hash.
117
+ - Review returns NOT_RUN with: `SURFACE_MISSING` (no or unparseable surface;
118
+ also refused at create for non-DEBUG tasks), `SURFACE_UNCLASSIFIED`,
119
+ `SURFACE_QUERY_TOO_BROAD` (one recorded query over 200 files),
120
+ `SURFACE_EXCLUSION_TOO_BROAD` (one glob over 10 files, or any file in the
121
+ diff), `SURFACE_DISMISSAL_TOO_BROAD` (over 128 dismissals),
122
+ `SURFACE_DISMISSAL_INVALID` (a recorded or rare query), `SCENARIO_MISSING`, `SURFACE_BUDGET_EXCEEDED` (120 s total, never
123
+ silently skipped) or `SURFACE_UNASSIGNED` (children's covers differ from the
124
+ parent's included list; checked by every review, including advance and batch).
125
+ - A surface revision is a contract revision: fresh evidence and two fresh
126
+ reviews. Exclusions added after an `SURFACE_UNCLASSIFIED` block are flagged
127
+ to reviewers as post-block exclusions.
128
+ - Tasks created before this rule shipped are grandfathered. Advisory local-log
129
+ metrics per discovery source are fail-open and never evidence.
130
+ - Plain `worktree finish` writes the same local receipt as advance, including
131
+ both rounds' residual risks and non-blocking findings, references it in the
132
+ git note and prints the residual risks.
133
+
134
+ ## EVIDENCE-ESCAPE-001
135
+
136
+ Every DEBUG fix MUST declare where its bug came from before review, and a declared escape MUST be linked, labelled and recorded mechanically.
137
+
138
+ - Trigger: A DEBUG hypothesis is confirmed, or a normal task fixes a bug that passed earlier gates.
139
+ - Action: Declare `origin` with `task debug --debug-action origin --payload-file <json>`: `{"kind": "escape-of", "commit": "<commit>", "label": "<label>"}` (optional `"receipt"`), or `{"kind": "pre-existing" | "external", "reason": "..."}`. A normal task may opt in with `task create|revise --escape-of <commit> --escape-label <label> [--escape-receipt <id>]`. Commit the `.agent-ops-escapes/<task-id>.json` record with the fix.
140
+ - Evidence: `origin` is bound into the task contract hash; finish, `task complete` and the completion gate refuse a DEBUG task created after this rule without it as `ESCAPE_ORIGIN_MISSING`; both reviewers receive the link state, labels, facts and residual risks under the `escape-label` criterion.
141
+ - Positive: `Hypothesis confirmed; origin escape-of 864dbbe2 label surface; replay marks the missed page surface-unseen; the record is committed with the fix.`
142
+ - Negative: `Finish a DEBUG fix without an origin, or relabel a surface-excluded miss as review because the reviewer "should have noticed".`
143
+
144
+ - The declaration comes after a confirmed hypothesis (`ESCAPE_ORIGIN_EARLY`
145
+ otherwise), inside the DEBUG budget. It is a contract revision: re-verify,
146
+ re-record the confirmed hypothesis with the new evidence, then review.
147
+ Re-declaring replaces the record. The output lists `git blame` hint commits
148
+ for lines the fix changed or removed; blame is a hint only, because an
149
+ omission bug lives in lines nobody wrote.
150
+ - Finish receipts stay local in the Git common directory. Schema 2 records
151
+ every commit in target..candidate and a per-file diff fingerprint (blob ids
152
+ and hunk positions dropped, so a squash merge keeps it); schema 1 receipts
153
+ still load and link by exact head only.
154
+ - Link states are counted separately: `gated` (a receipt matched),
155
+ `unresolved` with a reason (`receipt-not-local` when a Git note or
156
+ `--escape-receipt` names a receipt this machine lacks, `ambiguous-receipt`,
157
+ `link-unavailable`) and `ungated` (no receipt and no note). Resolution
158
+ order: `--escape-receipt` override, the commit inside a receipt's recorded
159
+ range (schema 1: exact head), per-file fingerprint match, then unresolved or
160
+ ungated.
161
+ - Label: one primary label, the earliest gate that could have caught the
162
+ escape — `requirement`, `surface`, `scenario`, `criteria`, `verify`,
163
+ `review` — or `flagged` (a residual risk or finding already said it) or
164
+ `out-of-model`. The agent proposes it; `git grep -F` replay of the linked
165
+ receipt's recorded surface queries at its candidate marks each file the fix
166
+ touched as included, excluded, hit, unseen or absent, and `surface-excluded`
167
+ or `surface-unseen` overrides any later label. The two reviewers adjudicate.
168
+ - The ledger is git-tracked and data only (commits, paths, labels, reasons,
169
+ counts; never file contents or transcripts), one file per task so parallel
170
+ worktrees never conflict. Gate catches (`SURFACE_*` and `SCENARIO_MISSING`
171
+ review blocks, review FAIL, verify red to green) append advisory local-log
172
+ `gate-catch` events with only the code and task id; logging fails open,
173
+ while the origin requirement fails closed.
174
+ - Scope R1 records. Rules learned from escapes, replay proof, backtests,
175
+ `--escape-fix` and an `agent-ops escapes` report are R2
176
+ ([EVIDENCE-ESCAPE-002](#evidence-escape-002)).
177
+
178
+ ## EVIDENCE-ESCAPE-002
179
+
180
+ A surface escape MUST become a proven, tracked rule before its fix finishes, and learned rules MUST only tighten the surface gate.
181
+
182
+ - Trigger: A declared escape-of origin (EVIDENCE-ESCAPE-001), or a past escape backfilled with `task create --escape-of <commit> --escape-label <label> --escape-fix <fix-commit>`.
183
+ - Action: For a `surface`, `surface-unseen` or `surface-excluded` label, commit a rule `.agent-ops-escapes/rules/<rule-id>.json` shaped `{"id", "escape", "label", "trigger": {"paths", "tokens"}, "expand": {"paths", "queries"}, "noExclude"}` and re-declare the origin. For `criteria` or `verify`, name the fixing task's red-to-green check with `--escape-promote <runnerId:checkId>` (DEBUG payload `"promote"`). Other labels record a lesson.
184
+ - Evidence: Finish, `task complete` and the completion gate re-run the proof and refuse `ESCAPE_RULE_UNPROVEN`, `ESCAPE_RULE_TOO_BROAD` or `SURFACE_BUDGET_EXCEEDED`; the ledger record stores the proving rule, its content hash and its backtest fires, commits and rate.
185
+ - Positive: `Escape surface-unseen; rule image-size-siblings fires on the escaped commit, its query hits both missed sibling pages there, and fires on 2 of the last 200 commits.`
186
+ - Negative: `Finish a surface escape whose rule never fires on the escaped commit, or add a rule field that excludes paths.`
187
+
188
+ - Rules are data only and need no trust: triggers and expands become argv
189
+ `git grep -F` queries and path globs, never shell or regex. The strict schema
190
+ has no field that removes a query, adds an exclusion or relaxes any gate.
191
+ An invalid rule fails closed as review NOT_RUN `ESCAPE_RULE_INVALID`; a
192
+ symlinked or non-regular rules directory or file as `ESCAPE_RULE_UNSAFE`.
193
+ - Surface gate: a rule fires only when the task diff matches a trigger path
194
+ glob or a changed line contains a trigger token. Every expand hit of a fired
195
+ rule must be in the diff, included, UNKNOWN or excluded, else
196
+ `SURFACE_UNCLASSIFIED`; with `noExclude` an excluded hit is
197
+ `ESCAPE_RULE_NO_EXCLUDE`. Fires and included/excluded counts per rule go to
198
+ local-log `escape-rule` events (fail-open).
199
+ - Proof: the rule must name the escaped commit, fire on that commit's own diff
200
+ and, replayed at that commit, hit every missed path that existed there.
201
+ Missed paths come from the fix commit with `--escape-fix`, otherwise from
202
+ the task diff. A `criteria`/`verify` escape needs its promoted check to be a
203
+ `redCheckIds` entry of a proven behavioral criterion of the fixing task
204
+ (red baseline, green candidate); the output prints a config proposal for a
205
+ new required verifier, and agent-ops never writes `.agent-ops/config.json`.
206
+ `scenario`, `review`, `requirement`, `flagged` and `out-of-model` record an
207
+ unproven lesson with a reason and do not block. No review or test ever runs
208
+ at the escaped commit.
209
+ - Backtest: the trigger is matched against the diffs of the last 200
210
+ first-parent commits in one argv `git log`; a fire rate above 30% is
211
+ `ESCAPE_RULE_TOO_BROAD`. It is time-budgeted (over budget is
212
+ `SURFACE_BUDGET_EXCEEDED`, never a silent pass) and cached by HEAD and rule
213
+ hash. Editing a proving rule makes the ledger record stale until re-declared.
214
+ - `agent-ops escapes [--json]` is a read-only, fail-open report: misses per
215
+ label from the ledger, `gate-catch` codes from the local log, origin mix,
216
+ link-state mix with unresolved reasons, and per-rule fire/included/excluded
217
+ counts with time windows, as raw counts with n and `insufficient-data`
218
+ when n < 10. Rules with at least 10 fires and no included hit are listed as
219
+ retirement candidates; removal is always a reviewed commit, never automatic.
220
+ - R2 supersedes the phase-2 `surface.aliases` config (now tracked rules) and
221
+ `surface stats` (now `agent-ops escapes`).
222
+
71
223
  ## EVIDENCE-AGGREGATE-001
72
224
 
73
225
  The verifier MUST return FAIL when a criterion is missing, duplicated, unknown, or has empty evidence.
@@ -10,6 +10,13 @@ The operator MUST stop when requirements, authorization, or a required decision
10
10
  - Positive: `Ask whether project or user scope is intended before applying installation.`
11
11
  - Negative: `Assume project scope because it is more convenient.`
12
12
 
13
+ Surface audit triggers ([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001)) also stop before editing:
14
+
15
+ - A `breaking` change to a shared contract with any UNKNOWN reader.
16
+ - A discovered surface excluded as "intentionally different": send the human
17
+ one consolidated behavior table (surface, current behavior, proposed
18
+ behavior, reason) and wait for the decision.
19
+
13
20
  ## JUDGMENT-RETRY-001
14
21
 
15
22
  The operator MUST change approach after the same failure repeats without new information.
@@ -53,10 +53,10 @@ This module defines the bounded loop used to plan, implement, verify, and hand o
53
53
 
54
54
  ## LOOP-START-001
55
55
 
56
- The operator MUST state acceptance criteria before editing and MUST stop when each criterion has evidence.
56
+ The operator MUST audit the change's surfaces, then state acceptance criteria before editing, and MUST stop when each criterion has evidence.
57
57
 
58
58
  - Trigger: Starting a multi-step implementation or debugging loop.
59
- - Action: Record 2–5 observable criteria, then inspect only the files needed for the current step.
59
+ - Action: Record the surface audit ([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001)), then 2–5 observable criteria, then inspect only the files within the audited scope that the current step needs.
60
60
  - Evidence: The task record and final report link each criterion to a command or read-back.
61
61
  - Positive: `Criteria: tests pass; package builds; changed files are read back.`
62
62
  - Negative: `I changed the repository and will decide what success means afterward.`
@@ -84,6 +84,11 @@ successful refutation MUST make the run FAIL.
84
84
  - Negative: `Manufacture a refutation so the challenge looks effective.`
85
85
  - Note: With one configured target the same target is invoked again in a fresh
86
86
  session. A FAIL is already terminal and is never re-checked.
87
+ - Note: For a surfaced task the second round also hunts unlisted surfaces and
88
+ scenarios under `surface-coverage`: it checks the five fixed scenario
89
+ dimensions and may run at most 3 new `git grep -F` queries per axis. Round 1
90
+ replays the recorded queries and reads every listed `valueChanges` consumer;
91
+ this is the only exception to "do not survey the repository".
87
92
 
88
93
  ## REVIEW-HOST-001
89
94