@kylecheng3146/agent-ops 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ export function sceneModel(snapshot) {
35
35
  return { passed: typeof p.passed === "number" ? p.passed : 0, total: typeof p.total === "number" ? p.total : 0,
36
36
  verify: p.verify === "PASS" || p.verify === "FAIL" ? p.verify : null, review: p.review === "PASS" || p.review === "FAIL" ? p.review : null };
37
37
  };
38
- const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 1) + "…" : value;
38
+ const short = (value, limit = 18) => value.length > limit ? value.slice(0, limit - 6) + "…" + value.slice(-5) : value;
39
39
  const optional = (value) => value !== null && typeof value === "object" ? value : {};
40
40
  const boardFor = (progress, status, taskId, pending) => ({
41
41
  passed: progress?.passed ?? 0, total: progress?.total ?? 0,
@@ -49,7 +49,7 @@ export function sceneModel(snapshot) {
49
49
  return typeof repo === "string" && repo.length > 0 ? repo : null;
50
50
  };
51
51
  const scoped = (repo, key) => repo === null ? key : "repo:" + repo + "|" + key;
52
- const titled = (repo, title) => short(repo === null ? title : repo + " · " + title, 34);
52
+ const titled = (repo, title) => repo === null ? title : repo + " · " + title;
53
53
  const makeAreas = () => PHASES.map(phase => {
54
54
  const area = areas[phase];
55
55
  return { phase, x: area.x, y: area.y, width: area.width, height: area.height, prop: area.prop, label: labels[phase] };
@@ -70,7 +70,7 @@ export function sceneModel(snapshot) {
70
70
  { kind: "plant", x: 57, y: 34, key: key + ":plant-bottom" },
71
71
  { kind: "door", x: 65, y: 33, key: key + ":door" }
72
72
  ];
73
- const makeRun = (run) => {
73
+ const makeRun = (run, index) => {
74
74
  const extra = optional(run);
75
75
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
76
76
  const questions = run.questions;
@@ -95,34 +95,34 @@ export function sceneModel(snapshot) {
95
95
  total += p?.total ?? 0;
96
96
  if (p?.verify === "FAIL")
97
97
  verify = "FAIL";
98
- else if (p?.verify === "PASS" && verify === null)
99
- verify = "PASS";
100
98
  if (p?.review === "FAIL")
101
99
  review = "FAIL";
102
- else if (p?.review === "PASS" && review === null)
103
- review = "PASS";
104
100
  actors.push({ key: key + ":" + agent.id, id: agent.id, kind: agent.role, x: point.x, y: point.y, alert: agent.role === "coordinator" && questions.length > 0,
105
101
  label: short(agent.id), status: agent.status, phase, taskId: agent.taskId || "unassigned", progress: p,
106
102
  narration: agent.narration, questionCount: agent.role === "coordinator" ? questions.length : 0, host: "unknown" });
107
103
  dialogue.push(`${run.title}: ${agent.id} ${agent.narration}.`);
108
104
  });
109
- run.reviewers.forEach((reviewer, index) => {
110
- const reviewerIndex = actors.filter(actor => actor.phase === "reviewing").length;
111
- const reviewerCount = run.agents.filter(agent => agent.phase === "reviewing").length + run.reviewers.length;
112
- const point = position("reviewing", reviewerIndex, reviewerCount);
113
- actors.push({ key: key + ":review:" + reviewer.slot, id: "reviewer " + reviewer.slot, kind: "reviewer", x: point.x, y: point.y, alert: false,
114
- label: "reviewer " + reviewer.slot, status: "reviewing", phase: "reviewing", taskId: reviewer.taskId ?? "unassigned",
115
- progress: null, narration: "reviewing", questionCount: 0, host: "unknown" });
116
- });
105
+ if (verify === null && run.agents.length && run.agents.every(agent => agent.progress?.verify === "PASS"))
106
+ verify = "PASS";
107
+ if (review === null && run.agents.length && run.agents.every(agent => agent.progress?.review === "PASS"))
108
+ review = "PASS";
117
109
  const rootProgress = { passed, total, verify, review };
118
110
  const taskId = run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
119
111
  if (questions.length > 0)
120
112
  dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
121
- return { key, sourceIndex: null, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions,
113
+ return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria: [],
114
+ activities: activitiesOf(optional(run).activities),
122
115
  board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
123
116
  clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
124
117
  completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
125
118
  };
119
+ const activitiesOf = (value) => (Array.isArray(value) ? value : []).map(raw => {
120
+ const item = optional(raw);
121
+ return { kind: item.kind === "verify" || item.kind === "review" || item.kind === "finish" ? item.kind : "verify",
122
+ startedAt: typeof item.startedAt === "string" ? item.startedAt : "",
123
+ targets: Array.isArray(item.targets) ? item.targets.filter((t) => typeof t === "string").slice(0, 2) : [],
124
+ round: item.round === 1 || item.round === 2 ? item.round : null, target: typeof item.target === "string" ? item.target : null };
125
+ });
126
126
  const makeDesk = (desk, index) => {
127
127
  const extra = optional(desk);
128
128
  const phase = extra.phase === "planning" || extra.phase === "implementing" || extra.phase === "verifying" || extra.phase === "reviewing" || extra.phase === "integrating" ? extra.phase : "unknown";
@@ -135,6 +135,15 @@ export function sceneModel(snapshot) {
135
135
  return { questionId: typeof item.questionId === "string" ? item.questionId : "unknown", prompt: typeof item.prompt === "string" ? item.prompt : "" };
136
136
  });
137
137
  const completedAt = typeof extra.completedAt === "string" ? extra.completedAt : null;
138
+ // Inline checks, no named helper: the page embeds sceneModel via toString, so it cannot carry bundler helpers.
139
+ const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
140
+ const item = optional(value);
141
+ return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
142
+ status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
143
+ finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
144
+ failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
145
+ exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
146
+ });
138
147
  const repo = repoOf(desk);
139
148
  const key = scoped(repo, "session:" + [desk.sessionId || "unknown", desk.branch || "unknown", desk.name || "session"].join(":"));
140
149
  const props = makeProps(key);
@@ -146,24 +155,11 @@ export function sceneModel(snapshot) {
146
155
  dialogue.push(`${desk.name}: waiting for your answer.`);
147
156
  dialogue.push(`${desk.name}: ${desk.narration}.`);
148
157
  return { key, sourceIndex: index, kind: "desk", title: titled(repo, desk.name), top: 0, props, actors: [actor], phaseAreas: makeAreas(),
149
- board: boardFor(p, status, taskId, questions.length), questions, papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
158
+ board: boardFor(p, status, taskId, questions.length), questions, criteria, activities: activitiesOf(extra.activities), papers: { [key + ":implementing"]: Math.min(8, desk.diff.files) }, books: { lit: p?.passed ?? 0, total: p?.total ?? 0 },
150
159
  clock: 1, phase, status, completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
151
160
  };
152
- snapshot.runs.forEach(run => floors.push(makeRun(run)));
161
+ snapshot.runs.forEach((run, index) => floors.push(makeRun(run, index)));
153
162
  snapshot.lobby.forEach((desk, index) => floors.push(makeDesk(desk, index)));
154
- snapshot.reviews.forEach((review, index) => {
155
- const repo = repoOf(review);
156
- const key = scoped(repo, "review:" + review.slot + ":" + index);
157
- const props = makeProps(key);
158
- const point = position("reviewing", 0);
159
- const actor = { key: key + ":actor", id: "reviewer " + review.slot, kind: "reviewer", x: point.x, y: point.y, alert: false, label: "reviewer " + review.slot,
160
- status: "reviewing", phase: "reviewing", taskId: review.taskId ?? "unassigned", progress: null,
161
- narration: "reviewing", questionCount: 0, host: "unknown" };
162
- dialogue.push(`Review slot ${review.slot}: ${review.taskId ?? "unassigned"}.`);
163
- floors.push({ key, sourceIndex: index, kind: "review", title: titled(repo, "Review slot " + review.slot), top: 0, props, actors: [actor], phaseAreas: makeAreas(), questions: [],
164
- board: boardFor(null, "reviewing", review.taskId ?? "unassigned", 0), papers: {}, books: { lit: 0, total: 0 }, clock: 1,
165
- phase: "reviewing", status: "reviewing", completedAt: null, repo, overview: { x: 0, y: 0, width: 0, height: 0 } });
166
- });
167
163
  // Rooms of one repository sit together; the sort is stable inside each.
168
164
  floors.sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
169
165
  if (dialogue.length === 0)
@@ -68,13 +68,12 @@ function retained(record, now) {
68
68
  const lastSeen = Date.parse(record.lastSeenAt);
69
69
  if (!Number.isFinite(lastSeen))
70
70
  return false;
71
- if (record.status === "active") {
72
- // ponytail: one TTL handles crashed hook clients; a persistent host heartbeat can replace it if needed.
71
+ if (record.status === "active" || record.completedAt === null || record.completedAt === undefined) {
72
+ // ponytail: one TTL handles crashed hook clients and windows closed without `end`;
73
+ // a persistent host heartbeat can replace it if needed.
73
74
  return now - lastSeen <= OFFICE_SESSION_STALE_MS;
74
75
  }
75
- const completed = record.completedAt === null || record.completedAt === undefined
76
- ? lastSeen
77
- : Date.parse(record.completedAt);
76
+ const completed = Date.parse(record.completedAt);
78
77
  return Number.isFinite(completed) && now - completed < OFFICE_SESSION_RETENTION_MS;
79
78
  }
80
79
  function prune(records, now) {
@@ -100,7 +99,8 @@ export async function recordOfficeSession(options) {
100
99
  root: options.projectRoot,
101
100
  firstSeenAt: previous?.firstSeenAt ?? at,
102
101
  lastSeenAt: at,
103
- status: options.event === "stop" || options.event === "end" ? "idle" : "active",
102
+ // A session that only started is waiting at its prompt; work begins with activity.
103
+ status: options.event === "activity" ? "active" : "idle",
104
104
  ...(options.agentId ?? previous?.agentId ? { agentId: options.agentId ?? previous?.agentId } : {}),
105
105
  ...(options.runId ?? previous?.runId ? { runId: options.runId ?? previous?.runId } : {}),
106
106
  ...(options.workerId ?? previous?.workerId ? { workerId: options.workerId ?? previous?.workerId } : {}),
@@ -117,6 +117,22 @@ export async function recordOfficeSession(options) {
117
117
  await writePrivateFile(path, JSON.stringify({ schemaVersion: 1, sessions: [...without, next].slice(-MAX_SESSIONS) }) + "\n", options.commonDir);
118
118
  });
119
119
  }
120
+ /**
121
+ * A long command's later step (batch, advance) on the session that runs it,
122
+ * which hooks only see start. Unknown sessions stay unknown.
123
+ */
124
+ export async function recordOfficeSessionPhase(commonDir, sessionId, phase, now = Date.now()) {
125
+ const path = officeSessionsPath(commonDir);
126
+ await withPrivateFileLock(path, commonDir, async () => {
127
+ const records = prune(await readRecords(commonDir), now);
128
+ if (!records.some(record => record.sessionId === sessionId))
129
+ return;
130
+ const at = new Date(now).toISOString();
131
+ const next = records.map(record => record.sessionId === sessionId
132
+ ? { ...record, status: "active", phase, lastSeenAt: at } : record);
133
+ await writePrivateFile(path, JSON.stringify({ schemaVersion: OFFICE_SESSION_SCHEMA_VERSION, sessions: next }) + "\n", commonDir);
134
+ });
135
+ }
120
136
  /** Closes a session's room once `worktree finish` merged its work. Unknown sessions stay unknown. */
121
137
  export async function markOfficeSessionCompleted(commonDir, sessionId, now = Date.now()) {
122
138
  const path = officeSessionsPath(commonDir);
@@ -57,6 +57,7 @@ export function buildOfficeSnapshot(input) {
57
57
  runSessionIds.add(worker.nativeSessionId);
58
58
  });
59
59
  const lobby = [];
60
+ const deskPaths = new Map();
60
61
  const represented = new Set();
61
62
  for (const worktree of input.worktrees) {
62
63
  if (worktree.runId !== undefined)
@@ -78,9 +79,15 @@ export function buildOfficeSnapshot(input) {
78
79
  ...(worktree.status === undefined && session?.taskStatus === undefined && session?.status === undefined ? {} : { status: worktree.status ?? session?.taskStatus ?? session?.status }),
79
80
  ...(worktree.taskId === undefined && session?.taskId === undefined ? {} : { taskId: worktree.taskId ?? session?.taskId ?? null }),
80
81
  ...(worktree.progress === undefined && session?.progress === undefined ? {} : { progress: worktree.progress ?? session?.progress ?? null }),
82
+ ...(worktree.title === undefined && session?.title === undefined ? {} : { title: worktree.title ?? session?.title }),
83
+ ...(worktree.criteria === undefined && session?.criteria === undefined ? {} : { criteria: worktree.criteria ?? session?.criteria ?? [] }),
84
+ ...(worktree.review === undefined && session?.review === undefined ? {} : { review: worktree.review ?? session?.review ?? null }),
85
+ ...(worktree.base === undefined ? {} : { base: worktree.base }),
86
+ ...(worktree.ahead === undefined ? {} : { ahead: worktree.ahead }),
81
87
  ...(completedAt === null && session?.completedAt === undefined ? {} : { completedAt }),
82
88
  ...(worktree.host === undefined && session?.host === undefined ? {} : { host: worktree.host ?? session?.host }),
83
89
  });
90
+ deskPaths.set(lobby.length - 1, worktree.path);
84
91
  represented.add(worktree.sessionId);
85
92
  }
86
93
  for (const session of sessions) {
@@ -100,11 +107,16 @@ export function buildOfficeSnapshot(input) {
100
107
  status: session.taskStatus ?? session.status,
101
108
  ...(session.taskId === undefined ? {} : { taskId: session.taskId }),
102
109
  ...(session.progress === undefined ? {} : { progress: session.progress }),
110
+ ...(session.title === undefined ? {} : { title: session.title }),
111
+ ...(session.criteria === undefined ? {} : { criteria: session.criteria }),
112
+ ...(session.review === undefined ? {} : { review: session.review }),
103
113
  ...(completedAt === null ? {} : { completedAt }),
104
114
  ...(session.host === undefined ? {} : { host: session.host })
105
115
  });
106
116
  }
107
- return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: input.reviews.filter(r => !claimed.has(r)) };
117
+ attachActivities(input, runs, lobby, deskPaths);
118
+ // Review slots no longer make rooms: a running review shows up as reviewers in its own room.
119
+ return { generatedAt: new Date(input.now).toISOString(), runs, lobby, reviews: [] };
108
120
  }
109
121
  /** One building from several repositories' snapshots; every room names its repository. */
110
122
  export function mergeOfficeSnapshots(parts, now) {
@@ -115,3 +127,52 @@ export function mergeOfficeSnapshots(parts, now) {
115
127
  reviews: parts.flatMap(({ repo, snapshot }) => snapshot.reviews.map(review => ({ ...review, repo })))
116
128
  };
117
129
  }
130
+ const ACTIVITY_PHASE = { verify: "verifying", review: "reviewing", finish: "integrating" };
131
+ function activityView(activity) {
132
+ return { kind: activity.kind, startedAt: activity.startedAt, ...(activity.targets === undefined ? {} : { targets: activity.targets }),
133
+ ...(activity.round === undefined ? {} : { round: activity.round }), ...(activity.target === undefined ? {} : { target: activity.target }) };
134
+ }
135
+ /**
136
+ * Puts each live activity in its room: the same session, else the same task,
137
+ * else the worktree it runs in. One that matches no room gets a room of its
138
+ * own for as long as it runs, so nothing that is running goes unseen.
139
+ */
140
+ function attachActivities(input, runs, lobby, deskPaths) {
141
+ const runSessions = (run) => new Set(run.workers.flatMap(worker => [worker.ownerSessionId, worker.nativeSessionId].filter((id) => id !== null)));
142
+ for (const activity of input.activities ?? []) {
143
+ const view = activityView(activity);
144
+ const toDesk = (index) => { const desk = lobby[index]; lobby[index] = { ...desk, activities: [...(desk.activities ?? []), view] }; };
145
+ const toRun = (index) => { const run = runs[index]; runs[index] = { ...run, activities: [...(run.activities ?? []), view] }; };
146
+ const sourceRun = (run) => input.runs.find(state => state.runId === run.runId);
147
+ const matchers = [
148
+ [desk => activity.sessionId !== undefined && desk.sessionId === activity.sessionId,
149
+ run => activity.sessionId !== undefined && runSessions(sourceRun(run)).has(activity.sessionId)],
150
+ [desk => activity.taskId !== undefined && desk.taskId === activity.taskId,
151
+ run => activity.taskId !== undefined && run.agents.some(agent => agent.taskId === activity.taskId)],
152
+ [(_, index) => deskPaths.get(index) === activity.root,
153
+ run => sourceRun(run).workers.some(worker => worker.worktree === activity.root)]
154
+ ];
155
+ let placed = false;
156
+ for (const [deskMatch, runMatch] of matchers) {
157
+ const desk = lobby.findIndex(deskMatch);
158
+ if (desk >= 0) {
159
+ toDesk(desk);
160
+ placed = true;
161
+ break;
162
+ }
163
+ const run = runs.findIndex(candidate => sourceRun(candidate) !== undefined && runMatch(candidate));
164
+ if (run >= 0) {
165
+ toRun(run);
166
+ placed = true;
167
+ break;
168
+ }
169
+ }
170
+ if (placed)
171
+ continue;
172
+ const name = activity.taskId ?? activity.worktree ?? activity.kind;
173
+ lobby.push({ name, branch: "(no worktree)", sessionId: activity.sessionId ?? `activity-${activity.kind}-${activity.pid}`, sessionActive: true,
174
+ diff: { files: 0, insertions: 0, deletions: 0, paths: [], recent: null }, narration: ACTIVITY_PHASE[activity.kind],
175
+ commands: activity.taskId === undefined ? [] : [`agent-ops task status --task ${quote(activity.taskId)}`],
176
+ phase: ACTIVITY_PHASE[activity.kind], status: "active", ...(activity.taskId === undefined ? {} : { taskId: activity.taskId }), activities: [view] });
177
+ }
178
+ }
@@ -1,3 +1,4 @@
1
+ import { withActivity } from "../office/activity.js";
1
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { join } from "node:path";
@@ -233,7 +234,8 @@ export async function finishWorktree(deps, options) {
233
234
  if (record === null) {
234
235
  throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
235
236
  }
236
- return await withFinishLock(deps, commonDir, async () => {
237
+ // Display only: the integrator is at work while the finish runs.
238
+ return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
237
239
  const sessionChildren = (await listWorktrees(deps, mainRoot))
238
240
  .map(({ record: child }) => child)
239
241
  .filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
@@ -507,5 +509,5 @@ export async function finishWorktree(deps, options) {
507
509
  await markRunIntegration(commonDir, record, "cleaned", "coordinator");
508
510
  return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
509
511
  ...(receipt === undefined ? {} : { receipt: receipt.path }) };
510
- });
512
+ }));
511
513
  }
