@wrongstack/plugins 1.0.7 → 1.0.10

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/dist/accessibility-auditor/index.d.ts +1 -1
  2. package/dist/accessibility-auditor.js +15 -3
  3. package/dist/agent-handoff.js +6 -6
  4. package/dist/auto-doc/index.d.ts +1 -1
  5. package/dist/auto-doc.js +31 -20
  6. package/dist/auto-i18n-extractor/index.d.ts +1 -1
  7. package/dist/auto-i18n-extractor.js +12 -8
  8. package/dist/branch-guard.js +3 -3
  9. package/dist/changelog-writer/index.d.ts +1 -1
  10. package/dist/changelog-writer.js +19 -10
  11. package/dist/checkpoint/index.d.ts +1 -1
  12. package/dist/checkpoint.js +38 -24
  13. package/dist/code-metrics/index.d.ts +1 -1
  14. package/dist/code-metrics.js +12 -4
  15. package/dist/commit-validator.js +5 -5
  16. package/dist/context-pins/index.d.ts +1 -1
  17. package/dist/context-pins.js +12 -9
  18. package/dist/cost-tracker.js +21 -9
  19. package/dist/cron/index.d.ts +1 -1
  20. package/dist/cron.js +18 -5
  21. package/dist/dead-code-detector/index.d.ts +1 -1
  22. package/dist/dead-code-detector.js +14 -12
  23. package/dist/duplicate-code-detector/index.d.ts +1 -1
  24. package/dist/duplicate-code-detector.js +11 -3
  25. package/dist/feature-flag-tracker/index.d.ts +1 -1
  26. package/dist/feature-flag-tracker.js +12 -4
  27. package/dist/file-watcher/index.d.ts +1 -1
  28. package/dist/file-watcher.js +37 -38
  29. package/dist/git-autocommit/index.d.ts +1 -1
  30. package/dist/git-autocommit.js +44 -39
  31. package/dist/gitignore-guard/index.d.ts +1 -1
  32. package/dist/gitignore-guard.js +12 -6
  33. package/dist/index.js +1534 -1080
  34. package/dist/interface-contract-guard/index.d.ts +1 -1
  35. package/dist/interface-contract-guard.js +12 -4
  36. package/dist/knowledge-graph/index.d.ts +1 -1
  37. package/dist/knowledge-graph.js +11 -9
  38. package/dist/migration-planner/index.d.ts +1 -1
  39. package/dist/migration-planner.js +6 -2
  40. package/dist/notify-hub/index.d.ts +1 -1
  41. package/dist/notify-hub.js +12 -11
  42. package/dist/performance-regression-gate/index.d.ts +1 -1
  43. package/dist/performance-regression-gate.js +33 -13
  44. package/dist/pr-drafter/index.d.ts +10 -1
  45. package/dist/pr-drafter.js +57 -26
  46. package/dist/refactor-suggester/index.d.ts +1 -1
  47. package/dist/refactor-suggester.js +17 -4
  48. package/dist/release-notes-generator.js +2 -2
  49. package/dist/secret-scanner/index.d.ts +1 -1
  50. package/dist/secret-scanner.js +11 -3
  51. package/dist/security-hotspot-scanner/index.d.ts +1 -1
  52. package/dist/security-hotspot-scanner.js +6 -2
  53. package/dist/semantic-search-indexer/index.d.ts +1 -1
  54. package/dist/semantic-search-indexer.js +19 -3
  55. package/dist/semver-bump/index.d.ts +1 -1
  56. package/dist/semver-bump.js +57 -37
  57. package/dist/session-recap.js +4 -2
  58. package/dist/shell-check/index.d.ts +1 -1
  59. package/dist/shell-check.js +30 -39
  60. package/dist/smart-rename/index.d.ts +1 -1
  61. package/dist/smart-rename.js +23 -10
  62. package/dist/template-engine/index.d.ts +1 -1
  63. package/dist/template-engine.js +51 -38
  64. package/dist/test-flake-detector/index.d.ts +1 -1
  65. package/dist/test-flake-detector.js +11 -5
  66. package/dist/test-generator/index.d.ts +1 -1
  67. package/dist/test-generator.js +12 -8
  68. package/dist/todo-tracker/index.d.ts +68 -1
  69. package/dist/todo-tracker.js +579 -395
  70. package/dist/token-budget.js +7 -4
  71. package/dist/token-throttle.js +6 -3
  72. package/package.json +7 -7
@@ -1,447 +1,631 @@
1
1
  // src/todo-tracker/index.ts
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fsp from "node:fs/promises";
4
- import { dirname } from "node:path";
5
- import { atomicWrite, ensureDir } from "@wrongstack/core/utils";
4
+ import { basename, dirname, extname } from "node:path";
5
+ import { ToolValidationError } from "@wrongstack/core/types";
6
+ import { atomicWrite, ensureDir, withFileLock } from "@wrongstack/core/utils";
6
7
  import { nowIso } from "@wrongstack/primitives";
8
+ var STATUSES = ["pending", "in_progress", "completed", "dropped"];
9
+ var PRIORITIES = ["low", "normal", "high"];
10
+ var defaultDeps = {
11
+ readFile: (p) => fsp.readFile(p, "utf8"),
12
+ rename: (from, to) => fsp.rename(from, to),
13
+ atomicWrite: (p, content, opts) => atomicWrite(p, content, opts),
14
+ withFileLock: (p, fn) => withFileLock(p, fn),
15
+ ensureDir: (dir) => ensureDir(dir)
16
+ };
17
+ function deriveProjectSlug(filePath) {
18
+ const base = basename(filePath.replace(/[\\/]+$/, "").replace(/\\/g, "/"));
19
+ const ext = extname(base);
20
+ const stem = ext && ext !== base ? base.slice(0, -ext.length) : base;
21
+ return stem || "tracker";
22
+ }
7
23
  function deriveFilePath(api) {
8
24
  const raw = api.config.extensions?.["todo-tracker"];
9
25
  const rawPath = raw?.["filePath"] ?? raw?.["file_path"] ?? raw?.["path"] ?? raw?.["file"] ?? raw?.["targetFile"];
10
26
  const explicit = typeof rawPath === "string" && rawPath.trim().length > 0 ? rawPath.trim() : null;
11
27
  if (explicit) {
12
- const base = explicit.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "tracker";
13
- return { filePath: explicit, projectSlug: base };
28
+ return { filePath: explicit, projectSlug: deriveProjectSlug(explicit) };
14
29
  }
15
30
  return { filePath: null, projectSlug: null };
16
31
  }
