@trevonistrevon/pi-loop 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1054 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +865 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/commands/loop-command.js +4 -5
  47. package/dist/commands/tasks-command.js +3 -3
  48. package/dist/goal-reducer.d.ts +9 -0
  49. package/dist/goal-reducer.js +11 -2
  50. package/dist/goal-store.d.ts +4 -15
  51. package/dist/goal-store.js +32 -154
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +4 -3
  54. package/dist/loop-reducer.d.ts +7 -0
  55. package/dist/loop-reducer.js +9 -0
  56. package/dist/monitor-manager.js +4 -4
  57. package/dist/reducer-backed-store.d.ts +65 -0
  58. package/dist/reducer-backed-store.js +160 -0
  59. package/dist/store.d.ts +4 -16
  60. package/dist/store.js +25 -156
  61. package/dist/task-reducer.js +3 -0
  62. package/dist/task-store.d.ts +4 -15
  63. package/dist/task-store.js +25 -147
  64. package/dist/tools/loop-tools.js +7 -5
  65. package/dist/tools/monitor-tools.js +12 -3
  66. package/dist/tools/native-task-tools.js +2 -2
  67. package/dist/trigger-system.js +2 -1
  68. package/dist/ui/widget.js +8 -1
  69. package/package.json +4 -1
  70. package/src/commands/loop-command.ts +4 -5
  71. package/src/commands/tasks-command.ts +3 -3
  72. package/src/goal-reducer.ts +20 -1
  73. package/src/goal-store.ts +35 -142
  74. package/src/index.ts +4 -3
  75. package/src/loop-reducer.ts +10 -0
  76. package/src/monitor-manager.ts +2 -3
  77. package/src/reducer-backed-store.ts +168 -0
  78. package/src/store.ts +28 -141
  79. package/src/task-reducer.ts +3 -0
  80. package/src/task-store.ts +28 -135
  81. package/src/tools/loop-tools.ts +6 -5
  82. package/src/tools/monitor-tools.ts +12 -3
  83. package/src/tools/native-task-tools.ts +2 -2
  84. package/src/trigger-system.ts +2 -1
  85. package/src/ui/widget.ts +8 -1
  86. package/vitest.config.ts +25 -0
  87. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/src/task-store.ts CHANGED
