@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,229 @@
1
+ // Reads everything a report is built from: the task's state, its checks and
2
+ // review, the diff, the plan, the tests it ran, what waits on a human and,
3
+ // once merged, its log. No model is involved.
4
+
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ import { git } from "../../git-exec";
8
+ import { db, type Session } from "../../db";
9
+ import { taskView } from "../../tasks";
10
+ import { taskChecks } from "../../tasks/checks-view";
11
+ import { expandHome, localWorktree } from "../../tasks/session";
12
+ import { PLAN_FILE } from "../../models/plan-step";
13
+ import { parseAcceptance } from "../../plans/acceptance";
14
+ import { lastItems } from "../../chat/store";
15
+ import { turnSignals } from "../../analyst/signals";
16
+ import { getLog, headerOf } from "../../session-logs/store";
17
+ import { pieceOfSession } from "../../plans/features";
18
+ import { liveLinkFor, listRecommendations } from "../../analyst/store";
19
+ import { criteriaOf, latestLoopFor } from "../../loops/store";
20
+ import { isFinished, type TaskState } from "../../tasks/state";
21
+ import { scrub } from "../../session-audit/normalize";
22
+ import type { AcceptanceItem, Stages } from "../estimate";
23
+ import type { EvidenceRef, NextPromptDraft } from "../types";
24
+ import {
25
+ acceptEvidence,
26
+ askEvidence,
27
+ ciEvidence,
28
+ diffEvidence,
29
+ evidenceHash,
30
+ logEvidence,
31
+ reviewEvidence,
32
+ stateEvidence,
33
+ testEvidence,
34
+ } from "./build";
35
+
36
+ export interface Gathered {
37
+ state: TaskState;
38
+ head: string | null;
39
+ evidence: EvidenceRef[];
40
+ hash: string;
41
+ items: AcceptanceItem[];
42
+ stages: Stages;
43
+ analystDraft: NextPromptDraft | null;
44
+ }
45
+
46
+ async function readPlan(s: Session): Promise<string | null> {
47
+ const dir = s.worktree_path ? expandHome(s.worktree_path) : null;
48
+ if (!dir) return null;
49
+ try {
50
+ return await fs.readFile(path.join(dir, PLAN_FILE), "utf8");
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ async function numstat(s: Session): Promise<string> {
57
+ const dir = localWorktree(s);
58
+ if (!dir) return "";
59
+ const base = (s.base_branch ?? "main").replace(/^origin\//, "");
60
+ try {
61
+ return await git(dir, ["diff", "--numstat", `origin/${base}...HEAD`], {
62
+ timeout: 10_000,
63
+ });
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ function openAsks(s: Session) {
70
+ if (!s.workspace_id) return [];
71
+ return db
72
+ .prepare(
73
+ `SELECT id, title, detail, created_at FROM orchestrator_asks
74
+ WHERE workspace_id = ? AND subject = ? AND status = 'open'`
75
+ )
76
+ .all(s.workspace_id, `task:${s.id}`) as {
77
+ id: number;
78
+ title: string;
79
+ detail: string;
80
+ created_at: string;
81
+ }[];
82
+ }
83
+
84
+ export async function gather(
85
+ s: Session,
86
+ extra?: { analystSummary?: string | null }
87
+ ): Promise<Gathered> {
88
+ const at = new Date().toISOString();
89
+ const [view, checks, plan, stat] = await Promise.all([
90
+ taskView(s),
91
+ taskChecks(s, { logs: true }).catch(() => null),
92
+ readPlan(s),
93
+ numstat(s),
94
+ ]);
95
+ const head = checks?.pr?.head ?? view.pr?.head ?? null;
96
+ const evidence: EvidenceRef[] = [
97
+ stateEvidence(view.state, view.blocked, at),
98
+ ...ciEvidence(checks?.checks ?? [], head, at),
99
+ ...reviewEvidence(checks?.review ?? null),
100
+ ...diffEvidence(stat, s.id, at),
101
+ ...askEvidence(openAsks(s)),
102
+ ];
103
+
104
+ if (s.view === "chat")
105
+ evidence.push(
106
+ ...testEvidence(turnSignals(lastItems(s.id, 600)), s.id, head, at)
107
+ );
108
+
109
+ const piece = pieceOfSession(s.id);
110
+ if (piece)
111
+ evidence.push({
112
+ id: `piece:${piece.position}`,
113
+ kind: "piece",
114
+ label: `Piece "${piece.name}" is ${piece.status}`,
115
+ status:
116
+ piece.status === "merged"
117
+ ? "pass"
118
+ : piece.status === "blocked"
119
+ ? "fail"
120
+ : "info",
121
+ at,
122
+ });
123
+
124
+ if (extra?.analystSummary)
125
+ evidence.push({
126
+ id: "state:analyst",
127
+ kind: "state",
128
+ label: "The analyst's summary of the last cycle",
129
+ status: "info",
130
+ at,
131
+ excerpt: scrub(extra.analystSummary).slice(0, 1500),
132
+ });
133
+
134
+ const log = isFinished(view.state) ? getLog(s.id) : null;
135
+ if (log) {
136
+ const h = headerOf(log);
137
+ evidence.push(
138
+ logEvidence({
139
+ sessionId: s.id,
140
+ mergeCommit: h.mergeCommit,
141
+ prNumber: h.prNumber,
142
+ durationMs: h.durationMs,
143
+ costUsd: h.costUsd,
144
+ createdAt: h.createdAt,
145
+ })
146
+ );
147
+ }
148
+
149
+ // Acceptance: the plan's checklist, else the loop's criteria. A loop
150
+ // criterion is met only when its cited evidence resolved (verified).
151
+ let items: AcceptanceItem[] = [];
152
+ const planItems = parseAcceptance(plan);
153
+ if (planItems.length) {
154
+ items = planItems.map((label) => ({ label }));
155
+ evidence.push(
156
+ ...acceptEvidence(
157
+ planItems.map((label) => ({ label, met: false })),
158
+ at
159
+ )
160
+ );
161
+ } else {
162
+ const loop = latestLoopFor(s.id);
163
+ const crit = loop
164
+ ? criteriaOf(loop.id).filter((c) => c.status !== "waived")
165
+ : [];
166
+ if (crit.length) {
167
+ const met = (c: (typeof crit)[number]) =>
168
+ c.status === "met" && c.verified;
169
+ evidence.push(
170
+ ...acceptEvidence(
171
+ crit.map((c) => ({
172
+ label: c.text,
173
+ met: met(c),
174
+ note: c.evidence?.note,
175
+ })),
176
+ at
177
+ )
178
+ );
179
+ items = crit.map((c, i) => ({
180
+ label: c.text,
181
+ evidence: met(c) ? [`accept:${i + 1}`] : undefined,
182
+ }));
183
+ }
184
+ }
185
+
186
+ const merged = view.state === "merged";
187
+ const pr = checks?.pr ?? null;
188
+ const ci = evidence.filter((e) => e.kind === "ci");
189
+ const stages: Stages = {
190
+ planned:
191
+ !!plan ||
192
+ s.plan_phase === "implementing" ||
193
+ s.plan_phase === "handoff" ||
194
+ !!pr,
195
+ pushed: !!pr || evidence.some((e) => e.kind === "diff"),
196
+ prOpen: !!pr,
197
+ ciGreen: ci.length > 0 && ci.every((e) => e.status === "pass"),
198
+ reviewed: evidence.some((e) => e.kind === "review" && e.status === "pass"),
199
+ merged,
200
+ };
201
+
202
+ return {
203
+ state: view.state,
204
+ head,
205
+ evidence,
206
+ hash: evidenceHash(evidence, head),
207
+ items,
208
+ stages,
209
+ analystDraft: analystDraft(s.id),
210
+ };
211
+ }
212
+
213
+ // The analyst's open recommendation with a prompt, when the task has one.
214
+ export function analystDraft(sessionId: string): NextPromptDraft | null {
215
+ const link = liveLinkFor(sessionId);
216
+ if (!link) return null;
217
+ const rec = listRecommendations(link.id, 5).find(
218
+ (r) => r.state === "open" && r.prompt
219
+ );
220
+ return rec
221
+ ? {
222
+ source: "analyst",
223
+ id: rec.id,
224
+ text: rec.prompt!,
225
+ status: "pending",
226
+ linkId: link.id,
227
+ }
228
+ : null;
229
+ }
@@ -0,0 +1,17 @@
1
+ import type { EvidenceRef } from "./types";
2
+
3
+ export const HEAD = "abc1234";
4
+
5
+ export const ev = (
6
+ id: string,
7
+ kind: EvidenceRef["kind"],
8
+ status: EvidenceRef["status"],
9
+ extra: Partial<EvidenceRef> = {}
10
+ ): EvidenceRef => ({
11
+ id,
12
+ kind,
13
+ label: extra.label ?? id,
14
+ status,
15
+ at: "2026-10-21T12:00:00.000Z",
16
+ ...extra,
17
+ });
@@ -0,0 +1,159 @@
1
+ // Builds, stores and serves a task's health report. Evidence first (server
2
+ // code), words second (the analyst model), then every claim is checked and
3
+ // the estimate is computed here. Cached on an evidence hash; one generation
4
+ // per task at a time.
5
+
6
+ import { db, type Session } from "../db";
7
+ import type { ClaudeRunner } from "../orchestrator/claude-cli";
8
+ import type { RunUsage } from "../usage/check-runs";
9
+ import { isFinished } from "../tasks/state";
10
+ import { gather, type Gathered } from "./evidence/gather";
11
+ import { computeEstimate } from "./estimate";
12
+ import { askModel, evidencePack } from "./model";
13
+ import { modeOf, workspaceOf } from "./mode";
14
+ import { hashOfLatest, insertReport, latestReport } from "./store";
15
+ import { headlineFor, templateLines } from "./template";
16
+ import { validateModelReport } from "./validate";
17
+ import type { HealthReport, ReportTrigger } from "./types";
18
+ import { notifyTopic } from "../status/hub";
19
+
20
+ export const reportTopic = (sessionId: string) => `report:${sessionId}`;
21
+
22
+ const inFlight = new Map<string, Promise<HealthReport>>();
23
+ export const isGenerating = (sessionId: string) => inFlight.has(sessionId);
24
+
25
+ export interface GenerateOptions {
26
+ analystSummary?: string | null;
27
+ runner?: ClaudeRunner;
28
+ // Build a new report even when the evidence is unchanged.
29
+ force?: boolean;
30
+ }
31
+
32
+ export function buildReport(
33
+ s: Pick<Session, "id" | "name" | "task_prompt">,
34
+ g: Gathered,
35
+ trigger: ReportTrigger,
36
+ raw: unknown | null,
37
+ usage: RunUsage | null
38
+ ): Omit<HealthReport, "id" | "createdAt" | "stale"> {
39
+ const v = raw === null ? null : validateModelReport(raw, g.evidence);
40
+ const fromModel = !!v && (v.lines.length > 0 || !!v.recommendation);
41
+ const tmpl = templateLines(g.state, g.evidence);
42
+ const estimate = computeEstimate({
43
+ head: g.head,
44
+ evidence: g.evidence,
45
+ items: g.items,
46
+ mappings: v?.mappings ?? [],
47
+ stages: g.stages,
48
+ });
49
+ const mode = modeOf(s.id).mode;
50
+ const finished = isFinished(g.state);
51
+ const text = mode !== "manual" && !finished ? (v?.nextPrompt ?? null) : null;
52
+ const draft =
53
+ mode === "manual" || finished
54
+ ? null
55
+ : (g.analystDraft ??
56
+ (text
57
+ ? {
58
+ source: "report" as const,
59
+ id: "",
60
+ text,
61
+ status: "pending" as const,
62
+ }
63
+ : null));
64
+ return {
65
+ sessionId: s.id,
66
+ workspaceId: workspaceOf(s.id),
67
+ head: g.head,
68
+ trigger,
69
+ headline: (fromModel && v?.headline) || headlineFor(g.state),
70
+ state: g.state,
71
+ estimate,
72
+ lines: fromModel ? v!.lines : tmpl.lines,
73
+ recommendation: fromModel ? v!.recommendation : tmpl.recommendation,
74
+ draft,
75
+ evidence: g.evidence,
76
+ source: fromModel ? "model" : "template",
77
+ dropped: v?.dropped ?? 0,
78
+ model: usage?.model ?? null,
79
+ costUsd: usage?.costUsd ?? null,
80
+ };
81
+ }
82
+
83
+ async function run(
84
+ sessionId: string,
85
+ trigger: ReportTrigger,
86
+ opts: GenerateOptions
87
+ ): Promise<HealthReport> {
88
+ const s = db
89
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
90
+ .get(sessionId) as Session | undefined;
91
+ if (!s) throw new Error("No such task");
92
+ const g = await gather(s, { analystSummary: opts.analystSummary });
93
+ const last = latestReport(sessionId, g.head);
94
+ // The same evidence needs no new report, and a task's end report is final.
95
+ if (
96
+ last &&
97
+ !opts.force &&
98
+ (hashOfLatest(sessionId) === g.hash ||
99
+ (last.trigger === "end" && isFinished(g.state)))
100
+ )
101
+ return last;
102
+
103
+ let raw: unknown = null;
104
+ let usage: RunUsage | null = null;
105
+ try {
106
+ raw = await askModel(
107
+ {
108
+ prompt: evidencePack(
109
+ { name: s.name, prompt: s.task_prompt ?? "" },
110
+ g.state,
111
+ g.evidence,
112
+ g.items
113
+ ),
114
+ workspaceId: workspaceOf(sessionId),
115
+ onUsage: (u) => (usage = u),
116
+ },
117
+ opts.runner
118
+ );
119
+ } catch (error) {
120
+ console.error(`Report for ${s.name}:`, error);
121
+ }
122
+ const report = buildReport(s, g, trigger, raw, usage);
123
+ const id = insertReport(report, g.hash);
124
+ notifyTopic(reportTopic(sessionId));
125
+ return (
126
+ latestReport(sessionId, g.head) ?? {
127
+ ...report,
128
+ id,
129
+ createdAt: "",
130
+ stale: false,
131
+ }
132
+ );
133
+ }
134
+
135
+ // One generation per task: a request while one runs shares its result.
136
+ export function generateReport(
137
+ sessionId: string,
138
+ trigger: ReportTrigger,
139
+ opts: GenerateOptions = {}
140
+ ): Promise<HealthReport> {
141
+ const running = inFlight.get(sessionId);
142
+ if (running) return running;
143
+ const p = run(sessionId, trigger, opts).finally(() =>
144
+ inFlight.delete(sessionId)
145
+ );
146
+ inFlight.set(sessionId, p);
147
+ return p;
148
+ }
149
+
150
+ // The trigger entry the loop and the task hooks use: fire and forget.
151
+ export function requestReport(
152
+ sessionId: string,
153
+ trigger: ReportTrigger,
154
+ opts: GenerateOptions & { cycleId?: number } = {}
155
+ ): void {
156
+ generateReport(sessionId, trigger, opts).catch((error) =>
157
+ console.error(`Report ${sessionId}:`, error)
158
+ );
159
+ }
@@ -0,0 +1,30 @@
1
+ // How the developer-analyst loop meets the reports: a loop makes its task
2
+ // Autonomous, every stop hands it back to Assisted with the instruction that
3
+ // wasn't sent kept as a pending draft, and each settled cycle asks for a
4
+ // report.
5
+
6
+ import { setLoopHooks } from "../loops/hooks";
7
+ import { setTaskEndHook } from "./end-hook";
8
+ import { generateReport, requestReport } from "./generate";
9
+ import { setTaskMode } from "./mode";
10
+ import { setDraftText } from "./store";
11
+
12
+ let installed = false;
13
+
14
+ export function installReportHooks(): void {
15
+ if (installed) return;
16
+ installed = true;
17
+ setTaskEndHook((id) => requestReport(id, "end"));
18
+ setLoopHooks({
19
+ onCycleSettled: ({ taskId, analystSummary }) =>
20
+ requestReport(taskId, "cycle", { analystSummary }),
21
+ onStart: (loop) => setTaskMode(loop.sessionId, "autonomous"),
22
+ onStop: (loop, _stop, pending) => {
23
+ setTaskMode(loop.sessionId, "assisted");
24
+ if (!pending) return;
25
+ generateReport(loop.sessionId, "cycle", { force: true })
26
+ .then((r) => setDraftText(r.id, pending, "pending"))
27
+ .catch((error) => console.error("Keeping the loop's draft:", error));
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,65 @@
1
+ // The operating mode: a workspace default and a per-task override. Manual
2
+ // until someone says otherwise. The developer-analyst loop reads it here.
3
+
4
+ import { db } from "../db";
5
+ import { isMode, type OperatingMode } from "./types";
6
+
7
+ export interface ModeOf {
8
+ mode: OperatingMode;
9
+ source: "task" | "workspace" | "default";
10
+ }
11
+
12
+ const read = (
13
+ scope: "task" | "workspace",
14
+ id: string
15
+ ): OperatingMode | null => {
16
+ const row = db
17
+ .prepare(
18
+ `SELECT mode FROM operating_modes WHERE scope = ? AND scope_id = ?`
19
+ )
20
+ .get(scope, id) as { mode: string } | undefined;
21
+ return row && isMode(row.mode) ? row.mode : null;
22
+ };
23
+
24
+ export function workspaceOf(sessionId: string): string | null {
25
+ const row = db
26
+ .prepare(`SELECT workspace_id FROM session_rows WHERE id = ?`)
27
+ .get(sessionId) as { workspace_id: string | null } | undefined;
28
+ return row?.workspace_id ?? null;
29
+ }
30
+
31
+ export function modeOf(sessionId: string): ModeOf {
32
+ const own = read("task", sessionId);
33
+ if (own) return { mode: own, source: "task" };
34
+ const ws = workspaceOf(sessionId);
35
+ const inherited = ws ? read("workspace", ws) : null;
36
+ return inherited
37
+ ? { mode: inherited, source: "workspace" }
38
+ : { mode: "manual", source: "default" };
39
+ }
40
+
41
+ export const workspaceMode = (workspaceId: string): OperatingMode =>
42
+ read("workspace", workspaceId) ?? "manual";
43
+
44
+ // null clears the task's override so it follows its workspace again.
45
+ function put(
46
+ scope: "task" | "workspace",
47
+ id: string,
48
+ mode: OperatingMode | null
49
+ ) {
50
+ if (mode === null) {
51
+ db.prepare(
52
+ `DELETE FROM operating_modes WHERE scope = ? AND scope_id = ?`
53
+ ).run(scope, id);
54
+ return;
55
+ }
56
+ db.prepare(
57
+ `INSERT INTO operating_modes (scope, scope_id, mode) VALUES (?, ?, ?)
58
+ ON CONFLICT(scope, scope_id) DO UPDATE SET mode = excluded.mode, updated_at = datetime('now')`
59
+ ).run(scope, id, mode);
60
+ }
61
+
62
+ export const setTaskMode = (sessionId: string, mode: OperatingMode | null) =>
63
+ put("task", sessionId, mode);
64
+ export const setWorkspaceMode = (id: string, mode: OperatingMode) =>
65
+ put("workspace", id, mode);
@@ -0,0 +1,115 @@
1
+ // The model's part of a report: word what the evidence shows. It has no field
2
+ // for an estimate, a percentage or a count of finished work; the server
3
+ // computes those (estimate.ts) and checks every claim (validate.ts).
4
+
5
+ import os from "os";
6
+ import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
7
+ import { untrusted, UNTRUSTED_RULE } from "../orchestrator/untrusted";
8
+ import type { RunUsage } from "../usage/check-runs";
9
+ import type { AcceptanceItem } from "./estimate";
10
+ import type { EvidenceRef } from "./types";
11
+ import { MAX_PROMPT } from "./validate";
12
+
13
+ export const REPORT_TIMEOUT_MS = 60_000;
14
+ const PACK_CHARS = 12_000;
15
+
16
+ const SYSTEM = `You write a short plain-English progress report on one software task for someone who does not read code. You are given numbered evidence items (CI checks, test runs, the review, changed files, acceptance items). Rules:
17
+ - Every line must cite the ids of the evidence it rests on, copied exactly. A line with no evidence is thrown away.
18
+ - Say only what the evidence shows. Use plain words: no file paths, no jargon, no stack traces.
19
+ - Never write a percentage, a completion estimate or a count of finished work: the system works those out. Any number you write must appear in the evidence you cite.
20
+ - Tones: "done" for something that passed or happened, "warn" for something failing or still running, "blocked" for something waiting on a person, "next" for what should happen next. A "done" line must cite a passing item; a "warn" or "blocked" line must cite a failing, pending or waiting item.
21
+ - "acceptance": for each numbered acceptance item that the evidence proves, give its number and the ids that prove it (a passing test, CI check or review at the latest push; a changed file alone proves nothing). Leave out items you cannot prove.
22
+ - "recommendation": one short paragraph on the single best next step, citing evidence.
23
+ - "nextPrompt": when work should go on, the exact message to send the coding agent, written as an instruction to it (no more than a few sentences); otherwise null.
24
+ ${UNTRUSTED_RULE}`;
25
+
26
+ export const SCHEMA = {
27
+ type: "object",
28
+ additionalProperties: false,
29
+ required: ["headline", "lines", "recommendation", "acceptance", "nextPrompt"],
30
+ properties: {
31
+ headline: { type: "string" },
32
+ lines: {
33
+ type: "array",
34
+ maxItems: 10,
35
+ items: {
36
+ type: "object",
37
+ additionalProperties: false,
38
+ required: ["tone", "text", "evidence"],
39
+ properties: {
40
+ tone: { enum: ["done", "warn", "blocked", "next"] },
41
+ text: { type: "string" },
42
+ evidence: { type: "array", items: { type: "string" } },
43
+ },
44
+ },
45
+ },
46
+ recommendation: {
47
+ type: ["object", "null"],
48
+ additionalProperties: false,
49
+ required: ["text", "evidence"],
50
+ properties: {
51
+ text: { type: "string" },
52
+ evidence: { type: "array", items: { type: "string" } },
53
+ },
54
+ },
55
+ acceptance: {
56
+ type: "array",
57
+ items: {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ required: ["item", "evidence"],
61
+ properties: {
62
+ item: { type: "integer" },
63
+ evidence: { type: "array", items: { type: "string" } },
64
+ },
65
+ },
66
+ },
67
+ nextPrompt: { type: ["string", "null"], maxLength: MAX_PROMPT },
68
+ },
69
+ } as const;
70
+
71
+ export function evidencePack(
72
+ task: { name: string; prompt: string },
73
+ state: string,
74
+ evidence: EvidenceRef[],
75
+ items: AcceptanceItem[]
76
+ ): string {
77
+ const rows = evidence.map(
78
+ (e) =>
79
+ `${e.id} | ${e.kind} | ${e.status} | ${e.label}${
80
+ e.excerpt ? `\n${untrusted(`evidence ${e.id}`, e.excerpt)}` : ""
81
+ }`
82
+ );
83
+ const accept = items.map((it, i) => `${i + 1}. ${it.label}`);
84
+ const body = [
85
+ `Task: ${task.name}`,
86
+ `Brief: ${untrusted("task brief", task.prompt.slice(0, 1200))}`,
87
+ `State: ${state}`,
88
+ accept.length ? `Acceptance items:\n${accept.join("\n")}` : "",
89
+ `Evidence (id | kind | status | label):\n${rows.join("\n")}`,
90
+ ]
91
+ .filter(Boolean)
92
+ .join("\n\n");
93
+ return body.slice(0, PACK_CHARS);
94
+ }
95
+
96
+ export async function askModel(
97
+ input: {
98
+ prompt: string;
99
+ workspaceId: string | null;
100
+ onUsage: (u: RunUsage) => void;
101
+ },
102
+ runner: ClaudeRunner = runClaude
103
+ ): Promise<unknown> {
104
+ return runner({
105
+ cwd: os.tmpdir(),
106
+ system: SYSTEM,
107
+ prompt: input.prompt,
108
+ schema: SCHEMA,
109
+ tools: [],
110
+ timeoutMs: REPORT_TIMEOUT_MS,
111
+ role: "analyze",
112
+ workspaceId: input.workspaceId,
113
+ onUsage: input.onUsage,
114
+ });
115
+ }
@@ -0,0 +1,26 @@
1
+ // The report a client sees: the stored one, with the current draft laid over
2
+ // it and its staleness against the task's head.
3
+
4
+ import { isFinished } from "../tasks/state";
5
+ import { analystDraft } from "./evidence/gather";
6
+ import { isGenerating } from "./generate";
7
+ import { modeOf } from "./mode";
8
+ import { latestReport } from "./store";
9
+ import type { ReportResponse } from "./types";
10
+
11
+ export function readReport(sessionId: string): ReportResponse {
12
+ const mode = modeOf(sessionId);
13
+ const report = latestReport(sessionId);
14
+ if (report) {
15
+ const live = mode.mode !== "manual" && !isFinished(report.state);
16
+ // The analyst's open recommendation is the draft when the task has one.
17
+ const own = live ? analystDraft(sessionId) : null;
18
+ report.draft = !live
19
+ ? null
20
+ : (own ??
21
+ (report.draft?.status === "pending" || report.draft?.status === "sent"
22
+ ? report.draft
23
+ : null));
24
+ }
25
+ return { report, generating: isGenerating(sessionId), mode };
26
+ }