@wrongstack/plugins 0.309.1 → 0.310.1

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.
@@ -45,6 +45,8 @@ export interface ModelPricing {
45
45
  input: number;
46
46
  /** Cost per 1M output (completion) tokens in USD. */
47
47
  output: number;
48
+ /** Cost per 1M prompt-cache read tokens; input rate is the safe fallback. */
49
+ cacheRead?: number | undefined;
48
50
  }
49
51
  declare const plugin: Plugin;
50
52
  export default plugin;
@@ -12,13 +12,15 @@ var __copyProps = (to, from, except, desc) => {
12
12
  };
13
13
  var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
14
14
 
15
+ // src/cost-tracker/index.ts
16
+ import { expectDefined } from "@wrongstack/core/utils";
17
+
15
18
  // src/runtime/index.ts
16
19
  var runtime_exports = {};
17
20
  __reExport(runtime_exports, runtime_star);
18
21
  import * as runtime_star from "@wrongstack/plugin-sdk/runtime";
19
22
 
20
23
  // src/cost-tracker/index.ts
21
- import { expectDefined } from "@wrongstack/core/utils";
22
24
  var API_VERSION = "^0.1.10";
23
25
  var PRICING = {
24
26
  "gpt-4o": { input: 5, output: 15 },
@@ -48,14 +50,14 @@ function readCostTrackerConfig(raw) {
48
50
  };
49
51
  }
50
52
  var modelKeyCache = new runtime_exports.BoundedMap({ max: 256 });
