@kylecheng3146/agent-ops 0.6.0 → 0.7.1

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 (57) hide show
  1. package/README.md +60 -1
  2. package/dist/packages/cli/src/args.js +19 -2
  3. package/dist/packages/cli/src/bin.js +14 -4
  4. package/dist/packages/cli/src/cli.js +20 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +19 -2
  6. package/dist/packages/cli/src/commands/advance.js +19 -2
  7. package/dist/packages/cli/src/commands/init.js +2 -0
  8. package/dist/packages/cli/src/commands/office.js +20 -0
  9. package/dist/packages/cli/src/commands/review.js +10 -4
  10. package/dist/packages/cli/src/commands/run.js +6 -2
  11. package/dist/packages/cli/src/commands/update.js +8 -0
  12. package/dist/packages/cli/src/context.js +7 -5
  13. package/dist/packages/cli/src/hook-entry.js +2 -1
  14. package/dist/packages/cli/src/hook-process.js +23 -2
  15. package/dist/packages/cli/src/loop-entry.js +2 -1
  16. package/dist/packages/cli/src/office-entry.js +261 -0
  17. package/dist/packages/cli/src/parallel-deps.js +4 -2
  18. package/dist/packages/cli/src/run-deps.js +9 -2
  19. package/dist/packages/cli/src/run-entry.js +9 -0
  20. package/dist/packages/cli/src/wizard.js +14 -2
  21. package/dist/runtime/src/adapters/agy/config.js +4 -3
  22. package/dist/runtime/src/adapters/agy/events.js +9 -0
  23. package/dist/runtime/src/adapters/claude/config.js +3 -3
  24. package/dist/runtime/src/adapters/claude/events.js +8 -0
  25. package/dist/runtime/src/adapters/codex/config.js +3 -3
  26. package/dist/runtime/src/adapters/codex/events.js +8 -0
  27. package/dist/runtime/src/adapters/opencode/config.js +5 -4
  28. package/dist/runtime/src/adapters/opencode/events.js +8 -0
  29. package/dist/runtime/src/config/explain.js +5 -0
  30. package/dist/runtime/src/fs/transaction.js +2 -1
  31. package/dist/runtime/src/hooks/codex-loop.js +4 -0
  32. package/dist/runtime/src/hooks/completion-gate.js +38 -3
  33. package/dist/runtime/src/install/harness.js +44 -1
  34. package/dist/runtime/src/install/plan.js +16 -11
  35. package/dist/runtime/src/install/preauth.js +44 -18
  36. package/dist/runtime/src/install/profiles.js +8 -2
  37. package/dist/runtime/src/install/update.js +1 -0
  38. package/dist/runtime/src/office/collect.js +268 -0
  39. package/dist/runtime/src/office/page.js +887 -0
  40. package/dist/runtime/src/office/repos.js +43 -0
  41. package/dist/runtime/src/office/scene.js +181 -0
  42. package/dist/runtime/src/office/server.js +143 -0
  43. package/dist/runtime/src/office/sessions.js +130 -0
  44. package/dist/runtime/src/office/snapshot.js +117 -0
  45. package/dist/runtime/src/parallel/finish.js +3 -0
  46. package/dist/runtime/src/parallel/guard.js +7 -1
  47. package/dist/runtime/src/review/slots.js +1 -1
  48. package/dist/runtime/src/run/hosts/codex.js +1 -1
  49. package/dist/runtime/src/run/phase.js +37 -0
  50. package/dist/runtime/src/run/service.js +8 -0
  51. package/dist/runtime/src/schema/validate.js +13 -2
  52. package/dist/runtime/src/security/permissions.js +6 -0
  53. package/dist/runtime/src/verify/source-fingerprint.js +4 -0
  54. package/docs/en/spec/harness-adapters.md +122 -1
  55. package/docs/zh-TW/spec/harness-adapters.md +100 -1
  56. package/package.json +1 -1
  57. package/schemas/config.schema.json +10 -1
