@harms-haus/pi-tasks 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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +402 -0
- package/package.json +62 -0
- package/src/config.ts +53 -0
- package/src/engine.ts +474 -0
- package/src/events.ts +223 -0
- package/src/formatting.ts +201 -0
- package/src/index.ts +23 -0
- package/src/renderers.ts +28 -0
- package/src/schemas.ts +65 -0
- package/src/state.ts +124 -0
- package/src/tools.ts +611 -0
- package/src/types.ts +137 -0
- package/src/validation.ts +185 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import type { TaskBoardSnapshot, TaskRecord, TaskEdit } from "./types";
|
|
2
|
+
import { MAX_TASKS, ACTIVE_STATUSES, TERMINAL_STATUSES } from "./types";
|
|
3
|
+
import {
|
|
4
|
+
isNonEmptyString,
|
|
5
|
+
isValidPhase,
|
|
6
|
+
hasSelfDependency,
|
|
7
|
+
hasDuplicateDependencies,
|
|
8
|
+
findMissingDependencies,
|
|
9
|
+
detectCycle,
|
|
10
|
+
cloneBoard,
|
|
11
|
+
} from "./validation";
|
|
12
|
+
|
|
13
|
+
// ── Internal Helpers ──
|
|
14
|
+
|
|
15
|
+
function guardNoActiveTasks(board: TaskBoardSnapshot, message: string): void {
|
|
16
|
+
if (board.tasks.some((t) => ACTIVE_STATUSES.has(t.status))) {
|
|
17
|
+
throw new Error(message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Board Creation ──
|
|
22
|
+
|
|
23
|
+
export function createEmptyBoard(): TaskBoardSnapshot {
|
|
24
|
+
return { version: 1, tasks: [], phases: [], pendingPhasePrompt: undefined };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Write Tasks ──
|
|
28
|
+
|
|
29
|
+
function validateTaskInput(
|
|
30
|
+
input: { title: string; prompt: string; profile: string },
|
|
31
|
+
index: number,
|
|
32
|
+
phaseNum: number,
|
|
33
|
+
): void {
|
|
34
|
+
const trimmedTitle = input.title.trim();
|
|
35
|
+
const trimmedPrompt = input.prompt.trim();
|
|
36
|
+
const trimmedProfile = input.profile.trim();
|
|
37
|
+
|
|
38
|
+
if (!isNonEmptyString(trimmedTitle)) {
|
|
39
|
+
throw new Error(`Phase ${phaseNum} task ${index + 1}: title must be a non-empty string`);
|
|
40
|
+
}
|
|
41
|
+
if (!isNonEmptyString(trimmedPrompt)) {
|
|
42
|
+
throw new Error(`Phase ${phaseNum} task ${index + 1}: prompt must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
if (!isNonEmptyString(trimmedProfile)) {
|
|
45
|
+
throw new Error(`Phase ${phaseNum} task ${index + 1}: profile must be a non-empty string`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeTasks(
|
|
50
|
+
board: TaskBoardSnapshot,
|
|
51
|
+
input: {
|
|
52
|
+
mode: "replace" | "append";
|
|
53
|
+
phases: Array<{
|
|
54
|
+
title: string;
|
|
55
|
+
tasks: Array<{ title: string; prompt: string; profile: string }>;
|
|
56
|
+
}>;
|
|
57
|
+
},
|
|
58
|
+
now: string,
|
|
59
|
+
): TaskBoardSnapshot {
|
|
60
|
+
let result: TaskBoardSnapshot;
|
|
61
|
+
let startPhase: number;
|
|
62
|
+
|
|
63
|
+
if (input.mode === "replace") {
|
|
64
|
+
guardNoActiveTasks(board, "Cannot replace board while tasks are implementing or reviewing.");
|
|
65
|
+
result = createEmptyBoard();
|
|
66
|
+
startPhase = 1;
|
|
67
|
+
} else {
|
|
68
|
+
result = cloneBoard(board);
|
|
69
|
+
startPhase = result.tasks.length > 0 ? Math.max(...result.tasks.map((t) => t.phase)) + 1 : 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const totalNewTasks = input.phases.reduce((sum, p) => sum + p.tasks.length, 0);
|
|
73
|
+
if (result.tasks.length + totalNewTasks > MAX_TASKS) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Cannot add ${totalNewTasks} tasks: would exceed maximum of ${MAX_TASKS} (currently ${result.tasks.length})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [i, inputPhase] of input.phases.entries()) {
|
|
80
|
+
const phaseNum = startPhase + i;
|
|
81
|
+
|
|
82
|
+
if (!isNonEmptyString(inputPhase.title.trim())) {
|
|
83
|
+
throw new Error(`Phase ${i + 1}: title must be a non-empty string`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [j, taskInput] of inputPhase.tasks.entries()) {
|
|
87
|
+
validateTaskInput(taskInput, j, phaseNum);
|
|
88
|
+
|
|
89
|
+
const phaseCount = result.tasks.filter((t) => t.phase === phaseNum).length;
|
|
90
|
+
const id = `t-${phaseNum}.${phaseCount + 1}`;
|
|
91
|
+
result.tasks.push({
|
|
92
|
+
id,
|
|
93
|
+
title: taskInput.title.trim(),
|
|
94
|
+
prompt: taskInput.prompt.trim(),
|
|
95
|
+
profile: taskInput.profile.trim(),
|
|
96
|
+
phase: phaseNum,
|
|
97
|
+
dependencies: [],
|
|
98
|
+
status: "draft",
|
|
99
|
+
createdAt: now,
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
result.phases.push({
|
|
105
|
+
phase: phaseNum,
|
|
106
|
+
status: "pending" as const,
|
|
107
|
+
title: inputPhase.title.trim(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Recompute Phases and Readiness (internal) ──
|
|
115
|
+
|
|
116
|
+
function recomputePhasesAndReadiness(
|
|
117
|
+
board: TaskBoardSnapshot,
|
|
118
|
+
oldBoard: TaskBoardSnapshot,
|
|
119
|
+
now: string,
|
|
120
|
+
): TaskBoardSnapshot {
|
|
121
|
+
const allPhaseNumbers = new Set(board.tasks.map((t) => t.phase));
|
|
122
|
+
const nonTerminalTasks = board.tasks.filter((t) => !TERMINAL_STATUSES.has(t.status));
|
|
123
|
+
|
|
124
|
+
if (nonTerminalTasks.length === 0) {
|
|
125
|
+
const phases = [...allPhaseNumbers]
|
|
126
|
+
.sort((a, b) => a - b)
|
|
127
|
+
.map((phase) => {
|
|
128
|
+
const oldPhase = oldBoard.phases.find((p) => p.phase === phase);
|
|
129
|
+
return {
|
|
130
|
+
phase,
|
|
131
|
+
status: "completed" as const,
|
|
132
|
+
completedAt: oldPhase?.completedAt ?? now,
|
|
133
|
+
title: oldPhase?.title,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
board.phases = phases;
|
|
137
|
+
return board;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const nonTerminalPhases = new Set(nonTerminalTasks.map((t) => t.phase));
|
|
141
|
+
const sortedNonTerminalPhases = [...nonTerminalPhases].sort((a, b) => a - b);
|
|
142
|
+
const activePhase = sortedNonTerminalPhases[0];
|
|
143
|
+
if (activePhase === undefined) return board;
|
|
144
|
+
|
|
145
|
+
const oldPhaseMap = new Map(oldBoard.phases.map((p) => [p.phase, p]));
|
|
146
|
+
board.phases = buildPhasesArray(allPhaseNumbers, activePhase, oldPhaseMap, now);
|
|
147
|
+
|
|
148
|
+
markReadyTasksInActivePhase(board, activePhase);
|
|
149
|
+
|
|
150
|
+
const allActivePhaseTerminal = board.tasks
|
|
151
|
+
.filter((t) => t.phase === activePhase)
|
|
152
|
+
.every((t) => TERMINAL_STATUSES.has(t.status));
|
|
153
|
+
|
|
154
|
+
if (allActivePhaseTerminal && sortedNonTerminalPhases.length > 1) {
|
|
155
|
+
const phaseRecord = board.phases.find((p) => p.phase === activePhase);
|
|
156
|
+
if (phaseRecord) {
|
|
157
|
+
phaseRecord.status = "completed";
|
|
158
|
+
phaseRecord.completedAt = now;
|
|
159
|
+
}
|
|
160
|
+
return recomputePhasesAndReadiness(board, oldBoard, now);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return board;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildPhasesArray(
|
|
167
|
+
allPhaseNumbers: Set<number>,
|
|
168
|
+
activePhase: number,
|
|
169
|
+
oldPhaseMap: Map<number, { completedAt?: string; title?: string }>,
|
|
170
|
+
now: string,
|
|
171
|
+
): TaskBoardSnapshot["phases"] {
|
|
172
|
+
return [...allPhaseNumbers]
|
|
173
|
+
.sort((a, b) => a - b)
|
|
174
|
+
.map((phase) => {
|
|
175
|
+
const oldPhase = oldPhaseMap.get(phase);
|
|
176
|
+
if (phase < activePhase) {
|
|
177
|
+
return {
|
|
178
|
+
phase,
|
|
179
|
+
status: "completed" as const,
|
|
180
|
+
completedAt: oldPhase?.completedAt ?? now,
|
|
181
|
+
title: oldPhase?.title,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (phase === activePhase) {
|
|
185
|
+
return { phase, status: "active" as const, title: oldPhase?.title };
|
|
186
|
+
}
|
|
187
|
+
return { phase, status: "pending" as const, title: oldPhase?.title };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function markReadyTasksInActivePhase(board: TaskBoardSnapshot, activePhase: number): void {
|
|
192
|
+
const taskMap = new Map(board.tasks.map((t) => [t.id, t]));
|
|
193
|
+
for (const task of board.tasks) {
|
|
194
|
+
if (task.phase === activePhase && task.status === "configured") {
|
|
195
|
+
const allDepsDone = task.dependencies.every((depId) => {
|
|
196
|
+
const dep = taskMap.get(depId);
|
|
197
|
+
return dep !== undefined && dep.status === "done";
|
|
198
|
+
});
|
|
199
|
+
if (allDepsDone) {
|
|
200
|
+
task.status = "ready";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Apply Edits ──
|
|
207
|
+
|
|
208
|
+
function validateDataEdit(edit: TaskEdit & { type: "data" }, hasActiveTasksOnBoard: boolean): void {
|
|
209
|
+
if (hasActiveTasksOnBoard) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"Cannot edit data while tasks are implementing/reviewing. Complete or advance active tasks first.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (edit.data.title !== undefined && !isNonEmptyString(edit.data.title)) {
|
|
215
|
+
throw new Error(`Task "${edit.id}": title must be a non-empty string`);
|
|
216
|
+
}
|
|
217
|
+
if (edit.data.prompt !== undefined && !isNonEmptyString(edit.data.prompt)) {
|
|
218
|
+
throw new Error(`Task "${edit.id}": prompt must be a non-empty string`);
|
|
219
|
+
}
|
|
220
|
+
if (edit.data.profile !== undefined && !isNonEmptyString(edit.data.profile)) {
|
|
221
|
+
throw new Error(`Task "${edit.id}": profile must be a non-empty string`);
|
|
222
|
+
}
|
|
223
|
+
if (edit.data.phase !== undefined && !isValidPhase(edit.data.phase)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Task "${edit.id}": phase must be an integer >= 1, got ${String(edit.data.phase)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateEdit(
|
|
231
|
+
edit: TaskEdit,
|
|
232
|
+
taskMap: Map<string, TaskRecord>,
|
|
233
|
+
existingIds: Set<string>,
|
|
234
|
+
hasActiveTasksOnBoard: boolean,
|
|
235
|
+
): void {
|
|
236
|
+
const task = taskMap.get(edit.id);
|
|
237
|
+
if (!task) {
|
|
238
|
+
throw new Error(`Task "${edit.id}" not found`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
switch (edit.type) {
|
|
242
|
+
case "data": {
|
|
243
|
+
validateDataEdit(edit, hasActiveTasksOnBoard);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case "blockers": {
|
|
247
|
+
if (hasActiveTasksOnBoard) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
"Cannot edit blockers while tasks are implementing/reviewing. Complete or advance active tasks first.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (hasSelfDependency(edit.id, edit.data.dependencies)) {
|
|
253
|
+
throw new Error(`Task "${edit.id}" cannot depend on itself`);
|
|
254
|
+
}
|
|
255
|
+
if (hasDuplicateDependencies(edit.data.dependencies)) {
|
|
256
|
+
throw new Error(`Task "${edit.id}" has duplicate dependencies`);
|
|
257
|
+
}
|
|
258
|
+
const missing = findMissingDependencies(edit.data.dependencies, existingIds);
|
|
259
|
+
if (missing.length > 0) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Task "${edit.id}" references non-existent dependencies: ${missing.join(", ")}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "advance": {
|
|
267
|
+
if (task.status !== "implementing" && task.status !== "reviewing") {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Cannot advance task "${edit.id}" from "${task.status}". Can only advance from "implementing" or "reviewing".`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "abandon": {
|
|
275
|
+
if (task.status === "done" || task.status === "abandoned") {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Cannot abandon task "${edit.id}" in "${task.status}" status. Already resolved.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function applyStructuralEdits(
|
|
286
|
+
edits: TaskEdit[],
|
|
287
|
+
taskMap: Map<string, TaskRecord>,
|
|
288
|
+
now: string,
|
|
289
|
+
): void {
|
|
290
|
+
for (const edit of edits) {
|
|
291
|
+
const task = taskMap.get(edit.id);
|
|
292
|
+
if (!task) continue;
|
|
293
|
+
|
|
294
|
+
if (edit.type === "data") {
|
|
295
|
+
if (edit.data.title !== undefined) task.title = edit.data.title;
|
|
296
|
+
if (edit.data.prompt !== undefined) task.prompt = edit.data.prompt;
|
|
297
|
+
if (edit.data.profile !== undefined) task.profile = edit.data.profile;
|
|
298
|
+
if (edit.data.phase !== undefined) task.phase = edit.data.phase;
|
|
299
|
+
task.updatedAt = now;
|
|
300
|
+
}
|
|
301
|
+
// edit.type === "blockers" is the only other structural type
|
|
302
|
+
// TypeScript doesn't narrow after the if above, so we check explicitly
|
|
303
|
+
if (edit.type === "blockers") {
|
|
304
|
+
task.dependencies = [...edit.data.dependencies];
|
|
305
|
+
task.updatedAt = now;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resetNonTerminalToDraft(tasks: TaskRecord[], now: string): void {
|
|
311
|
+
for (const task of tasks) {
|
|
312
|
+
if (!TERMINAL_STATUSES.has(task.status) && !ACTIVE_STATUSES.has(task.status)) {
|
|
313
|
+
task.status = "draft";
|
|
314
|
+
task.updatedAt = now;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function applyStateEdits(
|
|
320
|
+
edits: TaskEdit[],
|
|
321
|
+
taskMap: Map<string, TaskRecord>,
|
|
322
|
+
board: TaskBoardSnapshot,
|
|
323
|
+
oldBoard: TaskBoardSnapshot,
|
|
324
|
+
now: string,
|
|
325
|
+
): void {
|
|
326
|
+
let needsRecompute = false;
|
|
327
|
+
for (const edit of edits) {
|
|
328
|
+
const task = taskMap.get(edit.id);
|
|
329
|
+
if (!task) continue;
|
|
330
|
+
|
|
331
|
+
if (edit.type === "advance") {
|
|
332
|
+
if (task.status === "implementing") {
|
|
333
|
+
task.status = "reviewing";
|
|
334
|
+
task.updatedAt = now;
|
|
335
|
+
} else {
|
|
336
|
+
// Must be "reviewing" (validated above)
|
|
337
|
+
task.status = "done";
|
|
338
|
+
task.updatedAt = now;
|
|
339
|
+
needsRecompute = true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// edit.type === "abandon" is the only other state type
|
|
343
|
+
if (edit.type === "abandon") {
|
|
344
|
+
task.status = "abandoned";
|
|
345
|
+
task.updatedAt = now;
|
|
346
|
+
needsRecompute = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (needsRecompute) {
|
|
350
|
+
recomputePhasesAndReadiness(board, oldBoard, now);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function applyEdits(
|
|
355
|
+
board: TaskBoardSnapshot,
|
|
356
|
+
edits: TaskEdit[],
|
|
357
|
+
now: string,
|
|
358
|
+
): TaskBoardSnapshot {
|
|
359
|
+
if (edits.length === 0) return cloneBoard(board);
|
|
360
|
+
|
|
361
|
+
const result = cloneBoard(board);
|
|
362
|
+
const taskMap = new Map(result.tasks.map((t) => [t.id, t]));
|
|
363
|
+
const existingIds = new Set(result.tasks.map((t) => t.id));
|
|
364
|
+
const boardHasActiveTasks = result.tasks.some((t) => ACTIVE_STATUSES.has(t.status));
|
|
365
|
+
|
|
366
|
+
// First pass: validate all edits
|
|
367
|
+
for (const edit of edits) {
|
|
368
|
+
validateEdit(edit, taskMap, existingIds, boardHasActiveTasks);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Second pass: apply all edits
|
|
372
|
+
const structuralEdits = edits.filter((e) => e.type === "data" || e.type === "blockers");
|
|
373
|
+
const stateEdits = edits.filter((e) => e.type === "advance" || e.type === "abandon");
|
|
374
|
+
|
|
375
|
+
// Apply structural edits, then reset non-terminal tasks
|
|
376
|
+
if (structuralEdits.length > 0) {
|
|
377
|
+
applyStructuralEdits(structuralEdits, taskMap, now);
|
|
378
|
+
resetNonTerminalToDraft(result.tasks, now);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Apply state edits (advance / abandon) with recompute
|
|
382
|
+
if (stateEdits.length > 0) {
|
|
383
|
+
applyStateEdits(stateEdits, taskMap, result, board, now);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Compile Board ──
|
|
390
|
+
|
|
391
|
+
export function compileBoard(board: TaskBoardSnapshot, now: string): TaskBoardSnapshot {
|
|
392
|
+
if (board.tasks.length === 0) {
|
|
393
|
+
throw new Error("Cannot compile an empty board");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
guardNoActiveTasks(
|
|
397
|
+
board,
|
|
398
|
+
"Cannot compile board while tasks are implementing or reviewing. Complete or advance active tasks first.",
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const idSet = new Set(board.tasks.map((t) => t.id));
|
|
402
|
+
if (idSet.size !== board.tasks.length) {
|
|
403
|
+
throw new Error("Duplicate task ids found on the board");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const task of board.tasks) {
|
|
407
|
+
const missing = findMissingDependencies(task.dependencies, idSet);
|
|
408
|
+
if (missing.length > 0) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Task "${task.id}" references non-existent dependencies: ${missing.join(", ")}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const cycle = detectCycle(board.tasks);
|
|
416
|
+
if (cycle.length > 0) {
|
|
417
|
+
throw new Error(`Dependency cycle detected: ${cycle.join(" → ")}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const result = cloneBoard(board);
|
|
421
|
+
|
|
422
|
+
for (const task of result.tasks) {
|
|
423
|
+
if (task.status === "draft") {
|
|
424
|
+
task.status = "configured";
|
|
425
|
+
task.updatedAt = now;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
recomputePhasesAndReadiness(result, board, now);
|
|
430
|
+
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Claim Ready Tasks ──
|
|
435
|
+
|
|
436
|
+
export function claimReadyTasks(
|
|
437
|
+
board: TaskBoardSnapshot,
|
|
438
|
+
count: number,
|
|
439
|
+
now: string,
|
|
440
|
+
): { board: TaskBoardSnapshot; claimed: TaskRecord[] } {
|
|
441
|
+
if (count < 1) {
|
|
442
|
+
throw new Error("count must be >= 1");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
guardNoActiveTasks(
|
|
446
|
+
board,
|
|
447
|
+
"Cannot claim tasks while tasks are implementing or reviewing. Complete or advance active tasks first.",
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const result = cloneBoard(board);
|
|
451
|
+
|
|
452
|
+
const readyTasks = result.tasks
|
|
453
|
+
.map((t, index) => ({ task: t, index }))
|
|
454
|
+
.filter(({ task }) => task.status === "ready")
|
|
455
|
+
.sort((a, b) => {
|
|
456
|
+
if (a.task.phase !== b.task.phase) return a.task.phase - b.task.phase;
|
|
457
|
+
return a.index - b.index;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const toClaim = readyTasks.slice(0, count);
|
|
461
|
+
const claimed: TaskRecord[] = [];
|
|
462
|
+
|
|
463
|
+
for (const { task } of toClaim) {
|
|
464
|
+
task.status = "implementing";
|
|
465
|
+
task.updatedAt = now;
|
|
466
|
+
claimed.push({ ...task });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { board: result, claimed };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Query Functions ──
|
|
473
|
+
|
|
474
|
+
export { hasActionableTasks, hasBlockedNonTerminalTasks, getStatusCounts } from "./validation";
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CUSTOM_SNAPSHOT_TYPE, MAX_AUTO_CONTINUE, TERMINAL_STATUSES } from "./types";
|
|
3
|
+
import { cloneBoard, hasActionableTasks, hasBlockedNonTerminalTasks } from "./validation";
|
|
4
|
+
import {
|
|
5
|
+
getBoardRef,
|
|
6
|
+
setBoard,
|
|
7
|
+
setBoardQuiet,
|
|
8
|
+
reconstructState,
|
|
9
|
+
updateUI,
|
|
10
|
+
incrementAutoContinue,
|
|
11
|
+
setLastToolWasAdvance,
|
|
12
|
+
resetState,
|
|
13
|
+
} from "./state";
|
|
14
|
+
import { resetConfig } from "./config";
|
|
15
|
+
import { formatHiddenContext, formatContinuePrompt } from "./formatting";
|
|
16
|
+
|
|
17
|
+
// ── Countdown Handles ──
|
|
18
|
+
|
|
19
|
+
let activeCountdown: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
let activeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
/** Clear any active countdown interval, timeout, and remove the countdown widget. */
|
|
23
|
+
function clearCountdown(ctx: ExtensionContext): void {
|
|
24
|
+
if (activeCountdown !== null) {
|
|
25
|
+
clearInterval(activeCountdown);
|
|
26
|
+
activeCountdown = null;
|
|
27
|
+
}
|
|
28
|
+
if (activeTimeout !== null) {
|
|
29
|
+
clearTimeout(activeTimeout);
|
|
30
|
+
activeTimeout = null;
|
|
31
|
+
}
|
|
32
|
+
if (ctx.hasUI) {
|
|
33
|
+
ctx.ui.setWidget("phased-tasks-countdown", undefined);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Abort Detection ──
|
|
38
|
+
|
|
39
|
+
/** Check if the last assistant message was aborted (user interrupted). */
|
|
40
|
+
function wasAborted(messages: { role: string; stopReason?: string }[]): boolean {
|
|
41
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
42
|
+
const msg = messages[i];
|
|
43
|
+
if (!msg) continue;
|
|
44
|
+
if (msg.role === "assistant") {
|
|
45
|
+
return msg.stopReason === "aborted";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Auto-Continue Delivery ──
|
|
52
|
+
|
|
53
|
+
/** Send auto-continue prompt, falling back to followUp delivery if agent is busy. */
|
|
54
|
+
function trySendAutoContinue(pi: ExtensionAPI, prompt: string): void {
|
|
55
|
+
try {
|
|
56
|
+
pi.sendUserMessage(prompt);
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
60
|
+
} catch {
|
|
61
|
+
// Agent truly unavailable (user typing, etc.) — skip auto-continue
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Schedule ──
|
|
67
|
+
|
|
68
|
+
/** Schedule auto-continue with countdown UI or timeout fallback. */
|
|
69
|
+
function scheduleAutoContinue(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
|
|
70
|
+
// Always clear both timer handles regardless of UI mode
|
|
71
|
+
if (activeCountdown !== null) {
|
|
72
|
+
clearInterval(activeCountdown);
|
|
73
|
+
activeCountdown = null;
|
|
74
|
+
}
|
|
75
|
+
if (activeTimeout !== null) {
|
|
76
|
+
clearTimeout(activeTimeout);
|
|
77
|
+
activeTimeout = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (ctx.hasUI) {
|
|
81
|
+
let remaining = 3;
|
|
82
|
+
const interval = setInterval(() => {
|
|
83
|
+
try {
|
|
84
|
+
remaining--;
|
|
85
|
+
if (remaining > 0) {
|
|
86
|
+
ctx.ui.setWidget(
|
|
87
|
+
"phased-tasks-countdown",
|
|
88
|
+
[`⏳ Auto-continuing in ${remaining}s... (type anything to interrupt)`],
|
|
89
|
+
{ placement: "aboveEditor" },
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
clearCountdown(ctx);
|
|
93
|
+
trySendAutoContinue(pi, prompt);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
clearCountdown(ctx);
|
|
97
|
+
}
|
|
98
|
+
}, 1000);
|
|
99
|
+
activeCountdown = interval;
|
|
100
|
+
|
|
101
|
+
ctx.ui.setWidget(
|
|
102
|
+
"phased-tasks-countdown",
|
|
103
|
+
["⏳ Auto-continuing in 3s... (type anything to interrupt)"],
|
|
104
|
+
{ placement: "aboveEditor" },
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
activeTimeout = setTimeout(() => {
|
|
108
|
+
activeTimeout = null;
|
|
109
|
+
trySendAutoContinue(pi, prompt);
|
|
110
|
+
}, 3000);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Handler Registration ──
|
|
115
|
+
|
|
116
|
+
export function registerEventHandlers(pi: ExtensionAPI): void {
|
|
117
|
+
pi.on("session_start", (_, ctx) => {
|
|
118
|
+
resetConfig();
|
|
119
|
+
clearCountdown(ctx);
|
|
120
|
+
setLastToolWasAdvance(false);
|
|
121
|
+
const board = reconstructState(ctx);
|
|
122
|
+
setBoard(board);
|
|
123
|
+
updateUI(ctx, board);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("session_tree", (_, ctx) => {
|
|
127
|
+
clearCountdown(ctx);
|
|
128
|
+
resetConfig();
|
|
129
|
+
setLastToolWasAdvance(false);
|
|
130
|
+
const board = reconstructState(ctx);
|
|
131
|
+
setBoard(board);
|
|
132
|
+
updateUI(ctx, board);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.on("session_shutdown", (_, ctx) => {
|
|
136
|
+
clearCountdown(ctx);
|
|
137
|
+
resetConfig();
|
|
138
|
+
resetState();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.on("before_agent_start", () => {
|
|
142
|
+
const board = getBoardRef();
|
|
143
|
+
if (board.tasks.length === 0) return;
|
|
144
|
+
|
|
145
|
+
let content = formatHiddenContext(board);
|
|
146
|
+
if (board.pendingPhasePrompt) {
|
|
147
|
+
content = `${board.pendingPhasePrompt.message}\n\n${content}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
message: {
|
|
152
|
+
customType: "phased-tasks-context",
|
|
153
|
+
content,
|
|
154
|
+
display: false,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
pi.on("agent_end", (event, ctx) => {
|
|
160
|
+
let board = getBoardRef();
|
|
161
|
+
if (board.tasks.length === 0) return;
|
|
162
|
+
if (wasAborted(event.messages)) return;
|
|
163
|
+
|
|
164
|
+
const count: number = incrementAutoContinue();
|
|
165
|
+
if (count > MAX_AUTO_CONTINUE) {
|
|
166
|
+
const nonTerminal = board.tasks.filter((t) => !TERMINAL_STATUSES.has(t.status));
|
|
167
|
+
pi.sendMessage(
|
|
168
|
+
{
|
|
169
|
+
customType: "phased-tasks-notice",
|
|
170
|
+
content: `Auto-continue limit reached (${MAX_AUTO_CONTINUE} iterations). ${nonTerminal.length} task(s) remain unresolved. Take over manually.`,
|
|
171
|
+
display: true,
|
|
172
|
+
},
|
|
173
|
+
{ triggerTurn: false },
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (hasActionableTasks(board)) {
|
|
179
|
+
// Consume pending phase prompt
|
|
180
|
+
let phasePrompt = "";
|
|
181
|
+
if (board.pendingPhasePrompt) {
|
|
182
|
+
phasePrompt = board.pendingPhasePrompt.message + "\n\n";
|
|
183
|
+
pi.sendMessage(
|
|
184
|
+
{
|
|
185
|
+
customType: "phased-tasks-notice",
|
|
186
|
+
content: `Phase ${board.pendingPhasePrompt.phase} complete.`,
|
|
187
|
+
display: true,
|
|
188
|
+
},
|
|
189
|
+
{ triggerTurn: false },
|
|
190
|
+
);
|
|
191
|
+
// Clear the prompt — will be persisted on next mutation
|
|
192
|
+
const updated = cloneBoard(board);
|
|
193
|
+
delete updated.pendingPhasePrompt;
|
|
194
|
+
setBoardQuiet(updated);
|
|
195
|
+
pi.appendEntry(CUSTOM_SNAPSHOT_TYPE, updated);
|
|
196
|
+
board = getBoardRef(); // Refresh after pendingPhasePrompt clear
|
|
197
|
+
}
|
|
198
|
+
const prompt = phasePrompt + formatContinuePrompt(board);
|
|
199
|
+
scheduleAutoContinue(pi, ctx, prompt);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (hasBlockedNonTerminalTasks(board)) {
|
|
204
|
+
const prompt = formatContinuePrompt(board); // deadlock message
|
|
205
|
+
scheduleAutoContinue(pi, ctx, prompt);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// All terminal — do nothing
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
pi.on("input", (_, ctx) => {
|
|
213
|
+
clearCountdown(ctx);
|
|
214
|
+
setLastToolWasAdvance(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("tool_result", (event) => {
|
|
218
|
+
const toolName = (event as { toolName?: string }).toolName;
|
|
219
|
+
if (toolName !== "advance_tasks") {
|
|
220
|
+
setLastToolWasAdvance(false);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|