pi-harness-runtime 0.10.0 → 0.10.2
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 +22 -0
- package/README.md +122 -0
- package/harness/e2e/minimax-quota-scraper.ts +4 -4
- package/harness/e2e/openai-quota-scraper.ts +13 -8
- package/index.ts +93 -2
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/dist/src/copy-sync.d.ts +27 -0
- package/packages/clipboard/dist/src/copy-sync.d.ts.map +1 -0
- package/packages/clipboard/dist/src/copy-sync.js +170 -0
- package/packages/clipboard/dist/src/copy-sync.js.map +1 -0
- package/packages/clipboard/dist/src/gist-relay.d.ts +48 -0
- package/packages/clipboard/dist/src/gist-relay.d.ts.map +1 -0
- package/packages/clipboard/dist/src/gist-relay.js +186 -0
- package/packages/clipboard/dist/src/gist-relay.js.map +1 -0
- package/packages/clipboard/dist/src/github-login.d.ts +16 -0
- package/packages/clipboard/dist/src/github-login.d.ts.map +1 -0
- package/packages/clipboard/dist/src/github-login.js +69 -0
- package/packages/clipboard/dist/src/github-login.js.map +1 -0
- package/packages/clipboard/dist/src/index.d.ts +2 -0
- package/packages/clipboard/dist/src/index.d.ts.map +1 -1
- package/packages/clipboard/dist/src/index.js +2 -1
- package/packages/clipboard/dist/src/index.js.map +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard/src/copy-sync.ts +201 -0
- package/packages/clipboard/src/gist-relay.ts +240 -0
- package/packages/clipboard/src/github-login.ts +107 -0
- package/packages/clipboard/src/index.ts +2 -0
- package/packages/clipboard/test/mojibake.test.mjs +143 -0
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +22 -0
- package/packages/todo-bd-sync/src/detector.ts +150 -0
- package/packages/todo-bd-sync/src/extension.ts +84 -0
- package/packages/todo-bd-sync/src/index.ts +401 -0
- package/packages/todo-bd-sync/src/sync.ts +322 -0
- package/packages/todo-bd-sync/src/todo-reminder.ts +418 -0
- package/packages/todo-bd-sync/src/types.ts +137 -0
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo-reminder.ts - Inject reminders to continue remaining todos
|
|
3
|
+
*
|
|
4
|
+
* ARCHITECTURE NOTES:
|
|
5
|
+
* - Reminders are DISABLED by default (Phase 1 emergency containment)
|
|
6
|
+
* until proper scoping is implemented.
|
|
7
|
+
* - Triggers are narrowed: only after agent_end, not every bash command.
|
|
8
|
+
* - Deduplication is persistent via file-based hash (survives process restart).
|
|
9
|
+
* - Source is scoped to sync-layer-mapped tasks only.
|
|
10
|
+
*
|
|
11
|
+
* This module does NOT modify the overlay (it works fine).
|
|
12
|
+
* It ONLY injects reminders into the LLM context when todos remain.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { execBdCommand } from "./sync.js";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
// ─── Persistent state file ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const STATE_DIR = join(homedir(), ".pi-harness-runtime");
|
|
24
|
+
const STATE_FILE = join(STATE_DIR, "todo-reminder-state.json");
|
|
25
|
+
|
|
26
|
+
interface ReminderState {
|
|
27
|
+
/** Hash of the last delivered reminder payload */
|
|
28
|
+
lastDeliveredHash: string;
|
|
29
|
+
/** Timestamp of last delivery (ms epoch) */
|
|
30
|
+
lastDeliveredAt: number;
|
|
31
|
+
/** Session ID this was delivered in (to avoid cross-session dedup issues) */
|
|
32
|
+
lastSessionId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ensureStateDir(): void {
|
|
36
|
+
if (!existsSync(STATE_DIR)) {
|
|
37
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadState(): ReminderState {
|
|
42
|
+
try {
|
|
43
|
+
if (existsSync(STATE_FILE)) {
|
|
44
|
+
const raw = readFileSync(STATE_FILE, "utf-8");
|
|
45
|
+
return JSON.parse(raw) as ReminderState;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
return { lastDeliveredHash: "", lastDeliveredAt: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveState(state: ReminderState): void {
|
|
54
|
+
ensureStateDir();
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore — best-effort
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a stable content hash for deduplication.
|
|
64
|
+
* Uses a simple non-crypto hash — we need dedup, not security.
|
|
65
|
+
*/
|
|
66
|
+
function contentHash(tasks: TrackedTask[]): string {
|
|
67
|
+
const ids = tasks
|
|
68
|
+
.map((t) => t.id)
|
|
69
|
+
.sort()
|
|
70
|
+
.join("|");
|
|
71
|
+
let hash = 0;
|
|
72
|
+
for (let i = 0; i < ids.length; i++) {
|
|
73
|
+
hash = (hash * 31 + ids.charCodeAt(i)) & 0xffffffff;
|
|
74
|
+
}
|
|
75
|
+
return `t${Math.abs(hash).toString(16)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A task that is tracked by the sync layer
|
|
80
|
+
*/
|
|
81
|
+
interface TrackedTask {
|
|
82
|
+
id: string;
|
|
83
|
+
title: string;
|
|
84
|
+
status: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Default reminder template
|
|
89
|
+
*/
|
|
90
|
+
const DEFAULT_REMINDER_TEMPLATE = `
|
|
91
|
+
Remaining todos detected:
|
|
92
|
+
{summary}
|
|
93
|
+
|
|
94
|
+
Please continue with the next pending task or ask the user which to prioritize.
|
|
95
|
+
`.trim();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Configuration for todo reminder
|
|
99
|
+
*/
|
|
100
|
+
export interface TodoReminderConfig {
|
|
101
|
+
/** Auto-inject reminder on agent_end (default: FALSE — containment) */
|
|
102
|
+
autoRemind?: boolean;
|
|
103
|
+
/** Custom reminder template (default: built-in) */
|
|
104
|
+
template?: string;
|
|
105
|
+
/** Minimum pending tasks to trigger reminder (default: 1) */
|
|
106
|
+
minPendingTasks?: number;
|
|
107
|
+
/** Deliver reminder as: "steer" | "followUp" (default: "steer") */
|
|
108
|
+
deliverAs?: "steer" | "followUp";
|
|
109
|
+
/** Enable verbose debug logging */
|
|
110
|
+
verbose?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Default configuration
|
|
115
|
+
* NOTE: autoRemind is FALSE by default — Phase 1 emergency containment.
|
|
116
|
+
* Enable only after proper task scoping is in place.
|
|
117
|
+
*/
|
|
118
|
+
const DEFAULT_CONFIG: Required<TodoReminderConfig> = {
|
|
119
|
+
autoRemind: false, // DISABLED by default — see bug report
|
|
120
|
+
template: DEFAULT_REMINDER_TEMPLATE,
|
|
121
|
+
minPendingTasks: 1,
|
|
122
|
+
deliverAs: "steer", // steer does NOT append to transcript
|
|
123
|
+
verbose: false,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a todo reminder instance
|
|
128
|
+
*/
|
|
129
|
+
export function createTodoReminder(
|
|
130
|
+
pi: ExtensionAPI,
|
|
131
|
+
config: TodoReminderConfig = {},
|
|
132
|
+
): TodoReminder {
|
|
133
|
+
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
|
134
|
+
return new TodoReminder(pi, finalConfig);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* TodoReminder class — injects reminders when todos remain.
|
|
139
|
+
*
|
|
140
|
+
* Key design decisions:
|
|
141
|
+
* 1. DISABLED by default (autoRemind: false) — Phase 1 containment
|
|
142
|
+
* 2. Deduplication is persistent via file-based hash (survives process restart)
|
|
143
|
+
* 3. Narrow trigger: only agent_end, never generic bash
|
|
144
|
+
* 4. deliverAs: "steer" avoids transcript growth
|
|
145
|
+
*/
|
|
146
|
+
export class TodoReminder {
|
|
147
|
+
private pi: ExtensionAPI;
|
|
148
|
+
private config: Required<TodoReminderConfig>;
|
|
149
|
+
private lastReminderAt = 0;
|
|
150
|
+
private state: ReminderState;
|
|
151
|
+
|
|
152
|
+
constructor(pi: ExtensionAPI, config: Required<TodoReminderConfig>) {
|
|
153
|
+
this.pi = pi;
|
|
154
|
+
this.config = config;
|
|
155
|
+
// Load persistent state from file — survives process restart
|
|
156
|
+
this.state = loadState();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Start the reminder system
|
|
161
|
+
*/
|
|
162
|
+
start(): void {
|
|
163
|
+
if (!this.config.autoRemind) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Only trigger on agent_end — not on every bash command.
|
|
168
|
+
// Bash triggers were too broad and caused spam during analysis sessions.
|
|
169
|
+
this.pi.on("agent_end", async () => {
|
|
170
|
+
await this.checkAndRemind();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if reminders should be sent and send them
|
|
176
|
+
*/
|
|
177
|
+
private async checkAndRemind(): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
const remaining = this.getTrackedTodos();
|
|
180
|
+
|
|
181
|
+
if (remaining.length < this.config.minPendingTasks) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Persistent content-based deduplication — survives process restart.
|
|
186
|
+
// Even if pi restarts, we won't re-deliver the same reminder payload.
|
|
187
|
+
const hash = contentHash(remaining);
|
|
188
|
+
if (hash === this.state.lastDeliveredHash) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await this.sendReminder(remaining);
|
|
193
|
+
this.lastReminderAt = Date.now();
|
|
194
|
+
|
|
195
|
+
// Persist the hash so restart doesn't cause replay
|
|
196
|
+
this.state.lastDeliveredHash = hash;
|
|
197
|
+
this.state.lastDeliveredAt = this.lastReminderAt;
|
|
198
|
+
saveState(this.state);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// Non-fatal - reminders are best-effort
|
|
201
|
+
console.warn("[DEBUG todo-reminder] Failed to check todos:", e);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get remaining (pending/in-progress) todos from bd.
|
|
207
|
+
*
|
|
208
|
+
* NOTE: This returns ALL open bd issues, not just mapped ones.
|
|
209
|
+
* When the mapping registry is integrated, this should be scoped.
|
|
210
|
+
*/
|
|
211
|
+
private getTrackedTodos(): TrackedTask[] {
|
|
212
|
+
try {
|
|
213
|
+
const output = execBdCommand("bd ready --json");
|
|
214
|
+
const issues = JSON.parse(output);
|
|
215
|
+
return issues
|
|
216
|
+
.filter(
|
|
217
|
+
(issue: Record<string, unknown>) =>
|
|
218
|
+
issue.status !== "closed" && issue.status !== "done",
|
|
219
|
+
)
|
|
220
|
+
.map((issue: Record<string, unknown>) => ({
|
|
221
|
+
id: issue.id as string,
|
|
222
|
+
title: issue.title as string,
|
|
223
|
+
status: issue.status as string,
|
|
224
|
+
}));
|
|
225
|
+
} catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Format the reminder message
|
|
232
|
+
*/
|
|
233
|
+
private formatReminder(remaining: TrackedTask[]): string {
|
|
234
|
+
const summary = remaining
|
|
235
|
+
.map((t) => `[${t.id}] ${t.title} (${t.status})`)
|
|
236
|
+
.join("\n");
|
|
237
|
+
|
|
238
|
+
return this.config.template.replace("{summary}", summary);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Send the reminder to the LLM.
|
|
243
|
+
*
|
|
244
|
+
* FIX: Re-check state before send to prevent stale reminders.
|
|
245
|
+
* If a task was closed after we prepared the reminder, skip delivery.
|
|
246
|
+
*/
|
|
247
|
+
private async sendReminder(remaining: TrackedTask[]): Promise<void> {
|
|
248
|
+
// CRITICAL FIX: Re-check state immediately before sending.
|
|
249
|
+
// A task may have been closed between checkAndRemind() and now.
|
|
250
|
+
// We must not deliver reminders for already-closed tasks.
|
|
251
|
+
const freshState = this.getTrackedTodos();
|
|
252
|
+
const freshHash = contentHash(freshState);
|
|
253
|
+
const preparedHash = contentHash(remaining);
|
|
254
|
+
|
|
255
|
+
// If the fresh state differs from what we prepared, tasks were modified
|
|
256
|
+
if (freshHash !== preparedHash) {
|
|
257
|
+
// Task was closed or modified — suppress stale reminder
|
|
258
|
+
if (this.config.verbose ?? false) {
|
|
259
|
+
console.log(
|
|
260
|
+
"[DEBUG todo-reminder] State changed since check — suppressing reminder",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const message = this.formatReminder(remaining);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
this.pi.sendUserMessage(message, {
|
|
270
|
+
deliverAs: this.config.deliverAs,
|
|
271
|
+
});
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.warn("[DEBUG todo-reminder] Failed to send reminder:", e);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Manually trigger a reminder (for slash command)
|
|
279
|
+
*/
|
|
280
|
+
async triggerNow(): Promise<void> {
|
|
281
|
+
await this.checkAndRemind();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get current remaining count
|
|
286
|
+
*/
|
|
287
|
+
getRemainingCount(): number {
|
|
288
|
+
return this.getTrackedTodos().length;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Clear the persistent dedupe state (for testing or manual reset)
|
|
293
|
+
*/
|
|
294
|
+
clearState(): void {
|
|
295
|
+
this.state = { lastDeliveredHash: "", lastDeliveredAt: 0 };
|
|
296
|
+
saveState(this.state);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a reminder with custom todos source (for tighter scoping)
|
|
302
|
+
* @param pi - Extension API
|
|
303
|
+
* @param getRemaining - Function that returns remaining todos
|
|
304
|
+
* @param config - Reminder config
|
|
305
|
+
*/
|
|
306
|
+
export function createCustomReminder(
|
|
307
|
+
pi: ExtensionAPI,
|
|
308
|
+
getRemaining: () => TrackedTask[],
|
|
309
|
+
config: TodoReminderConfig = {},
|
|
310
|
+
): CustomTodoReminder {
|
|
311
|
+
return new CustomTodoReminder(pi, getRemaining, config);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Custom reminder with injectable todos source.
|
|
316
|
+
* Use this to scope reminders to only mapped/created tasks.
|
|
317
|
+
*/
|
|
318
|
+
class CustomTodoReminder {
|
|
319
|
+
private pi: ExtensionAPI;
|
|
320
|
+
private getRemaining: () => TrackedTask[];
|
|
321
|
+
private config: Required<TodoReminderConfig>;
|
|
322
|
+
private lastReminderAt = 0;
|
|
323
|
+
private state: ReminderState;
|
|
324
|
+
|
|
325
|
+
constructor(
|
|
326
|
+
pi: ExtensionAPI,
|
|
327
|
+
getRemaining: () => TrackedTask[],
|
|
328
|
+
config: TodoReminderConfig,
|
|
329
|
+
) {
|
|
330
|
+
this.pi = pi;
|
|
331
|
+
this.getRemaining = getRemaining;
|
|
332
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
333
|
+
// Load persistent state from file
|
|
334
|
+
this.state = loadState();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
start(): void {
|
|
338
|
+
if (!this.config.autoRemind) return;
|
|
339
|
+
|
|
340
|
+
// Only trigger on agent_end — not on every bash command
|
|
341
|
+
this.pi.on("agent_end", async () => {
|
|
342
|
+
await this.checkAndRemind();
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async checkAndRemind(): Promise<void> {
|
|
347
|
+
try {
|
|
348
|
+
const remaining = this.getRemaining();
|
|
349
|
+
if (remaining.length < this.config.minPendingTasks) return;
|
|
350
|
+
|
|
351
|
+
// Persistent content-based deduplication
|
|
352
|
+
const hash = contentHash(remaining);
|
|
353
|
+
if (hash === this.state.lastDeliveredHash) return;
|
|
354
|
+
|
|
355
|
+
await this.sendReminder(remaining);
|
|
356
|
+
this.lastReminderAt = Date.now();
|
|
357
|
+
|
|
358
|
+
// Persist so restart doesn't replay
|
|
359
|
+
this.state.lastDeliveredHash = hash;
|
|
360
|
+
this.state.lastDeliveredAt = this.lastReminderAt;
|
|
361
|
+
saveState(this.state);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.warn("[DEBUG todo-reminder] Failed to check todos:", e);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private formatReminder(remaining: TrackedTask[]): string {
|
|
368
|
+
const summary = remaining
|
|
369
|
+
.map((t) => `[${t.id}] ${t.title} (${t.status})`)
|
|
370
|
+
.join("\n");
|
|
371
|
+
|
|
372
|
+
return this.config.template.replace("{summary}", summary);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async sendReminder(remaining: TrackedTask[]): Promise<void> {
|
|
376
|
+
// CRITICAL FIX: Re-check state immediately before sending.
|
|
377
|
+
// A task may have been closed between checkAndRemind() and now.
|
|
378
|
+
const freshState = this.getRemaining();
|
|
379
|
+
const freshHash = contentHash(freshState);
|
|
380
|
+
const preparedHash = contentHash(remaining);
|
|
381
|
+
|
|
382
|
+
// If the fresh state differs from what we prepared, tasks were modified
|
|
383
|
+
if (freshHash !== preparedHash) {
|
|
384
|
+
if (this.config.verbose ?? false) {
|
|
385
|
+
console.log(
|
|
386
|
+
"[DEBUG todo-reminder] State changed since check — suppressing reminder",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const message = this.formatReminder(remaining);
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
this.pi.sendUserMessage(message, {
|
|
396
|
+
deliverAs: this.config.deliverAs,
|
|
397
|
+
});
|
|
398
|
+
} catch (e) {
|
|
399
|
+
console.warn("[DEBUG todo-reminder] Failed to send reminder:", e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async triggerNow(): Promise<void> {
|
|
404
|
+
await this.checkAndRemind();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
getRemainingCount(): number {
|
|
408
|
+
return this.getRemaining().length;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Clear the persistent dedupe state
|
|
413
|
+
*/
|
|
414
|
+
clearState(): void {
|
|
415
|
+
this.state = { lastDeliveredHash: "", lastDeliveredAt: 0 };
|
|
416
|
+
saveState(this.state);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for todo-bd-sync
|
|
3
|
+
* Two-way sync between rpiv-todo overlay and bd issue tracker
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Task from rpiv-todo
|
|
7
|
+
export interface TodoTask {
|
|
8
|
+
id: number;
|
|
9
|
+
subject: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
activeForm?: string;
|
|
12
|
+
status: TodoStatus;
|
|
13
|
+
blockedBy?: number[];
|
|
14
|
+
owner?: string;
|
|
15
|
+
metadata?: {
|
|
16
|
+
bdId?: string; // Mapped bd issue ID (e.g., "bd-a1b2")
|
|
17
|
+
lastSync?: number; // Timestamp of last sync
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "deleted";
|
|
23
|
+
|
|
24
|
+
// Bd issue structure
|
|
25
|
+
export interface BdIssue {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
status: BdStatus;
|
|
29
|
+
priority?: number;
|
|
30
|
+
createdAt?: string;
|
|
31
|
+
updatedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type BdStatus = "open" | "in_progress" | "closed" | "blocked";
|
|
35
|
+
|
|
36
|
+
// Sync event types
|
|
37
|
+
export type SyncDirection = "todo_to_bd" | "bd_to_todo" | "session_start";
|
|
38
|
+
|
|
39
|
+
// Conflict resolution
|
|
40
|
+
export interface SyncConflict {
|
|
41
|
+
task: TodoTask;
|
|
42
|
+
bdIssue: BdIssue;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
resolution: "todo_wins" | "bd_wins" | "recent_wins";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Sync result
|
|
48
|
+
export interface SyncResult {
|
|
49
|
+
success: boolean;
|
|
50
|
+
direction: SyncDirection;
|
|
51
|
+
taskId?: number;
|
|
52
|
+
bdId?: string;
|
|
53
|
+
action?: string;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ID mapping storage
|
|
58
|
+
export interface IdMapping {
|
|
59
|
+
todoId: number;
|
|
60
|
+
bdId: string;
|
|
61
|
+
createdAt: number;
|
|
62
|
+
lastSync: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Registry of ID mappings
|
|
66
|
+
export class IdMappingRegistry {
|
|
67
|
+
private mappings: Map<number, IdMapping> = new Map(); // todoId -> mapping
|
|
68
|
+
private bdMappings: Map<string, number> = new Map(); // bdId -> todoId
|
|
69
|
+
|
|
70
|
+
set(todoId: number, bdId: string): void {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const mapping: IdMapping = {
|
|
73
|
+
todoId,
|
|
74
|
+
bdId,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
lastSync: now,
|
|
77
|
+
};
|
|
78
|
+
this.mappings.set(todoId, mapping);
|
|
79
|
+
this.bdMappings.set(bdId, todoId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getByTodoId(todoId: number): IdMapping | undefined {
|
|
83
|
+
return this.mappings.get(todoId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getByBdId(bdId: string): IdMapping | undefined {
|
|
87
|
+
const todoId = this.bdMappings.get(bdId);
|
|
88
|
+
return todoId !== undefined ? this.mappings.get(todoId) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hasTodoId(todoId: number): boolean {
|
|
92
|
+
return this.mappings.has(todoId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hasBdId(bdId: string): boolean {
|
|
96
|
+
return this.bdMappings.has(bdId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
updateLastSync(todoId: number): void {
|
|
100
|
+
const mapping = this.mappings.get(todoId);
|
|
101
|
+
if (mapping) {
|
|
102
|
+
mapping.lastSync = Date.now();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
delete(todoId: number): void {
|
|
107
|
+
const mapping = this.mappings.get(todoId);
|
|
108
|
+
if (mapping) {
|
|
109
|
+
this.bdMappings.delete(mapping.bdId);
|
|
110
|
+
this.mappings.delete(todoId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getAll(): IdMapping[] {
|
|
115
|
+
return Array.from(this.mappings.values());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clear(): void {
|
|
119
|
+
this.mappings.clear();
|
|
120
|
+
this.bdMappings.clear();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Status mapping between todo and bd
|
|
125
|
+
export const STATUS_MAP: Record<TodoStatus, BdStatus> = {
|
|
126
|
+
pending: "open",
|
|
127
|
+
in_progress: "in_progress",
|
|
128
|
+
completed: "closed",
|
|
129
|
+
deleted: "closed",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const BD_STATUS_MAP: Record<BdStatus, TodoStatus> = {
|
|
133
|
+
open: "pending",
|
|
134
|
+
in_progress: "in_progress",
|
|
135
|
+
closed: "completed",
|
|
136
|
+
blocked: "pending",
|
|
137
|
+
};
|