@kylecheng3146/agent-ops 0.8.0 → 0.8.3

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 (51) hide show
  1. package/dist/packages/cli/src/args.js +56 -1
  2. package/dist/packages/cli/src/bin.js +29 -2
  3. package/dist/packages/cli/src/cli.js +9 -3
  4. package/dist/packages/cli/src/codex-loop-process.js +6 -0
  5. package/dist/packages/cli/src/commands/hook.js +4 -1
  6. package/dist/packages/cli/src/commands/review.js +9 -3
  7. package/dist/packages/cli/src/commands/task.js +44 -2
  8. package/dist/packages/cli/src/debug-intake.js +245 -0
  9. package/dist/packages/cli/src/hook-process.js +110 -37
  10. package/dist/runtime/src/adapters/agy/config.js +3 -2
  11. package/dist/runtime/src/adapters/claude/config.js +4 -3
  12. package/dist/runtime/src/adapters/codex/config.js +7 -3
  13. package/dist/runtime/src/adapters/codex/events.js +2 -1
  14. package/dist/runtime/src/adapters/codex/input.js +20 -2
  15. package/dist/runtime/src/adapters/codex/output.js +4 -1
  16. package/dist/runtime/src/config/merge.js +1 -1
  17. package/dist/runtime/src/hooks/completion-gate.js +15 -0
  18. package/dist/runtime/src/install/harness.js +2 -0
  19. package/dist/runtime/src/install/plan.js +3 -3
  20. package/dist/runtime/src/install/profiles.js +2 -0
  21. package/dist/runtime/src/office/art.js +2 -1
  22. package/dist/runtime/src/office/layout.js +6 -3
  23. package/dist/runtime/src/office/page.js +133 -9
  24. package/dist/runtime/src/office/scene.js +13 -4
  25. package/dist/runtime/src/office/snapshot.js +12 -0
  26. package/dist/runtime/src/parallel/manage.js +19 -2
  27. package/dist/runtime/src/review/attestation.js +4 -1
  28. package/dist/runtime/src/review/execute.js +1 -1
  29. package/dist/runtime/src/review/slots.js +59 -44
  30. package/dist/runtime/src/run/hosts/codex.js +1 -1
  31. package/dist/runtime/src/run/transport.js +3 -0
  32. package/dist/runtime/src/schema/validate.js +8 -2
  33. package/dist/runtime/src/security/permissions.js +3 -1
  34. package/dist/runtime/src/task/completion.js +20 -1
  35. package/dist/runtime/src/task/contract.js +1 -1
  36. package/dist/runtime/src/task/debug-intake.js +12 -0
  37. package/dist/runtime/src/task/debug-state.js +130 -0
  38. package/dist/runtime/src/task/debug-user-event.js +93 -0
  39. package/dist/runtime/src/task/debug.js +586 -0
  40. package/dist/runtime/src/task/service.js +46 -0
  41. package/dist/runtime/src/task/store.js +8 -1
  42. package/dist/runtime/src/verify/acceptance-replay.js +9 -4
  43. package/dist/runtime/src/verify/service.js +21 -2
  44. package/docs/en/guides/task-debug.md +111 -0
  45. package/docs/en/spec/harness-adapters.md +15 -2
  46. package/docs/zh-TW/guides/task-debug.md +66 -0
  47. package/docs/zh-TW/spec/harness-adapters.md +11 -2
  48. package/package.json +1 -1
  49. package/schemas/config.schema.json +30 -2
  50. package/schemas/manifest.schema.json +1 -0
  51. package/schemas/task.schema.json +62 -2
@@ -159,12 +159,12 @@ var languageButton = document.getElementById("language"), live = document.getEle
159
159
  var snapshot = null, model = null, frame = 0, offline = false, scale = 1, scaleY = 1;
160
160
  var mode = "overview", selectedKey = null, showRecent = false, detailPage = 0, dialogClose = null, detailPreviousFocus = null, detailTarget = null;
161
161
  var detailSignature = "";
162
- var people = {}, staffMemory = {}, viewer = spawnViewer(), doorFrames = {}, images = {}, bases = {}, roomCanvases = {}, overviewSignature = "", hudSignature = "", labelNodes = {}, lastFigures = [], stageCanvas = null;
162
+ var people = {}, staffMemory = {}, glances = {}, viewer = spawnViewer(), doorFrames = {}, images = {}, bases = {}, roomCanvases = {}, overviewSignature = "", hudSignature = "", labelNodes = {}, lastFigures = [], stageCanvas = null;
163
163
  var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
164
164
  function spawnViewer(){ return {x: Math.round(LAYOUT.spawn.x / 2) * 2, y: Math.round(LAYOUT.spawn.y / 2) * 2, path: [], dir: "up", step: 0}; }
