@muggleai/works 5.7.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 (111) 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 +11 -2
  7. package/dist/plugin/hooks/hooks.json +7 -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-record-tests.sh +5 -4
  12. package/dist/plugin/scripts/guardrails.mjs +6 -3
  13. package/dist/plugin/scripts/pr-watch-guards.sh +46 -0
  14. package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  15. package/dist/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  16. package/dist/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  17. package/dist/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  18. package/dist/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  19. package/{plugin/skills/_shared/vcs/github → dist/plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  20. package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  21. package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  22. package/dist/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  23. package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  24. package/dist/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  25. package/dist/plugin/skills/_shared/vcs/github.md +5 -2
  26. package/dist/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  27. package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  28. package/dist/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  29. package/dist/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  30. package/dist/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  31. package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  32. package/dist/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  33. package/dist/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  34. package/dist/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  35. package/dist/plugin/skills/_shared/vcs/gitlab.md +6 -3
  36. package/dist/plugin/skills/_shared/vcs/post-signature.md +31 -0
  37. package/dist/plugin/skills/do/build.md +58 -56
  38. package/dist/plugin/skills/do/open-prs/forward.md +85 -84
  39. package/dist/plugin/skills/do/open-prs/update.md +3 -3
  40. package/dist/plugin/skills/do/per-comment-replies.md +2 -2
  41. package/dist/plugin/skills/do/resolve-conflicts.md +59 -58
  42. package/dist/plugin/skills/do/unit-tests.md +1 -1
  43. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  44. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  45. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  46. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  47. package/dist/plugin/skills/muggle-pr-followup/contract.md +171 -158
  48. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  49. package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  50. package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  51. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  52. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  53. package/dist/plugin/skills/muggle-pr-followup/stop.md +31 -0
  54. package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  55. package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  56. package/dist/release-manifest.json +4 -4
  57. package/package.json +6 -6
  58. package/plugin/.claude-plugin/plugin.json +1 -1
  59. package/plugin/.cursor-plugin/plugin.json +1 -1
  60. package/plugin/hooks/README.md +11 -2
  61. package/plugin/hooks/hooks.json +7 -1
  62. package/plugin/scripts/gc-state.sh +66 -0
  63. package/plugin/scripts/guard-run-job-object.ps1 +256 -0
  64. package/plugin/scripts/guard-run.mjs +327 -0
  65. package/plugin/scripts/guardrail-record-tests.sh +5 -4
  66. package/plugin/scripts/guardrails.mjs +6 -3
  67. package/plugin/scripts/pr-watch-guards.sh +46 -0
  68. package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  69. package/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  70. package/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  71. package/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  72. package/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  73. package/{dist/plugin/skills/_shared/vcs/github → plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  74. package/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  75. package/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  76. package/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  77. package/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  78. package/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  79. package/plugin/skills/_shared/vcs/github.md +5 -2
  80. package/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  81. package/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  82. package/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  83. package/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  84. package/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  85. package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  86. package/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  87. package/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  88. package/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  89. package/plugin/skills/_shared/vcs/gitlab.md +6 -3
  90. package/plugin/skills/_shared/vcs/post-signature.md +31 -0
  91. package/plugin/skills/do/build.md +58 -56
  92. package/plugin/skills/do/open-prs/forward.md +85 -84
  93. package/plugin/skills/do/open-prs/update.md +3 -3
  94. package/plugin/skills/do/per-comment-replies.md +2 -2
  95. package/plugin/skills/do/resolve-conflicts.md +59 -58
  96. package/plugin/skills/do/unit-tests.md +1 -1
  97. package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  98. package/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  99. package/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  100. package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  101. package/plugin/skills/muggle-pr-followup/contract.md +171 -158
  102. package/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  103. package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  104. package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  105. package/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  106. package/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  107. package/plugin/skills/muggle-pr-followup/stop.md +31 -0
  108. package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  109. package/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  110. package/dist/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
  111. package/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -uo pipefail
4
+
5
+ # Prune ephemeral muggle state that nothing else garbage-collects, so it does not
6
+ # grow without bound (observed: hundreds of dead per-session files). Runs from a
7
+ # SessionStart hook, TTL-gated to once/day unless MUGGLE_STATE_GC_FORCE=1. Silent
8
+ # and best-effort: it never blocks session start and never prints context.
9
+ #
10
+ # Collection is keyed on INACTIVITY, never creation age, so a session that lasts
11
+ # months is never collected while it is still in use:
12
+ #
13
+ # guardrails/<session>.json one per Claude session. An in-use session keeps
14
+ # rewriting it (guardrails.mjs on every guarded tool call) and refreshes it
15
+ # on resume (below), so its mtime tracks last activity — only a session gone
16
+ # quiet for the whole window is collected, and by then it has ended.
17
+ # muggle-do/sessions/<slug>/ a PR-follow-up slot. Pruned only once finalized
18
+ # (result.md present — the terminal marker); an OPEN slot, a PR watched for
19
+ # any length of time, has no result.md and is never touched.
20
+ #
21
+ # Age tests use `find -mmin/-mtime` (portable across GNU and BSD) rather than
22
+ # `date -r`, whose file-mtime meaning is GNU-only.
23
+
24
+ home="${HOME}"
25
+ guardrails_dir="${home}/.muggle-ai/guardrails"
26
+ sessions_dir="${home}/.muggle-ai/muggle-do/sessions"
27
+ marker_dir="${home}/.cache/muggle"
28
+ marker="${marker_dir}/state-gc-checked"
29
+ guardrails_ttl_days="${MUGGLE_GUARDRAILS_TTL_DAYS:-14}"
30
+ slot_ttl_days="${MUGGLE_SLOT_TTL_DAYS:-30}"
31
+
32
+ # Refresh this session's own guardrails file before anything else, every start
33
+ # (not behind the daily gate — a resume on an already-swept day must still mark
34
+ # the session live). This is what makes a long-lived or resumed session immune to
35
+ # the inactivity sweep, whatever its age. Filename sanitization mirrors
36
+ # guardrails.mjs fileFor().
37
+ session_id=$(cat 2>/dev/null | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
38
+ if [ -n "${session_id}" ]; then
39
+ safe_id="${session_id//[^A-Za-z0-9_-]/_}"
40
+ [ -f "${guardrails_dir}/${safe_id}.json" ] && touch "${guardrails_dir}/${safe_id}.json" 2>/dev/null || true
41
+ fi
42
+
43
+ # TTL gate: skip when the marker exists and is younger than 24h (1440 min).
44
+ if [ -z "${MUGGLE_STATE_GC_FORCE:-}" ] && [ -f "$marker" ] \
45
+ && [ -z "$(find "$marker" -mmin +1440 2>/dev/null)" ]; then
46
+ exit 0
47
+ fi
48
+
49
+ if [ -d "$guardrails_dir" ]; then
50
+ find "$guardrails_dir" -maxdepth 1 -type f -name '*.json' \
51
+ -mtime "+${guardrails_ttl_days}" -delete 2>/dev/null || true
52
+ fi
53
+
54
+ if [ -d "$sessions_dir" ]; then
55
+ for slot in "$sessions_dir"/*/; do
56
+ [ -d "$slot" ] || continue
57
+ [ -f "${slot}result.md" ] || continue
58
+ if [ -n "$(find "${slot}result.md" -mtime "+${slot_ttl_days}" 2>/dev/null)" ]; then
59
+ rm -rf "$slot" 2>/dev/null || true
60
+ fi
61
+ done
62
+ fi
63
+
64
+ mkdir -p "$marker_dir" 2>/dev/null || true
65
+ touch "$marker" 2>/dev/null || true
66
+ exit 0
@@ -0,0 +1,256 @@
1
+ # Windows backend for guard-run. Callers never invoke this file: the portable
2
+ # entry point is guard-run.mjs (plain node, runs everywhere), which probes the
3
+ # platform and dispatches to one backend — this Job Object shim on win32,
4
+ # systemd scope / prlimit on linux, ulimit + process group on darwin. So skill
5
+ # instructions stay OS-agnostic by referencing guard-run.mjs only; PowerShell
6
+ # exists solely inside this win32 backend. guard-run.mjs invokes it as:
7
+ #
8
+ # powershell -File guard-run-job-object.ps1 <limit> <kill|persist> <launcherPid> <base64CommandLine>
9
+ #
10
+ # The child is created CREATE_SUSPENDED, assigned to a fresh Job Object, then
11
+ # resumed — so it and every descendant are inside the job from their first
12
+ # instruction. JOB_OBJECT_LIMIT_ACTIVE_PROCESS makes the process past the limit
13
+ # fail at creation in the kernel (nothing to clean up), which is what stops a
14
+ # fork storm: a sweeper that reacts after enumeration always loses that race.
15
+ #
16
+ # kill mode adds JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — this shim holds the only
17
+ # job handle, so when it exits or is killed the kernel destroys the entire tree
18
+ # — and watches the launcher: if the node launcher dies first, the shim
19
+ # terminates the job so orphans are impossible. persist mode (guard-run
20
+ # --service) keeps only the cap; the tree may outlive the launcher.
21
+
22
+ $ErrorActionPreference = "Stop"
23
+
24
+ if ($args.Count -ne 4) {
25
+ [Console]::Error.WriteLine("usage: guard-run-job-object.ps1 <limit> <kill|persist> <launcherPid> <base64CommandLine>")
26
+ exit 2
27
+ }
28
+
29
+ $activeProcessLimit = 0
30
+ if (-not [int]::TryParse($args[0], [ref]$activeProcessLimit) -or $activeProcessLimit -lt 1) {
31
+ [Console]::Error.WriteLine("guard-run-job-object: limit must be a positive integer")
32
+ exit 2
33
+ }
34
+ if ($args[1] -notin @("kill", "persist")) {
35
+ [Console]::Error.WriteLine("guard-run-job-object: mode must be 'kill' or 'persist'")
36
+ exit 2
37
+ }
38
+ $killOnClose = $args[1] -eq "kill"
39
+ $launcherPid = 0
40
+ if (-not [int]::TryParse($args[2], [ref]$launcherPid)) {
41
+ [Console]::Error.WriteLine("guard-run-job-object: launcherPid must be an integer")
42
+ exit 2
43
+ }
44
+ try {
45
+ $commandLine = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($args[3]))
46
+ } catch {
47
+ [Console]::Error.WriteLine("guard-run-job-object: command line is not valid base64")
48
+ exit 2
49
+ }
50
+
51
+ Add-Type -TypeDefinition @'
52
+ using System;
53
+ using System.Runtime.InteropServices;
54
+ using System.Text;
55
+
56
+ namespace MuggleGuardRun
57
+ {
58
+ [StructLayout(LayoutKind.Sequential)]
59
+ public struct JOBOBJECT_BASIC_LIMIT_INFORMATION
60
+ {
61
+ public long PerProcessUserTimeLimit;
62
+ public long PerJobUserTimeLimit;
63
+ public uint LimitFlags;
64
+ public UIntPtr MinimumWorkingSetSize;
65
+ public UIntPtr MaximumWorkingSetSize;
66
+ public uint ActiveProcessLimit;
67
+ public UIntPtr Affinity;
68
+ public uint PriorityClass;
69
+ public uint SchedulingClass;
70
+ }
71
+
72
+ [StructLayout(LayoutKind.Sequential)]
73
+ public struct IO_COUNTERS
74
+ {
75
+ public ulong ReadOperationCount;
76
+ public ulong WriteOperationCount;
77
+ public ulong OtherOperationCount;
78
+ public ulong ReadTransferCount;
79
+ public ulong WriteTransferCount;
80
+ public ulong OtherTransferCount;
81
+ }
82
+
83
+ [StructLayout(LayoutKind.Sequential)]
84
+ public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
85
+ {
86
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
87
+ public IO_COUNTERS IoInfo;
88
+ public UIntPtr ProcessMemoryLimit;
89
+ public UIntPtr JobMemoryLimit;
90
+ public UIntPtr PeakProcessMemoryUsed;
91
+ public UIntPtr PeakJobMemoryUsed;
92
+ }
93
+
94
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
95
+ public struct STARTUPINFO
96
+ {
97
+ public uint cb;
98
+ public string lpReserved;
99
+ public string lpDesktop;
100
+ public string lpTitle;
101
+ public uint dwX;
102
+ public uint dwY;
103
+ public uint dwXSize;
104
+ public uint dwYSize;
105
+ public uint dwXCountChars;
106
+ public uint dwYCountChars;
107
+ public uint dwFillAttribute;
108
+ public uint dwFlags;
109
+ public ushort wShowWindow;
110
+ public ushort cbReserved2;
111
+ public IntPtr lpReserved2;
112
+ public IntPtr hStdInput;
113
+ public IntPtr hStdOutput;
114
+ public IntPtr hStdError;
115
+ }
116
+
117
+ [StructLayout(LayoutKind.Sequential)]
118
+ public struct PROCESS_INFORMATION
119
+ {
120
+ public IntPtr hProcess;
121
+ public IntPtr hThread;
122
+ public uint dwProcessId;
123
+ public uint dwThreadId;
124
+ }
125
+
126
+ public static class JobObjectLauncher
127
+ {
128
+ const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
129
+ const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
130
+ const int JobObjectExtendedLimitInformation = 9;
131
+ const uint CREATE_SUSPENDED = 0x00000004;
132
+ const uint SYNCHRONIZE = 0x00100000;
133
+ const uint INFINITE = 0xFFFFFFFF;
134
+ const uint WAIT_OBJECT_0 = 0;
135
+ const int LAUNCHER_DEATH_EXIT_CODE = 137;
136
+
137
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
138
+ static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
139
+
140
+ [DllImport("kernel32.dll", SetLastError = true)]
141
+ static extern bool SetInformationJobObject(IntPtr hJob, int jobObjectInfoClass,
142
+ ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo, uint cbJobObjectInfoLength);
143
+
144
+ [DllImport("kernel32.dll", SetLastError = true)]
145
+ static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
146
+
147
+ [DllImport("kernel32.dll", SetLastError = true)]
148
+ static extern bool TerminateJobObject(IntPtr hJob, uint uExitCode);
149
+
150
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
151
+ static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
152
+ IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
153
+ uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
154
+ ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
155
+
156
+ [DllImport("kernel32.dll", SetLastError = true)]
157
+ static extern uint ResumeThread(IntPtr hThread);
158
+
159
+ [DllImport("kernel32.dll", SetLastError = true)]
160
+ static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
161
+
162
+ [DllImport("kernel32.dll", SetLastError = true)]
163
+ static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
164
+
165
+ [DllImport("kernel32.dll", SetLastError = true)]
166
+ static extern uint WaitForMultipleObjects(uint nCount, IntPtr[] lpHandles, bool bWaitAll,
167
+ uint dwMilliseconds);
168
+
169
+ [DllImport("kernel32.dll", SetLastError = true)]
170
+ static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
171
+
172
+ [DllImport("kernel32.dll", SetLastError = true)]
173
+ static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId);
174
+
175
+ static Exception LastError(string operation)
176
+ {
177
+ return new Exception(operation + " failed (Win32 error " +
178
+ Marshal.GetLastWin32Error() + ")");
179
+ }
180
+
181
+ public static int Run(string commandLine, int activeProcessLimit, bool killOnClose,
182
+ int launcherPid)
183
+ {
184
+ IntPtr job = CreateJobObject(IntPtr.Zero, null);
185
+ if (job == IntPtr.Zero) throw LastError("CreateJobObject");
186
+
187
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits =
188
+ new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
189
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS
190
+ | (killOnClose ? JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE : 0);
191
+ limits.BasicLimitInformation.ActiveProcessLimit = (uint)activeProcessLimit;
192
+ uint limitsSize = (uint)Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
193
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref limits,
194
+ limitsSize))
195
+ {
196
+ throw LastError("SetInformationJobObject");
197
+ }
198
+
199
+ STARTUPINFO startupInfo = new STARTUPINFO();
200
+ startupInfo.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFO));
201
+ PROCESS_INFORMATION processInfo;
202
+ StringBuilder mutableCommandLine = new StringBuilder(commandLine);
203
+ if (!CreateProcess(null, mutableCommandLine, IntPtr.Zero, IntPtr.Zero, true,
204
+ CREATE_SUSPENDED, IntPtr.Zero, null, ref startupInfo, out processInfo))
205
+ {
206
+ throw LastError("CreateProcess for [" + commandLine + "]");
207
+ }
208
+ if (!AssignProcessToJobObject(job, processInfo.hProcess))
209
+ {
210
+ TerminateProcess(processInfo.hProcess, 1);
211
+ throw LastError("AssignProcessToJobObject");
212
+ }
213
+ ResumeThread(processInfo.hThread);
214
+
215
+ if (killOnClose)
216
+ {
217
+ IntPtr launcher = OpenProcess(SYNCHRONIZE, false, (uint)launcherPid);
218
+ if (launcher == IntPtr.Zero)
219
+ {
220
+ // Launcher already gone: the tree is orphaned at birth — reap it.
221
+ TerminateJobObject(job, LAUNCHER_DEATH_EXIT_CODE);
222
+ return LAUNCHER_DEATH_EXIT_CODE;
223
+ }
224
+ IntPtr[] handles = new IntPtr[] { processInfo.hProcess, launcher };
225
+ uint signaled = WaitForMultipleObjects(2, handles, false, INFINITE);
226
+ if (signaled == WAIT_OBJECT_0 + 1)
227
+ {
228
+ TerminateJobObject(job, LAUNCHER_DEATH_EXIT_CODE);
229
+ return LAUNCHER_DEATH_EXIT_CODE;
230
+ }
231
+ }
232
+ else
233
+ {
234
+ WaitForSingleObject(processInfo.hProcess, INFINITE);
235
+ }
236
+
237
+ uint exitCode;
238
+ if (!GetExitCodeProcess(processInfo.hProcess, out exitCode))
239
+ {
240
+ throw LastError("GetExitCodeProcess");
241
+ }
242
+ // The job handle is deliberately never closed here: in kill mode this
243
+ // process's death is what closes it, and that close is the kernel's
244
+ // cue to destroy any stragglers.
245
+ return (int)exitCode;
246
+ }
247
+ }
248
+ }
249
+ '@
250
+
251
+ try {
252
+ exit ([MuggleGuardRun.JobObjectLauncher]::Run($commandLine, $activeProcessLimit, $killOnClose, $launcherPid))
253
+ } catch {
254
+ [Console]::Error.WriteLine("guard-run-job-object: $($_.Exception.Message)")
255
+ exit 1
256
+ }
@@ -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
+ });
@@ -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