@@ -1,127 +1,30 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
3
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
4
4
  import { reduceTaskState, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
5
5
  import type { TaskEntry, TaskStoreData } from "./task-types.js";
6
6
 
7
7
  const TASKS_DIR = join(homedir(), ".pi", "tasks");
8
- const LOCK_RETRY_MS = 50;
9
- const LOCK_MAX_RETRIES = 100;
10
8
  const MAX_TASKS = 200;
11
9
 
12
- function acquireLock(lockPath: string): void {
13
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
14
- try {
15
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
16
- return;
17
- } catch (e: any) {
18
- if (e.code === "EEXIST") {
19
- try {
20
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
21
- if (!pid || !isProcessRunning(pid)) {
22
- try { unlinkSync(lockPath); } catch { /* ignore */ }
23
- continue;
24
- }
25
- } catch { /* ignore read errors */ }
26
- const start = Date.now();
27
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
28
- continue;
29
- }
30
- throw e;
31
- }
32
- }
33
- throw new Error(`Failed to acquire lock: ${lockPath}`);
34
- }
35
-
36
- function releaseLock(lockPath: string): void {
37
- try { unlinkSync(lockPath); } catch { /* ignore */ }
38
- }
39
-
40
- function isProcessRunning(pid: number): boolean {
41
- try { process.kill(pid, 0); return true; } catch { return false; }
42
- }
43
-
44
- export class TaskStore {
45
- private filePath: string | undefined;
46
- private lockPath: string | undefined;
47
- private lastLoadedSignature: string | undefined;
48
-
49
- private nextId = 1;
50
- private tasks = new Map<string, TaskEntry>();
51
-
10
+ export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
52
11
  constructor(listIdOrPath?: string) {
53
- if (!listIdOrPath) return;
54
- const isAbsPath = isAbsolute(listIdOrPath);
55
- const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
56
- mkdirSync(dirname(filePath), { recursive: true });
57
- this.filePath = filePath;
58
- this.lockPath = filePath + ".lock";
59
- this.load();
60
- }
61
-
62
- private getFileSignature(): string | undefined {
63
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
64
- const stat = statSync(this.filePath);
65
- return `${stat.mtimeMs}:${stat.size}`;
66
- }
67
-
68
- private load(force = false): void {
69
- if (!this.filePath) return;
70
- const signature = this.getFileSignature();
71
- if (!signature) return;
72
- if (!force && signature === this.lastLoadedSignature) return;
73
- try {
74
- const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
75
- this.nextId = data.nextId;
76
- this.tasks.clear();
77
- for (const task of data.tasks) {
78
- this.tasks.set(task.id, task);
79
- }
80
- this.lastLoadedSignature = signature;
81
- } catch { /* corrupt file — start fresh */ }
82
- }
83
-
84
- private save(): void {
85
- if (!this.filePath) return;
86
- const data: TaskStoreData = {
87
- nextId: this.nextId,
88
- tasks: Array.from(this.tasks.values()),
89
- };
90
- const tmpPath = this.filePath + ".tmp";
91
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
92
- renameSync(tmpPath, this.filePath);
93
- this.lastLoadedSignature = this.getFileSignature();
94
- }
95
-
96
- private withLock<T>(fn: () => T): T {
97
- if (!this.lockPath) return fn();
98
- acquireLock(this.lockPath);
99
- try {
100
- this.load(true);
101
- const result = fn();
102
- this.save();
103
- return result;
104
- } finally {
105
- releaseLock(this.lockPath);
106
- }
107
- }
108
-
109
- private toReducerState(): TaskReducerState {
110
- return {
111
- nextId: this.nextId,
112
- tasksById: Object.fromEntries(this.tasks.entries()),
113
- };
114
- }
115
-
116
- private applyReducerEvent(event: TaskReducerEvent): void {
117
- const result = reduceTaskState(this.toReducerState(), event);
118
- this.nextId = result.state.nextId;
119
- this.tasks = new Map(Object.entries(result.state.tasksById));
12
+ super(
13
+ {
14
+ baseDir: TASKS_DIR,
15
+ reduce: (state, event) => reduceTaskState(state, event),
16
+ toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
17
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
18
+ serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
19
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
20
+ },
21
+ listIdOrPath,
22
+ );
120
23
  }
121
24
 
122
25
  create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
123
26
  return this.withLock(() => {
124
- if (this.tasks.size >= MAX_TASKS) {
27
+ if (this.entries.size >= MAX_TASKS) {
125
28
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
126
29
  }
127
30
  const now = Date.now();
@@ -132,23 +35,13 @@ export class TaskStore {
132
35
  entityType: "task",
133
36
  payload: { subject, description, metadata },
134
37
  });
135
- return this.tasks.get(String(this.nextId - 1))!;
38
+ return this.entries.get(String(this.nextId - 1))!;
136
39
  });
137
40
  }
138
41
 
