@roodriigoooo/pi-docket 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/assets/docket_logo.jpeg +0 -0
  5. package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
  6. package/docs/adr/0002-rename-to-docket.md +44 -0
  7. package/docs/architecture.md +101 -0
  8. package/docs/bundle-guidelines.md +39 -0
  9. package/docs/configuration.md +191 -0
  10. package/docs/releases/0.4.0.md +93 -0
  11. package/extensions/artifact-catalog.ts +467 -0
  12. package/extensions/background-work.ts +510 -0
  13. package/extensions/checkpoint-commands.ts +147 -0
  14. package/extensions/checkpoint-lifecycle.ts +195 -0
  15. package/extensions/checkpoint-selector.ts +162 -0
  16. package/extensions/checkpoint-store.ts +230 -0
  17. package/extensions/checkpoint-summarizer.ts +141 -0
  18. package/extensions/docket-command-grammar.ts +319 -0
  19. package/extensions/docket-command-router.ts +626 -0
  20. package/extensions/docket-config.ts +88 -0
  21. package/extensions/docket-extension-surface.ts +43 -0
  22. package/extensions/docket-navigator.ts +585 -0
  23. package/extensions/docket.README.md +46 -0
  24. package/extensions/docket.ts +2965 -0
  25. package/extensions/event-log.ts +121 -0
  26. package/extensions/git-context.ts +44 -0
  27. package/extensions/loaded-artifact-context.ts +228 -0
  28. package/extensions/search-index.ts +140 -0
  29. package/extensions/types.ts +40 -0
  30. package/extensions/worker-activity.ts +402 -0
  31. package/extensions/worker-changes.ts +180 -0
  32. package/extensions/worker-commands.ts +251 -0
  33. package/extensions/worker-dock-cache.ts +147 -0
  34. package/extensions/worker-events.ts +87 -0
  35. package/extensions/worker-eviction.ts +55 -0
  36. package/extensions/worker-guardrails.md +125 -0
  37. package/extensions/worker-kinds/patcher.md +23 -0
  38. package/extensions/worker-kinds/scout.md +17 -0
  39. package/extensions/worker-kinds.ts +280 -0
  40. package/extensions/worker-result.ts +193 -0
  41. package/extensions/worker-store.ts +621 -0
  42. package/extensions/worker-summary-embed.ts +98 -0
  43. package/package.json +53 -0
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: patcher
3
+ description: Edits files in the worker's worktree and proposes a change set for review.
4
+ read_only: false
5
+ default_worktree: true
6
+ parent_seed: full
7
+ can_spawn: scout
8
+ layout: split-events
9
+ ---
10
+
11
+ You are a patcher worker. The parent expects you to produce a coherent change set in your worker worktree that can be reviewed and promoted as a single unit.
12
+
13
+ Make minimal, scoped edits. Prefer editing existing files over creating new ones. Keep diffs small. If you discover the task requires a much larger change than implied, stop and call `docket_wait`.
14
+
15
+ When you finish, call `docket_done` with:
16
+ - `outcome: completed` or `outcome: proposal`
17
+ - a one- or two-sentence `summary`
18
+ - `evidence` listing files changed and the gist of why
19
+ - `recommended` action bullets for the parent (e.g. "review src/auth.ts:42", "run npm test")
20
+
21
+ You may dispatch a scout child worker via `docket_spawn_child` to gather context before editing — use it sparingly and only when the parent's seeded context is missing something concrete.
22
+
23
+ Never push, force-push, reset --hard, or run destructive git operations.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: scout
3
+ description: Fast read-only recon. Use for grep/find/ls style investigations.
4
+ read_only: true
5
+ default_worktree: false
6
+ parent_seed: full
7
+ max_artifacts: 80
8
+ max_duration_sec: 120
9
+ ---
10
+
11
+ You are a scout worker. Your job is to find things in the repository quickly and report back what you found, with concrete refs to files and line ranges.
12
+
13
+ Stick to read-only tools: `grep`, `find`, `ls`, `read`. Do not edit files. Do not run anything that mutates state.
14
+
15
+ Aim for a fast `docket_done` with `outcome: findings` (or `no_evidence` when scope is clear and nothing matched). Each evidence entry should be a concrete path or a one-line excerpt the parent can act on.
16
+
17
+ If the task is vague or scope is unclear, call `docket_wait` early — don't burn cycles guessing.
@@ -0,0 +1,280 @@
1
+ import fsSync from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
+
7
+ export const DEFAULT_KIND_NAME = "default";
8
+
9
+ export type WorkerParentSeedPolicy = "full" | "none";
10
+ export type WorkerLayout = "single" | "split-events";
11
+ export type WorkerThinking = "off" | "low" | "medium" | "high";
12
+
13
+ export type WorkerKind = {
14
+ name: string;
15
+ description?: string;
16
+ model?: string;
17
+ thinking?: WorkerThinking;
18
+ readOnly: boolean;
19
+ defaultWorktree: boolean;
20
+ parentSeedPolicy: WorkerParentSeedPolicy;
21
+ maxArtifacts?: number;
22
+ maxDurationSec?: number;
23
+ canSpawn: string[];
24
+ guardrailsAppend?: string;
25
+ systemPrompt?: string;
26
+ layout: WorkerLayout;
27
+ source: "builtin" | "user" | "runtime";
28
+ sourcePath?: string;
29
+ };
30
+
31
+ export type WorkerKindRegistry = {
32
+ get(name: string | undefined): WorkerKind;
33
+ list(): WorkerKind[];
34
+ names(): string[];
35
+ register(kind: WorkerKind): () => void;
36
+ unregister(name: string): boolean;
37
+ reload(cwd: string): Promise<void>;
38
+ defaultKind(projectDefault?: string): WorkerKind;
39
+ };
40
+
41
+ const BUILTIN_DEFAULT: WorkerKind = {
42
+ name: DEFAULT_KIND_NAME,
43
+ description: "General-purpose Docket worker; matches pre-kinds behavior.",
44
+ readOnly: false,
45
+ defaultWorktree: true,
46
+ parentSeedPolicy: "full",
47
+ canSpawn: [],
48
+ layout: "single",
49
+ source: "builtin",
50
+ };
51
+
52
+ function csv(value: string | undefined): string[] {
53
+ if (!value) return [];
54
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
55
+ }
56
+
57
+ function asBool(value: unknown, fallback: boolean): boolean {
58
+ if (typeof value === "boolean") return value;
59
+ if (typeof value !== "string") return fallback;
60
+ const lowered = value.trim().toLowerCase();
61
+ if (["true", "yes", "y", "1", "on"].includes(lowered)) return true;
62
+ if (["false", "no", "n", "0", "off"].includes(lowered)) return false;
63
+ return fallback;
64
+ }
65
+
66
+ function asInt(value: unknown): number | undefined {
67
+ if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value);
68
+ if (typeof value !== "string") return undefined;
69
+ const parsed = Number(value.trim());
70
+ return Number.isFinite(parsed) ? Math.floor(parsed) : undefined;
71
+ }
72
+
73
+ function asSeedPolicy(value: unknown): WorkerParentSeedPolicy {
74
+ if (typeof value === "string") {
75
+ const lowered = value.trim().toLowerCase();
76
+ if (lowered === "none" || lowered === "fresh") return "none";
77
+ }
78
+ return "full";
79
+ }
80
+
81
+ function asLayout(value: unknown): WorkerLayout {
82
+ if (typeof value === "string" && value.trim().toLowerCase() === "split-events") return "split-events";
83
+ return "single";
84
+ }
85
+
86
+ function asThinking(value: unknown): WorkerThinking | undefined {
87
+ if (typeof value !== "string") return undefined;
88
+ const lowered = value.trim().toLowerCase();
89
+ if (["off", "low", "medium", "high"].includes(lowered)) return lowered as WorkerThinking;
90
+ return undefined;
91
+ }
92
+
93
+ function normalizeName(value: string | undefined): string | undefined {
94
+ if (!value) return undefined;
95
+ const trimmed = value.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
96
+ return trimmed.length > 0 ? trimmed.slice(0, 32) : undefined;
97
+ }
98
+
99
+ export function parseWorkerKindMarkdown(text: string, source: WorkerKind["source"], sourcePath?: string): WorkerKind | undefined {
100
+ const parsed = parseFrontmatter<Record<string, unknown>>(text);
101
+ const fm = (parsed.frontmatter ?? {}) as Record<string, unknown>;
102
+ const name = normalizeName(typeof fm.name === "string" ? fm.name : undefined);
103
+ if (!name || name === DEFAULT_KIND_NAME) return undefined;
104
+ const body = (parsed.body ?? "").trim();
105
+ const description = typeof fm.description === "string" ? fm.description.trim() : undefined;
106
+ const model = typeof fm.model === "string" ? fm.model.trim() : undefined;
107
+ const thinking = asThinking(fm.thinking);
108
+ const readOnly = asBool(fm.read_only ?? fm.readonly ?? fm.readOnly, false);
109
+ const defaultWorktree = asBool(fm.default_worktree ?? fm.defaultWorktree ?? fm.worktree, true);
110
+ const parentSeedPolicy = asSeedPolicy(fm.parent_seed ?? fm.parentSeedPolicy ?? fm.seed);
111
+ const maxArtifacts = asInt(fm.max_artifacts ?? fm.maxArtifacts);
112
+ const maxDurationSec = asInt(fm.max_duration_sec ?? fm.maxDurationSec ?? fm.timeout);
113
+ const canSpawnRaw = fm.can_spawn ?? fm.canSpawn ?? fm.spawn_kinds ?? fm.subagent_agents;
114
+ const canSpawn = Array.isArray(canSpawnRaw) ? canSpawnRaw.map(String) : csv(typeof canSpawnRaw === "string" ? canSpawnRaw : undefined);
115
+ const guardrailsAppend = typeof fm.guardrails_append === "string" ? fm.guardrails_append : undefined;
116
+ const layout = asLayout(fm.layout);
117
+ return {
118
+ name,
119
+ ...(description ? { description } : {}),
120
+ ...(model ? { model } : {}),
121
+ ...(thinking ? { thinking } : {}),
122
+ readOnly,
123
+ defaultWorktree,
124
+ parentSeedPolicy,
125
+ ...(maxArtifacts !== undefined ? { maxArtifacts } : {}),
126
+ ...(maxDurationSec !== undefined ? { maxDurationSec } : {}),
127
+ canSpawn: canSpawn.map(normalizeName).filter((value): value is string => typeof value === "string"),
128
+ ...(guardrailsAppend ? { guardrailsAppend } : {}),
129
+ ...(body.length > 0 ? { systemPrompt: body } : {}),
130
+ layout,
131
+ source,
132
+ ...(sourcePath ? { sourcePath } : {}),
133
+ };
134
+ }
135
+
136
+ function bundledKindsDir(): string {
137
+ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
138
+ return path.join(extensionDir, "worker-kinds");
139
+ }
140
+
141
+ function userKindsDir(cwd: string): string[] {
142
+ const out: string[] = [];
143
+ out.push(path.join(getAgentDir(), "docket", "worker-kinds"));
144
+ out.push(path.join(cwd, ".pi", "docket", "worker-kinds"));
145
+ return out;
146
+ }
147
+
148
+ async function readKindFiles(dir: string, source: WorkerKind["source"]): Promise<WorkerKind[]> {
149
+ let entries: string[];
150
+ try {
151
+ entries = await fs.readdir(dir);
152
+ } catch {
153
+ return [];
154
+ }
155
+ const out: WorkerKind[] = [];
156
+ for (const entry of entries) {
157
+ if (!entry.endsWith(".md")) continue;
158
+ const filePath = path.join(dir, entry);
159
+ try {
160
+ const text = await fs.readFile(filePath, "utf8");
161
+ const kind = parseWorkerKindMarkdown(text, source, filePath);
162
+ if (kind) out.push(kind);
163
+ } catch {
164
+ // skip broken files
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ export function createWorkerKindRegistry(): WorkerKindRegistry {
171
+ const kinds = new Map<string, WorkerKind>();
172
+ kinds.set(BUILTIN_DEFAULT.name, BUILTIN_DEFAULT);
173
+
174
+ const set = (kind: WorkerKind): void => {
175
+ kinds.set(kind.name, kind);
176
+ };
177
+
178
+ const reload = async (cwd: string): Promise<void> => {
179
+ // Preserve runtime-registered kinds across reload; refresh builtin + user kinds from disk.
180
+ const preservedRuntime: WorkerKind[] = [];
181
+ for (const k of kinds.values()) if (k.source === "runtime") preservedRuntime.push(k);
182
+ kinds.clear();
183
+ kinds.set(BUILTIN_DEFAULT.name, BUILTIN_DEFAULT);
184
+ const bundled = await readKindFiles(bundledKindsDir(), "builtin");
185
+ for (const k of bundled) set(k);
186
+ for (const dir of userKindsDir(cwd)) {
187
+ const userKinds = await readKindFiles(dir, "user");
188
+ for (const k of userKinds) set(k);
189
+ }
190
+ for (const k of preservedRuntime) set(k);
191
+ };
192
+
193
+ const reloadSync = (cwd: string): void => {
194
+ // Only used as a best-effort sync fallback. Reads bundled MDs from disk so the
195
+ // worker-side, which doesn't await config load, can still resolve its kind.
196
+ try {
197
+ const entries = fsSync.readdirSync(bundledKindsDir());
198
+ for (const entry of entries) {
199
+ if (!entry.endsWith(".md")) continue;
200
+ try {
201
+ const filePath = path.join(bundledKindsDir(), entry);
202
+ const text = fsSync.readFileSync(filePath, "utf8");
203
+ const kind = parseWorkerKindMarkdown(text, "builtin", filePath);
204
+ if (kind && !kinds.has(kind.name)) set(kind);
205
+ } catch { /* skip */ }
206
+ }
207
+ } catch { /* dir missing is fine */ }
208
+ try {
209
+ for (const dir of userKindsDir(cwd)) {
210
+ const entries = fsSync.readdirSync(dir);
211
+ for (const entry of entries) {
212
+ if (!entry.endsWith(".md")) continue;
213
+ try {
214
+ const filePath = path.join(dir, entry);
215
+ const text = fsSync.readFileSync(filePath, "utf8");
216
+ const kind = parseWorkerKindMarkdown(text, "user", filePath);
217
+ if (kind) set(kind);
218
+ } catch { /* skip */ }
219
+ }
220
+ }
221
+ } catch { /* skip */ }
222
+ };
223
+
224
+ return {
225
+ get(name: string | undefined): WorkerKind {
226
+ if (!name) return BUILTIN_DEFAULT;
227
+ return kinds.get(name) ?? BUILTIN_DEFAULT;
228
+ },
229
+ list(): WorkerKind[] {
230
+ return Array.from(kinds.values()).sort((a, b) => {
231
+ if (a.name === DEFAULT_KIND_NAME) return -1;
232
+ if (b.name === DEFAULT_KIND_NAME) return 1;
233
+ return a.name.localeCompare(b.name);
234
+ });
235
+ },
236
+ names(): string[] {
237
+ return Array.from(kinds.keys()).sort();
238
+ },
239
+ register(kind: WorkerKind): () => void {
240
+ const normalized = normalizeName(kind.name);
241
+ if (!normalized || normalized === DEFAULT_KIND_NAME) {
242
+ throw new Error(`Docket: invalid worker kind name "${kind.name}"`);
243
+ }
244
+ const normalizedKind: WorkerKind = { ...kind, name: normalized, source: kind.source ?? "runtime" };
245
+ set(normalizedKind);
246
+ return () => {
247
+ const current = kinds.get(normalized);
248
+ if (current && current === normalizedKind) kinds.delete(normalized);
249
+ };
250
+ },
251
+ unregister(name: string): boolean {
252
+ const normalized = normalizeName(name);
253
+ if (!normalized || normalized === DEFAULT_KIND_NAME) return false;
254
+ return kinds.delete(normalized);
255
+ },
256
+ reload,
257
+ defaultKind(projectDefault?: string): WorkerKind {
258
+ if (projectDefault) {
259
+ const explicit = kinds.get(normalizeName(projectDefault) ?? "");
260
+ if (explicit) return explicit;
261
+ }
262
+ return BUILTIN_DEFAULT;
263
+ },
264
+ // Expose sync fallback for the worker-side path that runs before config loads.
265
+ // Not part of the public type to avoid leaking blocking I/O affordances.
266
+ ...({ _reloadSync: reloadSync } as Record<string, unknown>),
267
+ };
268
+ }
269
+
270
+ export function workerKindGuardrailsAppendix(kind: WorkerKind): string {
271
+ const parts: string[] = [];
272
+ if (kind.readOnly) parts.push("- This worker is **read-only** by configuration. Do not edit files. If the task requires edits, call `docket_wait` and ask the parent to spawn a writable worker instead.");
273
+ if (kind.maxArtifacts !== undefined) parts.push(`- Artifact cap for this kind: ${kind.maxArtifacts}. Stay focused.`);
274
+ if (kind.maxDurationSec !== undefined) parts.push(`- Soft time budget for this kind: ${kind.maxDurationSec}s. If you exceed it, call \`docket_done\` with partial findings rather than continuing silently.`);
275
+ if (kind.canSpawn.length > 0) parts.push(`- You may dispatch child workers via \`docket_spawn_child\` using only these kinds: ${kind.canSpawn.join(", ")}. Children inherit fleet/depth caps. Children's results return to you, not to the human user.`);
276
+ if (kind.guardrailsAppend) parts.push(kind.guardrailsAppend.trim());
277
+ if (kind.systemPrompt) parts.push(`\n${kind.systemPrompt.trim()}`);
278
+ if (parts.length === 0) return "";
279
+ return `\n\n## Kind-specific rules (kind: \`${kind.name}\`)\n\n${parts.join("\n")}\n`;
280
+ }
@@ -0,0 +1,193 @@
1
+ import { deriveWorkerState, workerActivityChip, workerDisplayName, workerQuestions, workerSourceLabel, workerStatusArtifact, workerTodoBoardLines, workerTodoSummary, type WorkerStatus } from "./background-work.js";
2
+ import type { Artifact } from "./types.js";
3
+
4
+ function firstLine(text: string | undefined): string | undefined {
5
+ const line = text?.split(/\r?\n/).map((part) => part.trim()).find(Boolean);
6
+ return line || undefined;
7
+ }
8
+
9
+ function truncate(text: string, max: number): string {
10
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
11
+ }
12
+
13
+ function latestArtifact(artifacts: Artifact[], kinds: Artifact["kind"][]): Artifact | undefined {
14
+ return artifacts
15
+ .filter((artifact) => kinds.includes(artifact.kind))
16
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))[0];
17
+ }
18
+
19
+ export function isWorkerStatusArtifact(artifact: Artifact | undefined): boolean {
20
+ if (!artifact) return false;
21
+ return artifact.meta?.workerStatus !== undefined || artifact.ref.startsWith("worker-status:") || artifact.displayId === "status" || artifact.id === "status";
22
+ }
23
+
24
+ function workerAnswerArtifacts(artifacts: Artifact[]): Artifact[] {
25
+ return artifacts.filter((artifact) => !isWorkerStatusArtifact(artifact));
26
+ }
27
+
28
+ export function workerResultSummary(worker: WorkerStatus, artifacts: Artifact[] = []): string {
29
+ const state = deriveWorkerState(worker);
30
+ const question = workerQuestions(worker).map((item, index) => `${index + 1}. ${item.text}`).join(" ");
31
+ const answer = latestArtifact(workerAnswerArtifacts(artifacts), ["response", "code"]);
32
+ const failure = latestArtifact(workerAnswerArtifacts(artifacts), ["error"]);
33
+ return firstLine(
34
+ state === "needs_input" ? question :
35
+ state === "failed" ? worker.lastError ?? failure?.title ?? failure?.body :
36
+ worker.summary ?? answer?.title ?? answer?.body ?? workerTodoSummary(worker) ?? workerDisplayName(worker),
37
+ ) ?? workerDisplayName(worker);
38
+ }
39
+
40
+ export function workerResultHeadline(worker: WorkerStatus, artifacts: Artifact[] = [], max = 72): string {
41
+ return truncate(workerResultSummary(worker, artifacts).replace(/\s+/g, " "), max);
42
+ }
43
+
44
+ export function workerResultArtifact(worker: WorkerStatus, artifacts: Artifact[] = []): Artifact | undefined {
45
+ const label = workerSourceLabel(worker);
46
+ const answer = latestArtifact(workerAnswerArtifacts(artifacts), ["response", "code", "error"]);
47
+ const status = artifacts.find((artifact) => artifact.meta?.workerId === worker.id && artifact.meta?.workerStatus)
48
+ ?? artifacts.find((artifact) => artifact.displayId === `${label}.status` || artifact.id === `${label}.status` || artifact.id === "status")
49
+ ?? workerStatusArtifact(worker);
50
+ return answer ?? status;
51
+ }
52
+
53
+ export function workerResultText(worker: WorkerStatus, artifacts: Artifact[] = [], maxBodyLines = 8): string {
54
+ const label = workerSourceLabel(worker);
55
+ const result = workerResultArtifact(worker, artifacts);
56
+ const resultIsStatus = isWorkerStatusArtifact(result);
57
+ const summary = workerResultSummary(worker, artifacts);
58
+ const body = !resultIsStatus ? result?.body?.split(/\r?\n/).slice(0, maxBodyLines).join("\n") : undefined;
59
+ const questions = workerQuestions(worker).map((item, index) => `${index + 1}. ${item.text}`).join("\n");
60
+ const todos = workerTodoBoardLines(worker, { includeHeader: true, maxItems: 8 });
61
+ return [
62
+ `${workerActivityChip(worker, { verbose: true })} ${summary}`,
63
+ body && body !== summary ? `answer:\n${body}` : undefined,
64
+ questions ? `needs input:\n${questions}` : undefined,
65
+ todos.length ? `progress:\n${todos.join("\n")}` : undefined,
66
+ `actions: /docket use ${label} · /docket ask ${label}`,
67
+ result && !resultIsStatus ? `ref: @${result.displayId}` : undefined,
68
+ ].filter((line): line is string => line !== undefined).join("\n");
69
+ }
70
+
71
+ export type WorkerResultReportSection = "outcome" | "question" | "failure";
72
+
73
+ export type WorkerResultReference = {
74
+ displayId: string;
75
+ kind: Artifact["kind"];
76
+ label: string;
77
+ };
78
+
79
+ export type WorkerResultReport = {
80
+ label: string;
81
+ state: ReturnType<typeof deriveWorkerState>;
82
+ stateLabel: string;
83
+ taskLabel: string;
84
+ progressLine: string;
85
+ changesLine: string;
86
+ primarySection: WorkerResultReportSection;
87
+ primaryBody: string;
88
+ recommendations: string[];
89
+ references: WorkerResultReference[];
90
+ nextActions: Array<{ key: string; label: string }>;
91
+ resultRef?: string;
92
+ };
93
+
94
+ const BULLET_RE = /^\s*(?:[-*•]|\d+[.)])\s+/;
95
+
96
+ function extractBullets(text: string | undefined, max = 6): string[] {
97
+ if (!text) return [];
98
+ const out: string[] = [];
99
+ let inSection = false;
100
+ for (const raw of text.split(/\r?\n/)) {
101
+ const line = raw.trim();
102
+ if (!line) { if (inSection) break; continue; }
103
+ if (/^(recommended|recommendations?|suggested|suggestions?):?$/i.test(line)) { inSection = true; continue; }
104
+ const match = line.match(BULLET_RE);
105
+ if (match) out.push(line.slice(match[0].length).trim());
106
+ else if (inSection) out.push(line);
107
+ }
108
+ return out.slice(0, max);
109
+ }
110
+
111
+ function fallbackSentences(text: string | undefined, max = 3): string[] {
112
+ if (!text) return [];
113
+ return text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean).slice(0, max);
114
+ }
115
+
116
+ function workerProgressLine(worker: WorkerStatus): string {
117
+ const todos = worker.todos ?? [];
118
+ if (todos.length === 0) return "no todos";
119
+ const completed = todos.filter((t) => t.state === "completed").length;
120
+ const open = todos.length - completed;
121
+ if (open === 0) return `${completed}/${todos.length} todos complete`;
122
+ return `${completed}/${todos.length} todos · ${open} open`;
123
+ }
124
+
125
+ function workerChangesLine(artifacts: Artifact[]): string {
126
+ const changeSet = artifacts.find((a) => a.meta?.workerChangeSet === true);
127
+ const changedFiles = Array.isArray(changeSet?.meta?.changedFiles) ? changeSet.meta.changedFiles : undefined;
128
+ if (changedFiles?.length) return `${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}`;
129
+ const edited = artifacts.filter((a) => a.kind === "file" && (a.meta?.tool === "edit" || a.meta?.tool === "write"));
130
+ if (edited.length === 0) return "none";
131
+ if (edited.length === 1) return `1 file (${edited[0]!.title})`;
132
+ return `${edited.length} files`;
133
+ }
134
+
135
+ function workerReferences(label: string, artifacts: Artifact[], max = 4): WorkerResultReference[] {
136
+ const candidates = artifacts.filter((a) => !isWorkerStatusArtifact(a));
137
+ const order: Artifact["kind"][] = ["response", "code", "file", "command", "error"];
138
+ const grouped = order.flatMap((kind) => candidates.filter((a) => a.kind === kind));
139
+ const seen = new Set<string>();
140
+ const refs: WorkerResultReference[] = [];
141
+ for (const artifact of grouped) {
142
+ const id = `${label}.${artifact.displayId}`;
143
+ if (seen.has(id)) continue;
144
+ seen.add(id);
145
+ refs.push({ displayId: id, kind: artifact.kind, label: firstLine(artifact.title) ?? artifact.kind });
146
+ if (refs.length >= max) break;
147
+ }
148
+ return refs;
149
+ }
150
+
151
+ export function workerResultReport(worker: WorkerStatus, artifacts: Artifact[] = []): WorkerResultReport {
152
+ const label = workerSourceLabel(worker);
153
+ const state = deriveWorkerState(worker);
154
+ const result = workerResultArtifact(worker, artifacts);
155
+ const resultIsStatus = isWorkerStatusArtifact(result);
156
+ const summary = workerResultSummary(worker, artifacts);
157
+ const summarySource = worker.summary ?? (result && !resultIsStatus ? `${result.title}\n${result.body}` : undefined);
158
+ const recommendations = extractBullets(summarySource);
159
+ if (recommendations.length === 0 && state !== "needs_input" && state !== "failed") {
160
+ recommendations.push(...fallbackSentences(summarySource, 2).filter((s) => s !== summary));
161
+ }
162
+ const questions = workerQuestions(worker);
163
+ const primarySection: WorkerResultReportSection = state === "needs_input" ? "question" : state === "failed" ? "failure" : "outcome";
164
+ const primaryBody = primarySection === "question"
165
+ ? questions.map((q, i) => `${i + 1}. ${q.text}`).join("\n") || summary
166
+ : primarySection === "failure"
167
+ ? worker.lastError ?? summary
168
+ : summary;
169
+ const stateLabel = state === "ready_open_todos" ? "ready · open todos" : state === "needs_input" ? "needs reply" : state;
170
+ const nextActions: Array<{ key: string; label: string }> = [];
171
+ if (state === "needs_input") nextActions.push({ key: "c", label: "Reply" });
172
+ else if (state === "failed") nextActions.push({ key: "Enter", label: "Inspect failure" });
173
+ else nextActions.push({ key: "Enter", label: "Review answer" });
174
+ nextActions.push({ key: "c", label: state === "needs_input" ? "Send answer" : "Ask follow-up" });
175
+ nextActions.push({ key: "l", label: "Load into prompt" });
176
+ nextActions.push({ key: "a", label: "Attach tmux" });
177
+ nextActions.push({ key: "x", label: "Dismiss" });
178
+ const uniqueActions = nextActions.filter((entry, index, arr) => arr.findIndex((other) => other.key === entry.key && other.label === entry.label) === index);
179
+ return {
180
+ label,
181
+ state,
182
+ stateLabel,
183
+ taskLabel: worker.task,
184
+ progressLine: workerProgressLine(worker),
185
+ changesLine: workerChangesLine(artifacts),
186
+ primarySection,
187
+ primaryBody,
188
+ recommendations,
189
+ references: workerReferences(label, artifacts),
190
+ nextActions: uniqueActions,
191
+ ...(result && !resultIsStatus ? { resultRef: `@${label}.${result.displayId}` } : {}),
192
+ };
193
+ }