@kylecheng3146/agent-ops 0.7.3 → 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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/packages/cli/src/args.js +72 -1
  3. package/dist/packages/cli/src/bin.js +69 -10
  4. package/dist/packages/cli/src/cli.js +11 -3
  5. package/dist/packages/cli/src/codex-loop-process.js +6 -0
  6. package/dist/packages/cli/src/commands/batch.js +3 -1
  7. package/dist/packages/cli/src/commands/hook.js +4 -1
  8. package/dist/packages/cli/src/commands/review.js +9 -3
  9. package/dist/packages/cli/src/commands/task.js +44 -2
  10. package/dist/packages/cli/src/commands/update.js +18 -0
  11. package/dist/packages/cli/src/commands/verify.js +3 -1
  12. package/dist/packages/cli/src/debug-intake.js +245 -0
  13. package/dist/packages/cli/src/hook-process.js +155 -34
  14. package/dist/packages/cli/src/office-entry.js +34 -6
  15. package/dist/packages/cli/src/run-deps.js +8 -0
  16. package/dist/runtime/src/adapters/agy/config.js +3 -2
  17. package/dist/runtime/src/adapters/claude/config.js +7 -3
  18. package/dist/runtime/src/adapters/codex/config.js +10 -3
  19. package/dist/runtime/src/adapters/codex/events.js +2 -1
  20. package/dist/runtime/src/adapters/codex/input.js +20 -2
  21. package/dist/runtime/src/adapters/codex/output.js +4 -1
  22. package/dist/runtime/src/config/merge.js +1 -1
  23. package/dist/runtime/src/hooks/completion-gate.js +15 -0
  24. package/dist/runtime/src/install/harness.js +2 -0
  25. package/dist/runtime/src/install/plan.js +3 -3
  26. package/dist/runtime/src/install/profiles.js +2 -0
  27. package/dist/runtime/src/install/update.js +10 -2
  28. package/dist/runtime/src/office/activity.js +105 -0
  29. package/dist/runtime/src/office/art.js +1000 -0
  30. package/dist/runtime/src/office/collect.js +137 -10
  31. package/dist/runtime/src/office/layout.js +203 -0
  32. package/dist/runtime/src/office/page.js +883 -644
  33. package/dist/runtime/src/office/scene.js +38 -33
  34. package/dist/runtime/src/office/sessions.js +18 -1
  35. package/dist/runtime/src/office/snapshot.js +74 -1
  36. package/dist/runtime/src/parallel/finish.js +4 -2
  37. package/dist/runtime/src/parallel/manage.js +19 -2
  38. package/dist/runtime/src/review/attestation.js +4 -1
  39. package/dist/runtime/src/review/batch.js +10 -0
  40. package/dist/runtime/src/review/execute.js +10 -1
  41. package/dist/runtime/src/review/slots.js +59 -44
  42. package/dist/runtime/src/run/hosts/codex.js +1 -1
  43. package/dist/runtime/src/run/transport.js +3 -0
  44. package/dist/runtime/src/schema/validate.js +8 -2
  45. package/dist/runtime/src/security/permissions.js +3 -1
  46. package/dist/runtime/src/task/completion.js +20 -1
  47. package/dist/runtime/src/task/contract.js +1 -1
  48. package/dist/runtime/src/task/debug-intake.js +12 -0
  49. package/dist/runtime/src/task/debug-state.js +130 -0
  50. package/dist/runtime/src/task/debug-user-event.js +93 -0
  51. package/dist/runtime/src/task/debug.js +586 -0
  52. package/dist/runtime/src/task/service.js +46 -0
  53. package/dist/runtime/src/task/store.js +8 -1
  54. package/dist/runtime/src/verify/acceptance-replay.js +9 -4
  55. package/dist/runtime/src/verify/service.js +21 -2
  56. package/docs/en/guides/configuration.md +7 -0
  57. package/docs/en/guides/task-debug.md +111 -0
  58. package/docs/en/spec/harness-adapters.md +31 -4
  59. package/docs/zh-TW/guides/configuration.md +6 -0
  60. package/docs/zh-TW/guides/task-debug.md +66 -0
  61. package/docs/zh-TW/spec/harness-adapters.md +23 -4
  62. package/package.json +1 -1
  63. package/schemas/config.schema.json +30 -2
  64. package/schemas/manifest.schema.json +1 -0
  65. package/schemas/task.schema.json +62 -2
