@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,421 @@
1
+ // src/session-recap/index.ts
2
+ var state = {
3
+ recapsPublished: 0,
4
+ recapsErrored: 0,
5
+ recapsSkipped: 0,
6
+ aiSummariesWritten: 0,
7
+ aiSummaryErrors: 0,
8
+ stopInvocations: 0,
9
+ totalInputTokens: 0,
10
+ totalOutputTokens: 0,
11
+ perModel: /* @__PURE__ */ new Map(),
12
+ toolCounts: /* @__PURE__ */ new Map(),
13
+ commitCount: 0,
14
+ startedAt: null,
15
+ lastActivityAt: null,
16
+ stopHookUnregister: null,
17
+ eventUnsubscribers: []
18
+ };
19
+ var DEFAULTS = {
20
+ enabled: true,
21
+ subjectPrefix: "session recap: ",
22
+ includeTranscriptTail: 3,
23
+ maxBodyChars: 8e3,
24
+ aiSummary: false
25
+ };
26
+ function readConfig(raw) {
27
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
28
+ const r = raw;
29
+ return {
30
+ enabled: r["enabled"] !== false,
31
+ subjectPrefix: typeof r["subjectPrefix"] === "string" ? r["subjectPrefix"] : DEFAULTS.subjectPrefix,
32
+ includeTranscriptTail: typeof r["includeTranscriptTail"] === "number" && r["includeTranscriptTail"] >= 0 ? r["includeTranscriptTail"] : DEFAULTS.includeTranscriptTail,
33
+ maxBodyChars: typeof r["maxBodyChars"] === "number" && r["maxBodyChars"] > 0 ? r["maxBodyChars"] : DEFAULTS.maxBodyChars,
34
+ aiSummary: r["aiSummary"] === true
35
+ };
36
+ }
37
+ function touchActivity() {
38
+ const now = (/* @__PURE__ */ new Date()).toISOString();
39
+ if (state.startedAt === null) state.startedAt = now;
40
+ state.lastActivityAt = now;
41
+ }
42
+ function bumpModelUsage(model, inputTokens, outputTokens) {
43
+ let m = state.perModel.get(model);
44
+ if (!m) {
45
+ m = { inputTokens: 0, outputTokens: 0, invocations: 0 };
46
+ state.perModel.set(model, m);
47
+ }
48
+ m.inputTokens += inputTokens;
49
+ m.outputTokens += outputTokens;
50
+ m.invocations += 1;
51
+ state.totalInputTokens += inputTokens;
52
+ state.totalOutputTokens += outputTokens;
53
+ }
54
+ function bumpToolCount(name) {
55
+ state.toolCounts.set(name, (state.toolCounts.get(name) ?? 0) + 1);
56
+ }
57
+ function formatDuration(startedAt, lastActivityAt) {
58
+ if (!startedAt) return "0s";
59
+ const start = Date.parse(startedAt);
60
+ const end = lastActivityAt ? Date.parse(lastActivityAt) : Date.now();
61
+ const ms = Math.max(0, end - start);
62
+ const sec = Math.floor(ms / 1e3);
63
+ if (sec < 60) return `${sec}s`;
64
+ const min = Math.floor(sec / 60);
65
+ const remSec = sec % 60;
66
+ if (min < 60) return `${min}m${remSec}s`;
67
+ const hr = Math.floor(min / 60);
68
+ const remMin = min % 60;
69
+ return `${hr}h${remMin}m`;
70
+ }
71
+ function topN(map, n) {
72
+ return [...map.entries()].sort((a, b) => {
73
+ const av = a[1];
74
+ const bv = b[1];
75
+ if (typeof av === "number" && typeof bv === "number") return bv - av;
76
+ return 0;
77
+ }).slice(0, n);
78
+ }
79
+ async function readTranscriptTail(transcriptPath, n) {
80
+ if (!transcriptPath || n <= 0) return [];
81
+ let raw;
82
+ try {
83
+ const { readFile } = await import('fs/promises');
84
+ raw = await readFile(transcriptPath, "utf-8");
85
+ } catch {
86
+ return [];
87
+ }
88
+ const lines = raw.split("\n").filter((l) => l.length > 0);
89
+ const tail = lines.slice(-n);
90
+ const out = [];
91
+ for (const l of tail) {
92
+ try {
93
+ out.push(JSON.parse(l));
94
+ } catch {
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+ function truncate(s, max) {
100
+ return s.length > max ? s.slice(0, max) + `
101
+
102
+ [truncated ${s.length - max} chars]` : s;
103
+ }
104
+ var plugin = {
105
+ name: "session-recap",
106
+ version: "0.1.0",
107
+ description: "Stop hook that posts a one-page session summary (tokens, tools, commits, last activity) to the project mailbox",
108
+ apiVersion: "^0.1.10",
109
+ capabilities: { tools: true, hooks: true },
110
+ defaultConfig: { ...DEFAULTS },
111
+ configSchema: {
112
+ type: "object",
113
+ properties: {
114
+ enabled: { type: "boolean", default: true, description: "Master switch." },
115
+ subjectPrefix: {
116
+ type: "string",
117
+ default: DEFAULTS.subjectPrefix,
118
+ description: "Prepended to the broadcast subject."
119
+ },
120
+ includeTranscriptTail: {
121
+ type: "number",
122
+ minimum: 0,
123
+ maximum: 50,
124
+ default: 3,
125
+ description: "Number of last transcript events to include in the recap body."
126
+ },
127
+ maxBodyChars: {
128
+ type: "number",
129
+ minimum: 500,
130
+ default: 8e3,
131
+ description: "Hard cap on the recap body size (chars)."
132
+ },
133
+ aiSummary: {
134
+ type: "boolean",
135
+ default: false,
136
+ description: 'Prepend an LLM-written natural-language summary (api.llm) to the recap. Provider/model follow extensions["session-recap"].llm, then the session default.'
137
+ },
138
+ llm: {
139
+ type: "object",
140
+ description: "Optional { provider, model } override for the AI summary."
141
+ }
142
+ }
143
+ },
144
+ setup(api) {
145
+ state.recapsPublished = 0;
146
+ state.recapsErrored = 0;
147
+ state.recapsSkipped = 0;
148
+ state.aiSummariesWritten = 0;
149
+ state.aiSummaryErrors = 0;
150
+ state.stopInvocations = 0;
151
+ state.totalInputTokens = 0;
152
+ state.totalOutputTokens = 0;
153
+ state.perModel.clear();
154
+ state.toolCounts.clear();
155
+ state.commitCount = 0;
156
+ state.startedAt = null;
157
+ state.lastActivityAt = null;
158
+ state.stopHookUnregister = null;
159
+ for (const off of state.eventUnsubscribers) {
160
+ try {
161
+ off();
162
+ } catch {
163
+ }
164
+ }
165
+ state.eventUnsubscribers = [];
166
+ const cfg = readConfig(api.config.extensions?.["session-recap"]);
167
+ const mailbox = api.mailbox;
168
+ if (api.onEvent) {
169
+ const offUsage = api.onEvent("provider.response", (payload) => {
170
+ touchActivity();
171
+ const p = payload;
172
+ const model = p?.model ?? "unknown";
173
+ const input = p?.usage?.input ?? 0;
174
+ const output = p?.usage?.output ?? 0;
175
+ bumpModelUsage(model, input, output);
176
+ });
177
+ state.eventUnsubscribers.push(offUsage);
178
+ }
179
+ if (api.onPattern) {
180
+ const offTool = api.onPattern("tool.*", (eventName, payload) => {
181
+ touchActivity();
182
+ const p = payload;
183
+ const toolName = p?.tool ?? p?.name ?? eventName;
184
+ if (typeof toolName === "string") bumpToolCount(toolName);
185
+ if (toolName === "git_autocommit" || toolName.startsWith("git ")) ;
186
+ });
187
+ state.eventUnsubscribers.push(offTool);
188
+ const offResult = api.onPattern("tool.result", (_event, payload) => {
189
+ const p = payload;
190
+ if (p?.tool === "git_autocommit" && p.isError === false) {
191
+ state.commitCount += 1;
192
+ }
193
+ });
194
+ state.eventUnsubscribers.push(offResult);
195
+ }
196
+ const stopHook = async (input) => {
197
+ if (!cfg.enabled) return;
198
+ touchActivity();
199
+ state.stopInvocations += 1;
200
+ if (!mailbox) {
201
+ state.recapsSkipped += 1;
202
+ api.log.warn(
203
+ "session-recap: no mailbox available on api \u2014 recap disabled. Add `mailbox` to the setupPlugins() call to enable cross-session summaries."
204
+ );
205
+ return;
206
+ }
207
+ const transcriptPath = api.session?.transcriptPath;
208
+ const tailEvents = await readTranscriptTail(transcriptPath, cfg.includeTranscriptTail);
209
+ const duration = formatDuration(state.startedAt, state.lastActivityAt);
210
+ const recap = {
211
+ session: {
212
+ id: input.sessionId ?? null,
213
+ cwd: input.cwd ?? null,
214
+ startedAt: state.startedAt,
215
+ endedAt: state.lastActivityAt,
216
+ duration
217
+ },
218
+ tokens: {
219
+ total: { input: state.totalInputTokens, output: state.totalOutputTokens },
220
+ perModel: topN(state.perModel, 10).map(([model, u]) => ({
221
+ model,
222
+ input: u.inputTokens,
223
+ output: u.outputTokens,
224
+ invocations: u.invocations
225
+ }))
226
+ },
227
+ tools: {
228
+ totalCalls: [...state.toolCounts.values()].reduce((a, b) => a + b, 0),
229
+ uniqueTools: state.toolCounts.size,
230
+ top: topN(state.toolCounts, 5)
231
+ },
232
+ commits: state.commitCount,
233
+ transcriptTail: tailEvents.flatMap((e) => {
234
+ const entry = {};
235
+ if (e.type !== void 0) entry.type = e.type;
236
+ if (e.ts !== void 0) entry.ts = e.ts;
237
+ if (e.role !== void 0) entry.role = e.role;
238
+ if (typeof e.content === "string") entry.preview = e.content.slice(0, 200);
239
+ return [entry];
240
+ })
241
+ };
242
+ const subject = `${cfg.subjectPrefix}${recap.session.id ?? "session"} \u2014 ${duration}, ${recap.tools.totalCalls} tool calls, ${recap.tokens.total.input + recap.tokens.total.output} tokens`.slice(
243
+ 0,
244
+ 200
245
+ );
246
+ let aiSummary = null;
247
+ if (cfg.aiSummary && api.llm) {
248
+ try {
249
+ const topTools = recap.tools.top.map(([n, c]) => `${n}\xD7${c}`).join(", ") || "none";
250
+ const result = await api.llm.complete(
251
+ `Summarize this coding-agent session in 2-3 sentences for a teammate catching up. Focus on what was worked on and the scale of activity. Be concrete and terse.
252
+
253
+ Duration: ${duration}
254
+ Tool calls: ${recap.tools.totalCalls} (top: ${topTools})
255
+ Commits: ${recap.commits}
256
+ Tokens: ${recap.tokens.total.input} in / ${recap.tokens.total.output} out
257
+ ` + (recap.transcriptTail.length > 0 ? `Recent activity: ${recap.transcriptTail.map((e) => e.preview ?? e.type ?? "").filter(Boolean).join(" | ").slice(0, 500)}` : ""),
258
+ { system: "You write concise engineering session recaps.", maxTokens: 200 }
259
+ );
260
+ const text = result.text.trim();
261
+ if (text) {
262
+ aiSummary = text;
263
+ state.aiSummariesWritten += 1;
264
+ }
265
+ } catch {
266
+ state.aiSummaryErrors += 1;
267
+ }
268
+ }
269
+ const recapWithSummary = aiSummary ? { summary: aiSummary, ...recap } : recap;
270
+ const bodyPrefix = aiSummary ? `${aiSummary}
271
+
272
+ ---
273
+ ` : "";
274
+ const body = truncate(
275
+ bodyPrefix + JSON.stringify(recapWithSummary, null, 2),
276
+ cfg.maxBodyChars
277
+ );
278
+ try {
279
+ const result = await mailbox.send({
280
+ from: "plugin:session-recap",
281
+ to: "*",
282
+ type: "status",
283
+ subject,
284
+ body,
285
+ priority: "low"
286
+ });
287
+ state.recapsPublished += 1;
288
+ api.log.info("session-recap: published session summary", {
289
+ messageId: result.id ?? null,
290
+ duration,
291
+ toolCalls: recap.tools.totalCalls,
292
+ tokensIn: recap.tokens.total.input,
293
+ tokensOut: recap.tokens.total.output
294
+ });
295
+ } catch (err) {
296
+ state.recapsErrored += 1;
297
+ api.log.warn("session-recap: mailbox.send failed", {
298
+ error: err instanceof Error ? err.message : String(err)
299
+ });
300
+ }
301
+ };
302
+ state.stopHookUnregister = api.registerHook("Stop", void 0, stopHook);
303
+ api.tools.register({
304
+ name: "session_recap_status",
305
+ description: "Reports session-recap state: config, accumulated metrics (tokens, tool calls, commits), and last recap status.",
306
+ inputSchema: { type: "object", properties: {} },
307
+ permission: "auto",
308
+ category: "Diagnostics",
309
+ mutating: false,
310
+ async execute() {
311
+ return {
312
+ ok: true,
313
+ enabled: cfg.enabled,
314
+ subjectPrefix: cfg.subjectPrefix,
315
+ includeTranscriptTail: cfg.includeTranscriptTail,
316
+ maxBodyChars: cfg.maxBodyChars,
317
+ mailboxAvailable: Boolean(mailbox),
318
+ aiSummary: cfg.aiSummary,
319
+ llmAvailable: Boolean(api.llm),
320
+ counters: {
321
+ stopInvocations: state.stopInvocations,
322
+ recapsPublished: state.recapsPublished,
323
+ recapsErrored: state.recapsErrored,
324
+ recapsSkipped: state.recapsSkipped,
325
+ aiSummariesWritten: state.aiSummariesWritten,
326
+ aiSummaryErrors: state.aiSummaryErrors
327
+ },
328
+ metrics: {
329
+ totalInputTokens: state.totalInputTokens,
330
+ totalOutputTokens: state.totalOutputTokens,
331
+ perModel: topN(state.perModel, 10).map(([model, u]) => ({
332
+ model,
333
+ input: u.inputTokens,
334
+ output: u.outputTokens,
335
+ invocations: u.invocations
336
+ })),
337
+ toolCalls: {
338
+ total: [...state.toolCounts.values()].reduce((a, b) => a + b, 0),
339
+ uniqueTools: state.toolCounts.size,
340
+ top: topN(state.toolCounts, 5)
341
+ },
342
+ commits: state.commitCount
343
+ },
344
+ timing: {
345
+ startedAt: state.startedAt,
346
+ lastActivityAt: state.lastActivityAt,
347
+ duration: formatDuration(state.startedAt, state.lastActivityAt)
348
+ }
349
+ };
350
+ }
351
+ });
352
+ api.log.info("session-recap plugin loaded", {
353
+ version: "0.1.0",
354
+ enabled: cfg.enabled,
355
+ mailboxAvailable: Boolean(mailbox)
356
+ });
357
+ },
358
+ teardown(api) {
359
+ if (state.stopHookUnregister) {
360
+ try {
361
+ state.stopHookUnregister();
362
+ } catch {
363
+ }
364
+ state.stopHookUnregister = null;
365
+ }
366
+ for (const off of state.eventUnsubscribers) {
367
+ try {
368
+ off();
369
+ } catch {
370
+ }
371
+ }
372
+ state.eventUnsubscribers = [];
373
+ const final = {
374
+ recapsPublished: state.recapsPublished,
375
+ recapsErrored: state.recapsErrored,
376
+ recapsSkipped: state.recapsSkipped,
377
+ aiSummariesWritten: state.aiSummariesWritten,
378
+ totalInputTokens: state.totalInputTokens,
379
+ totalOutputTokens: state.totalOutputTokens,
380
+ toolCalls: [...state.toolCounts.values()].reduce((a, b) => a + b, 0),
381
+ commits: state.commitCount
382
+ };
383
+ state.recapsPublished = 0;
384
+ state.recapsErrored = 0;
385
+ state.recapsSkipped = 0;
386
+ state.aiSummariesWritten = 0;
387
+ state.aiSummaryErrors = 0;
388
+ state.stopInvocations = 0;
389
+ state.totalInputTokens = 0;
390
+ state.totalOutputTokens = 0;
391
+ state.perModel.clear();
392
+ state.toolCounts.clear();
393
+ state.commitCount = 0;
394
+ state.startedAt = null;
395
+ state.lastActivityAt = null;
396
+ api.log.info("session-recap: teardown complete", { final });
397
+ },
398
+ async health() {
399
+ return {
400
+ ok: true,
401
+ message: `session-recap: ${state.stopInvocations} stop(s), ${state.recapsPublished} recap(s) published (${state.aiSummariesWritten} with AI summary), ${state.recapsErrored} error(s), ${state.totalInputTokens + state.totalOutputTokens} tokens observed`,
402
+ counters: {
403
+ stopInvocations: state.stopInvocations,
404
+ recapsPublished: state.recapsPublished,
405
+ recapsErrored: state.recapsErrored,
406
+ recapsSkipped: state.recapsSkipped,
407
+ aiSummariesWritten: state.aiSummariesWritten,
408
+ aiSummaryErrors: state.aiSummaryErrors
409
+ },
410
+ metrics: {
411
+ totalInputTokens: state.totalInputTokens,
412
+ totalOutputTokens: state.totalOutputTokens,
413
+ toolCalls: [...state.toolCounts.values()].reduce((a, b) => a + b, 0),
414
+ commits: state.commitCount
415
+ }
416
+ };
417
+ }
418
+ };
419
+ var session_recap_default = plugin;
420
+
421
+ export { session_recap_default as default };
@@ -4,6 +4,14 @@ import { join } from 'path';
4
4
 
5
5
  // src/shell-check/index.ts
6
6
  var API_VERSION = "^0.1.10";
7
+ var state = {
8
+ /** Per-session invocation count. */
9
+ invocationCount: 0,
10
+ /** Total issues found across all runs this session (success or fail). */
11
+ totalIssues: 0,
12
+ /** Most recent run summary, surfaced by health(). */
13
+ lastRun: null
14
+ };
7
15
  function runShellCheck(files, severity, cwd) {
8
16
  if (!existsSync("shellcheck")) {
9
17
  try {
@@ -92,6 +100,9 @@ var plugin = {
92
100
  }
93
101
  },
94
102
  setup(api) {
103
+ state.invocationCount = 0;
104
+ state.totalIssues = 0;
105
+ state.lastRun = null;
95
106
  api.tools.register({
96
107
  name: "shellcheck",
97
108
  description: "Run shellcheck analysis on shell script files. Pass `files` for specific files, or `directory` (optionally with `pattern`) to recursively scan for .sh files. Returns issues with file, line, column, severity, code, and message.",
@@ -130,10 +141,12 @@ var plugin = {
130
141
  category: "Code Quality",
131
142
  mutating: true,
132
143
  async execute(input) {
133
- const files = input["files"];
134
- const directory = input["directory"] ?? ".";
135
- const pattern = input["pattern"] ?? "";
136
- const severity = input["severity"] ?? "warning";
144
+ const inp = input;
145
+ const files = inp.files;
146
+ const directory = inp.directory ?? ".";
147
+ const pattern = inp.pattern ?? "";
148
+ const severity = inp.severity ?? "warning";
149
+ state.invocationCount += 1;
137
150
  let checkFiles;
138
151
  let scannedDirectories = false;
139
152
  if (files && files.length > 0) {
@@ -143,6 +156,13 @@ var plugin = {
143
156
  scannedDirectories = true;
144
157
  }
145
158
  if (checkFiles.length === 0) {
159
+ state.lastRun = {
160
+ when: (/* @__PURE__ */ new Date()).toISOString(),
161
+ filesChecked: 0,
162
+ issues: 0,
163
+ severity,
164
+ mode: scannedDirectories ? "directory" : "files"
165
+ };
146
166
  return {
147
167
  ok: true,
148
168
  filesScanned: 0,
@@ -171,6 +191,14 @@ var plugin = {
171
191
  const styleCount = issues.filter((i) => i.level === "style").length;
172
192
  api.metrics.counter("issues_found", issues.length, { severity });
173
193
  api.metrics.histogram("issues_per_file", issues.length / Math.max(checkFiles.length, 1));
194
+ state.totalIssues += issues.length;
195
+ state.lastRun = {
196
+ when: (/* @__PURE__ */ new Date()).toISOString(),
197
+ filesChecked: checkFiles.length,
198
+ issues: issues.length,
199
+ severity,
200
+ mode: scannedDirectories ? "directory" : "files"
201
+ };
174
202
  return {
175
203
  ok: true,
176
204
  mode: scannedDirectories ? "directory" : "files",
@@ -190,6 +218,26 @@ var plugin = {
190
218
  }
191
219
  });
192
220
  api.log.info("shell-check plugin loaded", { version: "0.2.0" });
221
+ },
222
+ teardown(api) {
223
+ const finalInvocations = state.invocationCount;
224
+ const finalIssues = state.totalIssues;
225
+ state.invocationCount = 0;
226
+ state.totalIssues = 0;
227
+ state.lastRun = null;
228
+ api.log.info("shell-check: teardown complete", {
229
+ invocations: finalInvocations,
230
+ totalIssues: finalIssues
231
+ });
232
+ },
233
+ async health() {
234
+ return {
235
+ ok: true,
236
+ message: state.lastRun === null ? `shell-check: ${state.invocationCount} run(s) this session` : `shell-check: last run checked ${state.lastRun.filesChecked} file(s), ${state.lastRun.issues} issue(s) at ${state.lastRun.when}`,
237
+ invocationCount: state.invocationCount,
238
+ totalIssues: state.totalIssues,
239
+ lastRun: state.lastRun
240
+ };
193
241
  }
194
242
  };
195
243
  var shell_check_default = plugin;
@@ -0,0 +1,51 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * spec-linker plugin — markdown link auditor for plugin references.
5
+ *
6
+ * Two hooks:
7
+ * 1. **PostToolUse** on `write|edit` — READ-ONLY. Scans the saved
8
+ * file for unlinked plugin references and surfaces them to the
9
+ * LLM via `additionalContext`. The LLM decides whether to fix
10
+ * the file in a follow-up edit.
11
+ *
12
+ * 2. **PreToolUse** on `write` (NOT `edit`) — AUTO-FIX. When the
13
+ * `autoFix` config is `true`, scans the would-be content and
14
+ * returns a `modifiedInput.content` where each unlinked plugin
15
+ * reference is wrapped in a markdown link. The tool executor
16
+ * then writes the fixed content instead of the original.
17
+ *
18
+ * Why `write` only and not `edit`? The `edit` tool's input shape
19
+ * is `{ path, old_string, new_string }` — `new_string` is a small
20
+ * patch, not the whole file. To auto-fix `edit` cleanly we'd have
21
+ * to either:
22
+ * - parse the file, find where `old_string` lives, substitute
23
+ * `new_string` with the auto-fixed version, and re-derive
24
+ * the new `old_string` (a hard string-diff problem), or
25
+ * - reject `edit` and force `write` (bad UX).
26
+ * Both are too complex for the win. `write` is the common case
27
+ * for new files; `edit` stays read-only and the PostToolUse
28
+ * context tells the LLM what to fix.
29
+ *
30
+ * The plugin catalog is sourced from `../catalog.js` (single source
31
+ * of truth — adding a new plugin to the catalog table there is
32
+ * enough for this plugin to start detecting it).
33
+ *
34
+ * Config (`config.extensions['spec-linker']`):
35
+ *
36
+ * ```jsonc
37
+ * {
38
+ * "enabled": true,
39
+ * "fileGlobs": ["**\/*.md", "**\/*.mdx"],
40
+ * "maxReferences": 8,
41
+ * "autoFix": false // when true, PreToolUse on `write` wraps unlinked
42
+ * // references in markdown links via modifiedInput
43
+ * }
44
+ * ```
45
+ *
46
+ * @public
47
+ */
48
+
49
+ declare const plugin: Plugin;
50
+
51
+ export { plugin as default };