@wrongstack/plugins 0.310.0 → 0.313.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.
@@ -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
@@ -8425,9 +8425,15 @@ function unquotePorcelainPath(raw) {
8425
8425
  return Buffer.from(bytes).toString("utf8");
8426
8426
  }
8427
8427
  function parsePorcelainLine(line) {
8428
- 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);
8429
8435
  if (!body) return null;
8430
- const status = line.slice(0, 2);
8436
+ const status = oneColumnTrimmed ? ` ${line.slice(0, 1)}` : line.slice(0, 2);
8431
8437
  if (status.includes("R") || status.includes("C")) {
8432
8438
  const arrow = body.lastIndexOf(" -> ");
8433
8439
  if (arrow !== -1) return unquotePorcelainPath(body.slice(arrow + 4).trim());
@@ -8443,20 +8449,38 @@ async function getStagedFiles(cwd) {
8443
8449
  const output = await runGit3(["diff", "--cached", "--name-only"], cwd);
8444
8450
  return output ? output.split("\n").filter(Boolean) : [];
8445
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
+ }
8446
8456
  async function stageFiles(files, cwd) {
8447
- if (!files || !Array.isArray(files)) return;
8448
- const existing = files.filter((f) => {
8449
- try {
8450
- return existsSync2(f);
8451
- } catch {
8452
- return false;
8453
- }
8454
- });
8455
- if (existing.length === 0) throw new Error("No files exist to stage");
8456
- 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);
8457
8472
  }
8458
- async function commitWithMessage(message, cwd) {
8459
- return await runGit3(["commit", "-m", message], cwd, GIT_COMMIT_TIMEOUT_MS);
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);
8476
+ }
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
+ }
8460
8484
  }