@@ -54,7 +54,7 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
54
54
  throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
55
55
  }
56
56
  const hooks = {};
57
- if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
57
+ if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) || capabilities.includes("project-loop")) {
58
58
  hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
59
59
  }
60
60
  if ((capabilities.includes("command-policy") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
@@ -62,7 +62,8 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
62
62
  }
63
63
  if (capabilities.includes("office-presence") ||
64
64
  capabilities.includes("optional-stop-verify") ||
65
- capabilities.includes("completion-gate")) {
65
+ capabilities.includes("completion-gate") ||
66
+ capabilities.includes("task-debug")) {
66
67
  hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
67
68
  }
68
69
  return { hooks };
@@ -113,8 +113,12 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
113
113
  if (capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) {
114
114
  hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
115
115
  }
116
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
117
- hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
116
+ // The loop has its own prompt hook. Outside it, a prompt is both the
117
+ // turn's start for Office and content for command policy.
118
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
119
+ hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
120
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
121
+ hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
118
122
  }
119
123
  }
120
124
  // The gate supersedes report-only Stop verification: one managed Stop
@@ -122,7 +126,7 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
122
126
  if (capabilities.includes("completion-gate")) {
123
127
  hooks.Stop = [matcherGroup("Stop", runtimePath, true)];
124
128
  }
125
- else if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
129
+ else if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
126
130
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
127
131
  }
128
132
  // Stop only ends a turn. SessionEnd is the one signal that the conversation
@@ -82,13 +82,20 @@ export function buildCodexHookConfig(capabilities, runtimePath) {
82
82
  if (capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) {
83
83
  hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
84
84
  }
85
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
86
- hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
85
+ // The loop has its own prompt hook. Outside it, a prompt is both the
86
+ // turn's start for Office and content for command policy.
87
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
88
+ hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
89
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
90
+ hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
87
91
  }
88
92
  }
89
- if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
93
+ if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
90
94
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
91
95
  }