17
32
  var FILE_VERSION = 1;
18
- async function loadFile(filePath) {
19
- let raw;
33
+ var isStr = (v) => typeof v === "string";
34
+ var isOptStr = (v) => v === void 0 || v === null || typeof v === "string";
35
+ function validateItem(raw, index) {
36
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
37
+ return `items[${index}] is not an object`;
38
+ }
39
+ const it = raw;
40
+ if (!isStr(it["id"]) || it["id"].length === 0) return `items[${index}].id is not a string`;
41
+ if (!isStr(it["content"])) return `items[${index}].content is not a string`;
42
+ if (!STATUSES.includes(it["status"])) {
43
+ return `items[${index}].status is not one of ${STATUSES.join("|")}`;
44
+ }
45
+ if (!PRIORITIES.includes(it["priority"])) {
46
+ return `items[${index}].priority is not one of ${PRIORITIES.join("|")}`;
47
+ }
48
+ if (!Array.isArray(it["tags"]) || !it["tags"].every(isStr)) {
49
+ return `items[${index}].tags is not a string[]`;
50
+ }
51
+ if (!isStr(it["createdAt"]) || !isStr(it["updatedAt"])) {
52
+ return `items[${index}] timestamps are not strings`;
53
+ }
54
+ if (!isOptStr(it["completedAt"]) || !isOptStr(it["sourceSessionId"]) || !isOptStr(it["notes"])) {
55
+ return `items[${index}] optional fields are not string|null`;
56
+ }
57
+ return {
58
+ id: it["id"],
59
+ content: it["content"],
60
+ status: it["status"],
61
+ priority: it["priority"],
62
+ tags: [...it["tags"]],
63
+ createdAt: it["createdAt"],
64
+ updatedAt: it["updatedAt"],
65
+ completedAt: it["completedAt"] ?? null,
66
+ sourceSessionId: it["sourceSessionId"] ?? null,
67
+ notes: it["notes"] ?? null
68
+ };
69
+ }
70
+ function parseTrackerFile(rawText) {
71
+ const text = rawText.charCodeAt(0) === 65279 ? rawText.slice(1) : rawText;
72
+ let parsed;
20
73
  try {
21
- raw = await fsp.readFile(filePath, "utf8");
74
+ parsed = JSON.parse(text);
22
75
  } catch (err) {
23
- if (err.code === "ENOENT") return null;
24
- throw err;
76
+ return { kind: "corrupt", reason: `invalid JSON: ${err.message}` };
25
77
  }
26
- try {
27
- const parsed = JSON.parse(raw);
28
- if (parsed.version !== FILE_VERSION || !Array.isArray(parsed.items)) {
29
- return null;
78
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
79
+ return { kind: "corrupt", reason: "top-level value is not an object" };
80
+ }
81
+ const obj = parsed;
82
+ if (typeof obj["version"] !== "number") {
83
+ return { kind: "corrupt", reason: "missing or non-numeric version" };
84
+ }
85
+ if (obj["version"] !== FILE_VERSION) {
86
+ return { kind: "unsupportedVersion", version: obj["version"] };
87
+ }
88
+ if (!Array.isArray(obj["items"])) {
89
+ return { kind: "invalidItems", reason: "items is not an array" };
90
+ }
91
+ const items = [];
92
+ for (const [i, rawItem] of obj["items"].entries()) {
93
+ const v = validateItem(rawItem, i);
94
+ if (typeof v === "string") return { kind: "invalidItems", reason: v };
95
+ items.push(v);
96
+ }
97
+ return {
98
+ kind: "ok",
99
+ file: {
100
+ version: FILE_VERSION,
101
+ // Any stored slug is accepted (older versions stored the basename
102
+ // including the extension); the current slug is written on next save.
103
+ projectSlug: isStr(obj["projectSlug"]) ? obj["projectSlug"] : "",
104
+ updatedAt: isStr(obj["updatedAt"]) ? obj["updatedAt"] : "",
105
+ items
30
106
  }
31
- return parsed;
32
- } catch {
33
- return null;
107
+ };
108
+ }
109
+ async function loadFile(deps, filePath) {
110
+ let raw;
111
+ try {
112
+ raw = await deps.readFile(filePath);
113
+ } catch (err) {
114
+ if (err.code === "ENOENT") return { kind: "missing" };
115
+ throw err;
34
116
  }
117
+ return parseTrackerFile(raw);
35
118
  }
36
- async function saveFile(filePath, file) {
37
- await ensureDir(dirname(filePath));
38
- await atomicWrite(filePath, JSON.stringify(file, null, 2), { mode: 384 });
119
+ function quarantineSuffix() {
120
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
39
121
  }
40
- var state = {
41
- filePath: null,
42
- projectSlug: null,
43
- file: null,
44
- addCount: 0,
45
- completeCount: 0,
46
- dropCount: 0,
47
- removeCount: 0,
48
- pullCount: 0,
49
- /** Most recent mutation for /diag plugins visibility. */
50
- lastMutation: null
51
- };
52
- function ensureFile() {
53
- if (!state.file) {
54
- state.file = {
55
- version: FILE_VERSION,
56
- projectSlug: state.projectSlug ?? "unconfigured",
57
- updatedAt: nowIso(),
58
- items: []
59
- };
60
- }
61
- return state.file;
122
+ function emptyFile(slug) {
123
+ return { version: FILE_VERSION, projectSlug: slug, updatedAt: nowIso(), items: [] };
62
124
  }
63
- function recordMutation(op, itemId) {
64
- state.lastMutation = { op, itemId, when: nowIso() };
65
- if (op === "add") state.addCount += 1;
66
- else if (op === "complete") state.completeCount += 1;
67
- else if (op === "drop") state.dropCount += 1;
68
- else if (op === "remove") state.removeCount += 1;
69
- else if (op === "pull") state.pullCount += 1;
125
+ function requireItemId(input) {
126
+ const rawId = input["id"] ?? input["itemId"] ?? input["taskId"] ?? input["todoId"];
127
+ const id = typeof rawId === "string" ? rawId.trim() : "";
128
+ if (!id) throw new ToolValidationError({ message: "id is required", field: "id" });
129
+ return id;
70
130
  }
71
- function findItemIndex(id) {
72
- return ensureFile().items.findIndex((it) => it.id === id);
131
+ function commonPrefixLength(a, b) {
132
+ const n = Math.min(a.length, b.length);
133
+ let i = 0;
134
+ while (i < n && a[i] === b[i]) i++;
135
+ return i;
73
136
  }
74
- function notConfiguredError() {
75
- return {
76
- ok: false,
77
- error: 'todo-tracker: no file path configured. Set `filePath` under `config.extensions["todo-tracker"]` or run inside a session where `paths.projectDir` is provided by the host (e.g. `wstack` CLI).'
78
- };
137
+ function requireItemIndex(file, id) {
138
+ const idx = file.items.findIndex((it) => it.id === id);
139
+ if (idx !== -1) return idx;
140
+ const open = file.items.filter((it) => it.status === "pending" || it.status === "in_progress");
141
+ const pool = open.length > 0 ? open : file.items;
142
+ const candidates = [...pool].sort((a, b) => commonPrefixLength(b.id, id) - commonPrefixLength(a.id, id)).slice(0, 5).map((it) => `${it.id} ("${it.content.slice(0, 40)}")`);
143
+ const hint = candidates.length > 0 ? ` Known ${open.length > 0 ? "open " : ""}ids: ${candidates.join(", ")}` : " The tracker is empty.";
144
+ throw new ToolValidationError({ message: `no item with id ${id}.${hint}`, field: "id" });
79
145
  }
80
- var plugin = {
81
- name: "todo-tracker",
82
- version: "0.1.0",
83
- description: "Persistent, project-scoped todo backlog that survives across sessions",
84
- apiVersion: "^0.1.10",
85
- capabilities: { tools: true },
86
- defaultConfig: {
87
- filePath: ""
88
- },
89
- configSchema: {
90
- type: "object",
91
- properties: {
92
- filePath: {
93
- type: "string",
94
- description: "Override the auto-derived per-project path. Defaults to <projectDir>/todo-tracker.json when `paths.projectDir` is provided by the host."
146
+ function createTodoTrackerPlugin(overrides = {}) {
147
+ const deps = { ...defaultDeps, ...overrides };
148
+ const instances = /* @__PURE__ */ new WeakMap();
149
+ let latest = null;
150
+ function unregisterTools(inst) {
151
+ const unregister = inst.api.tools.unregister;
152
+ for (const name of inst.registeredTools.splice(0)) {
153
+ if (typeof unregister !== "function") continue;
154
+ try {
155
+ unregister.call(inst.api.tools, name);
156
+ } catch (err) {
157
+ inst.api.log.warn("todo-tracker: failed to unregister tool", { name, err });
95
158
  }
96
159
  }
97
- },
98
- async setup(api) {
99
- state.addCount = 0;
100
- state.completeCount = 0;
101
- state.dropCount = 0;
102
- state.removeCount = 0;
103
- state.pullCount = 0;
104
- state.lastMutation = null;
105
- state.file = null;
106
- const derived = deriveFilePath(api);
107
- if (derived.filePath === null) {
108
- api.log.warn(
109
- 'todo-tracker: no file path configured (set `config.extensions["todo-tracker"].filePath` or wire `paths.projectDir` through PluginAPI) \u2014 tools will report a clear error'
160
+ }
161
+ function assertWritable(inst) {
162
+ if (inst.readOnlyReason !== null) {
163
+ throw new Error(`todo-tracker: store is read-only \u2014 ${inst.readOnlyReason}`);
164
+ }
165
+ }
166
+ function serialize(inst, fn) {
167
+ const run = inst.queue.then(fn, fn);
168
+ inst.queue = run.catch(() => void 0);
169
+ return run;
170
+ }
171
+ async function quarantineLocked(inst, reason) {
172
+ const target = `${inst.filePath}.corrupt-${quarantineSuffix()}`;
173
+ try {
174
+ await deps.rename(inst.filePath, target);
175
+ } catch (err) {
176
+ inst.readOnlyReason = `${inst.filePath} is unreadable (${reason}) and could not be moved aside (${err.message}); fix or remove the file, then reload the plugin`;
177
+ inst.api.log.error(
178
+ "todo-tracker: corrupt file could not be quarantined; store is read-only",
179
+ {
180
+ filePath: inst.filePath,
181
+ reason,
182
+ err
183
+ }
110
184
  );
111
- return;
185
+ return false;
112
186
  }
113
- state.filePath = derived.filePath;
114
- state.projectSlug = derived.projectSlug;
115
- state.file = await loadFile(state.filePath);
116
- if (state.file === null) {
117
- state.file = {
118
- version: FILE_VERSION,
119
- projectSlug: state.projectSlug ?? "tracker",
120
- updatedAt: nowIso(),
121
- items: []
122
- };
187
+ inst.degradedReason = `${inst.filePath} was unreadable (${reason}); original moved to ${target}`;
188
+ inst.api.log.error(`todo-tracker: corrupt file quarantined to ${target}`, {
189
+ filePath: inst.filePath,
190
+ quarantinedTo: target,
191
+ reason
192
+ });
193
+ return true;
194
+ }
195
+ async function resolveLoad(inst, res, opts) {
196
+ switch (res.kind) {
197
+ case "ok":
198
+ return res.file;
199
+ case "missing":
200
+ return emptyFile(inst.projectSlug);
201
+ case "unsupportedVersion": {
202
+ inst.readOnlyReason = `${inst.filePath} has format version ${JSON.stringify(res.version)}, this plugin only writes version ${FILE_VERSION}; refusing to modify it`;
203
+ inst.api.log.error("todo-tracker: unsupported file version; store is read-only", {
204
+ filePath: inst.filePath,
205
+ version: res.version
206
+ });
207
+ if (opts.strict) assertWritable(inst);
208
+ return emptyFile(inst.projectSlug);
209
+ }
210
+ case "corrupt":
211
+ case "invalidItems": {
212
+ const quarantine = async () => {
213
+ const again = opts.locked ? res : await loadFile(deps, inst.filePath);
214
+ if (again.kind === "ok") return again.file;
215
+ if (again.kind === "missing") return emptyFile(inst.projectSlug);
216
+ if (again.kind === "unsupportedVersion") {
217
+ return resolveLoad(inst, again, { locked: true, strict: opts.strict });
218
+ }
219
+ const moved = await quarantineLocked(inst, again.reason);
220
+ if (!moved && opts.strict) assertWritable(inst);
221
+ return emptyFile(inst.projectSlug);
222
+ };
223
+ return opts.locked ? quarantine() : deps.withFileLock(inst.filePath, quarantine);
224
+ }
123
225
  }
124
- api.tools.register({
125
- name: "todo_tracker_list",
126
- description: "List persistent todo-tracker items. Filterable by status, priority, and tag. By default only pending + in_progress items are shown.",
127
- inputSchema: {
128
- type: "object",
129
- properties: {
130
- status: {
131
- type: "string",
132
- enum: ["pending", "in_progress", "completed", "dropped", "all"],
133
- description: "Filter by status. 'all' returns every item; default is pending+in_progress."
134
- },
135
- priority: { type: "string", enum: ["low", "normal", "high"] },
136
- tag: { type: "string", description: "Filter by exact tag match" },
137
- limit: { type: "number", description: "Max items to return (default 50, max 200)" }
226
+ }
227
+ async function refresh(inst) {
228
+ if (inst.readOnlyReason !== null) return inst.file;
229
+ const res = await loadFile(deps, inst.filePath);
230
+ const file = await resolveLoad(inst, res, { locked: false, strict: false });
231
+ inst.file = file;
232
+ return file;
233
+ }
234
+ function mutate(inst, apply) {
235
+ return serialize(
236
+ inst,
237
+ () => deps.withFileLock(inst.filePath, async () => {
238
+ assertWritable(inst);
239
+ const res = await loadFile(deps, inst.filePath);
240
+ const current = await resolveLoad(inst, res, { locked: true, strict: true });
241
+ const draft = structuredClone(current);
242
+ const now = nowIso();
243
+ const { changed, result } = apply(draft, now);
244
+ if (changed) {
245
+ draft.version = FILE_VERSION;
246
+ draft.projectSlug = inst.projectSlug;
247
+ draft.updatedAt = now;
248
+ await deps.ensureDir(dirname(inst.filePath));
249
+ await deps.atomicWrite(inst.filePath, JSON.stringify(draft, null, 2), { mode: 384 });
250
+ inst.file = draft;
251
+ } else {
252
+ inst.file = current;
138
253
  }
139
- },
140
- permission: "auto",
141
- mutating: false,
142
- async execute(input) {
143
- if (state.filePath === null) return notConfiguredError();
144
- const rawStatus = typeof input["status"] === "string" ? input["status"].trim().toLowerCase() : void 0;
145
- const status = rawStatus ?? "active";
146
- const rawPriority = typeof input["priority"] === "string" ? input["priority"].trim().toLowerCase() : void 0;
147
- const priority = rawPriority;
148
- const tag = input["tag"];
149
- const limit = Math.min(Math.max(Number(input["limit"] ?? 50) || 50, 1), 200);
150
- const file = ensureFile();
151
- let items = file.items;
152
- if (status !== "all") {
153
- if (status === "active") {
154
- items = items.filter((it) => it.status === "pending" || it.status === "in_progress");
155
- } else {
156
- items = items.filter((it) => it.status.toLowerCase() === status);
157
- }
254
+ return result;
255
+ })
256
+ );
257
+ }
258
+ function recordMutation(inst, op, itemId) {
259
+ inst.lastMutation = { op, itemId, when: nowIso() };
260
+ if (op === "add") inst.addCount += 1;
261
+ else if (op === "complete") inst.completeCount += 1;
262
+ else if (op === "drop") inst.dropCount += 1;
263
+ else if (op === "remove") inst.removeCount += 1;
264
+ }
265
+ function sessionCounts(inst) {
266
+ return {
267
+ add: inst.addCount,
268
+ complete: inst.completeCount,
269
+ drop: inst.dropCount,
270
+ remove: inst.removeCount,
271
+ pull: inst.pullCount
272
+ };
273
+ }
274
+ function disposeInstance(api) {
275
+ const inst = instances.get(api);
276
+ if (!inst) return void 0;
277
+ unregisterTools(inst);
278
+ instances.delete(api);
279
+ if (latest === inst) latest = null;
280
+ return inst;
281
+ }
282
+ const plugin2 = {
283
+ name: "todo-tracker",
284
+ version: "0.1.0",
285
+ description: "Persistent, project-scoped todo backlog that survives across sessions",
286
+ apiVersion: "^0.1.10",
287
+ capabilities: { tools: true },
288
+ defaultConfig: {
289
+ filePath: ""
290
+ },
291
+ configSchema: {
292
+ type: "object",
293
+ properties: {
294
+ filePath: {
295
+ type: "string",
296
+ description: "Override the auto-derived per-project path. Defaults to <projectDir>/todo-tracker.json when `paths.projectDir` is provided by the host."
158
297
  }
159
- if (priority) items = items.filter((it) => it.priority.toLowerCase() === priority);
160
- if (tag) items = items.filter((it) => it.tags.includes(tag));
161
- const total = items.length;
162
- const truncated = items.slice(0, limit);
163
- return {
164
- ok: true,
165
- total,
166
- returned: truncated.length,
167
- truncated: total > truncated.length,
168
- items: truncated
169
- };
170
298
  }
171
- });
172
- api.tools.register({
173
- name: "todo_tracker_add",
174
- description: "Append a new item to the persistent todo-tracker backlog.",
175
- inputSchema: {
176
- type: "object",
177
- properties: {
178
- content: { type: "string", description: "What needs doing (required)" },
179
- priority: { type: "string", enum: ["low", "normal", "high"], default: "normal" },
180
- tags: {
181
- type: "array",
182
- items: { type: "string" },
183
- description: "Optional tags for filtering"
184
- },
185
- sourceSessionId: { type: "string", description: "Session that created this item" },
186
- notes: { type: "string", description: "Optional free-form notes" }
299
+ },
300
+ async setup(api) {
301
+ disposeInstance(api);
302
+ const derived = deriveFilePath(api);
303
+ if (derived.filePath === null) {
304
+ latest = null;
305
+ api.log.warn(
306
+ 'todo-tracker: no file path configured (set `config.extensions["todo-tracker"].filePath` or wire `paths.projectDir` through PluginAPI) \u2014 tools will report a clear error'
307
+ );
308
+ return;
309
+ }
310
+ const inst = {
311
+ api,
312
+ filePath: derived.filePath,
313
+ projectSlug: derived.projectSlug ?? "tracker",
314
+ file: emptyFile(derived.projectSlug ?? "tracker"),
315
+ readOnlyReason: null,
316
+ degradedReason: null,
317
+ queue: Promise.resolve(),
318
+ registeredTools: [],
319
+ addCount: 0,
320
+ completeCount: 0,
321
+ dropCount: 0,
322
+ removeCount: 0,
323
+ pullCount: 0,
324
+ lastMutation: null
325
+ };
326
+ instances.set(api, inst);
327
+ latest = inst;
328
+ await refresh(inst);
329
+ const register = (tool) => {
330
+ api.tools.register(tool);
331
+ inst.registeredTools.push(tool.name);
332
+ };
333
+ register({
334
+ name: "todo_tracker_list",
335
+ description: "List persistent todo-tracker items. Filterable by status, priority, and tag. By default only pending + in_progress items are shown.",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {
339
+ status: {
340
+ type: "string",
341
+ enum: ["pending", "in_progress", "completed", "dropped", "all"],
342
+ description: "Filter by status. 'all' returns every item; default is pending+in_progress."
343
+ },
344
+ priority: { type: "string", enum: ["low", "normal", "high"] },
345
+ tag: { type: "string", description: "Filter by exact tag match" },
346
+ limit: { type: "number", description: "Max items to return (default 50, max 200)" }
347
+ }
187
348
  },
188
- required: ["content"]
189
- },
190
- permission: "auto",
191
- mutating: true,
192
- async execute(input) {
193
- if (state.filePath === null) return notConfiguredError();
194
- const rawContent = input["content"] ?? input["text"] ?? input["task"] ?? input["title"] ?? input["todo"] ?? input["message"] ?? input["item"];
195
- const content = typeof rawContent === "string" ? rawContent.trim() : "";
196
- if (!content) {
197
- return { ok: false, error: "content is required and must be a non-empty string" };
349
+ permission: "auto",
350
+ mutating: false,
351
+ async execute(input) {
352
+ const rawStatus = typeof input["status"] === "string" ? input["status"].trim().toLowerCase() : void 0;
353
+ const status = rawStatus ?? "active";
354
+ const priority = typeof input["priority"] === "string" ? input["priority"].trim().toLowerCase() : void 0;
355
+ const tag = typeof input["tag"] === "string" ? input["tag"] : void 0;
356
+ const limit = Math.min(Math.max(Number(input["limit"] ?? 50) || 50, 1), 200);
357
+ const file = await refresh(inst);
358
+ let items = file.items;
359
+ if (status !== "all") {
360
+ if (status === "active") {
361
+ items = items.filter((it) => it.status === "pending" || it.status === "in_progress");
362
+ } else {
363
+ items = items.filter((it) => it.status === status);
364
+ }
365
+ }
366
+ if (priority) items = items.filter((it) => it.priority === priority);
367
+ if (tag) items = items.filter((it) => it.tags.includes(tag));
368
+ const total = items.length;
369
+ const truncated = items.slice(0, limit);
370
+ return {
371
+ ok: true,
372
+ total,
373
+ returned: truncated.length,
374
+ truncated: total > truncated.length,
375
+ items: truncated,
376
+ ...inst.readOnlyReason ? { readOnly: inst.readOnlyReason } : {}
377
+ };
198
378
  }
199
- const rawPri = typeof input["priority"] === "string" ? input["priority"].trim().toLowerCase() : "";
200
- const priority = rawPri === "low" || rawPri === "high" ? rawPri : "normal";
201
- const tags = Array.isArray(input["tags"]) ? input["tags"].filter((t) => typeof t === "string") : [];
202
- const sourceSessionId = typeof input["sourceSessionId"] === "string" ? input["sourceSessionId"] : null;
203
- const notes = typeof input["notes"] === "string" ? input["notes"] : null;
204
- const now = nowIso();
205
- const item = {
206
- id: randomUUID(),
207
- content,
208
- status: "pending",
209
- priority,
210
- tags,
211
- createdAt: now,
212
- updatedAt: now,
213
- completedAt: null,
214
- sourceSessionId,
215
- notes
216
- };
217
- const file = ensureFile();
218
- file.items.push(item);
219
- file.updatedAt = now;
220
- await saveFile(state.filePath, file);
221
- recordMutation("add", item.id);
222
- api.log.info("todo-tracker: added item", { id: item.id, content });
223
- try {
224
- await api.session?.append?.({
225
- type: "todo-tracker:add",
226
- ts: now,
227
- id: item.id,
228
- content,
229
- priority,
230
- tags
379
+ });
380
+ register({
381
+ name: "todo_tracker_add",
382
+ description: "Append a new item to the persistent todo-tracker backlog.",
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: {
386
+ content: { type: "string", description: "What needs doing (required)" },
387
+ priority: { type: "string", enum: ["low", "normal", "high"], default: "normal" },
388
+ tags: {
389
+ type: "array",
390
+ items: { type: "string" },
391
+ description: "Optional tags for filtering"
392
+ },
393
+ sourceSessionId: { type: "string", description: "Session that created this item" },
394
+ notes: { type: "string", description: "Optional free-form notes" }
395
+ },
396
+ required: ["content"]
397
+ },
398
+ permission: "auto",
399
+ mutating: true,
400
+ async execute(input) {
401
+ const rawContent = input["content"] ?? input["text"] ?? input["task"] ?? input["title"] ?? input["todo"] ?? input["message"] ?? input["item"];
402
+ const content = typeof rawContent === "string" ? rawContent.trim() : "";
403
+ if (!content) {
404
+ throw new ToolValidationError({
405
+ message: "content is required and must be a non-empty string",
406
+ field: "content"
407
+ });
408
+ }
409
+ const rawPri = typeof input["priority"] === "string" ? input["priority"].trim().toLowerCase() : "";
410
+ const priority = rawPri === "low" || rawPri === "high" ? rawPri : "normal";
411
+ const tags = Array.isArray(input["tags"]) ? input["tags"].filter((t) => typeof t === "string") : [];
412
+ const sourceSessionId = typeof input["sourceSessionId"] === "string" ? input["sourceSessionId"] : null;
413
+ const notes = typeof input["notes"] === "string" ? input["notes"] : null;
414
+ const item = await mutate(inst, (draft, now) => {
415
+ const created = {
416
+ id: randomUUID(),
417
+ content,
418
+ status: "pending",
419
+ priority,
420
+ tags,
421
+ createdAt: now,
422
+ updatedAt: now,
423
+ completedAt: null,
424
+ sourceSessionId,
425
+ notes
426
+ };
427
+ draft.items.push(created);
428
+ return { changed: true, result: created };
231
429
  });
232
- } catch {
430
+ recordMutation(inst, "add", item.id);
431
+ api.log.info("todo-tracker: added item", { id: item.id, content });
432
+ try {
433
+ await api.session?.append?.({
434
+ type: "todo-tracker:add",
435
+ ts: item.createdAt,
436
+ id: item.id,
437
+ content,
438
+ priority,
439
+ tags
440
+ });
441
+ } catch (err) {
442
+ api.log.warn("todo-tracker: session.append failed (item was saved)", {
443
+ id: item.id,
444
+ err
445
+ });
446
+ }
447
+ return { ok: true, item };
233
448
  }
234
- return { ok: true, item };
235
- }
236
- });
237
- api.tools.register({
238
- name: "todo_tracker_complete",
239
- description: "Mark a tracked item as completed. Idempotent.",
240
- inputSchema: {
241
- type: "object",
242
- properties: {
243
- id: { type: "string", description: "Item id" }
449
+ });
450
+ const setTerminalStatus = async (input, target) => {
451
+ const id = requireItemId(input);
452
+ return mutate(inst, (draft, now) => {
453
+ const item = draft.items[requireItemIndex(draft, id)];
454
+ if (item.status === target) {
455
+ return {
456
+ changed: false,
457
+ result: { ok: true, item, message: `already ${target} (idempotent)` }
458
+ };
459
+ }
460
+ item.status = target;
461
+ item.updatedAt = now;
462
+ item.completedAt = now;
463
+ return { changed: true, result: { ok: true, item } };
464
+ });
465
+ };
466
+ register({
467
+ name: "todo_tracker_complete",
468
+ description: "Mark a tracked item as completed. Idempotent.",
469
+ inputSchema: {
470
+ type: "object",
471
+ properties: {
472
+ id: { type: "string", description: "Item id" }
473
+ },
474
+ required: ["id"]
244
475
  },
245
- required: ["id"]
246
- },
247
- permission: "auto",
248
- mutating: true,
249
- async execute(input) {
250
- if (state.filePath === null) return notConfiguredError();
251
- const rawId = input["id"] ?? input["itemId"] ?? input["taskId"] ?? input["todoId"];
252
- const id = typeof rawId === "string" ? rawId.trim() : "";
253
- if (!id) return { ok: false, error: "id is required" };
254
- const idx = findItemIndex(id);
255
- if (idx === -1) return { ok: false, error: `no item with id ${id}` };
256
- const file = ensureFile();
257
- const item = file.items[idx];
258
- if (item.status === "completed") {
259
- return { ok: true, item, message: "already completed (idempotent)" };
476
+ permission: "auto",
477
+ mutating: true,
478
+ async execute(input) {
479
+ const result = await setTerminalStatus(input, "completed");
480
+ if (!result.message) {
481
+ recordMutation(inst, "complete", result.item.id);
482
+ api.log.info("todo-tracker: completed item", { id: result.item.id });
483
+ }
484
+ return result;
260
485
  }
261
- const now = nowIso();
262
- item.status = "completed";
263
- item.updatedAt = now;
264
- item.completedAt = now;
265
- file.updatedAt = now;
266
- await saveFile(state.filePath, file);
267
- recordMutation("complete", id);
268
- api.log.info("todo-tracker: completed item", { id });
269
- return { ok: true, item };
270
- }
271
- });
272
- api.tools.register({
273
- name: "todo_tracker_drop",
274
- description: "Mark a tracked item as dropped (skipped/obsolete). The row is kept for audit. Idempotent.",
275
- inputSchema: {
276
- type: "object",
277
- properties: {
278
- id: { type: "string", description: "Item id" }
486
+ });
487
+ register({
488
+ name: "todo_tracker_drop",
489
+ description: "Mark a tracked item as dropped (skipped/obsolete). The row is kept for audit. Idempotent.",
490
+ inputSchema: {
491
+ type: "object",
492
+ properties: {
493
+ id: { type: "string", description: "Item id" }
494
+ },
495
+ required: ["id"]
279
496
  },
280
- required: ["id"]
281
- },
282
- permission: "auto",
283
- mutating: true,
284
- async execute(input) {
285
- if (state.filePath === null) return notConfiguredError();
286
- const rawId = input["id"] ?? input["itemId"] ?? input["taskId"] ?? input["todoId"];
287
- const id = typeof rawId === "string" ? rawId.trim() : "";
288
- if (!id) return { ok: false, error: "id is required" };
289
- const idx = findItemIndex(id);
290
- if (idx === -1) return { ok: false, error: `no item with id ${id}` };
291
- const file = ensureFile();
292
- const item = file.items[idx];
293
- if (item.status === "dropped") {
294
- return { ok: true, item, message: "already dropped (idempotent)" };
497
+ permission: "auto",
498
+ mutating: true,
499
+ async execute(input) {
500
+ const result = await setTerminalStatus(input, "dropped");
501
+ if (!result.message) recordMutation(inst, "drop", result.item.id);
502
+ return result;
295
503
  }
296
- const now = nowIso();
297
- item.status = "dropped";
298
- item.updatedAt = now;
299
- item.completedAt = now;
300
- file.updatedAt = now;
301
- await saveFile(state.filePath, file);
302
- recordMutation("drop", id);
303
- return { ok: true, item };
304
- }
305
- });
306
- api.tools.register({
307
- name: "todo_tracker_remove",
308
- description: "Permanently delete a tracked item by id. Use todo_tracker_drop instead if you want to keep the audit row.",
309
- inputSchema: {
310
- type: "object",
311
- properties: {
312
- id: { type: "string", description: "Item id" }
504
+ });
505
+ register({
506
+ name: "todo_tracker_remove",
507
+ description: "Permanently delete a tracked item by id. Use todo_tracker_drop instead if you want to keep the audit row.",
508
+ inputSchema: {
509
+ type: "object",
510
+ properties: {
511
+ id: { type: "string", description: "Item id" }
512
+ },
513
+ required: ["id"]
313
514
  },
314
- required: ["id"]
315
- },
316
- permission: "confirm",
317
- mutating: true,
318
- async execute(input) {
319
- if (state.filePath === null) return notConfiguredError();
320
- const rawId = input["id"] ?? input["itemId"] ?? input["taskId"] ?? input["todoId"];
321
- const id = typeof rawId === "string" ? rawId.trim() : "";
322
- if (!id) return { ok: false, error: "id is required" };
323
- const idx = findItemIndex(id);
324
- if (idx === -1) return { ok: false, error: `no item with id ${id}` };
325
- const file = ensureFile();
326
- const [removed] = file.items.splice(idx, 1);
327
- file.updatedAt = nowIso();
328
- await saveFile(state.filePath, file);
329
- recordMutation("remove", id);
330
- return { ok: true, removed };
331
- }
332
- });
333
- api.tools.register({
334
- name: "todo_tracker_pull",
335
- description: "Return all pending + in_progress items. The LLM is expected to take this list and re-register each entry with the session-local `todo` tool (which mutates ctx.todos). After pull, the LLM may also choose to call todo_tracker_complete on items it finishes mid-session.",
336
- inputSchema: {
337
- type: "object",
338
- properties: {
339
- limit: { type: "number", description: "Max items to return (default 50, max 200)" }
515
+ permission: "confirm",
516
+ mutating: true,
517
+ async execute(input) {
518
+ const id = requireItemId(input);
519
+ const removed = await mutate(inst, (draft) => {
520
+ const [gone] = draft.items.splice(requireItemIndex(draft, id), 1);
521
+ return { changed: true, result: gone };
522
+ });
523
+ recordMutation(inst, "remove", id);
524
+ return { ok: true, removed };
340
525
  }
341
- },
342
- permission: "auto",
343
- mutating: false,
344
- async execute(input) {
345
- if (state.filePath === null) return notConfiguredError();
346
- const limit = Math.min(Math.max(Number(input["limit"] ?? 50) || 50, 1), 200);
347
- const file = ensureFile();
348
- const items = file.items.filter((it) => it.status === "pending" || it.status === "in_progress").slice(0, limit);
349
- if (items.length > 0) {
350
- recordMutation("pull", items[0].id);
526
+ });
527
+ register({
528
+ name: "todo_tracker_pull",
529
+ description: "Return all pending + in_progress items. The LLM is expected to take this list and re-register each entry with the session-local `todo` tool (which mutates ctx.todos). After pull, the LLM may also choose to call todo_tracker_complete on items it finishes mid-session.",
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ limit: { type: "number", description: "Max items to return (default 50, max 200)" }
534
+ }
535
+ },
536
+ permission: "auto",
537
+ mutating: false,
538
+ async execute(input) {
539
+ const limit = Math.min(Math.max(Number(input["limit"] ?? 50) || 50, 1), 200);
540
+ const file = await refresh(inst);
541
+ const open = file.items.filter(
542
+ (it) => it.status === "pending" || it.status === "in_progress"
543
+ );
544
+ const items = open.slice(0, limit);
545
+ if (items.length > 0) inst.pullCount += 1;
546
+ return {
547
+ ok: true,
548
+ total: open.length,
549
+ returned: items.length,
550
+ truncated: open.length > items.length,
551
+ items,
552
+ hint: "These are persistent items. To work on them this session, register each one with the built-in `todo` tool. Mark them `completed` via todo_tracker_complete when done."
553
+ };
351
554
  }
555
+ });
556
+ register({
557
+ name: "todo_tracker_status",
558
+ description: "Report todo-tracker counters (per-status totals) + the file path + last update timestamp.",
559
+ inputSchema: { type: "object", properties: {} },
560
+ permission: "auto",
561
+ mutating: false,
562
+ async execute() {
563
+ const file = await refresh(inst);
564
+ const byStatus = {
565
+ pending: 0,
566
+ in_progress: 0,
567
+ completed: 0,
568
+ dropped: 0
569
+ };
570
+ for (const it of file.items) {
571
+ if (STATUSES.includes(it.status)) byStatus[it.status] += 1;
572
+ }
573
+ return {
574
+ ok: true,
575
+ filePath: inst.filePath,
576
+ projectSlug: inst.projectSlug,
577
+ updatedAt: file.updatedAt,
578
+ counters: byStatus,
579
+ total: file.items.length,
580
+ session: sessionCounts(inst),
581
+ lastMutation: inst.lastMutation,
582
+ readOnly: inst.readOnlyReason,
583
+ degraded: inst.degradedReason
584
+ };
585
+ }
586
+ });
587
+ api.log.info("todo-tracker plugin loaded", {
588
+ filePath: inst.filePath,
589
+ projectSlug: inst.projectSlug,
590
+ initialItemCount: inst.file.items.length,
591
+ readOnly: inst.readOnlyReason,
592
+ degraded: inst.degradedReason
593
+ });
594
+ },
595
+ teardown(api) {
596
+ const inst = disposeInstance(api);
597
+ if (!inst) return;
598
+ api.log.info("todo-tracker: teardown complete", { sessionCounts: sessionCounts(inst) });
599
+ },
600
+ async health() {
601
+ const inst = latest;
602
+ if (inst === null) {
352
603
  return {
353
- ok: true,
354
- total: items.length,
355
- items,
356
- hint: "These are persistent items. To work on them this session, register each one with the built-in `todo` tool. Mark them `completed` via todo_tracker_complete when done."
357
- };
358
- }
359
- });
360
- api.tools.register({
361
- name: "todo_tracker_status",
362
- description: "Report todo-tracker counters (per-status totals) + the file path + last update timestamp.",
363
- inputSchema: { type: "object", properties: {} },
364
- permission: "auto",
365
- mutating: false,
366
- async execute() {
367
- if (state.filePath === null) return notConfiguredError();
368
- const file = ensureFile();
369
- const byStatus = {
370
- pending: 0,
371
- in_progress: 0,
372
- completed: 0,
373
- dropped: 0
374
- };
375
- for (const it of file.items) byStatus[it.status] += 1;
376
- return {
377
- ok: true,
378
- filePath: state.filePath,
379
- projectSlug: state.projectSlug,
380
- updatedAt: file.updatedAt,
381
- counters: byStatus,
382
- total: file.items.length,
383
- session: {
384
- add: state.addCount,
385
- complete: state.completeCount,
386
- drop: state.dropCount,
387
- remove: state.removeCount,
388
- pull: state.pullCount
389
- },
390
- lastMutation: state.lastMutation
604
+ ok: false,
605
+ message: "todo-tracker: no file path configured \u2014 tools will error"
391
606
  };
392
607
  }
393
- });
394
- api.log.info("todo-tracker plugin loaded", {
395
- filePath: state.filePath,
396
- projectSlug: state.projectSlug,
397
- initialItemCount: state.file.items.length
398
- });
399
- },
400
- teardown(api) {
401
- const finalCounts = {
402
- add: state.addCount,
403
- complete: state.completeCount,
404
- drop: state.dropCount,
405
- remove: state.removeCount,
406
- pull: state.pullCount
407
- };
408
- state.addCount = 0;
409
- state.completeCount = 0;
410
- state.dropCount = 0;
411
- state.removeCount = 0;
412
- state.pullCount = 0;
413
- state.lastMutation = null;
414
- state.file = null;
415
- state.filePath = null;
416
- state.projectSlug = null;
417
- api.log.info("todo-tracker: teardown complete", { sessionCounts: finalCounts });
418
- },
419
- async health() {
420
- if (state.filePath === null) {
608
+ const reason = inst.readOnlyReason ?? inst.degradedReason;
421
609
  return {
422
- ok: false,
423
- message: "todo-tracker: no file path configured \u2014 tools will error"
610
+ ok: reason === null,
611
+ message: reason === null ? `todo-tracker: ${inst.file.items.length} item(s) at ${inst.filePath}` : `todo-tracker: ${inst.readOnlyReason ? "read-only" : "degraded"} \u2014 ${reason}`,
612
+ filePath: inst.filePath,
613
+ projectSlug: inst.projectSlug,
614
+ total: inst.file.items.length,
615
+ readOnly: inst.readOnlyReason,
616
+ degraded: inst.degradedReason,
617
+ sessionCounts: sessionCounts(inst),
618
+ lastMutation: inst.lastMutation
424
619
  };
425
620
  }
426
- const file = ensureFile();
427
- return {
428
- ok: true,
429
- message: `todo-tracker: ${file.items.length} item(s) at ${state.filePath}`,
430
- filePath: state.filePath,
431
- projectSlug: state.projectSlug,
432
- total: file.items.length,
433
- sessionCounts: {
434
- add: state.addCount,
435
- complete: state.completeCount,
436
- drop: state.dropCount,
437
- remove: state.removeCount,
438
- pull: state.pullCount
439
- },
440
- lastMutation: state.lastMutation
441
- };
442
- }
443
- };
621
+ };
622
+ return plugin2;
623
+ }
624
+ var plugin = createTodoTrackerPlugin();
444
625
  var todo_tracker_default = plugin;
445
626
  export {
446
- todo_tracker_default as default
627
+ createTodoTrackerPlugin,
628
+ todo_tracker_default as default,
629
+ deriveProjectSlug,
630
+ parseTrackerFile
447
631
  };