165
165
  var STRINGS = {
166
- en: {copyFailed:"Copy manually", copyHint:"Clipboard unavailable; select the command and copy it manually.", checks:"Acceptance checks", unfinished:"Not complete", sessionEnded:"Session ended", attention:"Needs attention", verifyFailed:"Verification failed", reviewFailed:"Review failed", blockedUnknown:"Blocked; reason not provided", openQuestion:"View questions and commands", detailsAction:"Details and commands", stale:"Reconnecting; showing last known state", viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", noTask:"No task", noTaskHint:"Nothing to verify or review", panel_task:"Whiteboard", panel_diff:"Screen", panel_verify:"QA board", panel_review:"Review desk", panel_integration:"Sorting table", noChanges:"No changed files yet.", filesCount:"files", notReviewed:"Not reviewed yet.", firstRound:"Round 1", recheck:"Re-check", blocking:"blocking", refutedYes:"The re-check overturned round 1.", refutedNo:"The re-check upheld round 1.", worktree:"Worktree", branch:"Branch", base:"Base", ahead:"Commits ahead", commitStep:"Commit", finishStep:"worktree finish (merge)", hideList:"Hide list »", showList:"« Work list", moveHint:"Arrow keys or WASD walk; Enter talks to the nearest person; Esc returns.", critPass:"PASS", critFail:"FAIL", critUnknown:"Undetermined", critPending:"Not verified yet", noCriteria:"This room has no acceptance criteria.", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", integrator:"Integrator", staff_verify:"Running verification", staff_review:"Reviewing", staff_wait:"Waiting for the other round", staff_finish:"Merging the worktree", staff_rest:"Resting in the lobby", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", lobby:"Lobby", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
167
- zh: {copyFailed:"手動複製", copyHint:"無法存取剪貼簿;請選取指令後手動複製。", checks:"驗收條件", unfinished:"尚未完成", sessionEnded:"工作階段已結束", attention:"需要介入", verifyFailed:"驗證未通過", reviewFailed:"審查未通過", blockedUnknown:"受阻;尚無原因資料", openQuestion:"查看問題與指令", detailsAction:"詳情與指令", stale:"重新連線中;顯示最後已知狀態", viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", panel_task:"白板", panel_diff:"隔間螢幕", panel_verify:"QA 狀態看板", panel_review:"主管桌", panel_integration:"分信桌", noChanges:"目前沒有變更的檔案。", filesCount:"個檔案", notReviewed:"尚未審查。", firstRound:"第 1 輪", recheck:"對抗複查", blocking:"阻擋", refutedYes:"對抗複查推翻了第 1 輪。", refutedNo:"對抗複查維持第 1 輪的結論。", worktree:"Worktree", branch:"分支", base:"基準", ahead:"領先 commit", commitStep:"commit", finishStep:"worktree finish(合併)", hideList:"收合 »", showList:"« 工作清單", moveHint:"方向鍵或 WASD 走動;Enter 和最近的人對話;Esc 返回。", critPass:"通過", critFail:"未通過", critUnknown:"無法判定", critPending:"還沒驗證", noCriteria:"這間房沒有驗收條件。", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", integrator:"整合", staff_verify:"執行驗證中", staff_review:"審查中", staff_wait:"等待另一輪審查", staff_finish:"合併 worktree 中", staff_rest:"在大廳休息", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", lobby:"大廳", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
166
+ en: {copyFailed:"Copy manually", copyHint:"Clipboard unavailable; select the command and copy it manually.", checks:"Acceptance checks", unfinished:"Not complete", sessionEnded:"Session ended", attention:"Needs attention", verifyFailed:"Verification failed", reviewFailed:"Review failed", blockedUnknown:"Blocked; reason not provided", openQuestion:"View questions and commands", detailsAction:"Details and commands", stale:"Reconnecting; showing last known state", viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", noTask:"No task", noTaskHint:"Nothing to verify or review", panel_task:"Whiteboard", panel_diff:"Screen", panel_verify:"QA board", panel_review:"Review desk", panel_integration:"Sorting table", noChanges:"No changed files yet.", filesCount:"files", notReviewed:"Not reviewed yet.", firstRound:"Round 1", recheck:"Re-check", blocking:"blocking", refutedYes:"The re-check overturned round 1.", refutedNo:"The re-check upheld round 1.", worktree:"Worktree", branch:"Branch", base:"Base", ahead:"Commits ahead", commitStep:"Commit", finishStep:"worktree finish (merge)", hideList:"Hide list »", showList:"« Work list", moveHint:"Arrow keys or WASD walk; Enter talks to the nearest person; Esc returns.", critPass:"PASS", critFail:"FAIL", critUnknown:"Undetermined", critPending:"Not verified yet", noCriteria:"This room has no acceptance criteria.", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", glance_pass:"Verify {n}/{t} passed", glance_fail:"Verify failed: {id}", glance_failed:"Verify failed", glance_pending:"{n} criteria still to verify", glance_all:"Criteria {n}/{t} passed", glance_review_pass:"Review passed ✓", glance_review_fail:"Review failed ✗", glance_wait_verify:"Still waiting for QA…", glance_wait_review:"Waiting for the review…", glance_wait_finish:"Waiting for the merge…", integrator:"Integrator", staff_verify:"Running verification", staff_review:"Reviewing", staff_wait:"Waiting for the other round", staff_finish:"Merging the worktree", staff_rest:"Resting in the lobby", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", lobby:"Lobby", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
167
+ zh: {copyFailed:"手動複製", copyHint:"無法存取剪貼簿;請選取指令後手動複製。", checks:"驗收條件", unfinished:"尚未完成", sessionEnded:"工作階段已結束", attention:"需要介入", verifyFailed:"驗證未通過", reviewFailed:"審查未通過", blockedUnknown:"受阻;尚無原因資料", openQuestion:"查看問題與指令", detailsAction:"詳情與指令", stale:"重新連線中;顯示最後已知狀態", viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", panel_task:"白板", panel_diff:"隔間螢幕", panel_verify:"QA 狀態看板", panel_review:"主管桌", panel_integration:"分信桌", noChanges:"目前沒有變更的檔案。", filesCount:"個檔案", notReviewed:"尚未審查。", firstRound:"第 1 輪", recheck:"對抗複查", blocking:"阻擋", refutedYes:"對抗複查推翻了第 1 輪。", refutedNo:"對抗複查維持第 1 輪的結論。", worktree:"Worktree", branch:"分支", base:"基準", ahead:"領先 commit", commitStep:"commit", finishStep:"worktree finish(合併)", hideList:"收合 »", showList:"« 工作清單", moveHint:"方向鍵或 WASD 走動;Enter 和最近的人對話;Esc 返回。", critPass:"通過", critFail:"未通過", critUnknown:"無法判定", critPending:"還沒驗證", noCriteria:"這間房沒有驗收條件。", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", glance_pass:"驗證 {n}/{t} 通過", glance_fail:"驗證未通過:{id}", glance_failed:"驗證未通過", glance_pending:"待驗證 {n} 條", glance_all:"條件 {n}/{t} 通過", glance_review_pass:"審查通過 ✓", glance_review_fail:"審查未通過 ✗", glance_wait_verify:"還在等 QA…", glance_wait_review:"等審查中…", glance_wait_finish:"等整合中…", integrator:"整合", staff_verify:"執行驗證中", staff_review:"審查中", staff_wait:"等待另一輪審查", staff_finish:"合併 worktree 中", staff_rest:"在大廳休息", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", lobby:"大廳", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
168
168
  };
169
169
  function getLanguage(){
170
170
  try { var cookie = document.cookie.split(";").map(function(part){ return part.trim().split("="); }).find(function(pair){ return pair[0] === "agent-office-language"; }); if (cookie && (cookie[1] === "en" || cookie[1] === "zh")) return cookie[1]; } catch (_) {}
@@ -249,8 +249,57 @@ function roomBase(floor){
249
249
  // ---- people: a slot in the room of their phase; they walk only when the phase changes ----
250
250
  /** Engineers plan and build; once the work is with QA, the reviewers or the integrator, they stay at their cubicle. */
251
251
  function roomFor(actor){ var room = ROOM_OF[actor.phase] || "lobby"; return room === "verifying" || room === "reviewing" || room === "integrating" ? "implementing" : room; }
252
- // ---- resident staff: they rest in the lobby and walk to their station while their real process runs ----
253
- var STAFF_ROLES = ["qa", "reviewerA", "reviewerB", "integrator"], STAFF_FADE = 600;
252
+ // ---- resident staff: they idle in the lobby and walk to their station while their real process runs ----
253
+ var STAFF_ROLES = ["qa", "reviewerA", "reviewerB", "integrator"], STAFF_FADE = 600, staffSpots = {};
254
+ /** Frames a person stays at a lobby spot: 20 to 45 seconds at 60 frames a second, fixed by their key and visit. */
255
+ function dwellFrames(key, visit){ var h = 0, s = key + "#" + visit; for (var i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0; return 1200 + h % 1501; }
256
+ function keyHash(s){ var h = 0; for (var i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0; return h; }
257
+ /** Spots an idle engineer stands near are left to them. */
258
+ function engineerNear(floor, spot){
259
+ var place = placements(floor).byActor, lobby = layoutRoom("lobby");
260
+ return floor.actors.some(function(actor){ var p = place[actor.key]; if (!p || p.room !== "lobby") return false; var slot = lobby.slots[Math.min(p.index, lobby.slots.length - 1)]; return Math.abs(slot.feet.x - spot.feet.x) < 30 && Math.abs(slot.feet.y - spot.feet.y) < 30; });
261
+ }
262
+ /** The free lobby spots of a room, nearest to a point first. */
263
+ function freeSpots(floor, from, except){
264
+ var taken = {};
265
+ STAFF_ROLES.forEach(function(role){ var key = floor.key + "|staff:" + role, held = staffSpots[key]; if (key !== except && held && held.spot !== null) taken[held.spot] = true; });
266
+ return LAYOUT.staff.spots.map(function(spot, index){ return index; }).filter(function(index){ return !taken[index] && !engineerNear(floor, LAYOUT.staff.spots[index]); })
267
+ .sort(function(a, b){ var p = LAYOUT.staff.spots[a].feet, q = LAYOUT.staff.spots[b].feet; return (Math.abs(p.x - from.x) + Math.abs(p.y - from.y)) - (Math.abs(q.x - from.x) + Math.abs(q.y - from.y)) || a - b; });
268
+ }
269
+ /**
270
+ * Where an idle staff member is: a reserved lobby spot. New rooms seat the four on different spots chosen from
271
+ * the room key, with staggered first moves; coming back from work takes the nearest free spot.
272
+ */
273
+ function idleSpot(floor, key, index){
274
+ var held = staffSpots[key];
275
+ if (!held) {
276
+ var first = (keyHash(floor.key) + index * 3) % LAYOUT.staff.spots.length, free = freeSpots(floor, LAYOUT.staff.spots[first].feet, key);
277
+ held = staffSpots[key] = {spot: free.length ? free[0] : null, visit: 0, until: frame + 300 + index * 600};
278
+ } else if (held.spot === null) {
279
+ var state = people[key], near = freeSpots(floor, state ? {x: state.x, y: state.y} : LAYOUT.spawn, key);
280
+ held.spot = near.length ? near[0] : null; held.visit++; held.until = frame + dwellFrames(key, held.visit);
281
+ }
282
+ return held.spot === null ? LAYOUT.staff.spots[index] : LAYOUT.staff.spots[held.spot];
283
+ }
284
+ /** One idle person a room at a time, once their stay is over and no result bubble shows, moves to another free spot. */
285
+ function wander(floor){
286
+ if (reducedMotion) return;
287
+ var walking = false, ready = null;
288
+ STAFF_ROLES.forEach(function(role){
289
+ var key = floor.key + "|staff:" + role, state = people[key], held = staffSpots[key], memory = staffMemory[key];
290
+ if (state && state.path.length) walking = true;
291
+ var resting = held && held.spot !== null && !(memory && memory.busy) && !(memory && memory.result && frame < memory.result.until);
292
+ if (resting && frame >= held.until && (!ready || held.until < staffSpots[ready].until)) ready = key;
293
+ });
294
+ if (walking || !ready) return;
295
+ var held = staffSpots[ready], state = people[ready], here = LAYOUT.staff.spots[held.spot].feet;
296
+ var options = freeSpots(floor, here, ready).filter(function(index){ return index !== held.spot; });
297
+ held.visit++; held.until = frame + dwellFrames(ready, held.visit);
298
+ if (!options.length || !state) return;
299
+ held.spot = options[dwellFrames(ready, held.visit) % options.length];
300
+ var spot = LAYOUT.staff.spots[held.spot];
301
+ moveTo(ready, spot, spot.feet.x + "," + spot.feet.y, false);
302
+ }
254
303
  function activityOf(floor, kind){ var list = floor.activities || []; for (var i = 0; i < list.length; i++) if (list[i].kind === kind) return list[i]; return null; }
255
304
  /** Reviewer nameplates: the running review's targets, else the newest report's rounds, else A and B. */
256
305
  function reviewerNames(floor){
@@ -268,9 +317,12 @@ function staffOf(floor){
268
317
  var plans = {qa: ["verify", "QA", "qa", at.qa, "verify"], reviewerA: ["review", names[0], "reviewer", second ? at.armchair : at.desk, "review"],
269
318
  reviewerB: ["review", names[1], "reviewer", second ? at.desk : at.armchair, "review"], integrator: ["finish", t("integrator"), "integrator", at.integrator, "integration"]};
270
319
  return STAFF_ROLES.map(function(role, index){
271
- var plan = plans[role], busy = !!activityOf(floor, plan[0]), slot = busy ? plan[3] : LAYOUT.staff.rest[index];
320
+ var plan = plans[role], busy = !!activityOf(floor, plan[0]), key = floor.key + "|staff:" + role;
321
+ // Work releases the lobby spot; the way back takes the nearest free one.
322
+ if (busy && staffSpots[key]) staffSpots[key].spot = null;
323
+ var slot = busy ? plan[3] : idleSpot(floor, key, index);
272
324
  var narration = !busy ? t("staff_rest") : plan[0] !== "review" ? t("staff_" + plan[0]) : slot === at.desk ? t("staff_review") : t("staff_wait");
273
- return {key: floor.key + "|staff:" + role, id: plan[1], kind: plan[2], staff: role, activity: plan[0], panel: plan[4], busy: busy, slot: slot,
325
+ return {key: key, id: plan[1], kind: plan[2], staff: role, activity: plan[0], panel: plan[4], busy: busy, slot: slot,
274
326
  target: slot.feet.x + "," + slot.feet.y, narration: narration, questionCount: 0, status: busy ? "running" : "idle"};
275
327
  });
276
328
  }
@@ -293,14 +345,82 @@ function placements(floor){
293
345
  });
294
346
  return {byActor: byActor, counts: counts};
295
347
  }
348
+ // ---- the engineer reads the whiteboard or the QA board when something worth reading happens ----
349
+ var GLANCE_STAY = 480, WAITING = {verifying: true, reviewing: true, integrating: true};
350
+ /** The one engineer a room's notices concern: a run's coordinator, or a session's engineer. */
351
+ function engineerOf(floor){ var lead = floor.actors.filter(function(a){ return a.kind === "coordinator"; })[0]; return lead || (floor.kind === "run" ? null : floor.actors[0] || null); }
352
+ function seatOf(floor, actor){ var p = placements(floor).byActor[actor.key], room = layoutRoom(p.room); return {slot: room.slots[Math.min(p.index, room.slots.length - 1)], target: p.room + ":" + p.index, hidden: p.index >= room.slots.length}; }
353
+ function glanceFacts(floor){ return {verify: floor.board.verify, review: floor.board.review, criteria: (floor.criteria || []).map(function(c){ return c.id + "=" + c.status; }).join(",")}; }
354
+ function fillIn(text, values){ return text.replace(/\{(\w+)\}/g, function(_, name){ return String(values[name]); }); }
355
+ /** What the engineer says at a notice: only what the snapshot shows. */
356
+ function glanceText(floor, reason){
357
+ var criteria = floor.criteria || [], passed = criteria.filter(function(c){ return c.status === "PASS"; }).length, pending = criteria.filter(function(c){ return !c.status; }).length;
358
+ if (reason === "verify") {
359
+ if (floor.board.verify === "PASS") return fillIn(t("glance_pass"), {n: floor.board.passed || passed, t: floor.board.total || criteria.length});
360
+ var failed = criteria.filter(function(c){ return c.status === "FAIL"; })[0];
361
+ return failed ? fillIn(t("glance_fail"), {id: failed.id}) : t("glance_failed");
362
+ }
363
+ if (reason === "review") return t(floor.board.review === "PASS" ? "glance_review_pass" : "glance_review_fail");
364
+ if (reason === "timer") { var busy = ["verify", "review", "finish"].filter(function(kind){ return activityOf(floor, kind); })[0]; if (busy) return t("glance_wait_" + busy); }
365
+ return pending ? fillIn(t("glance_pending"), {n: pending}) : fillIn(t("glance_all"), {n: passed, t: criteria.length});
366
+ }
367
+ function glanceTimer(key, visit){ return 2400 + dwellFrames(key, visit) % 3001; }
368
+ /** Turns snapshot changes into a reading trip: the QA board first, then the whiteboard. The first snapshot is only a baseline. */
369
+ function noticeChanges(floor){
370
+ var facts = glanceFacts(floor), state = glances[floor.key];
371
+ if (!state) { glances[floor.key] = {facts: facts, stops: [], arrived: null, visit: 0, next: frame + glanceTimer(floor.key, 0), bubble: null}; return; }
372
+ var before = state.facts, stops = [];
373
+ state.facts = facts;
374
+ var settled = function(value){ return value === "PASS" || value === "FAIL"; };
375
+ if (facts.verify !== before.verify && settled(facts.verify)) stops.push({spot: "status", reason: "verify"});
376
+ if (facts.review !== before.review && settled(facts.review)) stops.push({spot: "board", reason: "review"});
377
+ else if (facts.criteria !== before.criteria) stops.push({spot: "board", reason: "criteria"});
378
+ var engineer = engineerOf(floor);
379
+ if (!stops.length || !engineer || engineer.questionCount > 0) return;
380
+ if (reducedMotion) { state.bubble = {key: engineer.key, text: glanceText(floor, stops[0].reason), until: frame + GLANCE_STAY}; return; }
381
+ stops.forEach(function(stop){ if (!state.stops.some(function(s){ return s.spot === stop.spot; })) state.stops.push(stop); });
382
+ }
383
+ /** Moves the engineer through their stops, waits at each, then back to the seat; between events they now and then look at the whiteboard. */
384
+ function glanceStep(floor){
385
+ var state = glances[floor.key], engineer = engineerOf(floor);
386
+ if (!state || !engineer || reducedMotion) return;
387
+ var person = people[engineer.key];
388
+ if (!person) return;
389
+ if (engineer.questionCount > 0) { if (state.stops.length) { state.stops = []; state.arrived = null; var home = seatOf(floor, engineer); moveTo(engineer.key, home.slot, home.target, home.hidden); } return; }
390
+ if (!state.stops.length) {
391
+ if (WAITING[floor.phase] && frame >= state.next) { state.visit++; state.next = frame + glanceTimer(floor.key, state.visit); state.stops.push({spot: "board", reason: "timer"}); }
392
+ else return;
393
+ }
394
+ var stop = state.stops[0], spot = LAYOUT.look[stop.spot], target = "look:" + stop.spot;
395
+ if (person.target !== target) { moveTo(engineer.key, spot, target, false); state.arrived = null; return; }
396
+ if (person.path.length) return;
397
+ if (state.arrived === null) { state.arrived = frame; stop.text = glanceText(floor, stop.reason); }
398
+ if (frame - state.arrived < GLANCE_STAY) return;
399
+ state.stops.shift(); state.arrived = null;
400
+ if (state.stops.length) { glanceStep(floor); return; }
401
+ var seat = seatOf(floor, engineer);
402
+ moveTo(engineer.key, seat.slot, seat.target, seat.hidden);
403
+ state.next = Math.max(state.next, frame + glanceTimer(floor.key, state.visit + 1));
404
+ }
405
+ /** The engineer's bubble while reading, or at the seat under reduced motion. */
406
+ function glanceBubble(floor){
407
+ var state = glances[floor.key], engineer = engineerOf(floor);
408
+ if (!state || !engineer) return null;
409
+ if (state.bubble && frame < state.bubble.until && state.bubble.key === engineer.key) return {key: engineer.key, text: state.bubble.text};
410
+ var stop = state.stops[0];
411
+ return stop && state.arrived !== null && stop.text ? {key: engineer.key, text: stop.text} : null;
412
+ }
296
413
  function syncPeople(){
297
414
  if (!model) return;
298
415
  var seen = {};
299
416
  model.floors.forEach(function(floor){
300
417
  var place = placements(floor).byActor;
418
+ noticeChanges(floor);
419
+ var reading = glances[floor.key] && glances[floor.key].stops.length ? engineerOf(floor) : null;
301
420
  floor.actors.forEach(function(actor){
302
421
  var p = place[actor.key], room = layoutRoom(p.room);
303
422
  seen[actor.key] = true;
423
+ if (reading && reading.key === actor.key && people[actor.key]) return; // on a reading trip; glanceStep brings them back
304
424
  moveTo(actor.key, room.slots[Math.min(p.index, room.slots.length - 1)], p.room + ":" + p.index, p.index >= room.slots.length);
305
425
  });
306
426
  staffOf(floor).forEach(function(member){
@@ -314,6 +434,8 @@ function syncPeople(){
314
434
  });
315
435
  Object.keys(people).forEach(function(key){ if (!seen[key]) delete people[key]; });
316
436
  Object.keys(staffMemory).forEach(function(key){ if (!seen[key]) delete staffMemory[key]; });
437
+ Object.keys(staffSpots).forEach(function(key){ if (!seen[key]) delete staffSpots[key]; });
438
+ Object.keys(glances).forEach(function(key){ if (!roomByKey(key)) delete glances[key]; });
317
439
  }
318
440
  function stepAlong(state){
319
441
  if (!state.path.length) return;
@@ -563,10 +685,12 @@ function renderLabels(floor, figs){
563
685
  figs.forEach(function(f){ label("name:" + f.actor.key, "nameplate" + (f.actor.questionCount ? " alert" : ""), shortName(f.actor.id, 14), f.x + 17, f.y + 50); });
564
686
  label("name:viewer", "nameplate viewer", t("viewer"), viewer.x, viewer.y + 1);
565
687
  var near = nearestActor(figs);
566
- if (near) label("speech", "speech", shortName(near.actor.id, 14) + ": " + localizeText(near.actor.narration), near.x + 17, near.y - 6);
688
+ var said = glanceBubble(floor), reader = said && figs.filter(function(f){ return f.actor.key === said.key; })[0];
689
+ if (reader) label("glance", "speech glance", said.text, reader.x + 17, reader.y - 6);
690
+ if (near && near !== reader) label("speech", "speech", shortName(near.actor.id, 14) + ": " + localizeText(near.actor.narration), near.x + 17, near.y - 6);
567
691
  }
568
692
  function render(){ if (!model) return; if (mode === "room") renderRoomView(); else renderOverview(); }
569
- function tick(){ frame++; if (!reducedMotion) advance(); render(); }
693
+ function tick(){ frame++; if (!reducedMotion) { if (model) model.floors.forEach(function(floor){ wander(floor); glanceStep(floor); }); advance(); } render(); }
570
694
  function attentionFor(actor){
571
695
  if(actor.questionCount)return t('waiting')+' ('+actor.questionCount+')';
572
696
  if(actor.progress&&actor.progress.verify==='FAIL')return t('verifyFailed');
@@ -106,13 +106,22 @@ export function sceneModel(snapshot) {
106
106
  verify = "PASS";
107
107
  if (review === null && run.agents.length && run.agents.every(agent => agent.progress?.review === "PASS"))
108
108
  review = "PASS";
109
- const rootProgress = { passed, total, verify, review };
110
- const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
109
+ const rootProgress = Object.prototype.hasOwnProperty.call(extra, "progress") ? progressOf(extra.progress) : { passed, total, verify, review };
110
+ const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
111
+ const item = optional(value);
112
+ return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
113
+ status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
114
+ finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
115
+ failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
116
+ exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
117
+ });
118
+ const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0
119
+ ? extra.taskId : run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
111
120
  if (questions.length > 0)
112
121
  dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
113
- return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria: [],
122
+ return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
114
123
  activities: activitiesOf(optional(run).activities),
115
- board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
124
+ board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
116
125
  clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
117
126
  completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
118
127
  };
@@ -29,6 +29,13 @@ export function buildOfficeSnapshot(input) {
29
29
  const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
30
30
  reviewers.forEach(r => claimed.add(r));
31
31
  const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
32
+ const coordinator = run.workers.find(worker => worker.workerId === run.coordinatorId);
33
+ // rootTaskId is the persisted run authority. Only legacy runs without it
34
+ // fall back to the coordinator's task id.
35
+ const rootTaskId = run.rootTaskId ?? (coordinator?.taskId || null);
36
+ const rootTask = rootTaskId === null ? undefined : run.tasks.find(task => task.taskId === rootTaskId);
37
+ const rootTree = rootTaskId === null ? undefined : input.worktrees.find(tree => tree.taskId === rootTaskId &&
38
+ (tree.runId === run.runId || tree.path === coordinator?.worktree));
32
39
  const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
33
40
  const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
34
41
  const phase = w.phase ?? "unknown";
@@ -39,6 +46,11 @@ export function buildOfficeSnapshot(input) {
39
46
  });
40
47
  const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
41
48
  return { runId: run.runId, title: run.rootTaskId ?? run.runId,
49
+ ...(rootTaskId === null ? {} : { taskId: rootTaskId }),
50
+ // The root task is the only source of run-level proof. A
51
+ // missing proof stays null; worker progress must never become root proof.
52
+ ...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
53
+ ...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
42
54
  status: run.status, phase: run.phase ?? "unknown",
43
55
  budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
44
56
  questions, agents, reviewers,
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { collectChangeSurface } from "../verify/change-surface.js";
6
+ import { FileRunRepository } from "../run/service.js";
6
7
  import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
7
8
  /**
8
9
  * A baseline no source state hashes to. A resumed session inherits whatever
@@ -141,8 +142,11 @@ export async function resumeWorktree(deps, options) {
141
142
  const sessionId = assertSessionId(options.sessionId);
142
143
  const mainRoot = await mainCheckout(deps, options.cwd);
143
144
  const previous = await requireRecord(mainRoot, name);
144
- const tasks = deps.tasks(previous.path);
145
+ // A base gives the service repository context, which debug resume needs.
146
+ const tasks = deps.tasks(previous.path, previous.base);
145
147
  const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
148
+ if (attached?.task.debug !== undefined)
149
+ await tasks.debugService().resume(attached.task.id, sessionId);
146
150
  if (attached !== null)
147
151
  await tasks.attach(sessionId, attached.task.id);
148
152
  const record = { ...previous, sessionId };
@@ -206,6 +210,15 @@ export async function removeWorktree(deps, options) {
206
210
  const name = assertWorktreeName(options.name);
207
211
  const mainRoot = await mainCheckout(deps, options.cwd);
208
212
  const status = await describe(deps, await requireRecord(mainRoot, name));
213
+ // A running run still writes here; --force discards work, it does not stop the run.
214
+ const runId = status.record.runId;
215
+ if (runId !== undefined) {
216
+ const commonDir = join(mainRoot, ".git");
217
+ const run = await new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir).read(runId).catch(() => null);
218
+ if (run !== null && ["active", "awaiting-input", "stopping"].includes(run.status)) {
219
+ throw manageError("WORKTREE_RUN_ACTIVE", `${name} belongs to run ${runId}, which is ${run.status}. Stop it first: agent-ops run stop ${runId}.`);
220
+ }
221
+ }
209
222
  if ((status.dirty || status.ahead > 0) && !options.force) {
210
223
  throw manageError("WORKTREE_UNMERGED", `${name} has ${status.dirty ? "uncommitted changes" : ""}${status.dirty && status.ahead > 0 ? " and " : ""}${status.ahead > 0 ? `${status.ahead} commit(s) not in ${status.record.targetBranch}` : ""}. Finish it, or remove it with --force to discard that work (the user is asked).`);
211
224
  }
@@ -217,7 +230,11 @@ export async function removeWorktree(deps, options) {
217
230
  ? mainGate.redirect(record.sessionId, null)
218
231
  : mainGate.removeRoot(record.sessionId, record.path));
219
232
  }
220
- await deps.trust.revoke(record.path, await deps.loadConfig(record.path)).catch(() => undefined);
233
+ // A paused or stopped run's worktree has no loadable policy config; its trust
234
+ // was never this checkout's to revoke, so removal must not depend on it.
235
+ const config = await deps.loadConfig(record.path).catch(() => null);
236
+ if (config !== null)
237
+ await deps.trust.revoke(record.path, config).catch(() => undefined);
221
238
  await removeCheckout(deps, record, true);
222
239
  return status;
223
240
  }
@@ -17,7 +17,9 @@ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
17
17
  const REPORT_ARTIFACT_MAX_BYTES = 512 * 1024;
18
18
  function validContractBinding(value) {
19
19
  const hashes = [value.taskContractHash, value.goalHash, value.coverageDigest];
20
- return hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
20
+ return (value.debugProofs === undefined || typeof value.debugProofs === "object" && value.debugProofs !== null && !Array.isArray(value.debugProofs) &&
21
+ Object.entries(value.debugProofs).every(([id, hash]) => TASK_ID_PATTERN.test(id) && typeof hash === "string" && DIGEST_PATTERN.test(hash))) &&
22
+ hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
21
23
  (value.treeContractHash === undefined || typeof value.treeContractHash === "string" && DIGEST_PATTERN.test(value.treeContractHash)) &&
22
24
  typeof value.candidateCommit === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value.candidateCommit) &&
23
25
  typeof value.taskContracts === "object" && value.taskContracts !== null && !Array.isArray(value.taskContracts) &&
@@ -26,6 +28,7 @@ function validContractBinding(value) {
26
28
  }
27
29
  function contractBinding(value) {
28
30
  return validContractBinding(value) ? {
31
+ ...(value.debugProofs === undefined ? {} : { debugProofs: value.debugProofs }),
29
32
  taskContractHash: value.taskContractHash, taskContracts: value.taskContracts,
30
33
  ...(value.treeContractHash === undefined ? {} : { treeContractHash: value.treeContractHash }),
31
34
  goalHash: value.goalHash, candidateCommit: value.candidateCommit, coverageDigest: value.coverageDigest
@@ -705,7 +705,7 @@ export function createReviewExecutor(options) {
705
705
  const clock = options.now ?? (() => Date.now());
706
706
  return async (request) => {
707
707
  const deadlineAt = clock() +
708
- (options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
708
+ Math.min(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS, await options.remainingBudgetMs?.() ?? Infinity);
709
709
  const remainingMs = () => deadlineAt - clock();
710
710
  /**
711
711
  * The options one stage runs under: its own timeout, or what is left of
@@ -48,62 +48,77 @@ export async function withReviewSlot(options, action) {
48
48
  }
49
49
  const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
50
50
  let waiting = false;
51
+ let waitEnded = false;
51
52
  let held;
52
53
  const token = randomUUID();
53
- while (held === undefined) {
54
- if (options.signal?.aborted === true)
55
- throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
56
- for (let index = 0; index < width && held === undefined; index += 1) {
57
- const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
58
- try {
59
- const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
60
- if (options.signal?.aborted === true)
61
- throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
62
- for (;;) {
63
- try {
64
- await mkdir(path);
65
- await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
66
- return true;
67
- }
68
- catch (error) {
69
- if (error.code !== "EEXIST")
70
- throw error;
71
- if (!await stale(path, options.staleMs ?? STALE_MS))
72
- return false;
73
- await rm(path, { recursive: true, force: true });
54
+ try {
55
+ while (held === undefined) {
56
+ if (options.signal?.aborted === true)
57
+ throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
58
+ for (let index = 0; index < width && held === undefined; index += 1) {
59
+ const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
60
+ try {
61
+ const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
62
+ if (options.signal?.aborted === true)
63
+ throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
64
+ for (;;) {
65
+ try {
66
+ await mkdir(path);
67
+ await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
68
+ return true;
69
+ }
70
+ catch (error) {
71
+ if (error.code !== "EEXIST")
72
+ throw error;
73
+ if (!await stale(path, options.staleMs ?? STALE_MS))
74
+ return false;
75
+ await rm(path, { recursive: true, force: true });
76
+ }
74
77
  }
75
- }
76
- });
77
- if (claimed)
78
- held = path;
78
+ });
79
+ if (claimed)
80
+ held = path;
81
+ }
82
+ catch (error) {
83
+ if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
84
+ throw error;
85
+ }
79
86
  }
80
- catch (error) {
81
- if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
82
- throw error;
87
+ if (held === undefined) {
88
+ if (!waiting) {
89
+ waiting = true;
90
+ await options.onWaitStart?.();
91
+ options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
92
+ }
93
+ await sleep(options.pollMs ?? POLL_MS);
83
94
  }
84
95
  }
85
- if (held === undefined) {
86
- if (!waiting) {
87
- waiting = true;
88
- options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
89
- }
90
- await sleep(options.pollMs ?? POLL_MS);
96
+ if (waiting) {
97
+ waitEnded = true;
98
+ await options.onWaitEnd?.(true);
91
99
  }
92
- }
93
- try {
94
100
  if (options.signal?.aborted === true)
95
101
  throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
96
102
  return await action();
97
103
  }
98
104
  finally {
99
- await withPrivateFileLock(held + ".state", options.dir, async () => {
100
- const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
101
- if (error.code === "ENOENT")
102
- return null;
103
- throw error;
105
+ if (waiting && !waitEnded) {
106
+ waitEnded = true;
107
+ try {
108
+ await options.onWaitEnd?.(false);
109
+ }
110
+ catch { /* preserve the original wait/abort failure */ }
111
+ }
112
+ if (held !== undefined) {
113
+ await withPrivateFileLock(held + ".state", options.dir, async () => {
114
+ const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
115
+ if (error.code === "ENOENT")
116
+ return null;
117
+ throw error;
118
+ });
119
+ if (owner !== null && JSON.parse(owner)?.token === token)
120
+ await rm(held, { recursive: true, force: true });
104
121
  });
105
- if (owner !== null && JSON.parse(owner)?.token === token)
106
- await rm(held, { recursive: true, force: true });
107
- });
122
+ }
108
123
  }
109
124
  }
@@ -236,7 +236,7 @@ export class CodexGoalHost {
236
236
  clientInfo: {
237
237
  name: "agent-ops",
238
238
  title: "agent-ops native goal host",
239
- version: "0.8.0"
239
+ version: "0.8.2"
240
240
  }
241
241
  });
242
242
  session.notify("initialized", {});
@@ -2,10 +2,13 @@ import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
+ import { readWindowsProcessIdentity } from "../security/permissions.js";
5
6
  const exec = promisify(execFile);
6
7
  export async function nativeProcessIdentity(pid) {
7
8
  if (!Number.isSafeInteger(pid) || pid < 1)
8
9
  return null;
10
+ if (process.platform === "win32")
11
+ return await readWindowsProcessIdentity(pid);
9
12
  try {
10
13
  const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
11
14
  return stdout.trim() || null;
@@ -1,3 +1,4 @@
1
+ import { validDebugSettings, validDebugIntake } from "../task/debug-state.js";
1
2
  import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
2
3
  import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
3
4
  const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
@@ -29,6 +30,7 @@ const HOOK_EVENT_VALUES = new Set([
29
30
  "SubagentStart",
30
31
  "SubagentStop",
31
32
  "Stop",
33
+ "Interrupt",
32
34
  "SessionEnd"
33
35
  ]);
34
36
  const MAX_TIMEOUT_MS = 2_147_483_647;
@@ -331,10 +333,12 @@ export function validateConfig(value) {
331
333
  if (!isRecord(root.features)) {
332
334
  return failure("INVALID_TYPE", "$.features", "features must be an object.");
333
335
  }
334
- const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification", "office"], "$.features");
336
+ const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification", "office", "debug"], "$.features");
335
337
  if (featuresUnknown !== undefined) {
336
338
  return featuresUnknown;
337
339
  }
340
+ if (root.features.debug !== undefined && !validDebugSettings(root.features.debug))
341
+ return failure("INVALID_FEATURE", "$.features.debug", "Debug requires enabled, bounded budgetMs and maxRounds.");
338
342
  if (root.features.office !== undefined) {
339
343
  if (!isRecord(root.features.office)) {
340
344
  return failure("INVALID_TYPE", "$.features.office", "office must be an object.");
@@ -566,10 +570,12 @@ export function validateCriterion(value, path) {
566
570
  return success(value);
567
571
  }
568
572
  export function validateTask(value) {
569
- const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
573
+ const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
570
574
  if (isFailure(root)) {
571
575
  return root;
572
576
  }
577
+ if (root.debug !== undefined && (!validDebugIntake(root.debug) || root.schemaVersion !== TASK_SCHEMA_VERSION || root.goal === undefined))
578
+ return failure("INVALID_DEBUG_TASK", "$.debug", "Debug tasks require immutable goal and bounded root intake.");
573
579
  if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
574
580
  return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
575
581
  if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
@@ -232,7 +232,9 @@ async function readPosixProcessIdentity(processId) {
232
232
  return null;
233
233
  }
234
234
  }
235
- async function readWindowsProcessIdentity(processId) {
235
+ export async function readWindowsProcessIdentity(processId) {
236
+ if (!Number.isSafeInteger(processId) || processId < 1)
237
+ return null;
236
238
  try {
237
239
  const script = [
238
240
  `$process = Get-Process -Id ${processId} -ErrorAction Stop`,
@@ -3,12 +3,31 @@ import { findReviewAttestation } from "../review/attestation.js";
3
3
  import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
4
4
  import { isPassingVerificationEvidence } from "../verify/evidence.js";
5
5
  import { taskContractHash } from "./contract.js";
6
- import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
6
+ import { acceptanceCoverage, coverageDigest } from "../verify/acceptance-coverage.js";
7
+ import { loadDebugProof, debugProofDigest, debugRemainingMs } from "./debug-state.js";
7
8
  export async function checkTaskCompletionEvidence(stored, options) {
9
+ const debug = stored.task.debug === undefined ? undefined : await loadDebugProof(options.root, stored.task.debug.rootTaskId);
10
+ if (debug?.control?.paused === true || debug?.control?.mode === "diagnose")
11
+ return { status: "FAIL", code: "TASK_DEBUG_SCOPE_RESTRICTED", remedy: "The human restricted this issue to pause or diagnosis. Preserve the active task; only a native human resume repair event authorizes repair completion." };
8
12
  const problem = await checkTaskVerificationEvidence(stored, options);
9
13
  if (problem !== null)
10
14
  return problem;
11
15
  const attestation = await findReviewAttestation(options.root, options.sourceFingerprint, stored.task.id);
16
+ if (debug !== undefined) {
17
+ const coverage = await acceptanceCoverage(stored, options.config, options.sourceFingerprint, options.evidenceStore);
18
+ const { FileTaskStore } = await import("./store.js");
19
+ const { join } = await import("node:path");
20
+ const reviewedRecords = (await new FileTaskStore(join(options.root, ".agent-ops/tasks/state.json"), options.root).read()).tasks.filter(r => attestation?.taskContracts?.[r.task.id] !== undefined);
21
+ const currentCoverage = [];
22
+ for (const record of reviewedRecords) {
23
+ const rows = await acceptanceCoverage(record, options.config, options.sourceFingerprint, options.evidenceStore);
24
+ currentCoverage.push(...rows.map(row => ({ ...row, criterionId: attestation?.tree === undefined ? row.criterionId : record.task.id + ":" + row.criterionId })));
25
+ }
26
+ const refs = new Set(coverage.filter(c => c.mode === "behavioral" && c.status === "proven").flatMap(c => c.evidenceReferences));
27
+ if (attestation?.coverageDigest !== coverageDigest(currentCoverage) || debug.operation !== null || debugRemainingMs(debug) === 0 || !debug.hypotheses.some(h => h.status === "confirmed" && h.supporting.some(ref => refs.has(ref))) ||
28
+ attestation?.debugProofs?.[stored.task.id] !== debugProofDigest(debug))
29
+ 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
+ }
12
31
  if (attestation === null || attestation.taskId !== stored.task.id ||
13
32
  ((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
14
33
  (attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||