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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * hooks-manager — Shared library for pi hook management
3
+ *
4
+ * Provides:
5
+ * - State persistence (global/local scope)
6
+ * - Hook registry (registerHook / isEnabled / getRegistry)
7
+ * - Toggle operations (addDisabled / removeDisabled)
8
+ */
9
+ // @ts-nocheck
10
+
11
+ //
12
+
13
+ //
14
+
15
+ //
16
+
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import type { HookOrigin, HookRegistration, HookSource, HookState, Scope, StatePaths } from "./types";
21
+
22
+ const STATE_FILE = "hooks-state.json";
23
+
24
+ // ── Registry (in-memory, per-process, shared via globalThis) ────────────
25
+
26
+ const GLOBAL_REGISTRY_KEY = "__PI_HOOKS_REGISTRY__" as const;
27
+ const GLOBAL_DISABLED_KEY = "__PI_HOOKS_DISABLED__" as const;
28
+
29
+ function getGlobalRegistry(): HookRegistration[] {
30
+ if (!(globalThis as any)[GLOBAL_REGISTRY_KEY]) {
31
+ (globalThis as any)[GLOBAL_REGISTRY_KEY] = [];
32
+ }
33
+ return (globalThis as any)[GLOBAL_REGISTRY_KEY];
34
+ }
35
+
36
+ function getGlobalDisabled(): Record<string, string[]> {
37
+ if (!(globalThis as any)[GLOBAL_DISABLED_KEY]) {
38
+ (globalThis as any)[GLOBAL_DISABLED_KEY] = {};
39
+ }
40
+ return (globalThis as any)[GLOBAL_DISABLED_KEY];
41
+ }
42
+
43
+
44
+ /** Register a hook's metadata. Called by each extension at load time. */
45
+ export function registerHook(
46
+ extension: string,
47
+ event: string,
48
+ opts: { blocking?: boolean; source?: HookSource; origin?: HookOrigin } = {},
49
+ ): void {
50
+ // Avoid duplicates (extension + event combo)
51
+ const reg = getGlobalRegistry();
52
+ const exists = reg.some((r) => r.extension === extension && r.event === event);
53
+ if (!exists) {
54
+ reg.push({
55
+ extension,
56
+ event,
57
+ blocking: opts.blocking === true,
58
+ source: opts.source ?? "pi",
59
+ origin: opts.origin ?? "global",
60
+ });
61
+ }
62
+ }
63
+
64
+ /** Get all registered hooks. */
65
+ export function getRegistry(): HookRegistration[] {
66
+ return [...getGlobalRegistry()];
67
+ }
68
+
69
+ /** Clear registry (for tests). */
70
+ export function clearRegistry(): void {
71
+ getGlobalRegistry().length = 0;
72
+ }
73
+
74
+ /** Set the current disabled map. Called by pi-hooks-manager on state changes. */
75
+ export function setCurrentDisabled(disabled: Record<string, string[]>): void {
76
+ (globalThis as any)[GLOBAL_DISABLED_KEY] = disabled;
77
+ }
78
+
79
+ /** Get the current disabled map (for tests/integration). */
80
+ export function getCurrentDisabled(): Record<string, string[]> {
81
+ return getGlobalDisabled();
82
+ }
83
+
84
+ /** Check if a specific hook is enabled using the shared current disabled state. */
85
+ export function isEnabled(extension: string, event: string): boolean {
86
+ const disabled = getGlobalDisabled();
87
+ const events = disabled[extension];
88
+ if (!events) return true;
89
+ return !events.includes(event);
90
+ }
91
+
92
+ /** Check if a specific hook is enabled given an explicit disabled map (for tests). */
93
+ export function isEnabledWithState(extension: string, event: string, disabled: Record<string, string[]>): boolean {
94
+ const events = disabled[extension];
95
+ if (!events) return true;
96
+ return !events.includes(event);
97
+ }
98
+
99
+ // ── State Persistence ──────────────────────────────────────────────────
100
+
101
+ function stateFilePath(dir: string): string {
102
+ return path.join(dir, STATE_FILE);
103
+ }
104
+
105
+ function readStateFile(dir: string): HookState | null {
106
+ const fp = stateFilePath(dir);
107
+ try {
108
+ if (!fs.existsSync(fp)) return null;
109
+ const raw = fs.readFileSync(fp, "utf-8");
110
+ const parsed = JSON.parse(raw);
111
+ return sanitizeState(parsed);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function sanitizeState(raw: unknown): HookState {
118
+ if (!raw || typeof raw !== "object") return { scope: "global", disabled: {} };
119
+ const obj = raw as Record<string, unknown>;
120
+ const scope: Scope = obj.scope === "local" ? "local" : "global";
121
+ let disabled: Record<string, string[]>;
122
+ if (obj.disabled && typeof obj.disabled === "object" && !Array.isArray(obj.disabled)) {
123
+ disabled = {};
124
+ for (const [key, val] of Object.entries(obj.disabled as Record<string, unknown>)) {
125
+ if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
126
+ disabled[key] = val;
127
+ }
128
+ }
129
+ } else {
130
+ disabled = {};
131
+ }
132
+ return { scope, disabled };
133
+ }
134
+
135
+ /** Load state from global and/or local files, merging as needed. */
136
+ export function loadState(paths: StatePaths, scope: Scope): HookState {
137
+ const globalState = readStateFile(paths.globalDir);
138
+
139
+ if (scope === "global") {
140
+ return globalState ?? { scope: "global", disabled: {} };
141
+ }
142
+
143
+ // scope === "local": merge local over global
144
+ const localState = readStateFile(paths.localDir);
145
+
146
+ if (!globalState && !localState) {
147
+ return { scope: "local", disabled: {} };
148
+ }
149
+
150
+ if (!localState) {
151
+ return { ...globalState!, scope: "local" };
152
+ }
153
+
154
+ if (!globalState) {
155
+ return localState;
156
+ }
157
+
158
+ // Merge: local overrides global for same extension, global entries preserved if not in local
159
+ const merged: Record<string, string[]> = { ...globalState.disabled };
160
+ for (const [ext, events] of Object.entries(localState.disabled)) {
161
+ merged[ext] = events; // local takes precedence
162
+ }
163
+
164
+ return { scope: "local", disabled: merged };
165
+ }
166
+
167
+ /** Save state to the appropriate directory based on scope. */
168
+ export function saveState(state: HookState, paths: StatePaths): void {
169
+ const dir = state.scope === "local" ? paths.localDir : paths.globalDir;
170
+ const fp = stateFilePath(dir);
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ fs.writeFileSync(fp, JSON.stringify(state, null, 2), "utf-8");
173
+ }
174
+
175
+ /** Switch scope. Returns new state. Saves to new scope's file. */
176
+ export function switchScope(current: HookState, newScope: Scope, paths: StatePaths): HookState {
177
+ if (current.scope === newScope) return current;
178
+
179
+ // Load the target scope's existing state
180
+ const targetState = loadState(paths, newScope);
181
+ saveState(targetState, paths);
182
+ return targetState;
183
+ }
184
+
185
+ // ── Toggle Operations ──────────────────────────────────────────────────
186
+
187
+ /** Add an event to the disabled list for an extension. Idempotent. */
188
+ export function addDisabled(state: HookState, extension: string, event: string): void {
189
+ if (!state.disabled[extension]) {
190
+ state.disabled[extension] = [];
191
+ }
192
+ if (!state.disabled[extension].includes(event)) {
193
+ state.disabled[extension].push(event);
194
+ }
195
+ }
196
+
197
+ /** Remove an event from the disabled list for an extension. Removes key if empty. */
198
+ export function removeDisabled(state: HookState, extension: string, event: string): void {
199
+ const events = state.disabled[extension];
200
+ if (!events) return;
201
+ const idx = events.indexOf(event);
202
+ if (idx === -1) return;
203
+ events.splice(idx, 1);
204
+ if (events.length === 0) {
205
+ delete state.disabled[extension];
206
+ }
207
+ }
@@ -0,0 +1,155 @@
1
+ // @ts-nocheck
2
+ //
3
+ //
4
+ //
5
+ import {
6
+ appendFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, resolve } from "node:path";
15
+
16
+ export type PluginLogLevel = "debug" | "info" | "warn" | "error";
17
+
18
+ export interface PluginLoggerOptions {
19
+ baseDir?: string;
20
+ filePath?: string;
21
+ maxBytes?: number;
22
+ mirrorToConsole?: boolean;
23
+ }
24
+
25
+ export interface PluginLogger {
26
+ readonly name: string;
27
+ readonly filePath: string;
28
+ debug(message: string, details?: unknown): void;
29
+ info(message: string, details?: unknown): void;
30
+ warn(message: string, details?: unknown): void;
31
+ error(message: string, details?: unknown): void;
32
+ }
33
+
34
+ const DEFAULT_BASE_DIR = resolve(homedir(), ".pi", "logs", "extensions");
35
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
36
+ const TRIM_TARGET_RATIO = 0.8;
37
+
38
+ function sanitizeFileName(name: string): string {
39
+ return (
40
+ name
41
+ .trim()
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9._-]+/g, "-")
44
+ .replace(/-{2,}/g, "-")
45
+ .replace(/^-+|-+$/g, "") || "plugin"
46
+ );
47
+ }
48
+
49
+ function normalizeDetails(details: unknown): string {
50
+ if (details === undefined) return "";
51
+ if (details instanceof Error) {
52
+ return JSON.stringify({
53
+ name: details.name,
54
+ message: details.message,
55
+ stack: details.stack,
56
+ });
57
+ }
58
+ if (typeof details === "string") return details;
59
+ try {
60
+ return JSON.stringify(details);
61
+ } catch (error) {
62
+ return String(details);
63
+ }
64
+ }
65
+
66
+ function buildLine(
67
+ name: string,
68
+ level: PluginLogLevel,
69
+ message: string,
70
+ details?: unknown,
71
+ ): string {
72
+ const suffix = normalizeDetails(details);
73
+ return `${new Date().toISOString()} [${name}] ${level.toUpperCase()} ${message}${suffix ? ` ${suffix}` : ""}\n`;
74
+ }
75
+
76
+ function trimFileToLimit(filePath: string, maxBytes: number): void {
77
+ if (!existsSync(filePath)) return;
78
+ const stats = statSync(filePath);
79
+ if (stats.size <= maxBytes) return;
80
+ const targetBytes = Math.max(1, Math.floor(maxBytes * TRIM_TARGET_RATIO));
81
+ const buffer = readFileSync(filePath);
82
+ const start = Math.max(0, buffer.length - targetBytes);
83
+ let trimmed = buffer.subarray(start);
84
+ const newlineIndex = trimmed.indexOf(0x0a);
85
+ if (newlineIndex >= 0 && newlineIndex < trimmed.length - 1) {
86
+ trimmed = trimmed.subarray(newlineIndex + 1);
87
+ }
88
+ writeFileSync(filePath, trimmed);
89
+ }
90
+
91
+ function appendLine(filePath: string, line: string, maxBytes: number): void {
92
+ mkdirSync(dirname(filePath), { recursive: true });
93
+ if (existsSync(filePath)) {
94
+ const currentSize = statSync(filePath).size;
95
+ const incomingSize = Buffer.byteLength(line);
96
+ if (currentSize + incomingSize > maxBytes) {
97
+ trimFileToLimit(filePath, Math.max(incomingSize, maxBytes));
98
+ }
99
+ }
100
+ appendFileSync(filePath, line, "utf8");
101
+ trimFileToLimit(filePath, maxBytes);
102
+ }
103
+
104
+ function mirror(level: PluginLogLevel, line: string): void {
105
+ const text = line.trimEnd();
106
+ if (level === "error") {
107
+ console.error(text);
108
+ return;
109
+ }
110
+ if (level === "warn") {
111
+ console.warn(text);
112
+ return;
113
+ }
114
+ console.log(text);
115
+ }
116
+
117
+ export function createPluginLogger(
118
+ name: string,
119
+ options: PluginLoggerOptions = {},
120
+ ): PluginLogger {
121
+ const filePath = options.filePath
122
+ ? resolve(options.filePath)
123
+ : resolve(
124
+ options.baseDir ?? DEFAULT_BASE_DIR,
125
+ `${sanitizeFileName(name)}.log`,
126
+ );
127
+ const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_BYTES);
128
+ const mirrorToConsole = options.mirrorToConsole ?? false;
129
+
130
+ const write = (
131
+ level: PluginLogLevel,
132
+ message: string,
133
+ details?: unknown,
134
+ ): void => {
135
+ const line = buildLine(name, level, message, details);
136
+ try {
137
+ appendLine(filePath, line, maxBytes);
138
+ } catch (error) {
139
+ const fallback = buildLine(name, "error", "log-write-failed", error);
140
+ console.error(fallback.trimEnd());
141
+ }
142
+ if (mirrorToConsole) {
143
+ mirror(level, line);
144
+ }
145
+ };
146
+
147
+ return {
148
+ name,
149
+ filePath,
150
+ debug: (message, details) => write("debug", message, details),
151
+ info: (message, details) => write("info", message, details),
152
+ warn: (message, details) => write("warn", message, details),
153
+ error: (message, details) => write("error", message, details),
154
+ };
155
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * pi-hooks-manager — Type definitions
3
+ */
4
+ // @ts-nocheck
5
+
6
+ //
7
+
8
+ //
9
+
10
+ //
11
+
12
+
13
+ /** Persistence scope for hook state */
14
+ export type Scope = "global" | "local";
15
+
16
+ /** Hook source — where the hook conceptually originates from */
17
+ export type HookSource = "pi" | "claude";
18
+
19
+ /** Hook origin — where the extension is loaded from */
20
+ export type HookOrigin = "global" | "local" | "package";
21
+
22
+ /** A registered hook's metadata */
23
+ export interface HookRegistration {
24
+ /** Extension name (directory/file name) */
25
+ extension: string;
26
+ /** Pi event name (e.g., "tool_call", "session_start") */
27
+ event: string;
28
+ /** Whether this hook can block the main workflow */
29
+ blocking: boolean;
30
+ /** Where the hook conceptually originates: native pi or Claude Code adapter */
31
+ source: HookSource;
32
+ /** Where the extension is loaded from: global (~/.pi/agent/), local (.pi/), or package */
33
+ origin: HookOrigin;
34
+ }
35
+
36
+ /** Persisted state of disabled hooks */
37
+ export interface HookState {
38
+ /** Current persistence scope */
39
+ scope: Scope;
40
+ /** Map of extension name → array of disabled event names */
41
+ disabled: Record<string, string[]>;
42
+ }
43
+
44
+ /** Options for state operations */
45
+ export interface StatePaths {
46
+ /** Global state directory (e.g., ~/.pi/agent/) */
47
+ globalDir: string;
48
+ /** Local state directory (e.g., .pi/) */
49
+ localDir: string;
50
+ }
51
+
52
+ /** Parsed /hooks command */
53
+ export type ParsedCommand =
54
+ | { action: "list" }
55
+ | { action: "enable"; extension: string; event?: string }
56
+ | { action: "disable"; extension: string; event?: string }
57
+ | { action: "status" }
58
+ | { action: "scope"; scope: Scope }
59
+ | { action: "unknown"; raw: string };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * message-stall — Reusable stall guard for todo-enforcer
3
+ *
4
+ * Prevents the enforcer from flooding the session with repeated or
5
+ * high-frequency messages. Two stall conditions:
6
+ *
7
+ * 1. **Repeated message guard**: If the same exact message is about to
8
+ * be sent for the 3rd consecutive time, stall it.
9
+ * - Reset when a DIFFERENT message arrives (new problem → fresh start).
10
+ *
11
+ * 2. **Rate limit**: If 5 messages have been sent within a 60-minute window,
12
+ * stall additional messages until the window expires.
13
+ *
14
+ * Usage:
15
+ * const { stalled, reason } = checkMessageStall(sessionId, message);
16
+ * if (stalled) { log("stalled", reason); return; }
17
+ * // ... proceed to deliver message ...
18
+ *
19
+ * Call resetStallState(sessionId) on session_start to clear tracking.
20
+ */
21
+ // @ts-nocheck
22
+
23
+ //
24
+
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface StallState {
29
+ /** Last message content that was checked (not stalled). */
30
+ lastMessage: string | null;
31
+
32
+ /** How many consecutive times the same message has been seen. */
33
+ repeatCount: number;
34
+
35
+ /** Timestamps of all delivered (non-stalled) messages for rate-limit tracking. */
36
+ messageTimestamps: number[];
37
+ }
38
+
39
+ export interface StallResult {
40
+ /** Whether the message should be stalled (not sent). */
41
+ stalled: boolean;
42
+
43
+ /** Reason for stalling: "repeated_message" | "rate_limit" | null. */
44
+ reason: "repeated_message" | "rate_limit" | null;
45
+ }
46
+
47
+ // ─── Constants ───────────────────────────────────────────────────────────────
48
+
49
+ /** Consecutive identical messages before stalling. */
50
+ const REPEAT_THRESHOLD = 3;
51
+
52
+ /** Stall when this many messages have been delivered in the window. */
53
+ const RATE_LIMIT_STALL_AT = 5;
54
+
55
+ /** Rate-limit window duration in ms (60 minutes). */
56
+ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
57
+
58
+ // ─── State store ─────────────────────────────────────────────────────────────
59
+
60
+ const stallStates = new Map<string, StallState>();
61
+
62
+ function getOrCreate(sessionId: string): StallState {
63
+ let state = stallStates.get(sessionId);
64
+ if (!state) {
65
+ state = {
66
+ lastMessage: null,
67
+ repeatCount: 0,
68
+ messageTimestamps: [],
69
+ };
70
+ stallStates.set(sessionId, state);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ // ─── Time abstraction (testable) ─────────────────────────────────────────────
76
+
77
+ let _getTime: () => number = () => Date.now();
78
+
79
+ /**
80
+ * Override time source for testing. Returns the previous getter.
81
+ * Production code should NOT call this.
82
+ */
83
+ export function stubGetTime(fn: () => number): () => number {
84
+ const prev = _getTime;
85
+ _getTime = fn;
86
+ return prev;
87
+ }
88
+
89
+ /** Reset time source to default. */
90
+ export function resetGetTime(): void {
91
+ _getTime = () => Date.now();
92
+ }
93
+
94
+ // ─── Internal helpers ────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Prune timestamps outside the rate-limit window.
98
+ * Returns the pruned array (mutates in place).
99
+ */
100
+ function pruneOldTimestamps(timestamps: number[], now: number): void {
101
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
102
+ while (timestamps.length > 0 && timestamps[0] < cutoff) {
103
+ timestamps.shift();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Fingerprint a message by its first non-empty line.
109
+ *
110
+ * Defense against the death-spiral: if {{latest_user_message}} in the prompt
111
+ * template echoes the previous injection, every successive message is longer
112
+ * than the last and exact-string equality never holds. The first line is the
113
+ * stable rule-determined prefix (e.g. "You have incomplete tasks. ..."), so
114
+ * fingerprinting on it lets the repeat-stall fire regardless of nested growth.
115
+ */
116
+ function fingerprint(message: string): string {
117
+ for (const line of message.split("\n")) {
118
+ const trimmed = line.trim();
119
+ if (trimmed) return trimmed;
120
+ }
121
+ return "";
122
+ }
123
+
124
+ // ─── Public API ──────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Check if a message should be stalled.
128
+ *
129
+ * Call this BEFORE delivering the message. If stalled=true, do NOT send.
130
+ *
131
+ * If not stalled, this function records the message for future tracking.
132
+ */
133
+ export function checkMessageStall(
134
+ sessionId: string,
135
+ message: string,
136
+ ): StallResult {
137
+ const state = getOrCreate(sessionId);
138
+ const now = _getTime();
139
+
140
+ // ── Check 1: Repeated message ─────────────────────────────────────
141
+ // Compare by first-line fingerprint so growing nested content (the
142
+ // enforcer's own injection echoed back via {{latest_user_message}}) is
143
+ // still caught as a repeat.
144
+ const fp = fingerprint(message);
145
+ const lastFp = state.lastMessage === null ? null : fingerprint(state.lastMessage);
146
+ if (lastFp !== null && lastFp === fp) {
147
+ state.repeatCount++;
148
+ // Keep lastMessage in sync with the most recent content so callers
149
+ // inspecting state see the actual last seen message.
150
+ state.lastMessage = message;
151
+ if (state.repeatCount >= REPEAT_THRESHOLD) {
152
+ return { stalled: true, reason: "repeated_message" };
153
+ }
154
+ } else {
155
+ // Different fingerprint → reset repeat counter
156
+ state.lastMessage = message;
157
+ state.repeatCount = 1;
158
+ }
159
+
160
+ // ── Check 2: Rate limit ───────────────────────────────────────────
161
+ pruneOldTimestamps(state.messageTimestamps, now);
162
+ // If we've already recorded RATE_LIMIT_MAX-1 messages, the next one is the Nth and should be stalled.
163
+ // (We record AFTER passing this check, so at the time of check, timestamps.length is the count of prior sends.)
164
+ if (state.messageTimestamps.length >= RATE_LIMIT_STALL_AT - 1) {
165
+ return { stalled: true, reason: "rate_limit" };
166
+ }
167
+
168
+ // ── Not stalled → record this message ─────────────────────────────
169
+ state.messageTimestamps.push(now);
170
+
171
+ return { stalled: false, reason: null };
172
+ }
173
+
174
+ /**
175
+ * Reset all stall state for a session.
176
+ * Call on session_start or manual reset.
177
+ */
178
+ export function resetStallState(sessionId: string): void {
179
+ stallStates.delete(sessionId);
180
+ }
181
+
182
+ /**
183
+ * Reset all stall state for all sessions.
184
+ * Useful for testing.
185
+ */
186
+ export function resetAllStallState(): void {
187
+ stallStates.clear();
188
+ }