@kylecheng3146/agent-ops 0.6.0 → 0.7.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.
- package/README.md +55 -1
- package/dist/packages/cli/src/args.js +19 -2
- package/dist/packages/cli/src/bin.js +14 -4
- package/dist/packages/cli/src/cli.js +20 -2
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +19 -2
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/run.js +6 -2
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/context.js +1 -0
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +23 -2
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- package/dist/packages/cli/src/run-deps.js +9 -2
- package/dist/packages/cli/src/run-entry.js +9 -0
- package/dist/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +5 -0
- package/dist/runtime/src/hooks/codex-loop.js +4 -0
- package/dist/runtime/src/install/harness.js +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/guard.js +7 -1
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/phase.js +37 -0
- package/dist/runtime/src/run/service.js +8 -0
- package/dist/runtime/src/schema/validate.js +13 -2
- package/docs/en/spec/harness-adapters.md +113 -1
- package/docs/zh-TW/spec/harness-adapters.md +93 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +10 -1
|
@@ -305,6 +305,49 @@ function subagentInstruction(descriptor) {
|
|
|
305
305
|
return "Delegate it with this harness's subagent or task tool when it has one; otherwise run the same bounded steps sequentially in this session.";
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* The `run` profile's hand-off of feature-sized work to `agent-ops run`.
|
|
310
|
+
* agy keeps the subtask flow: `run` launches only Claude Code or Codex.
|
|
311
|
+
* AGENTS.md is shared by Codex and opencode, so it carries the Codex form.
|
|
312
|
+
*/
|
|
313
|
+
function autoRunInstruction(descriptor, context) {
|
|
314
|
+
const file = descriptor.control.instructionFile;
|
|
315
|
+
if (!context.capabilities.includes("auto-run") || file === "GEMINI.md") {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
const host = file === "CLAUDE.md" ? "claude" : "codex";
|
|
319
|
+
const start = `agent-ops run --goal-file .agent-ops/state/run-goal.md --host ${host} --wait`;
|
|
320
|
+
return [
|
|
321
|
+
"With the `run` profile, a change that needs more than five acceptance",
|
|
322
|
+
"criteria goes to `agent-ops run` instead of being split here; five or",
|
|
323
|
+
"fewer stay in this session. From the main checkout, before any `task",
|
|
324
|
+
"create` (its worktree makes `run` refuse with `WORKTREE_NESTED`), write",
|
|
325
|
+
"the user's prompt verbatim, then your proposed acceptance criteria marked",
|
|
326
|
+
"as proposals, to `.agent-ops/state/run-goal.md` (gitignored), and start",
|
|
327
|
+
...(host === "claude"
|
|
328
|
+
? [`\`${start}\``, "as a background shell command."]
|
|
329
|
+
: [
|
|
330
|
+
"it as a background shell command through the trusted outer host",
|
|
331
|
+
"runner, like review: the outer request uses",
|
|
332
|
+
"`sandbox_permissions: \"require_escalated\"`, and the shell form is",
|
|
333
|
+
`\`env -u CODEX_SANDBOX_NETWORK_DISABLED ${start}\`.`
|
|
334
|
+
]),
|
|
335
|
+
"When it returns awaiting input, relay the question to the user, run",
|
|
336
|
+
"`agent-ops run respond <id> --question-id <q> --answer <text>`, and poll",
|
|
337
|
+
"`agent-ops run status <id>` until it finishes or asks again; on",
|
|
338
|
+
"completion report the result. If the start is refused with",
|
|
339
|
+
"`RUN_TARGET_REQUIRED`, `RUN_BACKGROUND_UNSUPPORTED`, `RUN_TARGET_DIRTY` or",
|
|
340
|
+
"`WORKTREE_NESTED`, say so in one line naming the code and fall back to",
|
|
341
|
+
"the subtask flow (`task create --parent`, then batch or advance).",
|
|
342
|
+
"`RUN_REPO_UNTRUSTED` means stop and ask the user; never work",
|
|
343
|
+
"around trust. If the run ends without completing (blocked, budget",
|
|
344
|
+
"exhausted, stopped, failing review), report the `agent-ops run status",
|
|
345
|
+
"<id>` state and code, the last `agent-ops run logs <id>` events, and the",
|
|
346
|
+
"exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
|
|
347
|
+
"then stop: never resume automatically or take over the run's work.",
|
|
348
|
+
""
|
|
349
|
+
];
|
|
350
|
+
}
|
|
308
351
|
export function managedRules(descriptor, context) {
|
|
309
352
|
const lines = [
|
|
310
353
|
"# Loop Engineering",
|
|
@@ -322,7 +365,7 @@ export function managedRules(descriptor, context) {
|
|
|
322
365
|
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
323
366
|
}
|
|
324
367
|
if (context.capabilities.includes("task")) {
|
|
325
|
-
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
|
|
368
|
+
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", ...autoRunInstruction(descriptor, context), "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
|
|
326
369
|
}
|
|
327
370
|
if (context.capabilities.includes("lifecycle-summary")) {
|
|
328
371
|
lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
|
|
@@ -79,7 +79,7 @@ async function detectVerificationCommands(root) {
|
|
|
79
79
|
blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
|
|
82
|
+
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree, officeEnabled) {
|
|
83
83
|
// Absent reviewRoles means external review is disabled; an empty selection
|
|
84
84
|
// must therefore omit the field rather than write an empty array.
|
|
85
85
|
const reviewRoles = reviewTargets.length > 0
|
|
@@ -96,13 +96,14 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
96
96
|
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
97
97
|
profiles: [...profiles],
|
|
98
98
|
verification,
|
|
99
|
-
features:
|
|
100
|
-
|
|
101
|
-
enabled: false
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
features: {
|
|
100
|
+
...(existing?.features ?? {
|
|
101
|
+
stopVerification: { enabled: false },
|
|
102
|
+
completionGate: { enabled: completionGateEnabled }
|
|
103
|
+
}),
|
|
104
|
+
...(officeEnabled === undefined
|
|
105
|
+
? (existing === undefined ? { office: { enabled: false } } : {})
|
|
106
|
+
: { office: { enabled: officeEnabled } })
|
|
106
107
|
},
|
|
107
108
|
pathMappings: existing?.pathMappings ?? [],
|
|
108
109
|
securityExceptions: existing?.securityExceptions ?? [],
|
|
@@ -110,7 +111,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
110
111
|
...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
|
-
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
|
|
114
|
+
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree, officeEnabled) {
|
|
114
115
|
const current = await readCurrentFile(root, CONFIG_PATH);
|
|
115
116
|
const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
|
|
116
117
|
if (current !== null && owned === undefined) {
|
|
@@ -146,7 +147,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
146
147
|
existingConfig.verification.commands.length === 0
|
|
147
148
|
? await detectVerificationCommands(root)
|
|
148
149
|
: { commands: [], blockers: [] };
|
|
149
|
-
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
|
|
150
|
+
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree, officeEnabled);
|
|
150
151
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
151
152
|
return {
|
|
152
153
|
operation: {
|
|
@@ -437,6 +438,10 @@ export async function createInstallPlan(options) {
|
|
|
437
438
|
await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
|
|
438
439
|
const existing = await readExistingManifest(options.root);
|
|
439
440
|
assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
|
|
441
|
+
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree, options.officeEnabled);
|
|
442
|
+
resolved.capabilities = resolved.capabilities.filter(capability => capability !== "office-presence");
|
|
443
|
+
if (config.config.features.office?.enabled === true)
|
|
444
|
+
resolved.capabilities.push("office-presence");
|
|
440
445
|
const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
|
|
441
446
|
const explicitHookTargets = new Map();
|
|
442
447
|
for (const target of options.hookTargets ?? []) {
|
|
@@ -520,7 +525,6 @@ export async function createInstallPlan(options) {
|
|
|
520
525
|
: [];
|
|
521
526
|
const operations = [];
|
|
522
527
|
const artifacts = [];
|
|
523
|
-
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
|
|
524
528
|
operations.push(config.operation);
|
|
525
529
|
artifacts.push(config.record);
|
|
526
530
|
const repaired = [];
|
|
@@ -634,6 +638,7 @@ export async function createInstallPlan(options) {
|
|
|
634
638
|
scope: options.scope,
|
|
635
639
|
harness: options.harness,
|
|
636
640
|
desired: config.config.worktree?.mode === "auto",
|
|
641
|
+
run: resolved.profiles.includes("run"),
|
|
637
642
|
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
|
|
638
643
|
});
|
|
639
644
|
return {
|
|
@@ -40,6 +40,9 @@ export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
|
40
40
|
"npx lint-staged",
|
|
41
41
|
"npx lint-staged *"
|
|
42
42
|
];
|
|
43
|
+
/** Added with the `run` profile: `agent-ops run` starts a launchd supervisor. */
|
|
44
|
+
export const CLAUDE_PREAUTH_RUN_ALLOW = ["Bash(agent-ops run *)"];
|
|
45
|
+
export const CLAUDE_PREAUTH_RUN_UNSANDBOXED = ["agent-ops run", "agent-ops run *"];
|
|
43
46
|
export const CODEX_RULES_PATH = "rules/agent-ops.rules";
|
|
44
47
|
export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
|
|
45
48
|
const HOSTS = ["AGENT_OPS_HOST=codex", "AGENT_OPS_HOST=claude", "AGENT_OPS_HOST=agy"];
|
|
@@ -129,6 +132,23 @@ export const CODEX_RULES_CONTENT = `${[
|
|
|
129
132
|
];
|
|
130
133
|
})
|
|
131
134
|
].join("\n")}\n`;
|
|
135
|
+
/**
|
|
136
|
+
* The `run` profile's rule: one argv prefix covers bare `agent-ops run` and
|
|
137
|
+
* every `agent-ops run *` shape the rules tell Codex to start escalated.
|
|
138
|
+
*/
|
|
139
|
+
export const CODEX_RUN_RULE = rule({
|
|
140
|
+
withHost: false,
|
|
141
|
+
command: ["run"],
|
|
142
|
+
justification: "agent-ops run starts a launchd supervisor and reviews, which need network and loopback outside the sandbox.",
|
|
143
|
+
match: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops run --goal-file .agent-ops/state/run-goal.md --host codex --wait",
|
|
144
|
+
notMatch: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops trust grant --scope project --yes"
|
|
145
|
+
});
|
|
146
|
+
/** The rules file for worktree auto mode and the `run` profile, or null for neither. */
|
|
147
|
+
export function codexRulesContent(worktree, run) {
|
|
148
|
+
if (!run)
|
|
149
|
+
return worktree ? CODEX_RULES_CONTENT : null;
|
|
150
|
+
return `${worktree ? CODEX_RULES_CONTENT : `${CODEX_RULES_MARKER}\n`}${CODEX_RUN_RULE}\n`;
|
|
151
|
+
}
|
|
132
152
|
export function codexHomeDirectory(env = process.env) {
|
|
133
153
|
return env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
|
|
134
154
|
}
|
|
@@ -165,7 +185,7 @@ function stringList(value, where) {
|
|
|
165
185
|
* settings. Nothing else in the file is touched. `null` content means the file
|
|
166
186
|
* held nothing but these entries and should go; `undefined` means no change.
|
|
167
187
|
*/
|
|
168
|
-
export function planClaudeLocalSettings(source, desired) {
|
|
188
|
+
export function planClaudeLocalSettings(source, desired, run = false) {
|
|
169
189
|
let settings = {};
|
|
170
190
|
if (source !== null && source.trim() !== "") {
|
|
171
191
|
let parsed;
|
|
@@ -188,11 +208,14 @@ export function planClaudeLocalSettings(source, desired) {
|
|
|
188
208
|
}
|
|
189
209
|
const allow = stringList(permissions.allow, "permissions.allow");
|
|
190
210
|
const excluded = stringList(sandbox.excludedCommands, "sandbox.excludedCommands");
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
// Run entries leave with the profile, so an update without it strips them.
|
|
212
|
+
const merge = (current, managed, runManaged) => {
|
|
213
|
+
const wanted = [...(desired ? managed : []), ...(run ? runManaged : [])];
|
|
214
|
+
const kept = current.filter((entry) => wanted.includes(entry) || ![...managed, ...runManaged].includes(entry));
|
|
215
|
+
return [...kept, ...wanted.filter((entry) => !kept.includes(entry))];
|
|
216
|
+
};
|
|
217
|
+
const nextAllow = merge(allow, CLAUDE_PREAUTH_ALLOW, CLAUDE_PREAUTH_RUN_ALLOW);
|
|
218
|
+
const nextExcluded = merge(excluded, CLAUDE_PREAUTH_UNSANDBOXED, CLAUDE_PREAUTH_RUN_UNSANDBOXED);
|
|
196
219
|
if (nextAllow.length === allow.length && nextExcluded.length === excluded.length &&
|
|
197
220
|
nextAllow.every((entry, index) => entry === allow[index]) &&
|
|
198
221
|
nextExcluded.every((entry, index) => entry === excluded[index])) {
|
|
@@ -218,15 +241,16 @@ export function planClaudeLocalSettings(source, desired) {
|
|
|
218
241
|
content: Object.keys(next).length === 0 ? null : `${JSON.stringify(next, null, 2)}\n`
|
|
219
242
|
};
|
|
220
243
|
}
|
|
221
|
-
async function claudeChange(root, desired) {
|
|
244
|
+
async function claudeChange(root, desired, run) {
|
|
222
245
|
const current = await readCurrent(root, CLAUDE_LOCAL_SETTINGS_PATH);
|
|
223
|
-
const planned = planClaudeLocalSettings(current?.content ?? null, desired);
|
|
246
|
+
const planned = planClaudeLocalSettings(current?.content ?? null, desired, run);
|
|
224
247
|
if (planned === undefined) {
|
|
225
248
|
return undefined;
|
|
226
249
|
}
|
|
227
|
-
const summary = desired
|
|
250
|
+
const summary = desired || run
|
|
228
251
|
? `Adds to ${CLAUDE_LOCAL_SETTINGS_PATH} (existing entries kept): permissions.allow ` +
|
|
229
|
-
`${JSON.stringify(CLAUDE_PREAUTH_ALLOW)}; sandbox.excludedCommands
|
|
252
|
+
`${JSON.stringify([...(desired ? CLAUDE_PREAUTH_ALLOW : []), ...(run ? CLAUDE_PREAUTH_RUN_ALLOW : [])])}; sandbox.excludedCommands ` +
|
|
253
|
+
`${JSON.stringify([...(desired ? CLAUDE_PREAUTH_UNSANDBOXED : []), ...(run ? CLAUDE_PREAUTH_RUN_UNSANDBOXED : [])])}.`
|
|
230
254
|
: `Removes the agent-ops entries from ${CLAUDE_LOCAL_SETTINGS_PATH}, keeping everything else.`;
|
|
231
255
|
if (planned.content === null) {
|
|
232
256
|
return current === null
|
|
@@ -250,7 +274,8 @@ async function claudeChange(root, desired) {
|
|
|
250
274
|
ownedContent: false
|
|
251
275
|
};
|
|
252
276
|
}
|
|
253
|
-
async function codexChange(codexHome, desired) {
|
|
277
|
+
async function codexChange(codexHome, desired, run) {
|
|
278
|
+
const content = codexRulesContent(desired, run);
|
|
254
279
|
const isDirectory = await lstat(codexHome).then((status) => status.isDirectory(), () => false);
|
|
255
280
|
if (!isDirectory) {
|
|
256
281
|
return undefined;
|
|
@@ -261,7 +286,7 @@ async function codexChange(codexHome, desired) {
|
|
|
261
286
|
if (current !== null && !current.content.startsWith(CODEX_RULES_MARKER)) {
|
|
262
287
|
return undefined;
|
|
263
288
|
}
|
|
264
|
-
if (
|
|
289
|
+
if (content === null) {
|
|
265
290
|
return current === null
|
|
266
291
|
? undefined
|
|
267
292
|
: {
|
|
@@ -271,37 +296,38 @@ async function codexChange(codexHome, desired) {
|
|
|
271
296
|
ownedContent: true
|
|
272
297
|
};
|
|
273
298
|
}
|
|
274
|
-
return current?.content ===
|
|
299
|
+
return current?.content === content
|
|
275
300
|
? undefined
|
|
276
301
|
: {
|
|
277
302
|
root: codexHome,
|
|
278
303
|
operation: {
|
|
279
304
|
kind: "write",
|
|
280
305
|
path: CODEX_RULES_PATH,
|
|
281
|
-
content
|
|
306
|
+
content,
|
|
282
307
|
expectedHash: current?.hash ?? null
|
|
283
308
|
},
|
|
284
|
-
summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review, batch, doctor --check-auth, worktree commit and lint-staged.`,
|
|
309
|
+
summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for ${desired ? `agent-ops review, batch, ${run ? "run, " : ""}doctor --check-auth, worktree commit and lint-staged` : "agent-ops run"}.`,
|
|
285
310
|
ownedContent: true
|
|
286
311
|
};
|
|
287
312
|
}
|
|
288
313
|
/**
|
|
289
314
|
* What init, update and uninstall change beyond the manifest: the local Claude
|
|
290
315
|
* settings entries and the Codex rules file. Project scope only; `desired`
|
|
291
|
-
* false
|
|
316
|
+
* (worktree auto mode) and `run` false plan the removal of what agent-ops wrote.
|
|
292
317
|
*/
|
|
293
318
|
export async function planPreauth(options) {
|
|
319
|
+
const run = options.run === true;
|
|
294
320
|
if (options.scope !== "project") {
|
|
295
321
|
return [];
|
|
296
322
|
}
|
|
297
323
|
const changes = [];
|
|
298
324
|
if (options.harness.includes("claude")) {
|
|
299
|
-
changes.push(await claudeChange(options.root, options.desired));
|
|
325
|
+
changes.push(await claudeChange(options.root, options.desired, run));
|
|
300
326
|
}
|
|
301
327
|
// Codex's home is outside the project, so it is only touched when the caller
|
|
302
328
|
// names it (the CLI does): planning alone must never reach into a real home.
|
|
303
329
|
if (options.harness.includes("codex") && options.codexHome !== undefined) {
|
|
304
|
-
changes.push(await codexChange(options.codexHome, options.desired));
|
|
330
|
+
changes.push(await codexChange(options.codexHome, options.desired, run));
|
|
305
331
|
}
|
|
306
332
|
return changes.filter((change) => change !== undefined);
|
|
307
333
|
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
-
const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop"];
|
|
2
|
+
const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop", "run"];
|
|
3
3
|
export const PROFILE_CAPABILITIES = {
|
|
4
4
|
core: ["rules", "task", "verify", "review"],
|
|
5
5
|
advisory: ["lifecycle-summary", "local-log"],
|
|
6
6
|
guardrails: ["command-policy"],
|
|
7
|
-
loop: ["project-loop"]
|
|
7
|
+
loop: ["project-loop"],
|
|
8
|
+
run: ["auto-run"]
|
|
8
9
|
};
|
|
9
10
|
export function resolveProfiles(inputProfiles) {
|
|
10
11
|
if (inputProfiles.length === 0) {
|
|
11
12
|
throw new AgentOpsError("PROFILE_REQUIRED", "At least one installation profile is required.");
|
|
12
13
|
}
|
|
13
14
|
const selectedProfiles = new Set(inputProfiles);
|
|
15
|
+
if (selectedProfiles.has("run")) {
|
|
16
|
+
selectedProfiles.add("loop");
|
|
17
|
+
}
|
|
14
18
|
if (selectedProfiles.has("guardrails") ||
|
|
15
19
|
selectedProfiles.has("loop")) {
|
|
16
20
|
selectedProfiles.add("core");
|
|
@@ -30,6 +34,8 @@ export function resolveProfiles(inputProfiles) {
|
|
|
30
34
|
}
|
|
31
35
|
export function resolveCapabilities(config) {
|
|
32
36
|
const resolved = resolveProfiles(config.profiles);
|
|
37
|
+
if (config.features.office?.enabled === true)
|
|
38
|
+
resolved.capabilities.push("office-presence");
|
|
33
39
|
if (config.features.stopVerification.enabled) {
|
|
34
40
|
resolved.capabilities.push("optional-stop-verify");
|
|
35
41
|
}
|
|
@@ -114,6 +114,7 @@ export async function createUpdatePlan(options) {
|
|
|
114
114
|
adapters: options.adapters,
|
|
115
115
|
toolkitVersion: options.toolkitVersion ?? targetVersion,
|
|
116
116
|
allowHarnessChange: true,
|
|
117
|
+
...(options.officeEnabled === undefined ? {} : { officeEnabled: options.officeEnabled }),
|
|
117
118
|
...(options.hookRuntimePath === undefined
|
|
118
119
|
? {}
|
|
119
120
|
: { hookRuntimePath: options.hookRuntimePath }),
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { readWorktreeRecord, parseWorktreeListPorcelain } from "../parallel/service.js";
|
|
6
|
+
import { FileRunRepository } from "../run/service.js";
|
|
7
|
+
import { criteriaProgress } from "../run/phase.js";
|
|
8
|
+
import { parseTaskStateSource } from "../task/store.js";
|
|
9
|
+
import { findReviewAttestation } from "../review/attestation.js";
|
|
10
|
+
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
11
|
+
import { readPrivateFile } from "../security/permissions.js";
|
|
12
|
+
import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
export const officeGit = async (cwd, args) => {
|
|
15
|
+
try {
|
|
16
|
+
return { exitCode: 0, stdout: (await run("git", [...args], { cwd, maxBuffer: 8 * 1024 * 1024 })).stdout };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { exitCode: 1, stdout: "" };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const MAX_PATHS = 200;
|
|
23
|
+
const OFFICE_PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
|
|
24
|
+
function officePhase(value) {
|
|
25
|
+
return value === undefined ? undefined : OFFICE_PHASES.has(value) ? value : "unknown";
|
|
26
|
+
}
|
|
27
|
+
function evidenceValue(value, taskId, criterionId) {
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
29
|
+
return null;
|
|
30
|
+
const evidence = value;
|
|
31
|
+
return evidence.taskId === taskId && evidence.criterionId === criterionId &&
|
|
32
|
+
(evidence.status === "PASS" || evidence.status === "FAIL" || evidence.status === "UNKNOWN")
|
|
33
|
+
? value : null;
|
|
34
|
+
}
|
|
35
|
+
async function taskView(root, record) {
|
|
36
|
+
const store = new FileEvidenceStore(root, root);
|
|
37
|
+
const loaded = [];
|
|
38
|
+
for (const criterion of record.task.criteria) {
|
|
39
|
+
for (const reference of record.evidence[criterion.id] ?? []) {
|
|
40
|
+
if (reference.startsWith("review:"))
|
|
41
|
+
continue;
|
|
42
|
+
const item = await store.load(reference).catch(() => null);
|
|
43
|
+
const parsed = evidenceValue(item, record.task.id, criterion.id);
|
|
44
|
+
if (parsed !== null)
|
|
45
|
+
loaded.push({ reference, evidence: parsed });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A task keeps its evidence history. The desk shows the newest observation
|
|
49
|
+
// for each verifier/acceptance phase so an old failure cannot poison a
|
|
50
|
+
// later PASS, and a stale review cannot remain current after a new verify.
|
|
51
|
+
const latest = new Map();
|
|
52
|
+
for (const item of loaded) {
|
|
53
|
+
const phase = item.evidence.acceptance?.phase ?? "command";
|
|
54
|
+
const key = `${item.evidence.criterionId}\0${item.evidence.commandId}\0${phase}`;
|
|
55
|
+
const previous = latest.get(key);
|
|
56
|
+
const currentAt = Date.parse(item.evidence.finishedAt);
|
|
57
|
+
const previousAt = previous === undefined ? Number.NEGATIVE_INFINITY : Date.parse(previous.evidence.finishedAt);
|
|
58
|
+
if (previous === undefined || currentAt >= previousAt)
|
|
59
|
+
latest.set(key, item);
|
|
60
|
+
}
|
|
61
|
+
const evidence = [...latest.values()].map(item => item.evidence);
|
|
62
|
+
let passed = 0;
|
|
63
|
+
for (const criterion of record.task.criteria) {
|
|
64
|
+
const rows = evidence.filter(item => item.criterionId === criterion.id);
|
|
65
|
+
const result = criteriaProgress([criterion], {
|
|
66
|
+
results: rows.map(item => ({ status: item.status })),
|
|
67
|
+
acceptance: rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? [])
|
|
68
|
+
});
|
|
69
|
+
passed += result.passed;
|
|
70
|
+
}
|
|
71
|
+
const total = record.task.criteria.length;
|
|
72
|
+
const verify = evidence.some(item => item.status === "FAIL" || item.status === "UNKNOWN")
|
|
73
|
+
? "FAIL"
|
|
74
|
+
: total > 0 && passed === total
|
|
75
|
+
? "PASS"
|
|
76
|
+
: null;
|
|
77
|
+
const newest = evidence.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null);
|
|
78
|
+
const review = newest !== null &&
|
|
79
|
+
await findReviewAttestation(root, newest.sourceFingerprint, record.task.id) !== null ? "PASS" : null;
|
|
80
|
+
const progress = { verify, review, passed, total };
|
|
81
|
+
const phase = record.status === "complete"
|
|
82
|
+
? "integrating"
|
|
83
|
+
: review === "PASS"
|
|
84
|
+
? "reviewing"
|
|
85
|
+
: verify !== null
|
|
86
|
+
? "verifying"
|
|
87
|
+
: evidence.length > 0
|
|
88
|
+
? "implementing"
|
|
89
|
+
: "planning";
|
|
90
|
+
return { taskId: record.task.id, status: record.status, phase, progress, completedAt: record.completedAt };
|
|
91
|
+
}
|
|
92
|
+
async function taskState(root) {
|
|
93
|
+
const source = await readPrivateFile(join(root, ".agent-ops", "tasks", "state.json"), root).catch(() => null);
|
|
94
|
+
if (source === null)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
return parseTaskStateSource(source);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function attachedTaskView(root, sessionId, fallbackTaskId) {
|
|
104
|
+
const state = await taskState(root);
|
|
105
|
+
if (state === null)
|
|
106
|
+
return null;
|
|
107
|
+
const taskId = state.sessions.find(session => session.sessionId === sessionId)?.taskId ?? fallbackTaskId;
|
|
108
|
+
if (taskId === undefined)
|
|
109
|
+
return null;
|
|
110
|
+
const record = state.tasks.find(task => task.task.id === taskId);
|
|
111
|
+
return record === undefined ? null : await taskView(root, record);
|
|
112
|
+
}
|
|
113
|
+
/** Changed paths and line counts against the worktree's base, committed or not. */
|
|
114
|
+
export async function worktreeDiff(git, path, base) {
|
|
115
|
+
// The record is a file inside the checkout; only a commit id may reach git's argument list.
|
|
116
|
+
const numstat = /^[0-9a-f]{40,64}$/u.test(base) ? await git(path, ["diff", "--numstat", "-z", base, "--"]) : { exitCode: 1, stdout: "" };
|
|
117
|
+
const untracked = await git(path, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
118
|
+
let insertions = 0, deletions = 0;
|
|
119
|
+
const paths = [];
|
|
120
|
+
// -z numstat: "added\tdeleted\tpath\0", renames as "added\tdeleted\t\0from\0to\0".
|
|
121
|
+
const fields = numstat.stdout.split("\0");
|
|
122
|
+
for (let i = 0; i < fields.length; i++) {
|
|
123
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/u.exec(fields[i]);
|
|
124
|
+
if (match === null)
|
|
125
|
+
continue;
|
|
126
|
+
insertions += Number(match[1]) || 0;
|
|
127
|
+
deletions += Number(match[2]) || 0;
|
|
128
|
+
paths.push(match[3] === "" ? (i += 2, fields[i]) : match[3]);
|
|
129
|
+
}
|
|
130
|
+
paths.push(...untracked.stdout.split("\0").filter(p => p !== ""));
|
|
131
|
+
let recent = null, newest = -1;
|
|
132
|
+
for (const p of paths.slice(0, MAX_PATHS)) {
|
|
133
|
+
const mtime = await stat(join(path, p)).then(s => s.mtimeMs, () => -1);
|
|
134
|
+
if (mtime > newest) {
|
|
135
|
+
newest = mtime;
|
|
136
|
+
recent = p;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { files: paths.length, insertions, deletions, paths: paths.slice(0, MAX_PATHS), recent: recent ?? paths[0] ?? null };
|
|
140
|
+
}
|
|
141
|
+
/** Each run is read on its own: one unreadable run must not hide the building. */
|
|
142
|
+
export async function readRuns(commonDir) {
|
|
143
|
+
const directory = join(commonDir, "agent-ops", "runs");
|
|
144
|
+
const repository = new FileRunRepository(directory, commonDir);
|
|
145
|
+
const names = await readdir(directory).catch(() => []);
|
|
146
|
+
const runs = [];
|
|
147
|
+
for (const name of names.filter(n => n.endsWith(".json"))) {
|
|
148
|
+
const state = await repository.read(basename(name, ".json")).catch(() => null);
|
|
149
|
+
if (state !== null)
|
|
150
|
+
runs.push(state);
|
|
151
|
+
}
|
|
152
|
+
return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
153
|
+
}
|
|
154
|
+
async function readSessions(mainRoot, commonDir, now, trustedRoots = [mainRoot]) {
|
|
155
|
+
const sessions = await readOfficeSessions(commonDir, now).catch(() => []);
|
|
156
|
+
const views = await Promise.all(sessions.map(async (session) => {
|
|
157
|
+
let task = null;
|
|
158
|
+
for (const root of trustedRoots) {
|
|
159
|
+
task = await attachedTaskView(root, session.sessionId, root === mainRoot ? session.taskId : undefined);
|
|
160
|
+
if (task !== null)
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
|
|
164
|
+
const phase = task === null
|
|
165
|
+
? observedPhase
|
|
166
|
+
: task.status === "complete"
|
|
167
|
+
? task.phase
|
|
168
|
+
: observedPhase ?? task.phase;
|
|
169
|
+
return task === null
|
|
170
|
+
? session
|
|
171
|
+
: { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress,
|
|
172
|
+
completedAt: task.completedAt, status: task.status === "complete" ? "idle" : session.status };
|
|
173
|
+
}));
|
|
174
|
+
return views.filter(session => session.completedAt === undefined || session.completedAt === null ||
|
|
175
|
+
now - Date.parse(session.completedAt) <= OFFICE_SESSION_RETENTION_MS);
|
|
176
|
+
}
|
|
177
|
+
function alive(pid) {
|
|
178
|
+
try {
|
|
179
|
+
process.kill(pid, 0);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return error.code === "EPERM";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Review slots held by a live process (see review/slots.ts). */
|
|
187
|
+
export async function readReviewSlots(commonDir) {
|
|
188
|
+
const slots = [];
|
|
189
|
+
for (const name of (await readdir(commonDir).catch(() => [])).sort()) {
|
|
190
|
+
const match = /^agent-ops-review-slot-(\d+)\.lock$/u.exec(name);
|
|
191
|
+
if (match === null)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
const owner = JSON.parse(await readFile(join(commonDir, name, "owner.json"), "utf8"));
|
|
195
|
+
if (typeof owner.pid !== "number" || typeof owner.at !== "number" || !alive(owner.pid))
|
|
196
|
+
continue;
|
|
197
|
+
slots.push({ slot: Number(match[1]), since: new Date(owner.at).toISOString(),
|
|
198
|
+
taskId: typeof owner.taskId === "string" ? owner.taskId : null, root: typeof owner.root === "string" ? owner.root : null });
|
|
199
|
+
}
|
|
200
|
+
catch { /* a slot being claimed or released */ }
|
|
201
|
+
}
|
|
202
|
+
return slots;
|
|
203
|
+
}
|
|
204
|
+
export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, now = Date.now()) {
|
|
205
|
+
const worktrees = [];
|
|
206
|
+
const discovered = [];
|
|
207
|
+
for (const entry of parseWorktreeListPorcelain((await git(mainRoot, ["worktree", "list", "--porcelain"])).stdout)) {
|
|
208
|
+
const path = await realpath(resolve(entry.path)).catch(() => resolve(entry.path));
|
|
209
|
+
const record = await readWorktreeRecord(path).catch(() => null);
|
|
210
|
+
if (record === null)
|
|
211
|
+
continue;
|
|
212
|
+
discovered.push({ path, record });
|
|
213
|
+
}
|
|
214
|
+
const sessions = await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)]);
|
|
215
|
+
for (const { path, record } of discovered) {
|
|
216
|
+
const session = sessions.find(item => item.sessionId === record.sessionId);
|
|
217
|
+
const task = await attachedTaskView(path, record.sessionId, session?.taskId);
|
|
218
|
+
const base = { name: record.name, path, branch: record.branch, sessionId: record.sessionId,
|
|
219
|
+
...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base) };
|
|
220
|
+
const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
|
|
221
|
+
const phase = task === null
|
|
222
|
+
? observedPhase
|
|
223
|
+
: task.status === "complete"
|
|
224
|
+
? task.phase
|
|
225
|
+
: observedPhase ?? task.phase;
|
|
226
|
+
worktrees.push(task === null
|
|
227
|
+
? session === undefined ? base : { ...base, ...(officePhase(session.phase) === undefined ? {} : { phase: officePhase(session.phase) }), status: session.status,
|
|
228
|
+
...(session.taskId === undefined ? {} : { taskId: session.taskId }), host: session.host ?? session.harness }
|
|
229
|
+
: { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress,
|
|
230
|
+
...(task.completedAt === null ? {} : { completedAt: task.completedAt }), host: session?.host ?? session?.harness });
|
|
231
|
+
}
|
|
232
|
+
return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir), now };
|
|
233
|
+
}
|