@kylecheng3146/agent-ops 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,17 @@ export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
28
28
  * thinking hard is never cut off.
29
29
  */
30
30
  export const DEFAULT_STALL_IDLE_MS = 90_000;
31
+ /**
32
+ * agy writes nothing to stdout, stderr or its log while one generation
33
+ * streams, so a long think is indistinguishable from a wedge. Its own logs
34
+ * show normal generations silent for up to ~177s; 90s cut real reviews off.
35
+ */
36
+ export const AGY_STALL_IDLE_MS = 240_000;
37
+ /**
38
+ * A call the service dropped before answering. It produced no verdict, so
39
+ * one fresh retry is not review shopping.
40
+ */
41
+ const TRANSIENT_NETWORK = /\b(?:network issue|503|service is currently unavailable|ECONNRESET|ETIMEDOUT|socket hang up)\b/iu;
31
42
  /** Two rounds at the per-target default: the previous worst case, now bounded. */
32
43
  export const DEFAULT_REVIEW_CHAIN_TIMEOUT_MS = 2 * DEFAULT_REVIEW_TIMEOUT_MS;
33
44
  /**
@@ -425,7 +436,8 @@ async function attemptTarget(request, options) {
425
436
  }
426
437
  throwIfInterrupted(target, options);
427
438
  options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
428
- const stallIdleMs = options.stallIdleMs ?? DEFAULT_STALL_IDLE_MS;
439
+ const stallIdleMs = options.stallIdleMs ??
440
+ (target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
429
441
  const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
430
442
  let spawned;
431
443
  try {
@@ -481,8 +493,11 @@ async function attemptTarget(request, options) {
481
493
  }
482
494
  if (spawned.failureClass === "nonzero-exit") {
483
495
  const output = `${spawned.stderr}\n${spawned.stdout}`;
484
- return skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
485
- `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`);
496
+ return {
497
+ ...skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
498
+ `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`),
499
+ ...(TRANSIENT_NETWORK.test(output) ? { transient: true } : {})
500
+ };
486
501
  }
487
502
  if (spawned.failureClass === "signal-exit") {
488
503
  return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
@@ -522,6 +537,18 @@ async function attemptTarget(request, options) {
522
537
  await rm(attemptDirectory, { recursive: true, force: true });
523
538
  }
524
539
  }
540
+ /**
541
+ * `attemptTarget`, retried once in a fresh session when the service dropped
542
+ * the call. Only once: a service that is down stays down.
543
+ */
544
+ async function attemptTargetWithRetry(request, options) {
545
+ const first = await attemptTarget(request, options);
546
+ if (first.kind !== "skip" || first.transient !== true || budgetSpent(options)) {
547
+ return first;
548
+ }
549
+ options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
550
+ return attemptTarget(request, options);
551
+ }
525
552
  /** Builds the `execute` callback with one necessary and one adversarial review. */
