@muggleai/works 5.10.0 → 5.11.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-OCLMXAVJ.js → chunk-MSPMXBO5.js} +418 -326
- package/dist/{chunk-7NC2DJTK.js → chunk-WUMAJOBT.js} +9 -4
- package/dist/cli.js +2 -2
- package/dist/index.js +2 -2
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/agents/test-prepare-runner.md +3 -1
- package/dist/plugin/agents/visual-walkthrough-builder.md +12 -5
- package/dist/plugin/commands/mtest.md +1 -1
- package/dist/plugin/hooks/README.md +15 -3
- package/dist/plugin/hooks/hooks.json +79 -0
- package/dist/plugin/scripts/guardrail-classify-gate.sh +23 -0
- package/dist/plugin/scripts/guardrail-debug-path-gate.sh +41 -0
- package/dist/plugin/scripts/guardrail-pr-terminal.sh +6 -2
- package/dist/plugin/scripts/guardrail-record-stage-read.sh +21 -0
- package/dist/plugin/scripts/guardrail-record-stage-signals.sh +23 -0
- package/dist/plugin/scripts/guardrail-record-tests.sh +11 -4
- package/dist/plugin/scripts/guardrail-report-format.sh +9 -5
- package/dist/plugin/scripts/guardrail-skill-stages.sh +35 -0
- package/dist/plugin/scripts/guardrail-stage-gate.sh +43 -0
- package/dist/plugin/scripts/guardrail-walkthrough-gate.sh +41 -0
- package/dist/plugin/scripts/guardrails.mjs +428 -35
- package/dist/plugin/scripts/pr-watch-events.sh +116 -0
- package/dist/plugin/scripts/pr-watch-guards.sh +8 -2
- package/dist/plugin/scripts/pr-watch-loop.sh +254 -0
- package/dist/plugin/scripts/reconcile-stale-watchers.sh +66 -18
- package/dist/plugin/scripts/sign-body.sh +88 -0
- package/dist/plugin/skills/CLAUDE.md +16 -0
- package/dist/plugin/skills/_shared/post-merge-cleanup.md +83 -3
- package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +6 -8
- package/dist/plugin/skills/_shared/pr-followup-helpers/reply-routing.md +10 -18
- package/dist/plugin/skills/_shared/vcs/CLAUDE.md +1 -0
- package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +3 -2
- package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +3 -2
- package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +3 -2
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +4 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-edit.md +4 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-note.md +4 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +4 -1
- package/dist/plugin/skills/_shared/vcs/post-signature.md +23 -12
- package/dist/plugin/skills/do/open-prs/forward.md +2 -2
- package/dist/plugin/skills/do/per-comment-replies.md +2 -15
- package/dist/plugin/skills/muggle-browser-task/SKILL.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +6 -3
- package/dist/plugin/skills/muggle-pr-followup/adopt.md +60 -0
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +10 -14
- package/dist/plugin/skills/muggle-pr-followup/auto-track.md +2 -0
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +63 -1
- package/dist/plugin/skills/muggle-pr-followup/output-templates/help.md +11 -1
- package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +1 -7
- package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -4
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +32 -6
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +18 -0
- package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +23 -0
- package/dist/plugin/skills/muggle-test/SKILL.md +22 -2
- package/dist/plugin/skills/muggle-test-feature-local/SKILL.md +3 -0
- package/dist/plugin/skills/muggle-test-prepare/SKILL.md +19 -12
- package/dist/plugin/skills/muggle-test-prepare/steps/confirm-recipe.md +56 -0
- package/dist/plugin/skills/muggle-test-prepare/steps/derive-service-graph.md +51 -0
- package/dist/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +13 -2
- package/dist/plugin/skills/muggle-test-prepare/steps/identify-services.md +4 -2
- package/dist/plugin/skills/muggle-test-prepare/steps/readiness-report.md +3 -1
- package/dist/plugin/skills/muggle-test-prepare/steps/record-resolution.md +32 -0
- package/dist/plugin/skills/muggle-test-prepare/steps/replay-or-learn.md +20 -0
- package/dist/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +4 -2
- package/dist/plugin/skills/muggle-test-prepare/steps/smoke-test.md +5 -1
- package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +3 -1
- package/dist/release-manifest.json +4 -4
- package/dist/src-B5OTAZKM.js +1 -0
- package/package.json +7 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/agents/test-prepare-runner.md +3 -1
- package/plugin/agents/visual-walkthrough-builder.md +12 -5
- package/plugin/commands/mtest.md +1 -1
- package/plugin/hooks/README.md +15 -3
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/scripts/guardrail-classify-gate.sh +23 -0
- package/plugin/scripts/guardrail-debug-path-gate.sh +41 -0
- package/plugin/scripts/guardrail-pr-terminal.sh +6 -2
- package/plugin/scripts/guardrail-record-stage-read.sh +21 -0
- package/plugin/scripts/guardrail-record-stage-signals.sh +23 -0
- package/plugin/scripts/guardrail-record-tests.sh +11 -4
- package/plugin/scripts/guardrail-report-format.sh +9 -5
- package/plugin/scripts/guardrail-skill-stages.sh +35 -0
- package/plugin/scripts/guardrail-stage-gate.sh +43 -0
- package/plugin/scripts/guardrail-walkthrough-gate.sh +41 -0
- package/plugin/scripts/guardrails.mjs +428 -35
- package/plugin/scripts/pr-watch-events.sh +116 -0
- package/plugin/scripts/pr-watch-guards.sh +8 -2
- package/plugin/scripts/pr-watch-loop.sh +254 -0
- package/plugin/scripts/reconcile-stale-watchers.sh +66 -18
- package/plugin/scripts/sign-body.sh +88 -0
- package/plugin/skills/CLAUDE.md +16 -0
- package/plugin/skills/_shared/post-merge-cleanup.md +83 -3
- package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +6 -8
- package/plugin/skills/_shared/pr-followup-helpers/reply-routing.md +10 -18
- package/plugin/skills/_shared/vcs/CLAUDE.md +1 -0
- package/plugin/skills/_shared/vcs/github/pr-edit.md +3 -2
- package/plugin/skills/_shared/vcs/github/reply-line-comment.md +3 -2
- package/plugin/skills/_shared/vcs/github/top-level-comment.md +3 -2
- package/plugin/skills/_shared/vcs/gitlab/mr-create.md +4 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-edit.md +4 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-note.md +4 -1
- package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +4 -1
- package/plugin/skills/_shared/vcs/post-signature.md +23 -12
- package/plugin/skills/do/open-prs/forward.md +2 -2
- package/plugin/skills/do/per-comment-replies.md +2 -15
- package/plugin/skills/muggle-browser-task/SKILL.md +1 -1
- package/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
- package/plugin/skills/muggle-pr-followup/SKILL.md +6 -3
- package/plugin/skills/muggle-pr-followup/adopt.md +60 -0
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +10 -14
- package/plugin/skills/muggle-pr-followup/auto-track.md +2 -0
- package/plugin/skills/muggle-pr-followup/evals/evals.json +63 -1
- package/plugin/skills/muggle-pr-followup/output-templates/help.md +11 -1
- package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +1 -7
- package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -4
- package/plugin/skills/muggle-pr-followup/reconcile.md +32 -6
- package/plugin/skills/muggle-pr-followup/state-schemas.md +18 -0
- package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +23 -0
- package/plugin/skills/muggle-test/SKILL.md +22 -2
- package/plugin/skills/muggle-test-feature-local/SKILL.md +3 -0
- package/plugin/skills/muggle-test-prepare/SKILL.md +19 -12
- package/plugin/skills/muggle-test-prepare/steps/confirm-recipe.md +56 -0
- package/plugin/skills/muggle-test-prepare/steps/derive-service-graph.md +51 -0
- package/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +13 -2
- package/plugin/skills/muggle-test-prepare/steps/identify-services.md +4 -2
- package/plugin/skills/muggle-test-prepare/steps/readiness-report.md +3 -1
- package/plugin/skills/muggle-test-prepare/steps/record-resolution.md +32 -0
- package/plugin/skills/muggle-test-prepare/steps/replay-or-learn.md +20 -0
- package/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +4 -2
- package/plugin/skills/muggle-test-prepare/steps/smoke-test.md +5 -1
- package/plugin/skills/muggle-test-prepare/steps/start-services.md +3 -1
- package/dist/src-AWUORQZZ.js +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
|
|
2
|
-
import { isAbsolute, resolve,
|
|
2
|
+
import { join, isAbsolute, resolve, dirname } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
4
6
|
|
|
5
7
|
// src/guardrails/cli.ts
|
|
6
8
|
var baseDir = (override) => override ?? join(homedir(), ".muggle-ai", "guardrails");
|
|
@@ -47,8 +49,23 @@ var GH_PR_REOPENED_LINE = /\bReopened pull request [\w./-]*#(\d+)/;
|
|
|
47
49
|
var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
|
|
48
50
|
var MAX_PR_TERMINAL_BLOCKS = 3;
|
|
49
51
|
var MAX_WATCH_BLOCKS = 3;
|
|
52
|
+
var MAX_WALKTHROUGH_BLOCKS = 3;
|
|
53
|
+
var GH_LOOKUP_TIMEOUT_MS = 1e4;
|
|
50
54
|
var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
|
|
51
55
|
var MUGGLE_TEST_SKILL_NAME = "muggle-test";
|
|
56
|
+
var MANDATORY_STAGES_FRONTMATTER_KEY = "mandatoryStages";
|
|
57
|
+
var SKILL_NAME_INPUT_KEYS = ["skill", "skillName", "name", "command"];
|
|
58
|
+
var MAX_STAGE_BLOCKS = 3;
|
|
59
|
+
var MAX_DEBUG_BLOCKS = 3;
|
|
60
|
+
var ANY_TEST_CASE = "*";
|
|
61
|
+
var MUGGLE_EXECUTION_TOOL = /muggle-local-(execute-test-generation|execute-replay)/i;
|
|
62
|
+
var MUGGLE_EVENT_EMIT_TOOL = /muggle-local-telemetry-event-emit/i;
|
|
63
|
+
var MUGGLE_FEEDBACK_CREATE_TOOL = /muggle-remote-user-feedback-create/i;
|
|
64
|
+
var PRE_EXECUTION_CLASSIFICATION_EVENT = "pre-execution-classification";
|
|
65
|
+
var FAILURE_DIAGNOSIS_EVENT = /-failure-(classified|resolved)$/;
|
|
66
|
+
var MUGGLE_RUN_ID_LINE = /\*\*Run ID:\*\*\s*([^\s*]+)/;
|
|
67
|
+
var MUGGLE_RUN_STATUS_LINE = /\*\*Status:\*\*\s*([A-Za-z_]+)/;
|
|
68
|
+
var MUGGLE_RUN_PASSED_STATUS = "passed";
|
|
52
69
|
|
|
53
70
|
// src/guardrails/prTerminal.ts
|
|
54
71
|
function detectPrTerminal(input2) {
|
|
@@ -115,6 +132,207 @@ function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
|
|
|
115
132
|
}
|
|
116
133
|
return { action: "block" /* Block */, blockCount: blockCount + 1 };
|
|
117
134
|
}
|
|
135
|
+
var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
136
|
+
var SEQUENCE_ENTRY = /^\s*-\s*(.+?)\s*$/;
|
|
137
|
+
var FLOW_LIST = /^\s*\[(.*)\]\s*$/;
|
|
138
|
+
var STAGE_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_STAGE_SKIP\b/;
|
|
139
|
+
var SKILLS_DIR_NAME = "skills";
|
|
140
|
+
var SKILLS_PATH_SEGMENT = `/${SKILLS_DIR_NAME}/`;
|
|
141
|
+
var SINGLE_PATH_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
142
|
+
var unquote = (raw) => raw.trim().replace(/^["']|["']$/g, "").trim();
|
|
143
|
+
function parseDeclaredStages(skillMarkdown) {
|
|
144
|
+
const frontmatter = FRONTMATTER.exec(skillMarkdown)?.[1];
|
|
145
|
+
if (!frontmatter) return [];
|
|
146
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
147
|
+
const keyIndex = lines.findIndex(
|
|
148
|
+
(line) => line.startsWith(`${MANDATORY_STAGES_FRONTMATTER_KEY}:`)
|
|
149
|
+
);
|
|
150
|
+
if (keyIndex < 0) return [];
|
|
151
|
+
const inlineValue = lines[keyIndex].slice(MANDATORY_STAGES_FRONTMATTER_KEY.length + 1).trim();
|
|
152
|
+
const flowEntries = FLOW_LIST.exec(inlineValue)?.[1];
|
|
153
|
+
if (flowEntries !== void 0) {
|
|
154
|
+
return flowEntries.split(",").map(unquote).filter((entry) => entry.length > 0);
|
|
155
|
+
}
|
|
156
|
+
const declared = [];
|
|
157
|
+
for (const line of lines.slice(keyIndex + 1)) {
|
|
158
|
+
const entry = SEQUENCE_ENTRY.exec(line);
|
|
159
|
+
if (!entry) break;
|
|
160
|
+
declared.push(unquote(entry[1]));
|
|
161
|
+
}
|
|
162
|
+
return declared;
|
|
163
|
+
}
|
|
164
|
+
function normalizeStagePath(filePath) {
|
|
165
|
+
return filePath.replaceAll("\\", "/").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
function stageLabel(normalizedStagePath) {
|
|
168
|
+
const insideSkills = normalizedStagePath.lastIndexOf(SKILLS_PATH_SEGMENT);
|
|
169
|
+
if (insideSkills < 0) return normalizedStagePath;
|
|
170
|
+
return normalizedStagePath.slice(insideSkills + SKILLS_PATH_SEGMENT.length);
|
|
171
|
+
}
|
|
172
|
+
function resolvePluginSkillsRoot() {
|
|
173
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.env.CURSOR_PLUGIN_ROOT;
|
|
174
|
+
if (pluginRoot) return join(pluginRoot, SKILLS_DIR_NAME);
|
|
175
|
+
return join(dirname(dirname(fileURLToPath(import.meta.url))), SKILLS_DIR_NAME);
|
|
176
|
+
}
|
|
177
|
+
function resolveSkillNameFromToolInput(toolInput) {
|
|
178
|
+
if (!toolInput) return void 0;
|
|
179
|
+
const indexed = toolInput;
|
|
180
|
+
for (const inputKey of SKILL_NAME_INPUT_KEYS) {
|
|
181
|
+
const value = indexed[inputKey];
|
|
182
|
+
if (typeof value !== "string" || value.trim().length === 0) continue;
|
|
183
|
+
const skillName = value.trim().split(":").pop()?.replace(/^\//, "") ?? "";
|
|
184
|
+
return SINGLE_PATH_SEGMENT.test(skillName) ? skillName : void 0;
|
|
185
|
+
}
|
|
186
|
+
return void 0;
|
|
187
|
+
}
|
|
188
|
+
function resolveSkillStagePaths(skillName, skillsRootDir) {
|
|
189
|
+
const skillDir = join(skillsRootDir, skillName);
|
|
190
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
191
|
+
if (!existsSync(skillFile)) return [];
|
|
192
|
+
const normalizedRoot = normalizeStagePath(skillsRootDir);
|
|
193
|
+
const staysInsideSkillsTree = (candidate) => normalizeStagePath(candidate).startsWith(`${normalizedRoot}/`);
|
|
194
|
+
const resolved = [];
|
|
195
|
+
for (const declared of parseDeclaredStages(readFileSync(skillFile, "utf-8"))) {
|
|
196
|
+
if (isAbsolute(declared)) continue;
|
|
197
|
+
const stagePath = [resolve(skillDir, declared), resolve(skillsRootDir, declared)].find(
|
|
198
|
+
(candidate) => staysInsideSkillsTree(candidate) && existsSync(candidate)
|
|
199
|
+
);
|
|
200
|
+
if (!stagePath) continue;
|
|
201
|
+
const normalized = normalizeStagePath(stagePath);
|
|
202
|
+
if (!resolved.includes(normalized)) resolved.push(normalized);
|
|
203
|
+
}
|
|
204
|
+
return resolved;
|
|
205
|
+
}
|
|
206
|
+
function applySkillInvocation(state, skillName, stagePaths) {
|
|
207
|
+
const merged = [...state.mandatoryStages ?? []];
|
|
208
|
+
for (const stagePath of stagePaths) if (!merged.includes(stagePath)) merged.push(stagePath);
|
|
209
|
+
if (state.lastInvokedSkillName === skillName && merged.length === (state.mandatoryStages?.length ?? 0)) {
|
|
210
|
+
return state;
|
|
211
|
+
}
|
|
212
|
+
return { ...state, lastInvokedSkillName: skillName, mandatoryStages: merged };
|
|
213
|
+
}
|
|
214
|
+
function applyStageRead(state, filePath) {
|
|
215
|
+
const stagePath = normalizeStagePath(filePath);
|
|
216
|
+
if ((state.stagesRead ?? []).includes(stagePath)) return state;
|
|
217
|
+
return { ...state, stagesRead: [...state.stagesRead ?? [], stagePath] };
|
|
218
|
+
}
|
|
219
|
+
function unreadMandatoryStages(state) {
|
|
220
|
+
const read = new Set(state.stagesRead ?? []);
|
|
221
|
+
return (state.mandatoryStages ?? []).filter((stagePath) => !read.has(stagePath));
|
|
222
|
+
}
|
|
223
|
+
function isStageSkipMarker(command) {
|
|
224
|
+
return STAGE_SKIP_MARKER.test(command);
|
|
225
|
+
}
|
|
226
|
+
function applyStageSkip(state, skipped) {
|
|
227
|
+
if (!skipped || state.stageSkipped === true) return state;
|
|
228
|
+
return { ...state, stageSkipped: true };
|
|
229
|
+
}
|
|
230
|
+
function stageGateDecision(state, unreadStagePaths, maxBlocks = MAX_STAGE_BLOCKS) {
|
|
231
|
+
const blockCount = state.stageBlockCount ?? 0;
|
|
232
|
+
if (state.stageSkipped === true || unreadStagePaths.length === 0) {
|
|
233
|
+
return { action: "none" /* None */, blockCount, unread: unreadStagePaths };
|
|
234
|
+
}
|
|
235
|
+
if (blockCount >= maxBlocks) {
|
|
236
|
+
return { action: "release" /* Release */, blockCount, unread: unreadStagePaths };
|
|
237
|
+
}
|
|
238
|
+
return { action: "block" /* Block */, blockCount: blockCount + 1, unread: unreadStagePaths };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/guardrails/preExecutionClassification.ts
|
|
242
|
+
var CLASSIFICATION_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_CLASSIFY_SKIP\b/;
|
|
243
|
+
function detectClassifiedTestCaseId(input2) {
|
|
244
|
+
if (!MUGGLE_EVENT_EMIT_TOOL.test(input2.tool_name ?? "")) return void 0;
|
|
245
|
+
if (input2.tool_input?.eventType !== PRE_EXECUTION_CLASSIFICATION_EVENT) return void 0;
|
|
246
|
+
return input2.tool_input?.testCaseId ?? ANY_TEST_CASE;
|
|
247
|
+
}
|
|
248
|
+
function applyClassifiedTestCase(state, testCaseId) {
|
|
249
|
+
if ((state.classifiedTestCaseIds ?? []).includes(testCaseId)) return state;
|
|
250
|
+
return { ...state, classifiedTestCaseIds: [...state.classifiedTestCaseIds ?? [], testCaseId] };
|
|
251
|
+
}
|
|
252
|
+
function resolveExecutionTargetTestCaseId(input2) {
|
|
253
|
+
if (!MUGGLE_EXECUTION_TOOL.test(input2.tool_name ?? "")) return void 0;
|
|
254
|
+
return input2.tool_input?.testCase?.id ?? input2.tool_input?.testScript?.testCaseId;
|
|
255
|
+
}
|
|
256
|
+
function isClassificationSkipMarker(command) {
|
|
257
|
+
return CLASSIFICATION_SKIP_MARKER.test(command);
|
|
258
|
+
}
|
|
259
|
+
function applyClassificationSkip(state, skipped) {
|
|
260
|
+
if (!skipped || state.classificationSkipped === true) return state;
|
|
261
|
+
return { ...state, classificationSkipped: true };
|
|
262
|
+
}
|
|
263
|
+
function classificationGateDecision(state, input2) {
|
|
264
|
+
if (state.lastInvokedSkillName !== MUGGLE_TEST_SKILL_NAME) return { deny: false };
|
|
265
|
+
if (state.classificationSkipped === true) return { deny: false };
|
|
266
|
+
const testCaseId = resolveExecutionTargetTestCaseId(input2);
|
|
267
|
+
if (!testCaseId) return { deny: false };
|
|
268
|
+
const classified = state.classifiedTestCaseIds ?? [];
|
|
269
|
+
if (classified.includes(ANY_TEST_CASE) || classified.includes(testCaseId)) return { deny: false };
|
|
270
|
+
return { deny: true, testCaseId };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/guardrails/debugPath.ts
|
|
274
|
+
var DEBUG_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_DEBUG_SKIP\b/;
|
|
275
|
+
var serialize = (input2) => `${JSON.stringify(input2.tool_input ?? {})}
|
|
276
|
+
${JSON.stringify(input2.tool_response ?? {})}`;
|
|
277
|
+
function renderedResult(toolResponse) {
|
|
278
|
+
const rendered = [];
|
|
279
|
+
const collect = (value) => {
|
|
280
|
+
if (typeof value === "string") rendered.push(value);
|
|
281
|
+
else if (Array.isArray(value)) value.forEach(collect);
|
|
282
|
+
else if (value && typeof value === "object") Object.values(value).forEach(collect);
|
|
283
|
+
};
|
|
284
|
+
collect(toolResponse);
|
|
285
|
+
return rendered.join("\n");
|
|
286
|
+
}
|
|
287
|
+
function detectFailedRunId(input2) {
|
|
288
|
+
if (!MUGGLE_EXECUTION_TOOL.test(input2.tool_name ?? "")) return void 0;
|
|
289
|
+
const rendered = renderedResult(input2.tool_response);
|
|
290
|
+
const status = MUGGLE_RUN_STATUS_LINE.exec(rendered)?.[1];
|
|
291
|
+
if (!status || status.toLowerCase() === MUGGLE_RUN_PASSED_STATUS) return void 0;
|
|
292
|
+
return MUGGLE_RUN_ID_LINE.exec(rendered)?.[1];
|
|
293
|
+
}
|
|
294
|
+
function applyFailedRun(state, runId) {
|
|
295
|
+
if ((state.failedRuns ?? []).includes(runId)) return state;
|
|
296
|
+
return { ...state, failedRuns: [...state.failedRuns ?? [], runId] };
|
|
297
|
+
}
|
|
298
|
+
function detectDebugEvidenceRunIds(input2, owedRunIds) {
|
|
299
|
+
const toolName = input2.tool_name ?? "";
|
|
300
|
+
const isDiagnosisEmit = MUGGLE_EVENT_EMIT_TOOL.test(toolName) && FAILURE_DIAGNOSIS_EVENT.test(input2.tool_input?.eventType ?? "");
|
|
301
|
+
if (!isDiagnosisEmit && !MUGGLE_FEEDBACK_CREATE_TOOL.test(toolName)) return [];
|
|
302
|
+
const payload = serialize(input2);
|
|
303
|
+
return owedRunIds.filter((runId) => payload.includes(runId));
|
|
304
|
+
}
|
|
305
|
+
function applyDebugEvidence(state, runIds) {
|
|
306
|
+
const debugged = [...state.debuggedRuns ?? []];
|
|
307
|
+
for (const runId of runIds) if (!debugged.includes(runId)) debugged.push(runId);
|
|
308
|
+
if (debugged.length === (state.debuggedRuns ?? []).length) return state;
|
|
309
|
+
return { ...state, debuggedRuns: debugged };
|
|
310
|
+
}
|
|
311
|
+
function isDebugSkipMarker(command) {
|
|
312
|
+
return DEBUG_SKIP_MARKER.test(command);
|
|
313
|
+
}
|
|
314
|
+
function applyDebugSkip(state, command) {
|
|
315
|
+
if (!isDebugSkipMarker(command)) return state;
|
|
316
|
+
const namedRuns = (state.failedRuns ?? []).filter((runId) => command.includes(runId));
|
|
317
|
+
if (namedRuns.length > 0) return applyDebugEvidence(state, namedRuns);
|
|
318
|
+
if (state.debugSkipped === true) return state;
|
|
319
|
+
return { ...state, debugSkipped: true };
|
|
320
|
+
}
|
|
321
|
+
function undebuggedFailedRuns(state) {
|
|
322
|
+
const debugged = new Set(state.debuggedRuns ?? []);
|
|
323
|
+
return (state.failedRuns ?? []).filter((runId) => !debugged.has(runId));
|
|
324
|
+
}
|
|
325
|
+
function debugGateDecision(state, maxBlocks = MAX_DEBUG_BLOCKS) {
|
|
326
|
+
const blockCount = state.debugBlockCount ?? 0;
|
|
327
|
+
const undebugged = undebuggedFailedRuns(state);
|
|
328
|
+
if (state.debugSkipped === true || undebugged.length === 0) {
|
|
329
|
+
return { action: "none" /* None */, blockCount, undebugged };
|
|
330
|
+
}
|
|
331
|
+
if (blockCount >= maxBlocks) {
|
|
332
|
+
return { action: "release" /* Release */, blockCount, undebugged };
|
|
333
|
+
}
|
|
334
|
+
return { action: "block" /* Block */, blockCount: blockCount + 1, undebugged };
|
|
335
|
+
}
|
|
118
336
|
|
|
119
337
|
// src/guardrails/testsGreen.ts
|
|
120
338
|
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/;
|
|
@@ -204,20 +422,12 @@ function watchGateDecision(state, untrackedPrUrls, maxBlocks = MAX_WATCH_BLOCKS)
|
|
|
204
422
|
untracked: untrackedPrUrls
|
|
205
423
|
};
|
|
206
424
|
}
|
|
207
|
-
|
|
208
|
-
// src/guardrails/detectBuildIntent.ts
|
|
209
|
-
var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
|
|
210
|
-
var DEVCYCLE = /\bresolve\b[^.?!]{0,40}\bconflicts?\b|\bget\b[^.?!]{0,40}\bpr\b[^.?!]{0,40}\b(green|merged?|passing)\b/i;
|
|
211
|
-
var QUESTION = /^\s*(why|what|how|when|where|who|is|are|does|do|can you (explain|tell)|explain)\b/i;
|
|
212
|
-
function detectBuildIntent(prompt) {
|
|
213
|
-
const p = (prompt ?? "").trim();
|
|
214
|
-
if (!p || p.startsWith("/")) return false;
|
|
215
|
-
if (QUESTION.test(p)) return false;
|
|
216
|
-
return BUILD.test(p) || DEVCYCLE.test(p);
|
|
217
|
-
}
|
|
218
425
|
var REPORT_SENTINEL = "muggle-pr-section";
|
|
219
|
-
var
|
|
220
|
-
var
|
|
426
|
+
var PR_PROSE_CMD = /\bgh\s+pr\s+(comment|create|edit)\b/;
|
|
427
|
+
var GH_API_CMD = /\bgh\s+api\b/;
|
|
428
|
+
var ISSUE_COMMENT_PATH = /\bissues\/comments\/\d+/;
|
|
429
|
+
var PATCH_METHOD = /(?:--method|-X)[=\s]+PATCH\b/;
|
|
430
|
+
var defaultFileReader = (path, cwd) => {
|
|
221
431
|
try {
|
|
222
432
|
const abs = isAbsolute(path) ? path : resolve(cwd ?? process.cwd(), path);
|
|
223
433
|
if (!existsSync(abs)) return null;
|
|
@@ -226,13 +436,115 @@ var defaultReader = (path, cwd) => {
|
|
|
226
436
|
return null;
|
|
227
437
|
}
|
|
228
438
|
};
|
|
229
|
-
function
|
|
439
|
+
function unquote2(s) {
|
|
230
440
|
const t = s.trim();
|
|
231
441
|
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
232
442
|
return t.slice(1, -1);
|
|
233
443
|
}
|
|
234
444
|
return t;
|
|
235
445
|
}
|
|
446
|
+
function isCommentEditCommand(cmd) {
|
|
447
|
+
return GH_API_CMD.test(cmd) && ISSUE_COMMENT_PATH.test(cmd) && PATCH_METHOD.test(cmd);
|
|
448
|
+
}
|
|
449
|
+
function isPrReportPostCommand(cmd) {
|
|
450
|
+
return PR_PROSE_CMD.test(cmd) || isCommentEditCommand(cmd);
|
|
451
|
+
}
|
|
452
|
+
function collectPrPostText(cmd, cwd, read) {
|
|
453
|
+
let text = cmd;
|
|
454
|
+
for (const match of cmd.matchAll(/(?:--body-file|--input)[=\s]+("[^"]+"|'[^']+'|\S+)/g)) {
|
|
455
|
+
const path = unquote2(match[1]);
|
|
456
|
+
if (path && path !== "-") {
|
|
457
|
+
const contents = read(path, cwd);
|
|
458
|
+
if (contents) text += "\n" + contents;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const match of cmd.matchAll(/jq\b[^|]*?("[^"]+\.json"|'[^']+\.json'|\S+\.json)/g)) {
|
|
462
|
+
const contents = read(unquote2(match[1]), cwd);
|
|
463
|
+
if (contents) text += "\n" + contents;
|
|
464
|
+
}
|
|
465
|
+
return text;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/guardrails/walkthroughPosted.ts
|
|
469
|
+
var WALKTHROUGH_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_WALKTHROUGH_SKIP\b/;
|
|
470
|
+
function isWalkthroughSkipMarker(cmd) {
|
|
471
|
+
return WALKTHROUGH_SKIP_MARKER.test(cmd);
|
|
472
|
+
}
|
|
473
|
+
function detectWalkthroughPost(input2, read = defaultFileReader) {
|
|
474
|
+
if (input2.tool_name !== "Bash") return false;
|
|
475
|
+
const cmd = input2.tool_input?.command ?? "";
|
|
476
|
+
if (!isPrReportPostCommand(cmd)) return false;
|
|
477
|
+
return collectPrPostText(cmd, input2.cwd, read).includes(REPORT_SENTINEL);
|
|
478
|
+
}
|
|
479
|
+
function applyWalkthroughPosted(state, posted) {
|
|
480
|
+
if (!posted || state.walkthroughPosted === true) return state;
|
|
481
|
+
return { ...state, walkthroughPosted: true };
|
|
482
|
+
}
|
|
483
|
+
function applyWalkthroughSkip(state, skipped) {
|
|
484
|
+
if (!skipped || state.walkthroughSkipped === true) return state;
|
|
485
|
+
return { ...state, walkthroughSkipped: true };
|
|
486
|
+
}
|
|
487
|
+
function runGh(args) {
|
|
488
|
+
try {
|
|
489
|
+
return execFileSync("gh", args, {
|
|
490
|
+
encoding: "utf-8",
|
|
491
|
+
timeout: GH_LOOKUP_TIMEOUT_MS,
|
|
492
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
493
|
+
});
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
var defaultPrWalkthroughLookup = {
|
|
499
|
+
branchPrUrl: () => {
|
|
500
|
+
const url = runGh(["pr", "view", "--json", "url", "-q", ".url"])?.trim();
|
|
501
|
+
return url && url.startsWith("http") ? url : null;
|
|
502
|
+
},
|
|
503
|
+
// Scans the description as well as the comments: muggle-do embeds the
|
|
504
|
+
// walkthrough in the body at PR-creation time, and a comments-only check
|
|
505
|
+
// would report that PR as owing one it already carries.
|
|
506
|
+
prCarriesWalkthrough: (prUrl) => {
|
|
507
|
+
const rendered = runGh(["pr", "view", prUrl, "--json", "body,comments"]);
|
|
508
|
+
if (rendered === null) return true;
|
|
509
|
+
return rendered.includes(REPORT_SENTINEL);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function scanForOwedWalkthroughs(state, lookup = defaultPrWalkthroughLookup) {
|
|
513
|
+
const candidates = new Set(state.prsHandled);
|
|
514
|
+
const branchPrUrl = lookup.branchPrUrl();
|
|
515
|
+
if (branchPrUrl) candidates.add(branchPrUrl);
|
|
516
|
+
const owed = [];
|
|
517
|
+
const verified = [];
|
|
518
|
+
for (const prUrl of candidates) {
|
|
519
|
+
if (lookup.prCarriesWalkthrough(prUrl)) verified.push(prUrl);
|
|
520
|
+
else owed.push(prUrl);
|
|
521
|
+
}
|
|
522
|
+
return { owed, verified };
|
|
523
|
+
}
|
|
524
|
+
function walkthroughGateDecision(state, owedPrUrls, maxBlocks = MAX_WALKTHROUGH_BLOCKS) {
|
|
525
|
+
const blockCount = state.walkthroughBlockCount ?? 0;
|
|
526
|
+
const alreadySettled = state.walkthroughPosted === true || state.walkthroughSkipped === true || state.e2eRun !== true;
|
|
527
|
+
if (alreadySettled || owedPrUrls.length === 0) {
|
|
528
|
+
return { action: "none" /* None */, blockCount, owed: owedPrUrls };
|
|
529
|
+
}
|
|
530
|
+
if (blockCount >= maxBlocks) {
|
|
531
|
+
return { action: "release" /* Release */, blockCount, owed: owedPrUrls };
|
|
532
|
+
}
|
|
533
|
+
return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedPrUrls };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/guardrails/detectBuildIntent.ts
|
|
537
|
+
var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
|
|
538
|
+
var DEVCYCLE = /\bresolve\b[^.?!]{0,40}\bconflicts?\b|\bget\b[^.?!]{0,40}\bpr\b[^.?!]{0,40}\b(green|merged?|passing)\b/i;
|
|
539
|
+
var QUESTION = /^\s*(why|what|how|when|where|who|is|are|does|do|can you (explain|tell)|explain)\b/i;
|
|
540
|
+
function detectBuildIntent(prompt) {
|
|
541
|
+
const p = (prompt ?? "").trim();
|
|
542
|
+
if (!p || p.startsWith("/")) return false;
|
|
543
|
+
if (QUESTION.test(p)) return false;
|
|
544
|
+
return BUILD.test(p) || DEVCYCLE.test(p);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/guardrails/reportGate.ts
|
|
236
548
|
function looksLikeE2EReport(text) {
|
|
237
549
|
const t = text.toLowerCase();
|
|
238
550
|
const statusEmojis = (text.match(/[✅❌⚠]/gu) ?? []).length;
|
|
@@ -242,26 +554,11 @@ function looksLikeE2EReport(text) {
|
|
|
242
554
|
const muggleContext = /\bmuggle\b/.test(t) || /muggle-ai\.com/.test(t) || /\be2e\b/.test(t) || /\bacceptance\b/.test(t);
|
|
243
555
|
return resultsStructure && muggleContext;
|
|
244
556
|
}
|
|
245
|
-
function
|
|
246
|
-
let text = cmd;
|
|
247
|
-
for (const m of cmd.matchAll(/--body-file[=\s]+("[^"]+"|'[^']+'|\S+)/g)) {
|
|
248
|
-
const p = unquote(m[1]);
|
|
249
|
-
if (p && p !== "-") {
|
|
250
|
-
const c = read(p, cwd);
|
|
251
|
-
if (c) text += "\n" + c;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
for (const m of cmd.matchAll(/jq\b[^|]*?("[^"]+\.json"|'[^']+\.json'|\S+\.json)/g)) {
|
|
255
|
-
const c = read(unquote(m[1]), cwd);
|
|
256
|
-
if (c) text += "\n" + c;
|
|
257
|
-
}
|
|
258
|
-
return text;
|
|
259
|
-
}
|
|
260
|
-
function evaluateReportPost(input2, read = defaultReader) {
|
|
557
|
+
function evaluateReportPost(input2, read = defaultFileReader) {
|
|
261
558
|
if (input2.tool_name !== "Bash") return { deny: false };
|
|
262
559
|
const cmd = input2.tool_input?.command ?? "";
|
|
263
|
-
if (!
|
|
264
|
-
const text =
|
|
560
|
+
if (!isPrReportPostCommand(cmd)) return { deny: false };
|
|
561
|
+
const text = collectPrPostText(cmd, input2.cwd, read);
|
|
265
562
|
if (text.includes(REPORT_SENTINEL)) return { deny: false };
|
|
266
563
|
if (!looksLikeE2EReport(text)) return { deny: false };
|
|
267
564
|
return {
|
|
@@ -358,10 +655,81 @@ function recordTests() {
|
|
|
358
655
|
e2eRan: isE2ERun(input),
|
|
359
656
|
e2eSkipped: isE2ESkipMarker(cmd)
|
|
360
657
|
});
|
|
361
|
-
const
|
|
658
|
+
const withWatchSkip = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
|
|
659
|
+
const withWalkthroughPost = applyWalkthroughPosted(withWatchSkip, detectWalkthroughPost(input));
|
|
660
|
+
const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, isWalkthroughSkipMarker(cmd));
|
|
661
|
+
const failedRunId = detectFailedRunId(input);
|
|
662
|
+
const next = failedRunId ? applyFailedRun(withWalkthroughSkip, failedRunId) : withWalkthroughSkip;
|
|
663
|
+
if (next !== state) writeState(next);
|
|
664
|
+
return "{}";
|
|
665
|
+
}
|
|
666
|
+
function skillStages() {
|
|
667
|
+
const skillName = resolveSkillNameFromToolInput(input.tool_input);
|
|
668
|
+
if (!skillName) return "{}";
|
|
669
|
+
const state = readState(sessionId);
|
|
670
|
+
const next = applySkillInvocation(
|
|
671
|
+
state,
|
|
672
|
+
skillName,
|
|
673
|
+
resolveSkillStagePaths(skillName, resolvePluginSkillsRoot())
|
|
674
|
+
);
|
|
675
|
+
if (next !== state) writeState(next);
|
|
676
|
+
const unread = unreadMandatoryStages(next);
|
|
677
|
+
if (unread.length === 0) return "{}";
|
|
678
|
+
const ctx = `The ${skillName} skill declares required reading: ${unread.map(stageLabel).join(", ")}. Read those files now, before working through the skill's steps \u2014 they carry mandatory steps SKILL.md only links to, and treating them as optional elaboration is how those steps get silently dropped. A Stop gate holds the turn open until they are opened.`;
|
|
679
|
+
return envelope("PostToolUse", ctx, host);
|
|
680
|
+
}
|
|
681
|
+
function recordStageRead() {
|
|
682
|
+
const filePath = input.tool_input?.file_path;
|
|
683
|
+
if (!filePath) return "{}";
|
|
684
|
+
const state = readState(sessionId);
|
|
685
|
+
const next = applyStageRead(state, filePath);
|
|
686
|
+
if (next !== state) writeState(next);
|
|
687
|
+
return "{}";
|
|
688
|
+
}
|
|
689
|
+
function recordStageSignals() {
|
|
690
|
+
const cmd = input.tool_input?.command ?? "";
|
|
691
|
+
const state = readState(sessionId);
|
|
692
|
+
const withStageSkip = applyStageSkip(state, isStageSkipMarker(cmd));
|
|
693
|
+
const withClassificationSkip = applyClassificationSkip(
|
|
694
|
+
withStageSkip,
|
|
695
|
+
isClassificationSkipMarker(cmd)
|
|
696
|
+
);
|
|
697
|
+
const withDebugSkip = applyDebugSkip(withClassificationSkip, cmd);
|
|
698
|
+
const classifiedTestCaseId = detectClassifiedTestCaseId(input);
|
|
699
|
+
const withClassification = classifiedTestCaseId ? applyClassifiedTestCase(withDebugSkip, classifiedTestCaseId) : withDebugSkip;
|
|
700
|
+
const next = applyDebugEvidence(
|
|
701
|
+
withClassification,
|
|
702
|
+
detectDebugEvidenceRunIds(input, undebuggedFailedRuns(withClassification))
|
|
703
|
+
);
|
|
362
704
|
if (next !== state) writeState(next);
|
|
363
705
|
return "{}";
|
|
364
706
|
}
|
|
707
|
+
function classifyGate() {
|
|
708
|
+
const decision = classificationGateDecision(readState(sessionId), input);
|
|
709
|
+
if (!decision.deny) return "{}";
|
|
710
|
+
const reason = `Test case ${decision.testCaseId} has no pre-execution classification this session. Run muggle-test Step 6f for it first: classify replay-vs-regen per _shared/failure-mode-handling.md \xA7A, then emit one muggle-local-telemetry-event-emit with eventType "pre-execution-classification" for this test case. That step is what calls muggle-remote-test-script-list, which is the only place this run learns the test case has never passed or has failed repeatedly \u2014 cheap now, ~5 minutes of browser time to rediscover after the fact. If this execution genuinely has no classification step (a single user-picked target), say why and run \`echo "MUGGLE_CLASSIFY_SKIP: <reason>"\` \u2014 that records the skip for the rest of the session.`;
|
|
711
|
+
return denyTool(reason, host);
|
|
712
|
+
}
|
|
713
|
+
function stageGate() {
|
|
714
|
+
const state = readState(sessionId);
|
|
715
|
+
const decision = stageGateDecision(state, unreadMandatoryStages(state));
|
|
716
|
+
if (decision.action !== "block" /* Block */) return "{}";
|
|
717
|
+
state.stageBlockCount = decision.blockCount;
|
|
718
|
+
writeState(state);
|
|
719
|
+
const stageList = decision.unread.map(stageLabel).join(", ");
|
|
720
|
+
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A skill invoked this session declares mandatory stages that were never opened: ${stageList}. Read them and carry out what they require \u2014 they are steps, not background reading, and SKILL.md only links to them. If they genuinely do not apply to this run, say why and run \`echo "MUGGLE_STAGE_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Mandatory stages still unread (reminder ${decision.blockCount}/${MAX_STAGE_BLOCKS}): ${stageList}. Read them, or record a legitimate skip via \`echo "MUGGLE_STAGE_SKIP: <reason>"\`.`;
|
|
721
|
+
return blockStop(reason, host);
|
|
722
|
+
}
|
|
723
|
+
function debugPathGate() {
|
|
724
|
+
const state = readState(sessionId);
|
|
725
|
+
const decision = debugGateDecision(state);
|
|
726
|
+
if (decision.action !== "block" /* Block */) return "{}";
|
|
727
|
+
state.debugBlockCount = decision.blockCount;
|
|
728
|
+
writeState(state);
|
|
729
|
+
const runList = decision.undebugged.join(", ");
|
|
730
|
+
const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
|
|
731
|
+
return blockStop(reason, host);
|
|
732
|
+
}
|
|
365
733
|
function e2eGate() {
|
|
366
734
|
const state = readState(sessionId);
|
|
367
735
|
const decision = e2eGateDecision(state);
|
|
@@ -384,6 +752,24 @@ function watchGate() {
|
|
|
384
752
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but no muggle-do session slot tracks it: ${prList}. Seed the slot and hand off per muggle-do Stage 8 \u2014 /muggle:muggle-pr-followup ${decision.untracked[0]} does both. Seeding is what matters: once a slot exists, reconcile arms it at the next session start and finalizes it when the PR goes terminal, so an unarmed slot is fine but no slot means nothing ever picks this PR up. If it genuinely should not be tracked (autoWatchPR=never, handed off elsewhere), tell the user why and run \`echo "MUGGLE_WATCH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `PR hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): seed a slot via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
|
|
385
753
|
return blockStop(reason, host);
|
|
386
754
|
}
|
|
755
|
+
function walkthroughGate() {
|
|
756
|
+
const state = readState(sessionId);
|
|
757
|
+
if (state.e2eRun !== true || state.walkthroughPosted === true || state.walkthroughSkipped === true) {
|
|
758
|
+
return "{}";
|
|
759
|
+
}
|
|
760
|
+
const scan = scanForOwedWalkthroughs(state);
|
|
761
|
+
if (scan.owed.length === 0) {
|
|
762
|
+
if (scan.verified.length > 0) writeState({ ...state, walkthroughPosted: true });
|
|
763
|
+
return "{}";
|
|
764
|
+
}
|
|
765
|
+
const decision = walkthroughGateDecision(state, scan.owed);
|
|
766
|
+
if (decision.action !== "block" /* Block */) return "{}";
|
|
767
|
+
state.walkthroughBlockCount = decision.blockCount;
|
|
768
|
+
writeState(state);
|
|
769
|
+
const prList = decision.owed.join(", ");
|
|
770
|
+
const reason = decision.blockCount === 1 ? `Do not end the turn yet. An E2E acceptance run happened this session but no visual walkthrough has reached ${prList}. Per the postPRVisualWalkthrough preference (default: always), post it now via /muggle:muggle-pr-visual-walkthrough \u2014 include the failed runs, which are the ones reviewers most need to see. If this result genuinely should not be posted (postPRVisualWalkthrough=never, someone else's PR, nothing renderable), tell the user why and run \`echo "MUGGLE_WALKTHROUGH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Walkthrough still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WALKTHROUGH_BLOCKS}): post via /muggle:muggle-pr-visual-walkthrough, or record a legitimate skip via \`echo "MUGGLE_WALKTHROUGH_SKIP: <reason>"\`.`;
|
|
771
|
+
return blockStop(reason, host);
|
|
772
|
+
}
|
|
387
773
|
function reportGate() {
|
|
388
774
|
const reportPostVerdict = evaluateReportPost(input);
|
|
389
775
|
if (!reportPostVerdict.deny || !reportPostVerdict.reason) return "{}";
|
|
@@ -406,7 +792,14 @@ var handlers = {
|
|
|
406
792
|
"e2e-gate": e2eGate,
|
|
407
793
|
"terminal-gate": terminalGate,
|
|
408
794
|
"watch-gate": watchGate,
|
|
795
|
+
"walkthrough-gate": walkthroughGate,
|
|
409
796
|
"report-gate": reportGate,
|
|
410
|
-
"build-router": buildRouter
|
|
797
|
+
"build-router": buildRouter,
|
|
798
|
+
"skill-stages": skillStages,
|
|
799
|
+
"record-stage-read": recordStageRead,
|
|
800
|
+
"record-stage-signals": recordStageSignals,
|
|
801
|
+
"classify-gate": classifyGate,
|
|
802
|
+
"stage-gate": stageGate,
|
|
803
|
+
"debug-path-gate": debugPathGate
|
|
411
804
|
};
|
|
412
805
|
process.stdout.write((handlers[sub] ?? (() => "{}"))());
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Wake conditions for the muggle-pr-followup watch loop — the single definition
|
|
4
|
+
# of what makes a quiet watcher speak. Sourced by pr-watch-loop.sh.
|
|
5
|
+
#
|
|
6
|
+
# These live in a file, rather than in the prose an arming session reads, because
|
|
7
|
+
# a re-derived loop silently loses conditions. Observed across four slots on one
|
|
8
|
+
# machine: two implemented the behind-base wake and two did not, and the two that
|
|
9
|
+
# did not left their PRs permanently unmergeable under a watcher that looked
|
|
10
|
+
# healthy — alive, heartbeating, no fetch errors, and nothing to say. A dropped
|
|
11
|
+
# wake has no failure mode that anyone notices.
|
|
12
|
+
#
|
|
13
|
+
# Every function is pure: arguments in, one event line on stdout, no I/O and no
|
|
14
|
+
# provider calls. That keeps each condition directly testable, which is the
|
|
15
|
+
# property prose never had. Exit 0 means "woke and emitted", 1 means "stay quiet".
|
|
16
|
+
# Each caller owns its own floor and advances it only on a real wake, so one
|
|
17
|
+
# occurrence fires exactly once.
|
|
18
|
+
|
|
19
|
+
# Splits the tab-separated state line into its fields, one per line, preserving
|
|
20
|
+
# empty ones. Not a wake, but every wake below reads its arguments out of this.
|
|
21
|
+
#
|
|
22
|
+
# `IFS=$'\t' read` cannot do it: tab is an IFS *whitespace* character, so bash
|
|
23
|
+
# collapses runs of tabs into a single delimiter, and the two adjacent tabs an
|
|
24
|
+
# empty field produces silently shift every later field left. With no unresolved
|
|
25
|
+
# thread — the common case, and also what a push leaves behind once its thread
|
|
26
|
+
# goes outdated — the thread field is empty, so the pending-check count lands in
|
|
27
|
+
# unresolved_threads and fires a thread wake for a PR with no threads, while the
|
|
28
|
+
# check digest lands in failed_checks and leaves red-CI detection reading a
|
|
29
|
+
# string where it expects a count. awk with an explicit FS does not collapse.
|
|
30
|
+
watch_split_state() {
|
|
31
|
+
printf '%s\n' "$1" | awk -F'\t' '{for (i = 1; i <= NF; i++) print $i}'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# A submitted review newer than the floor. Monotonic ids, so `>` is the whole
|
|
35
|
+
# test. PENDING (unsubmitted) reviews are excluded by the caller's query — they
|
|
36
|
+
# are the reviewer's own drafts and are not feedback until submitted.
|
|
37
|
+
watch_wake_review() {
|
|
38
|
+
local pr="$1" latest="$2" floor="$3"
|
|
39
|
+
[ "${latest:-0}" -gt "${floor:-0}" ] 2>/dev/null || return 1
|
|
40
|
+
echo "EVENT pr=$pr new submitted review id=$latest"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# A thread comment newer than the floor. Same monotonic-id reasoning as reviews.
|
|
44
|
+
watch_wake_comment() {
|
|
45
|
+
local pr="$1" latest="$2" floor="$3"
|
|
46
|
+
[ "${latest:-0}" -gt "${floor:-0}" ] 2>/dev/null || return 1
|
|
47
|
+
echo "EVENT pr=$pr new thread comment id=$latest"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# A thread that is unresolved and not already known to be. `known` is the
|
|
51
|
+
# semicolon-joined THREADS floor; membership is the test, not ordering, because
|
|
52
|
+
# thread ids are opaque strings rather than a monotonic sequence.
|
|
53
|
+
watch_wake_thread() {
|
|
54
|
+
local pr="$1" thread_id="$2" known="$3"
|
|
55
|
+
[ -n "$thread_id" ] || return 1
|
|
56
|
+
case ";${known};" in
|
|
57
|
+
*";${thread_id};"*) return 1 ;;
|
|
58
|
+
esac
|
|
59
|
+
echo "EVENT pr=$pr thread newly unresolved id=$thread_id"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# The head's checks having **settled** red: nothing still pending and at least
|
|
63
|
+
# one failure. Pending-with-a-failure is not a wake — a run in flight may still
|
|
64
|
+
# go green, and the tick would idle on it anyway.
|
|
65
|
+
#
|
|
66
|
+
# The floor is the head SHA rather than a monotonic id because a check rollup is
|
|
67
|
+
# not monotonic: it flips green to red and resets on every push. Keying on the
|
|
68
|
+
# red head fires once per red head and re-arms on the next push.
|
|
69
|
+
watch_wake_ci_red() {
|
|
70
|
+
local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" floor="$5"
|
|
71
|
+
[ -n "$head_sha" ] || return 1
|
|
72
|
+
[ "$head_sha" != "$floor" ] || return 1
|
|
73
|
+
[ "${pending_count:-0}" -eq 0 ] 2>/dev/null || return 1
|
|
74
|
+
[ "${failed_count:-0}" -gt 0 ] 2>/dev/null || return 1
|
|
75
|
+
echo "EVENT pr=$pr checks settled red head=$head_sha"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# The branch needing a rebase onto its base — conflicting with it, or merely
|
|
79
|
+
# behind it.
|
|
80
|
+
#
|
|
81
|
+
# Behind is the half that regenerated loops kept dropping, and it is invisible
|
|
82
|
+
# from the signals a conflict check uses: a behind-but-clean branch reports
|
|
83
|
+
# mergeable=MERGEABLE, exactly like a current one. `mergeStateStatus` cannot
|
|
84
|
+
# stand in either — it is one enum with precedence, and BLOCKED (review
|
|
85
|
+
# required) masks BEHIND on any PR still awaiting approval, which is most of
|
|
86
|
+
# them. Behind-ness has to be measured, so the caller passes `behind_count` from
|
|
87
|
+
# a compare call.
|
|
88
|
+
#
|
|
89
|
+
# The floor is the rebase key `<head_sha>..<base_tip_sha>`, pairing both sides:
|
|
90
|
+
# staleness is a function of the pair, so a head-only key would wedge
|
|
91
|
+
# permanently the first time the base advanced (nothing can move the head while
|
|
92
|
+
# the branch sits blocked). Pairing re-arms whenever either side moves.
|
|
93
|
+
watch_wake_rebase() {
|
|
94
|
+
local pr="$1" mergeable="$2" behind_count="$3" rebase_key="$4" floor="$5"
|
|
95
|
+
[ -n "$rebase_key" ] || return 1
|
|
96
|
+
[ "$rebase_key" != "$floor" ] || return 1
|
|
97
|
+
if [ "$mergeable" = "CONFLICTING" ]; then
|
|
98
|
+
echo "EVENT pr=$pr branch conflicting with base key=$rebase_key"
|
|
99
|
+
return 0
|
|
100
|
+
fi
|
|
101
|
+
[ -n "$behind_count" ] && [ "$behind_count" -gt 0 ] 2>/dev/null || return 1
|
|
102
|
+
echo "EVENT pr=$pr branch behind base by $behind_count — rebase due key=$rebase_key"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# A blocked watch whose CI signature moved at all. This is a resume probe, not a
|
|
106
|
+
# failure probe: it is dormant unless `blocked_digest` is set, and it wakes on
|
|
107
|
+
# any move away from that value rather than only on red, so a block waiting on a
|
|
108
|
+
# green pass, a rerun, or an external deploy check resumes as promptly as one
|
|
109
|
+
# waiting on a failure.
|
|
110
|
+
watch_wake_blocked_resume() {
|
|
111
|
+
local pr="$1" digest="$2" blocked_digest="$3"
|
|
112
|
+
[ -n "$blocked_digest" ] || return 1
|
|
113
|
+
[ -n "$digest" ] || return 1
|
|
114
|
+
[ "$digest" != "$blocked_digest" ] || return 1
|
|
115
|
+
echo "EVENT pr=$pr ci digest moved while blocked — resume check"
|
|
116
|
+
}
|