holo-codex 0.1.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.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,1479 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { relative, resolve } from "node:path";
4
+ import { readArtifact } from "./artifacts.js";
5
+ import { describeAutonomyPosture, evaluateMergeReadiness, type MergeReadiness } from "./autonomy-policy.js";
6
+ import { readConfigForEdit, saveConfigEdit } from "./config-editor.js";
7
+ import { isRecord, loadConfig, statePath } from "./config.js";
8
+ import { AgentLoopError, toErrorPayload } from "./errors.js";
9
+ import { recoverBlockedRun, TERMINAL_WORKER_GATE_KINDS } from "./gate-recovery.js";
10
+ import { detectHappy } from "./happy.js";
11
+ import { inspectHookCapture } from "./hook-capture.js";
12
+ import { resolveLoopShape } from "./loop-shapes.js";
13
+ import { deriveNotifications, type LoopNotification } from "./notification-feed.js";
14
+ import { resolvePrSelection, type PrSelection } from "./pr-selector.js";
15
+ import { applyProfileConfig, resolveProfile, workflowStages } from "./profiles.js";
16
+ import { redactSecrets } from "./redaction.js";
17
+ import { blockRunForTerminalWorker, runStateMachine, resumeStateMachine, stopStateMachine } from "./state-machine.js";
18
+ import { SqliteAgentLoopStorage } from "./storage.js";
19
+ import { executeWorker } from "./worker.js";
20
+ import { getDeliveryWorkItem, WORKFLOW_STAGE_EVIDENCE_KIND } from "./delivery-work-item.js";
21
+ import { appendWorkflowEvidence, deriveWorkflowBoard, selectWorkflowBoardRun } from "./workflow-board.js";
22
+ import type { AgentLoopConfig, AgentLoopEvent, AgentLoopGate, AgentLoopRun, AgentTimelineQuery, AgentTimelineEntry, GateDecisionInput, WorkerRun, WorkerType } from "./types.js";
23
+ import type { AgentLoopState } from "./state-types.js";
24
+
25
+ const NOTIFICATION_EVENT_LIMIT = 100;
26
+ const HISTORICAL_EVENT_SCAN_LIMIT = 100_000;
27
+ const HISTORICAL_EVENT_KIND = "historical_gate_marked_handled";
28
+ const HISTORICAL_REEVALUATED_EVENT_KIND = "historical_gate_re_evaluated";
29
+
30
+ type HistoricalGateReevaluationResult =
31
+ | "still_historical"
32
+ | "overridden_by_current_reality"
33
+ | "active_again"
34
+ | "manually_handled";
35
+
36
+ export interface McpControllerOptions {
37
+ repoRoot: string;
38
+ startRun?: (repoRoot: string, runId: string) => boolean | void;
39
+ mcpToken?: string;
40
+ }
41
+
42
+ export interface McpResult<T = unknown> {
43
+ ok: boolean;
44
+ data?: T;
45
+ error?: ReturnType<typeof toErrorPayload>;
46
+ gate?: string;
47
+ }
48
+
49
+ /** Shared control-plane API used by MCP tools and CLI gate commands. */
50
+ export class McpController {
51
+ constructor(private readonly options: McpControllerOptions) {}
52
+
53
+ loopStatus(): McpResult {
54
+ return this.withConfig(() => this.withStorage((storage) => {
55
+ this.reconcileTerminalWorker(storage);
56
+ const current = storage.getCurrentStatus();
57
+ return ok({ ...current, nextAction: nextAction(current.status, current.gate?.kind) });
58
+ }));
59
+ }
60
+
61
+ loopMissionControl(): McpResult {
62
+ return this.withConfig((config) => this.withStorage((storage) => {
63
+ this.reconcileTerminalWorker(storage);
64
+ const snapshot = storage.readTransaction(() => {
65
+ const current = storage.getCurrentStatus();
66
+ const run = current.run ?? storage.getCurrentRun();
67
+ const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
68
+ const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
69
+ const runs = storage.listRuns(20);
70
+ const dismissedHistoricalGateIds = historicalGateHandledIds(historicalEvents);
71
+ const gates = annotateGates({
72
+ gates: storage.listGates(),
73
+ current,
74
+ ...(run ? { run } : {}),
75
+ runs,
76
+ dismissedHistoricalGateIds
77
+ });
78
+ const activeGates = gates.filter((gate) => gate.activity === "active");
79
+ const currentRunGates = gates.filter((gate) => gate.activity === "active" && (gate.runId === run?.id || gate.runId === undefined));
80
+ const missionCurrent = currentForMissionControl(current, gates);
81
+ const effectiveConfig = applyProfileConfig(config);
82
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
83
+ const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
84
+ const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
85
+ const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
86
+ const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
87
+ const deliveryWorkItem = getDeliveryWorkItem(storage, run?.id);
88
+ const selection = shape.id === "pr-loop" ? resolvePrSelection(this.options.repoRoot, effectiveConfig, {
89
+ ...(deliveryWorkItem ? { workItem: deliveryWorkItem } : {})
90
+ }) : undefined;
91
+ const workers = annotateWorkers({
92
+ workers: storage.listWorkers(undefined, 20),
93
+ gates,
94
+ ...(run ? { run } : {})
95
+ });
96
+ const activeWorkers = workers.filter((worker) => worker.activity === "active");
97
+ const timeline = storage.listAgentTimeline({
98
+ limit: 20,
99
+ ...(run ? { runId: run.id } : {})
100
+ }).entries;
101
+ const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates: currentRunGates, decisions, runChecks }) : undefined;
102
+ const missionMergeReadiness = mergeReadiness ? mergeReadinessForMissionDisplay(mergeReadiness, events) : undefined;
103
+ const dismissedIds = notificationDismissedIds(events);
104
+ const notifications = deriveNotifications({ config: effectiveConfig, events, gates: activeGates, timelineEntries: timeline, workers: activeWorkers, ...(mergeReadiness ? { mergeReadiness } : {}), ...(run ? { runId: run.id } : {}), now: new Date(), dismissedIds });
105
+ return {
106
+ current: { ...missionCurrent, nextAction: nextAction(missionCurrent.status, missionCurrent.gate?.kind) },
107
+ gates,
108
+ pr: shape.id === "pr-loop" && run ? storage.getPrLink(run.id) : undefined,
109
+ ci: shape.id === "pr-loop" ? ci : [],
110
+ reviewComments: shape.id === "pr-loop" ? reviewComments : [],
111
+ workers,
112
+ artifacts: run ? storage.listArtifacts(run.id) : [],
113
+ events,
114
+ decisions,
115
+ timelineSummary: buildTimelineSummary({
116
+ timeline,
117
+ workers,
118
+ ...(run ? { currentRunId: run.id } : {}),
119
+ listWorkerEvents: (workerId) => storage.listWorkerEvents(workerId)
120
+ }),
121
+ autonomy: describeAutonomyPosture(effectiveConfig),
122
+ mergeReadiness: missionMergeReadiness,
123
+ notifications,
124
+ profile: resolveProfile(effectiveConfig, run?.currentState as AgentLoopState | undefined),
125
+ plan: selection?.plan,
126
+ selection: selection ? selectionSummary(selection) : genericSelectionSummary(effectiveConfig),
127
+ recoveryWarnings: recoveryWarnings(missionCurrent.gate?.kind, gates, workers)
128
+ };
129
+ });
130
+ return ok(snapshot);
131
+ }));
132
+ }
133
+
134
+ loopWorkflowBoard(input: { runId?: string } = {}): McpResult {
135
+ return this.withConfig((config) => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
136
+ const effectiveConfig = applyProfileConfig(config);
137
+ const run = selectWorkflowBoardRun(storage, input.runId);
138
+ const currentRun = storage.getCurrentRun();
139
+ const deliveryWorkItem = getDeliveryWorkItem(storage, run?.id);
140
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
141
+ const gates = run ? storage.listGates(run.id) : storage.listGates();
142
+ const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
143
+ const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
144
+ const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
145
+ const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
146
+ const mergeReadiness = shape.id === "pr-loop" && run
147
+ ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks })
148
+ : undefined;
149
+ return ok(deriveWorkflowBoard({
150
+ config: effectiveConfig,
151
+ ...(run ? { run } : {}),
152
+ ...(currentRun ? { currentRun } : {}),
153
+ gates,
154
+ events: storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT).filter((event) => !run || event.runId === run.id || event.runId === undefined),
155
+ workers: run ? storage.listWorkers(run.id, 20) : [],
156
+ artifacts: run ? storage.listArtifacts(run.id) : [],
157
+ pr: run && shape.id === "pr-loop" ? storage.getPrLink(run.id) : undefined,
158
+ ci,
159
+ reviewComments,
160
+ decisions,
161
+ runChecks,
162
+ ...(deliveryWorkItem ? { deliveryWorkItem } : {}),
163
+ ...(mergeReadiness ? { mergeReadiness } : {}),
164
+ hookCapture: inspectHookCapture(this.options.repoRoot)
165
+ }));
166
+ })));
167
+ }
168
+
169
+ loopAppendWorkflowEvidence(body: unknown, token?: string): McpResult {
170
+ const auth = this.requireToken(token);
171
+ if (auth) return auth;
172
+ if (!isRecord(body)) {
173
+ return fail(new AgentLoopError("invalid_config", "Workflow evidence append requires a JSON object."));
174
+ }
175
+ return this.withConfig(() => this.withStorage((storage) => ok(appendWorkflowEvidence(storage, {
176
+ runId: typeof body.runId === "string" ? body.runId : undefined,
177
+ stageId: typeof body.stageId === "string" ? body.stageId : undefined,
178
+ substageId: typeof body.substageId === "string" ? body.substageId : undefined,
179
+ summary: typeof body.summary === "string" ? body.summary : undefined,
180
+ evidenceRefIds: body.evidenceRefIds,
181
+ artifactIds: body.artifactIds,
182
+ actor: typeof body.actor === "string" ? body.actor : undefined,
183
+ status: typeof body.status === "string" ? body.status : undefined,
184
+ source: typeof body.source === "string" ? body.source : "dashboard",
185
+ review: body.review
186
+ }))));
187
+ }
188
+
189
+ loopAgentTimeline(query: AgentTimelineQuery = {}): McpResult {
190
+ return this.withConfig(() => this.withStorageReadOnly((storage) => ok(storage.listAgentTimeline(query))));
191
+ }
192
+
193
+ loopObserve(limit = 20): McpResult {
194
+ return this.withConfig((config) => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
195
+ const current = storage.getCurrentStatus();
196
+ const run = current.run ?? storage.getCurrentRun();
197
+ const timeline = storage.listAgentTimeline({
198
+ limit,
199
+ ...(run ? { runId: run.id } : {})
200
+ });
201
+ return ok({
202
+ dashboard: dashboardInfo(config),
203
+ happy: detectHappy(),
204
+ current: { ...current, nextAction: nextAction(current.status, current.gate?.kind) },
205
+ timeline
206
+ });
207
+ })));
208
+ }
209
+
210
+ loopNextAction(): McpResult {
211
+ return this.withConfig(() => this.withStorage((storage) => {
212
+ this.reconcileTerminalWorker(storage);
213
+ const current = storage.getCurrentStatus();
214
+ return ok({ nextAction: nextAction(current.status, current.gate?.kind), current });
215
+ }));
216
+ }
217
+
218
+ loopStep(token?: string): Promise<McpResult> {
219
+ const auth = this.requireToken(token);
220
+ if (auth) return Promise.resolve(auth);
221
+ return this.withConfigAsync(async () => ok(await runStateMachine({
222
+ repoRoot: this.options.repoRoot,
223
+ dryRun: false,
224
+ untilGate: false,
225
+ singleStep: true
226
+ })));
227
+ }
228
+
229
+ loopResume(token?: string): Promise<McpResult> {
230
+ const auth = this.requireToken(token);
231
+ if (auth) return Promise.resolve(auth);
232
+ return this.withConfigAsync(async () => ok(await resumeStateMachine(this.options.repoRoot)));
233
+ }
234
+
235
+ loopStop(token?: string): McpResult {
236
+ const auth = this.requireToken(token);
237
+ if (auth) return auth;
238
+ return this.withConfig(() => ok(stopStateMachine(this.options.repoRoot)));
239
+ }
240
+
241
+ loopRunUntilGate(token?: string): McpResult {
242
+ const auth = this.requireToken(token);
243
+ if (auth) return auth;
244
+ return this.withConfig((config) => this.withStorage((storage) => {
245
+ const effectiveConfig = applyProfileConfig(config);
246
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
247
+ const { run, created } = storage.getOrCreateActiveRun({ currentState: shape.initialState });
248
+ const workerGate = blockRunForTerminalWorker(storage, run);
249
+ if (workerGate) {
250
+ return ok({
251
+ runId: workerGate.runId,
252
+ status: workerGate.status,
253
+ alreadyRunning: !created,
254
+ reconciled: true,
255
+ gate: workerGate.gate
256
+ });
257
+ }
258
+ if (created) {
259
+ storage.appendEvent({
260
+ runId: run.id,
261
+ kind: "mcp_run_until_gate_started",
262
+ message: "MCP requested background run until gate."
263
+ });
264
+ const started = (this.options.startRun ?? startBackgroundRun)(this.options.repoRoot, run.id);
265
+ if (started === false) {
266
+ storage.updateRunStatus(run.id, run.version, "BLOCKED", { currentState: run.currentState ?? shape.initialState });
267
+ storage.writeGate({
268
+ runId: run.id,
269
+ kind: "required_tool_unavailable",
270
+ message: "Could not start background agent-loop run."
271
+ });
272
+ const gate = storage.listGates(run.id).find((item) => item.status === "open");
273
+ return fail(new AgentLoopError("required_tool_unavailable", "Could not start background run.", {
274
+ details: { runId: run.id, gate }
275
+ }));
276
+ }
277
+ }
278
+ return ok({ runId: run.id, status: "RUNNING", alreadyRunning: !created });
279
+ }));
280
+ }
281
+
282
+ loopListGates(): McpResult {
283
+ return this.withConfig(() => this.withStorage((storage) => {
284
+ this.reconcileTerminalWorker(storage);
285
+ return ok({ gates: annotatedGatesSnapshot(storage) });
286
+ }));
287
+ }
288
+
289
+ loopExplainGate(gateId: string): McpResult {
290
+ return this.withConfig(() => this.withStorageReadOnly((storage) => {
291
+ const gate = annotatedGateSnapshot(storage, gateId);
292
+ if (!gate) {
293
+ throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
294
+ }
295
+ return ok({ gate, nextAction: nextAction("BLOCKED", gate.kind) });
296
+ }));
297
+ }
298
+
299
+ loopApproveGate(gateId: string, input: string | GateDecisionInput, token?: string): McpResult {
300
+ return this.decideGate(gateId, "approved", input, token);
301
+ }
302
+
303
+ loopRejectGate(gateId: string, input: string | GateDecisionInput, token?: string): McpResult {
304
+ return this.decideGate(gateId, "rejected", input, token);
305
+ }
306
+
307
+ loopListRuns(limit?: number): McpResult {
308
+ return this.withConfig(() => this.withStorageReadOnly((storage) => ok({ runs: storage.listRuns(limit) })));
309
+ }
310
+
311
+ loopListWorkers(input?: number | { limit?: number; workerId?: string; includeEvents?: boolean }): McpResult {
312
+ return this.withConfig(() => this.withStorageReadOnly((storage) => {
313
+ const run = storage.getCurrentRun();
314
+ const limit = typeof input === "number" ? input : input?.limit;
315
+ const workerId = typeof input === "number" ? undefined : input?.workerId;
316
+ const includeEvents = typeof input === "number" ? false : input?.includeEvents === true;
317
+ const workers = storage
318
+ .listWorkers(run?.id, limit ?? 50)
319
+ .filter((worker) => !workerId || worker.id === workerId);
320
+ if (!includeEvents) {
321
+ return ok({ workers });
322
+ }
323
+ const eventsByWorker = Object.fromEntries(workers.map((worker) => [
324
+ worker.id,
325
+ storage.listAgentTimeline({ workerId: worker.id, sources: ["worker_event"], limit: 50 }).entries
326
+ ]));
327
+ return ok({ workers, eventsByWorker });
328
+ }));
329
+ }
330
+
331
+ loopListEvents(sinceSeq?: number, limit?: number): McpResult {
332
+ return this.withConfig(() => this.withStorageReadOnly((storage) => {
333
+ const options = {
334
+ ...(sinceSeq === undefined ? {} : { sinceSeq }),
335
+ limit: limit ?? 50
336
+ };
337
+ return ok({ events: storage.listEvents(options) });
338
+ }));
339
+ }
340
+
341
+ loopReadArtifact(artifactId: string): McpResult {
342
+ return this.withConfig(() => this.withStorageReadOnly((storage) => {
343
+ const artifactRoot = resolve(this.options.repoRoot, ".agent-loop", "artifacts");
344
+ const record = storage.getArtifact(artifactId);
345
+ assertArtifactPathInsideRoot(artifactRoot, record.path, artifactId);
346
+ if (isSensitiveArtifactKind(record.kind)) {
347
+ throw new AgentLoopError("policy_violation", `Artifact kind ${record.kind} is not readable through the dashboard API.`, {
348
+ details: { artifactId, kind: record.kind }
349
+ });
350
+ }
351
+ const artifact = readArtifact(storage, artifactId);
352
+ return ok({
353
+ record: artifact.record,
354
+ contentBase64: artifact.content.toString("base64")
355
+ });
356
+ }));
357
+ }
358
+
359
+ loopGetPrStatus(): McpResult {
360
+ return this.withCurrentRun((storage, run) => ok({ pr: storage.getPrLink(run.id) }));
361
+ }
362
+
363
+ loopGetCiStatus(): McpResult {
364
+ return this.withCurrentRun((storage, run) => ok({ checks: storage.listCiChecks(run.id) }));
365
+ }
366
+
367
+ loopGetReviewComments(): McpResult {
368
+ return this.withCurrentRun((storage, run) => ok({ comments: storage.listReviewComments(run.id) }));
369
+ }
370
+
371
+ loopListArtifacts(): McpResult {
372
+ return this.withCurrentRun((storage, run) => ok({ artifacts: storage.listArtifacts(run.id) }));
373
+ }
374
+
375
+ loopDashboardMeta(): McpResult {
376
+ return this.withConfig((config) => {
377
+ const effectiveConfig = applyProfileConfig(config);
378
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
379
+ return ok({
380
+ appName: "HOLO-Codex",
381
+ surface: "dashboard",
382
+ targetRepo: {
383
+ root: this.options.repoRoot,
384
+ repoId: config.repoId
385
+ },
386
+ pollingMs: 3000,
387
+ autonomy: describeAutonomyPosture(config),
388
+ pages: [
389
+ "Mission Control",
390
+ "Plan Navigator",
391
+ "Policy Config",
392
+ "Dry-run Preview",
393
+ "Notifications",
394
+ "Gate Center",
395
+ ...(shape.id === "pr-loop" ? ["PR Inbox"] : []),
396
+ "Worker Runs",
397
+ "Scope Guard",
398
+ "Event Ledger",
399
+ "Artifact Diff Viewer",
400
+ "Recovery Center"
401
+ ]
402
+ });
403
+ });
404
+ }
405
+
406
+ loopPlanNavigator(): McpResult {
407
+ return this.withConfig((config) => {
408
+ const effectiveConfig = applyProfileConfig(config);
409
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
410
+ if (shape.id !== "pr-loop") {
411
+ return ok({ plan: undefined, selection: genericSelectionSummary(effectiveConfig) });
412
+ }
413
+ const selection = resolvePrSelection(this.options.repoRoot, effectiveConfig);
414
+ return ok({ plan: selection.plan, selection: selectionSummary(selection) });
415
+ });
416
+ }
417
+
418
+ loopPolicyConfig(): McpResult {
419
+ return this.withConfig(() => ok(readConfigForEdit(this.options.repoRoot)));
420
+ }
421
+
422
+ loopSavePolicyConfig(body: unknown, token?: string): McpResult {
423
+ const auth = this.requireToken(token);
424
+ if (auth) return auth;
425
+ if (!isRecord(body) || !isRecord(body.nextConfig) || typeof body.expectedHash !== "string") {
426
+ return fail(new AgentLoopError("invalid_config", "Policy config save requires nextConfig and expectedHash."));
427
+ }
428
+ const expectedHash = body.expectedHash;
429
+ return this.withConfig(() => ok(saveConfigEdit(this.options.repoRoot, {
430
+ nextConfig: body.nextConfig as never,
431
+ expectedHash,
432
+ ...(typeof body.note === "string" ? { note: body.note } : {}),
433
+ ...(typeof body.confirmationToken === "string" ? { confirmationToken: body.confirmationToken } : {})
434
+ })));
435
+ }
436
+
437
+ loopDryRunPreview(): McpResult {
438
+ return this.withConfig((config) => this.withStorage((storage) => {
439
+ this.reconcileTerminalWorker(storage);
440
+ const current = storage.getCurrentStatus();
441
+ const run = current.run ?? storage.getCurrentRun();
442
+ const effectiveConfig = applyProfileConfig(config);
443
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
444
+ const profile = resolveProfile(effectiveConfig, run?.currentState as AgentLoopState | undefined);
445
+ const selection = shape.id === "pr-loop" ? resolvePrSelection(this.options.repoRoot, effectiveConfig) : undefined;
446
+ const gates = run ? storage.listGates(run.id) : storage.listGates();
447
+ const openGates = gates.filter((gate) => gate.status === "open");
448
+ const ci = run ? storage.listCiChecks(run.id) : [];
449
+ const reviewComments = run ? storage.listReviewComments(run.id) : [];
450
+ const decisions = run ? storage.listDecisions(run.id) : [];
451
+ const runChecks = run ? storage.listRunChecks(run.id) : [];
452
+ const mergeForecast = evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks });
453
+ return ok({
454
+ nextPr: selection && !selection.ambiguous ? selection.item : undefined,
455
+ branchName: selection && !selection.ambiguous ? selection.branchName : undefined,
456
+ selection: selection ? selectionSummary(selection) : genericSelectionSummary(effectiveConfig),
457
+ profile,
458
+ workflowStages: workflowStages(effectiveConfig),
459
+ commandsPlanned: [
460
+ "git status --short --branch",
461
+ "pnpm agent-loop run --until=gate",
462
+ effectiveConfig.lintCommand,
463
+ effectiveConfig.testCommand
464
+ ].filter(Boolean),
465
+ workerType: profile.roleMapping.find((role) => role.state === (shape.id === "generic-loop" ? "EXECUTE_STEP" : "IMPLEMENT"))?.workerType ?? "implementation",
466
+ possibleGates: openGates.map((gate) => gate.kind),
467
+ missingConditions: shape.id === "pr-loop" ? mergeForecast.missingConditions : openGates.map((gate) => gate.kind),
468
+ filesLikelyTouched: shape.id === "pr-loop" ? likelyTouchedFiles(this.options.repoRoot, effectiveConfig.plansDir, selection && !selection.ambiguous ? selection.item.file : undefined) : genericLikelyTouchedFiles(effectiveConfig),
469
+ autonomyForecast: describeAutonomyPosture(effectiveConfig),
470
+ mergeForecast: shape.id === "pr-loop" ? mergeForecast : undefined
471
+ });
472
+ }));
473
+ }
474
+
475
+ loopNotifications(): McpResult {
476
+ return this.withConfig((config) => this.withStorageReadOnly((storage) => {
477
+ const notifications = storage.readTransaction(() => {
478
+ const effectiveConfig = applyProfileConfig(config);
479
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
480
+ const current = storage.getCurrentStatus();
481
+ const run = current.run ?? storage.getCurrentRun();
482
+ const gates = run ? storage.listGates(run.id) : storage.listGates();
483
+ const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
484
+ const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
485
+ const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
486
+ const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
487
+ const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
488
+ const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
489
+ const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
490
+ const timelineEntries = run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [];
491
+ return deriveNotifications({
492
+ config: effectiveConfig,
493
+ events,
494
+ gates,
495
+ timelineEntries,
496
+ workers,
497
+ ...(mergeReadiness ? { mergeReadiness } : {}),
498
+ ...(run ? { runId: run.id } : {}),
499
+ now: new Date(),
500
+ dismissedIds: notificationDismissedIds(events)
501
+ });
502
+ });
503
+ return ok({ notifications });
504
+ }));
505
+ }
506
+
507
+ loopMarkNotificationsRead(body: unknown, token?: string): McpResult {
508
+ const auth = this.requireToken(token);
509
+ if (auth) return auth;
510
+ return this.withConfig((config) => this.withStorage((storage) => {
511
+ const effectiveConfig = applyProfileConfig(config);
512
+ const current = storage.getCurrentStatus();
513
+ const run = current.run ?? storage.getCurrentRun();
514
+ const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
515
+ const gates = run ? storage.listGates(run.id) : storage.listGates();
516
+ const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
517
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
518
+ const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
519
+ const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
520
+ const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
521
+ const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
522
+ const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
523
+ const notifications = deriveNotifications({
524
+ config: effectiveConfig,
525
+ events,
526
+ gates,
527
+ timelineEntries: run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [],
528
+ workers,
529
+ ...(mergeReadiness ? { mergeReadiness } : {}),
530
+ ...(run ? { runId: run.id } : {}),
531
+ now: new Date(),
532
+ dismissedIds: notificationDismissedIds(events)
533
+ });
534
+ const requestedIds = isRecord(body) && Array.isArray(body.notificationIds)
535
+ ? body.notificationIds.filter((id): id is string => typeof id === "string")
536
+ : notifications.map((notification) => notification.id);
537
+ const notificationIds = requestedIds.filter((id) => notifications.some((notification) => notification.id === id));
538
+ storage.appendEvent({
539
+ ...(current.run ? { runId: current.run.id } : {}),
540
+ kind: "notification_marked_read",
541
+ message: `Marked ${notificationIds.length} notification(s) read.`,
542
+ payload: { notificationIds, source: "dashboard" }
543
+ });
544
+ return ok({ markedRead: notificationIds.length, notificationIds });
545
+ }));
546
+ }
547
+
548
+ loopDismissNotifications(body: unknown, token?: string): McpResult {
549
+ const auth = this.requireToken(token);
550
+ if (auth) return auth;
551
+ return this.withConfig((config) => this.withStorage((storage) => {
552
+ const effectiveConfig = applyProfileConfig(config);
553
+ const current = storage.getCurrentStatus();
554
+ const run = current.run ?? storage.getCurrentRun();
555
+ const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
556
+ const gates = run ? storage.listGates(run.id) : storage.listGates();
557
+ const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
558
+ const shape = resolveLoopShape(effectiveConfig.loopShape);
559
+ const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
560
+ const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
561
+ const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
562
+ const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
563
+ const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
564
+ const notifications = deriveNotifications({
565
+ config: effectiveConfig,
566
+ events,
567
+ gates,
568
+ timelineEntries: run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [],
569
+ workers,
570
+ ...(mergeReadiness ? { mergeReadiness } : {}),
571
+ ...(run ? { runId: run.id } : {}),
572
+ now: new Date(),
573
+ dismissedIds: notificationDismissedIds(events)
574
+ });
575
+ const requestedIds = isRecord(body) && Array.isArray(body.notificationIds)
576
+ ? body.notificationIds.filter((id): id is string => typeof id === "string")
577
+ : [];
578
+ const oneShotIds = requestedIds
579
+ .filter((id) => id.startsWith("longrunning:"))
580
+ .filter((id) => notifications.some((notification) => notification.id === id));
581
+ storage.appendEvent({
582
+ ...(current.run ? { runId: current.run.id } : {}),
583
+ kind: "notification_dismissed",
584
+ message: `Dismissed ${oneShotIds.length} notification(s).`,
585
+ payload: { notificationIds: oneShotIds, source: "dashboard" }
586
+ });
587
+ return ok({ dismissed: oneShotIds.length, notificationIds: oneShotIds });
588
+ }));
589
+ }
590
+
591
+ loopExportAudit(input: { runId: string; format: "markdown" | "json" }): McpResult {
592
+ return this.withConfig(() => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
593
+ const run = storage.listRuns(200).find((item) => item.id === input.runId);
594
+ if (!run) {
595
+ throw new AgentLoopError("storage_error", `Run not found: ${input.runId}`);
596
+ }
597
+ const data = buildAuditData(storage, run);
598
+ const content = input.format === "json" ? data : renderAuditMarkdown(data);
599
+ return ok({ runId: run.id, format: input.format, content });
600
+ })));
601
+ }
602
+
603
+ loopRecover(token?: string): McpResult {
604
+ const auth = this.requireToken(token);
605
+ if (auth) return auth;
606
+ return this.withConfig(() => ok(recoverBlockedRun(this.options.repoRoot, "dashboard")));
607
+ }
608
+
609
+ loopMarkHistoricalGateHandled(gateId: string, token?: string): McpResult {
610
+ const auth = this.requireToken(token);
611
+ if (auth) return auth;
612
+ return this.withConfig(() => this.withStorage((storage) => {
613
+ const gate = storage.getGate(gateId);
614
+ if (!gate) {
615
+ throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
616
+ }
617
+ const current = storage.getCurrentStatus();
618
+ const run = current.run ?? storage.getCurrentRun();
619
+ const annotated = annotateGates({
620
+ gates: storage.listGates(),
621
+ current,
622
+ ...(run ? { run } : {}),
623
+ runs: storage.listRuns(20),
624
+ dismissedHistoricalGateIds: historicalGateHandledIds(storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT))
625
+ }).find((item) => item.id === gate.id);
626
+ if (annotated?.activity === "active") {
627
+ throw new AgentLoopError("invalid_config", "Active gates must be approved, rejected, or recovered; they cannot be marked handled.");
628
+ }
629
+ const payload = {
630
+ gateId: gate.id,
631
+ gateKind: gate.kind,
632
+ gateRunId: gate.runId,
633
+ gateStatus: gate.status,
634
+ activity: annotated?.activity ?? "historical",
635
+ activityReason: annotated?.activityReason ?? "historical_run",
636
+ source: "dashboard"
637
+ };
638
+ storage.appendEvent({
639
+ ...(gate.runId ? { runId: gate.runId } : {}),
640
+ kind: HISTORICAL_EVENT_KIND,
641
+ message: `Marked historical gate ${gate.id} as handled in the dashboard view.`,
642
+ payload
643
+ });
644
+ if (gate.runId) {
645
+ storage.appendDecision({
646
+ runId: gate.runId,
647
+ kind: HISTORICAL_EVENT_KIND,
648
+ message: `Marked historical gate ${gate.id} as handled in the dashboard view.`,
649
+ details: payload
650
+ });
651
+ }
652
+ return ok({ gate: { ...gate, activity: "historical", activityReason: "marked_handled" }, markedHandled: true });
653
+ }));
654
+ }
655
+
656
+ loopReevaluateHistoricalGate(gateId: string, token?: string): McpResult {
657
+ const auth = this.requireToken(token);
658
+ if (auth) return auth;
659
+ return this.withConfig(() => this.withStorage((storage) => {
660
+ this.reconcileTerminalWorker(storage);
661
+ const gate = storage.getGate(gateId);
662
+ if (!gate) {
663
+ throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
664
+ }
665
+ const current = storage.getCurrentStatus();
666
+ const run = current.run ?? storage.getCurrentRun();
667
+ const annotated = annotateGates({
668
+ gates: storage.listGates(),
669
+ current,
670
+ ...(run ? { run } : {}),
671
+ runs: storage.listRuns(20),
672
+ dismissedHistoricalGateIds: historicalGateHandledIds(storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT))
673
+ }).find((item) => item.id === gate.id);
674
+ const result = reevaluationResult(annotated?.activity, annotated?.activityReason);
675
+ const payload = {
676
+ gateId: gate.id,
677
+ gateKind: gate.kind,
678
+ gateRunId: gate.runId,
679
+ gateStatus: gate.status,
680
+ activity: annotated?.activity ?? "historical",
681
+ activityReason: annotated?.activityReason ?? "historical_run",
682
+ result,
683
+ source: "dashboard"
684
+ };
685
+ storage.appendEvent({
686
+ ...(gate.runId ? { runId: gate.runId } : {}),
687
+ kind: HISTORICAL_REEVALUATED_EVENT_KIND,
688
+ message: `Re-evaluated historical gate ${gate.id} in the dashboard view.`,
689
+ payload
690
+ });
691
+ if (gate.runId) {
692
+ storage.appendDecision({
693
+ runId: gate.runId,
694
+ kind: HISTORICAL_REEVALUATED_EVENT_KIND,
695
+ message: `Re-evaluated historical gate ${gate.id} in the dashboard view.`,
696
+ details: payload
697
+ });
698
+ }
699
+ return ok({ gate: { ...gate, activity: payload.activity, activityReason: payload.activityReason }, result, reevaluated: true });
700
+ }));
701
+ }
702
+
703
+ async loopSpawnWorker(type: WorkerType, dryRun = true, token?: string): Promise<McpResult> {
704
+ const auth = this.requireToken(token);
705
+ if (auth) return auth;
706
+ return await this.withConfigAsync(async (config) => {
707
+ const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot));
708
+ try {
709
+ const run = storage.getCurrentRun();
710
+ if (!run) {
711
+ throw new AgentLoopError("storage_error", "No current run exists.");
712
+ }
713
+ storage.appendDecision({
714
+ runId: run.id,
715
+ kind: "mcp_spawn_worker_requested",
716
+ message: `MCP requested ${type} worker.`,
717
+ details: { type, dryRun }
718
+ });
719
+ return ok(await executeWorker({
720
+ repoRoot: this.options.repoRoot,
721
+ storage,
722
+ run,
723
+ config,
724
+ state: workerState(run.currentState),
725
+ type,
726
+ dryRun
727
+ }));
728
+ } catch (error) {
729
+ return fail(error);
730
+ } finally {
731
+ storage.close();
732
+ }
733
+ });
734
+ }
735
+
736
+ loopOpenDashboard(): McpResult {
737
+ return this.withConfig((config) => {
738
+ if (!config.dashboard?.enabled) {
739
+ return ok({ enabled: false, message: "Run `pnpm agent-loop dashboard` to start the local dashboard." });
740
+ }
741
+ const port = config.dashboard.port ?? 0;
742
+ return ok({ enabled: true, url: `http://${config.dashboard.host}:${port}` });
743
+ });
744
+ }
745
+
746
+ private decideGate(gateId: string, decision: "approved" | "rejected", input: string | GateDecisionInput, token?: string): McpResult {
747
+ const auth = this.requireToken(token);
748
+ if (auth) return auth;
749
+ const decisionInput = normalizeGateDecisionInput(input);
750
+ const note = decisionInput.note;
751
+ if (note.trim().length === 0) {
752
+ return fail(new AgentLoopError("invalid_config", "Gate approval note is required."));
753
+ }
754
+ return this.withConfig(() => this.withStorage((storage) => {
755
+ const gate = storage.decideGate(gateId, decision, note);
756
+ const runId = gate.runId ?? storage.getCurrentRun()?.id;
757
+ if (runId) {
758
+ storage.appendDecision({
759
+ runId,
760
+ kind: `gate_${decision}`,
761
+ message: `${decision} gate ${gate.id}.`,
762
+ details: {
763
+ gateId: gate.id,
764
+ gateKind: gate.kind,
765
+ state: gateState(gate.details),
766
+ note,
767
+ source: decisionInput.source ?? "api",
768
+ payload: decisionInput.payload ?? {},
769
+ gateDetails: gate.details
770
+ }
771
+ });
772
+ }
773
+ return ok({ gate });
774
+ }));
775
+ }
776
+
777
+ private withConfig(fn: (config: ReturnType<typeof loadConfig>["config"]) => McpResult): McpResult {
778
+ try {
779
+ const { config } = loadConfig(this.options.repoRoot);
780
+ return fn(config);
781
+ } catch (error) {
782
+ return fail(error);
783
+ }
784
+ }
785
+
786
+ private async withConfigAsync(fn: (config: ReturnType<typeof loadConfig>["config"]) => Promise<McpResult>): Promise<McpResult> {
787
+ try {
788
+ const { config } = loadConfig(this.options.repoRoot);
789
+ return await fn(config);
790
+ } catch (error) {
791
+ return fail(error);
792
+ }
793
+ }
794
+
795
+ private withStorage(fn: (storage: SqliteAgentLoopStorage) => McpResult): McpResult {
796
+ const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot));
797
+ try {
798
+ return fn(storage);
799
+ } catch (error) {
800
+ return fail(error);
801
+ } finally {
802
+ storage.close();
803
+ }
804
+ }
805
+
806
+ private withStorageReadOnly(fn: (storage: SqliteAgentLoopStorage) => McpResult): McpResult {
807
+ const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot), { mode: "ro" });
808
+ try {
809
+ return fn(storage);
810
+ } catch (error) {
811
+ return fail(error);
812
+ } finally {
813
+ storage.close();
814
+ }
815
+ }
816
+
817
+ private withCurrentRun(fn: (storage: SqliteAgentLoopStorage, run: AgentLoopRun) => McpResult): McpResult {
818
+ return this.withConfig(() => this.withStorageReadOnly((storage) => {
819
+ const run = storage.getCurrentRun();
820
+ if (!run) {
821
+ throw new AgentLoopError("storage_error", "No current run exists.");
822
+ }
823
+ return fn(storage, run);
824
+ }));
825
+ }
826
+
827
+ private requireToken(token: string | undefined): McpResult | undefined {
828
+ return requireMcpToken(token, this.options.mcpToken);
829
+ }
830
+
831
+ private reconcileTerminalWorker(storage: SqliteAgentLoopStorage): void {
832
+ const run = storage.getCurrentRun();
833
+ if (run) {
834
+ blockRunForTerminalWorker(storage, run);
835
+ }
836
+ }
837
+ }
838
+
839
+ function isSensitiveArtifactKind(kind: string): boolean {
840
+ return ["worker-prompt", "worker-jsonl", "worker-result"].includes(kind);
841
+ }
842
+
843
+ function normalizeGateDecisionInput(input: string | GateDecisionInput): GateDecisionInput {
844
+ if (typeof input === "string") {
845
+ return { note: input };
846
+ }
847
+ return {
848
+ note: input.note,
849
+ ...(input.source ? { source: input.source } : {}),
850
+ ...(input.payload ? { payload: input.payload } : {})
851
+ };
852
+ }
853
+
854
+ function gateState(details: unknown): string | undefined {
855
+ if (!isRecord(details)) return undefined;
856
+ return typeof details.state === "string" ? details.state : undefined;
857
+ }
858
+
859
+ function selectionSummary(selection: PrSelection): Record<string, unknown> {
860
+ if (selection.mode === "ambiguous") {
861
+ return {
862
+ mode: selection.mode,
863
+ ambiguous: true,
864
+ reason: selection.reason,
865
+ candidates: selection.candidates,
866
+ evidence: selection.evidence
867
+ };
868
+ }
869
+ return {
870
+ mode: selection.mode,
871
+ ambiguous: false,
872
+ item: selection.item,
873
+ branchName: selection.branchName,
874
+ ...(selection.mode === "current_pr" ? {
875
+ prNumber: selection.pr.number,
876
+ prUrl: selection.pr.url
877
+ } : {}),
878
+ evidence: selection.evidence
879
+ };
880
+ }
881
+
882
+ function genericSelectionSummary(config: AgentLoopConfig): Record<string, unknown> {
883
+ return {
884
+ mode: "generic_loop",
885
+ ambiguous: false,
886
+ loopShape: config.loopShape,
887
+ workflowProfile: config.workflowProfile,
888
+ evidence: ["generic-loop uses workflow profile state, not legacy PR spec selection."]
889
+ };
890
+ }
891
+
892
+ function genericLikelyTouchedFiles(config: AgentLoopConfig): string[] {
893
+ const profile = resolveProfile(config);
894
+ const allowedRoots = profile.allowedWriteRoots ?? [];
895
+ return [
896
+ ...allowedRoots.map((root) => `${root}/`),
897
+ ".agent-loop/artifacts/"
898
+ ];
899
+ }
900
+
901
+ function dashboardInfo(config: AgentLoopConfig): { url: string; host: string; port: number; loopbackOnly: true } {
902
+ const host = config.dashboard?.host ?? "127.0.0.1";
903
+ const port = config.dashboard?.port ?? 0;
904
+ return { url: `http://${host}:${port}/`, host, port, loopbackOnly: true };
905
+ }
906
+
907
+ function notificationReadIds(events: AgentLoopEvent[]): Set<string> {
908
+ return notificationIdsForKind(events, "notification_marked_read");
909
+ }
910
+
911
+ function notificationDismissedIds(events: AgentLoopEvent[]): Set<string> {
912
+ return notificationIdsForKind(events, "notification_dismissed");
913
+ }
914
+
915
+ function notificationIdsForKind(events: AgentLoopEvent[], kind: string): Set<string> {
916
+ const ids = new Set<string>();
917
+ for (const event of events) {
918
+ if (event.kind !== kind || typeof event.payload !== "object" || event.payload === null) {
919
+ continue;
920
+ }
921
+ const notificationIds = (event.payload as { notificationIds?: unknown }).notificationIds;
922
+ if (Array.isArray(notificationIds)) {
923
+ for (const id of notificationIds) {
924
+ if (typeof id === "string") ids.add(id);
925
+ }
926
+ }
927
+ }
928
+ return ids;
929
+ }
930
+
931
+ function mergeReadinessForMissionDisplay(readiness: MergeReadiness, events: AgentLoopEvent[]): MergeReadiness {
932
+ if (!hasWorkflowCleanupEvidence(events)) {
933
+ return readiness;
934
+ }
935
+ return {
936
+ ...readiness,
937
+ state: "ready",
938
+ ready: true,
939
+ missingConditions: [],
940
+ evidence: [...readiness.evidence, "cleanup evidence recorded after merge"]
941
+ };
942
+ }
943
+
944
+ function hasWorkflowCleanupEvidence(events: AgentLoopEvent[]): boolean {
945
+ return events.some((event) => {
946
+ if (event.kind !== WORKFLOW_STAGE_EVIDENCE_KIND || !isRecord(event.payload)) {
947
+ return false;
948
+ }
949
+ return event.payload.stageId === "cleanup";
950
+ });
951
+ }
952
+
953
+ function buildAuditData(storage: SqliteAgentLoopStorage, run: AgentLoopRun): Record<string, unknown> {
954
+ const gates = storage.listGates(run.id).map((gate) => ({
955
+ id: gate.id,
956
+ runId: gate.runId,
957
+ kind: gate.kind,
958
+ status: gate.status,
959
+ message: redactSecrets(gate.message),
960
+ details: redactAuditValue(gate.details),
961
+ createdAt: gate.createdAt,
962
+ resolvedAt: gate.resolvedAt,
963
+ decisionNote: gate.decisionNote ? redactSecrets(gate.decisionNote) : undefined,
964
+ decidedAt: gate.decidedAt
965
+ }));
966
+ const ci = storage.listCiChecks(run.id);
967
+ const reviewComments = storage.listReviewComments(run.id).map((comment) => ({
968
+ id: comment.id,
969
+ prNumber: comment.prNumber,
970
+ url: comment.url,
971
+ author: comment.author,
972
+ path: comment.path,
973
+ line: comment.line,
974
+ actionable: comment.actionable,
975
+ isResolved: comment.isResolved,
976
+ isOutdated: comment.isOutdated,
977
+ status: comment.status
978
+ }));
979
+ const timeline = storage.listAgentTimeline({ runId: run.id, limit: 200 }).entries.map((entry) => ({
980
+ timelineSeq: entry.timelineSeq,
981
+ occurredAt: entry.occurredAt,
982
+ source: entry.source,
983
+ kind: entry.kind,
984
+ workerId: entry.workerId,
985
+ threadId: entry.threadId,
986
+ title: redactSecrets(entry.title),
987
+ summary: redactSecrets(entry.summary).slice(0, 2_000),
988
+ status: entry.status,
989
+ artifactIds: entry.artifactIds
990
+ }));
991
+ return {
992
+ generatedAt: new Date().toISOString(),
993
+ run,
994
+ pr: storage.getPrLink(run.id),
995
+ ci,
996
+ reviewComments,
997
+ workers: storage.listWorkers(run.id, 100).map((worker) => ({
998
+ ...worker,
999
+ ...(worker.error ? { error: redactSecrets(worker.error) } : {})
1000
+ })),
1001
+ gates,
1002
+ decisions: storage.listDecisions(run.id).map((decision) => ({
1003
+ ...decision,
1004
+ message: redactSecrets(decision.message),
1005
+ details: redactAuditValue(decision.details)
1006
+ })),
1007
+ artifacts: storage.listArtifacts(run.id).map((artifact) => ({
1008
+ id: artifact.id,
1009
+ runId: artifact.runId,
1010
+ kind: artifact.kind,
1011
+ name: redactSecrets(artifact.name),
1012
+ path: redactSecrets(artifact.path),
1013
+ sha256: artifact.sha256,
1014
+ createdAt: artifact.createdAt
1015
+ })),
1016
+ timeline
1017
+ };
1018
+ }
1019
+
1020
+ function renderAuditMarkdown(data: Record<string, unknown>): string {
1021
+ const run = data.run as AgentLoopRun;
1022
+ const pr = data.pr as { prNumber?: number; url?: string; state?: string } | undefined;
1023
+ const workers = data.workers as WorkerRun[];
1024
+ const gates = data.gates as Array<{ kind: string; status: string; message: string }>;
1025
+ const timeline = data.timeline as Array<{ occurredAt: string; source: string; title: string; status?: string }>;
1026
+ const lines = [
1027
+ `# Agent Loop Audit: ${run.id}`,
1028
+ "",
1029
+ `- Status: ${run.status}`,
1030
+ `- State: ${run.currentState ?? "unknown"}`,
1031
+ `- Branch: ${run.branch ?? "unknown"}`,
1032
+ `- Generated: ${data.generatedAt}`,
1033
+ pr ? `- PR: #${pr.prNumber ?? "unknown"} ${pr.state ?? ""} ${pr.url ?? ""}` : "- PR: none",
1034
+ "",
1035
+ "## Gates",
1036
+ ...listLines(gates.map((gate) => `${gate.kind} / ${gate.status} - ${redactSecrets(gate.message)}`)),
1037
+ "",
1038
+ "## Workers",
1039
+ ...listLines(workers.map((worker) => `${worker.id} / ${worker.type} / ${worker.status}`)),
1040
+ "",
1041
+ "## Timeline",
1042
+ ...listLines(timeline.slice(0, 80).map((entry) => `${entry.occurredAt} ${entry.source} ${entry.status ?? ""} - ${entry.title}`))
1043
+ ];
1044
+ return `${lines.join("\n")}\n`;
1045
+ }
1046
+
1047
+ function listLines(items: string[]): string[] {
1048
+ return items.length === 0 ? ["- none"] : items.map((item) => `- ${item}`);
1049
+ }
1050
+
1051
+ function redactAuditValue(value: unknown): unknown {
1052
+ if (typeof value === "string") {
1053
+ return redactSecrets(value).slice(0, 2_000);
1054
+ }
1055
+ if (Array.isArray(value)) {
1056
+ return value.slice(0, 20).map(redactAuditValue);
1057
+ }
1058
+ if (typeof value !== "object" || value === null) {
1059
+ return value;
1060
+ }
1061
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).slice(0, 40).map(([key, nested]) => [
1062
+ key,
1063
+ redactAuditField(key, nested)
1064
+ ]));
1065
+ }
1066
+
1067
+ function redactAuditField(key: string, value: unknown): unknown {
1068
+ if (/token|api_key|authorization|password|secret/i.test(key)) {
1069
+ return "[redacted]";
1070
+ }
1071
+ if (/stdout|stderr|output|rawJsonl|contentBase64|prompt/i.test(key)) {
1072
+ return {
1073
+ omitted: true,
1074
+ reason: "raw content is excluded from audit exports",
1075
+ length: typeof value === "string" ? value.length : JSON.stringify(value ?? "").length,
1076
+ type: Array.isArray(value) ? "array" : typeof value
1077
+ };
1078
+ }
1079
+ return redactAuditValue(value);
1080
+ }
1081
+
1082
+ function buildTimelineSummary(input: {
1083
+ timeline: AgentTimelineEntry[];
1084
+ workers: WorkerRun[];
1085
+ currentRunId?: string;
1086
+ nowMs?: number;
1087
+ listWorkerEvents: (workerId: string) => Array<{ eventType: string; itemType?: string; createdAt: string }>;
1088
+ }): Record<string, unknown> {
1089
+ const activeWorker = input.workers.find((worker) => worker.status === "running");
1090
+ const lastFailure = input.timeline.find((entry) =>
1091
+ entry.source === "worker" &&
1092
+ (entry.status === "failed" || entry.status === "timed_out" || entry.status === "invalid_output")
1093
+ );
1094
+ const summary: Record<string, unknown> = {
1095
+ hasObservationGap: hasObservationGap(input.workers, input.listWorkerEvents, input.nowMs),
1096
+ ...(input.currentRunId ? { runId: input.currentRunId } : {})
1097
+ };
1098
+ if (input.timeline[0]) {
1099
+ summary.latest = input.timeline[0];
1100
+ }
1101
+ if (lastFailure) {
1102
+ summary.lastFailure = lastFailure;
1103
+ }
1104
+ if (activeWorker) {
1105
+ summary.activeWorker = {
1106
+ id: activeWorker.id,
1107
+ type: activeWorker.type,
1108
+ status: activeWorker.status,
1109
+ ...(activeWorker.threadId ? { threadId: activeWorker.threadId } : {}),
1110
+ startedAt: activeWorker.startedAt
1111
+ };
1112
+ }
1113
+ return summary;
1114
+ }
1115
+
1116
+ function hasObservationGap(
1117
+ workers: WorkerRun[],
1118
+ listWorkerEvents: (workerId: string) => Array<{ eventType: string; itemType?: string; createdAt: string }>,
1119
+ nowMs = Date.now()
1120
+ ): boolean {
1121
+ return workers.some((worker) => {
1122
+ const events = listWorkerEvents(worker.id);
1123
+ if (events.length === 0 && !worker.rawJsonlArtifactId) {
1124
+ return true;
1125
+ }
1126
+ const hasSummary = events.some((event) =>
1127
+ event.eventType === "thread.started" ||
1128
+ event.eventType === "turn.completed" ||
1129
+ event.itemType === "command_execution" ||
1130
+ event.itemType === "file_change" ||
1131
+ event.itemType === "agent_message" ||
1132
+ event.itemType === "mcp_tool_call" ||
1133
+ event.itemType === "web_search" ||
1134
+ event.itemType === "todo_list" ||
1135
+ event.itemType === "error"
1136
+ );
1137
+ if (worker.rawJsonlArtifactId && !hasSummary) {
1138
+ return true;
1139
+ }
1140
+ if (worker.status !== "running") {
1141
+ return false;
1142
+ }
1143
+ const startedAt = Date.parse(worker.startedAt);
1144
+ if (Number.isNaN(startedAt) || nowMs - startedAt <= 60_000) {
1145
+ return false;
1146
+ }
1147
+ const newestEventMs = events.reduce((latest, event) => {
1148
+ const value = Date.parse(event.createdAt);
1149
+ return Number.isNaN(value) ? latest : Math.max(latest, value);
1150
+ }, 0);
1151
+ return newestEventMs < startedAt || nowMs - newestEventMs > 60_000;
1152
+ });
1153
+ }
1154
+
1155
+ function assertArtifactPathInsideRoot(artifactRoot: string, path: string, artifactId: string): void {
1156
+ try {
1157
+ const rootRealPath = realpathSync(artifactRoot);
1158
+ const artifactRealPath = realpathSync(path);
1159
+ const relativePath = relative(rootRealPath, artifactRealPath);
1160
+ if (relativePath.startsWith("..") || relativePath === "" || relativePath.startsWith("/")) {
1161
+ throw new AgentLoopError("artifact_integrity_error", "Artifact path escapes artifact root.", {
1162
+ details: { artifactId, path }
1163
+ });
1164
+ }
1165
+ } catch (error) {
1166
+ if (error instanceof AgentLoopError) {
1167
+ throw error;
1168
+ }
1169
+ throw new AgentLoopError("artifact_integrity_error", "Artifact path cannot be verified.", {
1170
+ details: { artifactId, path, cause: error instanceof Error ? error.message : String(error) }
1171
+ });
1172
+ }
1173
+ }
1174
+
1175
+ function startBackgroundRun(repoRoot: string, runId: string): boolean {
1176
+ try {
1177
+ execFileSync("which", ["pnpm"], { stdio: "ignore" });
1178
+ } catch (error) {
1179
+ markBackgroundRunFailed(repoRoot, runId, error);
1180
+ return false;
1181
+ }
1182
+ const child = spawn("pnpm", ["agent-loop", "run", "--until=gate"], {
1183
+ cwd: repoRoot,
1184
+ detached: true,
1185
+ stdio: "ignore",
1186
+ shell: false
1187
+ });
1188
+ let handledStartFailure = false;
1189
+ const failStartOnce = (error: unknown): void => {
1190
+ if (handledStartFailure) {
1191
+ return;
1192
+ }
1193
+ handledStartFailure = true;
1194
+ markBackgroundRunFailed(repoRoot, runId, error);
1195
+ };
1196
+ child.on("error", (error) => {
1197
+ failStartOnce(error);
1198
+ });
1199
+ if (!child.pid) {
1200
+ failStartOnce(new Error("Background process did not start."));
1201
+ return false;
1202
+ }
1203
+ child.unref();
1204
+ return true;
1205
+ }
1206
+
1207
+ function markBackgroundRunFailed(repoRoot: string, runId: string, error: unknown): void {
1208
+ let storage: SqliteAgentLoopStorage | undefined;
1209
+ try {
1210
+ storage = new SqliteAgentLoopStorage(statePath(repoRoot));
1211
+ const run = storage.getCurrentRun();
1212
+ if (run?.id === runId && run.status === "RUNNING") {
1213
+ try {
1214
+ storage.updateRunStatus(run.id, run.version, "BLOCKED", {
1215
+ ...(run.currentState ? { currentState: run.currentState } : {})
1216
+ });
1217
+ } catch {
1218
+ // Best-effort cleanup; the run may have advanced concurrently.
1219
+ }
1220
+ }
1221
+ try {
1222
+ storage.writeGate({
1223
+ runId,
1224
+ kind: "required_tool_unavailable",
1225
+ message: "Could not start background agent-loop run.",
1226
+ details: { cause: error instanceof Error ? error.message : String(error) }
1227
+ });
1228
+ storage.appendEvent({
1229
+ runId,
1230
+ kind: "background_run_start_failed",
1231
+ message: "Could not start background agent-loop run.",
1232
+ payload: { cause: error instanceof Error ? error.message : String(error) }
1233
+ });
1234
+ } catch {
1235
+ // Async process-start failure handling must never crash the MCP server.
1236
+ }
1237
+ } catch {
1238
+ // Best-effort cleanup path.
1239
+ } finally {
1240
+ storage?.close();
1241
+ }
1242
+ }
1243
+
1244
+ function nextAction(status: string, gate?: string): string {
1245
+ if (gate === "needs_repo_init") {
1246
+ return "Run `pnpm agent-loop init`.";
1247
+ }
1248
+ if (gate) {
1249
+ return "Inspect the gate, fix the cause, then approve or reject with a note.";
1250
+ }
1251
+ if (status === "IDLE" || status === "READY") {
1252
+ return "Run until the next gate.";
1253
+ }
1254
+ if (status === "STOPPED") {
1255
+ return "Resume only after confirming the stopped run should continue.";
1256
+ }
1257
+ return "Poll status.";
1258
+ }
1259
+
1260
+ function ok<T>(data: T): McpResult<T> {
1261
+ return { ok: true, data };
1262
+ }
1263
+
1264
+ function fail(error: unknown): McpResult {
1265
+ const payload = toErrorPayload(error);
1266
+ return {
1267
+ ok: false,
1268
+ error: payload,
1269
+ ...(payload.code ? { gate: payload.code } : {})
1270
+ };
1271
+ }
1272
+
1273
+ function requireMcpToken(token: string | undefined, expectedToken?: string): McpResult | undefined {
1274
+ const expected = expectedToken ?? process.env.AGENT_LOOP_MCP_TOKEN;
1275
+ if (!expected) {
1276
+ return fail(new AgentLoopError("needs_secret_or_login", "AGENT_LOOP_MCP_TOKEN is required for mutating MCP tools.", {
1277
+ exitCode: 2
1278
+ }));
1279
+ }
1280
+ if (token !== expected) {
1281
+ return fail(new AgentLoopError("needs_secret_or_login", "MCP token is missing or invalid.", {
1282
+ exitCode: 2
1283
+ }));
1284
+ }
1285
+ return undefined;
1286
+ }
1287
+
1288
+ function recoveryWarnings(
1289
+ gate: string | undefined,
1290
+ gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }> = [],
1291
+ workers: Array<WorkerRun & { activity?: "active" | "historical"; activityReason?: string }> = []
1292
+ ): string[] {
1293
+ const warnings = gate === "needs_repo_init"
1294
+ ? ["needs_repo_init is visible; use explicit recovery after config is valid."]
1295
+ : [];
1296
+ const historicalOpen = gates.filter((item) => item.status === "open" && item.activity === "historical");
1297
+ if (historicalOpen.length > 0) {
1298
+ warnings.push(`${historicalOpen.length} historical open gate(s) belong to an inactive or superseded run.`);
1299
+ }
1300
+ const staleWorkers = workers.filter((item) => item.activityReason === "stale_worker_failure");
1301
+ if (staleWorkers.length > 0) {
1302
+ warnings.push(`${staleWorkers.length} stale worker failure(s) are from an older run or before the current run started.`);
1303
+ }
1304
+ return warnings;
1305
+ }
1306
+
1307
+ function currentForMissionControl(
1308
+ current: ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]>,
1309
+ gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }>
1310
+ ): ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]> {
1311
+ const activeGate = gates.find((gate) => gate.status === "open" && gate.activity === "active");
1312
+ if (activeGate) {
1313
+ return {
1314
+ ...current,
1315
+ status: "BLOCKED",
1316
+ gate: {
1317
+ kind: activeGate.kind,
1318
+ message: activeGate.message,
1319
+ ...(activeGate.details === undefined ? {} : { details: activeGate.details })
1320
+ }
1321
+ };
1322
+ }
1323
+ if (!current.gate) {
1324
+ return current;
1325
+ }
1326
+ const { gate: _gate, ...withoutGate } = current;
1327
+ return {
1328
+ ...withoutGate,
1329
+ status: current.run?.status ?? current.status
1330
+ };
1331
+ }
1332
+
1333
+ function annotatedGatesSnapshot(storage: SqliteAgentLoopStorage): Array<AgentLoopGate & { activity: "active" | "historical"; activityReason: string }> {
1334
+ return storage.readTransaction(() => {
1335
+ const current = storage.getCurrentStatus();
1336
+ const run = current.run ?? storage.getCurrentRun();
1337
+ const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
1338
+ return annotateGates({
1339
+ gates: storage.listGates(),
1340
+ current,
1341
+ ...(run ? { run } : {}),
1342
+ runs: storage.listRuns(20),
1343
+ dismissedHistoricalGateIds: historicalGateHandledIds(historicalEvents)
1344
+ });
1345
+ });
1346
+ }
1347
+
1348
+ function annotatedGateSnapshot(storage: SqliteAgentLoopStorage, gateId: string): (AgentLoopGate & { activity: "active" | "historical"; activityReason: string }) | undefined {
1349
+ return storage.readTransaction(() => {
1350
+ const gate = storage.getGate(gateId);
1351
+ if (!gate) return undefined;
1352
+ const current = storage.getCurrentStatus();
1353
+ const run = current.run ?? storage.getCurrentRun();
1354
+ const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
1355
+ return annotateGates({
1356
+ gates: [gate],
1357
+ current,
1358
+ ...(run ? { run } : {}),
1359
+ runs: includeRun(storage.listRuns(20), run),
1360
+ dismissedHistoricalGateIds: historicalGateHandledIds(historicalEvents)
1361
+ })[0];
1362
+ });
1363
+ }
1364
+
1365
+ function includeRun(runs: AgentLoopRun[], run: AgentLoopRun | undefined): AgentLoopRun[] {
1366
+ if (!run || runs.some((item) => item.id === run.id)) return runs;
1367
+ return [run, ...runs];
1368
+ }
1369
+
1370
+ function annotateGates(input: {
1371
+ gates: AgentLoopGate[];
1372
+ current: ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]>;
1373
+ run?: AgentLoopRun;
1374
+ runs: AgentLoopRun[];
1375
+ dismissedHistoricalGateIds: Set<string>;
1376
+ }): Array<AgentLoopGate & { activity: "active" | "historical"; activityReason: string }> {
1377
+ const runById = new Map(input.runs.map((run) => [run.id, run]));
1378
+ const currentRunId = input.run?.id;
1379
+ const currentStartedAt = input.run?.startedAt ? Date.parse(input.run.startedAt) : (input.run?.createdAt ? Date.parse(input.run.createdAt) : undefined);
1380
+ return input.gates.map((gate) => {
1381
+ const gateRun = gate.runId ? runById.get(gate.runId) : undefined;
1382
+ const activeRun = gateRun ? isActiveRun(gateRun) && gateRun.id === currentRunId : input.current.gate?.kind === gate.kind;
1383
+ const inactiveGateRun = gateRun ? !isActiveRun(gateRun) : false;
1384
+ const gateCreatedAt = Date.parse(gate.createdAt);
1385
+ const supersededByCurrentRun = gate.runId !== undefined &&
1386
+ gate.runId !== currentRunId &&
1387
+ currentRunId !== undefined &&
1388
+ (inactiveGateRun || (
1389
+ currentStartedAt !== undefined &&
1390
+ !Number.isNaN(gateCreatedAt) &&
1391
+ gateCreatedAt < currentStartedAt
1392
+ ));
1393
+ if (gate.status === "open" && activeRun && !supersededByCurrentRun) {
1394
+ return { ...gate, activity: "active" as const, activityReason: gate.runId ? "current_run" : "repo_gate" };
1395
+ }
1396
+ if (input.dismissedHistoricalGateIds.has(gate.id)) {
1397
+ return { ...gate, activity: "historical" as const, activityReason: "marked_handled" };
1398
+ }
1399
+ if (supersededByCurrentRun) {
1400
+ return { ...gate, activity: "historical" as const, activityReason: "overridden_by_reality" };
1401
+ }
1402
+ if (gate.status !== "open") {
1403
+ return { ...gate, activity: "historical" as const, activityReason: "handled_gate" };
1404
+ }
1405
+ return { ...gate, activity: "historical" as const, activityReason: gateRun ? "historical_run" : "repo_gate_not_current" };
1406
+ });
1407
+ }
1408
+
1409
+ function annotateWorkers(input: {
1410
+ workers: WorkerRun[];
1411
+ gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }>;
1412
+ run?: AgentLoopRun;
1413
+ }): Array<WorkerRun & { activity: "active" | "historical"; activityReason: string }> {
1414
+ const currentRunId = input.run?.id;
1415
+ const currentStartedAt = input.run?.startedAt ? Date.parse(input.run.startedAt) : undefined;
1416
+ return input.workers.map((worker) => {
1417
+ const terminalFailure = worker.status === "failed" || worker.status === "invalid_output" || worker.status === "timed_out";
1418
+ const workerTime = Date.parse(worker.completedAt ?? worker.startedAt);
1419
+ const olderThanCurrentRun = currentStartedAt !== undefined && !Number.isNaN(workerTime) && workerTime < currentStartedAt;
1420
+ const workerGate = input.gates.find((gate) =>
1421
+ TERMINAL_WORKER_GATE_KINDS.includes(gate.kind) &&
1422
+ isRecord(gate.details) &&
1423
+ gate.details.workerId === worker.id
1424
+ );
1425
+ if (terminalFailure && (worker.runId !== currentRunId || olderThanCurrentRun || workerGate?.activity === "historical")) {
1426
+ return { ...worker, activity: "historical" as const, activityReason: "stale_worker_failure" };
1427
+ }
1428
+ if (worker.runId === currentRunId && input.run && isActiveRun(input.run)) {
1429
+ return { ...worker, activity: "active" as const, activityReason: "current_run" };
1430
+ }
1431
+ return { ...worker, activity: "historical" as const, activityReason: "historical_run" };
1432
+ });
1433
+ }
1434
+
1435
+ function isActiveRun(run: AgentLoopRun): boolean {
1436
+ return run.status === "RUNNING" || run.status === "BLOCKED";
1437
+ }
1438
+
1439
+ function historicalGateHandledIds(events: AgentLoopEvent[]): Set<string> {
1440
+ const ids = new Set<string>();
1441
+ for (const event of events) {
1442
+ if (event.kind !== HISTORICAL_EVENT_KIND || !isRecord(event.payload)) {
1443
+ continue;
1444
+ }
1445
+ const gateId = event.payload.gateId;
1446
+ if (typeof gateId === "string") {
1447
+ ids.add(gateId);
1448
+ }
1449
+ }
1450
+ return ids;
1451
+ }
1452
+
1453
+ function reevaluationResult(activity: "active" | "historical" | undefined, activityReason: string | undefined): HistoricalGateReevaluationResult {
1454
+ if (activity === "active") {
1455
+ return "active_again";
1456
+ }
1457
+ if (activityReason === "marked_handled") {
1458
+ return "manually_handled";
1459
+ }
1460
+ if (activityReason === "overridden_by_reality") {
1461
+ return "overridden_by_current_reality";
1462
+ }
1463
+ return "still_historical";
1464
+ }
1465
+
1466
+ function workerState(value: string | undefined): AgentLoopState {
1467
+ if (value === "WRITE_SPEC" || value === "IMPLEMENT" || value === "FIX_REVIEW" || value === "SELF_CHECK") {
1468
+ return value;
1469
+ }
1470
+ return "SELF_CHECK";
1471
+ }
1472
+
1473
+ function likelyTouchedFiles(repoRoot: string, plansDir: string, selectedFile: string | undefined): string[] {
1474
+ const paths = new Set([plansDir, ".agent-loop/config.json"]);
1475
+ if (selectedFile) {
1476
+ paths.add(relative(repoRoot, selectedFile).replaceAll("\\", "/"));
1477
+ }
1478
+ return [...paths];
1479
+ }