96
+ if (capabilities.includes("task-debug")) {
97
+ hooks.Interrupt = [{ hooks: [{ ...commandHook("Interrupt", runtimePath), timeout: 3 }] }];
98
+ }
92
99
  return {
93
100
  description: "agent-ops lifecycle hooks.",
94
101
  hooks
@@ -8,7 +8,8 @@ export const CODEX_SUPPORTED_EVENTS = [
8
8
  "PostCompact",
9
9
  "SubagentStart",
10
10
  "SubagentStop",
11
- "Stop"
11
+ "Stop",
12
+ "Interrupt"
12
13
  ];
13
14
  export function codexMatcherSupport(event) {
14
15
  return event === "PreToolUse" ? "tool-name" : "unsupported";
@@ -15,17 +15,35 @@ export function normalizeCodexHookInput(input) {
15
15
  return normalizeHookEvent(input);
16
16
  }
17
17
  const projectRoot = input.cwd;
18
+ const sessionId = typeof input.session_id === "string" && input.session_id.length > 0
19
+ ? input.session_id
20
+ : undefined;
18
21
  if (input.hook_event_name === "SessionStart") {
19
- return normalizeHookEvent({
22
+ const start = normalizeHookEvent({
20
23
  event: "session-start",
21
24
  projectRoot
22
25
  });
26
+ return {
27
+ event: "session-start",
28
+ projectRoot: start.projectRoot,
29
+ ...(sessionId === undefined ? {} : { sessionId })
30
+ };
23
31
  }
24
32
  if (input.hook_event_name === "Stop") {
25
- return normalizeHookEvent({
33
+ const stop = normalizeHookEvent({
26
34
  event: "stop",
27
35
  projectRoot
28
36
  });
37
+ return {
38
+ event: "stop",
39
+ projectRoot: stop.projectRoot,
40
+ ...(sessionId === undefined ? {} : { sessionId }),
41
+ terminationReason: typeof input.termination_reason === "string"
42
+ ? input.termination_reason
43
+ : "model_stop",
44
+ // stop_hook_active means a prior continuation, not a non-idle Stop.
45
+ fullyIdle: true
46
+ };
29
47
  }
30
48
  if (input.hook_event_name === "PreToolUse" &&
31
49
  input.tool_name === "Bash") {
@@ -1,6 +1,9 @@
1
1
  export const CODEX_NON_ZERO_EXIT_BEHAVIOR = "UNKNOWN";
2
2
  export const CODEX_PRE_TOOL_BLOCKING = "UNKNOWN";
3
3
  export function codexHookOutput(event, result) {
4
+ if (event === "Stop" && result.action === "block") {
5
+ return { exitCode: 0, stdout: JSON.stringify({ decision: "block", reason: `agent-ops: ${result.code}. ${result.remedy ?? "Continue the unfinished task."}` }) };
6
+ }
4
7
  if (event === "Stop" && result.evidence !== undefined) {
5
8
  return {
6
9
  exitCode: 0,
@@ -28,7 +31,7 @@ export function codexHookOutput(event, result) {
28
31
  continue: result.action !== "block",
29
32
  ...(result.action === "block"
30
33
  ? { stopReason: result.code }
31
- : { systemMessage: `agent-ops: ${result.code}` })
34
+ : { systemMessage: `agent-ops: ${result.code}${result.remedy === undefined ? "" : ". " + result.remedy}` })
32
35
  })
33
36
  };
34
37
  }
@@ -114,7 +114,7 @@ export function mergeConfigLayers(inputLayers) {
114
114
  let worktree;
115
115
  for (const layer of layers) {
116
116
  schemaVersion = effective(layer.config.schemaVersion, layer);
117
- features = effective(layer.config.features, layer);
117
+ features = effective({ ...layer.config.features, ...(layer.config.features.debug === undefined && features?.value.debug !== undefined ? { debug: features.value.debug } : {}) }, layer);
118
118
  // Whole-block override: a workflow choice, not a guardrail, so a project
119
119
  // may turn a user-level `auto` off.
120
120
  if (layer.config.worktree !== undefined) {
@@ -260,6 +260,9 @@ export class CompletionGateService {
260
260
  }
261
261
  }
262
262
  async grantPermit(sessionId) {
263
+ const attached = await this.#options.taskService.status({ sessionId }).catch(() => null);
264
+ if (attached?.task.debug !== undefined && attached.status === "active")
265
+ throw new AgentOpsError("TASK_DEBUG_CHECKPOINT_REQUIRED", "Active debug issues require a runtime stop checkpoint; allow-stop does not grant budget or success.");
263
266
  const fingerprint = await this.#fingerprint();
264
267
  const granted = await this.#store.mutate(sessionId, (state) => {
265
268
  if (state === null) {
@@ -485,6 +488,18 @@ export class CompletionGateService {
485
488
  }
486
489
  return last;
487
490
  }
491
+ const debugTask = await this.#options.taskService.status({ sessionId }).catch(() => null);
492
+ if (debugTask?.task.debug !== undefined) {
493
+ if (debugTask.status === "active") {
494
+ await this.#options.taskService.debugService().prepareStop(debugTask.task.id, sessionId);
495
+ const valid = await this.#options.taskService.debugService().validCheckpoint(debugTask.task.id, sessionId, fingerprint);
496
+ return valid ? gateResult("continue", "UNKNOWN", "COMPLETION_GATE_TASK_DEBUG_STOPPED", "Original issue remains active with a runtime-bound non-success checkpoint.") :
497
+ gateResult("block", "FAIL", "COMPLETION_GATE_TASK_DEBUG_ACTIVE", "Continue the investigation/evidence loop or record a current runtime stop checkpoint, even when Git has no net diff.");
498
+ }
499
+ const failure = await this.#validateTask(sessionId);
500
+ if (failure !== null)
501
+ return failure;
502
+ }
488
503
  if (changed) {
489
504
  const failure = await this.#validateTask(sessionId);
490
505
  if (failure !== null)
@@ -361,6 +361,8 @@ export function managedRules(descriptor, context) {
361
361
  : [`Toolkit version: ${context.toolkitVersion}`]),
362
362
  ""
363
363
  ];
364
+ if (context.capabilities.includes("task-debug"))
365
+ lines.push("Task DEBUG routing is enabled for investigation/bugfix issues. Native prompt hooks create the issue task when a session identity is available; otherwise use agent-ops task create --debug --goal <verbatim issue> with two to five issue-specific criteria. Explain/read-only, feature and refactor requests use the existing normal task flow.", "For DEBUG: understand the issue, investigate with task debug --debug-action investigate --command-id <trusted verifier>, record falsifiable hypotheses with --debug-action hypothesis --payload-file <json>, reproduce, fix, verify, then obtain two fresh independent reviews and finish automatically. A clean HEAD is the automatic baseline; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Replace provisional intake criteria with relevant behavioral acceptance; baseline setup/skip/timeout/UNKNOWN is never reproduction. Never report success from edits or unrelated green checks.", "DEBUG defaults are 60 cumulative active minutes and 5 material repair candidates across the original issue, revisions, sessions and children. After two stagnant candidates document one substantive strategy switch; two further stagnant candidates block. Save task debug --debug-action checkpoint before stopping. Resume preserves budgets and blockers. A human continue event may authorize --debug-action extend --event-id latest; unsupported native event sources fail closed. Never use allow-stop for DEBUG.", "DEBUG human control comes only from native human events: '只診斷' / 'diagnosis only' keeps investigating without product repairs until current sealed baseline assertion FAIL confirms a hypothesis or runtime limits/blockers permit non-success Stop. '暫停' / 'pause' saves progress and stops immediately. '繼續' / 'continue' resumes the existing scope; '繼續修復' / 'resume repair' restores the full loop. Codex Interrupt is a pause. Preserve the original task, evidence, consumed time and rounds; diagnosis or pause never marks repair complete. CLI actor/text fields and synthetic instructions cannot authorize these changes.", "DEBUG is foreground work. On Claude, agy and native Codex, Stop refuses an active DEBUG issue without a valid stop checkpoint, with or without the completion gate. Codex uses decision:block to continue the original task even on repeated Stop; stop_hook_active does not waive it. Codex's generic completion gate remains unsupported; opencode has no supported foreground lifecycle. DEBUG gate faults get one targeted task/config reload, then an explicit UNKNOWN/incomplete diagnostic and non-success Stop, preserving state. Without reliable native session identity, automatic hook intake is unsupported; the existing native Agent can use the task CLI. Only a matching owned debug worktree checkpoint permits dirty recovery; preserve all original user edits.", "");
364
366
  if (context.capabilities.includes("rules")) {
365
367
  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.", "");
366
368
  }
@@ -1,4 +1,5 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
+ import { DEBUG_DEFAULTS } from "../task/debug-state.js";
2
3
  import { CONFIG_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION } from "../contracts.js";
3
4
  import { discoverProject } from "../discovery/index.js";
4
5
  import { sha256 } from "../fs/hash.js";
@@ -97,6 +98,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
97
98
  profiles: [...profiles],
98
99
  verification,
99
100
  features: {
101
+ debug: existing?.features.debug ?? { ...DEBUG_DEFAULTS },
100
102
  ...(existing?.features ?? {
101
103
  stopVerification: { enabled: false },
102
104
  completionGate: { enabled: completionGateEnabled }
@@ -439,9 +441,7 @@ export async function createInstallPlan(options) {
439
441
  const existing = await readExistingManifest(options.root);
440
442
  assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
441
443
  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");
444
+ resolved.capabilities = resolveCapabilities(config.config).capabilities;
445
445
  const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
446
446
  const explicitHookTargets = new Map();
447
447
  for (const target of options.hookTargets ?? []) {
@@ -34,6 +34,8 @@ export function resolveProfiles(inputProfiles) {
34
34
  }
35
35
  export function resolveCapabilities(config) {
36
36
  const resolved = resolveProfiles(config.profiles);
37
+ if (config.features.debug?.enabled === true)
38
+ resolved.capabilities.push("task-debug");
37
39
  if (config.features.office?.enabled === true)
38
40
  resolved.capabilities.push("office-presence");
39
41
  if (config.features.stopVerification.enabled) {
@@ -106,11 +106,19 @@ export async function createUpdatePlan(options) {
106
106
  configPreview.steps.length === 0) {
107
107
  throw new AgentOpsError("UPDATE_INSTALLATION_INVALID", "Update requires a passing config doctor check.");
108
108
  }
109
+ const current = configPreview.migrated.profiles;
110
+ // `run` implies `loop`; turning run off keeps the loop it brought, since
111
+ // the config cannot tell an implied loop from a chosen one.
112
+ const profiles = options.autoRun === true
113
+ ? current.includes("run") ? current : [...current, "run"]
114
+ : options.autoRun === false && current.includes("run")
115
+ ? [...current.filter((profile) => profile !== "run"), ...(current.includes("loop") ? [] : ["loop"])]
116
+ : current;
109
117
  const installation = await createInstallPlan({
110
118
  root: options.root,
111
119
  scope: report.manifest.scope,
112
120
  harness: options.harness ?? report.manifest.harness,
113
- profiles: configPreview.migrated.profiles,
121
+ profiles,
114
122
  adapters: options.adapters,
115
123
  toolkitVersion: options.toolkitVersion ?? targetVersion,
116
124
  allowHarnessChange: true,
@@ -122,7 +130,7 @@ export async function createUpdatePlan(options) {
122
130
  ? {}
123
131
  : { hookTargets: options.hookTargets }),
124
132
  existingConfig: {
125
- value: configPreview.migrated,
133
+ value: profiles === current ? configPreview.migrated : { ...configPreview.migrated, profiles },
126
134
  sourceHash: configPreview.sourceHash
127
135
  },
128
136
  ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
@@ -0,0 +1,105 @@
1
+ import { readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
4
+ const KINDS = new Set(["verify", "review", "finish"]);
5
+ const ID = /^[A-Za-z0-9._-]{1,128}$/u;
6
+ const TARGET = /^[a-z][a-z0-9-]{0,31}$/u;
7
+ const FILE = /^(verify|review|finish)-(\d{1,10})-(\d{1,10})\.json$/u;
8
+ const NOOP = { update: async () => { }, end: async () => { } };
9
+ export function activityDirectory(commonDir) {
10
+ return join(commonDir, "agent-ops", "office-activity");
11
+ }
12
+ /** Keeps only the allowlisted, well-formed fields; null when the record cannot stand. */
13
+ export function activityValue(value) {
14
+ if (typeof value !== "object" || value === null || Array.isArray(value))
15
+ return null;
16
+ const item = value;
17
+ if (!KINDS.has(item.kind) || typeof item.pid !== "number" || !Number.isInteger(item.pid) || item.pid <= 0 ||
18
+ typeof item.root !== "string" || item.root.length === 0 || item.root.length > 4096 ||
19
+ typeof item.startedAt !== "string" || Number.isNaN(Date.parse(item.startedAt)))
20
+ return null;
21
+ const id = (field) => typeof field === "string" && ID.test(field) ? field : undefined;
22
+ const targets = Array.isArray(item.targets) ? item.targets.filter((t) => typeof t === "string" && TARGET.test(t)).slice(0, 2) : undefined;
23
+ const round = item.round === 1 || item.round === 2 ? item.round : undefined;
24
+ const target = typeof item.target === "string" && TARGET.test(item.target) ? item.target : undefined;
25
+ const taskId = id(item.taskId), sessionId = id(item.sessionId), worktree = id(item.worktree);
26
+ return { kind: item.kind, pid: item.pid, root: item.root, startedAt: item.startedAt,
27
+ ...(taskId === undefined ? {} : { taskId }), ...(sessionId === undefined ? {} : { sessionId }), ...(worktree === undefined ? {} : { worktree }),
28
+ ...(targets === undefined || targets.length === 0 ? {} : { targets }), ...(round === undefined ? {} : { round }), ...(target === undefined ? {} : { target }) };
29
+ }
30
+ let sequence = 0;
31
+ /** Starts a record; returns a handle whose writes never throw. No common dir means no record. */
32
+ export async function beginActivity(commonDir, start, options = {}) {
33
+ if (commonDir === undefined || commonDir.length === 0)
34
+ return NOOP;
35
+ const pid = options.pid ?? process.pid, startedAt = (options.now ?? (() => new Date()))().toISOString();
36
+ let current = activityValue({ ...start, pid, startedAt });
37
+ if (current === null)
38
+ return NOOP;
39
+ // One process may run several reviews at once (batch), so the name carries a sequence too.
40
+ const path = join(activityDirectory(commonDir), `${current.kind}-${pid}-${++sequence}.json`);
41
+ // Writes run one after another in call order, so a slow early write never lands over a later one,
42
+ // and none runs after end() has removed the record.
43
+ let queue = Promise.resolve(), ended = false;
44
+ const write = () => (queue = queue.then(async () => {
45
+ if (ended)
46
+ return;
47
+ try {
48
+ await writePrivateFile(path, JSON.stringify(current) + "\n", commonDir);
49
+ }
50
+ catch { /* display only */ }
51
+ }));
52
+ await write();
53
+ return {
54
+ update: async (patch) => { const next = activityValue({ ...current, ...patch }); if (next !== null) {
55
+ current = next;
56
+ await write();
57
+ } },
58
+ end: async () => { ended = true; await queue; try {
59
+ await rm(path, { force: true });
60
+ }
61
+ catch { /* display only */ } }
62
+ };
63
+ }
64
+ /** Runs `work` with a record present; the record goes when the work ends, however it ends. */
65
+ export async function withActivity(commonDir, start, work) {
66
+ const handle = await beginActivity(commonDir, start).catch(() => NOOP);
67
+ try {
68
+ return await work(handle);
69
+ }
70
+ finally {
71
+ await handle.end().catch(() => undefined);
72
+ }
73
+ }
74
+ function processAlive(pid) {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ }
79
+ catch (error) {
80
+ return error.code === "EPERM";
81
+ }
82
+ }
83
+ /** Live records only: malformed files, files whose name disagrees with their content and dead processes are skipped. */
84
+ export async function readActivities(commonDir, alive = processAlive) {
85
+ const directory = activityDirectory(commonDir);
86
+ const names = (await readdir(directory).catch(() => [])).filter(name => FILE.test(name)).sort().slice(0, 200);
87
+ const records = [];
88
+ for (const name of names) {
89
+ const match = FILE.exec(name);
90
+ const source = await readPrivateFile(join(directory, name), commonDir).catch(() => null);
91
+ if (source === null || source.length > 16 * 1024)
92
+ continue;
93
+ let value;
94
+ try {
95
+ value = activityValue(JSON.parse(source));
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (value === null || value.kind !== match[1] || value.pid !== Number(match[2]) || !alive(value.pid))
101
+ continue;
102
+ records.push(value);
103
+ }
104
+ return records;
105
+ }