@saadnvd1/agent-os 0.3.723 → 0.3.731

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 (150) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/ports/route.test.ts +43 -0
  3. package/app/api/bus/ports/route.ts +35 -0
  4. package/app/api/loops/route.ts +17 -0
  5. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  6. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  7. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  8. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  9. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  10. package/app/api/tasks/[id]/loop/route.ts +72 -0
  11. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  12. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  13. package/app/api/tasks/[id]/mode/route.ts +29 -0
  14. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  15. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  16. package/app/api/tasks/[id]/report/route.ts +39 -0
  17. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  18. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  19. package/app/page.tsx +8 -0
  20. package/components/Analyst/PairChip.tsx +131 -0
  21. package/components/Analyst/PairSwitch.tsx +76 -0
  22. package/components/Analyst/ReviewCard.tsx +272 -0
  23. package/components/Analyst/SignalChips.tsx +39 -0
  24. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  25. package/components/Analyst/cardState.test.ts +109 -0
  26. package/components/Analyst/cardState.ts +95 -0
  27. package/components/Analyst/pair.ts +81 -0
  28. package/components/Chat/Code.tsx +3 -0
  29. package/components/Chat/Messages.tsx +8 -1
  30. package/components/Chat/TurnCopy.tsx +84 -0
  31. package/components/Chat/itemContext.ts +14 -0
  32. package/components/Draft/DraftChips.tsx +10 -0
  33. package/components/Draft/DraftPanel.tsx +7 -0
  34. package/components/Pane/DesktopTabBar.tsx +6 -0
  35. package/components/Pane/MobileTabBar.tsx +17 -0
  36. package/components/Pane/MobileViewPicker.tsx +4 -0
  37. package/components/Pane/PaneMenu.test.ts +21 -0
  38. package/components/Pane/PaneMenu.tsx +16 -0
  39. package/components/Pane/PaneViewToggle.tsx +12 -0
  40. package/components/Pane/index.tsx +19 -0
  41. package/components/Report/DraftCard.tsx +89 -0
  42. package/components/Report/EstimateBar.tsx +51 -0
  43. package/components/Report/EvidenceSheet.tsx +98 -0
  44. package/components/Report/ModeChooser.tsx +43 -0
  45. package/components/Report/ReportView.tsx +289 -0
  46. package/components/SessionList/GroupShelves.tsx +57 -22
  47. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  48. package/components/SessionList/SessionRow.tsx +12 -1
  49. package/components/SessionList/SessionRowMenu.tsx +6 -0
  50. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  51. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  52. package/components/SessionList/index.tsx +11 -2
  53. package/components/SessionList/useGroupDrag.ts +83 -0
  54. package/components/Sessions/RolePill.tsx +7 -0
  55. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  56. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  57. package/components/Tasks/Loop/LoopView.tsx +350 -0
  58. package/components/Tasks/Loop/labels.ts +17 -0
  59. package/components/Tasks/TaskRow.tsx +2 -0
  60. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  61. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  62. package/components/ui/segmented.tsx +55 -0
  63. package/components/ui/status-pill.tsx +3 -0
  64. package/contexts/PaneContext.tsx +21 -1
  65. package/data/exports/queries.ts +11 -0
  66. package/data/loops/index.ts +3 -0
  67. package/data/loops/keys.ts +4 -0
  68. package/data/loops/mutations.ts +68 -0
  69. package/data/loops/queries.ts +40 -0
  70. package/data/push/topics.ts +6 -0
  71. package/data/reports/index.ts +115 -0
  72. package/lib/agents/brief.ts +1 -1
  73. package/lib/analyst/forward.ts +1 -1
  74. package/lib/db/changes.ts +7 -0
  75. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  76. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  77. package/lib/done/index.ts +2 -0
  78. package/lib/drafts.ts +2 -0
  79. package/lib/export/markdown.ts +7 -1
  80. package/lib/export/turn.ts +48 -0
  81. package/lib/health/config.ts +2 -0
  82. package/lib/health/env/classes.ts +14 -1
  83. package/lib/health/port-wait-tick.ts +222 -0
  84. package/lib/health/port-wait.ts +160 -0
  85. package/lib/health/tick.ts +9 -2
  86. package/lib/health/types.ts +2 -0
  87. package/lib/loops/analyse.ts +46 -0
  88. package/lib/loops/analyst-prompt.ts +203 -0
  89. package/lib/loops/checkCriteria.ts +114 -0
  90. package/lib/loops/compactEvidence.ts +140 -0
  91. package/lib/loops/controls.ts +317 -0
  92. package/lib/loops/decideStop.ts +113 -0
  93. package/lib/loops/deps.ts +128 -0
  94. package/lib/loops/diffMap.ts +39 -0
  95. package/lib/loops/draftCriteria.ts +71 -0
  96. package/lib/loops/gatherEvidence.ts +265 -0
  97. package/lib/loops/hooks.ts +40 -0
  98. package/lib/loops/index.ts +7 -0
  99. package/lib/loops/route.ts +83 -0
  100. package/lib/loops/runCycle.ts +323 -0
  101. package/lib/loops/screenInstruction.ts +59 -0
  102. package/lib/loops/sendInstruction.ts +83 -0
  103. package/lib/loops/snapshot.ts +38 -0
  104. package/lib/loops/stopLoop.ts +86 -0
  105. package/lib/loops/store.ts +481 -0
  106. package/lib/loops/summary.ts +27 -0
  107. package/lib/loops/tickLoops.ts +141 -0
  108. package/lib/loops/types.ts +223 -0
  109. package/lib/loops/world.test-helpers.ts +223 -0
  110. package/lib/models/plan-step.ts +4 -1
  111. package/lib/orchestrator/digest.ts +2 -1
  112. package/lib/orchestrator/loop-guard.ts +5 -2
  113. package/lib/orchestrator/pr-wake.ts +4 -0
  114. package/lib/orchestrator/watcher.ts +8 -1
  115. package/lib/panes.ts +52 -0
  116. package/lib/plans/acceptance.ts +23 -0
  117. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  118. package/lib/ports/ensurePorts.ts +138 -0
  119. package/lib/ports/findHardcodedPorts.ts +116 -0
  120. package/lib/ports/index.ts +15 -0
  121. package/lib/ports/listListeners.ts +134 -0
  122. package/lib/ports/reassignPort.ts +45 -0
  123. package/lib/ports/types.ts +62 -0
  124. package/lib/ports/whoHolds.ts +155 -0
  125. package/lib/project-config/brief.ts +4 -2
  126. package/lib/project-config/session.ts +19 -3
  127. package/lib/reports/drafts.ts +87 -0
  128. package/lib/reports/end-hook.ts +17 -0
  129. package/lib/reports/estimate.ts +107 -0
  130. package/lib/reports/evidence/build.ts +193 -0
  131. package/lib/reports/evidence/gather.ts +229 -0
  132. package/lib/reports/fixtures.ts +17 -0
  133. package/lib/reports/generate.ts +159 -0
  134. package/lib/reports/hooks.ts +30 -0
  135. package/lib/reports/mode.ts +65 -0
  136. package/lib/reports/model.ts +115 -0
  137. package/lib/reports/read.ts +26 -0
  138. package/lib/reports/store.ts +180 -0
  139. package/lib/reports/template.ts +117 -0
  140. package/lib/reports/types.ts +135 -0
  141. package/lib/reports/validate.ts +142 -0
  142. package/lib/sidebar/groupDrop.ts +36 -0
  143. package/lib/sidebar/row-menu.ts +9 -0
  144. package/lib/tasks/contract/routes.ts +5 -0
  145. package/lib/tasks/contract/types.ts +3 -0
  146. package/lib/tasks/finish.ts +2 -0
  147. package/lib/tasks/move-carry.ts +1 -0
  148. package/lib/windows-ports.ts +36 -18
  149. package/package.json +2 -2
  150. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,223 @@
