@wrongstack/plugins 0.277.1 → 0.280.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.
Files changed (72) hide show
  1. package/README.md +838 -0
  2. package/dist/auto-doc.d.ts +8 -0
  3. package/dist/auto-doc.js +175 -13
  4. package/dist/auto-escalate.d.ts +45 -0
  5. package/dist/auto-escalate.js +190 -0
  6. package/dist/branch-guard.d.ts +33 -0
  7. package/dist/branch-guard.js +228 -0
  8. package/dist/changelog-writer.d.ts +73 -0
  9. package/dist/changelog-writer.js +369 -0
  10. package/dist/checkpoint.d.ts +55 -0
  11. package/dist/checkpoint.js +305 -0
  12. package/dist/commit-validator.d.ts +33 -0
  13. package/dist/commit-validator.js +315 -0
  14. package/dist/config-validator.d.ts +48 -0
  15. package/dist/config-validator.js +347 -0
  16. package/dist/context-pins.d.ts +45 -0
  17. package/dist/context-pins.js +240 -0
  18. package/dist/cost-tracker.d.ts +40 -1
  19. package/dist/cost-tracker.js +105 -4
  20. package/dist/dep-guard.d.ts +65 -0
  21. package/dist/dep-guard.js +316 -0
  22. package/dist/diff-summary.d.ts +36 -0
  23. package/dist/diff-summary.js +235 -0
  24. package/dist/error-lens.d.ts +67 -0
  25. package/dist/error-lens.js +280 -0
  26. package/dist/format-on-save.d.ts +35 -0
  27. package/dist/format-on-save.js +219 -0
  28. package/dist/git-autocommit.js +186 -26
  29. package/dist/import-organizer.d.ts +52 -0
  30. package/dist/import-organizer.js +274 -0
  31. package/dist/index.d.ts +32 -6
  32. package/dist/index.js +10151 -1628
  33. package/dist/injection-shield.d.ts +49 -0
  34. package/dist/injection-shield.js +205 -0
  35. package/dist/lint-gate.d.ts +33 -0
  36. package/dist/lint-gate.js +394 -0
  37. package/dist/llm-cache.d.ts +56 -0
  38. package/dist/llm-cache.js +251 -0
  39. package/dist/loop-breaker.d.ts +43 -0
  40. package/dist/loop-breaker.js +241 -0
  41. package/dist/model-router.d.ts +69 -0
  42. package/dist/model-router.js +198 -0
  43. package/dist/notify-hub.d.ts +45 -0
  44. package/dist/notify-hub.js +304 -0
  45. package/dist/path-guard.d.ts +54 -0
  46. package/dist/path-guard.js +235 -0
  47. package/dist/prompt-firewall.d.ts +57 -0
  48. package/dist/prompt-firewall.js +290 -0
  49. package/dist/secret-scanner.d.ts +34 -0
  50. package/dist/secret-scanner.js +409 -0
  51. package/dist/semver-bump.js +45 -0
  52. package/dist/session-recap.d.ts +50 -0
  53. package/dist/session-recap.js +421 -0
  54. package/dist/shell-check.js +52 -4
  55. package/dist/spec-linker.d.ts +51 -0
  56. package/dist/spec-linker.js +541 -0
  57. package/dist/template-engine.js +19 -1
  58. package/dist/test-runner-gate.d.ts +37 -0
  59. package/dist/test-runner-gate.js +356 -0
  60. package/dist/todo-listener.d.ts +37 -0
  61. package/dist/todo-listener.js +216 -0
  62. package/dist/todo-tracker.d.ts +5 -0
  63. package/dist/todo-tracker.js +441 -0
  64. package/dist/token-budget.d.ts +40 -0
  65. package/dist/token-budget.js +254 -0
  66. package/dist/token-throttle.d.ts +54 -0
  67. package/dist/token-throttle.js +203 -0
  68. package/package.json +116 -12
  69. package/dist/json-path.d.ts +0 -18
  70. package/dist/json-path.js +0 -15
  71. package/dist/web-search.d.ts +0 -19
  72. package/dist/web-search.js +0 -15
