todo-enforcer 1.0.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/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +53 -0
- package/src/conditions.ts +79 -0
- package/src/config.ts +592 -0
- package/src/external-caller.ts +219 -0
- package/src/index.ts +1022 -0
- package/src/lib/hooks-manager.ts +207 -0
- package/src/lib/plugin-logger.ts +155 -0
- package/src/lib/types.ts +59 -0
- package/src/message-stall.ts +188 -0
- package/src/session-state.ts +395 -0
- package/src/todo-snapshot.ts +288 -0
- package/src/type-guards.ts +105 -0
- package/todo-enforcer.example.json +52 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo-enforcer config loader
|
|
3
|
+
*
|
|
4
|
+
* Loads and merges todo-enforcer configuration from:
|
|
5
|
+
* 1. ~/.todo-enforcer.json (global defaults)
|
|
6
|
+
* 2. <cwd>/.todo-enforcer.json (project overrides, higher priority)
|
|
7
|
+
*
|
|
8
|
+
* The project file deep-merges onto the global file.
|
|
9
|
+
* Missing files are silently skipped.
|
|
10
|
+
*/
|
|
11
|
+
// @ts-nocheck
|
|
12
|
+
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, constants } from "node:fs";
|
|
17
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { createPluginLogger } from "./lib/plugin-logger";
|
|
22
|
+
|
|
23
|
+
const logger = createPluginLogger("todo-enforcer");
|
|
24
|
+
|
|
25
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A single rule that the enforcer evaluates on agent_end.
|
|
29
|
+
*
|
|
30
|
+
* Rules are evaluated in order. The FIRST rule whose `condition` matches
|
|
31
|
+
* wins — no further rules are evaluated.
|
|
32
|
+
*/
|
|
33
|
+
export interface EnforcerRule {
|
|
34
|
+
/** Human-readable name for logging. */
|
|
35
|
+
name: string;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Condition to match. Evaluated against the todo snapshot.
|
|
39
|
+
* Built-in conditions:
|
|
40
|
+
* - "has_incomplete" — any pending/in_progress tasks remain
|
|
41
|
+
* - "all_complete" — every non-deleted task is completed
|
|
42
|
+
* - "has_in_progress" — at least one task is in_progress
|
|
43
|
+
* - "none" — never matches (disabled rule)
|
|
44
|
+
* - "always" — always matches
|
|
45
|
+
*
|
|
46
|
+
* Custom conditions are registered via the `conditions` map in config.
|
|
47
|
+
*/
|
|
48
|
+
condition: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How to handle the match:
|
|
52
|
+
* - "prompt" — inject a static prompt string into the TUI
|
|
53
|
+
* - "external" — call an external command/function and inject its output
|
|
54
|
+
* - "noop" — do nothing (useful for logging / future hooks)
|
|
55
|
+
*/
|
|
56
|
+
action: "prompt" | "external" | "noop" | "spawn";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* For action="prompt": the message to inject.
|
|
60
|
+
* Supports {{variables}}:
|
|
61
|
+
* {{incomplete_count}} — number of incomplete tasks
|
|
62
|
+
* {{completed_count}} — number of completed tasks
|
|
63
|
+
* {{total_count}} — total non-deleted tasks
|
|
64
|
+
* {{incomplete_list}} — "- [status] #id subject" per incomplete task
|
|
65
|
+
* {{completed_list}} — "- [completed] #id subject" per completed task
|
|
66
|
+
* {{session_summary}} — session file path or identifier
|
|
67
|
+
*/
|
|
68
|
+
prompt?: string;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* For action="external": command configuration.
|
|
72
|
+
*/
|
|
73
|
+
external?: ExternalCallConfig;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* For action="spawn": spawn a pi -p session to generate continuation guidance.
|
|
77
|
+
* Non-blocking — runs in background and delivers output when complete.
|
|
78
|
+
*/
|
|
79
|
+
spawn?: SpawnConfig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DeliveryMode = "followUp" | "steer";
|
|
83
|
+
export type AssistantContextMode = "mostRecent" | "allSinceLatestUser";
|
|
84
|
+
export type UserContextMode = "latest";
|
|
85
|
+
|
|
86
|
+
/** How the enforcer delivers its message to the agent. */
|
|
87
|
+
export type MessageMode =
|
|
88
|
+
| "userMessage" // pi.sendUserMessage(text, opts) — appears as user message, simplest
|
|
89
|
+
| "customMessage"; // pi.sendMessage({ customType, content, display }, opts) — structured custom-type
|
|
90
|
+
|
|
91
|
+
export interface SessionContext {
|
|
92
|
+
latestUserMessage: string;
|
|
93
|
+
assistantMessages: string;
|
|
94
|
+
allMessagesSinceLatestUser: string;
|
|
95
|
+
sessionMetadata: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MessageDeliveryConfig {
|
|
99
|
+
/** Delivery mode: "userMessage" (default) or "customMessage". */
|
|
100
|
+
mode?: MessageMode;
|
|
101
|
+
|
|
102
|
+
/** For customMessage mode: the customType field. Default: "todo-enforcer". */
|
|
103
|
+
customType?: string;
|
|
104
|
+
|
|
105
|
+
/** For customMessage mode: whether the message is visible in TUI. Default: true. */
|
|
106
|
+
display?: boolean;
|
|
107
|
+
|
|
108
|
+
/** Whether to trigger a new agent turn when session is idle. Default: true. */
|
|
109
|
+
triggerTurn?: boolean;
|
|
110
|
+
|
|
111
|
+
/** How to deliver: "followUp" (after current turn) or "steer". Default: "followUp". */
|
|
112
|
+
deliverAs?: DeliveryMode;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ContextFeedConfig {
|
|
116
|
+
userMode?: UserContextMode;
|
|
117
|
+
assistantMode?: AssistantContextMode;
|
|
118
|
+
includeSessionMetadata?: boolean;
|
|
119
|
+
excludePreviousEnforcerMessages?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ExternalHttpConfig {
|
|
123
|
+
url: string;
|
|
124
|
+
method?: string;
|
|
125
|
+
headers?: Record<string, string>;
|
|
126
|
+
timeoutMs?: number;
|
|
127
|
+
silent?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ErrorFallbackMode = "skip" | "default_prompt";
|
|
131
|
+
|
|
132
|
+
export interface ExternalCallConfig {
|
|
133
|
+
/**
|
|
134
|
+
* Command to execute. Array of args.
|
|
135
|
+
* The command receives the full session context as JSON on stdin.
|
|
136
|
+
* Environment variables:
|
|
137
|
+
* TODO_INCOMPLETE_COUNT, TODO_COMPLETED_COUNT, TODO_TOTAL_COUNT
|
|
138
|
+
*/
|
|
139
|
+
command?: string[];
|
|
140
|
+
|
|
141
|
+
/** Optional HTTP endpoint to receive the session payload as JSON. */
|
|
142
|
+
http?: ExternalHttpConfig;
|
|
143
|
+
|
|
144
|
+
/** Timeout in ms. Default: 15000. */
|
|
145
|
+
timeoutMs?: number;
|
|
146
|
+
|
|
147
|
+
/** If true, errors from the external call are silently ignored. Default: true. */
|
|
148
|
+
silent?: boolean;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What to do when the external call fails.
|
|
152
|
+
* - "skip": do nothing.
|
|
153
|
+
* - "default_prompt": fall back to the configured default prompt rule.
|
|
154
|
+
*/
|
|
155
|
+
errorFallback?: ErrorFallbackMode;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SpawnConfig {
|
|
159
|
+
/** Template for the pi -p prompt. Supports {{variable}} interpolation. */
|
|
160
|
+
template: string;
|
|
161
|
+
/** Timeout in ms. Default: 7200000 (2 hours). */
|
|
162
|
+
timeoutMs?: number;
|
|
163
|
+
/** Working directory for the pi process. Default: ctx.cwd. */
|
|
164
|
+
cwd?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface BackoffConfig {
|
|
168
|
+
/** Master enable/disable for backoff. Default: true. */
|
|
169
|
+
enabled?: boolean;
|
|
170
|
+
|
|
171
|
+
/** Multiplier for cooldown (e.g., 2 = double each time). Default: 2. */
|
|
172
|
+
factor?: number;
|
|
173
|
+
|
|
174
|
+
/** Maximum cooldown in ms. Default: 3600000 (1 hour). */
|
|
175
|
+
maxDelayMs?: number;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Regex patterns to detect errors in the injected message content.
|
|
179
|
+
* If a pattern matches, backoff is triggered/incremented.
|
|
180
|
+
*/
|
|
181
|
+
errorPatterns?: string[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface TodoEnforcerConfig {
|
|
185
|
+
/** Master enable/disable. Default: true. */
|
|
186
|
+
enabled?: boolean;
|
|
187
|
+
|
|
188
|
+
/** Maximum injections per session before giving up. Default: 5. */
|
|
189
|
+
maxInjections?: number;
|
|
190
|
+
|
|
191
|
+
/** Cooldown between injections in ms. Default: 60000 (1 min). */
|
|
192
|
+
cooldownMs?: number;
|
|
193
|
+
|
|
194
|
+
/** Exponential backoff settings. */
|
|
195
|
+
backoff?: BackoffConfig;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Ordered list of rules. First match wins.
|
|
199
|
+
* If no rule matches, nothing is injected.
|
|
200
|
+
*/
|
|
201
|
+
rules: EnforcerRule[];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Custom condition functions (key = condition name, value = description).
|
|
205
|
+
* The actual condition logic lives in the extension — this map just
|
|
206
|
+
* declares which custom conditions exist for validation/logging.
|
|
207
|
+
*/
|
|
208
|
+
conditions?: Record<string, string>;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether to track and report stagnation (same incomplete count across
|
|
212
|
+
* multiple agent_end events). Default: true.
|
|
213
|
+
*/
|
|
214
|
+
detectStagnation?: boolean;
|
|
215
|
+
|
|
216
|
+
/** Stagnation threshold: N consecutive idle events with no progress. Default: 3. */
|
|
217
|
+
stagnationThreshold?: number;
|
|
218
|
+
|
|
219
|
+
/** Message delivery configuration for injected reminders. */
|
|
220
|
+
messageDelivery?: MessageDeliveryConfig;
|
|
221
|
+
|
|
222
|
+
/** Controls what session context is fed into prompts and external calls. */
|
|
223
|
+
contextFeed?: ContextFeedConfig;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Whether to deliver the completion summary when all tasks are done.
|
|
227
|
+
* When false (default), the all-complete rule is suppressed — no message
|
|
228
|
+
* is sent after every task finishes. Set to true to re-enable the summary.
|
|
229
|
+
*/
|
|
230
|
+
completionSummary?: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export const DEFAULT_CONFIG: TodoEnforcerConfig = {
|
|
236
|
+
enabled: true,
|
|
237
|
+
maxInjections: 5,
|
|
238
|
+
cooldownMs: 5_000,
|
|
239
|
+
backoff: {
|
|
240
|
+
enabled: true,
|
|
241
|
+
factor: 2,
|
|
242
|
+
maxDelayMs: 3_600_000,
|
|
243
|
+
errorPatterns: [
|
|
244
|
+
"429",
|
|
245
|
+
"rate limit",
|
|
246
|
+
"No deployments available",
|
|
247
|
+
"Try again in",
|
|
248
|
+
"Retry failed after",
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
detectStagnation: true,
|
|
252
|
+
stagnationThreshold: 3,
|
|
253
|
+
messageDelivery: {
|
|
254
|
+
mode: "userMessage",
|
|
255
|
+
customType: "todo-enforcer",
|
|
256
|
+
display: true,
|
|
257
|
+
triggerTurn: true,
|
|
258
|
+
deliverAs: "followUp",
|
|
259
|
+
},
|
|
260
|
+
completionSummary: false,
|
|
261
|
+
contextFeed: {
|
|
262
|
+
userMode: "latest",
|
|
263
|
+
assistantMode: "allSinceLatestUser",
|
|
264
|
+
includeSessionMetadata: true,
|
|
265
|
+
excludePreviousEnforcerMessages: true,
|
|
266
|
+
},
|
|
267
|
+
rules: [
|
|
268
|
+
{
|
|
269
|
+
name: "incomplete-tasks-remain",
|
|
270
|
+
condition: "has_incomplete",
|
|
271
|
+
action: "prompt",
|
|
272
|
+
prompt: `You have incomplete tasks. Continue working on them.
|
|
273
|
+
|
|
274
|
+
[Status: {{completed_count}}/{{total_count}} completed, {{incomplete_count}} remaining]
|
|
275
|
+
|
|
276
|
+
Remaining tasks:
|
|
277
|
+
{{incomplete_list}}
|
|
278
|
+
|
|
279
|
+
Latest user message:
|
|
280
|
+
{{latest_user_message}}
|
|
281
|
+
|
|
282
|
+
Recent assistant messages:
|
|
283
|
+
{{assistant_messages}}
|
|
284
|
+
|
|
285
|
+
Pick up where you left off. Do NOT stop until all tasks are completed or explicitly blocked.`,
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: "all-complete-celebration",
|
|
289
|
+
condition: "all_complete",
|
|
290
|
+
action: "prompt",
|
|
291
|
+
prompt: `All {{total_count}} tasks are complete. Great work.
|
|
292
|
+
|
|
293
|
+
Completed tasks:
|
|
294
|
+
{{completed_list}}
|
|
295
|
+
|
|
296
|
+
You may now summarize the results or ask the user for next steps.`,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// ─── Deep merge ──────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
function deepMerge(base: any, override: any): any {
|
|
305
|
+
const result = { ...base };
|
|
306
|
+
for (const key of Object.keys(override)) {
|
|
307
|
+
const baseVal = base[key];
|
|
308
|
+
const overVal = override[key];
|
|
309
|
+
if (
|
|
310
|
+
baseVal &&
|
|
311
|
+
overVal &&
|
|
312
|
+
typeof baseVal === "object" &&
|
|
313
|
+
typeof overVal === "object" &&
|
|
314
|
+
!Array.isArray(baseVal) &&
|
|
315
|
+
!Array.isArray(overVal)
|
|
316
|
+
) {
|
|
317
|
+
result[key] = deepMerge(baseVal, overVal);
|
|
318
|
+
} else {
|
|
319
|
+
result[key] = overVal;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Config loader ───────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
export function tryParseJson(raw: string): Record<string, unknown> | null {
|
|
328
|
+
const stripped = raw.trim().replace(/^\s*\/\/.*$/gm, "");
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(stripped);
|
|
331
|
+
if (parsed && typeof parsed === "object") {
|
|
332
|
+
return parsed as Record<string, unknown>;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
337
|
+
logger.error("JSON parse failed", { error: error.message });
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function loadJsonFile(filePath: string): Record<string, unknown> | null {
|
|
343
|
+
try {
|
|
344
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
345
|
+
return tryParseJson(raw);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
348
|
+
if ("code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
logger.error("Failed to read config file", { filePath, error: error.message });
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function loadJsonFileAsync(
|
|
357
|
+
filePath: string,
|
|
358
|
+
): Promise<Record<string, unknown> | null> {
|
|
359
|
+
try {
|
|
360
|
+
const raw = await readFile(filePath, "utf-8");
|
|
361
|
+
return tryParseJson(raw);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
364
|
+
if ("code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
logger.error("Failed to read config file", { filePath, error: error.message });
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function mergeConfigLayers(
|
|
373
|
+
globalCfg?: Partial<TodoEnforcerConfig> | null,
|
|
374
|
+
projectCfg?: Partial<TodoEnforcerConfig> | null,
|
|
375
|
+
): TodoEnforcerConfig {
|
|
376
|
+
let merged = { ...DEFAULT_CONFIG } as TodoEnforcerConfig;
|
|
377
|
+
|
|
378
|
+
if (globalCfg) {
|
|
379
|
+
merged = deepMerge(merged, globalCfg as TodoEnforcerConfig);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (projectCfg) {
|
|
383
|
+
merged = deepMerge(merged, projectCfg as TodoEnforcerConfig);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!Array.isArray(merged.rules) || merged.rules.length === 0) {
|
|
387
|
+
logger.warn("No rules defined — using defaults");
|
|
388
|
+
merged.rules = DEFAULT_CONFIG.rules;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
merged.messageDelivery = deepMerge(
|
|
392
|
+
DEFAULT_CONFIG.messageDelivery,
|
|
393
|
+
merged.messageDelivery ?? {},
|
|
394
|
+
);
|
|
395
|
+
merged.contextFeed = deepMerge(
|
|
396
|
+
DEFAULT_CONFIG.contextFeed,
|
|
397
|
+
merged.contextFeed ?? {},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return merged;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── First-launch initialization ────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sync version of ensureGlobalConfig — used only by the sync loadConfig() path.
|
|
407
|
+
* If the global config file (~/.todo-enforcer.json) does not exist,
|
|
408
|
+
* create it with all default values so the user can see and edit them.
|
|
409
|
+
* Only writes if the file is missing — never overwrites.
|
|
410
|
+
*/
|
|
411
|
+
function ensureGlobalConfigSync(globalPath: string): void {
|
|
412
|
+
try {
|
|
413
|
+
readFileSync(globalPath, "utf-8");
|
|
414
|
+
return; // file exists
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
417
|
+
if ("code" in error && (error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
418
|
+
logger.error("Failed to check config existence (sync)", { globalPath, error: error.message });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// file doesn't exist — proceed to create
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
const defaults = DEFAULT_CONFIG;
|
|
425
|
+
const output: Record<string, unknown> = {
|
|
426
|
+
"// delivery mode":
|
|
427
|
+
"userMessage = pi.sendUserMessage() (default, simplest) | customMessage = pi.sendMessage() with customType",
|
|
428
|
+
enabled: defaults.enabled,
|
|
429
|
+
maxInjections: defaults.maxInjections,
|
|
430
|
+
cooldownMs: defaults.cooldownMs,
|
|
431
|
+
completionSummary: defaults.completionSummary ?? false,
|
|
432
|
+
backoff: defaults.backoff,
|
|
433
|
+
detectStagnation: defaults.detectStagnation,
|
|
434
|
+
stagnationThreshold: defaults.stagnationThreshold,
|
|
435
|
+
messageDelivery: {
|
|
436
|
+
mode: defaults.messageDelivery?.mode ?? "userMessage",
|
|
437
|
+
customType: defaults.messageDelivery?.customType ?? "todo-enforcer",
|
|
438
|
+
display: defaults.messageDelivery?.display ?? true,
|
|
439
|
+
triggerTurn: defaults.messageDelivery?.triggerTurn ?? true,
|
|
440
|
+
deliverAs: defaults.messageDelivery?.deliverAs ?? "followUp",
|
|
441
|
+
},
|
|
442
|
+
contextFeed: defaults.contextFeed,
|
|
443
|
+
rules: defaults.rules?.map((r) => ({
|
|
444
|
+
name: r.name,
|
|
445
|
+
condition: r.condition,
|
|
446
|
+
action: r.action,
|
|
447
|
+
...(r.prompt ? { prompt: r.prompt } : {}),
|
|
448
|
+
...(r.external ? { external: r.external } : {}),
|
|
449
|
+
})),
|
|
450
|
+
};
|
|
451
|
+
writeFileSync(
|
|
452
|
+
globalPath,
|
|
453
|
+
JSON.stringify(output, null, "\t") + "\n",
|
|
454
|
+
"utf-8",
|
|
455
|
+
);
|
|
456
|
+
logger.info("Created default config (sync)", { globalPath });
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// Non-blocking — config will use in-memory defaults.
|
|
459
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
460
|
+
logger.error("Failed to write default config (sync)", { globalPath, error: error.message });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* If the global config file (~/.todo-enforcer.json) does not exist,
|
|
466
|
+
* create it with all default values so the user can see and edit them.
|
|
467
|
+
* Only writes if the file is missing — never overwrites.
|
|
468
|
+
*/
|
|
469
|
+
async function ensureGlobalConfig(globalPath: string): Promise<void> {
|
|
470
|
+
try {
|
|
471
|
+
await access(globalPath, constants.F_OK);
|
|
472
|
+
return; // file exists
|
|
473
|
+
} catch (err) {
|
|
474
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
475
|
+
if ("code" in error && (error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
476
|
+
logger.error("Failed to check config existence", { globalPath, error: error.message });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// file doesn't exist — proceed to create
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
const defaults = DEFAULT_CONFIG;
|
|
483
|
+
const output: Record<string, unknown> = {
|
|
484
|
+
"// delivery mode":
|
|
485
|
+
"userMessage = pi.sendUserMessage() (default, simplest) | customMessage = pi.sendMessage() with customType",
|
|
486
|
+
enabled: defaults.enabled,
|
|
487
|
+
maxInjections: defaults.maxInjections,
|
|
488
|
+
cooldownMs: defaults.cooldownMs,
|
|
489
|
+
completionSummary: defaults.completionSummary ?? false,
|
|
490
|
+
backoff: defaults.backoff,
|
|
491
|
+
detectStagnation: defaults.detectStagnation,
|
|
492
|
+
stagnationThreshold: defaults.stagnationThreshold,
|
|
493
|
+
messageDelivery: {
|
|
494
|
+
mode: defaults.messageDelivery?.mode ?? "userMessage",
|
|
495
|
+
customType: defaults.messageDelivery?.customType ?? "todo-enforcer",
|
|
496
|
+
display: defaults.messageDelivery?.display ?? true,
|
|
497
|
+
triggerTurn: defaults.messageDelivery?.triggerTurn ?? true,
|
|
498
|
+
deliverAs: defaults.messageDelivery?.deliverAs ?? "followUp",
|
|
499
|
+
},
|
|
500
|
+
contextFeed: defaults.contextFeed,
|
|
501
|
+
rules: defaults.rules?.map((r) => ({
|
|
502
|
+
name: r.name,
|
|
503
|
+
condition: r.condition,
|
|
504
|
+
action: r.action,
|
|
505
|
+
...(r.prompt ? { prompt: r.prompt } : {}),
|
|
506
|
+
...(r.external ? { external: r.external } : {}),
|
|
507
|
+
})),
|
|
508
|
+
};
|
|
509
|
+
await writeFile(
|
|
510
|
+
globalPath,
|
|
511
|
+
JSON.stringify(output, null, "\t") + "\n",
|
|
512
|
+
"utf-8",
|
|
513
|
+
);
|
|
514
|
+
logger.info("Created default config", { globalPath });
|
|
515
|
+
} catch (err) {
|
|
516
|
+
// Non-blocking — config will use in-memory defaults.
|
|
517
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
518
|
+
logger.error("Failed to write default config", { globalPath, error: error.message });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function loadConfigAsync(
|
|
523
|
+
cwd: string,
|
|
524
|
+
): Promise<TodoEnforcerConfig> {
|
|
525
|
+
const globalPath = resolve(homedir(), ".todo-enforcer.json");
|
|
526
|
+
|
|
527
|
+
// Ensure global config exists on first launch
|
|
528
|
+
await ensureGlobalConfig(globalPath);
|
|
529
|
+
|
|
530
|
+
const projectPath = resolve(cwd, ".todo-enforcer.json");
|
|
531
|
+
|
|
532
|
+
const [globalCfg, projectCfg] = await Promise.all([
|
|
533
|
+
loadJsonFileAsync(
|
|
534
|
+
globalPath,
|
|
535
|
+
) as Promise<Partial<TodoEnforcerConfig> | null>,
|
|
536
|
+
loadJsonFileAsync(
|
|
537
|
+
projectPath,
|
|
538
|
+
) as Promise<Partial<TodoEnforcerConfig> | null>,
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
return mergeConfigLayers(globalCfg, projectCfg);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function loadConfig(cwd: string): TodoEnforcerConfig {
|
|
545
|
+
const globalPath = resolve(homedir(), ".todo-enforcer.json");
|
|
546
|
+
|
|
547
|
+
// Ensure global config exists on first launch
|
|
548
|
+
ensureGlobalConfigSync(globalPath);
|
|
549
|
+
|
|
550
|
+
const projectPath = resolve(cwd, ".todo-enforcer.json");
|
|
551
|
+
|
|
552
|
+
const globalCfg = loadJsonFile(
|
|
553
|
+
globalPath,
|
|
554
|
+
) as Partial<TodoEnforcerConfig> | null;
|
|
555
|
+
const projectCfg = loadJsonFile(
|
|
556
|
+
projectPath,
|
|
557
|
+
) as Partial<TodoEnforcerConfig> | null;
|
|
558
|
+
|
|
559
|
+
return mergeConfigLayers(globalCfg, projectCfg);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ─── Template interpolation ──────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
export interface TodoSnapshot extends SessionContext {
|
|
565
|
+
incompleteCount: number;
|
|
566
|
+
inProgressCount: number;
|
|
567
|
+
completedCount: number;
|
|
568
|
+
totalCount: number;
|
|
569
|
+
incompleteList: string;
|
|
570
|
+
completedList: string;
|
|
571
|
+
sessionSummary: string;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function interpolateTemplate(
|
|
575
|
+
template: string,
|
|
576
|
+
snapshot: TodoSnapshot,
|
|
577
|
+
): string {
|
|
578
|
+
return template
|
|
579
|
+
.replace(/\{\{incomplete_count\}\}/g, String(snapshot.incompleteCount))
|
|
580
|
+
.replace(/\{\{completed_count\}\}/g, String(snapshot.completedCount))
|
|
581
|
+
.replace(/\{\{total_count\}\}/g, String(snapshot.totalCount))
|
|
582
|
+
.replace(/\{\{incomplete_list\}\}/g, snapshot.incompleteList)
|
|
583
|
+
.replace(/\{\{completed_list\}\}/g, snapshot.completedList)
|
|
584
|
+
.replace(/\{\{session_summary\}\}/g, snapshot.sessionSummary)
|
|
585
|
+
.replace(/\{\{latest_user_message\}\}/g, snapshot.latestUserMessage)
|
|
586
|
+
.replace(/\{\{assistant_messages\}\}/g, snapshot.assistantMessages)
|
|
587
|
+
.replace(
|
|
588
|
+
/\{\{all_messages_since_latest_user\}\}/g,
|
|
589
|
+
snapshot.allMessagesSinceLatestUser,
|
|
590
|
+
)
|
|
591
|
+
.replace(/\{\{session_metadata\}\}/g, snapshot.sessionMetadata);
|
|
592
|
+
}
|