@@ -25,6 +25,14 @@ export async function runBatch(ids, deps, options) {
25
25
  throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
26
26
  }
27
27
  const progress = options.onProgress ?? (() => { });
28
+ const phase = (value) => {
29
+ try {
30
+ options.onPhase?.(value);
31
+ }
32
+ catch {
33
+ // Display only.
34
+ }
35
+ };
28
36
  const controller = new AbortController();
29
37
  const outer = options.signal;
30
38
  if (outer?.aborted === true) {
@@ -71,6 +79,7 @@ export async function runBatch(ids, deps, options) {
71
79
  entry.retried = true;
72
80
  }
73
81
  let result;
82
+ phase("reviewing");
74
83
  try {
75
84
  result = await deps.review(id, controller.signal);
76
85
  }
@@ -127,6 +136,7 @@ export async function runBatch(ids, deps, options) {
127
136
  break;
128
137
  }
129
138
  let status;
139
+ phase("verifying");
130
140
  try {
131
141
  status = await deps.verify(id, controller.signal);
132
142
  }
@@ -690,6 +690,13 @@ async function attemptTargetWithRetry(request, options) {
690
690
  return attemptTarget({ ...request, resumeLostTurn: false }, options);
691
691
  }
692
692
  /** Builds the `execute` callback with one necessary and one adversarial review. */
693
+ /** Display only: a failing listener never touches the review. */
694
+ function announceRound(options, round, target, targets) {
695
+ try {
696
+ options.onRound?.(round, target, targets);
697
+ }
698
+ catch { /* display only */ }
699
+ }
693
700
  export function createReviewExecutor(options) {
694
701
  const report = options.onProgress ?? (() => { });
695
702
  const host = detectHostTarget(options.env ?? process.env);
@@ -802,6 +809,7 @@ export function createReviewExecutor(options) {
802
809
  report("chain: review deadline reached before the first round");
803
810
  return { status: "NOT_RUN", reason: "timeout", attempts: [] };
804
811
  }
812
+ announceRound(options, 1, primaryTarget, plannedTargets);
805
813
  const primaryOutcome = await attemptTargetWithRetry({
806
814
  ...shared,
807
815
  target: primaryTarget,
@@ -911,6 +919,7 @@ export function createReviewExecutor(options) {
911
919
  };
912
920
  }
913
921
  // The second round draws on what is left, never a fresh full budget.
922
+ announceRound(options, 2, adversarialTarget, plannedTargets);
914
923
  const adversarialOutcome = await attemptTargetWithRetry({
915
924
  ...shared,
916
925
  target: adversarialTarget,
@@ -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.7.2"
239
+ version: "0.8.0"
240
240
  }
241
241
  });
242
242
  session.notify("initialized", {});
@@ -448,6 +448,13 @@ To narrow an existing installation, pass the desired list to `agent-ops update
448
448
  --harness`; shared paths remain managed while removed harness-owned artifacts,
449
449
  markers, and hooks are reconciled.
450
450
 
451
+ Update keeps the configured profiles. To add or remove the opt-in `run`
452
+ profile on an existing installation, pass `agent-ops update --auto-run on` or
453
+ `--auto-run off`; `off` removes only `run`, and the `loop` it brought stays
454
+ (use `init` to change other profiles). Without the flag, an interactive update
455
+ on macOS with Claude Code or Codex installed asks once, defaulting to the
456
+ current choice; `--yes` and non-interactive updates preserve it.
457
+
451
458
  To remove only one integrated harness, use `agent-ops uninstall --harness agy`
452
459
  (or another installed id). The remaining manifest and shared paths stay in
453
460
  place; omit `--harness` to remove the complete managed installation.
@@ -145,7 +145,10 @@ The `run` profile MUST be opt-in, select `core` and `loop` with it, and add only
145
145
  the `auto-run` capability. With it, the Claude Code and Codex managed rules hand
146
146
  a change that needs more than five acceptance criteria to `agent-ops run`; five
147
147
  or fewer stay in the session. agy rules, and every rule file without `auto-run`,
148
- MUST stay byte-identical to the rules without the profile.
148
+ MUST stay byte-identical to the rules without the profile. Update MUST preserve
149
+ the profile unless `--auto-run on|off` is supplied or an interactive update on
150
+ macOS with Claude Code or Codex asks (default: the current choice); `off`
151
+ removes only `run` and keeps `loop`.
149
152
 
150
153
  - Trigger: A project selects `run`, and a Claude Code or Codex session meets a
151
154
  change that needs more than five acceptance criteria.
@@ -221,7 +224,18 @@ Claude or Codex transcripts or file contents.
221
224
  Claude Code also gets a managed `SessionEnd` hook: when the conversation
222
225
  closes, its room closes too, even without a task. It writes no output and
223
226
  never fails the host. Other hosts publish no end event and rely on the
224
- activity timeout. A room without a task shows a no-task board rather than
227
+ activity timeout. A session that has only started is idle; a submitted
228
+ prompt or tool activity marks it active, and agy's per-invocation start
229
+ counts as activity. Outside the project loop, Claude Code and Codex get a
230
+ managed `UserPromptSubmit` hook whenever Office or command policy is enabled:
231
+ Office records the turn's start, and command policy refuses a prompt carrying
232
+ a literal credential in the host's native shape (Claude `decision: "block"`,
233
+ Codex exit code 2). Its runtime failures let the prompt through. Hooks see
234
+ only a command's start, so `agent-ops batch` reports each verify or review
235
+ as it starts to the sessions attached to its tasks (the newest step shows),
236
+ and `task advance` outside a run reports verify, review and integration to
237
+ its own session. These reports update only a known session, never fail the
238
+ command, and record nothing while Office is disabled. A room without a task shows a no-task board rather than
225
239
  pending verification and review. Session metadata contains bounded
226
240
  identifiers, known work state and activity times, never native transcripts,
227
241
  prompts or tool/file contents. Startup and observation failures are advisory.
@@ -395,6 +395,12 @@ config-hash 與 timestamp evidence,且永遠不會完成 task。Config v1 會
395
395
  `agent-ops update --harness`;shared path 會繼續受管理,被移除 harness
396
396
  擁有的 artifact、marker 與 hook 則會被安全同步。
397
397
 
398
+ Update 會保留已設定的 profile。若要在既有 installation 加入或移除 opt-in 的
399
+ `run` profile,請使用 `agent-ops update --auto-run on` 或 `--auto-run off`;
400
+ `off` 只移除 `run`,它帶入的 `loop` 會保留(其他 profile 請用 `init` 變更)。
401
+ 未提供此旗標時,在已安裝 Claude Code 或 Codex 的 macOS 上互動式 update 會詢問
402
+ 一次,預設為目前的選擇;`--yes` 與非互動式 update 會保留原設定。
403
+
398
404
  若只要移除單一已整合 harness,可使用
399
405
  `agent-ops uninstall --harness agy`(或其他已安裝 id)。剩餘 manifest 與
400
406
  shared path 會保留;省略 `--harness` 才會移除整套 managed installation。
@@ -126,7 +126,9 @@ event metadata、回傳有界且 redacted 的 session context,並在 update
126
126
  capability。啟用後,Claude Code 與 Codex 的 managed rules 會將需要超過五項
127
127
  acceptance criteria 的變更交給 `agent-ops run`;五項以內仍留在 session 中處理。
128
128
  agy rules,以及所有未含 `auto-run` 的 rule file,MUST 與未啟用此 profile 時
129
- byte-identical。
129
+ byte-identical。Update MUST 保留此 profile,除非提供 `--auto-run on|off`,或在已
130
+ 安裝 Claude Code 或 Codex 的 macOS 上互動式 update 詢問(預設為目前選擇);
131
+ `off` 只移除 `run`,保留 `loop`。
130
132
 
131
133
  - Trigger: Project 選擇 `run`,且 Claude Code 或 Codex session 遇到需要超過五項
132
134
  acceptance criteria 的變更。
@@ -189,7 +191,15 @@ base 的 `git diff`;旁白只使用路徑(`docs/**` 為「writing docs」、
189
191
  刪除該紀錄,絕不對其 pid 發送訊號。`worktree finish` 成功後會記錄 session
190
192
  完成,使房間關閉;只有新的啟動或 active task 會重新開啟。啟用 Office 時,Claude Code
191
193
  另有受管理的 `SessionEnd` hook:對話關閉時房間也隨之關閉,即使沒有 task。它不輸出
192
- 任何內容,也絕不讓 host 失敗。其他 host 沒有結束事件,依活動逾時處理。沒有 task 的
194
+ 任何內容,也絕不讓 host 失敗。其他 host 沒有結束事件,依活動逾時處理。只有啟動的
195
+ session 為閒置;送出 prompt 或 tool 活動才標為 active,agy 以 invocation 為單位
196
+ 的啟動視為活動。不在 project loop 時,只要啟用 Office 或 command policy,Claude
197
+ Code 與 Codex 會有受管理的 `UserPromptSubmit` hook:Office 記錄該輪開始,command
198
+ policy 以 host 原生格式拒絕含字面憑證的 prompt(Claude `decision: "block"`、Codex
199
+ exit code 2)。其 runtime 失敗時放行 prompt。Hook 只看得到指令開始,因此
200
+ `agent-ops batch` 在每個 verify 或 review 開始時回報給其 task 綁定的 session
201
+ (顯示最新一步),不在 run 內的 `task advance` 則把驗證、審查、整合回報給自己
202
+ 的 session。這些回報只更新已知 session,絕不讓指令失敗,Office 關閉時不記錄。沒有 task 的
193
203
  房間白板顯示「無任務」,而非待驗證與待審查。Session metadata
194
204
  只記錄有界限的識別資訊、已知工作狀態與活動時間,不包含 native transcript、
195
205
  prompt、tool 或檔案內容。啟動與活動觀察失敗均為 advisory。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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",