51
- function estimateCost(model, promptTokens, completionTokens) {
53
+ function estimateCost(model, freshTokens, completionTokens, cachedTokens = 0) {
52
54
  let key = modelKeyCache.get(model);
53
55
  if (!key) {
54
56
  key = model.toLowerCase();
55
57
  modelKeyCache.set(model, key);
56
58
  }
57
59
  const pricing = pricingOverrides[key] ?? bundledFromRegistry[key] ?? PRICING[key] ?? DEFAULT_PRICING;
58
- const inputCost = promptTokens / 1e6 * pricing.input;
60
+ const inputCost = freshTokens / 1e6 * pricing.input + cachedTokens / 1e6 * (pricing.cacheRead ?? pricing.input);
59
61
  const outputCost = completionTokens / 1e6 * pricing.output;
60
62
  return inputCost + outputCost;
61
63
  }
@@ -89,12 +91,17 @@ var plugin = {
89
91
  },
90
92
  pricingOverrides: {
91
93
  type: "object",
92
- description: "Per-model pricing overrides in USD per 1M tokens. Keys are lowercased model names; values are { input, output }. Takes precedence over the bundled PRICING table.",
94
+ description: "Per-model pricing overrides in USD per 1M tokens. Values are { input, output, cacheRead? }.",
93
95
  additionalProperties: {
94
96
  type: "object",
95
97
  properties: {
96
98
  input: { type: "number", minimum: 0, description: "Cost per 1M input tokens in USD" },
97
- output: { type: "number", minimum: 0, description: "Cost per 1M output tokens in USD" }
99
+ output: { type: "number", minimum: 0, description: "Cost per 1M output tokens in USD" },
100
+ cacheRead: {
101
+ type: "number",
102
+ minimum: 0,
103
+ description: "Cost per 1M prompt-cache read tokens in USD"
104
+ }
98
105
  },
99
106
  required: ["input", "output"],
100
107
  additionalProperties: false
@@ -127,7 +134,12 @@ var plugin = {
127
134
  const input = v["input"];
128
135
  const output = v["output"];
129
136
  if (typeof input !== "number" || typeof output !== "number") continue;
130
- pricingOverrides[model.toLowerCase()] = { input, output };
137
+ const cacheRead = v["cacheRead"];
138
+ pricingOverrides[model.toLowerCase()] = {
139
+ input,
140
+ output,
141
+ ...typeof cacheRead === "number" ? { cacheRead } : {}
142
+ };
131
143
  }
132
144
  }
133
145
  if (api.modelsRegistry) {
@@ -142,7 +154,8 @@ var plugin = {
142
154
  if (cost && typeof cost.input === "number" && typeof cost.output === "number") {
143
155
  bundledFromRegistry[modelId.toLowerCase()] = {
144
156
  input: cost.input,
145
- output: cost.output
157
+ output: cost.output,
158
+ ...typeof cost.cache_read === "number" ? { cacheRead: cost.cache_read } : {}
146
159
  };
147
160
  hydrated += 1;
148
161
  }
@@ -169,10 +182,12 @@ var plugin = {
169
182
  api.onEvent("provider.response", async (payload) => {
170
183
  const usage = payload.usage;
171
184
  const model = payload.ctx?.model ?? "unknown";
172
- const promptTokens = usage.input ?? 0;
185
+ const cachedTokens = usage.cacheRead ?? 0;
186
+ const freshTokens = (usage.input ?? 0) + (usage.cacheWrite ?? 0);
187
+ const promptTokens = freshTokens + cachedTokens;
173
188
  const completionTokens = usage.output ?? 0;
174
189
  const totalTokens = promptTokens + completionTokens;
175
- const costUsd = estimateCost(model, promptTokens, completionTokens);
190
+ const costUsd = estimateCost(model, freshTokens, completionTokens, cachedTokens);
176
191
  const record = {
177
192
  promptTokens,
178
193
  completionTokens,
@@ -3,10 +3,25 @@
3
3
  *
4
4
  * Tools registered:
5
5
  * - git_autocommit: Stage files and create a commit with AI-written conventional commit messages.
6
- * Supports `files` for specific staging and `dry_run` for preview.
6
+ * Supports `files` for specific staging, `paths` for scoped pathspec staging,
7
+ * and `dry_run` for preview.
8
+ *
9
+ * Scope guard (2026-08): this tool previously committed the ENTIRE git index,
10
+ * and auto-staged every changed file in the tree when the index was empty —
11
+ * while its own `autoStage: false` default was never consulted. On a shared
12
+ * working tree that let one agent's commit absorb files another process had
13
+ * staged concurrently (observed: a release commit absorbed a concurrently
14
+ * staged workstream it never asked for). The guard:
15
+ * - `files` callers commit via `git commit --only -- <files>` — exactly
16
+ * those paths; anything else staged stays in the index for its owner.
17
+ * - `paths` callers stage ONLY changed files matching the pathspecs (git
18
+ * resolves the globs) and commit those, fenced the same way.
19
+ * - With no files/paths and an empty index, the tool now honors `autoStage`
20
+ * (default false) and returns an instructive error instead of silently
21
+ * staging the whole tree. Set `autoStage: true` for the legacy behavior.
7
22
  *
8
23
  * Note: The former `git_autocommit` and `git_autocommit` tools have been removed.
9
- * - For staging: use `git_autocommit` with `files` (it stages automatically), or `bash` with `git add`.
24
+ * - For staging: use `git_autocommit` with `files` or `paths` (it stages automatically), or `bash` with `git add`.
10
25
  * - For status: use the built-in `git` tool with `command: "status"` or `command: "diff"`.
11
26
  */
12
27
  import type { Plugin } from '@wrongstack/core/types';
@@ -74,9 +74,15 @@ function unquotePorcelainPath(raw) {
74
74
  return Buffer.from(bytes).toString("utf8");
75
75
  }
76
76
  function parsePorcelainLine(line) {
77
- const body = line.slice(3);
77
+ const twoColumn = /^[MADRCUTX?! ]{2} /.test(line);
78
+ const oneColumnTrimmed = !twoColumn && /^[MADRCUTX?!] /.test(line);
79
+ if (!twoColumn && !oneColumnTrimmed) {
80
+ const bodyAny = line.slice(3);
81
+ return bodyAny ? unquotePorcelainPath(bodyAny.trim()) : null;
82
+ }
83
+ const body = oneColumnTrimmed ? line.slice(2) : line.slice(3);
78
84
  if (!body) return null;
79
- const status = line.slice(0, 2);
85
+ const status = oneColumnTrimmed ? ` ${line.slice(0, 1)}` : line.slice(0, 2);
80
86
  if (status.includes("R") || status.includes("C")) {
81
87
  const arrow = body.lastIndexOf(" -> ");
82
88
  if (arrow !== -1) return unquotePorcelainPath(body.slice(arrow + 4).trim());
@@ -92,20 +98,38 @@ async function getStagedFiles(cwd) {
92
98
  const output = await runGit(["diff", "--cached", "--name-only"], cwd);
93
99
  return output ? output.split("\n").filter(Boolean) : [];
94
100
  }
101
+ async function getScopedStagedFiles(paths, cwd) {
102
+ const output = await runGit(["diff", "--cached", "--name-only", "--", ...paths], cwd);
103
+ return output ? output.split("\n").filter(Boolean) : [];
104
+ }
95
105
  async function stageFiles(files, cwd) {
96
- if (!files || !Array.isArray(files)) return;
97
- const existing = files.filter((f) => {
98
- try {
99
- return existsSync(f);
100
- } catch {
101
- return false;
102
- }
103
- });
104
- if (existing.length === 0) throw new Error("No files exist to stage");
105
- await runGit(["add", "--", ...existing], cwd);
106
+ if (!files || !Array.isArray(files) || files.length === 0) return;
107
+ const hasPattern = files.some((f) => /[*?[\]]/.test(f));
108
+ if (!hasPattern) {
109
+ const existing = files.filter((f) => {
110
+ try {
111
+ return existsSync(f);
112
+ } catch {
113
+ return false;
114
+ }
115
+ });
116
+ if (existing.length === 0) throw new Error("No files exist to stage");
117
+ await runGit(["add", "--", ...existing], cwd);
118
+ return;
119
+ }
120
+ await runGit(["add", "--", ...files], cwd);
121
+ }
122
+ async function commitWithMessage(message, cwd, paths) {
123
+ const scoped = paths && paths.length > 0 ? ["--only", "--", ...paths] : [];
124
+ return await runGit(["commit", "-m", message, ...scoped], cwd, GIT_COMMIT_TIMEOUT_MS);
106
125
  }
107
- async function commitWithMessage(message, cwd) {
108
- return await runGit(["commit", "-m", message], cwd, GIT_COMMIT_TIMEOUT_MS);
126
+ async function scopedPathsDrifted(paths, cwd) {
127
+ try {
128
+ const out = await runGit(["diff", "--name-only", "--", ...paths], cwd);
129
+ return out ? out.split("\n").filter(Boolean) : [];
130
+ } catch {
131
+ return [];
132
+ }
109
133
  }
110
134
  async function getWorktrees(cwd) {
111
135
  try {
@@ -148,6 +172,17 @@ async function getStagedDiff(cwd) {
148
172
  return { stat: "(unavailable)", diff: "(unavailable)" };
149
173
  }
150
174
  }
175
+ async function getScopedStagedDiff(paths, cwd) {
176
+ try {
177
+ const stat = await runGit(["diff", "--cached", "--stat", "--", ...paths], cwd);
178
+ const diff = await runGit(["diff", "--cached", "--", ...paths], cwd);
179
+ const MAX_DIFF = 2e4;
180
+ const truncated = diff.length > MAX_DIFF ? diff.slice(0, MAX_DIFF) + "\n\n... (diff truncated)" : diff;
181
+ return { stat: stat || "(no stat)", diff: truncated || "(clean)" };
182
+ } catch {
183
+ return { stat: "(unavailable)", diff: "(unavailable)" };
184
+ }
185
+ }
151
186
  async function externalChangesSinceStage(cwd) {
152
187
  try {
153
188
  const out = await runGit(["status", "--porcelain"], cwd);
@@ -219,7 +254,7 @@ function extractJsonObject(text) {
219
254
  }
220
255
  var plugin = {
221
256
  name: "git-autocommit",
222
- version: "0.2.0",
257
+ version: "0.3.0",
223
258
  description: "AI-powered git staging and conventional commit message generation",
224
259
  apiVersion: API_VERSION,
225
260
  capabilities: { tools: true, llm: true },
@@ -233,7 +268,11 @@ var plugin = {
233
268
  type: "object",
234
269
  properties: {
235
270
  conventionalCommits: { type: "boolean", default: true },
236
- autoStage: { type: "boolean", default: false },
271
+ autoStage: {
272
+ type: "boolean",
273
+ default: false,
274
+ description: "When the index is empty and no files/paths were given, stage every changed file before committing (legacy whole-tree behavior). Default false: the tool returns an instructive error instead, so a commit never absorbs unrelated concurrently staged work."
275
+ },
237
276
  defaultType: { type: "string", default: "feat" },
238
277
  useLlm: {
239
278
  type: "boolean",
@@ -263,14 +302,19 @@ var plugin = {
263
302
  };
264
303
  api.tools.register({
265
304
  name: "git_autocommit",
266
- description: "Stage files and create a git commit with an AI-generated conventional commit message. Pass files to stage specific ones, or leave empty to auto-detect all changed files.",
305
+ description: 'Stage files and create a git commit with an AI-generated conventional commit message. Pass files for exact paths, or paths (git pathspec globs like "**/package.json", "website/**") to stage only matching changed files. Commits are fenced to the staged scope \u2014 unrelated concurrently staged files are left in the index, not absorbed.',
267
306
  inputSchema: {
268
307
  type: "object",
269
308
  properties: {
270
309
  files: {
271
310
  type: "array",
272
311
  items: { type: "string" },
273
- description: "Specific files to stage. If empty, auto-detects all changed files."
312
+ description: "Specific files to stage and commit. The commit is fenced to exactly these paths."
313
+ },
314
+ paths: {
315
+ type: "array",
316
+ items: { type: "string" },
317
+ description: 'Git pathspec globs limiting what this commit may include (e.g. ["**/package.json", "CHANGELOG.md", "website/**"] for a release). Only changed files matching these patterns are staged and committed.'
274
318
  },
275
319
  type: {
276
320
  type: "string",
@@ -324,7 +368,52 @@ var plugin = {
324
368
  }
325
369
  files = rawFiles;
326
370
  }
327
- if (files && files.length > 0) {
371
+ let pathspecs;
372
+ const rawPaths = input["paths"];
373
+ if (rawPaths !== void 0) {
374
+ if (!Array.isArray(rawPaths)) {
375
+ return { ok: false, error: "paths must be an array of pathspec patterns" };
376
+ }
377
+ pathspecs = rawPaths.filter((p) => typeof p === "string" && p.length > 0);
378
+ if (pathspecs.length === 0) {
379
+ return { ok: false, error: "paths must contain at least one non-empty pattern" };
380
+ }
381
+ if (files && files.length > 0) {
382
+ return {
383
+ ok: false,
384
+ error: "Pass either files (exact paths) or paths (pathspec globs), not both \u2014 the other would be silently ignored."
385
+ };
386
+ }
387
+ }
388
+ let commitScope;
389
+ let staged = [];
390
+ if (pathspecs) {
391
+ try {
392
+ await stageFiles(pathspecs);
393
+ } catch (err) {
394
+ return {
395
+ ok: false,
396
+ error: `Failed to stage files matching paths: ${err instanceof Error ? err.message : String(err)}`
397
+ };
398
+ }
399
+ try {
400
+ staged = await getScopedStagedFiles(pathspecs);
401
+ } catch {
402
+ staged = [];
403
+ }
404
+ if (staged.length === 0) {
405
+ return {
406
+ ok: false,
407
+ error: "No changed files match the given paths \u2014 refusing to commit anything else."
408
+ };
409
+ }
410
+ commitScope = staged;
411
+ try {
412
+ staged = await getStagedFiles();
413
+ } catch {
414
+ staged = commitScope;
415
+ }
416
+ } else if (files && files.length > 0) {
328
417
  try {
329
418
  await stageFiles(files);
330
419
  } catch (err) {
@@ -333,31 +422,37 @@ var plugin = {
333
422
  error: `Failed to stage files: ${err instanceof Error ? err.message : String(err)}`
334
423
  };
335
424
  }
336
- }
337
- let staged = [];
338
- try {
339
- staged = await getStagedFiles();
340
- } catch {
341
- staged = [];
342
- }
343
- if (staged.length === 0) {
425
+ commitScope = files;
344
426
  try {
345
- const changed = await getChangedFiles();
346
- if (changed.length > 0) {
347
- try {
348
- await stageFiles(changed);
349
- } catch {
350
- }
351
- try {
352
- staged = await getStagedFiles();
353
- } catch {
354
- staged = [];
427
+ staged = await getStagedFiles();
428
+ } catch {
429
+ staged = [];
430
+ }
431
+ } else {
432
+ try {
433
+ staged = await getStagedFiles();
434
+ } catch {
435
+ staged = [];
436
+ }
437
+ if (staged.length === 0 && opts.autoStage) {
438
+ try {
439
+ const changed = await getChangedFiles();
440
+ if (changed.length > 0) {
441
+ try {
442
+ await stageFiles(changed);
443
+ } catch {
444
+ }
445
+ try {
446
+ staged = await getStagedFiles();
447
+ } catch {
448
+ staged = [];
449
+ }
355
450
  }
451
+ } catch {
356
452
  }
357
- } catch {
358
453
  }
359
454
  }
360
- const { stat, diff: stagedDiff } = await getStagedDiff();
455
+ const { stat, diff: stagedDiff } = commitScope ? await getScopedStagedDiff(commitScope) : await getStagedDiff();
361
456
  let generatedByLlm = false;
362
457
  if (wantGenerate && staged.length > 0) {
363
458
  const g = await generateCommitFromDiff(api, stat, stagedDiff);
@@ -400,9 +495,19 @@ var plugin = {
400
495
  if (staged.length === 0) {
401
496
  return {
402
497
  ok: false,
403
- error: "Nothing staged. Add files with git add or provide files input."
498
+ error: 'Nothing staged. Pass files (exact paths) or paths (pathspec globs) to scope this commit, stage with git add beforehand, or set extensions["git-autocommit"].autoStage=true to allow staging every changed file (legacy whole-tree behavior).'
404
499
  };
405
500
  }
501
+ let scopeWarning = null;
502
+ if (commitScope) {
503
+ const scopedSet = new Set(commitScope);
504
+ const foreign = staged.filter((f) => !scopedSet.has(f));
505
+ if (foreign.length > 0) {
506
+ const preview = foreign.slice(0, 10).join(", ");
507
+ const suffix = foreign.length > 10 ? ` and ${foreign.length - 10} more` : "";
508
+ scopeWarning = `\u26A0 Scope guard: ${foreign.length} staged file(s) outside the requested scope (${preview}${suffix}) were left uncommitted and remain staged for their owner.`;
509
+ }
510
+ }
406
511
  const worktreeWarn = await simultaneousEditWarning();
407
512
  const externalChanges = await externalChangesSinceStage();
408
513
  let externalWarning = null;
@@ -411,7 +516,7 @@ var plugin = {
411
516
  const suffix = externalChanges.length > 10 ? ` and ${externalChanges.length - 10} more` : "";
412
517
  externalWarning = `\u26A0 External changes detected since staging: ${preview}${suffix}. Another agent may be modifying files concurrently. These unstaged changes will NOT be included in this commit, but they indicate simultaneous edits. Review carefully.`;
413
518
  }
414
- const warning = [worktreeWarn, externalWarning].filter(Boolean).join("\n") || void 0;
519
+ const warning = [worktreeWarn, scopeWarning, externalWarning].filter(Boolean).join("\n") || void 0;
415
520
  if (dryRun) {
416
521
  return {
417
522
  ok: true,
@@ -428,16 +533,20 @@ ${stagedDiff}
428
533
  \`\`\``
429
534
  };
430
535
  }
431
- let preCommitDiff = stagedDiff;
432
- let preCommitStat = stat;
433
- if (staged.length === 0) {
434
- const fresh = await getStagedDiff();
435
- preCommitDiff = fresh.diff;
436
- preCommitStat = fresh.stat;
536
+ if (commitScope && !dryRun) {
537
+ const drifted = await scopedPathsDrifted(commitScope);
538
+ if (drifted.length > 0) {
539
+ const preview = drifted.slice(0, 10).join(", ");
540
+ const suffix = drifted.length > 10 ? ` and ${drifted.length - 10} more` : "";
541
+ return {
542
+ ok: false,
543
+ error: `Working tree changed after staging for: ${preview}${suffix}. A scoped commit takes working-tree content, so committing now could include changes that were never staged or previewed. Re-run the tool to re-stage the current content.`
544
+ };
545
+ }
437
546
  }
438
547
  let hash = "";
439
548
  try {
440
- hash = await commitWithMessage(msg);
549
+ hash = await commitWithMessage(msg, void 0, commitScope);
441
550
  } catch (err) {
442
551
  return {
443
552
  ok: false,
@@ -457,7 +566,7 @@ ${stagedDiff}
457
566
  commitType: type,
458
567
  scope: String(scope ?? ""),
459
568
  /* v8 ignore next -- staged is always an array here; the : [] fallback is defensive. */
460
- files: Array.isArray(staged) ? staged : [],
569
+ files: Array.isArray(staged) ? commitScope ?? staged : [],
461
570
  warning: warning ?? null
462
571
  });
463
572
  } catch (_err) {
@@ -466,7 +575,7 @@ ${stagedDiff}
466
575
  ok: true,
467
576
  hash,
468
577
  message: msg,
469
- stagedFiles: staged,
578
+ stagedFiles: commitScope ?? staged,
470
579
  type,
471
580
  scope: scope ?? null,
472
581
  generatedByLlm,
@@ -474,10 +583,10 @@ ${stagedDiff}
474
583
  diff: `
475
584
  ## Staged diff
476
585
 
477
- ${preCommitStat}
586
+ ${stat}
478
587
 
479
588
  \`\`\`diff
480
- ${preCommitDiff}
589
+ ${stagedDiff}
481
590
  \`\`\``
482
591
  };
483
592
  } catch (err) {
@@ -489,7 +598,7 @@ ${preCommitDiff}
489
598
  }
490
599
  });
491
600
  api.log.info("git-autocommit plugin loaded", {
492
- version: "0.2.0",
601
+ version: "0.3.0",
493
602
  conventionalCommits: opts.conventionalCommits
494
603
  });
495
604
  },
package/dist/index.js CHANGED
@@ -4370,14 +4370,14 @@ function readCostTrackerConfig(raw) {
4370
4370
  };
4371
4371
  }
4372
4372
  var modelKeyCache = new runtime_exports.BoundedMap({ max: 256 });
4373
- function estimateCost(model, promptTokens, completionTokens) {
4373
+ function estimateCost(model, freshTokens, completionTokens, cachedTokens = 0) {
4374
4374
  let key = modelKeyCache.get(model);
4375
4375
  if (!key) {
4376
4376
  key = model.toLowerCase();
4377
4377
  modelKeyCache.set(model, key);
4378
4378
  }
4379
4379
  const pricing = pricingOverrides[key] ?? bundledFromRegistry[key] ?? PRICING[key] ?? DEFAULT_PRICING;
4380
- const inputCost = promptTokens / 1e6 * pricing.input;
4380
+ const inputCost = freshTokens / 1e6 * pricing.input + cachedTokens / 1e6 * (pricing.cacheRead ?? pricing.input);
4381
4381
  const outputCost = completionTokens / 1e6 * pricing.output;
4382
4382
  return inputCost + outputCost;
4383
4383
  }
@@ -4411,12 +4411,17 @@ var plugin14 = {
4411
4411
  },
4412
4412
  pricingOverrides: {
4413
4413
  type: "object",
4414
- description: "Per-model pricing overrides in USD per 1M tokens. Keys are lowercased model names; values are { input, output }. Takes precedence over the bundled PRICING table.",
4414
+ description: "Per-model pricing overrides in USD per 1M tokens. Values are { input, output, cacheRead? }.",
4415
4415
  additionalProperties: {
4416
4416
  type: "object",
4417
4417
  properties: {
4418
4418
  input: { type: "number", minimum: 0, description: "Cost per 1M input tokens in USD" },
4419
- output: { type: "number", minimum: 0, description: "Cost per 1M output tokens in USD" }
4419
+ output: { type: "number", minimum: 0, description: "Cost per 1M output tokens in USD" },
4420
+ cacheRead: {
4421
+ type: "number",
4422
+ minimum: 0,
4423
+ description: "Cost per 1M prompt-cache read tokens in USD"
4424
+ }
4420
4425
  },
4421
4426
  required: ["input", "output"],
4422
4427
  additionalProperties: false
@@ -4449,7 +4454,12 @@ var plugin14 = {
4449
4454
  const input = v["input"];
4450
4455
  const output = v["output"];
4451
4456
  if (typeof input !== "number" || typeof output !== "number") continue;
4452
- pricingOverrides[model.toLowerCase()] = { input, output };
4457
+ const cacheRead = v["cacheRead"];
4458
+ pricingOverrides[model.toLowerCase()] = {
4459
+ input,
4460
+ output,
4461
+ ...typeof cacheRead === "number" ? { cacheRead } : {}
4462
+ };
4453
4463
  }
4454
4464
  }
4455
4465
  if (api.modelsRegistry) {
@@ -4464,7 +4474,8 @@ var plugin14 = {
4464
4474
  if (cost && typeof cost.input === "number" && typeof cost.output === "number") {
4465
4475
  bundledFromRegistry[modelId.toLowerCase()] = {
4466
4476
  input: cost.input,
4467
- output: cost.output
4477
+ output: cost.output,
4478
+ ...typeof cost.cache_read === "number" ? { cacheRead: cost.cache_read } : {}
4468
4479
  };
4469
4480
  hydrated += 1;
4470
4481
  }
@@ -4491,10 +4502,12 @@ var plugin14 = {
4491
4502
  api.onEvent("provider.response", async (payload) => {
4492
4503
  const usage = payload.usage;
4493
4504
  const model = payload.ctx?.model ?? "unknown";
4494
- const promptTokens = usage.input ?? 0;
4505
+ const cachedTokens = usage.cacheRead ?? 0;
4506
+ const freshTokens = (usage.input ?? 0) + (usage.cacheWrite ?? 0);
4507
+ const promptTokens = freshTokens + cachedTokens;
4495
4508
  const completionTokens = usage.output ?? 0;
4496
4509
  const totalTokens = promptTokens + completionTokens;
4497
- const costUsd = estimateCost(model, promptTokens, completionTokens);
4510
+ const costUsd = estimateCost(model, freshTokens, completionTokens, cachedTokens);
4498
4511
  const record = {
4499
4512
  promptTokens,
4500
4513
  completionTokens,
@@ -8412,9 +8425,15 @@ function unquotePorcelainPath(raw) {
8412
8425
  return Buffer.from(bytes).toString("utf8");
8413
8426
  }
8414
8427
  function parsePorcelainLine(line) {
8415
- const body = line.slice(3);
8428
+ const twoColumn = /^[MADRCUTX?! ]{2} /.test(line);
8429
+ const oneColumnTrimmed = !twoColumn && /^[MADRCUTX?!] /.test(line);
8430
+ if (!twoColumn && !oneColumnTrimmed) {
8431
+ const bodyAny = line.slice(3);
8432
+ return bodyAny ? unquotePorcelainPath(bodyAny.trim()) : null;
8433
+ }
8434
+ const body = oneColumnTrimmed ? line.slice(2) : line.slice(3);
8416
8435
  if (!body) return null;
8417
- const status = line.slice(0, 2);
8436
+ const status = oneColumnTrimmed ? ` ${line.slice(0, 1)}` : line.slice(0, 2);
8418
8437
  if (status.includes("R") || status.includes("C")) {
8419
8438
  const arrow = body.lastIndexOf(" -> ");
8420
8439
  if (arrow !== -1) return unquotePorcelainPath(body.slice(arrow + 4).trim());
@@ -8430,20 +8449,38 @@ async function getStagedFiles(cwd) {
8430
8449
  const output = await runGit3(["diff", "--cached", "--name-only"], cwd);
8431
8450
  return output ? output.split("\n").filter(Boolean) : [];
8432
8451
  }
8452
+ async function getScopedStagedFiles(paths, cwd) {
8453
+ const output = await runGit3(["diff", "--cached", "--name-only", "--", ...paths], cwd);
8454
+ return output ? output.split("\n").filter(Boolean) : [];
8455
+ }
8433
8456
  async function stageFiles(files, cwd) {
8434
- if (!files || !Array.isArray(files)) return;
8435
- const existing = files.filter((f) => {
8436
- try {
8437
- return existsSync2(f);
8438
- } catch {
8439
- return false;
8440
- }
8441
- });
8442
- if (existing.length === 0) throw new Error("No files exist to stage");
8443
- await runGit3(["add", "--", ...existing], cwd);
8457
+ if (!files || !Array.isArray(files) || files.length === 0) return;
8458
+ const hasPattern = files.some((f) => /[*?[\]]/.test(f));
8459
+ if (!hasPattern) {
8460
+ const existing = files.filter((f) => {
8461
+ try {
8462
+ return existsSync2(f);
8463
+ } catch {
8464
+ return false;
8465
+ }
8466
+ });
8467
+ if (existing.length === 0) throw new Error("No files exist to stage");
8468
+ await runGit3(["add", "--", ...existing], cwd);
8469
+ return;
8470
+ }
8471
+ await runGit3(["add", "--", ...files], cwd);
8472
+ }
8473
+ async function commitWithMessage(message, cwd, paths) {
8474
+ const scoped = paths && paths.length > 0 ? ["--only", "--", ...paths] : [];
8475
+ return await runGit3(["commit", "-m", message, ...scoped], cwd, GIT_COMMIT_TIMEOUT_MS);
8444
8476
  }
8445
- async function commitWithMessage(message, cwd) {
8446
- return await runGit3(["commit", "-m", message], cwd, GIT_COMMIT_TIMEOUT_MS);
8477
+ async function scopedPathsDrifted(paths, cwd) {
8478
+ try {
8479
+ const out = await runGit3(["diff", "--name-only", "--", ...paths], cwd);
8480
+ return out ? out.split("\n").filter(Boolean) : [];
8481
+ } catch {
8482
+ return [];
8483
+ }
8447
8484
  }
8448
8485
  async function getWorktrees(cwd) {
8449
8486
  try {
@@ -8486,6 +8523,17 @@ async function getStagedDiff(cwd) {
8486
8523
  return { stat: "(unavailable)", diff: "(unavailable)" };
8487
8524
  }
8488
8525
  }
8526
+ async function getScopedStagedDiff(paths, cwd) {
8527
+ try {
8528
+ const stat8 = await runGit3(["diff", "--cached", "--stat", "--", ...paths], cwd);
8529
+ const diff = await runGit3(["diff", "--cached", "--", ...paths], cwd);
8530
+ const MAX_DIFF = 2e4;
8531
+ const truncated = diff.length > MAX_DIFF ? diff.slice(0, MAX_DIFF) + "\n\n... (diff truncated)" : diff;
8532
+ return { stat: stat8 || "(no stat)", diff: truncated || "(clean)" };
8533
+ } catch {
8534
+ return { stat: "(unavailable)", diff: "(unavailable)" };
8535
+ }
8536
+ }
8489
8537
  async function externalChangesSinceStage(cwd) {
8490
8538
  try {
8491
8539
  const out = await runGit3(["status", "--porcelain"], cwd);
@@ -8557,7 +8605,7 @@ function extractJsonObject2(text) {
8557
8605
  }
8558
8606
  var plugin26 = {
8559
8607
  name: "git-autocommit",
8560
- version: "0.2.0",
8608
+ version: "0.3.0",
8561
8609
  description: "AI-powered git staging and conventional commit message generation",
8562
8610
  apiVersion: API_VERSION18,
8563
8611
  capabilities: { tools: true, llm: true },
@@ -8571,7 +8619,11 @@ var plugin26 = {
8571
8619
  type: "object",
8572
8620
  properties: {
8573
8621
  conventionalCommits: { type: "boolean", default: true },
8574
- autoStage: { type: "boolean", default: false },
8622
+ autoStage: {
8623
+ type: "boolean",
8624
+ default: false,
8625
+ description: "When the index is empty and no files/paths were given, stage every changed file before committing (legacy whole-tree behavior). Default false: the tool returns an instructive error instead, so a commit never absorbs unrelated concurrently staged work."
8626
+ },
8575
8627
  defaultType: { type: "string", default: "feat" },
8576
8628
  useLlm: {
8577
8629
  type: "boolean",
@@ -8601,14 +8653,19 @@ var plugin26 = {
8601
8653
  };
8602
8654
  api.tools.register({
8603
8655
  name: "git_autocommit",
8604
- description: "Stage files and create a git commit with an AI-generated conventional commit message. Pass files to stage specific ones, or leave empty to auto-detect all changed files.",
8656
+ description: 'Stage files and create a git commit with an AI-generated conventional commit message. Pass files for exact paths, or paths (git pathspec globs like "**/package.json", "website/**") to stage only matching changed files. Commits are fenced to the staged scope \u2014 unrelated concurrently staged files are left in the index, not absorbed.',
8605
8657
  inputSchema: {
8606
8658
  type: "object",
8607
8659
  properties: {
8608
8660
  files: {
8609
8661
  type: "array",
8610
8662
  items: { type: "string" },
8611
- description: "Specific files to stage. If empty, auto-detects all changed files."
8663
+ description: "Specific files to stage and commit. The commit is fenced to exactly these paths."
8664
+ },
8665
+ paths: {
8666
+ type: "array",
8667
+ items: { type: "string" },
8668
+ description: 'Git pathspec globs limiting what this commit may include (e.g. ["**/package.json", "CHANGELOG.md", "website/**"] for a release). Only changed files matching these patterns are staged and committed.'
8612
8669
  },
8613
8670
  type: {
8614
8671
  type: "string",
@@ -8662,7 +8719,52 @@ var plugin26 = {
8662
8719
  }
8663
8720
  files = rawFiles;
8664
8721
  }
8665
- if (files && files.length > 0) {
8722
+ let pathspecs;
8723
+ const rawPaths = input["paths"];
8724
+ if (rawPaths !== void 0) {
8725
+ if (!Array.isArray(rawPaths)) {
8726
+ return { ok: false, error: "paths must be an array of pathspec patterns" };
8727
+ }
8728
+ pathspecs = rawPaths.filter((p) => typeof p === "string" && p.length > 0);
8729
+ if (pathspecs.length === 0) {
8730
+ return { ok: false, error: "paths must contain at least one non-empty pattern" };
8731
+ }
8732
+ if (files && files.length > 0) {
8733
+ return {
8734
+ ok: false,
8735
+ error: "Pass either files (exact paths) or paths (pathspec globs), not both \u2014 the other would be silently ignored."
8736
+ };
8737
+ }
8738
+ }
8739
+ let commitScope;
8740
+ let staged = [];
8741
+ if (pathspecs) {
8742
+ try {
8743
+ await stageFiles(pathspecs);
8744
+ } catch (err) {
8745
+ return {
8746
+ ok: false,
8747
+ error: `Failed to stage files matching paths: ${err instanceof Error ? err.message : String(err)}`
8748
+ };
8749
+ }
8750
+ try {
8751
+ staged = await getScopedStagedFiles(pathspecs);
8752
+ } catch {
8753
+ staged = [];
8754
+ }
8755
+ if (staged.length === 0) {
8756
+ return {
8757
+ ok: false,
8758
+ error: "No changed files match the given paths \u2014 refusing to commit anything else."
8759
+ };
8760
+ }
8761
+ commitScope = staged;
8762
+ try {
8763
+ staged = await getStagedFiles();
8764
+ } catch {
8765
+ staged = commitScope;
8766
+ }
8767
+ } else if (files && files.length > 0) {
8666
8768
  try {
8667
8769
  await stageFiles(files);
8668
8770
  } catch (err) {
@@ -8671,31 +8773,37 @@ var plugin26 = {
8671
8773
  error: `Failed to stage files: ${err instanceof Error ? err.message : String(err)}`
8672
8774
  };
8673
8775
  }
8674
- }
8675
- let staged = [];
8676
- try {
8677
- staged = await getStagedFiles();
8678
- } catch {
8679
- staged = [];
8680
- }
8681
- if (staged.length === 0) {
8776
+ commitScope = files;
8682
8777
  try {
8683
- const changed = await getChangedFiles();
8684
- if (changed.length > 0) {
8685
- try {
8686
- await stageFiles(changed);
8687
- } catch {
8688
- }
8689
- try {
8690
- staged = await getStagedFiles();
8691
- } catch {
8692
- staged = [];
8778
+ staged = await getStagedFiles();
8779
+ } catch {
8780
+ staged = [];
8781
+ }
8782
+ } else {
8783
+ try {
8784
+ staged = await getStagedFiles();
8785
+ } catch {
8786
+ staged = [];
8787
+ }
8788
+ if (staged.length === 0 && opts.autoStage) {
8789
+ try {
8790
+ const changed = await getChangedFiles();
8791
+ if (changed.length > 0) {
8792
+ try {
8793
+ await stageFiles(changed);
8794
+ } catch {
8795
+ }
8796
+ try {
8797
+ staged = await getStagedFiles();
8798
+ } catch {
8799
+ staged = [];
8800
+ }
8693
8801
  }
8802
+ } catch {
8694
8803
  }
8695
- } catch {
8696
8804
  }
8697
8805
  }
8698
- const { stat: stat8, diff: stagedDiff } = await getStagedDiff();
8806
+ const { stat: stat8, diff: stagedDiff } = commitScope ? await getScopedStagedDiff(commitScope) : await getStagedDiff();
8699
8807
  let generatedByLlm = false;
8700
8808
  if (wantGenerate && staged.length > 0) {
8701
8809
  const g = await generateCommitFromDiff(api, stat8, stagedDiff);
@@ -8738,9 +8846,19 @@ var plugin26 = {
8738
8846
  if (staged.length === 0) {
8739
8847
  return {
8740
8848
  ok: false,
8741
- error: "Nothing staged. Add files with git add or provide files input."
8849
+ error: 'Nothing staged. Pass files (exact paths) or paths (pathspec globs) to scope this commit, stage with git add beforehand, or set extensions["git-autocommit"].autoStage=true to allow staging every changed file (legacy whole-tree behavior).'
8742
8850
  };
8743
8851
  }
8852
+ let scopeWarning = null;
8853
+ if (commitScope) {
8854
+ const scopedSet = new Set(commitScope);
8855
+ const foreign = staged.filter((f) => !scopedSet.has(f));
8856
+ if (foreign.length > 0) {
8857
+ const preview = foreign.slice(0, 10).join(", ");
8858
+ const suffix = foreign.length > 10 ? ` and ${foreign.length - 10} more` : "";
8859
+ scopeWarning = `\u26A0 Scope guard: ${foreign.length} staged file(s) outside the requested scope (${preview}${suffix}) were left uncommitted and remain staged for their owner.`;
8860
+ }
8861
+ }
8744
8862
  const worktreeWarn = await simultaneousEditWarning();
8745
8863
  const externalChanges = await externalChangesSinceStage();
8746
8864
  let externalWarning = null;
@@ -8749,7 +8867,7 @@ var plugin26 = {
8749
8867
  const suffix = externalChanges.length > 10 ? ` and ${externalChanges.length - 10} more` : "";
8750
8868
  externalWarning = `\u26A0 External changes detected since staging: ${preview}${suffix}. Another agent may be modifying files concurrently. These unstaged changes will NOT be included in this commit, but they indicate simultaneous edits. Review carefully.`;
8751
8869
  }
8752
- const warning = [worktreeWarn, externalWarning].filter(Boolean).join("\n") || void 0;
8870
+ const warning = [worktreeWarn, scopeWarning, externalWarning].filter(Boolean).join("\n") || void 0;
8753
8871
  if (dryRun) {
8754
8872
  return {
8755
8873
  ok: true,
@@ -8766,16 +8884,20 @@ ${stagedDiff}
8766
8884
  \`\`\``
8767
8885
  };
8768
8886
  }
8769
- let preCommitDiff = stagedDiff;
8770
- let preCommitStat = stat8;
8771
- if (staged.length === 0) {
8772
- const fresh = await getStagedDiff();
8773
- preCommitDiff = fresh.diff;
8774
- preCommitStat = fresh.stat;
8887
+ if (commitScope && !dryRun) {
8888
+ const drifted = await scopedPathsDrifted(commitScope);
8889
+ if (drifted.length > 0) {
8890
+ const preview = drifted.slice(0, 10).join(", ");
8891
+ const suffix = drifted.length > 10 ? ` and ${drifted.length - 10} more` : "";
8892
+ return {
8893
+ ok: false,
8894
+ error: `Working tree changed after staging for: ${preview}${suffix}. A scoped commit takes working-tree content, so committing now could include changes that were never staged or previewed. Re-run the tool to re-stage the current content.`
8895
+ };
8896
+ }
8775
8897
  }
8776
8898
  let hash = "";
8777
8899
  try {
8778
- hash = await commitWithMessage(msg);
8900
+ hash = await commitWithMessage(msg, void 0, commitScope);
8779
8901
  } catch (err) {
8780
8902
  return {
8781
8903
  ok: false,
@@ -8795,7 +8917,7 @@ ${stagedDiff}
8795
8917
  commitType: type,
8796
8918
  scope: String(scope ?? ""),
8797
8919
  /* v8 ignore next -- staged is always an array here; the : [] fallback is defensive. */
8798
- files: Array.isArray(staged) ? staged : [],
8920
+ files: Array.isArray(staged) ? commitScope ?? staged : [],
8799
8921
  warning: warning ?? null
8800
8922
  });
8801
8923
  } catch (_err) {
@@ -8804,7 +8926,7 @@ ${stagedDiff}
8804
8926
  ok: true,
8805
8927
  hash,
8806
8928
  message: msg,
8807
- stagedFiles: staged,
8929
+ stagedFiles: commitScope ?? staged,
8808
8930
  type,
8809
8931
  scope: scope ?? null,
8810
8932
  generatedByLlm,
@@ -8812,10 +8934,10 @@ ${stagedDiff}
8812
8934
  diff: `
8813
8935
  ## Staged diff
8814
8936
 
8815
- ${preCommitStat}
8937
+ ${stat8}
8816
8938
 
8817
8939
  \`\`\`diff
8818
- ${preCommitDiff}
8940
+ ${stagedDiff}
8819
8941
  \`\`\``
8820
8942
  };
8821
8943
  } catch (err) {
@@ -8827,7 +8949,7 @@ ${preCommitDiff}
8827
8949
  }
8828
8950
  });
8829
8951
  api.log.info("git-autocommit plugin loaded", {
8830
- version: "0.2.0",
8952
+ version: "0.3.0",
8831
8953
  conventionalCommits: opts.conventionalCommits
8832
8954
  });
8833
8955
  },
@@ -22673,9 +22795,10 @@ var plugin60 = {
22673
22795
  var todo_listener_default = plugin60;
22674
22796
 
22675
22797
  // src/todo-tracker/index.ts
22676
- import * as fsp from "node:fs/promises";
22677
22798
  import { randomUUID } from "node:crypto";
22799
+ import * as fsp from "node:fs/promises";
22678
22800
  import { atomicWrite as atomicWrite3, ensureDir as ensureDir3 } from "@wrongstack/core/utils";
22801
+ import { nowIso } from "@wrongstack/primitives";
22679
22802
  function deriveFilePath(api) {
22680
22803
  const raw = api.config.extensions?.["todo-tracker"];
22681
22804
  const explicit = typeof raw?.["filePath"] === "string" ? raw["filePath"] : null;
@@ -22720,9 +22843,6 @@ var state56 = {
22720
22843
  /** Most recent mutation for /diag plugins visibility. */
22721
22844
  lastMutation: null
22722
22845
  };
22723
- function nowIso() {
22724
- return (/* @__PURE__ */ new Date()).toISOString();
22725
- }
22726
22846
  function ensureFile() {
22727
22847
  if (!state56.file) {
22728
22848
  state56.file = {
@@ -1,3 +1,32 @@
1
+ /**
2
+ * todo-tracker plugin — Persistent, project-scoped todo backlog that
3
+ * survives across sessions.
4
+ *
5
+ * Why a separate plugin from the built-in `todo` tool?
6
+ * - The built-in `todo` tool mutates `ctx.todos`, which is
7
+ * session-scoped and auto-clears when all items complete.
8
+ * - todo-tracker writes to disk (`~/.wrongstack/projects/<slug>/todo-tracker.json`)
9
+ * and survives across sessions. Items are explicit add/complete;
10
+ * no auto-clear.
11
+ *
12
+ * Use cases:
13
+ * - Backlog of work the user wants to track over days/weeks
14
+ * - Items the LLM noticed but didn't finish — pull them into a fresh
15
+ * session via `todo_tracker_pull` (the LLM then registers them with
16
+ * the session's `ctx.todos` via the built-in `todo` tool)
17
+ * - Per-project scratchpad that survives `wstack resume <id>`
18
+ *
19
+ * Tools registered:
20
+ * - todo_tracker_list : List items, filterable by status/tag/priority
21
+ * - todo_tracker_add : Append a new item
22
+ * - todo_tracker_complete : Mark an item completed (idempotent)
23
+ * - todo_tracker_drop : Mark an item dropped (idempotent)
24
+ * - todo_tracker_remove : Permanently delete by id
25
+ * - todo_tracker_pull : Return pending items for LLM to promote
26
+ * into the session's ctx.todos via the
27
+ * built-in `todo` tool
28
+ * - todo_tracker_status : Counters + last update timestamp
29
+ */
1
30
  import type { Plugin } from '@wrongstack/core/types';
2
31
  declare const plugin: Plugin;
3
32
  export default plugin;
@@ -1,7 +1,8 @@
1
1
  // src/todo-tracker/index.ts
2
- import * as fsp from "node:fs/promises";
3
2
  import { randomUUID } from "node:crypto";
3
+ import * as fsp from "node:fs/promises";
4
4
  import { atomicWrite, ensureDir } from "@wrongstack/core/utils";
5
+ import { nowIso } from "@wrongstack/primitives";
5
6
  function deriveFilePath(api) {
6
7
  const raw = api.config.extensions?.["todo-tracker"];
7
8
  const explicit = typeof raw?.["filePath"] === "string" ? raw["filePath"] : null;
@@ -46,9 +47,6 @@ var state = {
46
47
  /** Most recent mutation for /diag plugins visibility. */
47
48
  lastMutation: null
48
49
  };
49
- function nowIso() {
50
- return (/* @__PURE__ */ new Date()).toISOString();
51
- }
52
50
  function ensureFile() {
53
51
  if (!state.file) {
54
52
  state.file = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/plugins",
3
- "version": "0.309.1",
3
+ "version": "0.310.1",
4
4
  "description": "Official WrongStack collection of focused plugins for code quality, security, observability, planning, and agent coordination",
5
5
  "license": "MIT",
6
6
  "author": "ECOSTACK TECHNOLOGY OÜ",
@@ -303,9 +303,10 @@
303
303
  "vitest": "^4.1.11"
304
304
  },
305
305
  "dependencies": {
306
- "@wrongstack/plugin-sdk": "0.309.1",
307
- "@wrongstack/core": "0.309.1",
308
- "@wrongstack/tools": "0.309.1"
306
+ "@wrongstack/core": "0.310.1",
307
+ "@wrongstack/plugin-sdk": "0.310.1",
308
+ "@wrongstack/primitives": "0.310.1",
309
+ "@wrongstack/tools": "0.310.1"
309
310
  },
310
311
  "scripts": {
311
312
  "build": "node ../../scripts/build-package.mjs",