@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,510 @@
1
+ import { gitSnapshotLabel } from "./git-context.js";
2
+ import type { Artifact, GitSnapshot } from "./types.js";
3
+
4
+ export type WorkerState = "starting" | "active" | "idle" | "needs_input" | "ready" | "failed" | "error" | "ended";
5
+ export type WorkerDerivedState = "starting" | "thinking" | "stale" | "needs_input" | "ready_open_todos" | "ready" | "empty" | "failed" | "idle";
6
+ export type WorkerProtocolState = "needs_input" | "ready" | "failed";
7
+ export type WorkerTodoState = "pending" | "in_progress" | "completed";
8
+
9
+ export type WorkerTodoInput = {
10
+ id?: string;
11
+ text: string;
12
+ state?: WorkerTodoState | "active" | "done" | "todo";
13
+ note?: string;
14
+ };
15
+
16
+ export type WorkerTodo = {
17
+ id: string;
18
+ text: string;
19
+ state: WorkerTodoState;
20
+ note?: string;
21
+ };
22
+
23
+ export type WorkerDoneOutcome = "completed" | "findings" | "proposal" | "no_evidence";
24
+ export type WorkerScopeConfidence = "clear" | "unclear";
25
+
26
+ export type WorkerDoneInput = {
27
+ summary?: string;
28
+ outcome?: WorkerDoneOutcome;
29
+ evidence?: string[];
30
+ recommended?: string[];
31
+ scopeConfidence?: WorkerScopeConfidence;
32
+ };
33
+
34
+ export type WorkerQuestion = {
35
+ id: string;
36
+ text: string;
37
+ createdAt: string;
38
+ answeredAt?: string;
39
+ /** One-line stakes the worker flags (irreversible/unauthorized); shown as a warning on the verdict card. */
40
+ risk?: string;
41
+ /** Concrete choices the worker proposes; selecting one is sent back verbatim. Zero-token, status-only. */
42
+ options?: string[];
43
+ /** Which option the worker recommends (matches one of `options`); pre-selected on the card. */
44
+ recommend?: string;
45
+ };
46
+
47
+ export type WorkerWorkspaceKind = "git" | "copy";
48
+
49
+ export type WorkerWorktree = {
50
+ path: string;
51
+ baseCwd: string;
52
+ /** Omitted on legacy statuses; treat as git worktree. */
53
+ kind?: WorkerWorkspaceKind;
54
+ baseRoot?: string;
55
+ parentCwd?: string;
56
+ baseHead?: string;
57
+ snapshotHead?: string;
58
+ };
59
+
60
+ export type WorkerStatus = {
61
+ id: string;
62
+ index: number;
63
+ tmuxSession: string;
64
+ /** Stable tmux window id (e.g. "@7") captured at create time. Used for targeting kill/send-keys so renamed/recycled windows don't misroute. */
65
+ tmuxWindowId?: string;
66
+ task: string;
67
+ cwd: string;
68
+ /** Canonical project root (git toplevel realpath, or cwd realpath for non-repos) that launched this worker. */
69
+ projectRoot?: string;
70
+ kind?: string;
71
+ parentWorkerId?: string;
72
+ depth?: number;
73
+ canSpawn?: string[];
74
+ git?: GitSnapshot;
75
+ worktree?: WorkerWorktree;
76
+ createdAt: string;
77
+ updatedAt: string;
78
+ state: WorkerState;
79
+ pid?: number;
80
+ sessionFile?: string;
81
+ model?: string;
82
+ contextPercent?: number;
83
+ artifactCount?: number;
84
+ question?: string;
85
+ questions?: WorkerQuestion[];
86
+ todos?: WorkerTodo[];
87
+ summary?: string;
88
+ outcome?: WorkerDoneOutcome;
89
+ evidence?: string[];
90
+ recommended?: string[];
91
+ scopeConfidence?: WorkerScopeConfidence;
92
+ lastError?: string;
93
+ };
94
+
95
+ export type WorkerProtocolMessage = {
96
+ content: string;
97
+ subject: string;
98
+ title: string;
99
+ subtitle: string;
100
+ messageKind: "action" | "error";
101
+ artifactKind: "response" | "error";
102
+ };
103
+
104
+ export function workerShortLabel(index: number): string {
105
+ return `w${index}`;
106
+ }
107
+
108
+ export function workerSourceLabel(worker: WorkerStatus): string {
109
+ return workerShortLabel(worker.index);
110
+ }
111
+
112
+ export function workerSummaryName(status: WorkerStatus, max = 32): string {
113
+ const slug = status.task.split(/\s+/).slice(0, 6).join(" ").trim();
114
+ return slug.length > max ? `${slug.slice(0, max - 1)}…` : slug;
115
+ }
116
+
117
+ export function workerDisplayName(worker: WorkerStatus, max = 34): string {
118
+ return workerSummaryName(worker, max);
119
+ }
120
+
121
+ const STARTING_CHIP_FRAMES = ["[o ]", "[ o ]", "[ o]"];
122
+ const THINKING_CHIP_FRAMES = ["(._.)", "(o_o)", "(._.)"];
123
+ const FRAME_INTERVAL_MS = 400;
124
+
125
+ // Live dock heartbeat: a single breathing dot for active (starting/thinking) workers.
126
+ // Rendered only in surfaces that repaint on a timer (the prompt dock), never in static chips.
127
+ const PULSE_FRAMES = ["·", "∘", "o", "●", "o", "∘"];
128
+ export const DOCK_PULSE_INTERVAL_MS = 450;
129
+
130
+ export function workerPulseGlyph(now = Date.now()): string {
131
+ return PULSE_FRAMES[Math.floor(now / DOCK_PULSE_INTERVAL_MS) % PULSE_FRAMES.length]!;
132
+ }
133
+
134
+ function workerStatusText(worker: WorkerStatus, fallback: string): string {
135
+ const text = worker.summary ?? worker.lastError ?? worker.question ?? fallback;
136
+ return text.split(/\r?\n/).map((part) => part.trim()).find(Boolean) ?? fallback;
137
+ }
138
+
139
+ function truncateWorkerStatus(text: string, max = 42): string {
140
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
141
+ }
142
+
143
+ export function workerMascotFrame(worker: WorkerStatus | undefined, options: { now?: number } = {}): string {
144
+ if (!worker) return "(._.)";
145
+ const state = deriveWorkerState(worker, options.now);
146
+ if (state === "starting" || state === "thinking") {
147
+ const frames = state === "starting" ? STARTING_CHIP_FRAMES : THINKING_CHIP_FRAMES;
148
+ const frameTime = Number.isFinite(options.now) ? options.now! : Date.now();
149
+ return frames[Math.floor(frameTime / FRAME_INTERVAL_MS) % frames.length]!;
150
+ }
151
+ if (state === "needs_input") return "(?_?)";
152
+ if (state === "ready_open_todos") return "(^_?)";
153
+ if (state === "ready") return "(^_^)";
154
+ if (state === "failed") return "(x_x)";
155
+ if (state === "stale") return "(-_-)";
156
+ if (state === "empty") return "(-.-)";
157
+ return "(._.)";
158
+ }
159
+
160
+ export function workerMascotLines(worker: WorkerStatus | undefined, options: { now?: number } = {}): string[] {
161
+ const label = worker ? workerSourceLabel(worker) : "docket";
162
+ return [
163
+ ` ${workerMascotFrame(worker, options)}`,
164
+ ` /|\\ ${label}`,
165
+ " / \\",
166
+ ];
167
+ }
168
+
169
+ export function workerActivityChip(worker: WorkerStatus, options: { verbose?: boolean; now?: number } = {}): string {
170
+ const state = deriveWorkerState(worker, options.now);
171
+ const label = workerSourceLabel(worker);
172
+ const kindTag = worker.kind && worker.kind !== "default" ? `·${worker.kind}` : "";
173
+ // Animated frames (starting/thinking) freeze in static one-shot messages; only emit the
174
+ // stable state faces here. Live liveliness for active workers lives in the dock pulse.
175
+ const face = state === "starting" || state === "thinking" ? "" : workerMascotFrame(worker, options);
176
+ let chip = `${label}${kindTag}${face}`;
177
+ if (!options.verbose) return chip;
178
+ if (state === "needs_input") return `${chip} ${truncateWorkerStatus(workerStatusText(worker, "needs input"))}`;
179
+ if (state === "failed") return `${chip} ${truncateWorkerStatus(workerStatusText(worker, "failed"))}`;
180
+ if (state === "ready_open_todos") return `${chip} ready · open todos ${workerTodoSummary(worker) ?? ""}`.trim();
181
+ if (state === "ready") return `${chip} ${truncateWorkerStatus(workerStatusText(worker, "ready") ?? workerTodoSummary(worker) ?? "ready")}`;
182
+ if (state === "stale") return `${chip} stale`;
183
+ if (state === "empty") return `${chip} done`;
184
+ return `${chip} ${truncateWorkerStatus(workerTodoSummary(worker) ?? workerDisplayName(worker, 28))}`;
185
+ }
186
+
187
+ export function workerLaunchSubject(worker: WorkerStatus, options: { now?: number } = {}): string {
188
+ return `spawned ${workerActivityChip(worker, options)} · ${deriveWorkerState(worker, options.now)}`;
189
+ }
190
+
191
+ export function workerLaunchDetail(worker: WorkerStatus, options: { now?: number } = {}): string {
192
+ const git = gitSnapshotLabel(worker.git);
193
+ const todos = workerTodoSummary(worker);
194
+ const kindLine = worker.kind && worker.kind !== "default" ? `kind: ${worker.kind}` : undefined;
195
+ return [
196
+ `status: ${workerActivityChip(worker, { verbose: true, now: options.now })}`,
197
+ kindLine,
198
+ todos ? `todos: ${todos}` : undefined,
199
+ git ? `git: ${git}` : undefined,
200
+ worker.worktree ? `space: ${worker.worktree.path}` : undefined,
201
+ `inbox: /docket`,
202
+ `debug: /docket workers`,
203
+ ].filter((line): line is string => line !== undefined).join("\n");
204
+ }
205
+
206
+ function normalizeWorkerTodoState(state: WorkerTodoInput["state"]): WorkerTodoState {
207
+ if (state === "completed" || state === "done") return "completed";
208
+ if (state === "in_progress" || state === "active") return "in_progress";
209
+ return "pending";
210
+ }
211
+
212
+ function workerTodoId(todo: WorkerTodoInput, index: number): string {
213
+ const id = todo.id?.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
214
+ return (id || `t${index + 1}`).slice(0, 32);
215
+ }
216
+
217
+ export function normalizeWorkerTodos(items: WorkerTodoInput[]): WorkerTodo[] {
218
+ return items
219
+ .map((item, index) => ({
220
+ id: workerTodoId(item, index),
221
+ text: item.text?.replace(/\s+/g, " ").trim() ?? "",
222
+ state: normalizeWorkerTodoState(item.state),
223
+ note: item.note?.replace(/\s+/g, " ").trim() || undefined,
224
+ }))
225
+ .filter((item) => item.text.length > 0)
226
+ .slice(0, 12);
227
+ }
228
+
229
+ export function workerTodosPatch(items: WorkerTodoInput[]): Partial<WorkerStatus> {
230
+ return { todos: normalizeWorkerTodos(items) };
231
+ }
232
+
233
+ export function workerTodoProgress(worker: WorkerStatus): { total: number; completed: number; inProgress: number; pending: number } {
234
+ const todos = worker.todos ?? [];
235
+ return todos.reduce((acc, todo) => {
236
+ acc.total++;
237
+ if (todo.state === "completed") acc.completed++;
238
+ else if (todo.state === "in_progress") acc.inProgress++;
239
+ else acc.pending++;
240
+ return acc;
241
+ }, { total: 0, completed: 0, inProgress: 0, pending: 0 });
242
+ }
243
+
244
+ export function workerHasOpenTodos(worker: WorkerStatus): boolean {
245
+ const progress = workerTodoProgress(worker);
246
+ return progress.total > 0 && progress.completed < progress.total;
247
+ }
248
+
249
+ export function workerTodoSummary(worker: WorkerStatus): string | undefined {
250
+ const todos = worker.todos ?? [];
251
+ if (todos.length === 0) return undefined;
252
+ const progress = workerTodoProgress(worker);
253
+ const current = todos.find((todo) => todo.state === "in_progress") ?? todos.find((todo) => todo.state === "pending");
254
+ const currentText = current ? `${current.text}${current.note ? ` (${current.note})` : ""}` : "done";
255
+ return `${progress.completed}/${progress.total} · ${currentText}`;
256
+ }
257
+
258
+ function workerTodoGlyph(state: WorkerTodoState): string {
259
+ if (state === "completed") return "✓";
260
+ if (state === "in_progress") return "◐";
261
+ return "○";
262
+ }
263
+
264
+ function truncatePlain(text: string, max: number): string {
265
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
266
+ }
267
+
268
+ export function workerTodoBoardLines(worker: WorkerStatus, options: { includeHeader?: boolean; maxItems?: number; maxText?: number } = {}): string[] {
269
+ const todos = worker.todos ?? [];
270
+ if (todos.length === 0) return [];
271
+ const progress = workerTodoProgress(worker);
272
+ const maxItems = options.maxItems ?? todos.length;
273
+ const maxText = options.maxText ?? 72;
274
+ const shown = todos.slice(0, maxItems);
275
+ const lines = options.includeHeader ? [`Todos (${progress.completed}/${progress.total})`] : [];
276
+ for (let i = 0; i < shown.length; i++) {
277
+ const todo = shown[i]!;
278
+ const branch = i === shown.length - 1 && shown.length === todos.length ? "└" : "├";
279
+ const text = truncatePlain(`${todo.text}${todo.note ? ` (${todo.note})` : ""}`, maxText);
280
+ lines.push(`${branch} ${workerTodoGlyph(todo.state)} ${text}`);
281
+ }
282
+ if (shown.length < todos.length) lines.push(`└ … ${todos.length - shown.length} more`);
283
+ return lines;
284
+ }
285
+
286
+ function normalizeShortList(items: string[] | undefined, max: number): string[] | undefined {
287
+ const normalized = (items ?? []).map((item) => item.replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, max);
288
+ return normalized.length ? normalized : undefined;
289
+ }
290
+
291
+ export function normalizeWorkerDoneInput(input: WorkerDoneInput = {}): WorkerDoneInput {
292
+ return {
293
+ summary: input.summary?.trim() || undefined,
294
+ outcome: input.outcome,
295
+ evidence: normalizeShortList(input.evidence, 12),
296
+ recommended: normalizeShortList(input.recommended, 12),
297
+ scopeConfidence: input.scopeConfidence,
298
+ };
299
+ }
300
+
301
+ export function formatWorkerDoneSummary(input: WorkerDoneInput = {}): string | undefined {
302
+ const done = normalizeWorkerDoneInput(input);
303
+ const parts: string[] = [];
304
+ if (done.summary) parts.push(done.summary);
305
+ if (done.recommended?.length && !/\bRecommended\s*:/i.test(done.summary ?? "")) {
306
+ parts.push(["Recommended:", ...done.recommended.map((item) => `- ${item}`)].join("\n"));
307
+ }
308
+ return parts.join("\n\n") || undefined;
309
+ }
310
+
311
+ const TASK_STOP_WORDS = new Set(["a", "an", "the", "and", "or", "to", "of", "for", "in", "on", "with", "by", "from", "up", "about", "please", "just"]);
312
+
313
+ export function workerTaskLooksVague(task: string): boolean {
314
+ const trimmed = task.trim();
315
+ if (!trimmed) return true;
316
+ const lower = trimmed.toLowerCase();
317
+ const words = lower.match(/[a-z0-9][a-z0-9_-]*/g) ?? [];
318
+ const meaningful = words.filter((word) => !TASK_STOP_WORDS.has(word));
319
+ const unfinishedTail = /(\.\.\.|…|,\s*|\bmore\s*)$/.test(lower);
320
+ const genericStart = /^(find|look for|search|check|inspect|investigate|review|improve|come up with|think about|work on)\b/.test(lower);
321
+ const concreteScope = /(`[^`]+`|[./][\w.-]+|\b[\w-]+\.(?:ts|tsx|js|jsx|json|md|svg|png|jpg|jpeg|css|html|go|rs|py|rb|java|yml|yaml)\b|#\d+|\b(repo|repository|codebase|project|extension|docs?|readme|tests?|src|source|file|directory|folder|command|function|class|component|api|cli|tui|worker|artifact|checkpoint|symbol|module|package)\b)/i.test(trimmed);
322
+ const deliverable = /\b(ascii|svg|logo|markdown|md|json|patch|diff|test|fix|implement|add|write|generate|report|summary|recommendations?|design|proposal)\b/i.test(trimmed);
323
+
324
+ if (unfinishedTail && (meaningful.length <= 5 || genericStart)) return true;
325
+ if (meaningful.length <= 2) return true;
326
+ if (genericStart && meaningful.length <= 3 && !concreteScope && !deliverable) return true;
327
+ if (genericStart && !concreteScope && !deliverable) return true;
328
+ return false;
329
+ }
330
+
331
+ function summarySaysNoEvidence(summary: string | undefined): boolean {
332
+ if (!summary) return false;
333
+ return /\b(no|not|nothing|couldn'?t|could not|didn'?t|did not|zero)\b.{0,60}\b(found|find|matches?|hits?|refs?|references?|related|evidence)\b/i.test(summary)
334
+ || /\b(found|find|matches?|hits?|refs?|references?|evidence)\b.{0,60}\b(no|nothing|zero)\b/i.test(summary);
335
+ }
336
+
337
+ export function workerDoneClarificationQuestion(worker: WorkerStatus, input: WorkerDoneInput = {}, options: { artifactEvidenceCount?: number } = {}): string | undefined {
338
+ const done = normalizeWorkerDoneInput(input);
339
+ const evidenceCount = (done.evidence?.length ?? 0) + (options.artifactEvidenceCount ?? 0);
340
+ const scopeUnclear = done.scopeConfidence === "unclear";
341
+ const vague = scopeUnclear || workerTaskLooksVague(worker.task);
342
+ if (!vague) return undefined;
343
+ if (scopeUnclear || done.outcome === "no_evidence" || summarySaysNoEvidence(done.summary) || (!done.outcome && evidenceCount === 0)) {
344
+ const task = truncatePlain(worker.task, 80);
345
+ return `I didn't find enough evidence to complete "${task}". What exactly should I search for, and where?`;
346
+ }
347
+ return undefined;
348
+ }
349
+
350
+ export function workerQuestions(worker: WorkerStatus): WorkerQuestion[] {
351
+ if (worker.questions?.length) return worker.questions;
352
+ if (worker.question) return [{ id: "legacy", text: worker.question, createdAt: worker.updatedAt }];
353
+ return [];
354
+ }
355
+
356
+ export function deriveWorkerState(worker: WorkerStatus, now = Date.now()): WorkerDerivedState {
357
+ if (worker.state === "needs_input") return "needs_input";
358
+ if (worker.state === "failed" || worker.state === "error") return "failed";
359
+ if (worker.state === "ready") return workerHasOpenTodos(worker) ? "ready_open_todos" : "ready";
360
+ if (worker.state === "ended") {
361
+ if ((worker.artifactCount ?? 0) === 0) return "empty";
362
+ return workerHasOpenTodos(worker) ? "ready_open_todos" : "ready";
363
+ }
364
+ const ageMs = now - Date.parse(worker.updatedAt);
365
+ if (Number.isFinite(ageMs) && ageMs > 90_000) return "stale";
366
+ if (worker.state === "active") return "thinking";
367
+ if (worker.state === "starting") return "starting";
368
+ if (worker.state === "idle") return "idle";
369
+ return "idle";
370
+ }
371
+
372
+ export function workerStateRank(worker: WorkerStatus, now = Date.now()): number {
373
+ const state = deriveWorkerState(worker, now);
374
+ if (state === "needs_input") return 0;
375
+ if (state === "failed") return 1;
376
+ if (state === "ready_open_todos") return 2;
377
+ if (state === "ready") return 3;
378
+ if (state === "thinking") return 4;
379
+ if (state === "starting") return 5;
380
+ if (state === "stale") return 6;
381
+ return 7;
382
+ }
383
+
384
+ export function isPromptDockWorker(worker: WorkerStatus, now = Date.now()): boolean {
385
+ return deriveWorkerState(worker, now) !== "empty";
386
+ }
387
+
388
+ export function buildWorkerInitialPrompt(input: { label: string; id: string; taskFile: string; artifactsFile: string; worktreePath?: string; kind?: string; depth?: number; parentWorkerLabel?: string }): string {
389
+ const kindLine = input.kind && input.kind !== "default" ? `You are operating under worker kind \`${input.kind}\`. Kind-specific rules are in <docket_worker_guardrails>.` : undefined;
390
+ const parentLine = input.parentWorkerLabel ? `You were dispatched by worker ${input.parentWorkerLabel} (depth ${input.depth ?? 1}). Your docket_done returns to that worker, not directly to the human user.` : undefined;
391
+ return [
392
+ `You are Docket worker ${input.label} (${input.id}).`,
393
+ `Your task is in ${input.taskFile}. Read it, then begin.`,
394
+ `Artifacts are auto-snapshotted to ${input.artifactsFile}.`,
395
+ input.worktreePath ? `Worker workspace: ${input.worktreePath}` : undefined,
396
+ kindLine,
397
+ parentLine,
398
+ "Operating rules and tool contracts live in <docket_worker_guardrails> in your system prompt. Follow them; do not skip the protocol tools (`docket_wait`, `docket_done`, `docket_fail`, `docket_todos`).",
399
+ ].filter((line): line is string => line !== undefined).join("\n");
400
+ }
401
+
402
+ export function appendWorkerQuestionPatch(worker: WorkerStatus, text: string, question: WorkerQuestion): Partial<WorkerStatus> | undefined {
403
+ const trimmed = text.trim();
404
+ if (!trimmed) return undefined;
405
+ const legacy = worker.question && !worker.questions?.length
406
+ ? [{ id: "legacy", text: worker.question, createdAt: worker.updatedAt }]
407
+ : [];
408
+ const questions = [...legacy, ...(worker.questions ?? []), { ...question, text: trimmed }];
409
+ return { state: "needs_input", question: questions.length === 1 ? trimmed : `${questions.length} questions`, questions };
410
+ }
411
+
412
+ export function workerInputAcceptedPatch(): Partial<WorkerStatus> {
413
+ return { state: "active", question: undefined, questions: [] };
414
+ }
415
+
416
+ export const HEARTBEAT_ARTIFACT_CAP = 200;
417
+
418
+ export function heartbeatArtifactSignature(artifacts: Artifact[]): string {
419
+ if (artifacts.length === 0) return "0:";
420
+ const last = artifacts[artifacts.length - 1]!;
421
+ const ts = last.timestamp ?? 0;
422
+ return `${artifacts.length}:${last.ref}:${ts}`;
423
+ }
424
+
425
+ export function workerHeartbeatPatch(current: WorkerStatus | undefined, input: { pid: number; sessionFile?: string; artifactCount: number }): Partial<WorkerStatus> {
426
+ const stickyState = current?.state === "needs_input" || current?.state === "ready" || current?.state === "failed" || current?.state === "idle";
427
+ return {
428
+ state: stickyState ? current.state : "active",
429
+ pid: input.pid,
430
+ sessionFile: input.sessionFile,
431
+ artifactCount: input.artifactCount,
432
+ };
433
+ }
434
+
435
+ export function workerProtocolPatch(worker: WorkerStatus, state: WorkerProtocolState, text: string | undefined, question: WorkerQuestion, doneInput?: WorkerDoneInput): Partial<WorkerStatus> | undefined {
436
+ if (state === "needs_input") return appendWorkerQuestionPatch(worker, text ?? "", question);
437
+ const patch: Partial<WorkerStatus> = {
438
+ state,
439
+ question: undefined,
440
+ questions: [],
441
+ summary: state === "ready" ? formatWorkerDoneSummary(doneInput ?? { summary: text }) : undefined,
442
+ lastError: state === "failed" ? text : undefined,
443
+ };
444
+ if (state === "ready") {
445
+ const done = normalizeWorkerDoneInput(doneInput ?? { summary: text });
446
+ if (done.outcome) patch.outcome = done.outcome;
447
+ if (done.evidence?.length) patch.evidence = done.evidence;
448
+ if (done.recommended?.length) patch.recommended = done.recommended;
449
+ if (done.scopeConfidence) patch.scopeConfidence = done.scopeConfidence;
450
+ }
451
+ return patch;
452
+ }
453
+
454
+ export function workerProtocolResultText(state: WorkerProtocolState): string {
455
+ if (state === "needs_input") return "Docket wait recorded. Stop now and wait for parent reply.";
456
+ if (state === "ready") return "Docket done recorded. Parent can review the worker output.";
457
+ return "Docket failure recorded. Parent can review the failure.";
458
+ }
459
+
460
+ export function workerProtocolMessage(state: WorkerProtocolState, text?: string): WorkerProtocolMessage {
461
+ const subject = state === "needs_input" ? "needs input" : state === "ready" ? "ready" : "failed";
462
+ const title = state === "needs_input"
463
+ ? `Needs input: ${text ?? "clarification requested"}`
464
+ : state === "ready"
465
+ ? `Worker ready${text ? `: ${text}` : ""}`
466
+ : `Worker failed: ${text ?? "unknown reason"}`;
467
+ return {
468
+ content: text ?? subject,
469
+ subject,
470
+ title,
471
+ subtitle: `worker ${subject}`,
472
+ messageKind: state === "failed" ? "error" : "action",
473
+ artifactKind: state === "failed" ? "error" : "response",
474
+ };
475
+ }
476
+
477
+ export function workerStatusArtifact(worker: WorkerStatus, now = Date.now()): Artifact | undefined {
478
+ const state = deriveWorkerState(worker, now);
479
+ if (state !== "needs_input" && state !== "ready_open_todos" && state !== "ready" && state !== "failed") return undefined;
480
+ const label = workerSourceLabel(worker);
481
+ const questions = workerQuestions(worker);
482
+ const questionText = questions.length ? questions.map((question, index) => `${index + 1}. ${question.text}`).join("\n") : undefined;
483
+ const ready = state === "ready" || state === "ready_open_todos";
484
+ const text = state === "needs_input" ? questionText : ready ? worker.summary : worker.lastError;
485
+ const todoLines = workerTodoBoardLines(worker, { includeHeader: true });
486
+ const progress = workerTodoProgress(worker);
487
+ const openTodos = Math.max(0, progress.total - progress.completed);
488
+ const git = gitSnapshotLabel(worker.git);
489
+ const title = state === "needs_input"
490
+ ? questions.length > 1 ? `${label} needs input: ${questions.length} questions` : `${label} needs input${questions[0]?.text ? `: ${questions[0].text}` : ""}`
491
+ : ready
492
+ ? `${label} ${state === "ready_open_todos" ? `ready · open todos ${openTodos}/${progress.total}` : "ready"}${text ? `: ${text}` : ""}`
493
+ : `${label} failed${text ? `: ${text}` : ""}`;
494
+ return {
495
+ id: "status",
496
+ displayId: "status",
497
+ ref: `worker-status:${worker.id}:0`,
498
+ kind: state === "failed" ? "error" : "response",
499
+ title,
500
+ subtitle: workerDisplayName(worker),
501
+ body: [`worker: ${label}`, `state: ${state}`, git ? `git: ${git}` : undefined, `task: ${worker.task}`, todoLines.length ? `progress:\n${todoLines.join("\n")}` : undefined, text ? `message:\n${text}` : undefined].filter((line): line is string => line !== undefined).join("\n"),
502
+ timestamp: Date.parse(worker.updatedAt),
503
+ meta: { workerId: worker.id, workerLabel: label, workerStatus: state, question: text, summary: worker.summary, outcome: worker.outcome, evidence: worker.evidence, recommended: worker.recommended, scopeConfidence: worker.scopeConfidence, lastError: worker.lastError, questionCount: questions.length, todoCount: worker.todos?.length ?? 0, todoOpenCount: openTodos, git: worker.git },
504
+ };
505
+ }
506
+
507
+ export function namespaceWorkerArtifacts(worker: WorkerStatus, artifacts: Artifact[]): Artifact[] {
508
+ const slot = workerSourceLabel(worker);
509
+ return artifacts.map((artifact) => ({ ...artifact, id: `${slot}.${artifact.displayId}`, displayId: `${slot}.${artifact.displayId}`, source: slot }));
510
+ }
@@ -0,0 +1,147 @@
1
+ import type { CheckpointStore, CheckpointSummary } from "./checkpoint-store.js";
2
+ import type { CheckpointIndexEntry } from "./types.js";
3
+
4
+ export type ResumeAction = "continue" | "preview" | "edit" | "delete" | "load";
5
+ export type ResumeMode = "resume" | "delete" | "load";
6
+ export type ResumeSelection = { action: ResumeAction; summary: CheckpointSummary; index: number } | null;
7
+
8
+ type NotifyLevel = "info" | "warning" | "error";
9
+
10
+ type CheckpointCommandsDeps = {
11
+ store: CheckpointStore;
12
+ hasUI: boolean;
13
+ notify(text: string, level: NotifyLevel): void;
14
+ emitText(text: string, kind: "list", heading: string): void;
15
+ confirmDelete(checkpoint: CheckpointIndexEntry): Promise<boolean>;
16
+ selectCheckpoint(summaries: CheckpointSummary[], selected: number, mode?: ResumeMode): Promise<ResumeSelection>;
17
+ showText(title: string, text: string): Promise<void>;
18
+ editText(title: string, text: string): Promise<string | undefined>;
19
+ startSession(checkpoint: CheckpointIndexEntry, content: string): Promise<void>;
20
+ };
21
+
22
+ export type CheckpointCommands = {
23
+ continue(idOrLast?: string): Promise<void>;
24
+ delete(idOrLast?: string): Promise<boolean>;
25
+ list(includeConsumed?: boolean): Promise<void>;
26
+ };
27
+
28
+ export function createCheckpointCommands(deps: CheckpointCommandsDeps): CheckpointCommands {
29
+ const deleteCheckpoint = async (idOrLast: string): Promise<boolean> => {
30
+ const checkpoint = await deps.store.find(idOrLast || "last", { includeConsumed: true });
31
+ if (!checkpoint) {
32
+ deps.notify("Docket checkpoint not found", "error");
33
+ return false;
34
+ }
35
+ if (!(await deps.confirmDelete(checkpoint))) {
36
+ deps.notify("Docket delete cancelled", "info");
37
+ return false;
38
+ }
39
+ await deps.store.purge(checkpoint);
40
+ deps.notify(`Docket checkpoint deleted: ${checkpoint.id}`, "info");
41
+ return true;
42
+ };
43
+
44
+ const continueCheckpoint = async (idOrLast: string): Promise<void> => {
45
+ const checkpoint = await deps.store.find(idOrLast || "last");
46
+ if (!checkpoint) {
47
+ deps.notify("Docket checkpoint not found", "error");
48
+ return;
49
+ }
50
+ await deps.startSession(checkpoint, await deps.store.readMarkdown(checkpoint));
51
+ };
52
+
53
+ const selectCheckpointToContinue = async (): Promise<void> => {
54
+ if (!deps.hasUI) {
55
+ await continueCheckpoint("last");
56
+ return;
57
+ }
58
+ let summaries = await deps.store.listSummaries();
59
+ if (summaries.length === 0) {
60
+ deps.notify("Docket checkpoint not found", "error");
61
+ return;
62
+ }
63
+ let selected = Math.max(0, summaries.length - 1);
64
+ while (true) {
65
+ const result = await deps.selectCheckpoint(summaries, selected);
66
+ if (!result) return;
67
+ selected = result.index;
68
+ const checkpoint = result.summary.entry;
69
+ if (result.action === "delete") {
70
+ if (!(await deps.confirmDelete(checkpoint))) continue;
71
+ await deps.store.purge(checkpoint);
72
+ deps.notify(`Docket checkpoint deleted: ${checkpoint.id}`, "info");
73
+ summaries = await deps.store.listSummaries();
74
+ if (summaries.length === 0) return;
75
+ selected = Math.min(selected, summaries.length - 1);
76
+ continue;
77
+ }
78
+ const markdown = await deps.store.readMarkdown(checkpoint);
79
+ if (result.action === "preview") {
80
+ await deps.showText(`Docket checkpoint ${checkpoint.id}`, markdown);
81
+ continue;
82
+ }
83
+ if (result.action === "edit") {
84
+ const edited = await deps.editText("Edit Docket checkpoint", markdown);
85
+ if (edited === undefined) {
86
+ deps.notify("Docket continue cancelled", "info");
87
+ return;
88
+ }
89
+ await deps.startSession(checkpoint, edited);
90
+ return;
91
+ }
92
+ await deps.startSession(checkpoint, markdown);
93
+ return;
94
+ }
95
+ };
96
+
97
+ const selectCheckpointToDelete = async (): Promise<void> => {
98
+ if (!deps.hasUI) {
99
+ await deleteCheckpoint("last");
100
+ return;
101
+ }
102
+ let summaries = await deps.store.listSummaries({ includeConsumed: true });
103
+ if (summaries.length === 0) {
104
+ deps.notify("Docket checkpoint not found", "error");
105
+ return;
106
+ }
107
+ let selected = Math.max(0, summaries.length - 1);
108
+ while (true) {
109
+ const result = await deps.selectCheckpoint(summaries, selected, "delete");
110
+ if (!result) return;
111
+ selected = result.index;
112
+ const checkpoint = result.summary.entry;
113
+ if (result.action === "preview") {
114
+ await deps.showText(`Docket checkpoint ${checkpoint.id}`, await deps.store.readMarkdown(checkpoint));
115
+ continue;
116
+ }
117
+ if (!(await deps.confirmDelete(checkpoint))) continue;
118
+ await deps.store.purge(checkpoint);
119
+ deps.notify(`Docket checkpoint deleted: ${checkpoint.id}`, "info");
120
+ summaries = await deps.store.listSummaries({ includeConsumed: true });
121
+ if (summaries.length === 0) return;
122
+ selected = Math.min(selected, summaries.length - 1);
123
+ }
124
+ };
125
+
126
+ return {
127
+ async continue(idOrLast?: string): Promise<void> {
128
+ if (idOrLast) await continueCheckpoint(idOrLast);
129
+ else await selectCheckpointToContinue();
130
+ },
131
+ async delete(idOrLast?: string): Promise<boolean> {
132
+ if (idOrLast) return deleteCheckpoint(idOrLast);
133
+ await selectCheckpointToDelete();
134
+ return true;
135
+ },
136
+ async list(includeConsumed = false): Promise<void> {
137
+ const index = await deps.store.list({ includeConsumed });
138
+ const lines = index.length
139
+ ? index.map((c) => {
140
+ const tag = `${c.mode}${c.consumeOnUse ? ":once" : ""}${c.consumedAt ? ":consumed" : ""}`;
141
+ return `${c.id}\t${tag}\t${c.cwd}\t${c.note ?? ""}`;
142
+ }).join("\n")
143
+ : "No Docket checkpoints";
144
+ deps.emitText(lines, "list", "docket · checkpoints");
145
+ },
146
+ };
147
+ }