@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.
- package/CHANGELOG.md +132 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/assets/docket_logo.jpeg +0 -0
- package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
- package/docs/adr/0002-rename-to-docket.md +44 -0
- package/docs/architecture.md +101 -0
- package/docs/bundle-guidelines.md +39 -0
- package/docs/configuration.md +191 -0
- package/docs/releases/0.4.0.md +93 -0
- package/extensions/artifact-catalog.ts +467 -0
- package/extensions/background-work.ts +510 -0
- package/extensions/checkpoint-commands.ts +147 -0
- package/extensions/checkpoint-lifecycle.ts +195 -0
- package/extensions/checkpoint-selector.ts +162 -0
- package/extensions/checkpoint-store.ts +230 -0
- package/extensions/checkpoint-summarizer.ts +141 -0
- package/extensions/docket-command-grammar.ts +319 -0
- package/extensions/docket-command-router.ts +626 -0
- package/extensions/docket-config.ts +88 -0
- package/extensions/docket-extension-surface.ts +43 -0
- package/extensions/docket-navigator.ts +585 -0
- package/extensions/docket.README.md +46 -0
- package/extensions/docket.ts +2965 -0
- package/extensions/event-log.ts +121 -0
- package/extensions/git-context.ts +44 -0
- package/extensions/loaded-artifact-context.ts +228 -0
- package/extensions/search-index.ts +140 -0
- package/extensions/types.ts +40 -0
- package/extensions/worker-activity.ts +402 -0
- package/extensions/worker-changes.ts +180 -0
- package/extensions/worker-commands.ts +251 -0
- package/extensions/worker-dock-cache.ts +147 -0
- package/extensions/worker-events.ts +87 -0
- package/extensions/worker-eviction.ts +55 -0
- package/extensions/worker-guardrails.md +125 -0
- package/extensions/worker-kinds/patcher.md +23 -0
- package/extensions/worker-kinds/scout.md +17 -0
- package/extensions/worker-kinds.ts +280 -0
- package/extensions/worker-result.ts +193 -0
- package/extensions/worker-store.ts +621 -0
- package/extensions/worker-summary-embed.ts +98 -0
- package/package.json +53 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { deriveWorkerState, workerActivityChip, workerDisplayName, workerQuestions, workerSourceLabel, workerStateRank, workerTodoBoardLines, workerTodoProgress, type WorkerDerivedState, type WorkerQuestion, type WorkerStatus } from "./background-work.js";
|
|
2
|
+
import { isWorkerStatusArtifact, workerResultArtifact, workerResultSummary } from "./worker-result.js";
|
|
3
|
+
import type { Artifact } from "./types.js";
|
|
4
|
+
import type { WorkerEvent } from "./worker-events.js";
|
|
5
|
+
|
|
6
|
+
export type WorkerEvidence = {
|
|
7
|
+
reads: number;
|
|
8
|
+
commands: number;
|
|
9
|
+
edits: number;
|
|
10
|
+
errors: number;
|
|
11
|
+
codeBlocks: number;
|
|
12
|
+
sampleFiles: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type WorkerActivityRow = {
|
|
16
|
+
worker: WorkerStatus;
|
|
17
|
+
label: string;
|
|
18
|
+
chip: string;
|
|
19
|
+
state: WorkerDerivedState;
|
|
20
|
+
stateLabel: string;
|
|
21
|
+
taskLabel: string;
|
|
22
|
+
message: string;
|
|
23
|
+
answer?: Artifact;
|
|
24
|
+
answerLine?: string;
|
|
25
|
+
outputLabel: string;
|
|
26
|
+
actionHint: string;
|
|
27
|
+
questions: WorkerQuestion[];
|
|
28
|
+
progress: { total: number; completed: number; inProgress: number; pending: number };
|
|
29
|
+
todoLines: string[];
|
|
30
|
+
recommendations: number;
|
|
31
|
+
filesChanged: number;
|
|
32
|
+
evidence: WorkerEvidence;
|
|
33
|
+
summary?: string;
|
|
34
|
+
updatedAt: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type WorkerActivityStackLine = {
|
|
38
|
+
kind: "worker" | "answer" | "question" | "todo";
|
|
39
|
+
state: WorkerDerivedState;
|
|
40
|
+
worker: WorkerStatus;
|
|
41
|
+
text: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type WorkerActivityTotals = {
|
|
45
|
+
workers: number;
|
|
46
|
+
active: number;
|
|
47
|
+
waiting: number;
|
|
48
|
+
ready: number;
|
|
49
|
+
readyOpenTodos: number;
|
|
50
|
+
failed: number;
|
|
51
|
+
todos: number;
|
|
52
|
+
completedTodos: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function firstLine(text: string | undefined): string | undefined {
|
|
56
|
+
const line = text?.split(/\r?\n/).map((part) => part.trim()).find(Boolean);
|
|
57
|
+
return line || undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BULLET_PREFIX = /^\s*(?:[-*•]|\d+[.)])\s+/;
|
|
61
|
+
|
|
62
|
+
function countRecommendations(summary: string | undefined): number {
|
|
63
|
+
if (!summary) return 0;
|
|
64
|
+
let count = 0;
|
|
65
|
+
let inRecommended = false;
|
|
66
|
+
for (const raw of summary.split(/\r?\n/)) {
|
|
67
|
+
const line = raw.trim();
|
|
68
|
+
if (!line) { if (inRecommended) break; continue; }
|
|
69
|
+
if (/^recommended:?$/i.test(line) || /^recommendations:?$/i.test(line) || /^suggested:?$/i.test(line)) { inRecommended = true; continue; }
|
|
70
|
+
if (BULLET_PREFIX.test(line)) count++;
|
|
71
|
+
else if (inRecommended) count++;
|
|
72
|
+
}
|
|
73
|
+
if (count > 0) return count;
|
|
74
|
+
const numbered = summary.match(/\b(\d+)\s+(?:suggestions?|recommendations?|recs?)\b/i);
|
|
75
|
+
return numbered ? Number(numbered[1]) : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function artifactTool(artifact: Artifact): string | undefined {
|
|
79
|
+
const tool = artifact.meta?.tool;
|
|
80
|
+
return typeof tool === "string" ? tool : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function computeEvidence(artifacts: Artifact[]): { evidence: WorkerEvidence; filesChanged: number } {
|
|
84
|
+
const evidence: WorkerEvidence = { reads: 0, commands: 0, edits: 0, errors: 0, codeBlocks: 0, sampleFiles: [] };
|
|
85
|
+
const fileNames = new Set<string>();
|
|
86
|
+
let filesChanged = 0;
|
|
87
|
+
for (const artifact of artifacts) {
|
|
88
|
+
if (artifact.kind === "file") {
|
|
89
|
+
const tool = artifactTool(artifact);
|
|
90
|
+
if (tool === "edit" || tool === "write") {
|
|
91
|
+
evidence.edits++;
|
|
92
|
+
filesChanged++;
|
|
93
|
+
if (fileNames.size < 4) fileNames.add(artifact.title);
|
|
94
|
+
} else if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls") {
|
|
95
|
+
evidence.reads++;
|
|
96
|
+
if (fileNames.size < 4) fileNames.add(artifact.title);
|
|
97
|
+
}
|
|
98
|
+
} else if (artifact.kind === "command") evidence.commands++;
|
|
99
|
+
else if (artifact.kind === "error") evidence.errors++;
|
|
100
|
+
else if (artifact.kind === "code") evidence.codeBlocks++;
|
|
101
|
+
}
|
|
102
|
+
evidence.sampleFiles = [...fileNames];
|
|
103
|
+
return { evidence, filesChanged };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildOutputLabel(state: WorkerDerivedState, answer: Artifact | undefined, recommendations: number, filesChanged: number, progress: { total: number; completed: number }): string {
|
|
107
|
+
if (state === "needs_input") return "needs reply";
|
|
108
|
+
if (state === "starting" || state === "thinking") return "working";
|
|
109
|
+
if (state === "failed") return "error";
|
|
110
|
+
if (state === "stale") return "stale";
|
|
111
|
+
if (state === "ready" || state === "ready_open_todos") {
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
if (recommendations > 0) parts.push(`${recommendations} ${recommendations === 1 ? "rec" : "recs"}`);
|
|
114
|
+
parts.push(filesChanged > 0 ? `${filesChanged} ${filesChanged === 1 ? "file" : "files"} changed` : "no files");
|
|
115
|
+
if (progress.total > 0) parts.push(`${progress.completed}/${progress.total} todos`);
|
|
116
|
+
if (parts.length === 0 || (parts.length === 1 && parts[0] === "no files")) {
|
|
117
|
+
if (!answer || isWorkerStatusArtifact(answer)) return "summary only";
|
|
118
|
+
}
|
|
119
|
+
return parts.join(" · ");
|
|
120
|
+
}
|
|
121
|
+
if (!answer || isWorkerStatusArtifact(answer)) return "no output";
|
|
122
|
+
if (answer.kind === "error") return "error";
|
|
123
|
+
if (answer.kind === "code") return "code output";
|
|
124
|
+
return "text output";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function shortModelLabel(id: string | undefined): string | undefined {
|
|
128
|
+
if (!id) return undefined;
|
|
129
|
+
const cleaned = id.replace(/^anthropic\//, "").replace(/^openai\//, "").replace(/^claude-/, "");
|
|
130
|
+
const stripped = cleaned.replace(/-\d{8}$/, "");
|
|
131
|
+
return stripped.length > 12 ? stripped.slice(0, 12) : stripped;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Return the kind name to show next to a worker label, or undefined for the implicit default. */
|
|
135
|
+
export function workerKindLabel(worker: WorkerStatus): string | undefined {
|
|
136
|
+
const kind = worker.kind?.trim();
|
|
137
|
+
if (!kind || kind === "default") return undefined;
|
|
138
|
+
return kind.length > 16 ? kind.slice(0, 16) : kind;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function pickModelBadge(worker: WorkerStatus, allWorkers: WorkerStatus[], parentModelId: string | undefined): string | undefined {
|
|
142
|
+
const workerLabel = shortModelLabel(worker.model);
|
|
143
|
+
if (!workerLabel) return undefined;
|
|
144
|
+
const parentLabel = shortModelLabel(parentModelId);
|
|
145
|
+
if (parentLabel && parentLabel === workerLabel) {
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
for (const w of allWorkers) {
|
|
148
|
+
const l = shortModelLabel(w.model);
|
|
149
|
+
if (l) seen.add(l);
|
|
150
|
+
}
|
|
151
|
+
if (seen.size <= 1) return undefined;
|
|
152
|
+
}
|
|
153
|
+
return workerLabel;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type DockRow = {
|
|
157
|
+
worker: WorkerStatus;
|
|
158
|
+
label: string;
|
|
159
|
+
state: WorkerDerivedState;
|
|
160
|
+
taskLabel: string;
|
|
161
|
+
progressLabel: string;
|
|
162
|
+
ageLabel: string;
|
|
163
|
+
attention: boolean;
|
|
164
|
+
chip?: string;
|
|
165
|
+
kindLabel?: string;
|
|
166
|
+
modelBadge?: string;
|
|
167
|
+
eventLine?: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const SKIP_TOOL_EVENT_NAMES = new Set([
|
|
171
|
+
"docket_wait",
|
|
172
|
+
"docket_done",
|
|
173
|
+
"docket_fail",
|
|
174
|
+
"docket_todos",
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
function truncateTool(text: string, max = 60): string {
|
|
178
|
+
if (text.length <= max) return text;
|
|
179
|
+
return `${text.slice(0, Math.max(1, max - 1))}…`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function dockEventSubLine(events: WorkerEvent[] | undefined, state: WorkerDerivedState): string | undefined {
|
|
183
|
+
if (!events?.length) return undefined;
|
|
184
|
+
if (state !== "thinking" && state !== "starting") return undefined;
|
|
185
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
186
|
+
const event = events[i]!;
|
|
187
|
+
if (event.kind === "tool") {
|
|
188
|
+
const tool = typeof event.payload.tool === "string" ? event.payload.tool : undefined;
|
|
189
|
+
if (!tool || SKIP_TOOL_EVENT_NAMES.has(tool)) continue;
|
|
190
|
+
const target = typeof event.payload.target === "string" ? event.payload.target : undefined;
|
|
191
|
+
return truncateTool(target ? `tool: ${tool} ${target}` : `tool: ${tool}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
195
|
+
const event = events[i]!;
|
|
196
|
+
if (event.kind === "todo") {
|
|
197
|
+
const total = Number(event.payload.total ?? 0);
|
|
198
|
+
const completed = Number(event.payload.completed ?? 0);
|
|
199
|
+
const inProgress = Number(event.payload.inProgress ?? 0);
|
|
200
|
+
if (!Number.isFinite(total) || total <= 0) continue;
|
|
201
|
+
const active = inProgress > 0 ? ` · ${inProgress} active` : "";
|
|
202
|
+
return `todos ${completed}/${total}${active}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function relativeAgeLabel(updatedAtMs: number, now: number): string {
|
|
209
|
+
const ageMs = now - updatedAtMs;
|
|
210
|
+
if (!Number.isFinite(ageMs) || ageMs < 0) return "";
|
|
211
|
+
const seconds = Math.round(ageMs / 1000);
|
|
212
|
+
if (seconds < 60) return `${seconds}s`;
|
|
213
|
+
const minutes = Math.round(seconds / 60);
|
|
214
|
+
if (minutes < 60) return `${minutes}m`;
|
|
215
|
+
const hours = Math.round(minutes / 60);
|
|
216
|
+
return `${hours}h`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function dockProgressLabel(row: WorkerActivityRow): string {
|
|
220
|
+
if (row.progress.total > 0) return `${row.progress.completed}/${row.progress.total} todos`;
|
|
221
|
+
if (row.state === "ready" || row.state === "ready_open_todos") {
|
|
222
|
+
if (row.recommendations > 0) return `${row.recommendations} ${row.recommendations === 1 ? "rec" : "recs"}`;
|
|
223
|
+
if (row.filesChanged > 0) return `${row.filesChanged} ${row.filesChanged === 1 ? "file" : "files"} changed`;
|
|
224
|
+
}
|
|
225
|
+
if (row.state === "needs_input") return "needs reply";
|
|
226
|
+
if (row.state === "failed") return "error";
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function dockChip(state: WorkerDerivedState): string | undefined {
|
|
231
|
+
if (state === "needs_input") return "← reply";
|
|
232
|
+
if (state === "failed") return "← inspect";
|
|
233
|
+
if (state === "ready" || state === "ready_open_todos") return "← review";
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isAttentionState(state: WorkerDerivedState): boolean {
|
|
238
|
+
return state === "needs_input" || state === "failed" || state === "ready" || state === "ready_open_todos";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function dockRowsForRender(
|
|
242
|
+
rows: WorkerActivityRow[],
|
|
243
|
+
options: { parentModelId?: string; now?: number; eventsByWorker?: Map<string, WorkerEvent[]> } = {},
|
|
244
|
+
): DockRow[] {
|
|
245
|
+
const now = options.now ?? Date.now();
|
|
246
|
+
const workers = rows.map((row) => row.worker);
|
|
247
|
+
return rows.map((row) => {
|
|
248
|
+
const modelBadge = pickModelBadge(row.worker, workers, options.parentModelId);
|
|
249
|
+
const chip = dockChip(row.state);
|
|
250
|
+
const events = options.eventsByWorker?.get(row.worker.id);
|
|
251
|
+
const eventLine = dockEventSubLine(events, row.state);
|
|
252
|
+
const kindLabel = workerKindLabel(row.worker);
|
|
253
|
+
return {
|
|
254
|
+
worker: row.worker,
|
|
255
|
+
label: row.label,
|
|
256
|
+
state: row.state,
|
|
257
|
+
taskLabel: row.taskLabel,
|
|
258
|
+
progressLabel: dockProgressLabel(row),
|
|
259
|
+
ageLabel: relativeAgeLabel(row.updatedAt || Date.parse(row.worker.updatedAt) || now, now),
|
|
260
|
+
attention: isAttentionState(row.state),
|
|
261
|
+
...(chip ? { chip } : {}),
|
|
262
|
+
...(kindLabel ? { kindLabel } : {}),
|
|
263
|
+
...(modelBadge ? { modelBadge } : {}),
|
|
264
|
+
...(eventLine ? { eventLine } : {}),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function workerActivityStateLabel(state: WorkerDerivedState): string {
|
|
270
|
+
if (state === "needs_input") return "needs input";
|
|
271
|
+
if (state === "ready_open_todos") return "ready/open todos";
|
|
272
|
+
if (state === "ready") return "ready";
|
|
273
|
+
if (state === "failed") return "failed";
|
|
274
|
+
if (state === "thinking") return "active";
|
|
275
|
+
if (state === "starting") return "starting";
|
|
276
|
+
if (state === "stale") return "stale";
|
|
277
|
+
if (state === "empty") return "done/empty";
|
|
278
|
+
return "idle";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function workerSummaryForCounts(worker: WorkerStatus, answer: Artifact | undefined): string | undefined {
|
|
282
|
+
const parts: string[] = [];
|
|
283
|
+
if (typeof worker.summary === "string" && worker.summary.length > 0) parts.push(worker.summary);
|
|
284
|
+
if (answer && !isWorkerStatusArtifact(answer)) parts.push(`${answer.title}\n${answer.body}`);
|
|
285
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function workerActivityActionHint(state: WorkerDerivedState): string {
|
|
289
|
+
if (state === "needs_input") return "press c to reply";
|
|
290
|
+
if (state === "ready" || state === "ready_open_todos") return "press l to load";
|
|
291
|
+
if (state === "failed") return "Enter details";
|
|
292
|
+
if (state === "starting" || state === "thinking") return "working";
|
|
293
|
+
return "Enter details";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function workerActivityRows(workers: WorkerStatus[], artifactsByWorker: Map<string, Artifact[]> = new Map(), options: { now?: number; maxTodoItems?: number } = {}): WorkerActivityRow[] {
|
|
297
|
+
const now = options.now ?? Date.now();
|
|
298
|
+
return workers.map((worker) => {
|
|
299
|
+
const artifacts = artifactsByWorker.get(worker.id) ?? [];
|
|
300
|
+
const state = deriveWorkerState(worker, now);
|
|
301
|
+
const answer = workerResultArtifact(worker, artifacts);
|
|
302
|
+
const answerLine = answer && !isWorkerStatusArtifact(answer) ? firstLine(answer.title) ?? firstLine(answer.body) : undefined;
|
|
303
|
+
const questions = workerQuestions(worker);
|
|
304
|
+
const questionText = questions.map((question, index) => `${index + 1}. ${question.text}`).join(" ");
|
|
305
|
+
const message = state === "needs_input" && questionText ? questionText : workerResultSummary(worker, artifacts) || workerDisplayName(worker);
|
|
306
|
+
const summary = workerSummaryForCounts(worker, answer);
|
|
307
|
+
const recommendations = countRecommendations(summary);
|
|
308
|
+
const { evidence, filesChanged } = computeEvidence(artifacts);
|
|
309
|
+
const progress = workerTodoProgress(worker);
|
|
310
|
+
return {
|
|
311
|
+
worker,
|
|
312
|
+
label: workerSourceLabel(worker),
|
|
313
|
+
chip: workerActivityChip(worker, { now }),
|
|
314
|
+
state,
|
|
315
|
+
stateLabel: workerActivityStateLabel(state),
|
|
316
|
+
taskLabel: workerDisplayName(worker, 32),
|
|
317
|
+
message,
|
|
318
|
+
answer,
|
|
319
|
+
answerLine,
|
|
320
|
+
outputLabel: buildOutputLabel(state, answer, recommendations, filesChanged, progress),
|
|
321
|
+
actionHint: workerActivityActionHint(state),
|
|
322
|
+
questions,
|
|
323
|
+
progress,
|
|
324
|
+
todoLines: workerTodoBoardLines(worker, { maxItems: options.maxTodoItems ?? 12, maxText: Number.POSITIVE_INFINITY }),
|
|
325
|
+
recommendations,
|
|
326
|
+
filesChanged,
|
|
327
|
+
evidence,
|
|
328
|
+
...(summary ? { summary } : {}),
|
|
329
|
+
updatedAt: Date.parse(worker.updatedAt) || 0,
|
|
330
|
+
};
|
|
331
|
+
}).sort((a, b) => workerStateRank(a.worker, now) - workerStateRank(b.worker, now) || b.updatedAt - a.updatedAt);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function workerActivityTotals(rows: WorkerActivityRow[]): WorkerActivityTotals {
|
|
335
|
+
return rows.reduce((acc, row) => {
|
|
336
|
+
acc.workers++;
|
|
337
|
+
if (row.state === "thinking" || row.state === "starting") acc.active++;
|
|
338
|
+
else if (row.state === "needs_input") acc.waiting++;
|
|
339
|
+
else if (row.state === "ready_open_todos") acc.readyOpenTodos++;
|
|
340
|
+
else if (row.state === "ready") acc.ready++;
|
|
341
|
+
else if (row.state === "failed") acc.failed++;
|
|
342
|
+
acc.todos += row.progress.total;
|
|
343
|
+
acc.completedTodos += row.progress.completed;
|
|
344
|
+
return acc;
|
|
345
|
+
}, { workers: 0, active: 0, waiting: 0, ready: 0, readyOpenTodos: 0, failed: 0, todos: 0, completedTodos: 0 });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function workerActivityStackLines(rows: WorkerActivityRow[]): WorkerActivityStackLine[] {
|
|
349
|
+
const lines: WorkerActivityStackLine[] = [];
|
|
350
|
+
for (const row of rows) {
|
|
351
|
+
const todoStatus = row.progress.total ? ` · todos ${row.progress.completed}/${row.progress.total}` : "";
|
|
352
|
+
lines.push({ kind: "worker", state: row.state, worker: row.worker, text: `${row.chip} · ${row.stateLabel}${todoStatus} · ${row.taskLabel} · ${row.outputLabel} · ${row.actionHint}` });
|
|
353
|
+
}
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function previewOutcomeBody(row: WorkerActivityRow): string {
|
|
358
|
+
if (row.state === "needs_input" && row.questions.length) return row.questions.map((q, i) => `${i + 1}. ${q.text}`).join("\n");
|
|
359
|
+
if (row.state === "failed") return row.worker.lastError || row.message || "Failure recorded without detail.";
|
|
360
|
+
if (row.state === "starting" || row.state === "thinking") return `${row.taskLabel} — working`;
|
|
361
|
+
return row.message || row.answerLine || row.taskLabel;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function previewEvidenceBody(row: WorkerActivityRow): string {
|
|
365
|
+
const counts: string[] = [];
|
|
366
|
+
if (row.evidence.reads > 0) counts.push(`${row.evidence.reads} reads`);
|
|
367
|
+
if (row.evidence.commands > 0) counts.push(`${row.evidence.commands} commands`);
|
|
368
|
+
if (row.evidence.edits > 0) counts.push(`${row.evidence.edits} edits`);
|
|
369
|
+
if (row.evidence.codeBlocks > 0) counts.push(`${row.evidence.codeBlocks} code blocks`);
|
|
370
|
+
if (row.evidence.errors > 0) counts.push(`${row.evidence.errors} errors`);
|
|
371
|
+
if (row.progress.total > 0) counts.push(`${row.progress.completed}/${row.progress.total} todos`);
|
|
372
|
+
const sample = row.evidence.sampleFiles.length ? `Files: ${row.evidence.sampleFiles.slice(0, 3).join(", ")}${row.evidence.sampleFiles.length > 3 ? "…" : ""}` : undefined;
|
|
373
|
+
const summary = counts.length ? counts.join(" · ") : "No artifacts captured yet.";
|
|
374
|
+
return sample ? `${summary}\n${sample}` : summary;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function previewNextActions(row: WorkerActivityRow): string {
|
|
378
|
+
const primary = row.state === "needs_input"
|
|
379
|
+
? "[c Reply]"
|
|
380
|
+
: row.state === "failed"
|
|
381
|
+
? "[Enter Inspect failure]"
|
|
382
|
+
: row.state === "ready" || row.state === "ready_open_todos"
|
|
383
|
+
? "[Enter Review answer]"
|
|
384
|
+
: "[Enter Open]";
|
|
385
|
+
const buttons = [primary, "[l Load summary]", "[c Continue]", "[a Attach tmux]", "[x Dismiss]"];
|
|
386
|
+
return buttons.join(" ");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function workerActivityPreviewLines(row: WorkerActivityRow): string[] {
|
|
390
|
+
const kindLabel = workerKindLabel(row.worker);
|
|
391
|
+
const lines: string[] = [];
|
|
392
|
+
if (kindLabel) lines.push("Kind", kindLabel);
|
|
393
|
+
lines.push(
|
|
394
|
+
"Outcome",
|
|
395
|
+
previewOutcomeBody(row),
|
|
396
|
+
"Evidence",
|
|
397
|
+
previewEvidenceBody(row),
|
|
398
|
+
"Next actions",
|
|
399
|
+
previewNextActions(row),
|
|
400
|
+
);
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { workerSourceLabel, workerSummaryName, type WorkerStatus } from "./background-work.js";
|
|
5
|
+
import type { Artifact } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type WorkerChangedFile = {
|
|
8
|
+
path: string;
|
|
9
|
+
additions?: number;
|
|
10
|
+
deletions?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WorkerChangeSet = {
|
|
14
|
+
workerId: string;
|
|
15
|
+
workerLabel: string;
|
|
16
|
+
files: WorkerChangedFile[];
|
|
17
|
+
stat: string;
|
|
18
|
+
patch: string;
|
|
19
|
+
hunkCount: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PromoteWorkerChangeSetResult =
|
|
23
|
+
| { ok: true; fileCount: number; message: string }
|
|
24
|
+
| { ok: false; needsConfirmation?: boolean; message: string };
|
|
25
|
+
|
|
26
|
+
function gitOutput(cwd: string, args: string[], input?: string): string | undefined {
|
|
27
|
+
const result = spawnSync("git", args, { cwd, input, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
|
28
|
+
if (result.error || result.status !== 0) return undefined;
|
|
29
|
+
return result.stdout;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitStatus(cwd: string, args: string[], input?: string): { status: number | null; stderr: string; error?: Error } {
|
|
33
|
+
const result = spawnSync("git", args, { cwd, input, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
|
34
|
+
return { status: result.status, stderr: result.stderr.trim(), ...(result.error ? { error: result.error } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function gitBuffer(cwd: string, args: string[]): Buffer | undefined {
|
|
38
|
+
const result = spawnSync("git", args, { cwd, encoding: "buffer", maxBuffer: 20 * 1024 * 1024 });
|
|
39
|
+
if (result.error || result.status !== 0) return undefined;
|
|
40
|
+
return result.stdout;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function repoRoot(cwd: string): string {
|
|
44
|
+
return gitOutput(cwd, ["rev-parse", "--show-toplevel"])?.trim() || cwd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stageWorkerWorkspace(worker: WorkerStatus): string | undefined {
|
|
48
|
+
const workspace = worker.worktree?.path;
|
|
49
|
+
if (!workspace || !fs.existsSync(workspace)) return undefined;
|
|
50
|
+
gitStatus(workspace, ["add", "-A"]);
|
|
51
|
+
return workspace;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseNumstat(text: string): WorkerChangedFile[] {
|
|
55
|
+
return text.split(/\r?\n/).map((line) => {
|
|
56
|
+
const [adds, dels, ...rest] = line.split("\t");
|
|
57
|
+
const file = rest.join("\t").trim();
|
|
58
|
+
if (!file) return undefined;
|
|
59
|
+
const additions = adds === "-" ? undefined : Number(adds);
|
|
60
|
+
const deletions = dels === "-" ? undefined : Number(dels);
|
|
61
|
+
return {
|
|
62
|
+
path: file,
|
|
63
|
+
...(Number.isFinite(additions) ? { additions } : {}),
|
|
64
|
+
...(Number.isFinite(deletions) ? { deletions } : {}),
|
|
65
|
+
};
|
|
66
|
+
}).filter((file): file is WorkerChangedFile => file !== undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function changeFileLine(file: WorkerChangedFile): string {
|
|
70
|
+
const stats = file.additions === undefined && file.deletions === undefined ? "binary" : `+${file.additions ?? 0}/-${file.deletions ?? 0}`;
|
|
71
|
+
return `${file.path} ${stats}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readWorkerChangeSet(worker: WorkerStatus): WorkerChangeSet | undefined {
|
|
75
|
+
const workspace = stageWorkerWorkspace(worker);
|
|
76
|
+
if (!workspace) return undefined;
|
|
77
|
+
const patch = gitOutput(workspace, ["diff", "--cached", "--binary", "HEAD"]);
|
|
78
|
+
if (!patch?.trim()) return undefined;
|
|
79
|
+
const stat = gitOutput(workspace, ["diff", "--cached", "--stat", "--compact-summary", "HEAD"])?.trimEnd() ?? "";
|
|
80
|
+
const files = parseNumstat(gitOutput(workspace, ["diff", "--cached", "--numstat", "HEAD"])?.trimEnd() ?? "");
|
|
81
|
+
const hunkCount = patch.match(/^@@ /gm)?.length ?? 0;
|
|
82
|
+
return { workerId: worker.id, workerLabel: workerSourceLabel(worker), files, stat, patch, hunkCount };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function workerChangeSetArtifact(worker: WorkerStatus): Artifact | undefined {
|
|
86
|
+
const changeSet = readWorkerChangeSet(worker);
|
|
87
|
+
if (!changeSet) return undefined;
|
|
88
|
+
const label = workerSourceLabel(worker);
|
|
89
|
+
const fileCount = changeSet.files.length;
|
|
90
|
+
const fileLines = changeSet.files.slice(0, 12).map((file) => `- ${changeFileLine(file)}`);
|
|
91
|
+
if (changeSet.files.length > fileLines.length) fileLines.push(`- … ${changeSet.files.length - fileLines.length} more`);
|
|
92
|
+
const body = [
|
|
93
|
+
`worker: ${label}`,
|
|
94
|
+
`task: ${worker.task}`,
|
|
95
|
+
`changes: ${fileCount} file${fileCount === 1 ? "" : "s"}`,
|
|
96
|
+
"",
|
|
97
|
+
"Files:",
|
|
98
|
+
...fileLines,
|
|
99
|
+
changeSet.stat ? "\nDiffstat:" : undefined,
|
|
100
|
+
changeSet.stat || undefined,
|
|
101
|
+
"\nPatch:",
|
|
102
|
+
changeSet.patch,
|
|
103
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
104
|
+
return {
|
|
105
|
+
id: "changes",
|
|
106
|
+
displayId: "changes",
|
|
107
|
+
ref: `worker-changes:${worker.id}:0`,
|
|
108
|
+
kind: "response",
|
|
109
|
+
title: `${label} change set · ${fileCount} file${fileCount === 1 ? "" : "s"}`,
|
|
110
|
+
subtitle: workerSummaryName(worker),
|
|
111
|
+
body,
|
|
112
|
+
timestamp: Date.parse(worker.updatedAt),
|
|
113
|
+
meta: {
|
|
114
|
+
workerChangeSet: true,
|
|
115
|
+
workerId: worker.id,
|
|
116
|
+
workerLabel: label,
|
|
117
|
+
workerStatus: "ready",
|
|
118
|
+
changedFiles: changeSet.files,
|
|
119
|
+
diffStat: changeSet.stat,
|
|
120
|
+
hunkCount: changeSet.hunkCount,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function markWorkspacePromoted(worker: WorkerStatus): void {
|
|
126
|
+
const workspace = worker.worktree?.path;
|
|
127
|
+
if (!workspace || !fs.existsSync(workspace)) return;
|
|
128
|
+
gitStatus(workspace, ["add", "-A"]);
|
|
129
|
+
const tree = gitOutput(workspace, ["write-tree"])?.trim();
|
|
130
|
+
const parent = gitOutput(workspace, ["rev-parse", "HEAD"])?.trim();
|
|
131
|
+
if (!tree || !parent) return;
|
|
132
|
+
const commit = spawnSync("git", ["commit-tree", tree, "-p", parent, "-m", "Docket promoted worker changes"], {
|
|
133
|
+
cwd: workspace,
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
env: {
|
|
136
|
+
...process.env,
|
|
137
|
+
GIT_AUTHOR_NAME: "Docket",
|
|
138
|
+
GIT_AUTHOR_EMAIL: "docket@example.invalid",
|
|
139
|
+
GIT_COMMITTER_NAME: "Docket",
|
|
140
|
+
GIT_COMMITTER_EMAIL: "docket@example.invalid",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const promotedHead = commit.status === 0 ? commit.stdout.trim() : undefined;
|
|
144
|
+
if (promotedHead) gitStatus(workspace, ["reset", "--hard", promotedHead]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parentChangedSinceSnapshot(worker: WorkerStatus, parentRoot: string): boolean {
|
|
148
|
+
const snapshot = worker.worktree?.snapshotHead ?? worker.worktree?.baseHead;
|
|
149
|
+
if (!snapshot) return false;
|
|
150
|
+
const diff = gitOutput(parentRoot, ["diff", "--name-status", snapshot, "--"])?.trim();
|
|
151
|
+
if (!diff) return false;
|
|
152
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
153
|
+
const [status, ...rest] = line.split("\t");
|
|
154
|
+
const rel = rest.join("\t");
|
|
155
|
+
if (status === "D" && rel) {
|
|
156
|
+
const currentPath = path.join(parentRoot, rel);
|
|
157
|
+
if (fs.existsSync(currentPath)) {
|
|
158
|
+
const baseline = gitBuffer(parentRoot, ["show", `${snapshot}:${rel}`]);
|
|
159
|
+
if (baseline && Buffer.compare(baseline, fs.readFileSync(currentPath)) === 0) continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function promoteWorkerChangeSet(worker: WorkerStatus, parentCwd: string, options: { force?: boolean } = {}): PromoteWorkerChangeSetResult {
|
|
168
|
+
const changeSet = readWorkerChangeSet(worker);
|
|
169
|
+
if (!changeSet) return { ok: false, message: "Worker has no change set to promote." };
|
|
170
|
+
const parentRoot = repoRoot(parentCwd);
|
|
171
|
+
if (!options.force && parentChangedSinceSnapshot(worker, parentRoot)) {
|
|
172
|
+
return { ok: false, needsConfirmation: true, message: "Parent tree changed since this worker started. Review risk before promoting." };
|
|
173
|
+
}
|
|
174
|
+
const check = gitStatus(parentRoot, ["apply", "--check", "--whitespace=nowarn"], changeSet.patch);
|
|
175
|
+
if (check.status !== 0) return { ok: false, message: check.stderr || "Worker change set does not apply cleanly." };
|
|
176
|
+
const applied = gitStatus(parentRoot, ["apply", "--whitespace=nowarn"], changeSet.patch);
|
|
177
|
+
if (applied.status !== 0) return { ok: false, message: applied.stderr || "Worker change set apply failed." };
|
|
178
|
+
markWorkspacePromoted(worker);
|
|
179
|
+
return { ok: true, fileCount: changeSet.files.length, message: `Promoted ${changeSet.files.length} file${changeSet.files.length === 1 ? "" : "s"} from ${workerSourceLabel(worker)}.` };
|
|
180
|
+
}
|