@muggleai/works 5.6.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/{chunk-LTULYOOW.js → chunk-5PYK7DWB.js} +15 -0
  2. package/dist/cli.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  5. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  6. package/dist/plugin/hooks/README.md +15 -3
  7. package/dist/plugin/hooks/hooks.json +41 -1
  8. package/dist/plugin/scripts/gc-state.sh +66 -0
  9. package/dist/plugin/scripts/guard-run-job-object.ps1 +256 -0
  10. package/dist/plugin/scripts/guard-run.mjs +327 -0
  11. package/dist/plugin/scripts/guardrail-offer-ran.sh +35 -0
  12. package/dist/plugin/scripts/guardrail-pr-terminal.sh +21 -0
  13. package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
  14. package/dist/plugin/scripts/guardrail-terminal-gate.sh +38 -0
  15. package/dist/plugin/scripts/guardrails.mjs +91 -3
  16. package/dist/plugin/scripts/pr-watch-guards.sh +46 -0
  17. package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  18. package/dist/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  19. package/dist/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  20. package/dist/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  21. package/dist/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  22. package/{plugin/skills/_shared/vcs/github → dist/plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  23. package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  24. package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  25. package/dist/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  26. package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  27. package/dist/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  28. package/dist/plugin/skills/_shared/vcs/github.md +5 -2
  29. package/dist/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  30. package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  31. package/dist/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  32. package/dist/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  33. package/dist/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  34. package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  35. package/dist/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  36. package/dist/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  37. package/dist/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  38. package/dist/plugin/skills/_shared/vcs/gitlab.md +6 -3
  39. package/dist/plugin/skills/_shared/vcs/post-signature.md +31 -0
  40. package/dist/plugin/skills/do/build.md +58 -56
  41. package/dist/plugin/skills/do/open-prs/forward.md +85 -84
  42. package/dist/plugin/skills/do/open-prs/update.md +3 -3
  43. package/dist/plugin/skills/do/per-comment-replies.md +2 -2
  44. package/dist/plugin/skills/do/resolve-conflicts.md +59 -58
  45. package/dist/plugin/skills/do/unit-tests.md +1 -1
  46. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  47. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  48. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  49. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  50. package/dist/plugin/skills/muggle-pr-followup/contract.md +171 -158
  51. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  52. package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  53. package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  54. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  55. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  56. package/dist/plugin/skills/muggle-pr-followup/stop.md +31 -0
  57. package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  58. package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  59. package/dist/release-manifest.json +4 -4
  60. package/package.json +6 -6
  61. package/plugin/.claude-plugin/plugin.json +1 -1
  62. package/plugin/.cursor-plugin/plugin.json +1 -1
  63. package/plugin/hooks/README.md +15 -3
  64. package/plugin/hooks/hooks.json +41 -1
  65. package/plugin/scripts/gc-state.sh +66 -0
  66. package/plugin/scripts/guard-run-job-object.ps1 +256 -0
  67. package/plugin/scripts/guard-run.mjs +327 -0
  68. package/plugin/scripts/guardrail-offer-ran.sh +35 -0
  69. package/plugin/scripts/guardrail-pr-terminal.sh +21 -0
  70. package/plugin/scripts/guardrail-record-tests.sh +5 -4
  71. package/plugin/scripts/guardrail-terminal-gate.sh +38 -0
  72. package/plugin/scripts/guardrails.mjs +91 -3
  73. package/plugin/scripts/pr-watch-guards.sh +46 -0
  74. package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  75. package/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  76. package/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  77. package/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  78. package/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  79. package/{dist/plugin/skills/_shared/vcs/github → plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  80. package/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  81. package/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  82. package/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  83. package/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  84. package/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  85. package/plugin/skills/_shared/vcs/github.md +5 -2
  86. package/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  87. package/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  88. package/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  89. package/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  90. package/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  91. package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  92. package/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  93. package/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  94. package/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  95. package/plugin/skills/_shared/vcs/gitlab.md +6 -3
  96. package/plugin/skills/_shared/vcs/post-signature.md +31 -0
  97. package/plugin/skills/do/build.md +58 -56
  98. package/plugin/skills/do/open-prs/forward.md +85 -84
  99. package/plugin/skills/do/open-prs/update.md +3 -3
  100. package/plugin/skills/do/per-comment-replies.md +2 -2
  101. package/plugin/skills/do/resolve-conflicts.md +59 -58
  102. package/plugin/skills/do/unit-tests.md +1 -1
  103. package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  104. package/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  105. package/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  106. package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  107. package/plugin/skills/muggle-pr-followup/contract.md +171 -158
  108. package/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  109. package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  110. package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  111. package/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  112. package/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  113. package/plugin/skills/muggle-pr-followup/stop.md +31 -0
  114. package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  115. package/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  116. package/dist/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
  117. package/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
@@ -0,0 +1,327 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { extname, join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ // src/guard-run/cli.ts
7
+
8
+ // src/guard-run/buildLaunchPlan.ts
9
+ function quoteWindowsArgument(argument) {
10
+ if (argument !== "" && !/[ \t"]/.test(argument)) return argument;
11
+ let quoted = '"';
12
+ let backslashCount = 0;
13
+ for (const character of argument) {
14
+ if (character === "\\") {
15
+ backslashCount += 1;
16
+ continue;
17
+ }
18
+ if (character === '"') {
19
+ quoted += "\\".repeat(backslashCount * 2 + 1) + '"';
20
+ backslashCount = 0;
21
+ continue;
22
+ }
23
+ quoted += "\\".repeat(backslashCount) + character;
24
+ backslashCount = 0;
25
+ }
26
+ return quoted + "\\".repeat(backslashCount * 2) + '"';
27
+ }
28
+ function buildWindowsCommandLine(command) {
29
+ return command.map(quoteWindowsArgument).join(" ");
30
+ }
31
+ function buildWindowsJobObjectPlan(input) {
32
+ const bareCommandLine = buildWindowsCommandLine(input.options.command);
33
+ const commandLine = input.commandNeedsCmdShell ? `cmd.exe /d /s /c "${bareCommandLine}"` : bareCommandLine;
34
+ return {
35
+ executable: "powershell.exe",
36
+ args: [
37
+ "-NoProfile",
38
+ "-NonInteractive",
39
+ "-ExecutionPolicy",
40
+ "Bypass",
41
+ "-File",
42
+ input.jobObjectShimPath,
43
+ String(input.options.activeProcessLimit),
44
+ input.options.isServiceMode ? "persist" : "kill",
45
+ String(input.launcherPid),
46
+ // Base64 survives the node→powershell→CreateProcessW arg relays that
47
+ // would otherwise re-interpret quotes.
48
+ Buffer.from(commandLine, "utf8").toString("base64")
49
+ ],
50
+ spawnDetached: false,
51
+ killsProcessGroupOnExit: false,
52
+ stopsScopeUnitOnExit: null
53
+ };
54
+ }
55
+ function buildLinuxSystemdScopePlan(args) {
56
+ return {
57
+ // pids.max (cgroup v2): the excess fork fails with EAGAIN in the kernel.
58
+ executable: "systemd-run",
59
+ args: [
60
+ "--user",
61
+ "--scope",
62
+ "--quiet",
63
+ "--collect",
64
+ `--unit=${args.scopeUnitName}`,
65
+ "-p",
66
+ `TasksMax=${args.options.activeProcessLimit}`,
67
+ "--",
68
+ ...args.options.command
69
+ ],
70
+ spawnDetached: false,
71
+ killsProcessGroupOnExit: false,
72
+ // Catchable launcher death stops the scope; after an untrappable SIGKILL
73
+ // the scope lives on but stays TasksMax-bounded, so the cap always holds.
74
+ stopsScopeUnitOnExit: args.options.isServiceMode ? null : args.scopeUnitName
75
+ };
76
+ }
77
+ function buildLinuxPrlimitProcessGroupPlan(args) {
78
+ return {
79
+ executable: "prlimit",
80
+ args: [`--nproc=${args.nprocCeiling}`, "--", ...args.options.command],
81
+ spawnDetached: true,
82
+ killsProcessGroupOnExit: !args.options.isServiceMode,
83
+ stopsScopeUnitOnExit: null
84
+ };
85
+ }
86
+ function buildDarwinUlimitProcessGroupPlan(args) {
87
+ return {
88
+ executable: "/bin/sh",
89
+ args: ["-c", `ulimit -u ${args.nprocCeiling}; exec "$@"`, "guard-run", ...args.options.command],
90
+ spawnDetached: true,
91
+ killsProcessGroupOnExit: !args.options.isServiceMode,
92
+ stopsScopeUnitOnExit: null
93
+ };
94
+ }
95
+ function buildBackendLaunchPlan(args) {
96
+ if (args.backend === "windows-job-object" /* WindowsJobObject */) {
97
+ return buildWindowsJobObjectPlan({
98
+ options: args.options,
99
+ jobObjectShimPath: args.jobObjectShimPath ?? "",
100
+ launcherPid: args.launcherPid ?? 0,
101
+ commandNeedsCmdShell: args.commandNeedsCmdShell ?? false
102
+ });
103
+ }
104
+ if (args.backend === "linux-systemd-scope" /* LinuxSystemdScope */) {
105
+ return buildLinuxSystemdScopePlan({
106
+ options: args.options,
107
+ scopeUnitName: args.scopeUnitName ?? ""
108
+ });
109
+ }
110
+ if (args.backend === "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */) {
111
+ return buildLinuxPrlimitProcessGroupPlan({
112
+ options: args.options,
113
+ nprocCeiling: args.nprocCeiling ?? 0
114
+ });
115
+ }
116
+ return buildDarwinUlimitProcessGroupPlan({
117
+ options: args.options,
118
+ nprocCeiling: args.nprocCeiling ?? 0
119
+ });
120
+ }
121
+
122
+ // src/guard-run/headroom.ts
123
+ function countProcessListLines(psOutput) {
124
+ return psOutput.split("\n").filter((line) => line.trim() !== "").length;
125
+ }
126
+ function computeNprocCeiling(args) {
127
+ return args.currentUserProcessCount + args.activeProcessLimit;
128
+ }
129
+
130
+ // src/guard-run/constants.ts
131
+ var GUARD_RUN_DEFAULT_ACTIVE_PROCESS_LIMIT = 64;
132
+ var GUARD_RUN_USAGE = "usage: guard-run [--limit N] [--service] -- <command...>\n Runs <command> with its whole process tree kernel-capped at N processes\n (default 64). Default mode also ties the tree to the launcher: killing\n guard-run reaps every descendant. --service keeps the cap but lets the\n tree outlive the launcher (for dev servers).";
133
+
134
+ // src/guard-run/parseArguments.ts
135
+ function parseError(errorMessage) {
136
+ return { options: null, errorMessage: `${errorMessage}
137
+ ${GUARD_RUN_USAGE}` };
138
+ }
139
+ function parsePositiveInteger(rawValue) {
140
+ if (rawValue === void 0 || !/^\d+$/.test(rawValue)) return null;
141
+ const parsed = Number(rawValue);
142
+ return parsed >= 1 ? parsed : null;
143
+ }
144
+ function parseGuardRunArguments(argv) {
145
+ let activeProcessLimit = GUARD_RUN_DEFAULT_ACTIVE_PROCESS_LIMIT;
146
+ let isServiceMode = false;
147
+ let index = 0;
148
+ while (index < argv.length) {
149
+ const token = argv[index];
150
+ if (token === "--") {
151
+ index += 1;
152
+ break;
153
+ }
154
+ if (token === "--service") {
155
+ isServiceMode = true;
156
+ index += 1;
157
+ continue;
158
+ }
159
+ if (token === "--limit" || token.startsWith("--limit=")) {
160
+ const rawLimit = token === "--limit" ? argv[index + 1] : token.slice("--limit=".length);
161
+ const parsedLimit = parsePositiveInteger(rawLimit);
162
+ if (parsedLimit === null) return parseError("--limit requires a positive integer");
163
+ activeProcessLimit = parsedLimit;
164
+ index += token === "--limit" ? 2 : 1;
165
+ continue;
166
+ }
167
+ if (token.startsWith("--")) return parseError(`unknown flag: ${token}`);
168
+ break;
169
+ }
170
+ const command = argv.slice(index);
171
+ if (command.length === 0) return parseError("no command given");
172
+ return {
173
+ options: {
174
+ activeProcessLimit,
175
+ isServiceMode,
176
+ command
177
+ },
178
+ errorMessage: null
179
+ };
180
+ }
181
+
182
+ // src/guard-run/selectBackend.ts
183
+ var SYSTEMD_USABLE_MANAGER_STATES = /* @__PURE__ */ new Set(["running", "degraded"]);
184
+ function selectGuardRunBackend(probe) {
185
+ if (probe.platform === "win32") {
186
+ return { backend: "windows-job-object" /* WindowsJobObject */, errorMessage: null };
187
+ }
188
+ if (probe.platform === "linux") {
189
+ const managerState = (probe.systemdUserManagerState ?? "").trim();
190
+ if (probe.hasSystemdRun && SYSTEMD_USABLE_MANAGER_STATES.has(managerState)) {
191
+ return { backend: "linux-systemd-scope" /* LinuxSystemdScope */, errorMessage: null };
192
+ }
193
+ if (probe.hasPrlimit) {
194
+ return { backend: "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */, errorMessage: null };
195
+ }
196
+ return {
197
+ backend: null,
198
+ errorMessage: "no process-cap backend available (need a systemd user manager or prlimit); refusing to run the command unguarded"
199
+ };
200
+ }
201
+ if (probe.platform === "darwin") {
202
+ return { backend: "darwin-ulimit-process-group" /* DarwinUlimitProcessGroup */, errorMessage: null };
203
+ }
204
+ return {
205
+ backend: null,
206
+ errorMessage: `unsupported platform "${probe.platform}"; refusing to run the command unguarded`
207
+ };
208
+ }
209
+
210
+ // src/guard-run/cli.ts
211
+ var JOB_OBJECT_SHIM_FILENAME = "guard-run-job-object.ps1";
212
+ function fail(message) {
213
+ process.stderr.write(`guard-run: ${message}
214
+ `);
215
+ process.exit(2);
216
+ }
217
+ function commandSucceeds(executable, args) {
218
+ const probe = spawnSync(executable, args, { stdio: "ignore", timeout: 1e4 });
219
+ return probe.error === void 0 && probe.status === 0;
220
+ }
221
+ function probedSystemdUserManagerState() {
222
+ const probe = spawnSync("systemctl", ["--user", "is-system-running"], {
223
+ encoding: "utf-8",
224
+ timeout: 1e4
225
+ });
226
+ if (probe.error !== void 0 || typeof probe.stdout !== "string") return null;
227
+ return probe.stdout.trim();
228
+ }
229
+ function resolveJobObjectShimPath() {
230
+ const bundledSibling = join(dirname(fileURLToPath(import.meta.url)), JOB_OBJECT_SHIM_FILENAME);
231
+ if (existsSync(bundledSibling)) return bundledSibling;
232
+ const pluginTreePath = join(
233
+ dirname(fileURLToPath(import.meta.url)),
234
+ "..",
235
+ "..",
236
+ "plugin",
237
+ "scripts",
238
+ JOB_OBJECT_SHIM_FILENAME
239
+ );
240
+ if (existsSync(pluginTreePath)) return pluginTreePath;
241
+ return fail(`cannot locate ${JOB_OBJECT_SHIM_FILENAME}; refusing to run the command unguarded`);
242
+ }
243
+ function windowsCommandNeedsCmdShell(firstCommandToken) {
244
+ const knownExtension = extname(firstCommandToken).toLowerCase();
245
+ if (knownExtension === ".cmd" || knownExtension === ".bat") return true;
246
+ if (knownExtension !== "") return false;
247
+ const lookup = spawnSync("where.exe", [firstCommandToken], {
248
+ encoding: "utf-8",
249
+ timeout: 1e4
250
+ });
251
+ if (lookup.error !== void 0 || lookup.status !== 0) return false;
252
+ const resolvedExtension = extname(lookup.stdout.split(/\r?\n/)[0] ?? "").toLowerCase();
253
+ return resolvedExtension === ".cmd" || resolvedExtension === ".bat";
254
+ }
255
+ function currentUserProcessCount() {
256
+ const uid = typeof process.getuid === "function" ? String(process.getuid()) : "";
257
+ const listing = spawnSync("ps", ["-u", uid, "-o", "pid="], {
258
+ encoding: "utf-8",
259
+ timeout: 1e4
260
+ });
261
+ if (listing.error !== void 0 || listing.status !== 0) {
262
+ return fail("cannot count current user processes for the nproc ceiling");
263
+ }
264
+ return countProcessListLines(listing.stdout);
265
+ }
266
+ function registerTreeCleanup(plan2, childPid) {
267
+ if (!plan2.killsProcessGroupOnExit && plan2.stopsScopeUnitOnExit === null) return;
268
+ let cleanedUp = false;
269
+ const cleanup = () => {
270
+ if (cleanedUp) return;
271
+ cleanedUp = true;
272
+ if (plan2.killsProcessGroupOnExit && childPid !== void 0) {
273
+ try {
274
+ process.kill(-childPid, "SIGKILL");
275
+ } catch {
276
+ }
277
+ }
278
+ if (plan2.stopsScopeUnitOnExit !== null) {
279
+ spawnSync("systemctl", ["--user", "stop", plan2.stopsScopeUnitOnExit], {
280
+ stdio: "ignore",
281
+ timeout: 1e4
282
+ });
283
+ }
284
+ };
285
+ process.on("exit", cleanup);
286
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
287
+ process.on(signal, () => {
288
+ cleanup();
289
+ process.exit(128 + (signal === "SIGINT" ? 2 : signal === "SIGTERM" ? 15 : 1));
290
+ });
291
+ }
292
+ }
293
+ var parseResult = parseGuardRunArguments(process.argv.slice(2));
294
+ if (parseResult.options === null) fail(parseResult.errorMessage ?? "invalid arguments");
295
+ var options = parseResult.options;
296
+ var isLinux = process.platform === "linux";
297
+ var selection = selectGuardRunBackend({
298
+ platform: process.platform,
299
+ hasSystemdRun: isLinux && commandSucceeds("systemd-run", ["--version"]),
300
+ systemdUserManagerState: isLinux ? probedSystemdUserManagerState() : null,
301
+ hasPrlimit: isLinux && commandSucceeds("prlimit", ["--version"])
302
+ });
303
+ if (selection.backend === null) fail(selection.errorMessage ?? "no backend");
304
+ var backend = selection.backend;
305
+ var needsNprocCeiling = backend === "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */ || backend === "darwin-ulimit-process-group" /* DarwinUlimitProcessGroup */;
306
+ var plan = buildBackendLaunchPlan({
307
+ backend,
308
+ options,
309
+ jobObjectShimPath: backend === "windows-job-object" /* WindowsJobObject */ ? resolveJobObjectShimPath() : void 0,
310
+ launcherPid: process.pid,
311
+ commandNeedsCmdShell: backend === "windows-job-object" /* WindowsJobObject */ ? windowsCommandNeedsCmdShell(options.command[0]) : false,
312
+ scopeUnitName: `muggle-guard-${process.pid}-${Date.now()}.scope`,
313
+ nprocCeiling: needsNprocCeiling ? computeNprocCeiling({
314
+ currentUserProcessCount: currentUserProcessCount(),
315
+ activeProcessLimit: options.activeProcessLimit
316
+ }) : void 0
317
+ });
318
+ var child = spawn(plan.executable, plan.args, {
319
+ stdio: "inherit",
320
+ detached: plan.spawnDetached,
321
+ windowsHide: true
322
+ });
323
+ child.on("error", (error) => fail(`failed to launch backend: ${String(error)}`));
324
+ registerTreeCleanup(plan, child.pid);
325
+ child.on("exit", (code, signal) => {
326
+ process.exitCode = code ?? (signal === "SIGKILL" ? 137 : signal === "SIGTERM" ? 143 : 1);
327
+ });
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # Next-options-offer observer (PostToolUse/AskUserQuestion). An AskUserQuestion
5
+ # call while a terminal PR is pending IS the post-merge handoff's exit: it
6
+ # clears terminalPending so the Stop gate (guardrail-terminal-gate.sh)
7
+ # releases. Emits no directive.
8
+ #
9
+ # Pre-filter: only spawn Node when the per-session state actually has a
10
+ # pending terminal PR, so the ordinary AskUserQuestion (no PR merged this
11
+ # session) never pays Node cold-start. Degrades to {}.
12
+ payload="$(cat)"
13
+
14
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
15
+ [ -n "$raw_sid" ] || raw_sid="unknown"
16
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
17
+
18
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
19
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
20
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
21
+ home="${HOME:-}"
22
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
23
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
24
+ fi
25
+
26
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
27
+ if [ ! -f "$state_file" ] \
28
+ || ! grep -q '"terminalPending"' "$state_file" \
29
+ || grep -q '"terminalPending": \[\]' "$state_file"; then
30
+ printf '{}'
31
+ exit 0
32
+ fi
33
+
34
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
35
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" offer-ran 2>/dev/null || printf '{}'
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # PR-terminal guardrail (PostToolUse/Bash + Monitor). When a PR just went
5
+ # terminal — a `gh pr merge`/`gh pr close` success line or the pr-followup
6
+ # watch monitor's `TERMINAL pr=N` exit line — arm the post-merge handoff:
7
+ # record the PR as pending, nudge the model to finalize/tear down and offer
8
+ # next options, and hold the Stop gate (guardrail-terminal-gate.sh) until the
9
+ # AskUserQuestion offer runs. Decision logic lives in the bundled guardrails.mjs.
10
+ #
11
+ # Fires after every Bash call, so a keyword pre-filter for the terminal output
12
+ # shapes keeps Node off the hot path. Degrades to {} so it never blocks.
13
+ payload="$(cat)"
14
+
15
+ if ! grep -Eiq 'merged pull request|closed pull request|TERMINAL pr=' <<<"$payload"; then
16
+ printf '{}'
17
+ exit 0
18
+ fi
19
+
20
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
21
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" pr-terminal 2>/dev/null || printf '{}'
@@ -9,12 +9,13 @@ set -uo pipefail
9
9
  # Fires after every Bash call and every muggle execute/replay, so a keyword
10
10
  # pre-filter for test runners and the muggle E2E tool names keeps Node off the
11
11
  # hot path. Only a `test` command (npm/pnpm/yarn/jest/vitest/pytest/go/cargo),
12
- # a muggle execute/replay/test-generation event, or an E2E skip marker reaches
13
- # guardrails.mjs, which then inspects the output for pass/fail and updates
14
- # state. Degrades to {}.
12
+ # a muggle execute/replay/test-generation event, a muggle-test skill telemetry
13
+ # emit (registers a clean-SKIP verdict as an E2E run), or an E2E skip marker
14
+ # reaches guardrails.mjs, which then inspects the output for pass/fail and
15
+ # updates state. Degrades to {}.
15
16
  payload="$(cat)"
16
17
 
17
- if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|MUGGLE_E2E_SKIP' <<<"$payload"; then
18
+ if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|muggle-local-telemetry-skill-emit|MUGGLE_E2E_SKIP' <<<"$payload"; then
18
19
  printf '{}'
19
20
  exit 0
20
21
  fi
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # PR-terminal → post-merge handoff gate (Stop). When a PR went terminal this
5
+ # session (merged/closed) and the next-options offer hasn't run, block the
6
+ # turn end until the handoff — finalize, teardown, AskUserQuestion offer —
7
+ # happens. Releases unconditionally after 3 blocks.
8
+ #
9
+ # This must stay synchronous (only a sync Stop hook can block the turn end),
10
+ # and it fires on EVERY turn end. There is no command payload to key off, so
11
+ # the pre-filter reads the same per-session state file guardrails.mjs uses and
12
+ # only spawns Node when a terminal PR is actually pending. On the overwhelming
13
+ # majority of turns no PR went terminal, so the state file is absent or
14
+ # terminalPending is empty and we return {} in-shell. Degrades to {}.
15
+ payload="$(cat)"
16
+
17
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
18
+ [ -n "$raw_sid" ] || raw_sid="unknown"
19
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
20
+
21
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
22
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
23
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
24
+ home="${HOME:-}"
25
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
26
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
27
+ fi
28
+
29
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
30
+ if [ ! -f "$state_file" ] \
31
+ || ! grep -q '"terminalPending"' "$state_file" \
32
+ || grep -q '"terminalPending": \[\]' "$state_file"; then
33
+ printf '{}'
34
+ exit 0
35
+ fi
36
+
37
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
38
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" terminal-gate 2>/dev/null || printf '{}'
@@ -40,6 +40,63 @@ ${input2.tool_response?.output ?? ""}`;
40
40
  return m ? m[0] : null;
41
41
  }
42
42
 
43
+ // src/guardrails/constants.ts
44
+ var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pull request [\w./-]*#(\d+)/;
45
+ var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
46
+ var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
47
+ var MAX_PR_TERMINAL_BLOCKS = 3;
48
+ var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
49
+ var MUGGLE_TEST_SKILL_NAME = "muggle-test";
50
+
51
+ // src/guardrails/prTerminal.ts
52
+ function detectPrTerminal(input2) {
53
+ if (input2.tool_name !== "Bash" && input2.tool_name !== "Monitor") return null;
54
+ const response = input2.tool_response;
55
+ const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
56
+ const mergedMatch = haystack.match(GH_PR_MERGED_LINE);
57
+ if (mergedMatch) {
58
+ return { prNumber: Number(mergedMatch[1]), verdict: "merged" /* Merged */ };
59
+ }
60
+ const closedMatch = haystack.match(GH_PR_CLOSED_LINE);
61
+ if (closedMatch) {
62
+ return { prNumber: Number(closedMatch[1]), verdict: "closed" /* Closed */ };
63
+ }
64
+ const monitorMatch = haystack.match(PR_MONITOR_TERMINAL_LINE);
65
+ if (monitorMatch) {
66
+ return {
67
+ prNumber: Number(monitorMatch[1]),
68
+ verdict: monitorMatch[2] === "MERGED" ? "merged" /* Merged */ : "closed" /* Closed */
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+ function applyPrTerminalDetected(state, prNumber) {
74
+ const pending = state.terminalPending ?? [];
75
+ const handled = state.terminalHandled ?? [];
76
+ if (pending.includes(prNumber) || handled.includes(prNumber)) return state;
77
+ return { ...state, terminalPending: [...pending, prNumber] };
78
+ }
79
+ function applyNextOptionsOffered(state) {
80
+ const pending = state.terminalPending ?? [];
81
+ if (pending.length === 0) return state;
82
+ return {
83
+ ...state,
84
+ terminalPending: [],
85
+ terminalHandled: [...state.terminalHandled ?? [], ...pending],
86
+ terminalBlockCount: 0
87
+ };
88
+ }
89
+ function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
90
+ const blockCount = state.terminalBlockCount ?? 0;
91
+ if ((state.terminalPending ?? []).length === 0) {
92
+ return { action: "none" /* None */, blockCount };
93
+ }
94
+ if (blockCount >= maxBlocks) {
95
+ return { action: "release" /* Release */, blockCount };
96
+ }
97
+ return { action: "block" /* Block */, blockCount: blockCount + 1 };
98
+ }
99
+
43
100
  // src/guardrails/testsGreen.ts
44
101
  var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
45
102
  var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
@@ -58,7 +115,8 @@ ${input2.tool_response?.stderr ?? ""}`;
58
115
  return !FAIL.test(out);
59
116
  }
60
117
  function isE2ERun(input2) {
61
- return E2E_TOOL.test(input2.tool_name ?? "");
118
+ if (E2E_TOOL.test(input2.tool_name ?? "")) return true;
119
+ return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
62
120
  }
63
121
 
64
122
  // src/guardrails/shouldRunE2E.ts
@@ -69,10 +127,10 @@ function shouldRunE2E(state) {
69
127
  function applyRecordedRun(state, run) {
70
128
  let next = state;
71
129
  if (run.unitTestPassed) {
72
- next = { ...next, unitTestsGreen: true, e2eRun: false, e2eBlockCount: 0 };
130
+ next = { ...next, unitTestsGreen: true, e2eRun: false };
73
131
  }
74
132
  if (run.e2eRan) {
75
- next = { ...next, e2eRun: true };
133
+ next = { ...next, e2eRun: true, e2eBlockCount: 0 };
76
134
  }
77
135
  if (run.e2eSkipped) {
78
136
  next = { ...next, e2eSkipped: true };
@@ -197,6 +255,33 @@ function prOpened() {
197
255
  Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its incoming reviews. If autoWatchPR=always, start it now by invoking /muggle:muggle-pr-followup with the PR URL; if =ask, offer it to the user; if =never, do nothing.`;
198
256
  return envelope("PostToolUse", ctx, host);
199
257
  }
258
+ function prTerminal() {
259
+ const terminalEvent = detectPrTerminal(input);
260
+ if (!terminalEvent) return "{}";
261
+ const state = readState(sessionId);
262
+ const next = applyPrTerminalDetected(state, terminalEvent.prNumber);
263
+ if (next === state) return "{}";
264
+ writeState(next);
265
+ const ctx = `PR #${terminalEvent.prNumber} went terminal (${terminalEvent.verdict}). Run the post-merge handoff now: finalize the watcher slot, tear down per autoCleanup, then OFFER NEXT OPTIONS to the user (AskUserQuestion) \u2014 release, queued work, deferred items. The stop gate holds until the offer runs.`;
266
+ return envelope("PostToolUse", ctx, host);
267
+ }
268
+ function offerRan() {
269
+ if (input.tool_name !== "AskUserQuestion") return "{}";
270
+ const state = readState(sessionId);
271
+ const next = applyNextOptionsOffered(state);
272
+ if (next !== state) writeState(next);
273
+ return "{}";
274
+ }
275
+ function terminalGate() {
276
+ const state = readState(sessionId);
277
+ const decision = prTerminalGateDecision(state);
278
+ if (decision.action !== "block" /* Block */) return "{}";
279
+ state.terminalBlockCount = decision.blockCount;
280
+ writeState(state);
281
+ const pendingPrList = (state.terminalPending ?? []).map((prNumber) => `#${prNumber}`).join(", ");
282
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. PR ${pendingPrList} went terminal (merged/closed) but the post-merge handoff has not run. Finalize the watcher slot, tear down per autoCleanup, then offer next options to the user via AskUserQuestion \u2014 release, queued work, deferred items. Only the AskUserQuestion offer clears this gate.` : `Post-merge handoff still owed for PR ${pendingPrList} (reminder ${decision.blockCount}/${MAX_PR_TERMINAL_BLOCKS}): finalize + tear down, then run the AskUserQuestion next-options offer.`;
283
+ return blockStop(reason, host);
284
+ }
200
285
  function recordTests() {
201
286
  const cmd = input.tool_input?.command ?? "";
202
287
  const state = readState(sessionId);
@@ -233,8 +318,11 @@ function buildRouter() {
233
318
  }
234
319
  var handlers = {
235
320
  "pr-opened": prOpened,
321
+ "pr-terminal": prTerminal,
322
+ "offer-ran": offerRan,
236
323
  "record-tests": recordTests,
237
324
  "e2e-gate": e2eGate,
325
+ "terminal-gate": terminalGate,
238
326
  "report-gate": reportGate,
239
327
  "build-router": buildRouter
240
328
  };
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Self-termination guards for the muggle-pr-followup watch loop. Sourced by the
4
+ # per-slot watch.sh that arm-watcher writes, and the single source of truth for
5
+ # the two conditions that stop a watcher from leaking across sessions.
6
+ #
7
+ # A bare `while true` monitor leaks on Windows: the OS does not stop a detached
8
+ # Git Bash loop when the Claude session that launched it ends, so orphaned
9
+ # watchers accumulate and each keeps spawning gh calls forever. Two guards bound
10
+ # that, and together with arm-watcher's pre-arm dedup keep at most one live
11
+ # watcher per PR:
12
+ #
13
+ # watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
14
+ # owns the slot. A loop whose PID no longer matches has
15
+ # been replaced by a newer arm and must exit.
16
+ # watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
17
+ # regardless, so an orphan nothing supersedes still dies
18
+ # on its own; reconcile re-arms an open PR inside a live
19
+ # session.
20
+
21
+ MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-21600}"
22
+ MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
23
+
24
+ # True when watch.pid exists and names a PID other than this loop's — a newer arm
25
+ # has taken ownership of the slot. Absent/empty watch.pid is not superseded: a
26
+ # loop that has not yet claimed the slot keeps running.
27
+ watcher_superseded() {
28
+ local slot="$1" mypid="$2" owner
29
+ [ -f "${slot}/watch.pid" ] || return 1
30
+ owner=$(cat "${slot}/watch.pid" 2>/dev/null)
31
+ [ -n "$owner" ] && [ "$owner" != "$mypid" ]
32
+ }
33
+
34
+ watcher_lifetime_exceeded() {
35
+ local started="$1" now="$2" max="${3:-$MUGGLE_PR_WATCH_MAX_LIFETIME}"
36
+ [ $((now - started)) -ge "$max" ]
37
+ }
38
+
39
+ # True when pid names a running process. `kill -0` sends no signal; EPERM means
40
+ # the process exists but is foreign, which still counts as alive. Used by
41
+ # arm-watcher's pre-arm dedup to decide whether a watcher already owns the slot.
42
+ watcher_pid_alive() {
43
+ local pid="$1"
44
+ [ -n "$pid" ] || return 1
45
+ kill -0 "$pid" 2>/dev/null
46
+ }
@@ -8,11 +8,11 @@ Append these two lines as the end of every loop-posted comment body:
8
8
 
9
9
  ```
10
10
  <!-- muggle-do:bot -->
11
- 🤖 _Automated reply from muggle-do._
11
+ 🤖 _Posted by `/muggle-do` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_
12
12
  ```
13
13
 
14
- - `<!-- muggle-do:bot -->` — hidden HTML marker; GitHub renders it invisibly and humans never type it. This is the **detection token**.
15
- - The visible line makes the automation clear to a reader.
14
+ - `<!-- muggle-do:bot -->` — hidden HTML marker; GitHub renders it invisibly and humans never type it. This is the **detection token**. It must stay exactly as written — echo-protection and addressed-by-loop classification read this literal string.
15
+ - The visible line is the shared Muggle Works signature ([`../vcs/post-signature.md`](../vcs/post-signature.md)) with `/muggle-do` as the command. It links a reader back to the tool and names the command that posted the comment.
16
16
 
17
17
  ## Detection
18
18
 
@@ -1,9 +1,10 @@
1
1
  # VCS provider recipes
2
2
 
3
- Provider-agnostic seam for the watcher and dev-cycle skills. A caller resolves one provider token via [`detect-vcs.md`](detect-vcs.md) — `github` (`gh`) or `gitlab` (`glab`) — then loads the matching provider's recipe for each agnostic step. The two recipe sets mirror each other call-for-call; callers branch on the token, never fork procedure.
3
+ Provider-agnostic seam for the watcher and dev-cycle skills. A caller resolves one provider token via [`detect-vcs.md`](detect-vcs.md) — `github` (`gh`) or `gitlab` (`glab`) — then loads the matching provider's recipe for each agnostic step. The two recipe sets mirror each other call-for-call; callers branch on the token, never fork procedure. Provider-agnostic entry points live once in `common/`, indexed by both TOCs: a **dispatcher** owns the `github`-vs-`gitlab` branch so a caller links it alone instead of naming both recipes side by side (`ci-rollup`, `branch-standing`, `push-to-branch`), and a recipe with no provider-specific procedure lives there too (`verify-working-tree`).
4
4
 
5
5
  ## Index
6
6
 
7
7
  - [`detect-vcs.md`](detect-vcs.md) — resolve `github` | `gitlab` from a URL argument or the origin remote.
8
8
  - [`github.md`](github.md) — `gh` / `git` recipe TOC; per-recipe files in [`github/`](github/).
9
9
  - [`gitlab.md`](gitlab.md) — `glab` / `git` recipe TOC; per-recipe files in [`gitlab/`](gitlab/).
10
+ - `common/` — provider-agnostic entry points (`ci-rollup`, `branch-standing`, `push-to-branch`, `verify-working-tree`), indexed by both provider TOCs.
@@ -0,0 +1,19 @@
1
+ # Branch standing vs base (tool-agnostic)
2
+
3
+ The single instruction the rebase trigger references for **conflict** and **out-of-date** detection — sites link here and never name a provider's metadata recipe inline. Both signals read from the metadata snapshot the caller already fetched; this file only says which field each provider reads.
4
+
5
+ Resolve the provider per [`../detect-vcs.md`](../detect-vcs.md).
6
+
7
+ ## Conflict with the base
8
+
9
+ - `github` → `mergeable == CONFLICTING`, corroborated by `mergeStateStatus == DIRTY` ([`../github/pr-metadata.md`](../github/pr-metadata.md)).
10
+ - `gitlab` → `detailed_merge_status` in `{broken_status, conflict}` ([`../gitlab/mr-metadata.md`](../gitlab/mr-metadata.md)).
11
+
12
+ A still-computing state — `github`'s `mergeable == UNKNOWN`, `gitlab`'s `checking` / `unchecked` — counts as **not conflicting** this tick.
13
+
14
+ ## Out of date (behind the base)
15
+
16
+ Read from **commit ancestry**, never from the merge-state field: GitHub masks `BEHIND` behind `DIRTY` / `BLOCKED`, and GitLab reports `need_rebase` only under fast-forward-merge — both hide a stale branch. The compare is exact even while the conflict state is still computing.
17
+
18
+ - `github` → the compare call in [`../github/pr-metadata.md`](../github/pr-metadata.md#behind-by-out-of-date-detection); `behind_by > 0` ⇒ out of date.
19
+ - `gitlab` → the compare call in [`../gitlab/mr-metadata.md`](../gitlab/mr-metadata.md#behind-by-out-of-date-detection); any base commit the head lacks ⇒ out of date.