526
553
  export function createReviewExecutor(options) {
527
554
  const report = options.onProgress ?? (() => { });
@@ -630,7 +657,7 @@ export function createReviewExecutor(options) {
630
657
  report("chain: review deadline reached before the first round");
631
658
  return { status: "NOT_RUN", reason: "timeout", attempts: [] };
632
659
  }
633
- const primaryOutcome = await attemptTarget({
660
+ const primaryOutcome = await attemptTargetWithRetry({
634
661
  ...shared,
635
662
  target: primaryTarget,
636
663
  label: `${primaryTarget}-primary`,
@@ -739,7 +766,7 @@ export function createReviewExecutor(options) {
739
766
  };
740
767
  }
741
768
  // The second round draws on what is left, never a fresh full budget.
742
- const adversarialOutcome = await attemptTarget({
769
+ const adversarialOutcome = await attemptTargetWithRetry({
743
770
  ...shared,
744
771
  target: adversarialTarget,
745
772
  label: `${adversarialTarget}-adversarial`,
@@ -38,7 +38,7 @@ export async function checkTaskCompletionEvidence(stored, options) {
38
38
  }
39
39
  }
40
40
  }
41
- const attestation = await findReviewAttestation(root, sourceFingerprint);
41
+ const attestation = await findReviewAttestation(root, sourceFingerprint, stored.task.id);
42
42
  if (attestation === null || attestation.taskId !== stored.task.id) {
43
43
  return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
44
44
  }
@@ -130,9 +130,17 @@ export class TaskService {
130
130
  state.tasks.push(record);
131
131
  if (input.sessionId !== undefined) {
132
132
  const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
133
+ // A subtask keeps the session on the top of its tree: the completion
134
+ // gate checks the attached task and everything under it, so attaching
135
+ // the subtask would let a Stop through with its parent unfinished.
136
+ let rootId = record.task.id;
137
+ for (let parentId = record.task.parentTaskId; parentId !== undefined;) {
138
+ rootId = parentId;
139
+ parentId = state.tasks.find(({ task }) => task.id === parentId)?.task.parentTaskId;
140
+ }
133
141
  const attachment = {
134
142
  sessionId: input.sessionId,
135
- taskId: record.task.id,
143
+ taskId: rootId,
136
144
  attachedAt: now
137
145
  };
138
146
  if (currentIndex === -1) {
@@ -304,7 +312,12 @@ export class TaskService {
304
312
  * task accepts evidence: a completed record must stay exactly as it was
305
313
  * verified.
306
314
  */
307
- async recordEvidence(taskId, evidenceInput) {
315
+ async recordEvidence(taskId, evidenceInput,
316
+ /**
317
+ * A PASS review's base: set records it, null clears it (the review covered
318
+ * uncommitted work), undefined leaves the record's value alone.
319
+ */
320
+ reviewBase) {
308
321
  const now = assertTimestamp(this.#now());
309
322
  return await this.#store.mutate((state) => {
310
323
  const current = findTask(state, taskId);
@@ -328,10 +341,13 @@ export class TaskService {
328
341
  ...new Set([...(evidence[criterionId] ?? []), ...references])
329
342
  ];
330
343
  }
344
+ const { reviewBase: previousBase, ...rest } = current;
345
+ const base = reviewBase === undefined ? previousBase : reviewBase ?? undefined;
331
346
  const updated = {
332
- ...current,
347
+ ...rest,
333
348
  evidence,
334
- updatedAt: now
349
+ updatedAt: now,
350
+ ...(base === undefined ? {} : { reviewBase: base })
335
351
  };
336
352
  replaceTask(state, updated);
337
353
  return cloneRecord(updated);
@@ -66,7 +66,7 @@ function parseTaskRecord(value) {
66
66
  ];
67
67
  // Every record written before an optional field existed must still parse, so
68
68
  // the accepted shapes are every combination of them.
69
- const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
69
+ const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
70
70
  const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
71
71
  ...baseKeys,
72
72
  ...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
@@ -146,6 +146,10 @@ function parseTaskRecord(value) {
146
146
  (typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
147
147
  return invalidState("Task state contains an invalid completion base.");
148
148
  }
149
+ if (value.reviewBase !== undefined &&
150
+ (typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
151
+ return invalidState("Task state contains an invalid review base.");
152
+ }
149
153
  return {
150
154
  task: task.value,
151
155
  status,
@@ -156,7 +160,8 @@ function parseTaskRecord(value) {
156
160
  archivedAt: value.archivedAt,
157
161
  failureFingerprint,
158
162
  policyConfigHash,
159
- completionBase
163
+ completionBase,
164
+ ...(value.reviewBase === undefined ? {} : { reviewBase: value.reviewBase })
160
165
  };
161
166
  }
162
167
  function parseSession(value) {
@@ -152,6 +152,17 @@ evidence. Give each editing conversation its own worktree instead:
152
152
  On Claude Code a direct Edit or Write of the main checkout outside
153
153
  `.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
154
154
  matches the file tools). Absent or `"off"` keeps today's single checkout.
155
+ - `agent-ops task create` run from the main checkout with a known session
156
+ creates that session's worktree first (or reuses it) and records the task
157
+ there, so the work starts in the worktree. Its output names the path to
158
+ enter and the `--base` to verify against.
159
+ - As a fallback on Claude Code, the first denied edit creates that worktree:
160
+ `.worktrees/session-<first eight characters of the session id>`, setup
161
+ included, bound to the session. The denial names the path to enter with
162
+ EnterWorktree; a later denial for the same session reuses it. If creation
163
+ fails, the denial says why and falls back to the manual `worktree add`.
164
+ Run `agent-ops update` once so the PreToolUse hook gets the 600-second
165
+ timeout setup needs.
155
166
  - `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` from the main
156
167
  checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
157
168
  never `.gitignore`. It copies the ignored files agent-ops installed and any
@@ -166,10 +177,20 @@ evidence. Give each editing conversation its own worktree instead:
166
177
  - The session's completion gate follows it: Claude Code enters the worktree
167
178
  with EnterWorktree, and a host that cannot move a session (agy) is judged by
168
179
  the worktree's gate through a redirect the main checkout records.
180
+ - In a worktree, verify and review each task but do not run `task complete`
181
+ (Claude Code refuses it inside an isolated worktree session). `agent-ops
182
+ worktree finish <name>` completes every non-archived task in the worktree,
183
+ subtasks before their parents, each against the base its PASS review
184
+ recorded (the worktree's base when none was recorded), before any rebase.
185
+ If one cannot complete, nothing merges and the error names the task.
186
+ Creating a subtask keeps the session attached to the top of its tree, and
187
+ each task keeps its own review record, so a parent and a subtask reviewed
188
+ on the same source do not overwrite each other.
169
189
  - `agent-ops worktree finish <name>` merges by fast-forward only, one finish
170
190
  at a time. If the target moved, it rebases; a clean rebase whose own patch
171
191
  is unchanged is re-verified before merging, and a conflict is reported with
172
- the `refs/notes/agent-ops` intent of the work that landed first.
192
+ the `refs/notes/agent-ops` intent of the work that landed first. The note
193
+ records every task of the worktree, subtasks indented under their parent.
173
194
  - `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
174
195
  what is left; `remove --force` discards work and asks the user first.
175
196
  `doctor` reports worktrees idle for more than seven days.
@@ -136,6 +136,14 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
136
136
  Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
137
137
  `agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
138
138
  目前的單一 checkout。
139
+ - 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
140
+ 該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
141
+ 要進入的路徑與 verify 用的 `--base`。
142
+ - 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
143
+ `.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
144
+ 拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
145
+ 建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
146
+ `agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
139
147
  - `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
140
148
  `agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
141
149
  `/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
@@ -148,10 +156,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
148
156
  - Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
149
157
  worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
150
158
  由 worktree 的 gate 判定。
159
+ - 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
160
+ (Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
161
+ <name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask
162
+ 後 parent,各自使用其 PASS review 記錄的 base(沒有記錄時使用 worktree 的 base)。
163
+ 任一 task 無法完成時不會合併,錯誤訊息會指出是哪個 task。建立 subtask 時 session
164
+ 維持附著在 task 樹最上層,且每個 task 各自保留 review 紀錄,所以在同一份原始碼上
165
+ review 的 parent 與 subtask 不會互相覆蓋。
151
166
  - `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
152
167
  finish。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
153
168
  重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
154
- 回報。
169
+ 回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下。
155
170
  - `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
156
171
  worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
157
172
  閒置超過七天的 worktree。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",