1
+ // The developer-analyst loop: types shared by the engine, the routes and
2
+ // the browser (no Node modules here).
3
+
4
+ export const CRITERION_STATUSES = ["open", "failing", "met", "waived"] as const;
5
+ export type CriterionStatus = (typeof CRITERION_STATUSES)[number];
6
+
7
+ export const CRITERION_SOURCES = ["card", "brief", "plan", "user"] as const;
8
+ export type CriterionSource = (typeof CRITERION_SOURCES)[number];
9
+
10
+ export const EVIDENCE_KINDS = ["test", "diff", "typecheck", "ci"] as const;
11
+ export type EvidenceKind = (typeof EVIDENCE_KINDS)[number];
12
+
13
+ export interface Evidence {
14
+ kind: EvidenceKind;
15
+ ref: string;
16
+ note: string;
17
+ }
18
+
19
+ export interface Criterion {
20
+ id: number;
21
+ loopId: string;
22
+ position: number;
23
+ text: string;
24
+ source: CriterionSource;
25
+ evidenceKind: EvidenceKind | null;
26
+ status: CriterionStatus;
27
+ evidence: Evidence | null;
28
+ // False when the analyst said "met" but the citation didn't resolve.
29
+ verified: boolean;
30
+ metCycle: number | null;
31
+ waivedBy: string | null;
32
+ }
33
+
34
+ export interface CriterionDraft {
35
+ text: string;
36
+ source?: CriterionSource;
37
+ evidenceKind?: EvidenceKind | null;
38
+ }
39
+
40
+ export type LoopState = "running" | "stopped";
41
+
42
+ export const STOP_REASONS = [
43
+ "stopped",
44
+ "blocked",
45
+ "done",
46
+ "repeat",
47
+ "no_progress",
48
+ "limit:cycles",
49
+ "limit:cost",
50
+ "limit:time",
51
+ ] as const;
52
+ export type StopReason = (typeof STOP_REASONS)[number];
53
+
54
+ export interface Limits {
55
+ maxCycles: number;
56
+ maxCostUsd: number;
57
+ maxMinutes: number;
58
+ graceSeconds: number;
59
+ stallCycles: number;
60
+ repeatCycles: number;
61
+ }
62
+
63
+ export const DEFAULT_LIMITS: Limits = {
64
+ maxCycles: 8,
65
+ maxCostUsd: 15,
66
+ maxMinutes: 180,
67
+ graceSeconds: 30,
68
+ stallCycles: 3,
69
+ repeatCycles: 3,
70
+ };
71
+
72
+ export const LIMIT_BOUNDS = {
73
+ maxCycles: [1, 30],
74
+ maxCostUsd: [1, 200],
75
+ maxMinutes: [10, 24 * 60],
76
+ graceSeconds: [0, 300],
77
+ stallCycles: [2, 10],
78
+ repeatCycles: [2, 10],
79
+ } as const satisfies Record<keyof Limits, readonly [number, number]>;
80
+
81
+ export interface LoopRow extends Limits {
82
+ id: string;
83
+ sessionId: string;
84
+ workspaceId: string | null;
85
+ state: LoopState;
86
+ stopReason: StopReason | null;
87
+ stopDetail: string | null;
88
+ goal: string;
89
+ costBaseUsd: number;
90
+ startedAt: number;
91
+ endedAt: number | null;
92
+ lastCycleAt: number | null;
93
+ runNow: boolean;
94
+ }
95
+
96
+ export type CycleTrigger = "turn_end" | "ci" | "review" | "manual";
97
+
98
+ // Where a cycle is. The steps before "decided" are redone after a restart;
99
+ // "sending" is safe to redo because the message goes by a fixed id.
100
+ export type CycleState =
101
+ | "claimed"
102
+ | "decided"
103
+ | "counting"
104
+ | "sending"
105
+ | "sent"
106
+ | "skipped"
107
+ | "stopped"
108
+ | "superseded"
109
+ | "error";
110
+
111
+ export type Sender = "analyst" | "user";
112
+
113
+ export interface Finding {
114
+ severity: "blocking" | "minor";
115
+ file?: string;
116
+ line?: number;
117
+ text: string;
118
+ }
119
+
120
+ export interface Analysis {
121
+ summary: string;
122
+ findings: Finding[];
123
+ criteria: {
124
+ id: number;
125
+ status: "met" | "open" | "failing";
126
+ evidence?: Evidence;
127
+ }[];
128
+ next: { instruction: string; focus: number[] } | null;
129
+ blocker: {
130
+ kind:
131
+ | "decision"
132
+ | "credential"
133
+ | "hard-line"
134
+ | "unclear-criteria"
135
+ | "environment";
136
+ text: string;
137
+ } | null;
138
+ }
139
+
140
+ export interface CommandResult {
141
+ name: "typecheck" | "test";
142
+ command: string;
143
+ exitCode: number | null;
144
+ durationMs: number;
145
+ passed: string[];
146
+ failed: string[];
147
+ errors: string[];
148
+ excerpt: string;
149
+ skipped?: string;
150
+ }
151
+
152
+ export interface EvidenceSummary {
153
+ green: boolean;
154
+ commands: CommandResult[];
155
+ ci: { name: string; state: string; sha?: string | null }[];
156
+ note?: string;
157
+ }
158
+
159
+ // The numbers "progress" is judged by: more met criteria, fewer failing
160
+ // tests, fewer type errors.
161
+ export interface Score {
162
+ met: number;
163
+ failingTests: number;
164
+ typeErrors: number;
165
+ }
166
+
167
+ export interface CycleRow {
168
+ id: number;
169
+ loopId: string;
170
+ n: number;
171
+ trigger: CycleTrigger;
172
+ state: CycleState;
173
+ snapshotSha: string | null;
174
+ tree: string | null;
175
+ changed: boolean;
176
+ evidence: EvidenceSummary | null;
177
+ logPath: string | null;
178
+ analysis: Analysis | null;
179
+ signature: string | null;
180
+ score: Score | null;
181
+ instruction: string | null;
182
+ instructionSent: string | null;
183
+ sender: Sender | null;
184
+ delivery: string | null;
185
+ sendAt: number | null;
186
+ input: string | null;
187
+ costUsd: number;
188
+ error: string | null;
189
+ startedAt: number;
190
+ endedAt: number | null;
191
+ }
192
+
193
+ // What the loop view shows.
194
+ export interface LoopView {
195
+ loop: LoopRow;
196
+ // Why nothing is moving right now, when something is holding it.
197
+ hold: "paused" | "usage-window" | null;
198
+ holdReason: string | null;
199
+ spentUsd: number;
200
+ elapsedMs: number;
201
+ criteria: Criterion[];
202
+ cycles: CycleRow[];
203
+ }
204
+
205
+ // What the pill on a task row shows.
206
+ export interface LoopSummary {
207
+ state: LoopState;
208
+ stopReason: StopReason | null;
209
+ cycle: number;
210
+ maxCycles: number;
211
+ met: number;
212
+ total: number;
213
+ hold: "paused" | "usage-window" | null;
214
+ }
215
+
216
+ export class LoopError extends Error {
217
+ constructor(
218
+ message: string,
219
+ readonly status = 409
220
+ ) {
221
+ super(message);
222
+ }
223
+ }
@@ -0,0 +1,223 @@
1
+ // A stubbed world for the loop: a coder that ends turns and changes files, an
2
+ // analyst that answers from a script, and a checks run that reports what the
3
+ // scenario says, on a clock the test moves. No model, shell or chat.
4
+ import { db, updateSessionRow } from "@/lib/db";
5
+ import { createProject } from "@/lib/projects";
6
+ import { createWorkspace, setProjectWorkspace } from "@/lib/workspaces";
7
+ import { ensureOrchestrator } from "@/lib/orchestrator/home";
8
+ import { seedSession } from "@/lib/orchestrator/testing";
9
+ import type { SessionFacts } from "@/lib/orchestrator/facts";
10
+ import type { LoopDeps } from "./deps";
11
+ import { resetLoopMemory, tickLoops } from "./tickLoops";
12
+ import { startLoop } from "./controls";
13
+ import { latestLoopFor } from "./store";
14
+ import type { Analysis, CommandResult, Limits } from "./types";
15
+
16
+ export interface Scenario {
17
+ // The failing tests (and so the signature) of cycle n; [] is green.
18
+ failing: (n: number) => string[];
19
+ // How many criteria the analyst marks met in cycle n (citing the diff).
20
+ met: (n: number) => number;
21
+ // Spend by the end of cycle n, in dollars.
22
+ spent?: (n: number) => number;
23
+ // The coder changed nothing in this cycle's turn.
24
+ unchanged?: (n: number) => boolean;
25
+ // Words the analyst gives as the next instruction.
26
+ instruction?: (n: number) => string | null;
27
+ blocker?: (n: number) => Analysis["blocker"];
28
+ }
29
+
30
+ export const CRITERIA = Array.from({ length: 10 }, (_, i) => ({
31
+ text: `Criterion ${i + 1}`,
32
+ }));
33
+
34
+ export async function makeWorld(
35
+ scenario: Scenario,
36
+ limits: Partial<Limits> = {}
37
+ ) {
38
+ const ws = createWorkspace(
39
+ `loop-ws-${Math.random().toString(36).slice(2, 8)}`
40
+ );
41
+ const project = createProject({
42
+ name: `loop-app-${Math.random().toString(36).slice(2, 8)}`,
43
+ workingDirectory: "/tmp/loop-app",
44
+ });
45
+ setProjectWorkspace(project.id, ws.id);
46
+ ensureOrchestrator(ws.id);
47
+ const taskId = seedSession({
48
+ projectId: project.id,
49
+ name: "loop-task",
50
+ task: true,
51
+ view: "chat",
52
+ branch: "feature/x",
53
+ });
54
+ updateSessionRow(db, taskId, {
55
+ base_branch: "main",
56
+ task_prompt: "Build the thing",
57
+ worktree_path: "/tmp/loop-app",
58
+ workspace_id: ws.id,
59
+ });
60
+
61
+ const world = {
62
+ ws: ws.id,
63
+ taskId,
64
+ t: Date.parse("2026-10-21T12:00:00Z"),
65
+ rev: 1,
66
+ cycle: 0,
67
+ spent: 0,
68
+ sent: [] as string[],
69
+ running: false,
70
+ prompts: [] as string[],
71
+ fact: {
72
+ id: taskId,
73
+ name: "loop-task",
74
+ project: project.id,
75
+ view: "chat",
76
+ status: "idle",
77
+ activity: null,
78
+ needsInput: false,
79
+ lastActive: 0,
80
+ branch: "feature/x",
81
+ task: { state: "working", pr: null, blocked: null },
82
+ stack: null,
83
+ } as unknown as SessionFacts,
84
+ };
85
+ world.fact.lastActive = world.t - 10 * 60_000;
86
+
87
+ const deps: LoopDeps = {
88
+ now: () => world.t,
89
+ task: (id) =>
90
+ id === taskId
91
+ ? (db
92
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
93
+ .get(id) as never)
94
+ : undefined,
95
+ prepare: async () => ({
96
+ repo: "/tmp/loop-app",
97
+ base: "origin/main",
98
+ dir: "/tmp/loop-app",
99
+ }),
100
+ snapshot: async () => ({
101
+ sha: `sha${world.rev}`,
102
+ tree: `tree${world.rev}`,
103
+ }),
104
+ commands: async () => ({ typecheck: null, test: "npm test" }),
105
+ testFiles: async () => [],
106
+ gather: async () => {
107
+ const n = world.cycle;
108
+ const failing = scenario.failing(n);
109
+ const test: CommandResult = {
110
+ name: "test",
111
+ command: "npm test",
112
+ exitCode: failing.length ? 1 : 0,
113
+ durationMs: 1000,
114
+ passed: failing.length ? [] : ["all.test.ts"],
115
+ failed: failing,
116
+ errors: [],
117
+ excerpt: failing.length ? failing.join("\n") : "",
118
+ };
119
+ return {
120
+ summary: { green: !failing.length, commands: [test], ci: [] },
121
+ signature: failing.length ? `test: ${failing.join("; ")}` : null,
122
+ logPath: null,
123
+ };
124
+ },
125
+ diff: async () => ({
126
+ map: "diff",
127
+ hunks: new Map([["a.ts", [[1, 100] as [number, number]]]]),
128
+ }),
129
+ analyse: async (input) => {
130
+ const n = world.cycle;
131
+ world.prompts.push(input.prompt.goal);
132
+ world.spent = scenario.spent?.(n) ?? world.spent;
133
+ const met = scenario.met(n);
134
+ const criteria = input.prompt.criteria;
135
+ return {
136
+ costUsd: 0,
137
+ analysis: {
138
+ summary: `Cycle ${n} summary`,
139
+ findings: [],
140
+ criteria: criteria.map((c, i) => ({
141
+ id: c.id,
142
+ status: i < met ? ("met" as const) : ("open" as const),
143
+ ...(i < met
144
+ ? {
145
+ evidence: {
146
+ kind: "diff" as const,
147
+ ref: "a.ts:5",
148
+ note: "changed",
149
+ },
150
+ }
151
+ : {}),
152
+ })),
153
+ next:
154
+ scenario.instruction?.(n) === null
155
+ ? null
156
+ : {
157
+ instruction:
158
+ scenario.instruction?.(n) ??
159
+ `Fix the failures from cycle ${n}`,
160
+ focus: [],
161
+ },
162
+ blocker: scenario.blocker?.(n) ?? null,
163
+ },
164
+ };
165
+ },
166
+ send: async (_task, _loopId, _n, text) => {
167
+ world.sent.push(text);
168
+ world.running = true;
169
+ return "sent";
170
+ },
171
+ coderReply: () => null,
172
+ coderRunning: async () => false,
173
+ amendments: () => [],
174
+ spent: () => world.spent,
175
+ hold: () => null,
176
+ waitingOnPerson: () => null,
177
+ };
178
+ // The cycle number gather and analyse see is the one being claimed.
179
+ const real = deps.snapshot;
180
+ deps.snapshot = async (dir) => {
181
+ world.cycle = latestCycleN() ?? 0;
182
+ return real(dir);
183
+ };
184
+ const latestCycleN = () => {
185
+ const loop = latestLoopFor(taskId);
186
+ return loop
187
+ ? (
188
+ db
189
+ .prepare(
190
+ `SELECT MAX(n) AS n FROM task_loop_cycles WHERE loop_id = ?`
191
+ )
192
+ .get(loop.id) as { n: number | null }
193
+ ).n
194
+ : null;
195
+ };
196
+
197
+ resetLoopMemory();
198
+ await startLoop(
199
+ taskId,
200
+ { criteria: CRITERIA, limits: { graceSeconds: 0, ...limits } },
201
+ deps
202
+ );
203
+
204
+ const tick = () => tickLoops(world.ws, [world.fact], deps);
205
+ const state = () => latestLoopFor(taskId)!;
206
+ // The coder takes a turn (runs, then goes quiet), then the loop looks.
207
+ async function round() {
208
+ world.fact.status = "running";
209
+ await tick();
210
+ world.t += 60_000;
211
+ world.fact.status = "idle";
212
+ world.fact.lastActive = world.t - 30_000;
213
+ if (!scenario.unchanged?.(world.cycle)) world.rev++;
214
+ await tick();
215
+ }
216
+ async function run(maxRounds = 40) {
217
+ await tick();
218
+ for (let i = 0; i < maxRounds && state().state === "running"; i++)
219
+ await round();
220
+ return state();
221
+ }
222
+ return { world, deps, tick, round, run, state };
223
+ }
@@ -22,6 +22,7 @@ import { queueEvent } from "../orchestrator/events";
22
22
  import { recordAutoApproval } from "../approvals/no-approvals";