@@ -0,0 +1,268 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { readWorktreeRecord, parseWorktreeListPorcelain } from "../parallel/service.js";
7
+ import { FileRunRepository } from "../run/service.js";
8
+ import { criteriaProgress } from "../run/phase.js";
9
+ import { parseTaskStateSource } from "../task/store.js";
10
+ import { findReviewAttestation } from "../review/attestation.js";
11
+ import { FileEvidenceStore } from "../verify/evidence.js";
12
+ import { readPrivateFile } from "../security/permissions.js";
13
+ import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
14
+ import { buildOfficeSnapshot, mergeOfficeSnapshots } from "./snapshot.js";
15
+ import { forgetOfficeRepos, readOfficeRepos } from "./repos.js";
16
+ const run = promisify(execFile);
17
+ export const officeGit = async (cwd, args) => {
18
+ try {
19
+ return { exitCode: 0, stdout: (await run("git", [...args], { cwd, maxBuffer: 8 * 1024 * 1024, windowsHide: true })).stdout };
20
+ }
21
+ catch {
22
+ return { exitCode: 1, stdout: "" };
23
+ }
24
+ };
25
+ const MAX_PATHS = 200;
26
+ const OFFICE_PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
27
+ function officePhase(value) {
28
+ return value === undefined ? undefined : OFFICE_PHASES.has(value) ? value : "unknown";
29
+ }
30
+ function evidenceValue(value, taskId, criterionId) {
31
+ if (typeof value !== "object" || value === null || Array.isArray(value))
32
+ return null;
33
+ const evidence = value;
34
+ return evidence.taskId === taskId && evidence.criterionId === criterionId &&
35
+ (evidence.status === "PASS" || evidence.status === "FAIL" || evidence.status === "UNKNOWN")
36
+ ? value : null;
37
+ }
38
+ async function taskView(root, record) {
39
+ const store = new FileEvidenceStore(root, root);
40
+ const loaded = [];
41
+ for (const criterion of record.task.criteria) {
42
+ for (const reference of record.evidence[criterion.id] ?? []) {
43
+ if (reference.startsWith("review:"))
44
+ continue;
45
+ const item = await store.load(reference).catch(() => null);
46
+ const parsed = evidenceValue(item, record.task.id, criterion.id);
47
+ if (parsed !== null)
48
+ loaded.push({ reference, evidence: parsed });
49
+ }
50
+ }
51
+ // A task keeps its evidence history. The desk shows the newest observation
52
+ // for each verifier/acceptance phase so an old failure cannot poison a
53
+ // later PASS, and a stale review cannot remain current after a new verify.
54
+ const latest = new Map();
55
+ for (const item of loaded) {
56
+ const phase = item.evidence.acceptance?.phase ?? "command";
57
+ const key = `${item.evidence.criterionId}\0${item.evidence.commandId}\0${phase}`;
58
+ const previous = latest.get(key);
59
+ const currentAt = Date.parse(item.evidence.finishedAt);
60
+ const previousAt = previous === undefined ? Number.NEGATIVE_INFINITY : Date.parse(previous.evidence.finishedAt);
61
+ if (previous === undefined || currentAt >= previousAt)
62
+ latest.set(key, item);
63
+ }
64
+ const evidence = [...latest.values()].map(item => item.evidence);
65
+ let passed = 0;
66
+ for (const criterion of record.task.criteria) {
67
+ const rows = evidence.filter(item => item.criterionId === criterion.id);
68
+ const result = criteriaProgress([criterion], {
69
+ results: rows.map(item => ({ status: item.status })),
70
+ acceptance: rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? [])
71
+ });
72
+ passed += result.passed;
73
+ }
74
+ const total = record.task.criteria.length;
75
+ const verify = evidence.some(item => item.status === "FAIL" || item.status === "UNKNOWN")
76
+ ? "FAIL"
77
+ : total > 0 && passed === total
78
+ ? "PASS"
79
+ : null;
80
+ const newest = evidence.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null);
81
+ const review = newest !== null &&
82
+ await findReviewAttestation(root, newest.sourceFingerprint, record.task.id) !== null ? "PASS" : null;
83
+ const progress = { verify, review, passed, total };
84
+ const phase = record.status === "complete"
85
+ ? "integrating"
86
+ : review === "PASS"
87
+ ? "reviewing"
88
+ : verify !== null
89
+ ? "verifying"
90
+ : evidence.length > 0
91
+ ? "implementing"
92
+ : "planning";
93
+ return { taskId: record.task.id, status: record.status, phase, progress, completedAt: record.completedAt };
94
+ }
95
+ async function taskState(root) {
96
+ const source = await readPrivateFile(join(root, ".agent-ops", "tasks", "state.json"), root).catch(() => null);
97
+ if (source === null)
98
+ return null;
99
+ try {
100
+ return parseTaskStateSource(source);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ async function attachedTaskView(root, sessionId, fallbackTaskId) {
107
+ const state = await taskState(root);
108
+ if (state === null)
109
+ return null;
110
+ const taskId = state.sessions.find(session => session.sessionId === sessionId)?.taskId ?? fallbackTaskId;
111
+ if (taskId === undefined)
112
+ return null;
113
+ const record = state.tasks.find(task => task.task.id === taskId);
114
+ return record === undefined ? null : await taskView(root, record);
115
+ }
116
+ /** Changed paths and line counts against the worktree's base, committed or not. */
117
+ export async function worktreeDiff(git, path, base) {
118
+ // The record is a file inside the checkout; only a commit id may reach git's argument list.
119
+ const numstat = /^[0-9a-f]{40,64}$/u.test(base) ? await git(path, ["diff", "--numstat", "-z", base, "--"]) : { exitCode: 1, stdout: "" };
120
+ const untracked = await git(path, ["ls-files", "--others", "--exclude-standard", "-z"]);
121
+ let insertions = 0, deletions = 0;
122
+ const paths = [];
123
+ // -z numstat: "added\tdeleted\tpath\0", renames as "added\tdeleted\t\0from\0to\0".
124
+ const fields = numstat.stdout.split("\0");
125
+ for (let i = 0; i < fields.length; i++) {
126
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/u.exec(fields[i]);
127
+ if (match === null)
128
+ continue;
129
+ insertions += Number(match[1]) || 0;
130
+ deletions += Number(match[2]) || 0;
131
+ paths.push(match[3] === "" ? (i += 2, fields[i]) : match[3]);
132
+ }
133
+ paths.push(...untracked.stdout.split("\0").filter(p => p !== ""));
134
+ let recent = null, newest = -1;
135
+ for (const p of paths.slice(0, MAX_PATHS)) {
136
+ const mtime = await stat(join(path, p)).then(s => s.mtimeMs, () => -1);
137
+ if (mtime > newest) {
138
+ newest = mtime;
139
+ recent = p;
140
+ }
141
+ }
142
+ return { files: paths.length, insertions, deletions, paths: paths.slice(0, MAX_PATHS), recent: recent ?? paths[0] ?? null };
143
+ }
144
+ /** Each run is read on its own: one unreadable run must not hide the building. */
145
+ export async function readRuns(commonDir) {
146
+ const directory = join(commonDir, "agent-ops", "runs");
147
+ const repository = new FileRunRepository(directory, commonDir);
148
+ const names = await readdir(directory).catch(() => []);
149
+ const runs = [];
150
+ for (const name of names.filter(n => n.endsWith(".json"))) {
151
+ const state = await repository.read(basename(name, ".json")).catch(() => null);
152
+ if (state !== null)
153
+ runs.push(state);
154
+ }
155
+ return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
156
+ }
157
+ async function readSessions(mainRoot, commonDir, now, trustedRoots = [mainRoot]) {
158
+ const sessions = await readOfficeSessions(commonDir, now).catch(() => []);
159
+ const views = await Promise.all(sessions.map(async (session) => {
160
+ let task = null;
161
+ for (const root of trustedRoots) {
162
+ task = await attachedTaskView(root, session.sessionId, root === mainRoot ? session.taskId : undefined);
163
+ if (task !== null)
164
+ break;
165
+ }
166
+ const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
167
+ const phase = task === null
168
+ ? observedPhase
169
+ : task.status === "complete"
170
+ ? task.phase
171
+ : observedPhase ?? task.phase;
172
+ return task === null
173
+ ? session
174
+ : { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress,
175
+ completedAt: task.completedAt, status: task.status === "complete" ? "idle" : session.status };
176
+ }));
177
+ return views.filter(session => session.completedAt === undefined || session.completedAt === null ||
178
+ now - Date.parse(session.completedAt) <= OFFICE_SESSION_RETENTION_MS);
179
+ }
180
+ function alive(pid) {
181
+ try {
182
+ process.kill(pid, 0);
183
+ return true;
184
+ }
185
+ catch (error) {
186
+ return error.code === "EPERM";
187
+ }
188
+ }
189
+ /** Review slots held by a live process (see review/slots.ts). */
190
+ export async function readReviewSlots(commonDir) {
191
+ const slots = [];
192
+ for (const name of (await readdir(commonDir).catch(() => [])).sort()) {
193
+ const match = /^agent-ops-review-slot-(\d+)\.lock$/u.exec(name);
194
+ if (match === null)
195
+ continue;
196
+ try {
197
+ const owner = JSON.parse(await readFile(join(commonDir, name, "owner.json"), "utf8"));
198
+ if (typeof owner.pid !== "number" || typeof owner.at !== "number" || !alive(owner.pid))
199
+ continue;
200
+ slots.push({ slot: Number(match[1]), since: new Date(owner.at).toISOString(),
201
+ taskId: typeof owner.taskId === "string" ? owner.taskId : null, root: typeof owner.root === "string" ? owner.root : null });
202
+ }
203
+ catch { /* a slot being claimed or released */ }
204
+ }
205
+ return slots;
206
+ }
207
+ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, now = Date.now()) {
208
+ const worktrees = [];
209
+ const discovered = [];
210
+ for (const entry of parseWorktreeListPorcelain((await git(mainRoot, ["worktree", "list", "--porcelain"])).stdout)) {
211
+ const path = await realpath(resolve(entry.path)).catch(() => resolve(entry.path));
212
+ const record = await readWorktreeRecord(path).catch(() => null);
213
+ if (record === null)
214
+ continue;
215
+ discovered.push({ path, record });
216
+ }
217
+ const sessions = await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)]);
218
+ for (const { path, record } of discovered) {
219
+ const session = sessions.find(item => item.sessionId === record.sessionId);
220
+ const task = await attachedTaskView(path, record.sessionId, session?.taskId);
221
+ const base = { name: record.name, path, branch: record.branch, sessionId: record.sessionId,
222
+ ...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base) };
223
+ const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
224
+ const phase = task === null
225
+ ? observedPhase
226
+ : task.status === "complete"
227
+ ? task.phase
228
+ : observedPhase ?? task.phase;
229
+ worktrees.push(task === null
230
+ ? session === undefined ? base : { ...base, ...(officePhase(session.phase) === undefined ? {} : { phase: officePhase(session.phase) }), status: session.status,
231
+ ...(session.taskId === undefined ? {} : { taskId: session.taskId }), host: session.host ?? session.harness }
232
+ : { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress,
233
+ ...(task.completedAt === null ? {} : { completedAt: task.completedAt }), host: session?.host ?? session?.harness });
234
+ }
235
+ return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir), now };
236
+ }
237
+ /**
238
+ * Every registered repository that still exists and still opts in, as one
239
+ * building. One that vanished or opted out is forgotten, and so is one that
240
+ * stayed quiet past the retention window with nothing left to show.
241
+ */
242
+ export async function collectOfficeBuilding(home, enabled, git = officeGit, now = Date.now()) {
243
+ const parts = [];
244
+ const forget = [];
245
+ const names = new Set();
246
+ for (const repo of await readOfficeRepos(home)) {
247
+ if (!await stat(repo.commonDir).then(item => item.isDirectory(), () => false) || !await enabled(repo.mainRoot).catch(() => false)) {
248
+ forget.push(repo.commonDir);
249
+ continue;
250
+ }
251
+ // One unreadable repository must not hide the rest of the building.
252
+ const snapshot = await collectOfficeInput(repo.mainRoot, repo.commonDir, git, now).then(buildOfficeSnapshot, () => null);
253
+ if (snapshot === null)
254
+ continue;
255
+ if (snapshot.runs.length + snapshot.lobby.length + snapshot.reviews.length === 0 &&
256
+ now - Date.parse(repo.lastSeenAt) >= OFFICE_SESSION_RETENTION_MS) {
257
+ forget.push(repo.commonDir);
258
+ continue;
259
+ }
260
+ let name = basename(repo.mainRoot);
261
+ if (names.has(name))
262
+ name += "~" + createHash("sha256").update(repo.commonDir).digest("hex").slice(0, 4);
263
+ names.add(name);
264
+ parts.push({ repo: name, snapshot });
265
+ }
266
+ await forgetOfficeRepos(home, forget).catch(() => undefined);
267
+ return { snapshot: mergeOfficeSnapshots(parts, now), repos: parts.length };
268
+ }