@kylecheng3146/agent-ops 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,8 +53,14 @@ agent-ops run logs <run-id>
53
53
  agent-ops run respond <run-id> --question-id <id> --answer "Clarification"
54
54
  agent-ops run stop <run-id>
55
55
  agent-ops run resume <run-id>
56
+ agent-ops run land <run-id> [--task <task-id>]... --yes
56
57
  ```
57
58
 
59
+ `run land` merges the delivered children of a paused, blocked or budget-limited
60
+ run ahead of the rest: it verifies them, runs the two-round review and finishes
61
+ with a receipt in a separate landing worktree, while the run stays resumable and
62
+ its final proof re-verifies the whole goal.
63
+
58
64
  `--host claude` uses Claude Code; `--wait` waits for a terminal or input state.
59
65
  The coordinator counts toward the two writer slots. Native processes write only
60
66
  under current leases in separate worktrees. Active time counts once across parallel
@@ -268,7 +274,8 @@ question through `agent-ops run respond`. A start refused with
268
274
  `WORKTREE_NESTED` falls back to the subtask flow; `RUN_REPO_UNTRUSTED` stops and
269
275
  asks you. A run that ends without completing is reported with its status, last
270
276
  log events, and the `agent-ops run resume <id>` / `agent-ops run stop <id>`
271
- commands; it is never resumed automatically. agy keeps the subtask flow.
277
+ commands (plus `agent-ops run land <id> --yes` when it has delivered tasks); it
278
+ is never resumed or landed automatically. agy keeps the subtask flow.
272
279
  Init and update pre-authorize `agent-ops run` for Claude Code and Codex.
273
280
 
274
281
  ```bash
@@ -922,7 +922,7 @@ export function parseArgs(argv) {
922
922
  };
923
923
  }