23
23
  import { addNote } from "../orchestrator/notes";
24
24
  import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
25
+ import { ACCEPTANCE_PROMPT } from "../plans/acceptance";
25
26
  import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
26
27
  import { orchestratorOf } from "../tasks/starter";
27
28
  import { isRemoteHost } from "../hosts";
@@ -46,7 +47,9 @@ export function planPrompt(task: string, only = false): string {
46
47
  ---
47
48
  Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.
48
49
 
49
- ${PIECES_PROMPT}`;
50
+ ${PIECES_PROMPT}
51
+
52
+ ${ACCEPTANCE_PROMPT}`;
50
53
  }
51
54
 
52
55
  export const PLAN_RETRY_PROMPT = `Write the plan to \`${PLAN_FILE}\` now, plan only.`;
@@ -7,6 +7,7 @@
7
7
  * is by id, so the same database gives the same text.
8
8
  */
9
9
 
10
+ import { loopDigestNote } from "../loops/summary";
10
11
  import { db } from "../db";
11
12
  import { openAsks } from "./asks";
12
13
  import { listRules } from "./rules";
@@ -185,7 +186,7 @@ function tasksSection(
185
186
  ? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}${t.pause_note ? ` (${t.pause_note})` : ""}`
186
187
  : "";
187
188
  const placed = t.placed_note ? ` | ${t.placed_note}` : "";
188
- return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${gates ? ` | ${gates}` : ""}`;
189
+ return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${loopDigestNote(t.id)}${gates ? ` | ${gates}` : ""}`;
189
190
  }),
190
191
  empty: "- None.",
191
192
  more: "the sessions tool",
@@ -76,9 +76,10 @@ const ERROR_LINE =
76
76
  /##\[error\]|\berror\b|\bfail(ed|ure|s)?\b|AssertionError|panic:/i;
77
77
  const RUNNER_EXIT = /Process completed with exit code/;
78
78
 
79
- // A CI failure: the job, then its failing tests, or its first error line.
79
+ // A failure's log (CI, or a local test run): the job or command, then its
80
+ // failing tests, or its first error line.
80
81
  // Null when there's nothing to tell one failure from another.
81
- export function ciSignature(
82
+ export function failureSignature(
82
83
  check: string | null | undefined,
83
84
  log: string | null
84
85
  ): string | null {
@@ -100,6 +101,8 @@ export function ciSignature(
100
101
  return error ? cap(`${check}: ${normalize(error)}`) : null;
101
102
  }
102
103
 
104
+ export const ciSignature = failureSignature;
105
+
103
106
  // A review's blocking findings, by where they are: the reviewer words the
104
107
  // same finding differently each time, but not its file and line. A finding
105
108
  // with no place is known by the start of its summary.
@@ -42,6 +42,7 @@ import { isPaused } from "./pause";
42
42
  import { repoOf } from "./repo";
43
43
  import { untrusted, UNTRUSTED_RULE } from "./untrusted";
44
44
  import { setPr } from "../db/session-parts";
45
+ import { liveLoopFor } from "../loops/store";
45
46
 
46
47
  export const COMMENT_WAKE_CAP = 10;
47
48
  // A first look at a PR this young takes its whole history as new.
@@ -170,6 +171,9 @@ export async function wakeTasks(
170
171
  for (const w of watched) {
171
172
  if (isPaused(workspaceId)) return;
172
173
  if (w.pr.checks !== "fail" || !w.pr.failing) continue;
174
+ // A running loop reads CI as part of its evidence and tells the coder
175
+ // in its next instruction: a task never has two steerers.
176
+ if (liveLoopFor(w.task.id)) continue;
173
177
  await wakeOnCheck(workspaceId, w, deps).catch((error) =>
174
178
  console.error(`Waking ${w.task.name} on CI:`, error)
175
179
  );
@@ -26,6 +26,8 @@ import { followAskVerdicts } from "./ask-verdict";
26
26
  import { raiseHeldAsks } from "./held-asks";
27
27
  import { rebaseConflicting } from "./rebase";
28
28
  import { wakeTasks } from "./pr-wake";
29
+ import { tickLoops } from "../loops/tickLoops";
30
+ import { installReportHooks } from "../reports/hooks";
29
31
  import { awayTick } from "../away/run";
30
32
  import { healthTick } from "../health/tick";
31
33
  import { observeCi } from "../health/ci";
@@ -93,7 +95,8 @@ export async function diffWorkspace(
93
95
  return nextDiffAt(now, facts, working);
94
96
  }
95
97
 
96
- // Wakes tasks whose PRs failed CI or got reviews, rebases what conflicts
98
+ // Wakes tasks whose PRs failed CI or got reviews, advances developer-analyst
99
+ // loops, rebases what conflicts
97
100
  // with its base, then merges what's armed and passes. In the background, one run per workspace at a time, so a git
98
101
  // fetch or a merge never holds up event delivery.
99
102
  const piping = new Set<string>();
@@ -105,6 +108,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
105
108
  await wakeTasks(workspaceId, facts).catch((error) =>
106
109
  console.error(`Waking tasks in ${workspaceId}:`, error)
107
110
  );
111
+ await tickLoops(workspaceId, facts).catch((error) =>
112
+ console.error(`Ticking loops in ${workspaceId}:`, error)
113
+ );
108
114
  await followAskHeads(workspaceId, facts).catch((error) =>
109
115
  console.error(`Following ask heads in ${workspaceId}:`, error)
110
116
  );
@@ -189,6 +195,7 @@ async function tick(): Promise<void> {
189
195
  }
190
196
 
191
197
  export function startOrchestratorWatcher(): void {
198
+ installReportHooks();
192
199
  if (timer) return;
193
200
  timer = setInterval(() => void tick(), DELIVER_EVERY_MS);
194
201
  timer.unref();
package/lib/panes.ts CHANGED
@@ -208,6 +208,58 @@ export function closePane(state: PaneState, paneId: string): PaneState | null {
208
208
  };
209
209
  }
210
210
 
211
+ export interface OpenSession {
212
+ sessionId: string;
213
+ tmux: string;
214
+ hostId?: string | null;
215
+ }
216
+
217
+ // Shows a session beside a pane (an analyst beside its coder): where a pane
218
+ // already shows it, that one; else in a new pane to its right; with no room
219
+ // for another pane, in a new tab of this one.
220
+ export function openBeside(
221
+ state: PaneState,
222
+ paneId: string,
223
+ open: OpenSession
224
+ ): PaneState {
225
+ for (const id of getAllPaneIds(state.layout)) {
226
+ const pane = state.panes[id];
227
+ const tab = pane?.tabs.find((t) => t.sessionId === open.sessionId);
228
+ if (tab)
229
+ return {
230
+ ...state,
231
+ focusedPaneId: id,
232
+ panes: { ...state.panes, [id]: { ...pane, activeTabId: tab.id } },
233
+ };
234
+ }
235
+ const tab: TabData = {
236
+ ...createTab(),
237
+ sessionId: open.sessionId,
238
+ attachedTmux: open.tmux,
239
+ attachedHost: open.hostId ?? null,
240
+ };
241
+ const split = state.panes[paneId] && splitPane(state, paneId, "horizontal");
242
+ if (split)
243
+ return {
244
+ ...split,
245
+ panes: {
246
+ ...split.panes,
247
+ [split.focusedPaneId]: { tabs: [tab], activeTabId: tab.id },
248
+ },
249
+ };
250
+ const pane = state.panes[paneId] ?? state.panes[state.focusedPaneId];
251
+ if (!pane) return state;
252
+ const target = state.panes[paneId] ? paneId : state.focusedPaneId;
253
+ return {
254
+ ...state,
255
+ focusedPaneId: target,
256
+ panes: {
257
+ ...state.panes,
258
+ [target]: { tabs: [...pane.tabs, tab], activeTabId: tab.id },
259
+ },
260
+ };
261
+ }
262
+
211
263
  // Get all pane IDs from layout
212
264
  export function getAllPaneIds(layout: PaneLayout): string[] {
213
265
  if (layout.type === "leaf") {
@@ -0,0 +1,23 @@
1
+ // A plan's optional "## Acceptance" section: a checklist of observable
2
+ // outcomes, parsed like the "## Pieces" block.
3
+
4
+ const MAX_ITEMS = 12;
5
+ const MAX_LEN = 240;
6
+
7
+ export function parseAcceptance(plan: string | null | undefined): string[] {
8
+ if (!plan) return [];
9
+ const lines = plan.split("\n");
10
+ const start = lines.findIndex((l) => /^##\s+Acceptance\s*$/i.test(l.trim()));
11
+ if (start < 0) return [];
12
+ const items: string[] = [];
13
+ for (const line of lines.slice(start + 1)) {
14
+ if (/^#{1,6}\s/.test(line)) break;
15
+ const m = /^\s*[-*]\s+\[[ xX]\]\s+(.+?)\s*$/.exec(line);
16
+ if (!m) continue;
17
+ items.push(m[1].slice(0, MAX_LEN));
18
+ if (items.length >= MAX_ITEMS) break;
19
+ }
20
+ return items;
21
+ }
22
+
23
+ export const ACCEPTANCE_PROMPT = `Also end the plan with an \`## Acceptance\` section: 3 to 8 checklist lines (\`- [ ] ...\`), each one outcome a test, a CI check or the review could show is true. They become the progress report's completion estimate.`;