@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,585 @@
|
|
|
1
|
+
import type { Artifact, ArtifactKind } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type NavigatorFilter = ArtifactKind | "all";
|
|
4
|
+
export type NavigatorMode = "review" | "answers" | "log";
|
|
5
|
+
export type NavigatorSource =
|
|
6
|
+
| { kind: "current" }
|
|
7
|
+
| { kind: "all" }
|
|
8
|
+
| { kind: "artifactSource"; source: string };
|
|
9
|
+
export type ReviewBucket = "needs" | "pinned" | "recent";
|
|
10
|
+
export type ReviewActionId =
|
|
11
|
+
| "inspect"
|
|
12
|
+
| "openFile"
|
|
13
|
+
| "promoteWorker"
|
|
14
|
+
| "tellWorker"
|
|
15
|
+
| "openVerdict"
|
|
16
|
+
| "attachReference"
|
|
17
|
+
| "injectFull"
|
|
18
|
+
| "copyArtifact"
|
|
19
|
+
| "pin"
|
|
20
|
+
| "markDone";
|
|
21
|
+
export type ReviewReasonId =
|
|
22
|
+
| "pinned"
|
|
23
|
+
| "done"
|
|
24
|
+
| "workerNeedsInput"
|
|
25
|
+
| "workerFailed"
|
|
26
|
+
| "workerReady"
|
|
27
|
+
| "workerChangeSet"
|
|
28
|
+
| "error"
|
|
29
|
+
| "changedFile"
|
|
30
|
+
| "createdFile"
|
|
31
|
+
| "failedCommand"
|
|
32
|
+
| "workerAnswer"
|
|
33
|
+
| "workerOutput"
|
|
34
|
+
| "assistantAnswer"
|
|
35
|
+
| "checkpointAvailable";
|
|
36
|
+
|
|
37
|
+
export type ReviewCategory =
|
|
38
|
+
| "needs-decision"
|
|
39
|
+
| "ready-for-review"
|
|
40
|
+
| "failed-blocked"
|
|
41
|
+
| "patch-proposed"
|
|
42
|
+
| "checkpoint-available"
|
|
43
|
+
| "pinned"
|
|
44
|
+
| "recent";
|
|
45
|
+
|
|
46
|
+
export type ReviewQueueState = {
|
|
47
|
+
pinnedRefs: ReadonlySet<string>;
|
|
48
|
+
doneRefs: ReadonlySet<string>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ReviewItem = {
|
|
52
|
+
artifact: Artifact;
|
|
53
|
+
bucket?: ReviewBucket;
|
|
54
|
+
reasonId?: ReviewReasonId;
|
|
55
|
+
primaryAction: ReviewActionId;
|
|
56
|
+
actions: ReviewActionId[];
|
|
57
|
+
headline: string;
|
|
58
|
+
recommendations: string[];
|
|
59
|
+
statusChip?: string;
|
|
60
|
+
provenance: string;
|
|
61
|
+
category?: ReviewCategory;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type NavigatorState = {
|
|
65
|
+
selected: number;
|
|
66
|
+
filter: NavigatorFilter;
|
|
67
|
+
source: NavigatorSource;
|
|
68
|
+
mode: NavigatorMode;
|
|
69
|
+
showDetail: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type NavigatorIntent =
|
|
73
|
+
| { kind: "move"; by: number }
|
|
74
|
+
| { kind: "top" }
|
|
75
|
+
| { kind: "bottom" }
|
|
76
|
+
| { kind: "setMode"; mode: NavigatorMode }
|
|
77
|
+
| { kind: "cycleMode" }
|
|
78
|
+
| { kind: "cycleFilter" }
|
|
79
|
+
| { kind: "cycleSource" }
|
|
80
|
+
| { kind: "toggleDetail" }
|
|
81
|
+
| { kind: "activatePrimary" }
|
|
82
|
+
| { kind: "runAction"; action: ReviewActionId }
|
|
83
|
+
| { kind: "createCheckpoint" }
|
|
84
|
+
| { kind: "search" }
|
|
85
|
+
| { kind: "close" };
|
|
86
|
+
|
|
87
|
+
export type NavigatorAction =
|
|
88
|
+
| { action: "runReviewAction"; id: ReviewActionId; item: ReviewItem }
|
|
89
|
+
| { action: "createCheckpoint" }
|
|
90
|
+
| { action: "search" }
|
|
91
|
+
| { action: "close" };
|
|
92
|
+
|
|
93
|
+
export type NavigatorTransition = {
|
|
94
|
+
state: NavigatorState;
|
|
95
|
+
action?: NavigatorAction;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type NavigatorViewModel = {
|
|
99
|
+
items: ReviewItem[];
|
|
100
|
+
selected: number;
|
|
101
|
+
selectedItem?: ReviewItem;
|
|
102
|
+
visible: ReviewItem[];
|
|
103
|
+
visibleStart: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const FILTERS: NavigatorFilter[] = ["all", "error", "command", "file", "code", "prompt", "response", "checkpoint"];
|
|
107
|
+
const BUCKET_RANK: Record<ReviewBucket, number> = { needs: 0, pinned: 1, recent: 2 };
|
|
108
|
+
const EMPTY_REFS: ReadonlySet<string> = new Set<string>();
|
|
109
|
+
const EMPTY_QUEUE: ReviewQueueState = { pinnedRefs: EMPTY_REFS, doneRefs: EMPTY_REFS };
|
|
110
|
+
|
|
111
|
+
export function currentNavigatorSource(): NavigatorSource {
|
|
112
|
+
return { kind: "current" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function allNavigatorSource(): NavigatorSource {
|
|
116
|
+
return { kind: "all" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function artifactNavigatorSource(source: string): NavigatorSource {
|
|
120
|
+
return { kind: "artifactSource", source };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function navigatorSourceLabel(source: NavigatorSource): string {
|
|
124
|
+
if (source.kind === "artifactSource") return source.source;
|
|
125
|
+
return source.kind;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function sameNavigatorSource(a: NavigatorSource, b: NavigatorSource): boolean {
|
|
129
|
+
if (a.kind !== b.kind) return false;
|
|
130
|
+
return a.kind !== "artifactSource" || a.source === (b as Extract<NavigatorSource, { kind: "artifactSource" }>).source;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function initialNavigatorState(): NavigatorState {
|
|
134
|
+
return { selected: 0, filter: "all", source: currentNavigatorSource(), mode: "review", showDetail: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function availableSources(artifacts: Artifact[]): NavigatorSource[] {
|
|
138
|
+
const slots = new Set<string>();
|
|
139
|
+
let hasCurrent = false;
|
|
140
|
+
let hasCarryover = false;
|
|
141
|
+
for (const artifact of artifacts) {
|
|
142
|
+
if (artifact.source) { slots.add(artifact.source); hasCarryover = true; }
|
|
143
|
+
else hasCurrent = true;
|
|
144
|
+
}
|
|
145
|
+
const out: NavigatorSource[] = [];
|
|
146
|
+
if (hasCurrent) out.push(currentNavigatorSource());
|
|
147
|
+
if (hasCarryover && hasCurrent) out.push(allNavigatorSource());
|
|
148
|
+
for (const slot of [...slots].sort()) out.push(artifactNavigatorSource(slot));
|
|
149
|
+
if (out.length === 0) out.push(allNavigatorSource());
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applySourceFilter(artifacts: Artifact[], source: NavigatorSource): Artifact[] {
|
|
154
|
+
if (source.kind === "all") return artifacts;
|
|
155
|
+
if (source.kind === "current") return artifacts.filter((artifact) => !artifact.source);
|
|
156
|
+
return artifacts.filter((artifact) => artifact.source === source.source);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function artifactMeta(artifact: Artifact): Record<string, unknown> {
|
|
160
|
+
return artifact.meta ?? {};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function artifactTool(artifact: Artifact): string | undefined {
|
|
164
|
+
const tool = artifactMeta(artifact).tool;
|
|
165
|
+
return typeof tool === "string" ? tool : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function artifactHasDiff(artifact: Artifact): boolean {
|
|
169
|
+
const diff = artifactMeta(artifact).diff;
|
|
170
|
+
return typeof diff === "string" && diff.length > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type ArtifactWorkerStatus = "starting" | "thinking" | "stale" | "needs_input" | "ready" | "ready_open_todos" | "empty" | "failed" | "idle";
|
|
174
|
+
|
|
175
|
+
function artifactWorkerStatus(artifact: Artifact): ArtifactWorkerStatus | undefined {
|
|
176
|
+
const status = artifactMeta(artifact).workerStatus;
|
|
177
|
+
if (status === "needs_input" || status === "ready" || status === "ready_open_todos" || status === "failed" || status === "stale" || status === "starting" || status === "thinking" || status === "empty" || status === "idle") return status;
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function artifactWorkerRef(artifact: Artifact): string | undefined {
|
|
182
|
+
const label = artifactMeta(artifact).workerLabel;
|
|
183
|
+
if (typeof label === "string" && label.length > 0) return label;
|
|
184
|
+
return artifact.source;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isWorkerChangeSet(artifact: Artifact): boolean {
|
|
188
|
+
return artifactMeta(artifact).workerChangeSet === true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isChangedFileArtifact(artifact: Artifact): boolean {
|
|
192
|
+
return artifact.kind === "file" && ["edit", "write"].includes(artifactTool(artifact) ?? "");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isFailedCommandArtifact(artifact: Artifact): boolean {
|
|
196
|
+
if (artifact.kind !== "command") return false;
|
|
197
|
+
const exitCode = artifactMeta(artifact).exitCode;
|
|
198
|
+
return typeof exitCode === "number" && exitCode !== 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function reviewBucket(artifact: Artifact, queueState: ReviewQueueState = EMPTY_QUEUE): ReviewBucket | undefined {
|
|
202
|
+
if (queueState.pinnedRefs.has(artifact.ref)) return "pinned";
|
|
203
|
+
if (queueState.doneRefs.has(artifact.ref)) return "recent";
|
|
204
|
+
const workerStatus = artifactWorkerStatus(artifact);
|
|
205
|
+
if (workerStatus === "needs_input" || workerStatus === "ready" || workerStatus === "ready_open_todos" || workerStatus === "failed") return "needs";
|
|
206
|
+
if (isWorkerChangeSet(artifact)) return "needs";
|
|
207
|
+
if (artifact.kind === "error") return "needs";
|
|
208
|
+
if (isChangedFileArtifact(artifact) && artifact.source) return "needs";
|
|
209
|
+
if (isFailedCommandArtifact(artifact)) return "needs";
|
|
210
|
+
if (artifact.source && (artifact.kind === "response" || artifact.kind === "code")) return "needs";
|
|
211
|
+
if (artifact.kind === "checkpoint") return "needs";
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function attentionRank(item: ReviewItem): number {
|
|
216
|
+
const artifact = item.artifact;
|
|
217
|
+
const status = artifactWorkerStatus(artifact);
|
|
218
|
+
if (status === "needs_input") return 0;
|
|
219
|
+
if (status === "failed") return 1;
|
|
220
|
+
if (artifact.kind === "error" || isFailedCommandArtifact(artifact)) return 2;
|
|
221
|
+
if (isWorkerChangeSet(artifact)) return 3;
|
|
222
|
+
if (isChangedFileArtifact(artifact)) return 4;
|
|
223
|
+
if (status === "ready") return 5;
|
|
224
|
+
if (artifact.source && artifact.kind === "response") return 5;
|
|
225
|
+
if (artifact.source && artifact.kind === "code") return 6;
|
|
226
|
+
return 100;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reviewReason(artifact: Artifact, bucket: ReviewBucket | undefined): ReviewReasonId | undefined {
|
|
230
|
+
const status = artifactWorkerStatus(artifact);
|
|
231
|
+
if (bucket === "pinned") return "pinned";
|
|
232
|
+
if (bucket === "recent") return "done";
|
|
233
|
+
if (status === "needs_input") return "workerNeedsInput";
|
|
234
|
+
if (status === "failed") return "workerFailed";
|
|
235
|
+
if (isWorkerChangeSet(artifact)) return "workerChangeSet";
|
|
236
|
+
if (status === "ready" || status === "ready_open_todos") return "workerReady";
|
|
237
|
+
if (artifact.kind === "error") return "error";
|
|
238
|
+
if (isChangedFileArtifact(artifact)) return artifactHasDiff(artifact) ? "changedFile" : "createdFile";
|
|
239
|
+
if (isFailedCommandArtifact(artifact)) return "failedCommand";
|
|
240
|
+
if (artifact.source && artifact.kind === "response") return "workerAnswer";
|
|
241
|
+
if (artifact.source && artifact.kind === "code") return "workerOutput";
|
|
242
|
+
if (artifact.kind === "checkpoint") return "checkpointAvailable";
|
|
243
|
+
if (artifact.kind === "response") return "assistantAnswer";
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function reviewCategory(reasonId: ReviewReasonId | undefined, bucket: ReviewBucket | undefined): ReviewCategory | undefined {
|
|
248
|
+
if (bucket === "pinned") return "pinned";
|
|
249
|
+
if (bucket === "recent") return "recent";
|
|
250
|
+
if (reasonId === "workerNeedsInput") return "needs-decision";
|
|
251
|
+
if (reasonId === "workerReady" || reasonId === "workerAnswer" || reasonId === "workerOutput") return "ready-for-review";
|
|
252
|
+
if (reasonId === "workerFailed" || reasonId === "error" || reasonId === "failedCommand") return "failed-blocked";
|
|
253
|
+
if (reasonId === "workerChangeSet" || reasonId === "changedFile" || reasonId === "createdFile") return "patch-proposed";
|
|
254
|
+
if (reasonId === "checkpointAvailable") return "checkpoint-available";
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function reviewCategoryLabel(category: ReviewCategory | undefined): string {
|
|
259
|
+
if (category === "needs-decision") return "Needs decision";
|
|
260
|
+
if (category === "ready-for-review") return "Ready for review";
|
|
261
|
+
if (category === "failed-blocked") return "Failed / blocked";
|
|
262
|
+
if (category === "patch-proposed") return "Patch proposed";
|
|
263
|
+
if (category === "checkpoint-available") return "Checkpoint available";
|
|
264
|
+
if (category === "pinned") return "Pinned";
|
|
265
|
+
if (category === "recent") return "Recently reviewed";
|
|
266
|
+
return "Other";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function primaryAction(artifact: Artifact): ReviewActionId {
|
|
270
|
+
const status = artifactWorkerStatus(artifact);
|
|
271
|
+
if (status === "needs_input" || status === "failed") return "openVerdict";
|
|
272
|
+
if (isWorkerChangeSet(artifact)) return "openVerdict";
|
|
273
|
+
if (artifact.kind === "file" && !artifactHasDiff(artifact)) return "openFile";
|
|
274
|
+
return "inspect";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function metaString(artifact: Artifact, key: string): string | undefined {
|
|
278
|
+
const value = artifactMeta(artifact)[key];
|
|
279
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function workerLabelOf(artifact: Artifact): string | undefined {
|
|
283
|
+
return artifactWorkerRef(artifact);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stripStatePrefix(text: string, label: string | undefined): string {
|
|
287
|
+
if (!label) return text;
|
|
288
|
+
const patterns = [
|
|
289
|
+
new RegExp(`^${label}\\s+ready(?:[\\/\\s][^:]*)?:\\s*`, "i"),
|
|
290
|
+
new RegExp(`^${label}\\s+failed(?:[^:]*)?:\\s*`, "i"),
|
|
291
|
+
new RegExp(`^${label}\\s+needs input(?:[^:]*)?:\\s*`, "i"),
|
|
292
|
+
];
|
|
293
|
+
for (const pattern of patterns) {
|
|
294
|
+
const next = text.replace(pattern, "");
|
|
295
|
+
if (next !== text) return next.trim();
|
|
296
|
+
}
|
|
297
|
+
return text;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function bodyMessageSection(body: string | undefined): string | undefined {
|
|
301
|
+
if (!body) return undefined;
|
|
302
|
+
const idx = body.indexOf("\nmessage:\n");
|
|
303
|
+
if (idx === -1) return undefined;
|
|
304
|
+
return body.slice(idx + "\nmessage:\n".length).trim() || undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function workerSummaryText(artifact: Artifact): string | undefined {
|
|
308
|
+
const status = artifactWorkerStatus(artifact);
|
|
309
|
+
if (status === "needs_input") return metaString(artifact, "question") ?? bodyMessageSection(artifact.body);
|
|
310
|
+
if (status === "ready" || status === "ready_open_todos") return metaString(artifact, "summary") ?? bodyMessageSection(artifact.body);
|
|
311
|
+
if (status === "failed") return metaString(artifact, "lastError") ?? bodyMessageSection(artifact.body);
|
|
312
|
+
if (artifact.source) return bodyMessageSection(artifact.body) ?? stripStatePrefix(artifact.title, workerLabelOf(artifact));
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function firstNonEmptyLine(text: string | undefined): string | undefined {
|
|
317
|
+
if (!text) return undefined;
|
|
318
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
319
|
+
const line = raw.trim();
|
|
320
|
+
if (line) return line;
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const BULLET_PREFIX = /^\s*(?:[-*•]|\d+[.)])\s+/;
|
|
326
|
+
|
|
327
|
+
function extractBullets(text: string | undefined): string[] {
|
|
328
|
+
if (!text) return [];
|
|
329
|
+
const lines = text.split(/\r?\n/);
|
|
330
|
+
const bullets: string[] = [];
|
|
331
|
+
let inRecommended = false;
|
|
332
|
+
for (const raw of lines) {
|
|
333
|
+
const line = raw.trim();
|
|
334
|
+
if (!line) { if (inRecommended) break; continue; }
|
|
335
|
+
if (/^recommended:?$/i.test(line) || /^recommendations:?$/i.test(line) || /^suggested:?$/i.test(line)) { inRecommended = true; continue; }
|
|
336
|
+
const match = line.match(BULLET_PREFIX);
|
|
337
|
+
if (match) bullets.push(line.slice(match[0].length).trim());
|
|
338
|
+
else if (inRecommended) bullets.push(line);
|
|
339
|
+
}
|
|
340
|
+
return bullets.filter(Boolean).slice(0, 4);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function fallbackSentences(text: string | undefined, max = 2): string[] {
|
|
344
|
+
if (!text) return [];
|
|
345
|
+
const sentences = text.split(/(?<=[.!?])\s+/).map((sentence) => sentence.trim()).filter(Boolean);
|
|
346
|
+
return sentences.slice(0, max);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function cardRecommendations(artifact: Artifact): string[] {
|
|
350
|
+
const summary = workerSummaryText(artifact);
|
|
351
|
+
const bullets = extractBullets(summary);
|
|
352
|
+
if (bullets.length > 0) return bullets;
|
|
353
|
+
if (artifact.kind === "response" || artifact.kind === "code") return fallbackSentences(summary ?? artifact.body, 2);
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function workerHeadline(artifact: Artifact, status: ArtifactWorkerStatus, label: string): string {
|
|
358
|
+
const subtitle = artifact.subtitle?.trim();
|
|
359
|
+
const task = subtitle && subtitle.length > 0 ? subtitle : undefined;
|
|
360
|
+
if (status === "needs_input") return task ? `${label} needs input · ${task}` : `${label} needs input`;
|
|
361
|
+
if (status === "failed") return task ? `${label} failed · ${task}` : `${label} failed`;
|
|
362
|
+
if (status === "ready" || status === "ready_open_todos") return task ? `${label} finished · ${task}` : `${label} finished`;
|
|
363
|
+
return task ? `${label} · ${task}` : label;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function cardHeadline(artifact: Artifact): string {
|
|
367
|
+
const status = artifactWorkerStatus(artifact);
|
|
368
|
+
const label = workerLabelOf(artifact);
|
|
369
|
+
if (isWorkerChangeSet(artifact)) return firstNonEmptyLine(artifact.title) ?? "Worker change set";
|
|
370
|
+
if (status && label) return workerHeadline(artifact, status, label);
|
|
371
|
+
if (artifact.kind === "error") return firstNonEmptyLine(artifact.title) ?? "Error";
|
|
372
|
+
if (isChangedFileArtifact(artifact)) {
|
|
373
|
+
const verb = artifactHasDiff(artifact) ? "Edited" : "Created";
|
|
374
|
+
return `${verb} ${artifact.title}`;
|
|
375
|
+
}
|
|
376
|
+
if (isFailedCommandArtifact(artifact)) return `Command failed: ${artifact.title}`;
|
|
377
|
+
if (artifact.source && artifact.kind === "response" && label) {
|
|
378
|
+
const cleaned = stripStatePrefix(artifact.title, label);
|
|
379
|
+
return `${label} answered${cleaned ? ` · ${cleaned}` : ""}`;
|
|
380
|
+
}
|
|
381
|
+
return firstNonEmptyLine(artifact.title) ?? artifact.kind;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function cardStatusChip(artifact: Artifact, bucket: ReviewBucket | undefined): string | undefined {
|
|
385
|
+
const status = artifactWorkerStatus(artifact);
|
|
386
|
+
if (status === "needs_input") return "needs reply";
|
|
387
|
+
if (status === "failed") return "failed";
|
|
388
|
+
if (status === "ready") return isWorkerChangeSet(artifact) ? "change set" : "ready";
|
|
389
|
+
if (status === "ready_open_todos") return "ready · open todos";
|
|
390
|
+
if (status === "stale") return "stale";
|
|
391
|
+
if (artifact.kind === "error") return "error";
|
|
392
|
+
if (isFailedCommandArtifact(artifact)) return "failed";
|
|
393
|
+
if (isChangedFileArtifact(artifact)) return artifactHasDiff(artifact) ? "changed" : "new file";
|
|
394
|
+
if (artifact.source && (artifact.kind === "response" || artifact.kind === "code")) return artifact.kind === "code" ? "code" : "answer";
|
|
395
|
+
if (bucket === "pinned") return "pinned";
|
|
396
|
+
if (bucket === "recent") return "done";
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function cardProvenance(artifact: Artifact): string {
|
|
401
|
+
const label = workerLabelOf(artifact);
|
|
402
|
+
return label ? `worker ${label}` : "current session";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function reviewActions(artifact: Artifact): ReviewActionId[] {
|
|
406
|
+
const actions: ReviewActionId[] = ["inspect"];
|
|
407
|
+
const status = artifactWorkerStatus(artifact);
|
|
408
|
+
if (status === "needs_input" || status === "failed" || isWorkerChangeSet(artifact)) actions.push("openVerdict");
|
|
409
|
+
if (isWorkerChangeSet(artifact)) actions.push("promoteWorker");
|
|
410
|
+
if (artifact.kind === "file") actions.push("openFile");
|
|
411
|
+
if (artifactWorkerRef(artifact)) actions.push("tellWorker");
|
|
412
|
+
actions.push("attachReference", "injectFull", "copyArtifact", "pin", "markDone");
|
|
413
|
+
return [...new Set(actions)];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function reviewItemForArtifact(artifact: Artifact, queueState: ReviewQueueState = EMPTY_QUEUE): ReviewItem {
|
|
417
|
+
const bucket = reviewBucket(artifact, queueState);
|
|
418
|
+
const action = primaryAction(artifact);
|
|
419
|
+
const actions = reviewActions(artifact);
|
|
420
|
+
const reason = reviewReason(artifact, bucket);
|
|
421
|
+
const category = reviewCategory(reason, bucket);
|
|
422
|
+
return {
|
|
423
|
+
artifact,
|
|
424
|
+
...(bucket ? { bucket } : {}),
|
|
425
|
+
...(reason ? { reasonId: reason } : {}),
|
|
426
|
+
primaryAction: action,
|
|
427
|
+
actions: actions.includes(action) ? actions : [action, ...actions],
|
|
428
|
+
headline: cardHeadline(artifact),
|
|
429
|
+
recommendations: cardRecommendations(artifact),
|
|
430
|
+
...(cardStatusChip(artifact, bucket) ? { statusChip: cardStatusChip(artifact, bucket) } : {}),
|
|
431
|
+
provenance: cardProvenance(artifact),
|
|
432
|
+
...(category ? { category } : {}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function sortReviewItems(items: ReviewItem[]): ReviewItem[] {
|
|
437
|
+
return [...items].sort((a, b) => {
|
|
438
|
+
const bucketA = a.bucket ?? "recent";
|
|
439
|
+
const bucketB = b.bucket ?? "recent";
|
|
440
|
+
const rank = BUCKET_RANK[bucketA] - BUCKET_RANK[bucketB];
|
|
441
|
+
if (rank !== 0) return rank;
|
|
442
|
+
const attention = attentionRank(a) - attentionRank(b);
|
|
443
|
+
if (attention !== 0) return attention;
|
|
444
|
+
return (b.artifact.timestamp ?? 0) - (a.artifact.timestamp ?? 0);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function applyModeFilter(items: ReviewItem[], mode: NavigatorMode): ReviewItem[] {
|
|
449
|
+
if (mode === "log") return sortLogItems(items);
|
|
450
|
+
if (mode === "answers") return items.filter((item) => item.artifact.kind === "response");
|
|
451
|
+
const queued = items.filter((item) => item.bucket !== undefined);
|
|
452
|
+
const active = queued.filter((item) => item.bucket !== "recent");
|
|
453
|
+
return sortReviewItems(active.length > 0 ? active : queued);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function episodeOrderKey(source: string | undefined): string {
|
|
457
|
+
return source ?? "";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function sortLogItems(items: ReviewItem[]): ReviewItem[] {
|
|
461
|
+
return [...items].sort((a, b) => {
|
|
462
|
+
const sa = episodeOrderKey(a.artifact.source);
|
|
463
|
+
const sb = episodeOrderKey(b.artifact.source);
|
|
464
|
+
if (sa !== sb) return sa.localeCompare(sb);
|
|
465
|
+
return (a.artifact.timestamp ?? 0) - (b.artifact.timestamp ?? 0);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export type EpisodeSummary = {
|
|
470
|
+
id: string;
|
|
471
|
+
source?: string;
|
|
472
|
+
label: string;
|
|
473
|
+
taskLabel?: string;
|
|
474
|
+
artifactCount: number;
|
|
475
|
+
firstTimestamp: number;
|
|
476
|
+
lastTimestamp: number;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
export function episodesFromItems(items: ReviewItem[]): EpisodeSummary[] {
|
|
480
|
+
const map = new Map<string, EpisodeSummary>();
|
|
481
|
+
for (const item of items) {
|
|
482
|
+
const source = item.artifact.source;
|
|
483
|
+
const id = source ?? "current";
|
|
484
|
+
const existing = map.get(id);
|
|
485
|
+
const taskLabel = item.artifact.subtitle?.trim() || undefined;
|
|
486
|
+
const ts = item.artifact.timestamp ?? 0;
|
|
487
|
+
if (!existing) {
|
|
488
|
+
map.set(id, {
|
|
489
|
+
id,
|
|
490
|
+
...(source ? { source } : {}),
|
|
491
|
+
label: source ? `Worker ${source}` : "Current session",
|
|
492
|
+
...(taskLabel ? { taskLabel } : {}),
|
|
493
|
+
artifactCount: 1,
|
|
494
|
+
firstTimestamp: ts,
|
|
495
|
+
lastTimestamp: ts,
|
|
496
|
+
});
|
|
497
|
+
} else {
|
|
498
|
+
existing.artifactCount++;
|
|
499
|
+
existing.firstTimestamp = Math.min(existing.firstTimestamp, ts);
|
|
500
|
+
existing.lastTimestamp = Math.max(existing.lastTimestamp, ts);
|
|
501
|
+
if (!existing.taskLabel && taskLabel) existing.taskLabel = taskLabel;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return [...map.values()].sort((a, b) => {
|
|
505
|
+
if (!a.source && b.source) return -1;
|
|
506
|
+
if (a.source && !b.source) return 1;
|
|
507
|
+
return (a.source ?? "").localeCompare(b.source ?? "");
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function filteredReviewItems(state: NavigatorState, artifacts: Artifact[], queueState: ReviewQueueState = EMPTY_QUEUE): ReviewItem[] {
|
|
512
|
+
const sourced = applySourceFilter(artifacts, state.source);
|
|
513
|
+
const items = sourced.map((artifact) => reviewItemForArtifact(artifact, queueState));
|
|
514
|
+
const moded = applyModeFilter(items, state.mode);
|
|
515
|
+
return state.filter === "all" ? moded : moded.filter((item) => item.artifact.kind === state.filter);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function selectedReviewItem(state: NavigatorState, artifacts: Artifact[], queueState: ReviewQueueState = EMPTY_QUEUE): ReviewItem | undefined {
|
|
519
|
+
return filteredReviewItems(state, artifacts, queueState)[state.selected];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function navigatorViewModel(state: NavigatorState, artifacts: Artifact[], queueState: ReviewQueueState = EMPTY_QUEUE, windowSize = 12): NavigatorViewModel {
|
|
523
|
+
const items = filteredReviewItems(state, artifacts, queueState);
|
|
524
|
+
const selected = Math.min(Math.max(0, state.selected), Math.max(0, items.length - 1));
|
|
525
|
+
const start = Math.max(0, Math.min(selected - Math.floor(windowSize / 2), items.length - windowSize));
|
|
526
|
+
const visible = items.slice(start, start + windowSize);
|
|
527
|
+
return { items, selected, selectedItem: items[selected], visible, visibleStart: start };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function clampSelected(selected: number, items: ReviewItem[]): number {
|
|
531
|
+
return Math.min(Math.max(0, selected), Math.max(0, items.length - 1));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function cycleFilter(filter: NavigatorFilter): NavigatorFilter {
|
|
535
|
+
return FILTERS[(FILTERS.indexOf(filter) + 1) % FILTERS.length] ?? "all";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function cycleMode(mode: NavigatorMode): NavigatorMode {
|
|
539
|
+
if (mode === "review") return "answers";
|
|
540
|
+
if (mode === "answers") return "log";
|
|
541
|
+
return "review";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function cycleSource(current: NavigatorSource, artifacts: Artifact[]): NavigatorSource {
|
|
545
|
+
const sources = availableSources(artifacts);
|
|
546
|
+
const idx = sources.findIndex((source) => sameNavigatorSource(source, current));
|
|
547
|
+
if (idx === -1) return sources[0] ?? currentNavigatorSource();
|
|
548
|
+
return sources[(idx + 1) % sources.length] ?? sources[0]!;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function switchMode(state: NavigatorState, mode: NavigatorMode): NavigatorState {
|
|
552
|
+
return { ...state, mode, selected: 0, filter: "all" };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function withSelectedReviewAction(state: NavigatorState, items: ReviewItem[], action: ReviewActionId): NavigatorTransition {
|
|
556
|
+
const selected = clampSelected(state.selected, items);
|
|
557
|
+
const normalizedState = selected === state.selected ? state : { ...state, selected };
|
|
558
|
+
const item = items[selected];
|
|
559
|
+
if (!item || !item.actions.includes(action)) return { state: normalizedState };
|
|
560
|
+
return { state: normalizedState, action: { action: "runReviewAction", id: action, item } };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function handleNavigatorIntent(state: NavigatorState, artifacts: Artifact[], queueState: ReviewQueueState, intent: NavigatorIntent): NavigatorTransition {
|
|
564
|
+
const items = filteredReviewItems(state, artifacts, queueState);
|
|
565
|
+
const selected = clampSelected(state.selected, items);
|
|
566
|
+
const normalizedState = selected === state.selected ? state : { ...state, selected };
|
|
567
|
+
|
|
568
|
+
if (intent.kind === "close") return { state: normalizedState, action: { action: "close" } };
|
|
569
|
+
if (intent.kind === "search") return { state: normalizedState, action: { action: "search" } };
|
|
570
|
+
if (intent.kind === "createCheckpoint") return { state: normalizedState, action: { action: "createCheckpoint" } };
|
|
571
|
+
if (intent.kind === "move") return { state: { ...normalizedState, selected: clampSelected(selected + intent.by, items) } };
|
|
572
|
+
if (intent.kind === "top") return { state: { ...normalizedState, selected: 0 } };
|
|
573
|
+
if (intent.kind === "bottom") return { state: { ...normalizedState, selected: Math.max(0, items.length - 1) } };
|
|
574
|
+
if (intent.kind === "toggleDetail") return { state: { ...normalizedState, showDetail: !normalizedState.showDetail } };
|
|
575
|
+
if (intent.kind === "setMode") return { state: switchMode(normalizedState, intent.mode) };
|
|
576
|
+
if (intent.kind === "cycleMode") return { state: switchMode(normalizedState, cycleMode(normalizedState.mode)) };
|
|
577
|
+
if (intent.kind === "cycleFilter") return { state: { ...normalizedState, filter: cycleFilter(normalizedState.filter), selected: 0 } };
|
|
578
|
+
if (intent.kind === "cycleSource") return { state: { ...normalizedState, source: cycleSource(normalizedState.source, artifacts), selected: 0 } };
|
|
579
|
+
if (intent.kind === "activatePrimary") {
|
|
580
|
+
const item = items[selected];
|
|
581
|
+
return item ? { state: normalizedState, action: { action: "runReviewAction", id: item.primaryAction, item } } : { state: normalizedState };
|
|
582
|
+
}
|
|
583
|
+
if (intent.kind === "runAction") return withSelectedReviewAction(normalizedState, items, intent.action);
|
|
584
|
+
return { state: normalizedState };
|
|
585
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Docket extension
|
|
2
|
+
|
|
3
|
+
Docket exposes one slash command:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
/docket
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Primary subcommands:
|
|
10
|
+
|
|
11
|
+
- `/docket` — open decision docket.
|
|
12
|
+
- `/docket spawn [--fresh] [--as <kind>] <task>` — launch explicit worker in `docket-workers` tmux session.
|
|
13
|
+
- `/docket tell w<N> [text]` — send parent input to worker.
|
|
14
|
+
- `/docket attach [w<N>]` — copy tmux attach command.
|
|
15
|
+
- `/docket save [--once] [--summarize] [note]` — save selected evidence as bundle and label current Pi tree leaf.
|
|
16
|
+
- `/docket load [id|last|w<N>]` — mount bundle or worker artifacts at zero model-context cost.
|
|
17
|
+
|
|
18
|
+
Advanced subcommands live in `/docket help advanced`.
|
|
19
|
+
|
|
20
|
+
Removed public aliases from the old Trail era are intentional: no `/trail`, no `checkpoint`, no `continue`, no `resume`, no `ckpt`, no `r`, no `s`, no `v`, no `ask`, no `result`, no `use`, no bare `inject` alias.
|
|
21
|
+
|
|
22
|
+
## Worker topology
|
|
23
|
+
|
|
24
|
+
Every worker is one window in a single tmux session named `docket-workers`. Parent → worker stdin uses `tmux send-keys -l` so user text is literal and does not trigger tmux keybindings.
|
|
25
|
+
|
|
26
|
+
Workers emit append-only NDJSON events to `workers/<id>/events.ndjson`. The parent watches the worker root with `fs.watch`, reads status/artifact files with mtime caching, and renders the dock without polling idle workers.
|
|
27
|
+
|
|
28
|
+
## Worker protocol
|
|
29
|
+
|
|
30
|
+
Workers coordinate with the parent through tools:
|
|
31
|
+
|
|
32
|
+
- `docket_todos`
|
|
33
|
+
- `docket_wait`
|
|
34
|
+
- `docket_done`
|
|
35
|
+
- `docket_fail`
|
|
36
|
+
- `docket_spawn_child`
|
|
37
|
+
|
|
38
|
+
Worker-side `/docket wait`, `/docket done`, and `/docket fail` are fallback prompt commands only.
|
|
39
|
+
|
|
40
|
+
## Save vs load
|
|
41
|
+
|
|
42
|
+
`/docket save` writes a deterministic orientation markdown file plus `<id>.artifacts.json`. It preserves evidence; it does not move the Pi session.
|
|
43
|
+
|
|
44
|
+
`/docket load` mounts bundle or worker artifacts into the current Docket navigator. Artifacts cost zero model-context tokens until attached with `/docket ref` or `/docket inject-full`.
|
|
45
|
+
|
|
46
|
+
Use Pi's `/tree`, `/fork`, `/clone`, `/compact`, `/new`, and `/resume` for session topology.
|