@tt-a1i/openpi 0.1.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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
export const TASKS_ENTRY_TYPE = "session-tasks";
|
|
2
|
+
export const TASK_STATUSES = [
|
|
3
|
+
"pending",
|
|
4
|
+
"in_progress",
|
|
5
|
+
"blocked",
|
|
6
|
+
"done",
|
|
7
|
+
"dropped",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
export interface TaskItem {
|
|
13
|
+
id: number;
|
|
14
|
+
subject: string;
|
|
15
|
+
detail?: string;
|
|
16
|
+
status: TaskStatus;
|
|
17
|
+
note?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskSnapshot {
|
|
21
|
+
version: 1;
|
|
22
|
+
revision: number;
|
|
23
|
+
nextId: number;
|
|
24
|
+
items: TaskItem[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskAddInput {
|
|
28
|
+
subject: string;
|
|
29
|
+
detail?: string;
|
|
30
|
+
status?: TaskStatus;
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TaskUpdateInput {
|
|
35
|
+
id: number;
|
|
36
|
+
subject?: string;
|
|
37
|
+
detail?: string | null;
|
|
38
|
+
status?: TaskStatus;
|
|
39
|
+
note?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TaskFilter {
|
|
43
|
+
id?: number;
|
|
44
|
+
status?: TaskStatus;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TaskMutation {
|
|
48
|
+
snapshot: TaskSnapshot;
|
|
49
|
+
items: TaskItem[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const TASKS_LIMITS = Object.freeze({
|
|
53
|
+
subjectChars: 120,
|
|
54
|
+
detailChars: 500,
|
|
55
|
+
noteChars: 500,
|
|
56
|
+
items: 100,
|
|
57
|
+
addBatch: 20,
|
|
58
|
+
snapshotBytes: 16_384,
|
|
59
|
+
projectionChars: 800,
|
|
60
|
+
renderedListChars: 16_384,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const REQUIRED_NOTE_STATUSES = new Set<TaskStatus>([
|
|
64
|
+
"blocked",
|
|
65
|
+
"done",
|
|
66
|
+
"dropped",
|
|
67
|
+
]);
|
|
68
|
+
const SNAPSHOT_KEYS = new Set(["version", "revision", "nextId", "items"]);
|
|
69
|
+
const ITEM_KEYS = new Set(["id", "subject", "detail", "status", "note"]);
|
|
70
|
+
const ADD_KEYS = new Set(["subject", "detail", "status", "note"]);
|
|
71
|
+
const UPDATE_KEYS = new Set(["id", "subject", "detail", "status", "note"]);
|
|
72
|
+
const PROJECTION_HEADER =
|
|
73
|
+
"Session tasks: advisory context, not an instruction to resume unrelated work. " +
|
|
74
|
+
"Real files, git, tests, tools, artifacts, and user confirmation are truth. " +
|
|
75
|
+
"Use tasks_list for details. After compaction/pivot, coordinate with these tasks instead of recreating items.";
|
|
76
|
+
|
|
77
|
+
export class TaskValidationError extends Error {
|
|
78
|
+
constructor(message: string) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "TaskValidationError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class TaskRestoreError extends Error {
|
|
85
|
+
constructor(message: string) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "TaskRestoreError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SessionTasks {
|
|
92
|
+
add(input: TaskAddInput | readonly TaskAddInput[]): TaskMutation;
|
|
93
|
+
update(input: TaskUpdateInput): TaskMutation;
|
|
94
|
+
list(filter?: TaskFilter): TaskItem[];
|
|
95
|
+
snapshot(): TaskSnapshot;
|
|
96
|
+
project(): string;
|
|
97
|
+
render(filter?: TaskFilter, maxChars?: number): string;
|
|
98
|
+
commit(snapshot: TaskSnapshot): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function emptyTaskSnapshot(): TaskSnapshot {
|
|
102
|
+
return { version: 1, revision: 0, nextId: 1, items: [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function validateTaskSnapshot(value: unknown): TaskSnapshot {
|
|
106
|
+
if (!isRecord(value)) fail("snapshot must be an object");
|
|
107
|
+
assertExactKeys(value, SNAPSHOT_KEYS, "snapshot");
|
|
108
|
+
if (value.version !== 1)
|
|
109
|
+
fail(`unsupported snapshot version: ${String(value.version)}`);
|
|
110
|
+
assertNonNegativeInteger(value.revision, "snapshot.revision");
|
|
111
|
+
assertPositiveInteger(value.nextId, "snapshot.nextId");
|
|
112
|
+
if (!Array.isArray(value.items)) fail("snapshot.items must be an array");
|
|
113
|
+
if (value.items.length > TASKS_LIMITS.items) {
|
|
114
|
+
fail(`snapshot.items exceeds ${TASKS_LIMITS.items}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ids = new Set<number>();
|
|
118
|
+
const items = value.items.map((item, index) => {
|
|
119
|
+
const checked = validateItem(item, `snapshot.items[${index}]`);
|
|
120
|
+
if (ids.has(checked.id)) fail(`duplicate task id: ${checked.id}`);
|
|
121
|
+
ids.add(checked.id);
|
|
122
|
+
return checked;
|
|
123
|
+
});
|
|
124
|
+
const nextId = value.nextId as number;
|
|
125
|
+
if (items.some((item) => item.id >= nextId)) {
|
|
126
|
+
fail("snapshot.nextId must be greater than every item id");
|
|
127
|
+
}
|
|
128
|
+
const snapshot: TaskSnapshot = {
|
|
129
|
+
version: 1,
|
|
130
|
+
revision: value.revision as number,
|
|
131
|
+
nextId,
|
|
132
|
+
items,
|
|
133
|
+
};
|
|
134
|
+
assertSnapshotBytes(snapshot);
|
|
135
|
+
return snapshot;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function applyTaskAdd(
|
|
139
|
+
current: TaskSnapshot,
|
|
140
|
+
additions: readonly TaskAddInput[],
|
|
141
|
+
): TaskMutation {
|
|
142
|
+
const base = validateTaskSnapshot(current);
|
|
143
|
+
if (additions.length < 1) fail("add requires at least one item");
|
|
144
|
+
if (additions.length > TASKS_LIMITS.addBatch) {
|
|
145
|
+
fail(`add batch exceeds ${TASKS_LIMITS.addBatch}`);
|
|
146
|
+
}
|
|
147
|
+
if (base.items.length + additions.length > TASKS_LIMITS.items) {
|
|
148
|
+
fail(`task list exceeds ${TASKS_LIMITS.items} items`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let nextId = base.nextId;
|
|
152
|
+
const added = additions.map((addition, index) => {
|
|
153
|
+
if (!isRecord(addition)) fail(`additions[${index}] must be an object`);
|
|
154
|
+
assertExactKeys(addition, ADD_KEYS, `additions[${index}]`);
|
|
155
|
+
assertPositiveInteger(nextId, "allocated id");
|
|
156
|
+
const item = validateItem(
|
|
157
|
+
{
|
|
158
|
+
id: nextId,
|
|
159
|
+
subject: addition.subject,
|
|
160
|
+
...(hasOwn(addition, "detail") ? { detail: addition.detail } : {}),
|
|
161
|
+
status: addition.status ?? "pending",
|
|
162
|
+
...(hasOwn(addition, "note") ? { note: addition.note } : {}),
|
|
163
|
+
},
|
|
164
|
+
`additions[${index}]`,
|
|
165
|
+
);
|
|
166
|
+
nextId += 1;
|
|
167
|
+
return item;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const candidate = closeCompletedTaskBatch(
|
|
171
|
+
validateTaskSnapshot({
|
|
172
|
+
version: 1,
|
|
173
|
+
revision: increment(base.revision, "snapshot revision"),
|
|
174
|
+
nextId,
|
|
175
|
+
items: [...base.items, ...added],
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
return { snapshot: candidate, items: cloneItems(added) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function applyTaskUpdate(
|
|
182
|
+
current: TaskSnapshot,
|
|
183
|
+
update: TaskUpdateInput,
|
|
184
|
+
): TaskMutation {
|
|
185
|
+
const base = validateTaskSnapshot(current);
|
|
186
|
+
if (!isRecord(update)) fail("update must be an object");
|
|
187
|
+
assertExactKeys(update, UPDATE_KEYS, "update");
|
|
188
|
+
assertPositiveInteger(update.id, "update.id");
|
|
189
|
+
if (Object.keys(update).length === 1)
|
|
190
|
+
fail("update must change at least one field");
|
|
191
|
+
|
|
192
|
+
const index = base.items.findIndex((item) => item.id === update.id);
|
|
193
|
+
if (index < 0) fail(`task item T${update.id} does not exist`);
|
|
194
|
+
const previous = base.items[index];
|
|
195
|
+
const status = hasOwn(update, "status")
|
|
196
|
+
? validateStatus(update.status, "update.status")
|
|
197
|
+
: previous.status;
|
|
198
|
+
const statusChanged = status !== previous.status;
|
|
199
|
+
const entersRequiredNote =
|
|
200
|
+
statusChanged && REQUIRED_NOTE_STATUSES.has(status);
|
|
201
|
+
|
|
202
|
+
if (entersRequiredNote && !hasOwn(update, "note")) {
|
|
203
|
+
fail(`a fresh note is required when status changes to ${status}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let note = previous.note;
|
|
207
|
+
if (statusChanged && REQUIRED_NOTE_STATUSES.has(previous.status))
|
|
208
|
+
note = undefined;
|
|
209
|
+
if (hasOwn(update, "note")) note = update.note ?? undefined;
|
|
210
|
+
|
|
211
|
+
const changed = validateItem(
|
|
212
|
+
{
|
|
213
|
+
id: previous.id,
|
|
214
|
+
subject: hasOwn(update, "subject") ? update.subject : previous.subject,
|
|
215
|
+
...(hasOwn(update, "detail")
|
|
216
|
+
? update.detail === null
|
|
217
|
+
? {}
|
|
218
|
+
: { detail: update.detail }
|
|
219
|
+
: previous.detail === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { detail: previous.detail }),
|
|
222
|
+
status,
|
|
223
|
+
...(note === undefined ? {} : { note }),
|
|
224
|
+
},
|
|
225
|
+
"updated item",
|
|
226
|
+
);
|
|
227
|
+
if (
|
|
228
|
+
changed.subject === previous.subject &&
|
|
229
|
+
changed.detail === previous.detail &&
|
|
230
|
+
changed.status === previous.status &&
|
|
231
|
+
changed.note === previous.note
|
|
232
|
+
) {
|
|
233
|
+
return { snapshot: base, items: cloneItems([previous]) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const items = base.items.slice();
|
|
237
|
+
items[index] = changed;
|
|
238
|
+
const candidate = closeCompletedTaskBatch(
|
|
239
|
+
validateTaskSnapshot({
|
|
240
|
+
version: 1,
|
|
241
|
+
revision: increment(base.revision, "snapshot revision"),
|
|
242
|
+
nextId: base.nextId,
|
|
243
|
+
items,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
return { snapshot: candidate, items: cloneItems([changed]) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function restoreTaskSnapshot(entries: readonly unknown[]): TaskSnapshot {
|
|
250
|
+
const candidates = entries.flatMap((entry, position) => {
|
|
251
|
+
const payload = extractTasksPayload(entry);
|
|
252
|
+
return payload.found ? [{ payload: payload.value, position }] : [];
|
|
253
|
+
});
|
|
254
|
+
if (candidates.length === 0) return emptyTaskSnapshot();
|
|
255
|
+
|
|
256
|
+
let winner:
|
|
257
|
+
| {
|
|
258
|
+
position: number;
|
|
259
|
+
revision: number;
|
|
260
|
+
snapshot?: TaskSnapshot;
|
|
261
|
+
error?: Error;
|
|
262
|
+
}
|
|
263
|
+
| undefined;
|
|
264
|
+
const unknownVersions: number[] = [];
|
|
265
|
+
const unrankedMalformed: number[] = [];
|
|
266
|
+
const validSnapshots: Array<{
|
|
267
|
+
position: number;
|
|
268
|
+
revision: number;
|
|
269
|
+
snapshot: TaskSnapshot;
|
|
270
|
+
}> = [];
|
|
271
|
+
|
|
272
|
+
for (const candidate of candidates) {
|
|
273
|
+
const revision = declaredRevision(candidate.payload);
|
|
274
|
+
const version = declaredVersion(candidate.payload);
|
|
275
|
+
const unknownVersion = version !== undefined && version !== 1;
|
|
276
|
+
if (unknownVersion) unknownVersions.push(candidate.position);
|
|
277
|
+
|
|
278
|
+
let snapshot: TaskSnapshot | undefined;
|
|
279
|
+
let error: Error | undefined;
|
|
280
|
+
if (unknownVersion) {
|
|
281
|
+
error = new TaskRestoreError(
|
|
282
|
+
`unsupported snapshot version: ${String(version)}`,
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
try {
|
|
286
|
+
snapshot = validateTaskSnapshot(candidate.payload);
|
|
287
|
+
if (revision !== undefined) {
|
|
288
|
+
validSnapshots.push({
|
|
289
|
+
position: candidate.position,
|
|
290
|
+
revision,
|
|
291
|
+
snapshot,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
} catch (caught) {
|
|
295
|
+
error = caught instanceof Error ? caught : new Error(String(caught));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (revision === undefined) {
|
|
299
|
+
unrankedMalformed.push(candidate.position);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
!winner ||
|
|
304
|
+
revision > winner.revision ||
|
|
305
|
+
(revision === winner.revision && candidate.position > winner.position)
|
|
306
|
+
) {
|
|
307
|
+
winner = { position: candidate.position, revision, snapshot, error };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!winner) {
|
|
312
|
+
throw new TaskRestoreError("task history contains no restorable snapshot");
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
unknownVersions.some((position) => position > winner.position) ||
|
|
316
|
+
unrankedMalformed.some((position) => position > winner.position)
|
|
317
|
+
) {
|
|
318
|
+
throw new TaskRestoreError(
|
|
319
|
+
"a later unknown or malformed task entry locks restoration",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (!winner.snapshot) {
|
|
323
|
+
throw new TaskRestoreError(
|
|
324
|
+
`winning task revision ${winner.revision} is malformed: ${winner.error?.message ?? "invalid snapshot"}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const current = closeCompletedTaskBatch(winner.snapshot);
|
|
329
|
+
if (isTaskBatchReset(current)) return current;
|
|
330
|
+
|
|
331
|
+
const latestClosedBatchRevision = validSnapshots.reduce(
|
|
332
|
+
(latest, candidate) =>
|
|
333
|
+
candidate.revision < winner.revision &&
|
|
334
|
+
(isTaskBatchComplete(candidate.snapshot) ||
|
|
335
|
+
isTaskBatchReset(candidate.snapshot))
|
|
336
|
+
? Math.max(latest, candidate.revision)
|
|
337
|
+
: latest,
|
|
338
|
+
-1,
|
|
339
|
+
);
|
|
340
|
+
const nextIdHighWater = validSnapshots
|
|
341
|
+
.filter((candidate) => candidate.revision > latestClosedBatchRevision)
|
|
342
|
+
.reduce(
|
|
343
|
+
(highWater, candidate) => Math.max(highWater, candidate.snapshot.nextId),
|
|
344
|
+
1,
|
|
345
|
+
);
|
|
346
|
+
const maxId = current.items.reduce(
|
|
347
|
+
(maximum, item) => Math.max(maximum, item.id),
|
|
348
|
+
0,
|
|
349
|
+
);
|
|
350
|
+
const nextId = Math.max(
|
|
351
|
+
nextIdHighWater,
|
|
352
|
+
current.nextId,
|
|
353
|
+
increment(maxId, "item id"),
|
|
354
|
+
);
|
|
355
|
+
return validateTaskSnapshot({ ...current, nextId });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function projectTasks(snapshot: TaskSnapshot): string {
|
|
359
|
+
const checked = validateTaskSnapshot(snapshot);
|
|
360
|
+
const rank: Record<TaskStatus, number> = {
|
|
361
|
+
in_progress: 0,
|
|
362
|
+
blocked: 1,
|
|
363
|
+
pending: 2,
|
|
364
|
+
done: 3,
|
|
365
|
+
dropped: 4,
|
|
366
|
+
};
|
|
367
|
+
const actionable = checked.items
|
|
368
|
+
.filter((item) => rank[item.status] < 3)
|
|
369
|
+
.sort((left, right) => rank[left.status] - rank[right.status]);
|
|
370
|
+
if (actionable.length === 0) return "";
|
|
371
|
+
|
|
372
|
+
let output = PROJECTION_HEADER;
|
|
373
|
+
for (const item of actionable) {
|
|
374
|
+
const line = `\nT${item.id} [${item.status}] ${singleLine(item.subject)}`;
|
|
375
|
+
if (charCount(output + line) > TASKS_LIMITS.projectionChars) break;
|
|
376
|
+
output += line;
|
|
377
|
+
}
|
|
378
|
+
return takeChars(output, TASKS_LIMITS.projectionChars);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function listTaskItems(
|
|
382
|
+
snapshot: TaskSnapshot,
|
|
383
|
+
filter: TaskFilter = {},
|
|
384
|
+
): TaskItem[] {
|
|
385
|
+
const checked = validateTaskSnapshot(snapshot);
|
|
386
|
+
validateFilter(filter);
|
|
387
|
+
return cloneItems(
|
|
388
|
+
checked.items.filter(
|
|
389
|
+
(item) =>
|
|
390
|
+
(filter.id === undefined || item.id === filter.id) &&
|
|
391
|
+
(filter.status === undefined || item.status === filter.status),
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function renderTaskList(
|
|
397
|
+
snapshot: TaskSnapshot,
|
|
398
|
+
filter: TaskFilter = {},
|
|
399
|
+
maxChars: number = TASKS_LIMITS.renderedListChars,
|
|
400
|
+
): string {
|
|
401
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 1) {
|
|
402
|
+
fail("maxChars must be a positive safe integer");
|
|
403
|
+
}
|
|
404
|
+
const bound = Math.min(maxChars, TASKS_LIMITS.renderedListChars);
|
|
405
|
+
const items = listTaskItems(snapshot, filter);
|
|
406
|
+
const text =
|
|
407
|
+
items.length === 0
|
|
408
|
+
? "No task items."
|
|
409
|
+
: items
|
|
410
|
+
.map((item) => {
|
|
411
|
+
const lines = [
|
|
412
|
+
`T${item.id} [${item.status}] ${singleLine(item.subject)}`,
|
|
413
|
+
];
|
|
414
|
+
if (item.detail !== undefined)
|
|
415
|
+
lines.push(` detail: ${singleLine(item.detail)}`);
|
|
416
|
+
if (item.note !== undefined)
|
|
417
|
+
lines.push(` note: ${singleLine(item.note)}`);
|
|
418
|
+
return lines.join("\n");
|
|
419
|
+
})
|
|
420
|
+
.join("\n");
|
|
421
|
+
return takeChars(text, bound);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function createSessionTasks(
|
|
425
|
+
initial: TaskSnapshot = emptyTaskSnapshot(),
|
|
426
|
+
): SessionTasks {
|
|
427
|
+
let current = cloneSnapshot(validateTaskSnapshot(initial));
|
|
428
|
+
return {
|
|
429
|
+
add(input) {
|
|
430
|
+
const additions = Array.isArray(input) ? input : [input];
|
|
431
|
+
return cloneMutation(applyTaskAdd(current, additions));
|
|
432
|
+
},
|
|
433
|
+
update(input) {
|
|
434
|
+
return cloneMutation(applyTaskUpdate(current, input));
|
|
435
|
+
},
|
|
436
|
+
list(filter) {
|
|
437
|
+
return listTaskItems(current, filter);
|
|
438
|
+
},
|
|
439
|
+
snapshot() {
|
|
440
|
+
return cloneSnapshot(current);
|
|
441
|
+
},
|
|
442
|
+
project() {
|
|
443
|
+
return projectTasks(current);
|
|
444
|
+
},
|
|
445
|
+
render(filter, maxChars) {
|
|
446
|
+
return renderTaskList(current, filter, maxChars);
|
|
447
|
+
},
|
|
448
|
+
commit(snapshot) {
|
|
449
|
+
current = cloneSnapshot(validateTaskSnapshot(snapshot));
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function isTaskBatchComplete(snapshot: TaskSnapshot) {
|
|
455
|
+
const checked = validateTaskSnapshot(snapshot);
|
|
456
|
+
return (
|
|
457
|
+
checked.items.length > 0 &&
|
|
458
|
+
checked.items.every(
|
|
459
|
+
(item) => item.status === "done" || item.status === "dropped",
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function closeCompletedTaskBatch(snapshot: TaskSnapshot) {
|
|
465
|
+
const checked = validateTaskSnapshot(snapshot);
|
|
466
|
+
if (!isTaskBatchComplete(checked)) return checked;
|
|
467
|
+
return validateTaskSnapshot({ ...checked, nextId: 1, items: [] });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isTaskBatchReset(snapshot: TaskSnapshot) {
|
|
471
|
+
return snapshot.items.length === 0 && snapshot.nextId === 1;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function validateItem(value: unknown, path: string): TaskItem {
|
|
475
|
+
if (!isRecord(value)) fail(`${path} must be an object`);
|
|
476
|
+
assertExactKeys(value, ITEM_KEYS, path);
|
|
477
|
+
assertPositiveInteger(value.id, `${path}.id`);
|
|
478
|
+
const subject = normalizeText(
|
|
479
|
+
value.subject,
|
|
480
|
+
`${path}.subject`,
|
|
481
|
+
TASKS_LIMITS.subjectChars,
|
|
482
|
+
true,
|
|
483
|
+
);
|
|
484
|
+
const status = validateStatus(value.status, `${path}.status`);
|
|
485
|
+
const detail = hasOwn(value, "detail")
|
|
486
|
+
? normalizeText(
|
|
487
|
+
value.detail,
|
|
488
|
+
`${path}.detail`,
|
|
489
|
+
TASKS_LIMITS.detailChars,
|
|
490
|
+
false,
|
|
491
|
+
)
|
|
492
|
+
: undefined;
|
|
493
|
+
const note = hasOwn(value, "note")
|
|
494
|
+
? normalizeText(value.note, `${path}.note`, TASKS_LIMITS.noteChars, true)
|
|
495
|
+
: undefined;
|
|
496
|
+
if (REQUIRED_NOTE_STATUSES.has(status) && note === undefined) {
|
|
497
|
+
fail(`${path}.note is required for ${status}`);
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
id: value.id as number,
|
|
501
|
+
subject,
|
|
502
|
+
...(detail !== undefined ? { detail } : {}),
|
|
503
|
+
status,
|
|
504
|
+
...(note !== undefined ? { note } : {}),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function validateStatus(value: unknown, path: string): TaskStatus {
|
|
509
|
+
if (!TASK_STATUSES.includes(value as TaskStatus)) {
|
|
510
|
+
fail(`${path} must be a valid task status`);
|
|
511
|
+
}
|
|
512
|
+
return value as TaskStatus;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function validateFilter(filter: TaskFilter) {
|
|
516
|
+
if (!isRecord(filter)) fail("filter must be an object");
|
|
517
|
+
assertExactKeys(filter, new Set(["id", "status"]), "filter");
|
|
518
|
+
if (filter.id !== undefined) assertPositiveInteger(filter.id, "filter.id");
|
|
519
|
+
if (filter.status !== undefined)
|
|
520
|
+
validateStatus(filter.status, "filter.status");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function extractTasksPayload(entry: unknown): {
|
|
524
|
+
found: boolean;
|
|
525
|
+
value?: unknown;
|
|
526
|
+
} {
|
|
527
|
+
if (
|
|
528
|
+
!isRecord(entry) ||
|
|
529
|
+
entry.type !== "custom" ||
|
|
530
|
+
entry.customType !== TASKS_ENTRY_TYPE
|
|
531
|
+
) {
|
|
532
|
+
return { found: false };
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
found: true,
|
|
536
|
+
value: hasOwn(entry, "data") ? entry.data : undefined,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function declaredRevision(value: unknown) {
|
|
541
|
+
if (!isRecord(value)) return undefined;
|
|
542
|
+
return Number.isSafeInteger(value.revision) && (value.revision as number) >= 0
|
|
543
|
+
? (value.revision as number)
|
|
544
|
+
: undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function declaredVersion(value: unknown) {
|
|
548
|
+
if (!isRecord(value) || !hasOwn(value, "version")) return undefined;
|
|
549
|
+
return value.version;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function assertSnapshotBytes(snapshot: TaskSnapshot) {
|
|
553
|
+
const bytes = new TextEncoder().encode(JSON.stringify(snapshot)).byteLength;
|
|
554
|
+
if (bytes > TASKS_LIMITS.snapshotBytes) {
|
|
555
|
+
fail(
|
|
556
|
+
`serialized snapshot exceeds ${TASKS_LIMITS.snapshotBytes} UTF-8 bytes`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function normalizeText(
|
|
562
|
+
value: unknown,
|
|
563
|
+
path: string,
|
|
564
|
+
maxChars: number,
|
|
565
|
+
requireNonBlank: boolean,
|
|
566
|
+
) {
|
|
567
|
+
if (typeof value !== "string") fail(`${path} must be a string`);
|
|
568
|
+
const normalized = singleLine(value);
|
|
569
|
+
if (/\p{Cc}/u.test(normalized))
|
|
570
|
+
fail(`${path} must not contain control characters`);
|
|
571
|
+
if (charCount(normalized) > maxChars)
|
|
572
|
+
fail(`${path} exceeds ${maxChars} characters`);
|
|
573
|
+
if (requireNonBlank && normalized.trim().length === 0)
|
|
574
|
+
fail(`${path} must not be blank`);
|
|
575
|
+
return normalized;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function assertPositiveInteger(value: unknown, path: string) {
|
|
579
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
580
|
+
fail(`${path} must be a positive safe integer`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function assertNonNegativeInteger(value: unknown, path: string) {
|
|
585
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
586
|
+
fail(`${path} must be a non-negative safe integer`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function assertExactKeys(
|
|
591
|
+
value: Record<string, unknown>,
|
|
592
|
+
allowed: ReadonlySet<string>,
|
|
593
|
+
path: string,
|
|
594
|
+
) {
|
|
595
|
+
for (const key of Object.keys(value)) {
|
|
596
|
+
if (!allowed.has(key)) fail(`${path} contains unknown field: ${key}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function increment(value: number, path: string) {
|
|
601
|
+
const next = value + 1;
|
|
602
|
+
if (!Number.isSafeInteger(next)) fail(`${path} exhausted safe integers`);
|
|
603
|
+
return next;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
607
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
608
|
+
return false;
|
|
609
|
+
const prototype = Object.getPrototypeOf(value);
|
|
610
|
+
return prototype === Object.prototype || prototype === null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function hasOwn(value: object, key: string) {
|
|
614
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function charCount(value: string) {
|
|
618
|
+
return Array.from(value).length;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function takeChars(value: string, maximum: number) {
|
|
622
|
+
const characters = Array.from(value);
|
|
623
|
+
if (characters.length <= maximum) return value;
|
|
624
|
+
if (maximum === 1) return "…";
|
|
625
|
+
return characters.slice(0, maximum - 1).join("") + "…";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function singleLine(value: string) {
|
|
629
|
+
return value.replace(/\s+/gu, " ").trim();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function cloneItems(items: readonly TaskItem[]) {
|
|
633
|
+
return items.map((item) => ({ ...item }));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function cloneSnapshot(snapshot: TaskSnapshot): TaskSnapshot {
|
|
637
|
+
return { ...snapshot, items: cloneItems(snapshot.items) };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function cloneMutation(mutation: TaskMutation): TaskMutation {
|
|
641
|
+
return {
|
|
642
|
+
snapshot: cloneSnapshot(mutation.snapshot),
|
|
643
|
+
items: cloneItems(mutation.items),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function fail(message: string): never {
|
|
648
|
+
throw new TaskValidationError(message);
|
|
649
|
+
}
|