@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.
- package/dist/git-autocommit/index.d.ts +17 -2
- package/dist/git-autocommit.js +162 -53
- package/dist/index.js +169 -54
- package/dist/notify-hub/webhook-channel.d.ts +6 -0
- package/dist/notify-hub.js +7 -1
- package/package.json +5 -5
|
@@ -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
|
|
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';
|
package/dist/git-autocommit.js
CHANGED
|
@@ -74,9 +74,15 @@ function unquotePorcelainPath(raw) {
|
|
|
74
74
|
return Buffer.from(bytes).toString("utf8");
|
|
75
75
|
}
|
|
76
76
|
function parsePorcelainLine(line) {
|
|
77
|
-
const
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
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.
|
|
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: {
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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:
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
${
|
|
586
|
+
${stat}
|
|
478
587
|
|
|
479
588
|
\`\`\`diff
|
|
480
|
-
${
|
|
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.
|
|
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
|
|
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
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
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:
|
|
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
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
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
|
-
${
|
|
8937
|
+
${stat8}
|
|
8829
8938
|
|
|
8830
8939
|
\`\`\`diff
|
|
8831
|
-
${
|
|
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.
|
|
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
|
-
|
|
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;
|
package/dist/notify-hub.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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/
|
|
307
|
-
"@wrongstack/
|
|
308
|
-
"@wrongstack/
|
|
309
|
-
"@wrongstack/
|
|
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",
|