924
924
  function parseRunArgs(argv) {
925
- const actions = ["start", "status", "logs", "resume", "stop", "respond"];
925
+ const actions = ["start", "status", "logs", "resume", "stop", "respond", "land"];
926
926
  if (argv.includes("--help") || argv.includes("-h"))
927
927
  return { command: "help", helpTopic: "run", profiles: [], dryRun: false, json: false, yes: false, rerun: false };
928
928
  const action = actions.includes(argv[0]) ? argv[0] : "start";
@@ -937,21 +937,30 @@ function parseRunArgs(argv) {
937
937
  positional = token;
938
938
  continue;
939
939
  }
940
- if (seen.has(token) && token !== "--retry-approval")
940
+ if (seen.has(token) && token !== "--retry-approval" && token !== "--task")
941
941
  duplicate(token);
942
942
  seen.add(token);
943
943
  if (token === "--json") {
944
944
  args.json = true;
945
945
  continue;
946
946
  }
947
+ if (token === "--yes") {
948
+ args.yes = true;
949
+ continue;
950
+ }
947
951
  if (token === "--wait") {
948
952
  args.wait = true;
949
953
  continue;
950
954
  }
951
- if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery"].includes(token))
955
+ if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery", "--task"].includes(token))
952
956
  throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
953
957
  const value = readOptionValue(argv, i++, token);
954
958
  switch (token) {
959
+ case "--task":
960
+ if (!/^[A-Za-z0-9._:/-]{1,256}$/u.test(value) || args.landTaskIds?.includes(value))
961
+ invalidValue(token, value);
962
+ args.landTaskIds = [...(args.landTaskIds ?? []), value];
963
+ break;
955
964
  case "--retry-delivery":
956
965
  if (!/^task-[a-z0-9-]{1,128}$/u.test(value))
957
966
  invalidValue(token, value);
@@ -991,6 +1000,12 @@ function parseRunArgs(argv) {
991
1000
  }
992
1001
  if (args.retryApprovalIds !== undefined && action !== "resume")
993
1002
  throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-approval requires run resume.");
1003
+ if (args.landTaskIds !== undefined && action !== "land")
1004
+ throw new CliArgumentError("CLI_INVALID_OPTION", "--task requires run land.");
1005
+ if (args.yes !== (action === "land"))
1006
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", action === "land"
1007
+ ? "Run land requires --yes to authorize its verification and both reviewer sessions."
1008
+ : "--yes is accepted only by run land.");
994
1009
  if (args.retryDeliveryTaskId !== undefined && (action !== "resume" || args.retryApprovalIds !== undefined))
995
1010
  throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-delivery requires run resume without --retry-approval.");
996
1011
  if (action === "start") {
@@ -130,6 +130,7 @@ The URL carries an access token; do not share it.
130
130
  agent-ops run resume <run-id> [--retry-approval <auth-event-id>]... [--json]
131
131
  agent-ops run resume <run-id> --retry-delivery <task-id> [--json]
132
132
  agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
133
+ agent-ops run land <run-id> [--task <task-id>]... --yes [--json]
133
134
 
134
135
  New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
135
136
  Command/review timeouts, provider limits and stop controls remain effective.
@@ -144,6 +145,10 @@ Status includes phase, original cause, recorded sealed proof, cleanup state,
144
145
  resumability and remediation. Resume revalidates authority and sealed artifacts;
145
146
  receipt-bound recovery executes only missing cleanup steps. Resolve the saved
146
147
  blocker before retrying: unchanged cleanup inputs are refused.
148
+ land merges delivered children of a paused, blocked or budget-limited run ahead
149
+ of the rest: it verifies them, runs the two-round review and finishes with a
150
+ receipt, leaving the run resumable. --task picks a subset; each one's
151
+ dependencies must be picked too or already landed.
147
152
  `,
148
153
  init: `Usage: agent-ops init [options]
149
154
 
@@ -102,7 +102,7 @@ export async function runAdvanceCommand(options) {
102
102
  if (parent.status !== "active" || parent.task.parentTaskId !== undefined) {
103
103
  throw new AgentOpsError("ADVANCE_PARENT_INVALID", "Advance requires an active root task in the coordinator worktree.");
104
104
  }
105
- const children = await integrateSessionChildren(deps, record, parent.task.id);
105
+ const children = await integrateSessionChildren(deps, record, parent.task.id, undefined, options.children);
106
106
  const step = options.step ?? runAdvanceStep;
107
107
  for (let attempt = 0; attempt < 2; attempt += 1) {
108
108
  const target = await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
@@ -159,6 +159,7 @@ export async function runAdvanceCommand(options) {
159
159
  ...(noChangePaths === undefined ? {} : { noChangePaths }) });
160
160
  const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
161
161
  const finalProof = { target, head, sourceFingerprint, children,
162
+ ...(options.children === undefined ? {} : { keepChildren: true }),
162
163
  ...(noChangePaths === undefined ? {} : { noChange: { sourceCommit: head,
163
164
  contractDigest: await deps.tasks(record.path).treeContract(parent.task.id), reviewScope: scope,
164
165
  artifactRefs: Object.values((await deps.tasks(record.path).status({ taskId: parent.task.id })).evidence).flat() } }) };
@@ -127,13 +127,10 @@ export async function runInitCommand(options) {
127
127
  : { worktree: worktreeConfig })
128
128
  });
129
129
  const trust = await trustChange(options, plan);
130
- // An installation with no verifier looks finished and is not: every task
131
- // completion needs current PASS evidence from a required verifier, so the
132
- // loop can never close. Detection stays conservative on purpose — it will
133
- // not guess a test command — which makes saying so out loud the whole fix.
130
+ // Empty commands block legacy criteria, not typed acceptance backed by runners.
134
131
  const verificationWarnings = plan.config.verification.commands.length === 0
135
132
  ? [
136
- "No verification command is configured, so no task can be completed. " +
133
+ "No verification command is configured; legacy criteria need a required verifier. " +
137
134
  "Add verification.commands to .agent-ops/config.json." +
138
135
  (plan.verificationBlockers.length === 0
139
136
  ? ""
@@ -317,6 +317,7 @@ const SURFACE_REASONS = {
317
317
  SURFACE_UNCLASSIFIED: "surface-unclassified",
318
318
  SURFACE_QUERY_TOO_BROAD: "surface-query-too-broad",
319
319
  SURFACE_EXCLUSION_TOO_BROAD: "surface-exclusion-too-broad",
320
+ SURFACE_INCLUSION_TOO_BROAD: "surface-inclusion-too-broad",
320
321
  SCENARIO_MISSING: "scenario-missing",
321
322
  SURFACE_BUDGET_EXCEEDED: "surface-budget-exceeded",
322
323
  SURFACE_UNASSIGNED: "surface-unassigned",
@@ -9,7 +9,7 @@ function actionOf(args) {
9
9
  const value = dynamic(args).runAction ?? dynamic(args).action;
10
10
  if (value === undefined || value === "start")
11
11
  return "start";
12
- if (["status", "logs", "resume", "stop", "respond"].includes(String(value)))
12
+ if (["status", "logs", "resume", "stop", "respond", "land"].includes(String(value)))
13
13
  return value;
14
14
  throw new AgentOpsError("RUN_ACTION_INVALID", `Unsupported run action: ${String(value)}`);
15
15
  }
@@ -109,6 +109,14 @@ export async function runRunCommand(options) {
109
109
  }
110
110
  if (action === "resume")
111
111
  return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds, true, options.args.retryDeliveryTaskId)));
112
+ if (action === "land") {
113
+ if (options.service.land === undefined)
114
+ throw new AgentOpsError("RUN_LAND_UNSUPPORTED", "This CLI cannot land run deliveries.");
115
+ const landed = await options.service.land(runId, options.args.landTaskIds);
116
+ const message = `Landed ${landed.taskIds.join(", ")} from run ${runId}. Receipt: ${landed.receipt}.\n` +
117
+ `The run stays ${landed.state.status}; continue the rest with: agent-ops run resume ${runId}`;
118
+ return okEnvelope("RUN_LANDED", { ...data(action, { state: landed.state, message }), landedTaskIds: landed.taskIds, receipt: landed.receipt });
119
+ }
112
120
  if (action === "stop")
113
121
  return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
114
122
  const questionId = options.args.questionId;
@@ -9,7 +9,8 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
9
9
  import { sha256 } from "../../../runtime/src/fs/hash.js";
10
10
  import { resolveCheckouts, readWorktreeRecord, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
11
11
  import { createLaunchdDescriptor, LaunchdController, readBootIdentity, readGuiLoginIdentity } from "../../../runtime/src/run/macOS.js";
12
- import { FileRunRepository, RunService, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
12
+ import { FileRunRepository, RunService, landingMarkerLive, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
13
+ import { landRun } from "../../../runtime/src/run/land.js";
13
14
  import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
14
15
  import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
15
16
  import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
@@ -28,6 +29,7 @@ import { nativeProcessIdentity } from "../../../runtime/src/run/transport.js";
28
29
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
29
30
  import { RunControlService } from "../../../runtime/src/run/controls.js";
30
31
  import { recordRunPhase } from "../../../runtime/src/run/phase.js";
32
+ import { runAdvanceCommand } from "./commands/advance.js";
31
33
  /** Worktree construction happens before a native lease exists; it must never write global trust. */
32
34
  export async function runWorktreeDependencies(deps, state) {
33
35
  state = await ensureRunPolicy(new FileRunRepository(join(state.commonDir, "agent-ops/runs"), state.commonDir), state);
@@ -213,6 +215,8 @@ export async function productionRunContext(cwd, options = {}) {
213
215
  };
214
216
  const resume = async (runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId) => {
215
217
  const before = await service.status(runId);
218
+ if (landingMarkerLive(before.landing))
219
+ throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${runId} is landing delivered work; wait for run land to finish.`);
216
220
  const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(before, retryDeliveryTaskId);
217
221
  const runtimeHash = await runRuntimeHash();
218
222
  const policy = await readRunPolicy(before, before.policyBinding?.runtimeHash ?? runtimeHash, Date.now(), true);
@@ -315,6 +319,18 @@ export async function productionRunContext(cwd, options = {}) {
315
319
  }
316
320
  return { state, message: `Run ${runId} stopped; registered native process death was confirmed.` };
317
321
  };
322
+ const land = async (runId, taskIds) => {
323
+ if (checkouts.currentRoot !== checkouts.mainRoot)
324
+ throw new AgentOpsError("WORKTREE_NESTED", "Land a run from the main checkout.");
325
+ return await landRun({ repository, runId, ...(taskIds === undefined ? {} : { taskIds }), deps,
326
+ advance: async (input) => {
327
+ const result = await runAdvanceCommand({ ...input, deps });
328
+ const receipt = result.data?.receipt;
329
+ if (result.status !== "ok" || typeof receipt !== "string")
330
+ throw new AgentOpsError(result.code, "Landing proof did not finish with a receipt.");
331
+ return { receipt };
332
+ } });
333
+ };
318
334
  const respond = async (runId, questionId, answer) => {
319
335
  const state = await service.status(runId);
320
336
  if (!state.questions.some(q => q.questionId === questionId && q.answeredAt === null))
@@ -331,7 +347,7 @@ export async function productionRunContext(cwd, options = {}) {
331
347
  }
332
348
  };
333
349
  return { ...checkouts, targetBranch: target.stdout.trim(), ownerSessionId: randomUUID(), repository,
334
- service: { start, resume, stop, respond, status: service.status.bind(service), logs: service.logs.bind(service) },
350
+ service: { start, resume, stop, respond, land, status: service.status.bind(service), logs: service.logs.bind(service) },
335
351
  waitForCompletion: async (runId) => {
336
352
  for (;;) {
337
353
  const state = await service.status(runId);
@@ -380,17 +380,15 @@ async function checkRegistrationDrift(root, manifest, config) {
380
380
  }
381
381
  }
382
382
  /**
383
- * Whether anything can ever be verified here. Completion requires current PASS
384
- * evidence from a required verifier, so an empty list is not a light
385
- * configuration — it is a loop that cannot close, and an installation reaches
386
- * that state quietly whenever stack detection declines to guess.
383
+ * Legacy criteria need required verifiers; typed acceptance can use runners
384
+ * even when no ordinary verification command is configured.
387
385
  */
388
386
  function checkVerificationCommands(config) {
389
387
  if (config === undefined) {
390
388
  return check("verification-commands", "UNKNOWN", "Configuration could not be read, so verifiers are unknown.");
391
389
  }
392
390
  if (config.verification.commands.length === 0) {
393
- return check("verification-commands", "DEGRADED", "No verification command is configured, so no task can be completed.",
391
+ return check("verification-commands", "DEGRADED", "No verification command is configured; legacy criteria need a required verifier.",
394
392
  // Codeless on purpose: the fix is an edit to the configuration file, not
395
393
  // an agent-ops command, and a code here would force a non-zero exit on
396
394
  // every installation whose stack detection declined to guess.
@@ -347,7 +347,8 @@ function autoRunInstruction(descriptor, context) {
347
347
  "report the `agent-ops run status",
348
348
  "<id>` state and code, the last `agent-ops run logs <id>` events, and the",
349
349
  "exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
350
- "then stop: never resume automatically or take over the run's work.",
350
+ "plus `agent-ops run land <id> --yes` when it has delivered tasks,",
351
+ "then stop: never resume or land automatically or take over the run's work.",
351
352
  ""
352
353
  ];
353
354
  }
@@ -88,7 +88,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
88
88
  const verification = existing?.verification !== undefined &&
89
89
  existing.verification.commands.length > 0
90
90
  ? existing.verification
91
- : { commands: [...detectedCommands] };
91
+ : { ...existing?.verification, commands: [...detectedCommands] };
92
92
  // Pre-0.11 DEBUG budget settings are dropped on rewrite.
93
93
  const existingFeatures = existing === undefined ? undefined : (({ debug: _legacyDebug, ...rest }) => rest)(existing.features);
94
94
  const effectiveWorktree = worktree !== undefined
@@ -318,9 +318,12 @@ export async function finishWorktree(deps, options) {
318
318
  return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
319
319
  const listing = await git(deps, mainRoot, ["worktree", "list", "--porcelain"], "WORKTREE_LIST_FAILED", "Cannot list cleanup worktrees.");
320
320
  const records = await Promise.all(parseWorktreeListPorcelain(listing).map(child => readWorktreeRecord(child.path)));
321
+ const keepChildren = options.finalProof?.keepChildren === true;
321
322
  const sessionChildren = records
322
- .filter((child) => child !== null && child.path !== record.path && child.sessionId === record.sessionId &&
323
- (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
323
+ .filter((child) => child !== null && child.path !== record.path && (keepChildren
324
+ ? options.finalProof.children.some(({ name }) => name === child.name)
325
+ : child.sessionId === record.sessionId &&
326
+ (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId))));
324
327
  if (options.finalProof?.recovery === true && record.runId !== undefined) {
325
328
  const saved = await new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir).read(record.runId);
326
329
  if (["receipt-written", "cleaned"].includes(saved?.integration?.status ?? ""))
@@ -579,7 +582,7 @@ export async function finishWorktree(deps, options) {
579
582
  // this session began would leave it reading the merge as its own change.
580
583
  // Only this worktree leaves the session; its other roots stay covered.
581
584
  await mainGate.afterFinish(record.sessionId, record.path, after);
582
- if (options.finalProof !== undefined) {
585
+ if (options.finalProof !== undefined && !keepChildren) {
583
586
  for (const child of sessionChildren) {
584
587
  await mainGate.afterFinish(record.sessionId, child.path, after);
585
588
  }
@@ -593,7 +596,7 @@ export async function finishWorktree(deps, options) {
593
596
  await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
594
597
  if (receiptOptions !== undefined)
595
598
  await markRunIntegration(commonDir, record, "receipt-written", "trust");
596
- if (options.finalProof !== undefined) {
599
+ if (options.finalProof !== undefined && !keepChildren) {
597
600
  for (const child of sessionChildren) {
598
601
  await attempt(`child ${child.name}`, async () => {
599
602
  const pinnedHead = await assertPinnedChild(child);
@@ -156,11 +156,13 @@ async function previous(record) {
156
156
  return value;
157
157
  }
158
158
  /** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
159
- export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds) {
160
- const statuses = await listWorktrees(deps, coordinator.mainRoot);
161
- const children = statuses.map(({ record }) => record)
159
+ export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds,
160
+ /** Another session's children chosen by the caller (`run land`); replaces the session filter only. */
161
+ explicitChildren) {
162
+ const statuses = explicitChildren === undefined ? await listWorktrees(deps, coordinator.mainRoot) : [];
163
+ const children = (explicitChildren ?? statuses.map(({ record }) => record)
162
164
  .filter((record) => record.name !== coordinator.name && record.sessionId === coordinator.sessionId &&
163
- (record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId)))
165
+ (record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId))))
164
166
  .filter(record => eligibleRunWorkerIds === undefined || (record.workerId !== undefined && eligibleRunWorkerIds.includes(record.workerId)))
165
167
  .sort((a, b) => a.name.localeCompare(b.name));
166
168
  const integrated = await previous(coordinator);
@@ -108,6 +108,8 @@ function taskDataBlock(invocation) {
108
108
  function surfaceBlock(invocation, round) {
109
109
  if (invocation.packet.surface === undefined)
110
110
  return [];
111
+ const discovery = invocation.packet.surface.discovery;
112
+ const skipped = round === "adversarial" ? discovery?.skipped ?? [] : [];
111
113
  return [
112
114
  "",
113
115
  "Surface coverage (narrow exception to \"Do not survey\"): task data carries the surface audit, its revision " +
@@ -121,7 +123,10 @@ function surfaceBlock(invocation, round) {
121
123
  "and indirect consumer. Do not run other searches beyond callers a specific question needs."
122
124
  : "Round 2: check the five fixed scenarios (initialState, redo, timeBoundary, identitySwitch, concurrency) against " +
123
125
  "the code, and hunt unlisted surfaces with at most 3 new `git grep -F` queries per axis (dependents, duplicates, " +
124
- "otherPaths, sharedState); cite each query you ran in evidence."
126
+ "otherPaths, sharedState); cite each query you ran in evidence.",
127
+ ...(skipped.length === 0 ? [] : ["Skipped derived queries (over 10 files outside the diff, so discovery never gated them; " +
128
+ `fewest first): ${skipped.map(row => `\`${row.query}\` (${row.files})`).join(", ")}. Beyond the per-axis budget, run at most 3 ` +
129
+ "extra `git grep -F` queries in total over these to find copied siblings the change missed; cite each in evidence."])
125
130
  ];
126
131
  }
127
132
  /** Escape Loop: both rounds adjudicate the earliest-gate label of each declared escape. */
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { calculateConfigHash } from "../config/hash.js";
5
+ import { sha256 } from "../fs/hash.js";
6
+ import { AgentOpsError } from "../fs/paths.js";
7
+ import { listWorktrees } from "../parallel/manage.js";
8
+ import { addWorktree, readWorktreeRecord, removeCheckout, sessionWorktreeName } from "../parallel/service.js";
9
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
10
+ import { landingMarkerLive, MAX_EVENTS } from "./service.js";
11
+ const LIVE_WORKER = ["assigned", "starting", "running", "idle", "handing-off"];
12
+ const LANDABLE = ["paused", "blocked", "budget-limited"];
13
+ /** Inside the landing worktree; names the run that owns it so another run never reuses it. */
14
+ const OWNER_FILE = join(".agent-ops", "tasks", "run-landing.json");
15
+ export function assertLandable(state) {
16
+ if (!LANDABLE.includes(state.status) || state.rootTaskId === null ||
17
+ state.workers.some(worker => LIVE_WORKER.includes(worker.status)))
18
+ throw new AgentOpsError("RUN_LAND_STATE_INVALID", `Run ${state.runId} is ${state.status}; land needs a paused, blocked or budget-limited run with a root task and no live worker.`);
19
+ }
20
+ /** Unlanded delivered children, or the requested ones; every dependency must be selected or already landed. */
21
+ export function selectLanding(state, requested) {
22
+ const landed = new Set((state.landings ?? []).flatMap(landing => landing.taskIds));
23
+ const eligible = state.tasks.filter(task => task.taskId !== state.rootTaskId && task.status === "delivered" &&
24
+ task.sourceCommit !== null && !landed.has(task.taskId));
25
+ for (const id of requested ?? [])
26
+ if (!eligible.some(task => task.taskId === id))
27
+ throw new AgentOpsError("RUN_LAND_TASK_INVALID", `${id} is not an unlanded delivered task of run ${state.runId}.`);
28
+ const selected = requested === undefined ? eligible : eligible.filter(task => requested.includes(task.taskId));
29
+ if (selected.length === 0)
30
+ throw new AgentOpsError("RUN_LAND_NOTHING", `Run ${state.runId} has no unlanded delivered task.`);
31
+ const ids = new Set(selected.map(task => task.taskId));
32
+ for (const task of selected)
33
+ for (const dependency of task.dependencies)
34
+ if (!ids.has(dependency) && !landed.has(dependency))
35
+ throw new AgentOpsError("RUN_LAND_DEPENDENCY_OPEN", `${task.taskId} depends on ${dependency}, which is neither selected nor landed.`);
36
+ return selected;
37
+ }
38
+ /**
39
+ * One landing session per attempt: a non-hex first letter keeps it apart from
40
+ * UUID sessions, and a fresh path per attempt never reuses a removed checkout.
41
+ */
42
+ export function landingSessionId(runId, nonce = randomUUID()) {
43
+ return `l${sha256(`${runId}:${nonce}`).slice(0, 7)}${LANDING_SUFFIX}${runId}`;
44
+ }
45
+ const LANDING_SUFFIX = "-land-";
46
+ async function git(deps, cwd, args) {
47
+ const result = await deps.git(cwd, args);
48
+ if (result.exitCode !== 0)
49
+ throw new AgentOpsError("RUN_LAND_GIT_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
50
+ return result.stdout.trim();
51
+ }
52
+ /** The worker checkout whose committed head is the frozen delivery. */
53
+ async function deliveredCheckout(deps, state, task) {
54
+ for (const worker of state.workers.filter(worker => worker.taskId === task.taskId && worker.worktree !== null)) {
55
+ const record = await readWorktreeRecord(worker.worktree);
56
+ if (record !== null && await git(deps, record.path, ["rev-parse", "HEAD"]) === task.sourceCommit)
57
+ return record;
58
+ }
59
+ throw new AgentOpsError("RUN_LAND_DELIVERY_MISSING", `No preserved checkout of ${task.taskId} is at its delivered commit ${task.sourceCommit}.`);
60
+ }
61
+ async function ownerOf(path) {
62
+ try {
63
+ const owner = JSON.parse(await readPrivateFile(join(path, OWNER_FILE), path) ?? "null");
64
+ return typeof owner?.runId === "string" ? owner.runId : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** Worktrees kept by this run's failed attempts are removed first; one that claims the run without proof is refused. */
71
+ async function freshLandingWorktree(deps, state, sessionId) {
72
+ const claimed = (await listWorktrees(deps, state.root)).map(({ record }) => record)
73
+ .filter(record => record.sessionId.endsWith(LANDING_SUFFIX + state.runId));
74
+ for (const record of claimed)
75
+ if (await ownerOf(record.path) !== state.runId)
76
+ throw new AgentOpsError("RUN_LAND_WORKTREE_FOREIGN", `${record.path} claims run ${state.runId} without its owner record; it was left untouched.`);
77
+ for (const record of claimed)
78
+ await removeCheckout(deps, record, true);
79
+ const record = (await addWorktree(deps, { cwd: state.root, name: sessionWorktreeName(sessionId), sessionId,
80
+ from: state.targetBranch, targetBranch: state.targetBranch })).record;
81
+ await writePrivateFile(join(record.path, OWNER_FILE), JSON.stringify({ runId: state.runId }), record.path);
82
+ return record;
83
+ }
84
+ async function createLandingRoot(deps, state, record, selected) {
85
+ const config = await deps.loadConfig(record.path);
86
+ const required = config.verification.commands.filter(command => command.required).map(command => command.id);
87
+ if (required.length === 0)
88
+ throw new AgentOpsError("RUN_LAND_VERIFIER_REQUIRED", "Landing proof needs at least one required verification command.");
89
+ const coordinator = state.workers.find(worker => worker.workerId === state.coordinatorId)?.worktree ?? null;
90
+ const root = coordinator === null ? null : await deps.tasks(coordinator).status({ taskId: state.rootTaskId }).catch(() => null);
91
+ const ids = selected.map(task => task.taskId);
92
+ const base = await git(deps, record.path, ["rev-parse", "HEAD"]);
93
+ const criteria = [
94
+ { id: "landed-verifiers", description: "Every required project verifier passes on the integrated candidate of the landed tasks.", verifierIds: required },
95
+ { id: "landed-coherent", verifierIds: [],
96
+ description: "The landed subset is coherent on its own: it exposes no half-finished behavior and references no work of an undelivered run task.",
97
+ acceptance: { mode: "review-only", baselineCommit: base, bindings: [],
98
+ reviewOnlyReason: "partial-landing coherence: whether a subset stands alone without the undelivered tasks is a reviewer judgment, not a mechanical check" } }
99
+ ];
100
+ // Typed acceptance needs repository context; the target head is the landing baseline.
101
+ const created = await deps.tasks(record.path, base).create({
102
+ title: `Land ${ids.join(", ")} from ${state.runId}`,
103
+ goal: `Land delivered tasks ${ids.join(", ")} of run ${state.runId} ahead of the rest of its goal:\n${state.goal}`.slice(0, 64_000),
104
+ intent: "Merge only the frozen deliveries of the listed run tasks; the run, its root task and its checkouts stay as they are.",
105
+ criteria, createdSourceCommit: base, policyConfigHash: calculateConfigHash(config), sessionId: record.sessionId,
106
+ ...(root?.task.surface === undefined ? {} : { surface: root.task.surface })
107
+ });
108
+ return created.task.id;
109
+ }
110
+ /**
111
+ * `agent-ops run land`: prove and merge delivered children of a stopped run
112
+ * while the run stays resumable. Landed tasks remain delivered, so a later
113
+ * resume and finalize re-verify the whole goal on the new target.
114
+ */
115
+ export async function landRun(options) {
116
+ const now = options.now ?? (() => new Date().toISOString());
117
+ let selected = [];
118
+ const held = await options.repository.mutate(options.runId, current => {
119
+ if (landingMarkerLive(current.landing))
120
+ throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${current.runId} is already landing delivered work.`);
121
+ assertLandable(current);
122
+ selected = selectLanding(current, options.taskIds);
123
+ return { ...current, landing: { processId: process.pid, hostname: hostname(), startedAt: now() } };
124
+ });
125
+ const taskIds = selected.map(task => task.taskId);
126
+ try {
127
+ const children = [];
128
+ for (const task of selected)
129
+ children.push(await deliveredCheckout(options.deps, held, task));
130
+ const sessionId = landingSessionId(held.runId);
131
+ const record = await freshLandingWorktree(options.deps, held, sessionId);
132
+ const parentTaskId = await createLandingRoot(options.deps, held, record, selected);
133
+ const { receipt } = await options.advance({ cwd: held.root, sessionId, parentTaskId, children });
134
+ const target = await git(options.deps, held.root, ["rev-parse", `${held.targetBranch}^{commit}`]);
135
+ const state = await options.repository.mutate(options.runId, current => ({ ...current, landing: null,
136
+ landings: [...(current.landings ?? []), { taskIds, receipt, target, landedAt: now() }],
137
+ events: [...current.events, { id: randomUUID(), at: now(), type: "integration", code: "RUN_PARTIAL_LANDED",
138
+ workerId: null, taskId: null, detail: `Landed ${taskIds.join(", ")} at ${target}; receipt ${receipt}.` }].slice(-MAX_EVENTS) }));
139
+ return { state, taskIds, receipt };
140
+ }
141
+ catch (cause) {
142
+ await options.repository.mutate(options.runId, current => ({ ...current, landing: null })).catch(() => undefined);
143
+ throw cause;
144
+ }
145
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir } from "node:fs/promises";
3
+ import { hostname } from "node:os";
3
4
  import { basename, join } from "node:path";
4
5
  import { sha256 } from "../fs/hash.js";
5
6
  import { AgentOpsError } from "../fs/paths.js";
@@ -10,6 +11,28 @@ import { redactSecrets } from "../security/redact.js";
10
11
  export const RUN_PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
11
12
  /** Writer slots are deliberately bounded; the coordinator may occupy one. */
12
13
  export const MAX_ACTIVE_WORKERS = 2;
14
+ /**
15
+ * A marker from another host is treated as live: its process cannot be probed.
16
+ *
17
+ * ponytail: a reused pid on this host reads as live; clear the marker by hand if that ever bites.
18
+ */
19
+ export function landingMarkerLive(marker) {
20
+ if (marker == null)
21
+ return false;
22
+ if (marker.hostname !== hostname())
23
+ return true;
24
+ try {
25
+ process.kill(marker.processId, 0);
26
+ return true;
27
+ }
28
+ catch (cause) {
29
+ return cause.code === "EPERM";
30
+ }
31
+ }
32
+ function refuseWhileLanding(state) {
33
+ if (landingMarkerLive(state.landing))
34
+ throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${state.runId} is landing delivered work; wait for run land to finish.`);
35
+ }
13
36
  const RUN_ID = /^[a-z][a-z0-9-]{7,63}$/u;
14
37
  const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
15
38
  const SHA = /^[a-f0-9]{40,64}$/u;
@@ -127,6 +150,15 @@ export function assertRunState(value) {
127
150
  return invalid("Run phase or task progress is invalid.");
128
151
  if (state.policyBinding !== undefined && !validPolicyBinding(state.policyBinding))
129
152
  return invalid("Invalid run policy binding.");
153
+ if (state.landings !== undefined && (!boundedArray(state.landings, 512) || state.landings.some(l => !plain(l) ||
154
+ !boundedArray(l.taskIds, 512) || l.taskIds.length === 0 || l.taskIds.some(id => !ID.test(String(id))) ||
155
+ typeof l.receipt !== "string" || l.receipt.length === 0 || l.receipt.length > 4096 ||
156
+ !SHA.test(String(l.target)) || !Number.isFinite(Date.parse(String(l.landedAt))))))
157
+ return invalid("Invalid run landing record.");
158
+ if (state.landing != null && (!plain(state.landing) || !Number.isSafeInteger(state.landing.processId) ||
159
+ state.landing.processId < 1 || typeof state.landing.hostname !== "string" || state.landing.hostname.length > 256 ||
160
+ !Number.isFinite(Date.parse(String(state.landing.startedAt)))))
161
+ return invalid("Invalid run landing marker.");
130
162
  if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
131
163
  state.proofProcess.processId < 1 || typeof state.proofProcess.processIdentity !== "string" ||
132
164
  state.proofProcess.processIdentity.length === 0 || state.proofProcess.processIdentity.length > 256))
@@ -442,6 +474,7 @@ export class RunService {
442
474
  }
443
475
  async resume(runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId, expectedFailureId) {
444
476
  const state = await this.#repository.mutate(runId, async (current) => {
477
+ refuseWhileLanding(current);
445
478
  if (retryDeliveryTaskId !== undefined && retryApprovalIds.length > 0)
446
479
  throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Delivery and approval retries require separate explicit controls.");
447
480
  const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(current, retryDeliveryTaskId, Date.parse(this.#now()));
@@ -491,6 +524,7 @@ export class RunService {
491
524
  const cleanAnswer = bounded(answer.trim(), "answer", MAX_GOAL_LENGTH);
492
525
  const state = await this.#repository.mutate(runId, (current) => {
493
526
  const question = current.questions.find((item) => item.questionId === cleanQuestion);
527
+ refuseWhileLanding(current);
494
528
  if (current.status !== "awaiting-input" || current.disableRestart)
495
529
  throw new AgentOpsError("RUN_NOT_AWAITING_INPUT", "Respond cannot restart a stopped or terminal run.");
496
530
  if (question === undefined)
@@ -11,8 +11,8 @@ import { globMatcher, isCovers, missingScenarios, parseSurface, SURFACE_AXES } f
11
11
  /** Cheapest first: a budget overrun stops before the long tail of diff tokens. */
12
12
  export const DISCOVERY_SOURCES = ["agent", "hunk-header", "template-fragment", "diff-token"];
13
13
  export const SURFACE_LIMITS = {
14
- // ponytail: derived queries over 10 files are skipped as generic; incidental ones under that are dismissed by query.
15
- agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
14
+ // ponytail: derived queries over 10 files outside the diff are skipped as generic; incidental ones under that are dismissed by query.
15
+ agentQueryFiles: 200, derivedQueryFiles: 10, exclusionFiles: 10, inclusionFiles: 10, dismissals: 128, dismissMinFiles: 4, budgetMs: 120_000, queryTimeoutMs: 30_000,
16
16
  tokenLength: 6, fragmentLength: 4, derivedQueries: 400
17
17
  };
18
18
  /** argv only: queries are task text and must never meet a shell or a regex engine. */
@@ -142,14 +142,23 @@ export async function runSurfaceGate(input) {
142
142
  const tracked = await git(input.root, ["ls-files", "-z"], Math.min(SURFACE_LIMITS.queryTimeoutMs, Math.max(1, remaining())));
143
143
  if (tracked.timedOut || remaining() <= 0)
144
144
  return notRun("SURFACE_BUDGET_EXCEEDED", "Discovery budget exhausted listing files.", []);
145
+ const trackedFiles = zList(tracked.stdout);
145
146
  const exclusions = surface.excluded.map(row => ({ ...row, match: globMatcher(row.glob) }));
146
147
  for (const row of exclusions) {
147
- const files = zList(tracked.stdout).filter(row.match);
148
+ const files = trackedFiles.filter(row.match);
148
149
  const diffFiles = [...changed].filter(row.match);
149
150
  if (files.length > SURFACE_LIMITS.exclusionFiles || diffFiles.length > 0)
150
151
  return notRun("SURFACE_EXCLUSION_TOO_BROAD", diffFiles.length > 0 ? "Files in the diff cannot be excluded."
151
152
  : `One exclusion may hide at most ${SURFACE_LIMITS.exclusionFiles} files.`, [{ glob: row.glob, files: files.length, diffFiles }]);
152
153
  }
154
+ // An included or UNKNOWN glob classifies every hit it matches, so it is capped like an exclusion.
155
+ for (const glob of [...surface.included.map(row => row.path), ...surface.unknown.flatMap(row => row.path === undefined ? [] : [row.path])]) {
156
+ const match = globMatcher(glob);
157
+ const files = trackedFiles.filter(path => match(path) && !changed.has(path)).length;
158
+ if (files > SURFACE_LIMITS.inclusionFiles)
159
+ return notRun("SURFACE_INCLUSION_TOO_BROAD", `One included or UNKNOWN glob may cover at most ${SURFACE_LIMITS.inclusionFiles} ` +
160
+ "files outside the diff; list narrower paths, each with its own behavior.", [{ glob, files }]);
161
+ }
153
162
  const head = (await git(input.root, ["rev-parse", "HEAD"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
154
163
  const diff = (await git(input.root, ["diff", "-U0", "--no-color", "--no-ext-diff", input.base ?? "HEAD", "--"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8");
155
164
  const derived = extractDiffQueries(diff);
@@ -217,7 +226,7 @@ export async function runSurfaceGate(input) {
217
226
  return notRun("SURFACE_DISMISSAL_INVALID", "A recorded axis query cannot be dismissed; classify its hits.", [{ query }]);
218
227
  const hit = hits.find(row => row.query === query && row.source !== "agent");
219
228
  const outside = hit === undefined ? [] : hit.files.filter(path => !changed.has(path));
220
- if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles && hit.files.length <= SURFACE_LIMITS.derivedQueryFiles)
229
+ if (hit !== undefined && outside.length < SURFACE_LIMITS.dismissMinFiles)
221
230
  return notRun("SURFACE_DISMISSAL_INVALID", `A query reaching fewer than ${SURFACE_LIMITS.dismissMinFiles} files outside the diff ` +
222
231
  "is the likeliest sibling signal and cannot be dismissed; classify its files.", [{ query, files: outside }]);
223
232
  if (hit !== undefined)
@@ -227,8 +236,9 @@ export async function runSurfaceGate(input) {
227
236
  sources[hit.source].queries += 1;
228
237
  if (hit.source === "agent" && hit.files.length > SURFACE_LIMITS.agentQueryFiles)
229
238
  return notRun("SURFACE_QUERY_TOO_BROAD", `Query hits more than ${SURFACE_LIMITS.agentQueryFiles} files; record a more specific fixed string.`, [{ query: hit.query, files: hit.files.length }]);
230
- if (hit.source !== "agent" && hit.files.length > SURFACE_LIMITS.derivedQueryFiles) {
231
- skipped.push({ source: hit.source, query: hit.query, files: hit.files.length });
239
+ const outside = hit.files.filter(path => !changed.has(path)).length;
240
+ if (hit.source !== "agent" && outside > SURFACE_LIMITS.derivedQueryFiles) {
241
+ skipped.push({ source: hit.source, query: hit.query, files: outside });
232
242
  continue;
233
243
  }
234
244
  if (hit.source !== "agent" && dismissals.has(hit.query))
@@ -284,7 +294,8 @@ export async function runSurfaceGate(input) {
284
294
  return notRun("ESCAPE_RULE_NO_EXCLUDE", "A learned escape rule marks these places noExclude: include them or mark them UNKNOWN.", noExclude);
285
295
  for (const source of DISCOVERY_SOURCES)
286
296
  sources[source].files = metrics.get(source).hits.size;
287
- const discovery = { cached, sources, skipped: skipped.slice(0, 50), postBlockExclusions, dismissed };
297
+ const discovery = { cached, sources, skipped: skipped
298
+ .sort((a, b) => a.files - b.files || (a.query < b.query ? -1 : a.query > b.query ? 1 : 0)).slice(0, 50), postBlockExclusions, dismissed };
288
299
  if (input.metrics === true)
289
300
  await logMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), DISCOVERY_SOURCES.map(source => ({ source,
290
301
  hits: metrics.get(source).hits.size, included: metrics.get(source).included.size, excluded: metrics.get(source).excluded.size,
@@ -99,6 +99,9 @@ Every new task MUST record a surface audit of where else its change lands before
99
99
  `glob` and a reason saying why the surface is unaffected ("out of scope" is
100
100
  not a reason); `unknown` rows name a reader the repository cannot see.
101
101
  `contracts` declare `compatible` or `breaking`. A single surface is allowed.
102
+ An `included` or `unknown` path glob may cover at most 10 tracked files
103
+ outside the diff, like an exclusion; list narrower paths, each with its own
104
+ behavior.
102
105
  - `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]`
103
106
  list two hops whenever a value's semantics change (clear, redact, default,
104
107
  normalize, unit/format).
@@ -114,8 +117,9 @@ Every new task MUST record a surface audit of where else its change lands before
114
117
  template-literal fixed fragments (at least 4 characters, separator-bounded,
115
118
  such as `order:`, `user_`, `menu.`); compound identifiers (snake_case,
116
119
  camelCase or containing digits, so prose words are not queries) and string
117
- literals from added and removed lines (at least 6 characters; skipped as too
118
- generic above 10 file hits, changed files included). Every hit outside the diff and the surface lists stops review.
120
+ literals from added and removed lines (at least 6 characters). A derived
121
+ query is skipped as too generic above 10 hit files outside the diff; skipped
122
+ queries, fewest hits first, go to the second reviewer. Every hit outside the diff and the surface lists stops review.
119
123
  - A derived query (diff token, template fragment or hunk-header name) whose hits
120
124
  are incidental may be dismissed once in the surface's optional `dismissed`
121
125
  list (`{query, reason}`, reason at most 256 characters, "out of scope" is
@@ -132,7 +136,8 @@ Every new task MUST record a surface audit of where else its change lands before
132
136
  also refused at create), `SURFACE_UNCLASSIFIED`,
133
137
  `SURFACE_QUERY_TOO_BROAD` (one recorded query over 200 files),
134
138
  `SURFACE_EXCLUSION_TOO_BROAD` (one glob over 10 files, or any file in the
135
- diff), `SURFACE_DISMISSAL_TOO_BROAD` (over 128 dismissals),
139
+ diff), `SURFACE_INCLUSION_TOO_BROAD` (one included or UNKNOWN glob over 10
140
+ files outside the diff), `SURFACE_DISMISSAL_TOO_BROAD` (over 128 dismissals),
136
141
  `SURFACE_DISMISSAL_INVALID` (a recorded or rare query), `SCENARIO_MISSING`, `SURFACE_BUDGET_EXCEEDED` (120 s total, never
137
142
  silently skipped) or `SURFACE_UNASSIGNED` (children's covers differ from the
138
143
  parent's included list; checked by every review, including advance and batch).
@@ -174,7 +174,8 @@ removes only `run` and keeps `loop`.
174
174
  ends without completing (blocked, budget exhausted, stopped, failing review)
175
175
  is reported with its `run status` state and code, its last `run logs` events,
176
176
  and the exact `agent-ops run resume <id>` and `agent-ops run stop <id>`
177
- commands, and is never resumed automatically or taken over by the session.
177
+ commands, plus `agent-ops run land <id> --yes` when it has delivered tasks,
178
+ and is never resumed or landed automatically or taken over by the session.
178
179
  With worktree auto mode, the Claude `PreToolUse` worktree guard allows a
179
180
  write to exactly `.agent-ops/state/run-goal.md` in the main checkout without
180
181
  creating a worktree. Confirmed init/update pre-authorizes `agent-ops run`:
@@ -73,6 +73,19 @@
73
73
  Interrupted controlled proof MUST record bounded redacted exit/signal, result
74
74
  failure stage and subprocess diagnostics without treating invalid output or a
75
75
  nonzero successful envelope as proof.
76
+ - `run land <id> [--task <task-id>]... --yes` MAY merge delivered children of a
77
+ paused, blocked or budget-limited run with no live worker ahead of the rest.
78
+ Every selected task's dependencies MUST be selected or already landed
79
+ (`RUN_LAND_DEPENDENCY_OPEN`). Proof runs in a fresh landing worktree from the
80
+ target under a generated root task (required verifiers plus a review-only
81
+ coherence criterion, the run root's surface), then the usual verification,
82
+ two-round tree review and receipt-bound finish. The run's checkouts are pinned
83
+ and never removed; landed tasks stay delivered, the run stays resumable and
84
+ records `landings[]` and `RUN_PARTIAL_LANDED`, and its final proof re-verifies
85
+ the whole goal on the new target. While landing, a marker refuses resume,
86
+ respond and another land (`RUN_LANDING_IN_PROGRESS`); a marker of a process
87
+ that no longer exists does not block. A failure leaves the target unchanged
88
+ and keeps the landing worktree until the next land removes it.
76
89
  Initial recovery after a current manual resume MAY ignore an owned Codex limit
77
90
  snapshot received before activation whose native update second strictly predates
78
91
  that resume. Automatic restarts, unknown/equal/future timestamps, clock rollback,
@@ -108,7 +108,9 @@ successful refutation MUST make the run FAIL.
108
108
  session. A FAIL is already terminal and is never re-checked.
109
109
  - Note: For a surfaced task the second round also hunts unlisted surfaces and
110
110
  scenarios under `surface-coverage`: it checks the five fixed scenario
111
- dimensions and may run at most 3 new `git grep -F` queries per axis. Round 1
111
+ dimensions and may run at most 3 new `git grep -F` queries per axis, plus at
112
+ most 3 in total over the derived queries discovery skipped as too generic,
113
+ which the prompt names with their hit counts. Round 1
112
114
  replays the recorded queries and reads every listed `valueChanges` consumer;
113
115
  this is the only exception to "do not survey the repository".
114
116
 
@@ -62,7 +62,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
62
62
  - `included` 列含 `id`、`path` glob 與 `behavior`;`excluded` 列含 `glob` 與說明
63
63
  該 surface 為何不受影響的理由(「out of scope」不是理由);`unknown` 列記錄
64
64
  repository 看不到的讀取者。`contracts` 宣告 `compatible` 或 `breaking`。只有單一
65
- surface 也可以。
65
+ surface 也可以。`included` 或 `unknown` 的 path glob 與排除一樣,至多涵蓋 diff
66
+ 以外 10 個 tracked 檔案;請改列更窄的路徑,各自寫出 behavior。
66
67
  - 值的語意改變(clear、redact、default、normalize、unit/format)時,
67
68
  `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]` 列出兩層
68
69
  consumer。
@@ -75,8 +76,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
75
76
  不以 task 文字組 regex),由便宜的來源先跑:記錄的查詢;diff hunk header 的外層
76
77
  函式名;template literal 固定片段(至少 4 字元且以分隔符界定,例如 `order:`、
77
78
  `user_`、`menu.`);新增與刪除行的複合識別字(snake_case、camelCase 或含數字,
78
- 因此一般文字單字不會成為查詢)與字串常值(至少 6 字元;命中超過 10 個檔案〈含
79
- 變更檔案〉視為太通用而略過)。diff 與 surface 清單之外的任何命中都會擋下 review。
79
+ 因此一般文字單字不會成為查詢)與字串常值(至少 6 字元)。衍生查詢在 diff 以外命中
80
+ 超過 10 個檔案時視為太通用而略過;略過的查詢依命中數由少到多交給第二輪 reviewer。diff 與 surface 清單之外的任何命中都會擋下 review。
80
81
  - 衍生查詢(diff 字詞、template 片段或 hunk header 函式名)的命中若只是巧合,可在
81
82
  surface 選用的 `dismissed` 清單中以一筆 `{query, reason}` 排除(理由至多 256
82
83
  字元,「out of scope」不是理由),不必逐檔排除。記錄的軸查詢與 escape rule 命中
@@ -88,7 +89,8 @@ English source version: 2026-10-20. Revalidate: when the English specification c
88
89
  - Review 以 NOT_RUN 回報:`SURFACE_MISSING`(沒有或無法解析的 surface;
89
90
  task 建立時也會拒絕)、`SURFACE_UNCLASSIFIED`、`SURFACE_QUERY_TOO_BROAD`(單一
90
91
  記錄查詢超過 200 個檔案)、`SURFACE_EXCLUSION_TOO_BROAD`(單一 glob 超過 10 個
91
- 檔案,或包含 diff 內的檔案)、`SURFACE_DISMISSAL_TOO_BROAD`(超過 128 筆排除)、
92
+ 檔案,或包含 diff 內的檔案)、`SURFACE_INCLUSION_TOO_BROAD`(單一 included 或
93
+ UNKNOWN glob 涵蓋 diff 以外超過 10 個檔案)、`SURFACE_DISMISSAL_TOO_BROAD`(超過 128 筆排除)、
92
94
  `SURFACE_DISMISSAL_INVALID`(記錄的查詢或稀有查詢)、`SCENARIO_MISSING`、`SURFACE_BUDGET_EXCEEDED`
93
95
  (總計 120 秒,絕不默默略過)或 `SURFACE_UNASSIGNED`(子 task covers 與父 task
94
96
  included 不一致;每次 review 都檢查,包含 advance 與 batch)。
@@ -150,8 +150,8 @@ byte-identical。Update MUST 保留此 profile,除非提供 `--auto-run on|off
150
150
  一行說明該 code,並退回 subtask flow;`RUN_REPO_UNTRUSTED` 則停止並詢問使用者。
151
151
  未完成即結束的 run(blocked、budget exhausted、stopped、review 失敗)須回報其
152
152
  `run status` state 與 code、最後的 `run logs` event,以及確切的
153
- `agent-ops run resume <id>` 與 `agent-ops run stop <id>` command,且永不自動
154
- resume 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
153
+ `agent-ops run resume <id>` 與 `agent-ops run stop <id>` command;有已交付 task
154
+ 時另附 `agent-ops run land <id> --yes`,且永不自動 resume、land 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
155
155
  worktree guard 允許在 main checkout 寫入恰為 `.agent-ops/state/run-goal.md`
156
156
  的路徑,且不建立 worktree。確認後的 init/update 會預先授權 `agent-ops run`:
157
157
  Claude Code 為 `Bash(agent-ops run *)`,Codex 為
@@ -22,6 +22,7 @@
22
22
  - 執行中的 run 僅在原始 repository trust、config、可執行 runtime 與操作範圍完全相同時,自動續期過期的有限授權。續期以 digest 綁定前一版本並保留既有評估,拒絕無效/倒退時鐘;並行 stop、denial 或 scope 變更使續期失敗。權限已變更或撤銷時須沿既有 trust/resume 流程取得真正授權;續期不授予永久 trust,也不消除 denial。明確 resume 先核對不可變 renewal lineage,再同步 policy。回答問題也沿用相同恢復檢查;兩者皆不得復活已被明確拒絕的工作。
23
23
  - `run resume <id> --retry-approval <auth-event-id>` MAY 明確重試以額度不足 wrapper 失敗的 Codex 自動審批。每筆指定失敗 MUST 保有原始 private artifact、digest、worker identity 與 policy lineage;真正或未指定的拒絕仍阻擋。多筆失敗可重複旗標。成功確認會留存並在後續 resume 重新驗證;此操作不授予命令權限,原生審批必須重跑,既有 writer 死亡、checkout、trust 與預算檢查仍適用。
24
24
  - `run resume <id> --retry-delivery <task-id>` MAY 在修復 harness 原因後,明確重新排程一個被 `RUN_FINAL_PROOF_INTERRUPTED` 阻擋的 child。MUST 核對同一筆中斷 handoff、所有 writer 與 proof 已確認停止、所屬 checkout 乾淨及目前 trust/policy;保留 run、task、goal、baselines、commits、耗用與失敗歷史,記錄已確認的 attempt,並要求新 native generation 及正常交付驗證。MUST NOT 直接標記交付、解除其他阻擋或確認命令拒絕;approval retry 是獨立操作。一般 resume 保留 no-progress 阻擋。受控 proof 中斷 MUST 記錄有界且已遮蔽的 exit/signal、失敗階段與子程序診斷;無效輸出或非零退出的成功 envelope 不得視為證據。
25
+ - `run land <id> [--task <task-id>]... --yes` MAY 在 run 為 paused、blocked 或 budget-limited 且無存活 worker 時,先行合併已交付的 child。每個選取 task 的依賴 MUST 已選取或已落地(`RUN_LAND_DEPENDENCY_OPEN`)。證明在由 target 建立的全新 landing worktree 中進行,使用自動產生的 root task(required verifier 加上 review-only 一致性條件,surface 沿用 run root),再走一般驗證、兩輪 tree review 與綁定 receipt 的 finish。run 的 checkout 只被釘住、永不移除;已落地 task 維持 delivered,run 仍可 resume 並記錄 `landings[]` 與 `RUN_PARTIAL_LANDED`,其最終證明會在新 target 上重新驗證整個 goal。落地期間的標記會拒絕 resume、respond 與另一次 land(`RUN_LANDING_IN_PROGRESS`);已不存在的程序留下的標記不會阻擋。失敗時 target 不變,landing worktree 保留到下次 land 移除。
25
26
  - 當次人工 resume 的初始恢復 MAY 忽略啟用前收到、且 native 更新秒數嚴格早於該次 resume 的本執行緒 Codex 額度快照。自動重啟、未知/同秒/未來時間、時鐘倒退,以及啟用開始後收到的所有額度事件仍有效。Native 啟用 MUST 確認 goal 為 active,才能啟動或轉向 turn。人工 resume 可解除衍生的 `RUN_POLICY_DISABLED` task 阻擋,但保留 ID、耗用、歷史及其他產品失敗。
26
27
  - 收斂判斷比較實際 per-check 狀態及未解除的 finding pins;source fingerprint、時間或診斷文字本身不能算進展。
27
28
 
@@ -98,7 +98,7 @@ PASS MUST 交給第二個規劃的 target 嘗試反駁,且反駁成立時 MUST
98
98
  - Positive: `codex 判 PASS,claude 找到 blocking 缺陷,整體判定 FAIL。`
99
99
  - Negative: `為了讓複查看起來有效而編造反駁。`
100
100
  - Note: 只有一個設定 target 時,會在全新 session 再呼叫同一 target。FAIL 已是終局,不再複查。
101
- - Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
101
+ - Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢,另外可針對 discovery 因太通用而略過的衍生查詢(prompt 會列出它們與命中數)總計最多再跑 3 個。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
102
102
 
103
103
  ## REVIEW-ORIGIN-001
104
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",