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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-caller — Execute external commands for action="external" rules
|
|
3
|
+
*
|
|
4
|
+
* Runs an external command with session context on stdin.
|
|
5
|
+
* Returns stdout as the injection message.
|
|
6
|
+
*/
|
|
7
|
+
// @ts-nocheck
|
|
8
|
+
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
import { createPluginLogger } from "./lib/plugin-logger";
|
|
15
|
+
import type {
|
|
16
|
+
ExternalCallConfig,
|
|
17
|
+
SessionContext,
|
|
18
|
+
TodoSnapshot,
|
|
19
|
+
} from "./config";
|
|
20
|
+
|
|
21
|
+
const logger = createPluginLogger("todo-enforcer");
|
|
22
|
+
|
|
23
|
+
/** Default timeout for external calls (15 seconds). */
|
|
24
|
+
export const DEFAULT_EXTERNAL_TIMEOUT_MS = 15_000;
|
|
25
|
+
|
|
26
|
+
export interface ExternalCallResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
output: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Execute a child-process command with the session payload on stdin.
|
|
34
|
+
*/
|
|
35
|
+
export async function invokeCommand(
|
|
36
|
+
command: string[],
|
|
37
|
+
stdinPayload: string,
|
|
38
|
+
timeoutMs: number,
|
|
39
|
+
silent: boolean,
|
|
40
|
+
snapshot: TodoSnapshot,
|
|
41
|
+
): Promise<ExternalCallResult> {
|
|
42
|
+
if (!Array.isArray(command) || command.length === 0) {
|
|
43
|
+
const error = "external.command must be a non-empty array";
|
|
44
|
+
if (!silent) logger.error(error);
|
|
45
|
+
return { success: false, output: "", error };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [cmd, ...args] = command;
|
|
49
|
+
const env = {
|
|
50
|
+
...process.env,
|
|
51
|
+
TODO_INCOMPLETE_COUNT: String(snapshot.incompleteCount),
|
|
52
|
+
TODO_COMPLETED_COUNT: String(snapshot.completedCount),
|
|
53
|
+
TODO_TOTAL_COUNT: String(snapshot.totalCount),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await new Promise<{ stdout: string; stderr: string }>(
|
|
58
|
+
(resolve, reject) => {
|
|
59
|
+
const child = spawn(cmd, args, {
|
|
60
|
+
env,
|
|
61
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
let stdout = "";
|
|
64
|
+
let stderr = "";
|
|
65
|
+
let settled = false;
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
child.kill("SIGKILL");
|
|
70
|
+
reject(new Error("timeout"));
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
|
|
73
|
+
child.stdout.on("data", (d: Buffer) => {
|
|
74
|
+
stdout += d.toString();
|
|
75
|
+
});
|
|
76
|
+
child.stderr.on("data", (d: Buffer) => {
|
|
77
|
+
stderr += d.toString();
|
|
78
|
+
});
|
|
79
|
+
child.on("error", (error) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
reject(error);
|
|
84
|
+
});
|
|
85
|
+
child.on("close", (code) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
if (code !== 0) {
|
|
90
|
+
reject(new Error(`exit code ${code}: ${stderr.trim()}`));
|
|
91
|
+
} else {
|
|
92
|
+
resolve({ stdout, stderr });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
child.stdin.write(stdinPayload);
|
|
96
|
+
child.stdin.end();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const output = result.stdout.trim();
|
|
101
|
+
if (!output) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
output: "",
|
|
105
|
+
error: "external command produced no output",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { success: true, output };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
if (!silent) {
|
|
113
|
+
logger.error(`External call failed: ${message}`);
|
|
114
|
+
}
|
|
115
|
+
return { success: false, output: "", error: message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function invokeHttp(
|
|
120
|
+
url: string,
|
|
121
|
+
init: RequestInit,
|
|
122
|
+
timeoutMs: number,
|
|
123
|
+
silent: boolean,
|
|
124
|
+
): Promise<ExternalCallResult> {
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(url, {
|
|
130
|
+
...init,
|
|
131
|
+
signal: controller.signal,
|
|
132
|
+
});
|
|
133
|
+
const output = (await response.text()).trim();
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
output: "",
|
|
138
|
+
error: `http ${response.status}: ${output}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (!output) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
output: "",
|
|
145
|
+
error: "external http call produced no output",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { success: true, output };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
if (!silent) {
|
|
152
|
+
logger.error(`External call failed: ${message}`);
|
|
153
|
+
}
|
|
154
|
+
return { success: false, output: "", error: message };
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timeout);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Execute an external command or HTTP request with session context.
|
|
162
|
+
*/
|
|
163
|
+
export async function executeExternalCall(
|
|
164
|
+
config: ExternalCallConfig,
|
|
165
|
+
snapshot: TodoSnapshot,
|
|
166
|
+
context: SessionContext,
|
|
167
|
+
): Promise<ExternalCallResult> {
|
|
168
|
+
const timeoutMs = config.timeoutMs ?? config.http?.timeoutMs ?? DEFAULT_EXTERNAL_TIMEOUT_MS;
|
|
169
|
+
const silent = config.silent ?? config.http?.silent ?? true;
|
|
170
|
+
const stdinPayload = JSON.stringify({ snapshot, context }, null, 2);
|
|
171
|
+
|
|
172
|
+
if (config.command) {
|
|
173
|
+
return await invokeCommand(
|
|
174
|
+
config.command,
|
|
175
|
+
stdinPayload,
|
|
176
|
+
timeoutMs,
|
|
177
|
+
silent,
|
|
178
|
+
snapshot,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (config.http) {
|
|
183
|
+
return await invokeHttp(
|
|
184
|
+
config.http.url,
|
|
185
|
+
{
|
|
186
|
+
method: config.http.method ?? "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
"content-type": "application/json",
|
|
189
|
+
...(config.http.headers ?? {}),
|
|
190
|
+
},
|
|
191
|
+
body: stdinPayload,
|
|
192
|
+
},
|
|
193
|
+
config.http.timeoutMs ?? timeoutMs,
|
|
194
|
+
silent,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const error = "external config requires either command or http";
|
|
199
|
+
if (!silent) logger.error(error);
|
|
200
|
+
return { success: false, output: "", error };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Dummy external call function for testing.
|
|
205
|
+
* Returns a fixed message with the snapshot info.
|
|
206
|
+
* Synchronous — returns a pre-resolved Promise for API compatibility.
|
|
207
|
+
*/
|
|
208
|
+
export function dummyExternalCall(
|
|
209
|
+
_config: ExternalCallConfig,
|
|
210
|
+
snapshot: TodoSnapshot,
|
|
211
|
+
context: SessionContext,
|
|
212
|
+
): Promise<ExternalCallResult> {
|
|
213
|
+
return Promise.resolve({
|
|
214
|
+
success: true,
|
|
215
|
+
output:
|
|
216
|
+
`[DUMMY CALL] Session has ${snapshot.incompleteCount} incomplete tasks and ` +
|
|
217
|
+
`${snapshot.completedCount} completed. Latest user: ${context.latestUserMessage}`,
|
|
218
|
+
});
|
|
219
|
+
}
|