Replace the 130-line if-else chain in dispatchNextUnit with a declarative DispatchRule[] table in auto-dispatch.ts. Each rule maps a GSD state to the unit type, unit ID, and prompt builder. Rules are evaluated in order; first match wins. The table is inspectable, testable per-rule, and extensible without modifying orchestration code. - auto-dispatch.ts: 258 lines, 12 named rules - auto.ts dispatch section: 130 lines → 20 lines - Updated auto-draft-pause test to verify rules in new location - 123/123 tests pass, zero TypeScript errors Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06f4bdc7f4
commit
4afcc81382
3 changed files with 291 additions and 163 deletions
258
src/resources/extensions/gsd/auto-dispatch.ts
Normal file
258
src/resources/extensions/gsd/auto-dispatch.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Auto-mode Dispatch Table — declarative phase → unit mapping.
|
||||
*
|
||||
* Each rule maps a GSD state to the unit type, unit ID, and prompt builder
|
||||
* that should be dispatched. Rules are evaluated in order; the first match wins.
|
||||
*
|
||||
* This replaces the 130-line if-else chain in dispatchNextUnit with a
|
||||
* data structure that is inspectable, testable per-rule, and extensible
|
||||
* without modifying orchestration code.
|
||||
*/
|
||||
|
||||
import type { GSDState } from "./types.js";
|
||||
import type { GSDPreferences } from "./preferences.js";
|
||||
import type { UatType } from "./files.js";
|
||||
import { loadFile, extractUatType } from "./files.js";
|
||||
import {
|
||||
resolveMilestoneFile, resolveSliceFile,
|
||||
relSliceFile,
|
||||
} from "./paths.js";
|
||||
import {
|
||||
buildResearchMilestonePrompt,
|
||||
buildPlanMilestonePrompt,
|
||||
buildResearchSlicePrompt,
|
||||
buildPlanSlicePrompt,
|
||||
buildExecuteTaskPrompt,
|
||||
buildCompleteSlicePrompt,
|
||||
buildCompleteMilestonePrompt,
|
||||
buildReplanSlicePrompt,
|
||||
buildRunUatPrompt,
|
||||
buildReassessRoadmapPrompt,
|
||||
checkNeedsReassessment,
|
||||
checkNeedsRunUat,
|
||||
} from "./auto-prompts.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DispatchAction =
|
||||
| { action: "dispatch"; unitType: string; unitId: string; prompt: string; pauseAfterDispatch?: boolean }
|
||||
| { action: "stop"; reason: string; level: "info" | "warning" | "error" }
|
||||
| { action: "skip" };
|
||||
|
||||
export interface DispatchContext {
|
||||
basePath: string;
|
||||
mid: string;
|
||||
midTitle: string;
|
||||
state: GSDState;
|
||||
prefs: GSDPreferences | undefined;
|
||||
}
|
||||
|
||||
interface DispatchRule {
|
||||
/** Human-readable name for debugging and test identification */
|
||||
name: string;
|
||||
/** Return a DispatchAction if this rule matches, null to fall through */
|
||||
match: (ctx: DispatchContext) => Promise<DispatchAction | null>;
|
||||
}
|
||||
|
||||
// ─── Rules ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DISPATCH_RULES: DispatchRule[] = [
|
||||
{
|
||||
name: "summarizing → complete-slice",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "summarizing") return null;
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "complete-slice",
|
||||
unitId: `${mid}/${sid}`,
|
||||
prompt: await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "run-uat (post-completion)",
|
||||
match: async ({ state, mid, basePath, prefs }) => {
|
||||
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
||||
if (!needsRunUat) return null;
|
||||
const { sliceId, uatType } = needsRunUat;
|
||||
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
||||
const uatContent = await loadFile(uatFile);
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "run-uat",
|
||||
unitId: `${mid}/${sliceId}`,
|
||||
prompt: await buildRunUatPrompt(
|
||||
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
||||
),
|
||||
pauseAfterDispatch: uatType !== "artifact-driven",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reassess-roadmap (post-completion)",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
|
||||
if (!needsReassess) return null;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "reassess-roadmap",
|
||||
unitId: `${mid}/${needsReassess.sliceId}`,
|
||||
prompt: await buildReassessRoadmapPrompt(mid, midTitle, needsReassess.sliceId, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "needs-discussion → stop",
|
||||
match: async ({ state, mid, midTitle }) => {
|
||||
if (state.phase !== "needs-discussion") return null;
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
|
||||
level: "warning",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pre-planning (no context) → stop",
|
||||
match: async ({ state, mid, basePath }) => {
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
const hasContext = !!(contextFile && await loadFile(contextFile));
|
||||
if (hasContext) return null; // fall through to next rule
|
||||
return {
|
||||
action: "stop",
|
||||
reason: "No context or roadmap yet. Run /gsd to discuss first.",
|
||||
level: "warning",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pre-planning (no research) → research-milestone",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
if (researchFile) return null; // has research, fall through
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "research-milestone",
|
||||
unitId: mid,
|
||||
prompt: await buildResearchMilestonePrompt(mid, midTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pre-planning (has research) → plan-milestone",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "plan-milestone",
|
||||
unitId: mid,
|
||||
prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "planning (no research, not S01) → research-slice",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "planning") return null;
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
||||
if (researchFile) return null; // has research, fall through
|
||||
// Skip slice research for S01 when milestone research already exists —
|
||||
// the milestone research already covers the same ground for the first slice.
|
||||
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "research-slice",
|
||||
unitId: `${mid}/${sid}`,
|
||||
prompt: await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "planning → plan-slice",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "planning") return null;
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "plan-slice",
|
||||
unitId: `${mid}/${sid}`,
|
||||
prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replanning-slice → replan-slice",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "replanning-slice") return null;
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "replan-slice",
|
||||
unitId: `${mid}/${sid}`,
|
||||
prompt: await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "executing → execute-task",
|
||||
match: async ({ state, mid, basePath }) => {
|
||||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const tid = state.activeTask.id;
|
||||
const tTitle = state.activeTask.title;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "execute-task",
|
||||
unitId: `${mid}/${sid}/${tid}`,
|
||||
prompt: await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "completing-milestone → complete-milestone",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "completing-milestone") return null;
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "complete-milestone",
|
||||
unitId: mid,
|
||||
prompt: await buildCompleteMilestonePrompt(mid, midTitle, basePath),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Resolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate dispatch rules in order. Returns the first matching action,
|
||||
* or a "stop" action if no rule matches (unhandled phase).
|
||||
*/
|
||||
export async function resolveDispatch(ctx: DispatchContext): Promise<DispatchAction> {
|
||||
for (const rule of DISPATCH_RULES) {
|
||||
const result = await rule.match(ctx);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// No rule matched — unhandled phase
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`,
|
||||
level: "info",
|
||||
};
|
||||
}
|
||||
|
||||
/** Exposed for testing — returns the rule names in evaluation order. */
|
||||
export function getDispatchRuleNames(): string[] {
|
||||
return DISPATCH_RULES.map(r => r.name);
|
||||
}
|
||||
|
|
@ -107,20 +107,7 @@ import {
|
|||
buildLoopRemediationSteps,
|
||||
reconcileMergeState,
|
||||
} from "./auto-recovery.js";
|
||||
import {
|
||||
buildResearchMilestonePrompt,
|
||||
buildPlanMilestonePrompt,
|
||||
buildResearchSlicePrompt,
|
||||
buildPlanSlicePrompt,
|
||||
buildExecuteTaskPrompt,
|
||||
buildCompleteSlicePrompt,
|
||||
buildCompleteMilestonePrompt,
|
||||
buildReplanSlicePrompt,
|
||||
buildRunUatPrompt,
|
||||
buildReassessRoadmapPrompt,
|
||||
checkNeedsReassessment,
|
||||
checkNeedsRunUat,
|
||||
} from "./auto-prompts.js";
|
||||
import { resolveDispatch } from "./auto-dispatch.js";
|
||||
import {
|
||||
type AutoDashboardData,
|
||||
updateProgressWidget as _updateProgressWidget,
|
||||
|
|
@ -1347,144 +1334,27 @@ async function dispatchNextUnit(
|
|||
|
||||
await runSecretsGate();
|
||||
|
||||
const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
|
||||
// Flag: for human/mixed UAT, pause auto-mode after the prompt is sent so the user
|
||||
// can perform the UAT manually. On next resume, result file will exist → skip.
|
||||
let pauseAfterUatDispatch = false;
|
||||
// ── Dispatch table: resolve phase → unit type + prompt ──
|
||||
const dispatchResult = await resolveDispatch({
|
||||
basePath, mid, midTitle: midTitle!, state, prefs,
|
||||
});
|
||||
|
||||
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
|
||||
// If the current phase is "summarizing", complete-slice is responsible for
|
||||
// complete-slice must run before reassessment.
|
||||
if (state.phase === "summarizing") {
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
unitType = "complete-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
} else {
|
||||
// ── Adaptive Replanning: check if last completed slice needs reassessment ──
|
||||
// Computed here (after summarizing guard) so complete-slice always runs first.
|
||||
const needsReassess = await checkNeedsReassessment(basePath, mid, state);
|
||||
if (needsRunUat) {
|
||||
const { sliceId, uatType } = needsRunUat;
|
||||
unitType = "run-uat";
|
||||
unitId = `${mid}/${sliceId}`;
|
||||
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
||||
const uatContent = await loadFile(uatFile);
|
||||
prompt = await buildRunUatPrompt(
|
||||
mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
|
||||
);
|
||||
// For non-artifact-driven UAT types, pause after the prompt is dispatched.
|
||||
// The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
|
||||
// then auto-mode pauses for human execution. On resume, result file exists → skip.
|
||||
if (uatType !== "artifact-driven") {
|
||||
pauseAfterUatDispatch = true;
|
||||
}
|
||||
} else if (needsReassess) {
|
||||
unitType = "reassess-roadmap";
|
||||
unitId = `${mid}/${needsReassess.sliceId}`;
|
||||
prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
|
||||
} else if (state.phase === "needs-discussion") {
|
||||
// Draft milestone — pause auto-mode and notify user.
|
||||
// This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
|
||||
// where the user chose "Needs own discussion". Auto-mode cannot proceed because
|
||||
// the draft is seed material, not a finalized context — planning requires a
|
||||
// dedicated discussion first.
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify(
|
||||
`${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
|
||||
} else if (state.phase === "pre-planning") {
|
||||
// Need roadmap — check if context exists
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
const hasContext = !!(contextFile && await loadFile(contextFile));
|
||||
|
||||
if (!hasContext) {
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Research before roadmap if no research exists
|
||||
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
const hasResearch = !!researchFile;
|
||||
|
||||
if (!hasResearch) {
|
||||
unitType = "research-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
|
||||
} else {
|
||||
unitType = "plan-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "planning") {
|
||||
// Slice needs planning — but research first if no research exists
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
||||
const hasResearch = !!researchFile;
|
||||
|
||||
if (!hasResearch) {
|
||||
// Skip slice research for S01 when milestone research already exists —
|
||||
// the milestone research already covers the same ground for the first slice.
|
||||
const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||||
const hasMilestoneResearch = !!milestoneResearchFile;
|
||||
if (hasMilestoneResearch && sid === "S01") {
|
||||
unitType = "plan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
} else {
|
||||
unitType = "research-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
}
|
||||
} else {
|
||||
unitType = "plan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
}
|
||||
|
||||
} else if (state.phase === "replanning-slice") {
|
||||
// Blocker discovered — replan the slice before continuing
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
unitType = "replan-slice";
|
||||
unitId = `${mid}/${sid}`;
|
||||
prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
|
||||
|
||||
} else if (state.phase === "executing" && state.activeTask) {
|
||||
// Execute next task
|
||||
const sid = state.activeSlice!.id;
|
||||
const sTitle = state.activeSlice!.title;
|
||||
const tid = state.activeTask.id;
|
||||
const tTitle = state.activeTask.title;
|
||||
unitType = "execute-task";
|
||||
unitId = `${mid}/${sid}/${tid}`;
|
||||
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
|
||||
|
||||
} else if (state.phase === "completing-milestone") {
|
||||
// All slices done — complete the milestone
|
||||
unitType = "complete-milestone";
|
||||
unitId = mid;
|
||||
prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
|
||||
|
||||
} else {
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify(`Unhandled phase "${state.phase}" — run /gsd doctor to diagnose.`, "info");
|
||||
return;
|
||||
if (dispatchResult.action === "stop") {
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
ctx.ui.notify(dispatchResult.reason, dispatchResult.level);
|
||||
return;
|
||||
}
|
||||
|
||||
unitType = dispatchResult.unitType;
|
||||
unitId = dispatchResult.unitId;
|
||||
prompt = dispatchResult.prompt;
|
||||
let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
||||
|
||||
// ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
|
||||
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
|
||||
if (preDispatchResult.firedHooks.length > 0) {
|
||||
|
|
|
|||
|
|
@ -74,14 +74,8 @@ assert(
|
|||
`executing label should include task ID, got: "${exResult.label}"`,
|
||||
);
|
||||
|
||||
// ─── Static verification: needs-discussion in dispatchNextUnit ──────────────
|
||||
// ─── Static verification: needs-discussion in dispatch table ──────────────
|
||||
|
||||
const autoSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "auto.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// describeNextUnit was extracted to auto-dashboard.ts — check there for the case
|
||||
const dashboardSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "auto-dashboard.ts"),
|
||||
"utf-8",
|
||||
|
|
@ -91,16 +85,22 @@ const dashboardSource = readFileSync(
|
|||
const hasDescribeCase = dashboardSource.includes('case "needs-discussion"');
|
||||
assert(hasDescribeCase, "auto-dashboard.ts describeNextUnit should have 'needs-discussion' case");
|
||||
|
||||
// Check dispatchNextUnit has the branch
|
||||
const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');
|
||||
assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch");
|
||||
// Dispatch logic moved to auto-dispatch.ts — verify the rule exists there
|
||||
const dispatchSource = readFileSync(
|
||||
join(import.meta.dirname, "..", "auto-dispatch.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Check the dispatch branch calls stopAuto
|
||||
const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"');
|
||||
const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600);
|
||||
// Check dispatch table has a needs-discussion rule
|
||||
const hasDispatchRule = dispatchSource.includes('"needs-discussion"');
|
||||
assert(hasDispatchRule, "auto-dispatch.ts should have 'needs-discussion' rule");
|
||||
|
||||
// Check the rule returns a stop action
|
||||
const ruleIdx = dispatchSource.indexOf('"needs-discussion"');
|
||||
const nextChunk = dispatchSource.slice(ruleIdx, ruleIdx + 600);
|
||||
assert(
|
||||
nextChunk.includes("stopAuto"),
|
||||
"needs-discussion dispatch branch should call stopAuto",
|
||||
nextChunk.includes('"stop"') || nextChunk.includes("action: \"stop\""),
|
||||
"needs-discussion dispatch rule should return stop action",
|
||||
);
|
||||
|
||||
// Check notification includes /gsd guidance
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue