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/index.ts
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo-enforcer — pi extension
|
|
3
|
+
*
|
|
4
|
+
* Monitors the agent's todo state on each agent_end. When the agent goes idle
|
|
5
|
+
* with incomplete tasks, evaluates a configurable rule set and injects a
|
|
6
|
+
* message to keep the agent working. Works in both TUI and non-TUI (headless) modes.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by oh-my-opencode's "Todo Continuation Enforcer" hook.
|
|
9
|
+
*
|
|
10
|
+
* Configuration (todo-enforcer.json):
|
|
11
|
+
* - ~/.todo-enforcer.json (global)
|
|
12
|
+
* - <cwd>/.todo-enforcer.json (project override)
|
|
13
|
+
*
|
|
14
|
+
* Delivery modes (config: messageDelivery.mode):
|
|
15
|
+
* - "userMessage" (default) — pi.sendUserMessage(text, opts)
|
|
16
|
+
* - "customMessage" — pi.sendMessage({ customType, content, display }, opts)
|
|
17
|
+
*
|
|
18
|
+
* Structure:
|
|
19
|
+
* todo-enforcer/
|
|
20
|
+
* ├── index.ts ← THIS FILE (entry point)
|
|
21
|
+
* ├── config.ts ← config loader + types + template interpolation
|
|
22
|
+
* ├── conditions.ts ← built-in + custom condition evaluator
|
|
23
|
+
* ├── external-caller.ts ← external command executor
|
|
24
|
+
* ├── session-state.ts ← per-session state tracking
|
|
25
|
+
* └── todo-snapshot.ts ← reads rpiv-todo state
|
|
26
|
+
*
|
|
27
|
+
* @see flow/requirements/todo-enforcer.md
|
|
28
|
+
*/
|
|
29
|
+
// @ts-nocheck
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
35
|
+
import { createPluginLogger } from "./lib/plugin-logger";
|
|
36
|
+
import { registerHook, isEnabled } from "./lib/hooks-manager";
|
|
37
|
+
import { evaluateCondition } from "./conditions";
|
|
38
|
+
import type {
|
|
39
|
+
EnforcerRule,
|
|
40
|
+
MessageDeliveryConfig,
|
|
41
|
+
SessionContext,
|
|
42
|
+
SpawnConfig,
|
|
43
|
+
TodoEnforcerConfig,
|
|
44
|
+
TodoSnapshot,
|
|
45
|
+
} from "./config";
|
|
46
|
+
import { DEFAULT_CONFIG, interpolateTemplate, loadConfigAsync } from "./config";
|
|
47
|
+
import { dummyExternalCall, executeExternalCall } from "./external-caller";
|
|
48
|
+
import {
|
|
49
|
+
checkSimilarError,
|
|
50
|
+
clearSessionIdentity,
|
|
51
|
+
setSessionState,
|
|
52
|
+
getCachedBranch,
|
|
53
|
+
setCachedBranch,
|
|
54
|
+
getCachedSessionId,
|
|
55
|
+
getState,
|
|
56
|
+
hasProgress,
|
|
57
|
+
incrementBackoff,
|
|
58
|
+
isCooldownElapsed,
|
|
59
|
+
isUnderLimit,
|
|
60
|
+
markCancelled,
|
|
61
|
+
markEvaluating,
|
|
62
|
+
markInFlight,
|
|
63
|
+
markInjection,
|
|
64
|
+
recordBranchLength,
|
|
65
|
+
resetBackoff,
|
|
66
|
+
resetConsecutive,
|
|
67
|
+
resetErrorTracking,
|
|
68
|
+
resetStagnation,
|
|
69
|
+
resetState,
|
|
70
|
+
setSpawnInFlight,
|
|
71
|
+
trackStagnation,
|
|
72
|
+
} from "./session-state";
|
|
73
|
+
import { checkMessageStall, resetStallState } from "./message-stall";
|
|
74
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
75
|
+
import { type SessionEntry, buildSessionContext, buildTodoSnapshot } from "./todo-snapshot";
|
|
76
|
+
|
|
77
|
+
/** Default timeout for spawned pi child processes (2 hours) */
|
|
78
|
+
const DEFAULT_SPAWN_TIMEOUT_MS = 7_200_000;
|
|
79
|
+
|
|
80
|
+
const HOOK_NAME = "todo-enforcer";
|
|
81
|
+
const logger = createPluginLogger(HOOK_NAME);
|
|
82
|
+
|
|
83
|
+
function safeWrap<T>(label: string, fn: () => T): T | null {
|
|
84
|
+
try {
|
|
85
|
+
return fn();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
+
logger.error(`${label}: ${message}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function safeWrapAsync<T>(
|
|
94
|
+
label: string,
|
|
95
|
+
fn: () => Promise<T>,
|
|
96
|
+
): Promise<T | null> {
|
|
97
|
+
try {
|
|
98
|
+
return await fn();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
logger.error(`${label}: ${message}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Command handlers use getCachedSessionId() — session identity is
|
|
107
|
+
// already captured in session_start before any command can run.
|
|
108
|
+
|
|
109
|
+
function getProgressBranchLength(branch: unknown[]): number {
|
|
110
|
+
return branch.filter((entry) => {
|
|
111
|
+
if (!entry || typeof entry !== "object") return true;
|
|
112
|
+
const message = (
|
|
113
|
+
entry as {
|
|
114
|
+
message?: { customType?: string; role?: string; toolName?: string; content?: unknown };
|
|
115
|
+
}
|
|
116
|
+
).message;
|
|
117
|
+
if (!message) return true;
|
|
118
|
+
// Exclude enforcer's own injected custom messages from progress measurement
|
|
119
|
+
if (message.customType === HOOK_NAME) return false;
|
|
120
|
+
// Exclude todo tool results (status queries, not real work)
|
|
121
|
+
if (message.role === "toolResult" && message.toolName === "todo") return false;
|
|
122
|
+
// Exclude user messages injected by the enforcer via sendUserMessage
|
|
123
|
+
if (message.role === "user" && typeof message.content === "string") {
|
|
124
|
+
const text = (message.content as string).substring(0, 200);
|
|
125
|
+
if (
|
|
126
|
+
text.includes("You have incomplete tasks. Continue working on them.") ||
|
|
127
|
+
text.includes("Pick up where you left off.")
|
|
128
|
+
) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}).length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function findMatchingRule(
|
|
135
|
+
rules: EnforcerRule[],
|
|
136
|
+
snapshot: TodoSnapshot,
|
|
137
|
+
): EnforcerRule | null {
|
|
138
|
+
for (const rule of rules) {
|
|
139
|
+
const matched = safeWrap(`condition "${rule.name}"`, () =>
|
|
140
|
+
evaluateCondition(rule.condition, snapshot),
|
|
141
|
+
);
|
|
142
|
+
if (matched) return rule;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function executeRule(
|
|
148
|
+
rule: EnforcerRule,
|
|
149
|
+
snapshot: TodoSnapshot,
|
|
150
|
+
context: SessionContext,
|
|
151
|
+
useDummyExternal: boolean,
|
|
152
|
+
): Promise<string | null> {
|
|
153
|
+
switch (rule.action) {
|
|
154
|
+
case "prompt": {
|
|
155
|
+
if (!rule.prompt) {
|
|
156
|
+
logger.warn(
|
|
157
|
+
`Rule "${rule.name}" has action=prompt but no prompt defined`,
|
|
158
|
+
);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return interpolateTemplate(rule.prompt, snapshot);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "external": {
|
|
165
|
+
if (!rule.external) {
|
|
166
|
+
logger.warn(
|
|
167
|
+
`Rule "${rule.name}" has action=external but no external config`,
|
|
168
|
+
);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const caller = useDummyExternal ? dummyExternalCall : executeExternalCall;
|
|
172
|
+
const result = await caller(rule.external, snapshot, context);
|
|
173
|
+
if (!result.success) {
|
|
174
|
+
const fallback = rule.external.errorFallback ?? "default_prompt";
|
|
175
|
+
if (fallback === "skip") {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// default_prompt: fall through — no matching fallback rule available
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return result.output;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "noop":
|
|
185
|
+
return null;
|
|
186
|
+
|
|
187
|
+
default: {
|
|
188
|
+
const _exhaustiveCheck: never = rule.action;
|
|
189
|
+
logger.warn(`Unknown action: ${String(_exhaustiveCheck)}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Deliver the enforcer message using the configured mode.
|
|
197
|
+
*
|
|
198
|
+
* - "userMessage" (default): uses pi.sendUserMessage() — appears as a user
|
|
199
|
+
* message in the conversation. Works in both TUI and non-TUI modes.
|
|
200
|
+
* - "customMessage": uses pi.sendMessage() with a structured customType —
|
|
201
|
+
* appears as a custom message with configurable display/triggerTurn/deliverAs.
|
|
202
|
+
*
|
|
203
|
+
* Both modes work in TUI and non-TUI. Neither gates on hasUI.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Deliver the enforcer message using the configured mode.
|
|
207
|
+
*
|
|
208
|
+
* CRITICAL: When called from agent_end, the agent is already idle.
|
|
209
|
+
* - userMessage mode: do NOT pass deliverAs — sendUserMessage without options
|
|
210
|
+
* triggers a new turn immediately. Passing deliverAs="followUp" causes the
|
|
211
|
+
* message to be queued for "after agent finishes" but agent already finished,
|
|
212
|
+
* so the message sits forever and no turn is triggered.
|
|
213
|
+
* - customMessage mode: use triggerTurn: true + deliverAs: "steer" to ensure
|
|
214
|
+
* the idle agent picks it up and starts a new turn.
|
|
215
|
+
*/
|
|
216
|
+
function deliverMessage(
|
|
217
|
+
pi: ExtensionAPI,
|
|
218
|
+
delivery: MessageDeliveryConfig,
|
|
219
|
+
message: string,
|
|
220
|
+
): void {
|
|
221
|
+
const mode = delivery.mode ?? "userMessage";
|
|
222
|
+
|
|
223
|
+
if (mode === "userMessage") {
|
|
224
|
+
// No deliverAs — let sendUserMessage trigger a turn immediately.
|
|
225
|
+
// This is the correct behavior when agent is idle at agent_end.
|
|
226
|
+
pi.sendUserMessage(message);
|
|
227
|
+
} else {
|
|
228
|
+
pi.sendMessage(
|
|
229
|
+
{
|
|
230
|
+
customType: delivery.customType ?? HOOK_NAME,
|
|
231
|
+
content: message,
|
|
232
|
+
display: delivery.display ?? true,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
triggerTurn: true,
|
|
236
|
+
deliverAs: "steer",
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default function (pi: ExtensionAPI) {
|
|
243
|
+
type ConfigCacheState = {
|
|
244
|
+
cwd: string | null;
|
|
245
|
+
value: TodoEnforcerConfig | null;
|
|
246
|
+
promise: Promise<TodoEnforcerConfig> | null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const configState: ConfigCacheState = {
|
|
250
|
+
cwd: null,
|
|
251
|
+
value: null,
|
|
252
|
+
promise: null,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
let config: TodoEnforcerConfig | null = null;
|
|
256
|
+
|
|
257
|
+
function startConfigLoad(cwd: string): Promise<TodoEnforcerConfig> {
|
|
258
|
+
if (configState.cwd === cwd && configState.value) {
|
|
259
|
+
return Promise.resolve(configState.value);
|
|
260
|
+
}
|
|
261
|
+
if (configState.cwd === cwd && configState.promise) {
|
|
262
|
+
return configState.promise;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
configState.cwd = cwd;
|
|
266
|
+
configState.value = null;
|
|
267
|
+
const promise = loadConfigAsync(cwd)
|
|
268
|
+
.then((loaded: TodoEnforcerConfig) => {
|
|
269
|
+
configState.value = loaded;
|
|
270
|
+
config = loaded;
|
|
271
|
+
logger.info("config-loaded", {
|
|
272
|
+
rules: loaded.rules.length,
|
|
273
|
+
maxInjections: loaded.maxInjections,
|
|
274
|
+
cooldownMs: loaded.cooldownMs,
|
|
275
|
+
logFile: logger.filePath,
|
|
276
|
+
});
|
|
277
|
+
return loaded;
|
|
278
|
+
})
|
|
279
|
+
.catch((error) => {
|
|
280
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
281
|
+
logger.error("config-load failed", { error: msg });
|
|
282
|
+
configState.value = null;
|
|
283
|
+
config = null;
|
|
284
|
+
return DEFAULT_CONFIG;
|
|
285
|
+
})
|
|
286
|
+
.finally(() => {
|
|
287
|
+
configState.promise = null;
|
|
288
|
+
});
|
|
289
|
+
configState.promise = promise;
|
|
290
|
+
return promise;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getConfigWhenNeeded(cwd: string): Promise<TodoEnforcerConfig> {
|
|
294
|
+
if (configState.cwd === cwd && configState.value) {
|
|
295
|
+
config = configState.value;
|
|
296
|
+
return Promise.resolve(configState.value);
|
|
297
|
+
}
|
|
298
|
+
return startConfigLoad(cwd);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Polling: timer-based re-evaluation after injection ────────────────
|
|
302
|
+
//
|
|
303
|
+
// After an injection, schedule a timer to re-check conditions once the
|
|
304
|
+
// cooldown expires. This ensures the enforcer can fire even if the agent
|
|
305
|
+
// goes idle without producing another agent_end event.
|
|
306
|
+
//
|
|
307
|
+
// Cancelled on: natural agent_end, session shutdown, all tasks done,
|
|
308
|
+
// max injections reached.
|
|
309
|
+
|
|
310
|
+
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
311
|
+
|
|
312
|
+
function cancelPoll(sessionId: string): void {
|
|
313
|
+
const existing = pollTimers.get(sessionId);
|
|
314
|
+
if (existing) {
|
|
315
|
+
clearTimeout(existing);
|
|
316
|
+
pollTimers.delete(sessionId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function schedulePoll(
|
|
321
|
+
sessionId: string,
|
|
322
|
+
cwd: string,
|
|
323
|
+
overrideDelayMs?: number,
|
|
324
|
+
): void {
|
|
325
|
+
cancelPoll(sessionId);
|
|
326
|
+
const baseDelay = config?.cooldownMs ?? 5_000;
|
|
327
|
+
const delay =
|
|
328
|
+
overrideDelayMs !== undefined ? overrideDelayMs : baseDelay + 1_000;
|
|
329
|
+
if (delay <= 0) {
|
|
330
|
+
void runPoll(sessionId, cwd);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const timer = setTimeout(() => {
|
|
334
|
+
pollTimers.delete(sessionId);
|
|
335
|
+
void runPoll(sessionId, cwd);
|
|
336
|
+
}, delay);
|
|
337
|
+
pollTimers.set(sessionId, timer);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function runPoll(sessionId: string, cwd: string): Promise<void> {
|
|
341
|
+
const state = getState(sessionId);
|
|
342
|
+
if (state.spawnInFlight || state.wasCancelled || state.isEvaluating) return;
|
|
343
|
+
|
|
344
|
+
const currentCfg = await getConfigWhenNeeded(cwd);
|
|
345
|
+
if (!currentCfg.enabled) return;
|
|
346
|
+
|
|
347
|
+
markEvaluating(sessionId, true);
|
|
348
|
+
try {
|
|
349
|
+
// Progress detection using cached branch
|
|
350
|
+
const branch = getCachedBranch();
|
|
351
|
+
const branchLength = Array.isArray(branch)
|
|
352
|
+
? getProgressBranchLength(branch)
|
|
353
|
+
: 0;
|
|
354
|
+
if (hasProgress(sessionId, branchLength)) {
|
|
355
|
+
resetConsecutive(sessionId);
|
|
356
|
+
resetErrorTracking(sessionId);
|
|
357
|
+
resetStagnation(sessionId);
|
|
358
|
+
}
|
|
359
|
+
recordBranchLength(sessionId, branchLength);
|
|
360
|
+
|
|
361
|
+
const injected = await pollEvaluate(sessionId, currentCfg, cwd);
|
|
362
|
+
if (injected) {
|
|
363
|
+
schedulePoll(sessionId, cwd);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
markEvaluating(sessionId, false);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Shared evaluation logic for poll timers (subset of agent_end). */
|
|
371
|
+
async function pollEvaluate(
|
|
372
|
+
sessionId: string,
|
|
373
|
+
cfg: TodoEnforcerConfig,
|
|
374
|
+
cwd: string,
|
|
375
|
+
): Promise<boolean> {
|
|
376
|
+
const state = getState(sessionId);
|
|
377
|
+
if (
|
|
378
|
+
state.spawnInFlight ||
|
|
379
|
+
state.isRecovering ||
|
|
380
|
+
state.wasCancelled ||
|
|
381
|
+
state.inFlight
|
|
382
|
+
) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
if (!isUnderLimit(sessionId, cfg.maxInjections ?? 5)) return false;
|
|
386
|
+
if (!isCooldownElapsed(sessionId, cfg.cooldownMs ?? 5_000, cfg.backoff))
|
|
387
|
+
return false;
|
|
388
|
+
|
|
389
|
+
const context = safeWrap("buildSessionContext", () =>
|
|
390
|
+
buildSessionContext(
|
|
391
|
+
sessionId,
|
|
392
|
+
cwd,
|
|
393
|
+
() => getCachedBranch() as SessionEntry[],
|
|
394
|
+
cfg.contextFeed ?? {},
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
if (!context) return false;
|
|
398
|
+
|
|
399
|
+
const snapshotResult = safeWrap("buildSnapshot", () =>
|
|
400
|
+
buildTodoSnapshot(sessionId, () => getCachedBranch() as SessionEntry[], context),
|
|
401
|
+
);
|
|
402
|
+
if (!snapshotResult || !snapshotResult.available) return false;
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
cfg.detectStagnation !== false &&
|
|
406
|
+
snapshotResult.snapshot.incompleteCount > 0
|
|
407
|
+
) {
|
|
408
|
+
const stagnant = trackStagnation(
|
|
409
|
+
sessionId,
|
|
410
|
+
snapshotResult.snapshot.incompleteCount,
|
|
411
|
+
cfg.stagnationThreshold ?? 3,
|
|
412
|
+
);
|
|
413
|
+
if (stagnant) return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let activeRules = getActiveRules(sessionId);
|
|
417
|
+
|
|
418
|
+
// Suppress all_complete rules when completionSummary is disabled (default)
|
|
419
|
+
if (!cfg.completionSummary) {
|
|
420
|
+
activeRules = activeRules.filter(
|
|
421
|
+
(rule) => rule.condition !== "all_complete",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const rule = findMatchingRule(activeRules, snapshotResult.snapshot);
|
|
426
|
+
if (!rule) return false;
|
|
427
|
+
|
|
428
|
+
logger.info("rule-matched (poll)", {
|
|
429
|
+
sessionId,
|
|
430
|
+
rule: rule.name,
|
|
431
|
+
action: rule.action,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Handle spawn action
|
|
435
|
+
if (rule.action === "spawn" && rule.spawn) {
|
|
436
|
+
setSpawnInFlight(sessionId, true);
|
|
437
|
+
markInjection(sessionId);
|
|
438
|
+
resetStagnation(sessionId);
|
|
439
|
+
executeSpawnAction(
|
|
440
|
+
rule.spawn,
|
|
441
|
+
snapshotResult.snapshot,
|
|
442
|
+
context,
|
|
443
|
+
sessionId,
|
|
444
|
+
cfg.messageDelivery ?? {},
|
|
445
|
+
cwd,
|
|
446
|
+
);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const message = await safeWrapAsync(`rule "${rule.name}"`, () =>
|
|
451
|
+
executeRule(rule, snapshotResult.snapshot, context, false),
|
|
452
|
+
);
|
|
453
|
+
if (!message) return false;
|
|
454
|
+
|
|
455
|
+
// ── Stall guard: repeated message + rate limit ───────────────────
|
|
456
|
+
const stallPoll = checkMessageStall(sessionId, message);
|
|
457
|
+
if (stallPoll.stalled) {
|
|
458
|
+
logger.warn("message-stalled (poll)", {
|
|
459
|
+
sessionId,
|
|
460
|
+
reason: stallPoll.reason,
|
|
461
|
+
rule: rule.name,
|
|
462
|
+
});
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
markInFlight(sessionId, true);
|
|
467
|
+
try {
|
|
468
|
+
deliverMessage(pi, cfg.messageDelivery ?? {}, message);
|
|
469
|
+
markInjection(sessionId);
|
|
470
|
+
resetStagnation(sessionId);
|
|
471
|
+
|
|
472
|
+
if (cfg.backoff?.enabled !== false) {
|
|
473
|
+
const matched = (cfg.backoff?.errorPatterns ?? []).some((p) =>
|
|
474
|
+
new RegExp(p, "i").test(message),
|
|
475
|
+
);
|
|
476
|
+
if (matched) incrementBackoff(sessionId);
|
|
477
|
+
else resetBackoff(sessionId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
logger.info("injection-delivered (poll)", {
|
|
481
|
+
sessionId,
|
|
482
|
+
injectionCount: getState(sessionId).injectionCount,
|
|
483
|
+
rule: rule.name,
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
487
|
+
logger.error(`Poll injection failed: ${msg}`, {
|
|
488
|
+
sessionId,
|
|
489
|
+
rule: rule.name,
|
|
490
|
+
});
|
|
491
|
+
} finally {
|
|
492
|
+
markInFlight(sessionId, false);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Spawn action: run pi -p in background ─────────────────────────────
|
|
499
|
+
|
|
500
|
+
function executeSpawnAction(
|
|
501
|
+
spawnConfig: SpawnConfig,
|
|
502
|
+
snapshot: TodoSnapshot,
|
|
503
|
+
_context: SessionContext,
|
|
504
|
+
sessionId: string,
|
|
505
|
+
delivery: MessageDeliveryConfig,
|
|
506
|
+
cwd: string,
|
|
507
|
+
): void {
|
|
508
|
+
const prompt = interpolateTemplate(spawnConfig.template, snapshot);
|
|
509
|
+
const timeout = spawnConfig.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS; // 2 hours
|
|
510
|
+
|
|
511
|
+
logger.info("spawn-started", {
|
|
512
|
+
sessionId,
|
|
513
|
+
timeout,
|
|
514
|
+
promptLength: prompt.length,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const child = spawnProcess("pi", ["-p", prompt], {
|
|
519
|
+
cwd: spawnConfig.cwd ?? cwd,
|
|
520
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
let stdout = "";
|
|
524
|
+
let stderr = "";
|
|
525
|
+
|
|
526
|
+
const killTimer = setTimeout(() => {
|
|
527
|
+
child.kill("SIGTERM");
|
|
528
|
+
logger.warn("spawn-timeout", { sessionId, timeout });
|
|
529
|
+
}, timeout);
|
|
530
|
+
|
|
531
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
532
|
+
stdout += data.toString();
|
|
533
|
+
});
|
|
534
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
535
|
+
stderr += data.toString();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
child.on("close", (code) => {
|
|
539
|
+
clearTimeout(killTimer);
|
|
540
|
+
setSpawnInFlight(sessionId, false);
|
|
541
|
+
|
|
542
|
+
if (code === 0 && stdout.trim()) {
|
|
543
|
+
logger.info("spawn-completed", {
|
|
544
|
+
sessionId,
|
|
545
|
+
outputLength: stdout.length,
|
|
546
|
+
});
|
|
547
|
+
deliverMessage(pi, delivery, stdout.trim());
|
|
548
|
+
schedulePoll(sessionId, cwd, 0);
|
|
549
|
+
} else {
|
|
550
|
+
logger.warn("spawn-failed", {
|
|
551
|
+
sessionId,
|
|
552
|
+
code,
|
|
553
|
+
stderr: stderr.substring(0, 500),
|
|
554
|
+
});
|
|
555
|
+
schedulePoll(sessionId, cwd);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
child.on("error", (err) => {
|
|
560
|
+
clearTimeout(killTimer);
|
|
561
|
+
setSpawnInFlight(sessionId, false);
|
|
562
|
+
logger.error("spawn-error", { sessionId, err: err.message });
|
|
563
|
+
schedulePoll(sessionId, cwd);
|
|
564
|
+
});
|
|
565
|
+
} catch (err) {
|
|
566
|
+
setSpawnInFlight(sessionId, false);
|
|
567
|
+
logger.error(
|
|
568
|
+
`spawn-exception: ${err instanceof Error ? err.message : String(err)}`,
|
|
569
|
+
{ sessionId },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
registerHook("todo-enforcer", "session_start", { blocking: false, source: "pi", origin: "global" });
|
|
575
|
+
registerHook("todo-enforcer", "session_shutdown", { blocking: false, source: "pi", origin: "global" });
|
|
576
|
+
registerHook("todo-enforcer", "agent_end", { blocking: false, source: "pi", origin: "global" });
|
|
577
|
+
|
|
578
|
+
pi.on("session_start", (_event, ctx) => {
|
|
579
|
+
if (!isEnabled("todo-enforcer", "session_start")) return;
|
|
580
|
+
|
|
581
|
+
// Extract cwd immediately — ctx becomes stale after session replacement.
|
|
582
|
+
const sessionCwd = ctx.cwd;
|
|
583
|
+
try {
|
|
584
|
+
// Capture session identity while ctx is fresh — all other hooks
|
|
585
|
+
// use the cached value to avoid stale-ctx crashes.
|
|
586
|
+
const sm = ctx.sessionManager;
|
|
587
|
+
setSessionState(sm.getSessionFile(), sm.getBranch());
|
|
588
|
+
const sessionId = getCachedSessionId();
|
|
589
|
+
resetState(sessionId);
|
|
590
|
+
resetStallState(sessionId);
|
|
591
|
+
void startConfigLoad(sessionCwd);
|
|
592
|
+
logger.info("session-start", { sessionId, cwd: sessionCwd });
|
|
593
|
+
} catch (error) {
|
|
594
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
595
|
+
logger.error("session_start error", { error: msg });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
pi.on("session_shutdown", () => {
|
|
600
|
+
if (!isEnabled("todo-enforcer", "session_shutdown")) return;
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const sessionId = getCachedSessionId();
|
|
604
|
+
cancelPoll(sessionId);
|
|
605
|
+
clearSessionIdentity();
|
|
606
|
+
sessionActiveRules.delete(sessionId);
|
|
607
|
+
// Individual state is cleaned on next session_start.
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
610
|
+
logger.error("session_shutdown error", { error: msg });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
615
|
+
if (!isEnabled("todo-enforcer", "agent_end")) return;
|
|
616
|
+
|
|
617
|
+
// Extract cwd immediately — ctx becomes stale after session replacement.
|
|
618
|
+
const sessionCwd = ctx.cwd;
|
|
619
|
+
const sm = ctx.sessionManager;
|
|
620
|
+
setCachedBranch(sm.getBranch());
|
|
621
|
+
const sessionId = getCachedSessionId();
|
|
622
|
+
const cfg = await getConfigWhenNeeded(sessionCwd);
|
|
623
|
+
|
|
624
|
+
if (!cfg.enabled) return;
|
|
625
|
+
|
|
626
|
+
const state = getState(sessionId);
|
|
627
|
+
if (state.isEvaluating) {
|
|
628
|
+
logger.debug("skipped", { sessionId, reason: "evaluation-in-flight" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
markEvaluating(sessionId, true);
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
// Detect user-initiated abort (Esc key) — suppress all future injections
|
|
635
|
+
if (
|
|
636
|
+
event.messages &&
|
|
637
|
+
Array.isArray(event.messages) &&
|
|
638
|
+
event.messages.length > 0
|
|
639
|
+
) {
|
|
640
|
+
const lastAssistant = [...event.messages]
|
|
641
|
+
.reverse()
|
|
642
|
+
.find(
|
|
643
|
+
(m): m is Extract<typeof m, { role: "assistant" }> =>
|
|
644
|
+
"role" in m && (m as { role?: string }).role === "assistant",
|
|
645
|
+
);
|
|
646
|
+
if (
|
|
647
|
+
lastAssistant &&
|
|
648
|
+
"stopReason" in lastAssistant &&
|
|
649
|
+
(lastAssistant as { stopReason?: string }).stopReason === "aborted"
|
|
650
|
+
) {
|
|
651
|
+
markCancelled(sessionId);
|
|
652
|
+
logger.info("agent-aborted", { sessionId, reason: "user-esc" });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Detect LLM errors (red line / quota) → fuzzy compare → backoff ──
|
|
658
|
+
if (
|
|
659
|
+
event.messages &&
|
|
660
|
+
Array.isArray(event.messages) &&
|
|
661
|
+
event.messages.length > 0
|
|
662
|
+
) {
|
|
663
|
+
const lastErr = [...event.messages]
|
|
664
|
+
.reverse()
|
|
665
|
+
.find(
|
|
666
|
+
(
|
|
667
|
+
m,
|
|
668
|
+
): m is Extract<
|
|
669
|
+
typeof m,
|
|
670
|
+
{ role: "assistant"; stopReason: string; errorMessage?: string }
|
|
671
|
+
> =>
|
|
672
|
+
"role" in m &&
|
|
673
|
+
(m as { role?: string }).role === "assistant" &&
|
|
674
|
+
"stopReason" in m &&
|
|
675
|
+
((m as { stopReason?: string }).stopReason === "error" ||
|
|
676
|
+
(m as { stopReason?: string }).stopReason === "aborted"),
|
|
677
|
+
);
|
|
678
|
+
if (lastErr?.errorMessage) {
|
|
679
|
+
const { isSimilar, similarCount } = checkSimilarError(
|
|
680
|
+
sessionId,
|
|
681
|
+
lastErr.errorMessage,
|
|
682
|
+
);
|
|
683
|
+
if (isSimilar) {
|
|
684
|
+
incrementBackoff(sessionId);
|
|
685
|
+
logger.warn("similar-llm-error", {
|
|
686
|
+
sessionId,
|
|
687
|
+
similarCount,
|
|
688
|
+
backoffCount: getState(sessionId).backoffCount,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Progress detection: reset consecutive count if meaningful branch grew ──
|
|
695
|
+
const branch = getCachedBranch();
|
|
696
|
+
const branchLength = Array.isArray(branch)
|
|
697
|
+
? getProgressBranchLength(branch)
|
|
698
|
+
: 0;
|
|
699
|
+
if (hasProgress(sessionId, branchLength)) {
|
|
700
|
+
logger.debug("progress-detected", { sessionId, branchLength });
|
|
701
|
+
resetConsecutive(sessionId);
|
|
702
|
+
resetErrorTracking(sessionId);
|
|
703
|
+
resetStagnation(sessionId);
|
|
704
|
+
}
|
|
705
|
+
recordBranchLength(sessionId, branchLength);
|
|
706
|
+
|
|
707
|
+
if (state.isRecovering) {
|
|
708
|
+
logger.debug("skipped", { sessionId, reason: "recovering" });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (state.wasCancelled) {
|
|
712
|
+
logger.debug("skipped", { sessionId, reason: "cancelled" });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (state.inFlight) {
|
|
716
|
+
logger.debug("skipped", { sessionId, reason: "in-flight" });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (!isUnderLimit(sessionId, cfg.maxInjections ?? 5)) {
|
|
721
|
+
logger.info("skipped", {
|
|
722
|
+
sessionId,
|
|
723
|
+
reason: "max-consecutive-injections",
|
|
724
|
+
consecutiveCount: state.consecutiveCount,
|
|
725
|
+
maxInjections: cfg.maxInjections ?? 5,
|
|
726
|
+
injectionCount: state.injectionCount,
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (
|
|
732
|
+
!isCooldownElapsed(sessionId, cfg.cooldownMs ?? 60_000, cfg.backoff)
|
|
733
|
+
) {
|
|
734
|
+
const s = getState(sessionId);
|
|
735
|
+
const delay = Math.min(
|
|
736
|
+
(cfg.cooldownMs ?? 60_000) *
|
|
737
|
+
(cfg.backoff?.factor ?? 2) ** s.backoffCount,
|
|
738
|
+
cfg.backoff?.maxDelayMs ?? 3_600_000,
|
|
739
|
+
);
|
|
740
|
+
const remaining = Math.ceil(
|
|
741
|
+
(delay - (Date.now() - (s.lastInjectedAt ?? 0))) / 1000,
|
|
742
|
+
);
|
|
743
|
+
logger.debug("skipped", {
|
|
744
|
+
sessionId,
|
|
745
|
+
reason: "cooldown-active",
|
|
746
|
+
remainingSeconds: remaining,
|
|
747
|
+
backoffCount: s.backoffCount,
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const context = safeWrap("buildSessionContext", () =>
|
|
753
|
+
buildSessionContext(
|
|
754
|
+
sessionId,
|
|
755
|
+
sessionCwd,
|
|
756
|
+
() => getCachedBranch() as SessionEntry[],
|
|
757
|
+
cfg.contextFeed ?? {},
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
if (!context) return;
|
|
761
|
+
|
|
762
|
+
const snapshotResult = safeWrap("buildSnapshot", () =>
|
|
763
|
+
buildTodoSnapshot(
|
|
764
|
+
sessionId,
|
|
765
|
+
() => getCachedBranch() as SessionEntry[],
|
|
766
|
+
context,
|
|
767
|
+
),
|
|
768
|
+
);
|
|
769
|
+
if (!snapshotResult || !snapshotResult.available) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (
|
|
774
|
+
cfg.detectStagnation !== false &&
|
|
775
|
+
snapshotResult.snapshot.incompleteCount > 0
|
|
776
|
+
) {
|
|
777
|
+
const threshold = cfg.stagnationThreshold ?? 3;
|
|
778
|
+
const stagnant = trackStagnation(
|
|
779
|
+
sessionId,
|
|
780
|
+
snapshotResult.snapshot.incompleteCount,
|
|
781
|
+
threshold,
|
|
782
|
+
);
|
|
783
|
+
if (stagnant) {
|
|
784
|
+
logger.info("stagnation-detected", {
|
|
785
|
+
sessionId,
|
|
786
|
+
incompleteCount: snapshotResult.snapshot.incompleteCount,
|
|
787
|
+
threshold: cfg.stagnationThreshold ?? 3,
|
|
788
|
+
});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let activeRules = getActiveRules(sessionId);
|
|
794
|
+
|
|
795
|
+
// Suppress all_complete rules when completionSummary is disabled (default)
|
|
796
|
+
if (!cfg.completionSummary) {
|
|
797
|
+
activeRules = activeRules.filter(
|
|
798
|
+
(rule) => rule.condition !== "all_complete",
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const rule = findMatchingRule(activeRules, snapshotResult.snapshot);
|
|
803
|
+
if (!rule) {
|
|
804
|
+
logger.debug("skipped", { sessionId, reason: "no-matching-rule" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
logger.info("rule-matched", {
|
|
809
|
+
sessionId,
|
|
810
|
+
rule: rule.name,
|
|
811
|
+
condition: rule.condition,
|
|
812
|
+
action: rule.action,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const message = await safeWrapAsync(`rule "${rule.name}"`, () =>
|
|
816
|
+
executeRule(rule, snapshotResult.snapshot, context, false),
|
|
817
|
+
);
|
|
818
|
+
if (!message) return;
|
|
819
|
+
|
|
820
|
+
// ── Stall guard: repeated message + rate limit ───────────────────
|
|
821
|
+
const stallAgent = checkMessageStall(sessionId, message);
|
|
822
|
+
if (stallAgent.stalled) {
|
|
823
|
+
logger.warn("message-stalled", {
|
|
824
|
+
sessionId,
|
|
825
|
+
reason: stallAgent.reason,
|
|
826
|
+
rule: rule.name,
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
markInFlight(sessionId, true);
|
|
832
|
+
try {
|
|
833
|
+
deliverMessage(pi, cfg.messageDelivery ?? {}, message);
|
|
834
|
+
|
|
835
|
+
markInjection(sessionId);
|
|
836
|
+
resetStagnation(sessionId);
|
|
837
|
+
|
|
838
|
+
if (cfg.backoff?.enabled !== false) {
|
|
839
|
+
const patterns = cfg.backoff?.errorPatterns ?? [];
|
|
840
|
+
const matched = patterns.some((pattern) =>
|
|
841
|
+
new RegExp(pattern, "i").test(message),
|
|
842
|
+
);
|
|
843
|
+
if (matched) {
|
|
844
|
+
incrementBackoff(sessionId);
|
|
845
|
+
logger.warn("output-error-pattern-matched", {
|
|
846
|
+
sessionId,
|
|
847
|
+
backoffCount: getState(sessionId).backoffCount,
|
|
848
|
+
});
|
|
849
|
+
} else {
|
|
850
|
+
resetBackoff(sessionId);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Schedule poll for re-evaluation after cooldown
|
|
855
|
+
schedulePoll(sessionId, sessionCwd);
|
|
856
|
+
|
|
857
|
+
logger.info("injection-delivered", {
|
|
858
|
+
sessionId,
|
|
859
|
+
injectionCount: state.injectionCount,
|
|
860
|
+
rule: rule.name,
|
|
861
|
+
});
|
|
862
|
+
} catch (err) {
|
|
863
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
864
|
+
logger.error(`Injection failed: ${msg}`, {
|
|
865
|
+
sessionId,
|
|
866
|
+
rule: rule.name,
|
|
867
|
+
});
|
|
868
|
+
} finally {
|
|
869
|
+
markInFlight(sessionId, false);
|
|
870
|
+
}
|
|
871
|
+
} finally {
|
|
872
|
+
markEvaluating(sessionId, false);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// No context hook needed — delivery is direct via sendUserMessage/sendMessage.
|
|
877
|
+
// Both APIs work in TUI and non-TUI modes.
|
|
878
|
+
|
|
879
|
+
const sessionActiveRules: Map<string, Set<string> | null> = new Map();
|
|
880
|
+
|
|
881
|
+
function getActiveRules(sessionId: string): EnforcerRule[] {
|
|
882
|
+
const override = sessionActiveRules.get(sessionId);
|
|
883
|
+
if (override === null) return config?.rules ?? [];
|
|
884
|
+
if (override === undefined) return config?.rules ?? [];
|
|
885
|
+
return (config?.rules ?? []).filter((rule) => override.has(rule.name));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
pi.registerCommand("enforcer-status", {
|
|
889
|
+
description:
|
|
890
|
+
"Show todo-enforcer state and loaded rules for current session",
|
|
891
|
+
handler: async (_args, ctx) => {
|
|
892
|
+
const cfg = await getConfigWhenNeeded(ctx.cwd);
|
|
893
|
+
const sessionId = getCachedSessionId();
|
|
894
|
+
const state = getState(sessionId);
|
|
895
|
+
const active = getActiveRules(sessionId);
|
|
896
|
+
const allRules = cfg.rules ?? [];
|
|
897
|
+
const delivery = cfg.messageDelivery ?? {};
|
|
898
|
+
|
|
899
|
+
const baseCooldown = cfg.cooldownMs ?? 60_000;
|
|
900
|
+
const currentDelay = Math.min(
|
|
901
|
+
baseCooldown * (cfg.backoff?.factor ?? 2) ** state.backoffCount,
|
|
902
|
+
cfg.backoff?.maxDelayMs ?? 3_600_000,
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const lines: string[] = [
|
|
906
|
+
`todo-enforcer: ${cfg.enabled ? "enabled" : "disabled"}`,
|
|
907
|
+
` Injections: ${state.injectionCount}/${cfg.maxInjections ?? "?"}`,
|
|
908
|
+
` Stagnation: ${state.stagnationCount}/${cfg.stagnationThreshold ?? "?"}`,
|
|
909
|
+
` Backoff: ${state.backoffCount} (delay: ${currentDelay / 1000}s, base: ${baseCooldown / 1000}s)`,
|
|
910
|
+
` In-flight: ${state.inFlight}`,
|
|
911
|
+
` Spawn in-flight: ${state.spawnInFlight}`,
|
|
912
|
+
` Poll pending: ${pollTimers.has(sessionId)}`,
|
|
913
|
+
` Cancelled: ${state.wasCancelled}`,
|
|
914
|
+
` Rules: ${active.length}/${allRules.length} active`,
|
|
915
|
+
` Delivery: mode=${delivery.mode ?? "userMessage"} display=${delivery.display ?? true} deliverAs=${delivery.deliverAs ?? "followUp"}`,
|
|
916
|
+
];
|
|
917
|
+
|
|
918
|
+
for (let i = 0; i < allRules.length; i++) {
|
|
919
|
+
const rule = allRules[i];
|
|
920
|
+
const isActive = active.some(
|
|
921
|
+
(activeRule) => activeRule.name === rule.name,
|
|
922
|
+
);
|
|
923
|
+
const marker = isActive ? "●" : "○";
|
|
924
|
+
const idx = String(i + 1).padStart(2, " ");
|
|
925
|
+
lines.push(
|
|
926
|
+
` ${marker} ${idx}. ${rule.name} [${rule.condition}] → ${rule.action}`,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const override = sessionActiveRules.get(sessionId);
|
|
931
|
+
if (override !== undefined && override !== null) {
|
|
932
|
+
lines.push(
|
|
933
|
+
` (session override active — resets on /enforcer-switch reset)`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
pi.registerCommand("enforcer-switch", {
|
|
942
|
+
description:
|
|
943
|
+
"Switch active rules for this session: /enforcer-switch <rule1,rule2,...> or /enforcer-switch reset",
|
|
944
|
+
getArgumentCompletions: (prefix) => {
|
|
945
|
+
const allRules = config?.rules ?? [];
|
|
946
|
+
const items = allRules
|
|
947
|
+
.filter((rule) => rule.name.startsWith(prefix))
|
|
948
|
+
.map((rule) => ({
|
|
949
|
+
value: rule.name,
|
|
950
|
+
label: `${rule.name} (${rule.condition} → ${rule.action})`,
|
|
951
|
+
}));
|
|
952
|
+
if ("reset".startsWith(prefix)) {
|
|
953
|
+
items.push({ value: "reset", label: "reset — restore all rules" });
|
|
954
|
+
}
|
|
955
|
+
return items.length > 0 ? items : null;
|
|
956
|
+
},
|
|
957
|
+
handler: async (args, ctx) => {
|
|
958
|
+
const cfg = await getConfigWhenNeeded(ctx.cwd);
|
|
959
|
+
const sessionId = getCachedSessionId();
|
|
960
|
+
const trimmed = args.trim().toLowerCase();
|
|
961
|
+
|
|
962
|
+
if (trimmed === "reset") {
|
|
963
|
+
sessionActiveRules.set(sessionId, null);
|
|
964
|
+
ctx.ui.notify("Rules reset to config defaults", "info");
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (!trimmed) {
|
|
969
|
+
ctx.ui.notify(
|
|
970
|
+
"Usage: /enforcer-switch <rule1,rule2,...> or /enforcer-switch reset",
|
|
971
|
+
"warning",
|
|
972
|
+
);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const allRules = cfg.rules ?? [];
|
|
977
|
+
const allNames = new Set(allRules.map((rule) => rule.name.toLowerCase()));
|
|
978
|
+
const requested = trimmed.split(/[\s,]+/).filter(Boolean);
|
|
979
|
+
|
|
980
|
+
const unknown = requested.filter((name) => !allNames.has(name));
|
|
981
|
+
if (unknown.length > 0) {
|
|
982
|
+
ctx.ui.notify(`Unknown rules: ${unknown.join(", ")}`, "error");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const exactNames = new Set<string>();
|
|
987
|
+
for (const name of requested) {
|
|
988
|
+
const found = allRules.find((rule) => rule.name.toLowerCase() === name);
|
|
989
|
+
if (found) {
|
|
990
|
+
exactNames.add(found.name);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
sessionActiveRules.set(sessionId, exactNames);
|
|
994
|
+
ctx.ui.notify(
|
|
995
|
+
`Active rules: ${Array.from(exactNames).join(", ")}`,
|
|
996
|
+
"info",
|
|
997
|
+
);
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
pi.registerCommand("enforcer-reset", {
|
|
1002
|
+
description: "Reset todo-enforcer state for current session",
|
|
1003
|
+
handler: async (_args, ctx) => {
|
|
1004
|
+
const sessionId = getCachedSessionId();
|
|
1005
|
+
resetState(sessionId);
|
|
1006
|
+
sessionActiveRules.delete(sessionId);
|
|
1007
|
+
ctx.ui.notify("todo-enforcer state + rule override reset", "info");
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
pi.registerShortcut("ctrl+shift+t", {
|
|
1012
|
+
description: "Toggle todo-enforcer enabled/disabled",
|
|
1013
|
+
handler: async (ctx) => {
|
|
1014
|
+
const cfg = await getConfigWhenNeeded(ctx.cwd);
|
|
1015
|
+
config = { ...cfg, enabled: !cfg.enabled };
|
|
1016
|
+
ctx.ui.notify(
|
|
1017
|
+
`todo-enforcer: ${config.enabled ? "enabled" : "disabled"}`,
|
|
1018
|
+
config.enabled ? "info" : "warning",
|
|
1019
|
+
);
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
}
|