139
- get(id: string): TaskEntry | undefined {
140
- if (this.filePath) this.load();
141
- return this.tasks.get(id);
142
- }
143
-
144
- list(): TaskEntry[] {
145
- if (this.filePath) this.load();
146
- return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
147
- }
148
-
149
42
  start(id: string): TaskEntry | undefined {
150
43
  return this.withLock(() => {
151
- const entry = this.tasks.get(id);
44
+ const entry = this.entries.get(id);
152
45
  if (!entry) return undefined;
153
46
  this.applyReducerEvent({
154
47
  type: "TASK_STARTED",
@@ -158,13 +51,13 @@ export class TaskStore {
158
51
  entityId: id,
159
52
  payload: { id },
160
53
  });
161
- return this.tasks.get(id);
54
+ return this.entries.get(id);
162
55
  });
163
56
  }
164
57
 
165
58
  complete(id: string): TaskEntry | undefined {
166
59
  return this.withLock(() => {
167
- const entry = this.tasks.get(id);
60
+ const entry = this.entries.get(id);
168
61
  if (!entry) return undefined;
169
62
  this.applyReducerEvent({
170
63
  type: "TASK_COMPLETED",
@@ -174,13 +67,13 @@ export class TaskStore {
174
67
  entityId: id,
175
68
  payload: { id },
176
69
  });
177
- return this.tasks.get(id);
70
+ return this.entries.get(id);
178
71
  });
179
72
  }
180
73
 
181
74
  reopen(id: string): TaskEntry | undefined {
182
75
  return this.withLock(() => {
183
- const entry = this.tasks.get(id);
76
+ const entry = this.entries.get(id);
184
77
  if (!entry) return undefined;
185
78
  this.applyReducerEvent({
186
79
  type: "TASK_REOPENED",
@@ -190,13 +83,13 @@ export class TaskStore {
190
83
  entityId: id,
191
84
  payload: { id },
192
85
  });
193
- return this.tasks.get(id);
86
+ return this.entries.get(id);
194
87
  });
195
88
  }
196
89
 
197
90
  updateDetails(id: string, fields: { subject?: string; description?: string }): TaskEntry | undefined {
198
91
  return this.withLock(() => {
199
- const entry = this.tasks.get(id);
92
+ const entry = this.entries.get(id);
200
93
  if (!entry) return undefined;
201
94
  if (fields.subject === undefined && fields.description === undefined) return entry;
202
95
  this.applyReducerEvent({
@@ -211,13 +104,13 @@ export class TaskStore {
211
104
  description: fields.description,
212
105
  },
213
106
  });
214
- return this.tasks.get(id);
107
+ return this.entries.get(id);
215
108
  });
216
109
  }
217
110
 
218
111
  delete(id: string): boolean {
219
112
  return this.withLock(() => {
220
- if (!this.tasks.has(id)) return false;
113
+ if (!this.entries.has(id)) return false;
221
114
  this.applyReducerEvent({
222
115
  type: "TASK_DELETED",
223
116
  at: Date.now(),
@@ -232,7 +125,7 @@ export class TaskStore {
232
125
 
233
126
  pendingCount(): number {
234
127
  let count = 0;
235
- for (const t of this.tasks.values()) {
128
+ for (const t of this.entries.values()) {
236
129
  if (t.status === "pending" || t.status === "in_progress") count++;
237
130
  }
238
131
  return count;
@@ -240,7 +133,7 @@ export class TaskStore {
240
133
 
241
134
  pruneCompleted(): number {
242
135
  return this.withLock(() => {
243
- const before = this.tasks.size;
136
+ const before = this.entries.size;
244
137
  this.applyReducerEvent({
245
138
  type: "TASKS_PRUNED",
246
139
  at: Date.now(),
@@ -248,7 +141,7 @@ export class TaskStore {
248
141
  entityType: "task",
249
142
  payload: { reason: "manual" },
250
143
  });
251
- return before - this.tasks.size;
144
+ return before - this.entries.size;
252
145
  });
253
146
  }
254
147
  }
@@ -149,7 +149,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
149
149
  "## Task-driven workflows",
150
150
  "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
151
151
  "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
152
- "Set taskBacklog: true for task-worker loops that process the existing pending queue. Task-backlog loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
152
+ "Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
153
153
  "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
154
154
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
155
155
  ],
@@ -229,10 +229,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
229
229
  `Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
230
230
  `Trigger: ${triggerDesc}\n` +
231
231
  `Recurring: ${entry.recurring}\n` +
232
- (entry.autoTask ? "Auto-task: enabled\n" : "") +
233
- (entry.taskBacklog ? "Task-backlog: enabled\n" : "") +
234
- (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
235
- (isTaskSystemReady() ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
232
+ (entry.autoTask ? "Auto-create task: enabled\n" : "") +
233
+ (entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
234
+ (bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
235
+ (isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
236
236
  `ID: ${entry.id} (use LoopDelete to cancel)`
237
237
  ));
238
238
  },
@@ -266,6 +266,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
266
266
  line += ` next: ${formatRemaining(remaining)}`;
267
267
  }
268
268
  if (entry.autoTask) line += " [auto-task]";
269
+ if (entry.taskBacklog) line += " [backlog-worker]";
269
270
  lines.push(line);
270
271
  }
271
272
 
@@ -82,10 +82,19 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
82
82
 
83
83
  let onDoneMsg = "";
84
84
  if (params.onDone) {
85
+ // onDone delivery is callback-only: the loop below is delivered solely
86
+ // via MonitorManager.onComplete (see handleMonitorDoneLoop →
87
+ // monitor-ondone-runtime), so it fires exactly once by construction.
88
+ // The event-typed trigger is metadata, NOT a live subscription — this
89
+ // loop is deliberately NOT passed to triggerSystem.add(). The "event"
90
+ // type lets expireEventLoops() prune it if orphaned across a session
91
+ // and lets the widget render it as a monitor-completion wake. Do not
92
+ // triggerSystem.add() this loop, or the monitor:done event would fire it
93
+ // a second time.
85
94
  const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
86
95
  const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
87
96
  handleMonitorDoneLoop(doneLoop, entry.id);
88
- onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
97
+ onDoneMsg = `\nCompletion wake loop #${doneLoop.id}: fires when the monitor completes — no polling needed`;
89
98
  }
90
99
 
91
100
  return Promise.resolve(textResult(
@@ -103,11 +112,11 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
103
112
  parameters: Type.Object({}),
104
113
  execute() {
105
114
  const monitors = getMonitorManager().list();
106
- if (monitors.length === 0) return Promise.resolve(textResult("No monitors running."));
115
+ if (monitors.length === 0) return Promise.resolve(textResult("No monitors."));
107
116
 
108
117
  const lines: string[] = [];
109
118
  for (const m of monitors) {
110
- const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
119
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "x";
111
120
  const age = Date.now() - m.startedAt;
112
121
  const ageStr = formatRemaining(age);
113
122
  let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
@@ -57,7 +57,7 @@ Fields:
57
57
  updateWidget();
58
58
 
59
59
  const autoLoopMsg = backlog.created && backlog.entry
60
- ? `\nWorker loop #${backlog.entry.id} auto-created`
60
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
61
61
  : "";
62
62
  return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
63
63
  },
@@ -124,7 +124,7 @@ Parameters: id (required), status, subject, description`,
124
124
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
125
125
  const statusMsg = status ? ` → ${status}` : "";
126
126
  const autoLoopMsg = backlog.created && backlog.entry
127
- ? `\nWorker loop #${backlog.entry.id} auto-created`
127
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
128
128
  : "";
129
129
  return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
130
130
  },
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { atMaxFires } from "./loop-reducer.js";
2
3
  import type { CronScheduler } from "./scheduler.js";
3
4
  import type { LoopStore } from "./store.js";
4
5
  import type { LoopEntry } from "./types.js";
@@ -110,7 +111,7 @@ export class TriggerSystem {
110
111
  return;
111
112
  }
112
113
 
113
- if (fresh.recurring && fresh.maxFires && (fresh.fireCount ?? 0) >= fresh.maxFires) {
114
+ if (fresh.recurring && atMaxFires(fresh)) {
114
115
  this.remove(fresh.id);
115
116
  this.store.delete(fresh.id);
116
117
  return;
package/src/ui/widget.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { MonitorManager } from "../monitor-manager.js";
3
3
  import type { LoopStore } from "../store.js";
4
+ import type { LoopEntry } from "../types.js";
4
5
 
5
6
  interface TaskSummary {
6
7
  count: number;
@@ -34,7 +35,7 @@ export class LoopWidget {
34
35
  }
35
36
 
36
37
  private computeStatus(): string | undefined {
37
- const loops = this.store.list().filter(l => l.status === "active");
38
+ const loops = this.store.list().filter(isStatusVisibleLoop);
38
39
  const monitors = this.monitorManager.list();
39
40
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
40
41
 
@@ -60,3 +61,9 @@ export class LoopWidget {
60
61
  function formatCount(count: number, noun: string): string {
61
62
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
62
63
  }
64
+
65
+ function isStatusVisibleLoop(loop: LoopEntry): boolean {
66
+ if (loop.status !== "active") return false;
67
+ if (loop.recurring) return true;
68
+ return !(loop.trigger.type === "event" && loop.trigger.source === "monitor:done");
69
+ }
@@ -0,0 +1,25 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["test/**/*.test.ts"],
7
+ exclude: ["node_modules", "dist"],
8
+ coverage: {
9
+ provider: "v8",
10
+ reporter: ["text-summary", "html", "json"],
11
+ include: ["src/**/*.ts"],
12
+ // Type-only modules and test helpers carry no executable logic worth gating.
13
+ exclude: ["src/**/*-types.ts", "src/types.ts"],
14
+ // Floors set just below current actuals (stmts 80%, branches 69.5%,
15
+ // funcs 92.5%, lines 82.9%) to catch regressions. Ratchet up as the
16
+ // runtime/ and tools/ suites land in Phase 4.
17
+ thresholds: {
18
+ statements: 78,
19
+ branches: 67,
20
+ functions: 90,
21
+ lines: 80,
22
+ },
23
+ },
24
+ },
25
+ });
@@ -1,81 +0,0 @@
1
- # Differential Review Report
2
-
3
- ## Update — 2026-06-02
4
-
5
- ### Resolved audit finding
6
- - **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
7
- - **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
8
- - **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
9
-
10
- ### Regression coverage added
11
- - `test/trigger-system.test.ts`
12
- - recurring `event` loop is deleted immediately at final `maxFires`
13
- - recurring `hybrid` loop is deleted immediately at final `maxFires`
14
- - hybrid cleanup also clears scheduled cron state
15
- - `test/index.test.ts`
16
- - extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
17
-
18
- ### Validation status
19
- - `npm run lint` ✅
20
- - `npm run typecheck` ✅
21
- - `npm run test` ✅
22
- - `npm run build` ✅
23
- - Current suite: **112 passing tests**
24
-
25
- ## Scope
26
- Reviewed recent uncommitted changes in:
27
- - `src/index.ts`
28
- - `src/ui/widget.ts`
29
- - `test/index.test.ts`
30
- - `test/widget.test.ts`
31
- - `README.md`
32
-
33
- ## Risk Summary
34
- - **Overall risk:** Medium
35
- - **Primary areas affected:** runtime task fallback routing, UI/widget behavior, interactive command registration
36
- - **Security impact:** Low direct security impact; main risks are state-management regressions and missing coverage around command behavior
37
-
38
- ## Findings
39
-
40
- ### 🟡 Warning
41
- `src/index.ts` - Native task fallback is covered for registration and file persistence, but not for interactive `/tasks` flows or loop-driven task lifecycle integration.
42
-
43
- **Why it matters:**
44
- The new `/tasks` command and interactive actions (`Start`, `Complete`, `Reopen`, `Delete`) are stateful and user-facing. Regressions here would not be caught by current tests. Similarly, native fallback behavior for `autoTask`, `hasPendingTasks()`, and `cleanDoneTasks()` is only indirectly covered.
45
-
46
- **Recommended follow-up:**
47
- Add tests for:
48
- - `/tasks` command registration and quick-create path
49
- - native `autoTask` creation path
50
- - `hasPendingTasks()` using native fallback
51
- - completed-task sweep behavior for native tasks
52
-
53
- ### 🟢 Suggestion
54
- `src/ui/widget.ts` - Compact widget behavior is appropriately simplified and focus-oriented.
55
-
56
- **Good pattern:**
57
- The single-line status approach reduces noise and matches the intended UX. Showing only active/next task focus text is a good constraint.
58
-
59
- ## Test Coverage Review
60
-
61
- ### Covered well
62
- - Native tool registration when `pi-tasks` is absent/present
63
- - Native task persistence path (`.pi/tasks/tasks.json`)
64
- - Compact widget states:
65
- - `none`
66
- - monitor-only count
67
- - loop + monitor count
68
- - active task focus text
69
- - next task focus text
70
- - retained widget rendering after content clears
71
-
72
- ### Coverage gaps
73
- - No tests for `/tasks` command behavior beyond registration
74
- - No direct tests for native `TaskUpdate` → widget focus transitions
75
- - No direct tests for native `cleanDoneTasks()` sweep behavior
76
- - No end-to-end tests for `LoopCreate(autoTask: true)` using native fallback
77
-
78
- ## Review Verdict
79
- - **No merge-blocking issues found**
80
- - Safe to release after version bump and validation
81
- - Recommended follow-up: add native task lifecycle command/integration tests in a later pass