8461
8485
  async function getWorktrees(cwd) {
8462
8486
  try {
@@ -8499,6 +8523,17 @@ async function getStagedDiff(cwd) {
8499
8523
  return { stat: "(unavailable)", diff: "(unavailable)" };
8500
8524
  }
8501
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
+ }
8502
8537
  async function externalChangesSinceStage(cwd) {
8503
8538
  try {
8504
8539
  const out = await runGit3(["status", "--porcelain"], cwd);
@@ -8570,7 +8605,7 @@ function extractJsonObject2(text) {
8570
8605
  }
8571
8606
  var plugin26 = {
8572
8607
  name: "git-autocommit",
8573
- version: "0.2.0",
8608
+ version: "0.3.0",
8574
8609
  description: "AI-powered git staging and conventional commit message generation",
8575
8610
  apiVersion: API_VERSION18,
8576
8611
  capabilities: { tools: true, llm: true },
@@ -8584,7 +8619,11 @@ var plugin26 = {
8584
8619
  type: "object",
8585
8620
  properties: {
8586
8621
  conventionalCommits: { type: "boolean", default: true },
8587
- 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
+ },
8588
8627
  defaultType: { type: "string", default: "feat" },
8589
8628
  useLlm: {
8590
8629
  type: "boolean",
@@ -8614,14 +8653,19 @@ var plugin26 = {
8614
8653
  };
8615
8654
  api.tools.register({
8616
8655
  name: "git_autocommit",
8617
- 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.',
8618
8657
  inputSchema: {
8619
8658
  type: "object",
8620
8659
  properties: {
8621
8660
  files: {
8622
8661
  type: "array",
8623
8662
  items: { type: "string" },
8624
- 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.'
8625
8669
  },
8626
8670
  type: {
8627
8671
  type: "string",
@@ -8675,7 +8719,52 @@ var plugin26 = {
8675
8719
  }
8676
8720
  files = rawFiles;
8677
8721
  }
8678
- 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) {
8679
8768
  try {
8680
8769
  await stageFiles(files);
8681
8770
  } catch (err) {
@@ -8684,31 +8773,37 @@ var plugin26 = {
8684
8773
  error: `Failed to stage files: ${err instanceof Error ? err.message : String(err)}`
8685
8774
  };
8686
8775
  }
8687
- }
8688
- let staged = [];
8689
- try {
8690
- staged = await getStagedFiles();
8691
- } catch {
8692
- staged = [];
8693
- }
8694
- if (staged.length === 0) {
8776
+ commitScope = files;
8695
8777
  try {
8696
- const changed = await getChangedFiles();
8697
- if (changed.length > 0) {
8698
- try {
8699
- await stageFiles(changed);
8700
- } catch {
8701
- }
8702
- try {
8703
- staged = await getStagedFiles();
8704
- } catch {
8705
- 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
+ }
8706
8801
  }
8802
+ } catch {
8707
8803
  }
8708
- } catch {
8709
8804
  }
8710
8805
  }
8711
- const { stat: stat8, diff: stagedDiff } = await getStagedDiff();
8806
+ const { stat: stat8, diff: stagedDiff } = commitScope ? await getScopedStagedDiff(commitScope) : await getStagedDiff();
8712
8807
  let generatedByLlm = false;
8713
8808
  if (wantGenerate && staged.length > 0) {
8714
8809
  const g = await generateCommitFromDiff(api, stat8, stagedDiff);
@@ -8751,9 +8846,19 @@ var plugin26 = {
8751
8846
  if (staged.length === 0) {
8752
8847
  return {
8753
8848
  ok: false,
8754
- 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).'
8755
8850
  };
8756
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
+ }
8757
8862
  const worktreeWarn = await simultaneousEditWarning();
8758
8863
  const externalChanges = await externalChangesSinceStage();
8759
8864
  let externalWarning = null;
@@ -8762,7 +8867,7 @@ var plugin26 = {
8762
8867
  const suffix = externalChanges.length > 10 ? ` and ${externalChanges.length - 10} more` : "";
8763
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.`;
8764
8869
  }
8765
- const warning = [worktreeWarn, externalWarning].filter(Boolean).join("\n") || void 0;
8870
+ const warning = [worktreeWarn, scopeWarning, externalWarning].filter(Boolean).join("\n") || void 0;
8766
8871
  if (dryRun) {
8767
8872
  return {
8768
8873
  ok: true,
@@ -8779,16 +8884,20 @@ ${stagedDiff}
8779
8884
  \`\`\``
8780
8885
  };
8781
8886
  }
8782
- let preCommitDiff = stagedDiff;
8783
- let preCommitStat = stat8;
8784
- if (staged.length === 0) {
8785
- const fresh = await getStagedDiff();
8786
- preCommitDiff = fresh.diff;
8787
- 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
+ }
8788
8897
  }
8789
8898
  let hash = "";
8790
8899
  try {
8791
- hash = await commitWithMessage(msg);
8900
+ hash = await commitWithMessage(msg, void 0, commitScope);
8792
8901
  } catch (err) {
8793
8902
  return {
8794
8903
  ok: false,
@@ -8808,7 +8917,7 @@ ${stagedDiff}
8808
8917
  commitType: type,
8809
8918
  scope: String(scope ?? ""),
8810
8919
  /* v8 ignore next -- staged is always an array here; the : [] fallback is defensive. */
8811
- files: Array.isArray(staged) ? staged : [],
8920
+ files: Array.isArray(staged) ? commitScope ?? staged : [],
8812
8921
  warning: warning ?? null
8813
8922
  });
8814
8923
  } catch (_err) {
@@ -8817,7 +8926,7 @@ ${stagedDiff}
8817
8926
  ok: true,
8818
8927
  hash,
8819
8928
  message: msg,
8820
- stagedFiles: staged,
8929
+ stagedFiles: commitScope ?? staged,
8821
8930
  type,
8822
8931
  scope: scope ?? null,
8823
8932
  generatedByLlm,
@@ -8825,10 +8934,10 @@ ${stagedDiff}
8825
8934
  diff: `
8826
8935
  ## Staged diff
8827
8936
 
8828
- ${preCommitStat}
8937
+ ${stat8}
8829
8938
 
8830
8939
  \`\`\`diff
8831
- ${preCommitDiff}
8940
+ ${stagedDiff}
8832
8941
  \`\`\``
8833
8942
  };
8834
8943
  } catch (err) {
@@ -8840,7 +8949,7 @@ ${preCommitDiff}
8840
8949
  }
8841
8950
  });
8842
8951
  api.log.info("git-autocommit plugin loaded", {
8843
- version: "0.2.0",
8952
+ version: "0.3.0",
8844
8953
  conventionalCommits: opts.conventionalCommits
8845
8954
  });
8846
8955
  },
@@ -12686,7 +12795,10 @@ var WebhookNotificationChannel = class {
12686
12795
  #headers;
12687
12796
  #timeoutMs;
12688
12797
  #maxFailures;
12798
+ #resetMs;
12689
12799
  #circuit;
12800
+ /** Timestamp (ms) when the circuit last opened — for time-based half-open. */
12801
+ #openedAt = 0;
12690
12802
  /** Total deliveries attempted (across all resets). */
12691
12803
  #totalAttempted = 0;
12692
12804
  /** Total successful deliveries. */
@@ -12700,6 +12812,7 @@ var WebhookNotificationChannel = class {
12700
12812
  this.#headers = opts.headers ?? {};
12701
12813
  this.#timeoutMs = opts.timeoutMs ?? 5e3;
12702
12814
  this.#maxFailures = opts.maxConsecutiveFailures ?? 5;
12815
+ this.#resetMs = opts.circuitResetMs ?? 3e4;
12703
12816
  this.#circuit = freshCircuit();
12704
12817
  }
12705
12818
  // -----------------------------------------------------------------------
@@ -12707,7 +12820,8 @@ var WebhookNotificationChannel = class {
12707
12820
  // -----------------------------------------------------------------------
12708
12821
  async deliver(msg) {
12709
12822
  const deliveredAt = (/* @__PURE__ */ new Date()).toISOString();
12710
- if (this.#circuit.open && this.#maxFailures > 0) {
12823
+ const inCooldown = this.#resetMs > 0 && Date.now() - this.#openedAt < this.#resetMs;
12824
+ if (this.#circuit.open && this.#maxFailures > 0 && inCooldown) {
12711
12825
  this.#totalSuppressed += 1;
12712
12826
  return {
12713
12827
  ok: false,
@@ -12749,6 +12863,7 @@ var WebhookNotificationChannel = class {
12749
12863
  this.#circuit.consecutiveFailures += 1;
12750
12864
  if (this.#maxFailures > 0 && this.#circuit.consecutiveFailures >= this.#maxFailures) {
12751
12865
  this.#circuit.open = true;
12866
+ this.#openedAt = Date.now();
12752
12867
  }
12753
12868
  return {
12754
12869
  ok: false,
@@ -27,6 +27,12 @@ export interface WebhookChannelOptions {
27
27
  * failures. Default 5. Set to 0 to disable the circuit breaker.
28
28
  */
29
29
  readonly maxConsecutiveFailures?: number | undefined;
30
+ /**
31
+ * How long (ms) the circuit breaker stays open before allowing a retry
32
+ * probe. Default 30_000. Set to 0 to disable automatic recovery (keep
33
+ * the breaker latched until `resetCircuit()` is called).
34
+ */
35
+ readonly circuitResetMs?: number | undefined;
30
36
  }
31
37
  export declare class WebhookNotificationChannel implements NotificationChannel {
32
38
  #private;
@@ -31,7 +31,10 @@ var WebhookNotificationChannel = class {
31
31
  #headers;
32
32
  #timeoutMs;
33
33
  #maxFailures;
34
+ #resetMs;
34
35
  #circuit;
36
+ /** Timestamp (ms) when the circuit last opened — for time-based half-open. */
37
+ #openedAt = 0;
35
38
  /** Total deliveries attempted (across all resets). */
36
39
  #totalAttempted = 0;
37
40
  /** Total successful deliveries. */
@@ -45,6 +48,7 @@ var WebhookNotificationChannel = class {
45
48
  this.#headers = opts.headers ?? {};
46
49
  this.#timeoutMs = opts.timeoutMs ?? 5e3;
47
50
  this.#maxFailures = opts.maxConsecutiveFailures ?? 5;
51
+ this.#resetMs = opts.circuitResetMs ?? 3e4;
48
52
  this.#circuit = freshCircuit();
49
53
  }
50
54
  // -----------------------------------------------------------------------
@@ -52,7 +56,8 @@ var WebhookNotificationChannel = class {
52
56
  // -----------------------------------------------------------------------
53
57
  async deliver(msg) {
54
58
  const deliveredAt = (/* @__PURE__ */ new Date()).toISOString();
55
- if (this.#circuit.open && this.#maxFailures > 0) {
59
+ const inCooldown = this.#resetMs > 0 && Date.now() - this.#openedAt < this.#resetMs;
60
+ if (this.#circuit.open && this.#maxFailures > 0 && inCooldown) {
56
61
  this.#totalSuppressed += 1;
57
62
  return {
58
63
  ok: false,
@@ -94,6 +99,7 @@ var WebhookNotificationChannel = class {
94
99
  this.#circuit.consecutiveFailures += 1;
95
100
  if (this.#maxFailures > 0 && this.#circuit.consecutiveFailures >= this.#maxFailures) {
96
101
  this.#circuit.open = true;
102
+ this.#openedAt = Date.now();
97
103
  }
98
104
  return {
99
105
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/plugins",
3
- "version": "0.310.0",
3
+ "version": "0.313.0",
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,10 +303,10 @@
303
303
  "vitest": "^4.1.11"
304
304
  },
305
305
  "dependencies": {
306
- "@wrongstack/core": "0.310.0",
307
- "@wrongstack/tools": "0.310.0",
308
- "@wrongstack/primitives": "0.310.0",
309
- "@wrongstack/plugin-sdk": "0.310.0"
306
+ "@wrongstack/plugin-sdk": "0.313.0",
307
+ "@wrongstack/core": "0.313.0",
308
+ "@wrongstack/tools": "0.313.0",
309
+ "@wrongstack/primitives": "0.313.0"
310
310
  },
311
311
  "scripts": {
312
312
  "build": "node ../../scripts/build-package.mjs",