@@ -0,0 +1,356 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { basename, dirname, join } from 'path';
4
+
5
+ // src/test-runner-gate/index.ts
6
+ var API_VERSION = "^0.1.10";
7
+ var state = {
8
+ invocationCount: 0,
9
+ /** Times a test file was found and tests ran. */
10
+ runCount: 0,
11
+ /** Times tests passed. */
12
+ passCount: 0,
13
+ /** Times tests failed. */
14
+ failCount: 0,
15
+ /** Times no test file was found for the source file. */
16
+ noTestCount: 0,
17
+ /** Times the test runner itself failed (timeout, crash). */
18
+ errorCount: 0,
19
+ /** Hook handle for teardown. */
20
+ hookUnregister: null,
21
+ /** Last test result — surfaced by health() + status tool. */
22
+ lastResult: null
23
+ };
24
+ var DEFAULTS = {
25
+ enabled: true,
26
+ runner: "auto",
27
+ command: "",
28
+ timeoutMs: 3e4,
29
+ testFilePatterns: [
30
+ "src/{name}.test.ts",
31
+ "tests/{name}.test.ts",
32
+ "tests/{name}-exec.test.ts"
33
+ ],
34
+ injectOnPass: false
35
+ };
36
+ function readConfig(raw) {
37
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
38
+ const r = raw;
39
+ const runner = r["runner"] === "vitest" || r["runner"] === "jest" || r["runner"] === "mocha" ? r["runner"] : "auto";
40
+ return {
41
+ enabled: r["enabled"] !== false,
42
+ runner,
43
+ command: typeof r["command"] === "string" ? r["command"] : DEFAULTS.command,
44
+ timeoutMs: typeof r["timeoutMs"] === "number" && r["timeoutMs"] > 0 ? r["timeoutMs"] : DEFAULTS.timeoutMs,
45
+ testFilePatterns: Array.isArray(r["testFilePatterns"]) && r["testFilePatterns"].length > 0 ? r["testFilePatterns"].filter((x) => typeof x === "string") : DEFAULTS.testFilePatterns,
46
+ injectOnPass: r["injectOnPass"] === true
47
+ };
48
+ }
49
+ function resolveTestFiles(sourcePath, patterns) {
50
+ const name = basename(sourcePath).replace(/\.[^.]+$/, "");
51
+ const pathNoExt = sourcePath.replace(/\.[^.]+$/, "");
52
+ const dir = dirname(sourcePath);
53
+ const candidates = [];
54
+ for (const pattern of patterns) {
55
+ const candidate = pattern.replace(/\{name\}/g, name).replace(/\{path\}/g, pathNoExt).replace(/\{dir\}/g, dir);
56
+ if (!candidate.startsWith("/") && !candidate.includes("{")) {
57
+ if (pattern.includes("{dir}")) {
58
+ candidates.push(candidate);
59
+ } else {
60
+ candidates.push(candidate);
61
+ candidates.push(join(dir, candidate));
62
+ }
63
+ }
64
+ }
65
+ return candidates;
66
+ }
67
+ function findTestFile(sourcePath, patterns) {
68
+ const candidates = resolveTestFiles(sourcePath, patterns);
69
+ for (const candidate of candidates) {
70
+ if (existsSync(candidate)) return candidate;
71
+ }
72
+ return null;
73
+ }
74
+ function detectRunner(requested) {
75
+ const candidates = [
76
+ { name: "vitest", command: "npx vitest run", jsonFlags: "--reporter=json" },
77
+ { name: "jest", command: "npx jest", jsonFlags: "--json" },
78
+ { name: "mocha", command: "npx mocha", jsonFlags: "--reporter json" }
79
+ ];
80
+ if (requested !== "auto") {
81
+ const match = candidates.find((c) => c.name === requested);
82
+ if (!match) return null;
83
+ try {
84
+ execSync(`npx ${match.name} --version`, {
85
+ encoding: "utf-8",
86
+ timeout: 5e3,
87
+ cwd: process.cwd(),
88
+ stdio: ["pipe", "pipe", "pipe"]
89
+ });
90
+ return match;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+ for (const candidate of candidates) {
96
+ try {
97
+ execSync(`npx ${candidate.name} --version`, {
98
+ encoding: "utf-8",
99
+ timeout: 5e3,
100
+ cwd: process.cwd(),
101
+ stdio: ["pipe", "pipe", "pipe"]
102
+ });
103
+ return candidate;
104
+ } catch {
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ function runTests(testFile, runner, customCommand, timeoutMs) {
110
+ const baseCommand = customCommand || runner.command;
111
+ const fullCommand = `${baseCommand} "${testFile}" ${runner.jsonFlags}`;
112
+ let stdout = "";
113
+ try {
114
+ stdout = execSync(fullCommand, {
115
+ encoding: "utf-8",
116
+ timeout: timeoutMs,
117
+ cwd: process.cwd(),
118
+ stdio: ["pipe", "pipe", "pipe"]
119
+ });
120
+ } catch (err) {
121
+ const e = err;
122
+ if (e.killed) return null;
123
+ if (e.stdout) stdout = e.stdout;
124
+ else return null;
125
+ }
126
+ try {
127
+ const data = JSON.parse(stdout);
128
+ const numTotalTests = data.numTotalTests ?? 0;
129
+ const numFailedTests = data.numFailedTests ?? 0;
130
+ const numPassedTests = data.numPassedTests ?? 0;
131
+ const success = data.success ?? numFailedTests === 0;
132
+ const failures = [];
133
+ if (data.testResults) {
134
+ for (const fileResult of data.testResults) {
135
+ for (const assertion of fileResult.assertionResults ?? []) {
136
+ if (assertion.status === "failed") {
137
+ const fullName = assertion.fullName ?? assertion.title ?? "unknown";
138
+ const message = (assertion.failureMessages?.[0] ?? "").split("\n")[0]?.slice(0, 200);
139
+ failures.push(`${fullName}: ${message}`);
140
+ if (failures.length >= 5) break;
141
+ }
142
+ }
143
+ if (failures.length >= 5) break;
144
+ }
145
+ }
146
+ return {
147
+ passed: success && numFailedTests === 0,
148
+ testCount: numTotalTests,
149
+ failCount: numFailedTests,
150
+ duration: `${data.startTime ? "\u2014" : ""} ${numPassedTests} passed, ${numFailedTests} failed`,
151
+ failures
152
+ };
153
+ } catch {
154
+ const passedMatch = stdout.match(/(\d+)\s+passed/);
155
+ const failedMatch = stdout.match(/(\d+)\s+failed/);
156
+ const passed = passedMatch ? Number.parseInt(passedMatch[1], 10) : 0;
157
+ const failed = failedMatch ? Number.parseInt(failedMatch[1], 10) : 0;
158
+ return {
159
+ passed: failed === 0,
160
+ testCount: passed + failed,
161
+ failCount: failed,
162
+ duration: `${passed} passed, ${failed} failed`,
163
+ failures: []
164
+ };
165
+ }
166
+ }
167
+ var plugin = {
168
+ name: "test-runner-gate",
169
+ version: "0.1.0",
170
+ description: "PostToolUse hook that runs the relevant test file after every write or edit to a source file",
171
+ apiVersion: API_VERSION,
172
+ capabilities: { tools: true, hooks: true },
173
+ defaultConfig: { ...DEFAULTS },
174
+ configSchema: {
175
+ type: "object",
176
+ properties: {
177
+ enabled: {
178
+ type: "boolean",
179
+ default: true,
180
+ description: "Master switch."
181
+ },
182
+ runner: {
183
+ type: "string",
184
+ enum: ["vitest", "jest", "mocha", "auto"],
185
+ default: "auto",
186
+ description: 'Which test runner to use. "auto" tries vitest first, then jest, then mocha.'
187
+ },
188
+ command: {
189
+ type: "string",
190
+ default: "",
191
+ description: "Custom command prefix (overrides the runner default). Empty = use runner default."
192
+ },
193
+ timeoutMs: {
194
+ type: "number",
195
+ minimum: 5e3,
196
+ default: 3e4,
197
+ description: "Test process timeout in milliseconds."
198
+ },
199
+ testFilePatterns: {
200
+ type: "array",
201
+ items: { type: "string" },
202
+ default: ["src/{name}.test.ts", "tests/{name}.test.ts", "tests/{name}-exec.test.ts"],
203
+ description: "Patterns to derive test file from source. {name}=basename, {path}=path-no-ext, {dir}=dirname."
204
+ },
205
+ injectOnPass: {
206
+ type: "boolean",
207
+ default: false,
208
+ description: "Inject additionalContext when tests pass too (default: only on failure)."
209
+ }
210
+ }
211
+ },
212
+ setup(api) {
213
+ state.invocationCount = 0;
214
+ state.runCount = 0;
215
+ state.passCount = 0;
216
+ state.failCount = 0;
217
+ state.noTestCount = 0;
218
+ state.errorCount = 0;
219
+ state.hookUnregister = null;
220
+ state.lastResult = null;
221
+ const cfg = readConfig(api.config.extensions?.["test-runner-gate"]);
222
+ const runner = detectRunner(cfg.runner);
223
+ if (!runner) {
224
+ api.log.warn("test-runner-gate: no test runner found (vitest, jest, mocha) \u2014 hook will be a no-op", {
225
+ requested: cfg.runner
226
+ });
227
+ } else {
228
+ api.log.info("test-runner-gate: detected runner", { name: runner.name });
229
+ }
230
+ const hook = (input) => {
231
+ if (!cfg.enabled || !runner) return;
232
+ if (input.toolResult?.isError) return;
233
+ const inp = input.toolInput ?? {};
234
+ const sourcePath = inp["path"];
235
+ if (!sourcePath || typeof sourcePath !== "string") return;
236
+ if (sourcePath.includes(".test.") || sourcePath.includes(".spec.")) return;
237
+ state.invocationCount += 1;
238
+ const testFile = findTestFile(sourcePath, cfg.testFilePatterns);
239
+ if (!testFile) {
240
+ state.noTestCount += 1;
241
+ return;
242
+ }
243
+ const result = runTests(testFile, runner, cfg.command, cfg.timeoutMs);
244
+ if (!result) {
245
+ state.errorCount += 1;
246
+ return;
247
+ }
248
+ state.runCount += 1;
249
+ state.lastResult = {
250
+ sourcePath,
251
+ testPath: testFile,
252
+ passed: result.passed,
253
+ testCount: result.testCount,
254
+ duration: result.duration,
255
+ when: (/* @__PURE__ */ new Date()).toISOString()
256
+ };
257
+ if (result.passed) {
258
+ state.passCount += 1;
259
+ if (!cfg.injectOnPass) return;
260
+ return {
261
+ additionalContext: `
262
+ \u2705 test-runner-gate: ${result.testCount} test(s) passed for ${testFile} (${result.duration}). Source: ${sourcePath}.`
263
+ };
264
+ }
265
+ state.failCount += 1;
266
+ const failureList = result.failures.length > 0 ? "\n" + result.failures.map((f) => ` \u274C ${f}`).join("\n") : "";
267
+ const truncated = result.failCount > 5 ? `
268
+ \u2026 and ${result.failCount - 5} more failure(s)` : "";
269
+ api.log.warn(`test-runner-gate: ${result.failCount} test(s) failed for ${testFile}`, {
270
+ source: sourcePath
271
+ });
272
+ return {
273
+ additionalContext: `
274
+ \u274C test-runner-gate: ${result.failCount} of ${result.testCount} test(s) FAILED for ${testFile} after editing ${sourcePath}.${failureList}${truncated}
275
+ Fix the failing tests or revert the change if it broke something.`
276
+ };
277
+ };
278
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
279
+ api.tools.register({
280
+ name: "test_gate_status",
281
+ description: "Reports test-runner-gate state: command, patterns, and per-session pass/fail/error/no-test counters.",
282
+ inputSchema: { type: "object", properties: {} },
283
+ permission: "auto",
284
+ category: "Testing",
285
+ mutating: false,
286
+ async execute() {
287
+ return {
288
+ ok: true,
289
+ enabled: cfg.enabled,
290
+ runner: runner?.name ?? "none",
291
+ command: cfg.command || runner?.command || "",
292
+ timeoutMs: cfg.timeoutMs,
293
+ testFilePatterns: cfg.testFilePatterns,
294
+ injectOnPass: cfg.injectOnPass,
295
+ counters: {
296
+ invocations: state.invocationCount,
297
+ runs: state.runCount,
298
+ passed: state.passCount,
299
+ failed: state.failCount,
300
+ noTest: state.noTestCount,
301
+ errors: state.errorCount
302
+ },
303
+ lastResult: state.lastResult
304
+ };
305
+ }
306
+ });
307
+ api.log.info("test-runner-gate plugin loaded", {
308
+ version: "0.1.0",
309
+ command: cfg.command,
310
+ patterns: cfg.testFilePatterns.length
311
+ });
312
+ },
313
+ teardown(api) {
314
+ if (state.hookUnregister) {
315
+ try {
316
+ state.hookUnregister();
317
+ } catch {
318
+ }
319
+ state.hookUnregister = null;
320
+ }
321
+ const final = {
322
+ invocations: state.invocationCount,
323
+ runs: state.runCount,
324
+ passed: state.passCount,
325
+ failed: state.failCount,
326
+ noTest: state.noTestCount,
327
+ errors: state.errorCount
328
+ };
329
+ state.invocationCount = 0;
330
+ state.runCount = 0;
331
+ state.passCount = 0;
332
+ state.failCount = 0;
333
+ state.noTestCount = 0;
334
+ state.errorCount = 0;
335
+ state.lastResult = null;
336
+ api.log.info("test-runner-gate: teardown complete", { final });
337
+ },
338
+ async health() {
339
+ return {
340
+ ok: true,
341
+ message: state.lastResult === null ? `test-runner-gate: ${state.invocationCount} invocation(s), ${state.runCount} test run(s)` : state.lastResult.passed ? `test-runner-gate: last run PASSED (${state.lastResult.testCount} tests) on ${state.lastResult.testPath}` : `test-runner-gate: last run FAILED (${state.lastResult.testCount} tests) on ${state.lastResult.testPath} at ${state.lastResult.when}`,
342
+ counters: {
343
+ invocations: state.invocationCount,
344
+ runs: state.runCount,
345
+ passed: state.passCount,
346
+ failed: state.failCount,
347
+ noTest: state.noTestCount,
348
+ errors: state.errorCount
349
+ },
350
+ lastResult: state.lastResult
351
+ };
352
+ }
353
+ };
354
+ var test_runner_gate_default = plugin;
355
+
356
+ export { test_runner_gate_default as default };
@@ -0,0 +1,37 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * todo-listener plugin — PostToolUse hook on the `todo` tool that
5
+ * broadcasts a structured status update to the project mailbox.
6
+ *
7
+ * When the agent (or any plugin) calls the built-in `todo` tool, the
8
+ * full todo list is replaced in `ctx.todos`. The hook fires after the
9
+ * tool completes, reads the new state, and posts a compact summary
10
+ * to the project mailbox so that other agents in the same project
11
+ * (terminals, WebUIs, shadow agents) can see what this agent is
12
+ * working on in real time.
13
+ *
14
+ * Use cases:
15
+ * - Multi-agent fleets where a coordinator should know which
16
+ * sub-agent is working on which item
17
+ * - Long-running sessions where a user opens a second terminal and
18
+ * wants to see the in-progress plan
19
+ * - Shadow agents that audit progress across the project
20
+ *
21
+ * Config (`config.extensions['todo-listener']`):
22
+ *
23
+ * ```jsonc
24
+ * {
25
+ * "enabled": true,
26
+ * "subjectPrefix": "todo: ",
27
+ * "broadcastOnChange": true,
28
+ * "cooldownMs": 5000
29
+ * }
30
+ * ```
31
+ *
32
+ * @public
33
+ */
34
+
35
+ declare const plugin: Plugin;
36
+
37
+ export { plugin as default };
@@ -0,0 +1,216 @@
1
+ // src/todo-listener/index.ts
2
+ var state = {
3
+ invocationCount: 0,
4
+ sentCount: 0,
5
+ skippedCount: 0,
6
+ errorCount: 0,
7
+ lastMessageId: null,
8
+ lastPayloadHash: "",
9
+ lastBroadcastAt: 0,
10
+ hookUnregister: null
11
+ };
12
+ var DEFAULTS = {
13
+ enabled: true,
14
+ subjectPrefix: "todo: ",
15
+ broadcastOnChange: true,
16
+ cooldownMs: 5e3
17
+ };
18
+ function readConfig(raw) {
19
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
20
+ const r = raw;
21
+ return {
22
+ enabled: r["enabled"] !== false,
23
+ subjectPrefix: typeof r["subjectPrefix"] === "string" ? r["subjectPrefix"] : DEFAULTS.subjectPrefix,
24
+ broadcastOnChange: r["broadcastOnChange"] !== false,
25
+ cooldownMs: typeof r["cooldownMs"] === "number" && r["cooldownMs"] >= 0 ? r["cooldownMs"] : DEFAULTS.cooldownMs
26
+ };
27
+ }
28
+ function hashTodos(todos) {
29
+ const sorted = todos.map((t) => `${t.id}|${t.status}|${t.content ?? ""}`).sort();
30
+ let h = 2166136261;
31
+ for (let i = 0; i < sorted.join("\n").length; i++) {
32
+ h ^= sorted.join("\n").charCodeAt(i);
33
+ h = h * 16777619 >>> 0;
34
+ }
35
+ return h.toString(16);
36
+ }
37
+ var plugin = {
38
+ name: "todo-listener",
39
+ version: "0.1.0",
40
+ description: "PostToolUse hook on `todo` tool \u2014 broadcasts a status update to the project mailbox so other agents can see what this one is working on",
41
+ apiVersion: "^0.1.10",
42
+ capabilities: { tools: true, hooks: true },
43
+ defaultConfig: { ...DEFAULTS },
44
+ configSchema: {
45
+ type: "object",
46
+ properties: {
47
+ enabled: { type: "boolean", default: true, description: "Master switch." },
48
+ subjectPrefix: {
49
+ type: "string",
50
+ default: DEFAULTS.subjectPrefix,
51
+ description: "Prepended to the broadcast `subject`. Useful for filtering the inbox."
52
+ },
53
+ broadcastOnChange: {
54
+ type: "boolean",
55
+ default: true,
56
+ description: "When true, identical consecutive payloads are suppressed."
57
+ },
58
+ cooldownMs: {
59
+ type: "number",
60
+ minimum: 0,
61
+ default: 5e3,
62
+ description: "Minimum interval between consecutive broadcasts (ms)."
63
+ }
64
+ }
65
+ },
66
+ setup(api) {
67
+ state.invocationCount = 0;
68
+ state.sentCount = 0;
69
+ state.skippedCount = 0;
70
+ state.errorCount = 0;
71
+ state.lastMessageId = null;
72
+ state.lastPayloadHash = "";
73
+ state.lastBroadcastAt = 0;
74
+ state.hookUnregister = null;
75
+ const cfg = readConfig(api.config.extensions?.["todo-listener"]);
76
+ const mailbox = api.mailbox;
77
+ const hook = async (input) => {
78
+ if (!cfg.enabled) return;
79
+ if (input.toolName !== "todo") return;
80
+ if (input.toolResult?.isError) return;
81
+ state.invocationCount += 1;
82
+ if (!mailbox) {
83
+ state.skippedCount += 1;
84
+ api.log.warn(
85
+ "todo-listener: no mailbox available on api \u2014 broadcasts disabled. Add `mailbox` to the setupPlugins() call to enable cross-agent visibility."
86
+ );
87
+ return;
88
+ }
89
+ const inp = input.toolInput ?? {};
90
+ const todos = Array.isArray(inp.todos) ? inp.todos : [];
91
+ const inProgress = todos.find((t) => t.status === "in_progress");
92
+ const pending = todos.filter((t) => t.status === "pending").length;
93
+ const completed = todos.filter((t) => t.status === "completed").length;
94
+ const payload = {
95
+ count: todos.length,
96
+ inProgress: inProgress ? { id: inProgress.id, content: inProgress.content } : null,
97
+ pending,
98
+ completed,
99
+ items: todos.map((t) => ({ id: t.id, status: t.status, content: t.content }))
100
+ };
101
+ const hash = hashTodos(todos);
102
+ if (cfg.broadcastOnChange && hash === state.lastPayloadHash) {
103
+ state.skippedCount += 1;
104
+ return;
105
+ }
106
+ const now = Date.now();
107
+ if (now - state.lastBroadcastAt < cfg.cooldownMs) {
108
+ state.skippedCount += 1;
109
+ return;
110
+ }
111
+ const subject = `${cfg.subjectPrefix}${inProgress ? `working on '${inProgress.content}'` : `${todos.length} item(s)`}`.slice(
112
+ 0,
113
+ 200
114
+ );
115
+ const body = JSON.stringify(payload, null, 2);
116
+ const sendInput = {
117
+ from: `plugin:todo-listener`,
118
+ to: "*",
119
+ type: "status",
120
+ subject,
121
+ body,
122
+ priority: "normal"
123
+ };
124
+ try {
125
+ const result = await mailbox.send(sendInput);
126
+ const id = result.id ?? null;
127
+ state.sentCount += 1;
128
+ state.lastMessageId = id;
129
+ state.lastPayloadHash = hash;
130
+ state.lastBroadcastAt = now;
131
+ api.log.info(`todo-listener: broadcast todo update`, {
132
+ count: payload.count,
133
+ inProgress: payload.inProgress?.id ?? null,
134
+ messageId: id
135
+ });
136
+ } catch (err) {
137
+ state.errorCount += 1;
138
+ api.log.warn("todo-listener: mailbox.send failed", {
139
+ error: err instanceof Error ? err.message : String(err)
140
+ });
141
+ }
142
+ };
143
+ state.hookUnregister = api.registerHook("PostToolUse", "todo", hook);
144
+ api.tools.register({
145
+ name: "todo_listener_status",
146
+ description: "Reports todo-listener state: config + per-session counters (invocations, sent, skipped, errors) and last broadcast id.",
147
+ inputSchema: { type: "object", properties: {} },
148
+ permission: "auto",
149
+ category: "Diagnostics",
150
+ mutating: false,
151
+ async execute() {
152
+ return {
153
+ ok: true,
154
+ enabled: cfg.enabled,
155
+ subjectPrefix: cfg.subjectPrefix,
156
+ broadcastOnChange: cfg.broadcastOnChange,
157
+ cooldownMs: cfg.cooldownMs,
158
+ mailboxAvailable: Boolean(mailbox),
159
+ counters: {
160
+ invocations: state.invocationCount,
161
+ sent: state.sentCount,
162
+ skipped: state.skippedCount,
163
+ errors: state.errorCount
164
+ },
165
+ lastMessageId: state.lastMessageId,
166
+ lastBroadcastAt: state.lastBroadcastAt > 0 ? new Date(state.lastBroadcastAt).toISOString() : null
167
+ };
168
+ }
169
+ });
170
+ api.log.info("todo-listener plugin loaded", {
171
+ version: "0.1.0",
172
+ enabled: cfg.enabled,
173
+ mailboxAvailable: Boolean(mailbox)
174
+ });
175
+ },
176
+ teardown(api) {
177
+ if (state.hookUnregister) {
178
+ try {
179
+ state.hookUnregister();
180
+ } catch {
181
+ }
182
+ state.hookUnregister = null;
183
+ }
184
+ const final = {
185
+ invocations: state.invocationCount,
186
+ sent: state.sentCount,
187
+ skipped: state.skippedCount,
188
+ errors: state.errorCount
189
+ };
190
+ state.invocationCount = 0;
191
+ state.sentCount = 0;
192
+ state.skippedCount = 0;
193
+ state.errorCount = 0;
194
+ state.lastMessageId = null;
195
+ state.lastPayloadHash = "";
196
+ state.lastBroadcastAt = 0;
197
+ api.log.info("todo-listener: teardown complete", { final });
198
+ },
199
+ async health() {
200
+ const base = `todo-listener: ${state.invocationCount} invocation(s), ${state.sentCount} sent, ${state.skippedCount} skipped, ${state.errorCount} error(s)`;
201
+ return {
202
+ ok: true,
203
+ message: state.lastMessageId ? `${base}; last broadcast ${state.lastMessageId}` : `${base}; no broadcast yet`,
204
+ counters: {
205
+ invocations: state.invocationCount,
206
+ sent: state.sentCount,
207
+ skipped: state.skippedCount,
208
+ errors: state.errorCount
209
+ },
210
+ lastMessageId: state.lastMessageId
211
+ };
212
+ }
213
+ };
214
+ var todo_listener_default = plugin;
215
+
216
+ export { todo_listener_default as default };
@@ -0,0 +1,5 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ declare const plugin: Plugin;
4
+
5
+ export { plugin as default };