@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.
- package/dist/{chunk-LTULYOOW.js → chunk-5PYK7DWB.js} +15 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/README.md +15 -3
- package/dist/plugin/hooks/hooks.json +41 -1
- package/dist/plugin/scripts/gc-state.sh +66 -0
- package/dist/plugin/scripts/guard-run-job-object.ps1 +256 -0
- package/dist/plugin/scripts/guard-run.mjs +327 -0
- package/dist/plugin/scripts/guardrail-offer-ran.sh +35 -0
- package/dist/plugin/scripts/guardrail-pr-terminal.sh +21 -0
- package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
- package/dist/plugin/scripts/guardrail-terminal-gate.sh +38 -0
- package/dist/plugin/scripts/guardrails.mjs +91 -3
- package/dist/plugin/scripts/pr-watch-guards.sh +46 -0
- package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
- package/dist/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
- package/dist/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
- package/dist/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
- package/dist/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
- package/{plugin/skills/_shared/vcs/github → dist/plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
- package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
- package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
- package/dist/plugin/skills/_shared/vcs/github.md +5 -2
- package/dist/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
- package/dist/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
- package/dist/plugin/skills/_shared/vcs/gitlab.md +6 -3
- package/dist/plugin/skills/_shared/vcs/post-signature.md +31 -0
- package/dist/plugin/skills/do/build.md +58 -56
- package/dist/plugin/skills/do/open-prs/forward.md +85 -84
- package/dist/plugin/skills/do/open-prs/update.md +3 -3
- package/dist/plugin/skills/do/per-comment-replies.md +2 -2
- package/dist/plugin/skills/do/resolve-conflicts.md +59 -58
- package/dist/plugin/skills/do/unit-tests.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
- package/dist/plugin/skills/muggle-pr-followup/contract.md +171 -158
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
- package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
- package/dist/plugin/skills/muggle-pr-followup/stop.md +31 -0
- package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
- package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
- package/dist/release-manifest.json +4 -4
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/hooks/README.md +15 -3
- package/plugin/hooks/hooks.json +41 -1
- package/plugin/scripts/gc-state.sh +66 -0
- package/plugin/scripts/guard-run-job-object.ps1 +256 -0
- package/plugin/scripts/guard-run.mjs +327 -0
- package/plugin/scripts/guardrail-offer-ran.sh +35 -0
- package/plugin/scripts/guardrail-pr-terminal.sh +21 -0
- package/plugin/scripts/guardrail-record-tests.sh +5 -4
- package/plugin/scripts/guardrail-terminal-gate.sh +38 -0
- package/plugin/scripts/guardrails.mjs +91 -3
- package/plugin/scripts/pr-watch-guards.sh +46 -0
- package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
- package/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
- package/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
- package/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
- package/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
- package/{dist/plugin/skills/_shared/vcs/github → plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
- package/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
- package/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
- package/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
- package/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
- package/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
- package/plugin/skills/_shared/vcs/github.md +5 -2
- package/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
- package/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
- package/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
- package/plugin/skills/_shared/vcs/gitlab.md +6 -3
- package/plugin/skills/_shared/vcs/post-signature.md +31 -0
- package/plugin/skills/do/build.md +58 -56
- package/plugin/skills/do/open-prs/forward.md +85 -84
- package/plugin/skills/do/open-prs/update.md +3 -3
- package/plugin/skills/do/per-comment-replies.md +2 -2
- package/plugin/skills/do/resolve-conflicts.md +59 -58
- package/plugin/skills/do/unit-tests.md +1 -1
- package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
- package/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
- package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
- package/plugin/skills/muggle-pr-followup/contract.md +171 -158
- package/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
- package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
- package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
- package/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
- package/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
- package/plugin/skills/muggle-pr-followup/stop.md +31 -0
- package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
- package/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
- package/dist/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
- 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,
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
🤖
|
|
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
|
|
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.
|