@saadnvd1/agent-os 0.3.723 → 0.3.728
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +6 -1
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import type {
|
|
3
|
+
DraftStatus,
|
|
4
|
+
HealthReport,
|
|
5
|
+
ReportSummary,
|
|
6
|
+
ReportTrigger,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
interface Row {
|
|
10
|
+
id: number;
|
|
11
|
+
session_id: string;
|
|
12
|
+
workspace_id: string | null;
|
|
13
|
+
head: string | null;
|
|
14
|
+
trigger: ReportTrigger;
|
|
15
|
+
evidence_hash: string;
|
|
16
|
+
data: string;
|
|
17
|
+
model: string | null;
|
|
18
|
+
cost_usd: number | null;
|
|
19
|
+
draft_text: string | null;
|
|
20
|
+
draft_status: DraftStatus | null;
|
|
21
|
+
created_at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What is stored in `data`: the report without what the row itself holds.
|
|
25
|
+
type Stored = Omit<
|
|
26
|
+
HealthReport,
|
|
27
|
+
"id" | "sessionId" | "workspaceId" | "createdAt" | "stale" | "draft"
|
|
28
|
+
> & { draft: HealthReport["draft"] };
|
|
29
|
+
|
|
30
|
+
export function insertReport(
|
|
31
|
+
r: Omit<HealthReport, "id" | "createdAt" | "stale">,
|
|
32
|
+
hash: string
|
|
33
|
+
): number {
|
|
34
|
+
const { id: _i, sessionId, workspaceId, ...rest } = r as HealthReport;
|
|
35
|
+
void _i;
|
|
36
|
+
const draft = r.draft?.source === "report" ? r.draft : null;
|
|
37
|
+
db.prepare(
|
|
38
|
+
`UPDATE health_reports SET draft_status = 'superseded'
|
|
39
|
+
WHERE session_id = ? AND draft_status = 'pending'`
|
|
40
|
+
).run(sessionId);
|
|
41
|
+
const info = db
|
|
42
|
+
.prepare(
|
|
43
|
+
`INSERT INTO health_reports (session_id, workspace_id, head, trigger, evidence_hash, data, model, cost_usd, draft_text, draft_status)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
45
|
+
)
|
|
46
|
+
.run(
|
|
47
|
+
sessionId,
|
|
48
|
+
workspaceId,
|
|
49
|
+
r.head,
|
|
50
|
+
r.trigger,
|
|
51
|
+
hash,
|
|
52
|
+
JSON.stringify({ ...rest, draft: null } satisfies Stored),
|
|
53
|
+
r.model,
|
|
54
|
+
r.costUsd,
|
|
55
|
+
draft?.text ?? null,
|
|
56
|
+
draft ? "pending" : null
|
|
57
|
+
);
|
|
58
|
+
return Number(info.lastInsertRowid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toReport(row: Row, head: string | null | undefined): HealthReport {
|
|
62
|
+
const data = JSON.parse(row.data) as Stored;
|
|
63
|
+
return {
|
|
64
|
+
...data,
|
|
65
|
+
id: row.id,
|
|
66
|
+
sessionId: row.session_id,
|
|
67
|
+
workspaceId: row.workspace_id,
|
|
68
|
+
createdAt: row.created_at,
|
|
69
|
+
draft: row.draft_text
|
|
70
|
+
? {
|
|
71
|
+
source: "report",
|
|
72
|
+
id: String(row.id),
|
|
73
|
+
text: row.draft_text,
|
|
74
|
+
status: row.draft_status ?? "pending",
|
|
75
|
+
}
|
|
76
|
+
: null,
|
|
77
|
+
stale: head !== undefined && head !== row.head,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `head` is the task's current head, to mark an older report stale.
|
|
82
|
+
export function latestReport(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
head?: string | null
|
|
85
|
+
): HealthReport | null {
|
|
86
|
+
const row = db
|
|
87
|
+
.prepare(
|
|
88
|
+
`SELECT * FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT 1`
|
|
89
|
+
)
|
|
90
|
+
.get(sessionId) as Row | undefined;
|
|
91
|
+
return row ? toReport(row, head) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getReport(id: number): HealthReport | null {
|
|
95
|
+
const row = db
|
|
96
|
+
.prepare(`SELECT * FROM health_reports WHERE id = ?`)
|
|
97
|
+
.get(id) as Row | undefined;
|
|
98
|
+
return row ? toReport(row, undefined) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function hashOfLatest(sessionId: string): string | null {
|
|
102
|
+
const row = db
|
|
103
|
+
.prepare(
|
|
104
|
+
`SELECT evidence_hash FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT 1`
|
|
105
|
+
)
|
|
106
|
+
.get(sessionId) as { evidence_hash: string } | undefined;
|
|
107
|
+
return row?.evidence_hash ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Claimed first, so a double tap sends once.
|
|
111
|
+
export function claimDraft(
|
|
112
|
+
reportId: number,
|
|
113
|
+
to: Exclude<DraftStatus, "pending">
|
|
114
|
+
): boolean {
|
|
115
|
+
return (
|
|
116
|
+
db
|
|
117
|
+
.prepare(
|
|
118
|
+
`UPDATE health_reports SET draft_status = ?, draft_decided_at = datetime('now')
|
|
119
|
+
WHERE id = ? AND draft_status = 'pending'`
|
|
120
|
+
)
|
|
121
|
+
.run(to, reportId).changes === 1
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function reopenDraft(reportId: number): void {
|
|
126
|
+
db.prepare(
|
|
127
|
+
`UPDATE health_reports SET draft_status = 'pending', draft_decided_at = NULL
|
|
128
|
+
WHERE id = ? AND draft_status = 'sent'`
|
|
129
|
+
).run(reportId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setDraftText(
|
|
133
|
+
reportId: number,
|
|
134
|
+
text: string,
|
|
135
|
+
status: DraftStatus
|
|
136
|
+
) {
|
|
137
|
+
db.prepare(
|
|
138
|
+
`UPDATE health_reports SET draft_text = ?, draft_status = ? WHERE id = ?`
|
|
139
|
+
).run(text, status, reportId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function historyOf(sessionId: string, limit = 20): ReportSummary[] {
|
|
143
|
+
const rows = db
|
|
144
|
+
.prepare(
|
|
145
|
+
`SELECT * FROM health_reports WHERE session_id = ? ORDER BY id DESC LIMIT ?`
|
|
146
|
+
)
|
|
147
|
+
.all(sessionId, limit) as Row[];
|
|
148
|
+
return rows.map((r) => summaryOf(toReport(r, undefined), ""));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function summaryOf(r: HealthReport, name: string): ReportSummary {
|
|
152
|
+
return {
|
|
153
|
+
sessionId: r.sessionId,
|
|
154
|
+
name,
|
|
155
|
+
headline: r.headline,
|
|
156
|
+
state: r.state,
|
|
157
|
+
estimate: {
|
|
158
|
+
done: r.estimate.done,
|
|
159
|
+
total: r.estimate.total,
|
|
160
|
+
basis: r.estimate.basis,
|
|
161
|
+
},
|
|
162
|
+
createdAt: r.createdAt,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The newest report of each task in a workspace.
|
|
167
|
+
export function latestByTask(
|
|
168
|
+
workspaceId: string
|
|
169
|
+
): { report: HealthReport; name: string }[] {
|
|
170
|
+
const rows = db
|
|
171
|
+
.prepare(
|
|
172
|
+
`SELECT h.*, s.name AS name FROM health_reports h
|
|
173
|
+
JOIN session_rows s ON s.id = h.session_id
|
|
174
|
+
WHERE h.workspace_id = ?
|
|
175
|
+
AND h.id = (SELECT MAX(id) FROM health_reports WHERE session_id = h.session_id)
|
|
176
|
+
ORDER BY h.id DESC LIMIT 100`
|
|
177
|
+
)
|
|
178
|
+
.all(workspaceId) as (Row & { name: string })[];
|
|
179
|
+
return rows.map((r) => ({ report: toReport(r, undefined), name: r.name }));
|
|
180
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The report with no model: sentences built from the same evidence. Used when
|
|
2
|
+
// the model fails or every line it wrote was rejected, and for the headline.
|
|
3
|
+
|
|
4
|
+
import type { TaskState } from "../tasks/state";
|
|
5
|
+
import type { EvidenceRef, ReportLine } from "./types";
|
|
6
|
+
|
|
7
|
+
const HEADLINES: Record<TaskState, string> = {
|
|
8
|
+
working: "The agent is working on it",
|
|
9
|
+
"needs-input": "The agent is waiting for an answer",
|
|
10
|
+
blocked: "The task is blocked and needs a decision",
|
|
11
|
+
review: "The pull request is open and waiting on review",
|
|
12
|
+
"checks-failing": "Checks are failing on the latest push",
|
|
13
|
+
"plan-ready": "The plan is written and waits for your approval",
|
|
14
|
+
exited: "The agent stopped before finishing",
|
|
15
|
+
merged: "The work is merged",
|
|
16
|
+
dropped: "The task was dropped",
|
|
17
|
+
done: "The task is finished",
|
|
18
|
+
moving: "The task is moving to another machine",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const headlineFor = (state: TaskState) => HEADLINES[state];
|
|
22
|
+
|
|
23
|
+
const NEXT: Partial<Record<TaskState, string>> = {
|
|
24
|
+
"checks-failing": "Next: fix the failing checks, then push again.",
|
|
25
|
+
review: "Next: wait for the review and the checks to settle, then merge.",
|
|
26
|
+
blocked: "Next: answer what the task is waiting on.",
|
|
27
|
+
"needs-input": "Next: reply to the agent so it can go on.",
|
|
28
|
+
"plan-ready": "Next: read the plan and approve it.",
|
|
29
|
+
exited: "Next: restart the agent or take the work over.",
|
|
30
|
+
working: "Next: let the agent finish its turn.",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function templateLines(
|
|
34
|
+
state: TaskState,
|
|
35
|
+
evidence: EvidenceRef[]
|
|
36
|
+
): {
|
|
37
|
+
lines: ReportLine[];
|
|
38
|
+
recommendation: { text: string; evidence: string[] } | null;
|
|
39
|
+
} {
|
|
40
|
+
const lines: ReportLine[] = [];
|
|
41
|
+
const add = (tone: ReportLine["tone"], text: string, ev: EvidenceRef[]) =>
|
|
42
|
+
lines.push({
|
|
43
|
+
id: `l${lines.length + 1}`,
|
|
44
|
+
tone,
|
|
45
|
+
text,
|
|
46
|
+
evidence: ev.map((e) => e.id),
|
|
47
|
+
});
|
|
48
|
+
const of = (kind: EvidenceRef["kind"]) =>
|
|
49
|
+
evidence.filter((e) => e.kind === kind);
|
|
50
|
+
|
|
51
|
+
const ci = of("ci");
|
|
52
|
+
const ciPass = ci.filter((e) => e.status === "pass");
|
|
53
|
+
const ciFail = ci.filter((e) => e.status === "fail");
|
|
54
|
+
const ciWait = ci.filter((e) => e.status === "pending");
|
|
55
|
+
if (ciPass.length)
|
|
56
|
+
add(
|
|
57
|
+
"done",
|
|
58
|
+
`${ciPass.length} CI check${ciPass.length === 1 ? "" : "s"} passed.`,
|
|
59
|
+
ciPass
|
|
60
|
+
);
|
|
61
|
+
for (const e of ciFail) add("warn", `${e.label} is failing.`, [e]);
|
|
62
|
+
if (ciWait.length)
|
|
63
|
+
add(
|
|
64
|
+
"warn",
|
|
65
|
+
`${ciWait.length} CI check${ciWait.length === 1 ? " is" : "s are"} still running.`,
|
|
66
|
+
ciWait
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
for (const e of of("test"))
|
|
70
|
+
add(
|
|
71
|
+
e.status === "pass" ? "done" : e.status === "fail" ? "warn" : "next",
|
|
72
|
+
e.status === "pass"
|
|
73
|
+
? `Tests passed: ${e.label}.`
|
|
74
|
+
: e.status === "fail"
|
|
75
|
+
? `Tests failed: ${e.label}.`
|
|
76
|
+
: `Tests are running: ${e.label}.`,
|
|
77
|
+
[e]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
for (const e of of("review"))
|
|
81
|
+
add(
|
|
82
|
+
e.status === "pass" ? "done" : e.status === "fail" ? "warn" : "next",
|
|
83
|
+
e.status === "pass"
|
|
84
|
+
? "The independent review passed."
|
|
85
|
+
: e.status === "fail"
|
|
86
|
+
? "The independent review found problems to fix."
|
|
87
|
+
: "The independent review has not finished.",
|
|
88
|
+
[e]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
for (const e of of("piece"))
|
|
92
|
+
add(e.status === "pass" ? "done" : "next", `${e.label}.`, [e]);
|
|
93
|
+
for (const e of of("accept").filter((e) => e.status === "pass"))
|
|
94
|
+
add("done", `Verified: ${e.label}.`, [e]);
|
|
95
|
+
for (const e of of("ask")) add("blocked", `${e.label}.`, [e]);
|
|
96
|
+
for (const e of of("log")) add("done", `${e.label}.`, [e]);
|
|
97
|
+
|
|
98
|
+
const hasDiff = of("diff");
|
|
99
|
+
if (hasDiff.length)
|
|
100
|
+
add(
|
|
101
|
+
"done",
|
|
102
|
+
`${hasDiff.length} file${hasDiff.length === 1 ? "" : "s"} changed.`,
|
|
103
|
+
hasDiff.slice(0, 12)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const stateEv = of("state");
|
|
107
|
+
if (NEXT[state] && stateEv.length) add("next", NEXT[state]!, stateEv);
|
|
108
|
+
|
|
109
|
+
const bad = [...ciFail, ...of("test").filter((e) => e.status === "fail")];
|
|
110
|
+
const recommendation = bad.length
|
|
111
|
+
? {
|
|
112
|
+
text: `Ask the agent to fix ${bad[0].label} first; everything else waits on it.`,
|
|
113
|
+
evidence: [bad[0].id],
|
|
114
|
+
}
|
|
115
|
+
: null;
|
|
116
|
+
return { lines, recommendation };
|
|
117
|
+
}
|