feat: add execution-layer auto-chain dry-run planner
This commit is contained in:
@@ -7,6 +7,16 @@ import { promisify } from "node:util";
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000;
|
const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000;
|
||||||
const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000;
|
const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000;
|
||||||
|
const LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
type AutoChainPlanResult = {
|
||||||
|
plannerStatus: string;
|
||||||
|
derivedAction: string;
|
||||||
|
dispatchMode: string;
|
||||||
|
reason: string;
|
||||||
|
requiredEvidence?: string[];
|
||||||
|
autoChainAllowed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type GateLockResult = {
|
type GateLockResult = {
|
||||||
gateRequired: boolean;
|
gateRequired: boolean;
|
||||||
@@ -210,6 +220,86 @@ async function runLongTaskGateLock(workspaceDir: string, wrapperResult: any): Pr
|
|||||||
return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS);
|
return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAutoChainPlannerInput(gateLockResult: GateLockResult | null, wrapperResult: any): Record<string, unknown> {
|
||||||
|
const requiredNextAction = typeof wrapperResult?.requiredNextAction === "string"
|
||||||
|
? wrapperResult.requiredNextAction.trim()
|
||||||
|
: "";
|
||||||
|
const plannerInput: Record<string, unknown> = {
|
||||||
|
gateStatus: gateLockResult?.gateStatus ?? "not_applicable",
|
||||||
|
actorStage: "hook_preflight",
|
||||||
|
requiredNextAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!requiredNextAction) return plannerInput;
|
||||||
|
|
||||||
|
if (requiredNextAction === "dispatch_follow_up_subagent") {
|
||||||
|
plannerInput.actorStage = "implementer_result";
|
||||||
|
plannerInput.requiredNextAction = "request_spec_review";
|
||||||
|
if (wrapperResult?.autoChainDispatchEvidence && typeof wrapperResult.autoChainDispatchEvidence === "object" && !Array.isArray(wrapperResult.autoChainDispatchEvidence)) {
|
||||||
|
plannerInput.executionEvidence = wrapperResult.autoChainDispatchEvidence;
|
||||||
|
}
|
||||||
|
return plannerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredNextAction === "dispatch_code_quality_review") {
|
||||||
|
plannerInput.actorStage = "spec_review";
|
||||||
|
plannerInput.requiredNextAction = "request_code_quality_review";
|
||||||
|
plannerInput.reviewOutcome = "pass";
|
||||||
|
plannerInput.reviewEvidence = wrapperResult?.reviewEvidence && typeof wrapperResult.reviewEvidence === "object" && !Array.isArray(wrapperResult.reviewEvidence)
|
||||||
|
? wrapperResult.reviewEvidence
|
||||||
|
: { source: "hook_preflight", verdict: "pass" };
|
||||||
|
return plannerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredNextAction === "dispatch_fix_slice") {
|
||||||
|
plannerInput.actorStage = "review_result";
|
||||||
|
plannerInput.requiredNextAction = "fix_review_findings";
|
||||||
|
plannerInput.blocker = typeof wrapperResult?.silentLaunchReason === "string" && wrapperResult.silentLaunchReason.trim()
|
||||||
|
? wrapperResult.silentLaunchReason.trim()
|
||||||
|
: "hook_preflight_blocker";
|
||||||
|
plannerInput.blockerEvidence = wrapperResult?.blockerEvidence && typeof wrapperResult.blockerEvidence === "object" && !Array.isArray(wrapperResult.blockerEvidence)
|
||||||
|
? wrapperResult.blockerEvidence
|
||||||
|
: { source: "hook_preflight", blocker: plannerInput.blocker };
|
||||||
|
return plannerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plannerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoChainPlanner(workspaceDir: string, gateLockResult: GateLockResult | null, wrapperResult: any): Promise<AutoChainPlanResult | null> {
|
||||||
|
if (!wrapperResult || wrapperResult.classification !== "long_task") return null;
|
||||||
|
const plannerPath = path.join(workspaceDir, "scripts", "plan_long_task_auto_chain.mjs");
|
||||||
|
const input = buildAutoChainPlannerInput(gateLockResult, wrapperResult);
|
||||||
|
return runJsonScript(plannerPath, workspaceDir, input, LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutoChainPlanBlock(planResult: AutoChainPlanResult | null): string {
|
||||||
|
if (!planResult) {
|
||||||
|
return [
|
||||||
|
"[LONG_TASK_AUTO_CHAIN_PLAN]",
|
||||||
|
"plannerStatus=degraded",
|
||||||
|
"derivedAction=none",
|
||||||
|
"dispatchMode=no_dispatch",
|
||||||
|
"autoChainAllowed=false",
|
||||||
|
"reason=auto-chain planner unavailable during hook preflight",
|
||||||
|
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"[LONG_TASK_AUTO_CHAIN_PLAN]",
|
||||||
|
`plannerStatus=${planResult.plannerStatus}`,
|
||||||
|
`derivedAction=${planResult.derivedAction}`,
|
||||||
|
`dispatchMode=${planResult.dispatchMode}`,
|
||||||
|
`autoChainAllowed=${planResult.autoChainAllowed}`,
|
||||||
|
`reason=${planResult.reason}`,
|
||||||
|
...((planResult.requiredEvidence ?? []).map((entry) => `requiredEvidence=${entry}`)),
|
||||||
|
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildWrapperEnforcement(wrapperResult: any): string[] {
|
function buildWrapperEnforcement(wrapperResult: any): string[] {
|
||||||
const lines = [
|
const lines = [
|
||||||
"- Treat this as ingress preflight guidance from the wrapper MVP.",
|
"- Treat this as ingress preflight guidance from the wrapper MVP.",
|
||||||
@@ -327,8 +417,9 @@ const forceRecall = async (event: any) => {
|
|||||||
runLongTaskWrapper(workspaceDir, ctx),
|
runLongTaskWrapper(workspaceDir, ctx),
|
||||||
]);
|
]);
|
||||||
const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null;
|
const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null;
|
||||||
|
const autoChainPlanResult = wrapperResult ? await runAutoChainPlanner(workspaceDir, gateLockResult, wrapperResult) : null;
|
||||||
|
|
||||||
if (!rulebook && !soul && !wrapperResult && !gateLockResult) return;
|
if (!rulebook && !soul && !wrapperResult && !gateLockResult && !autoChainPlanResult) return;
|
||||||
|
|
||||||
const wrapperBlock = wrapperResult
|
const wrapperBlock = wrapperResult
|
||||||
? [
|
? [
|
||||||
@@ -353,6 +444,7 @@ const forceRecall = async (event: any) => {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
||||||
|
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
||||||
|
|
||||||
const recallBlock = [
|
const recallBlock = [
|
||||||
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
||||||
@@ -361,6 +453,7 @@ const forceRecall = async (event: any) => {
|
|||||||
"",
|
"",
|
||||||
wrapperBlock || null,
|
wrapperBlock || null,
|
||||||
gateLockBlock,
|
gateLockBlock,
|
||||||
|
autoChainPlanBlock,
|
||||||
rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null,
|
rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null,
|
||||||
soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null,
|
soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null,
|
||||||
"[/RECALL_GATE]",
|
"[/RECALL_GATE]",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const repoRoot = path.resolve(__dirname, '..');
|
|||||||
const handlerPath = path.join(repoRoot, 'hooks', 'force-recall', 'handler.ts');
|
const handlerPath = path.join(repoRoot, 'hooks', 'force-recall', 'handler.ts');
|
||||||
const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.mjs');
|
const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.mjs');
|
||||||
const gateLockPath = path.join(repoRoot, 'scripts', 'long_task_gate_lock.mjs');
|
const gateLockPath = path.join(repoRoot, 'scripts', 'long_task_gate_lock.mjs');
|
||||||
|
const plannerPath = path.join(repoRoot, 'scripts', 'plan_long_task_auto_chain.mjs');
|
||||||
|
|
||||||
async function importTsModule(tsPath) {
|
async function importTsModule(tsPath) {
|
||||||
const source = await fs.readFile(tsPath, 'utf8');
|
const source = await fs.readFile(tsPath, 'utf8');
|
||||||
@@ -55,7 +56,7 @@ function buildWrapperScript(wrapperResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath)]);
|
await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath), fs.access(plannerPath)]);
|
||||||
const { default: forceRecall } = await importTsModule(handlerPath);
|
const { default: forceRecall } = await importTsModule(handlerPath);
|
||||||
assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function');
|
assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function');
|
||||||
|
|
||||||
@@ -74,6 +75,13 @@ async function main() {
|
|||||||
'handoff.mode=button_path',
|
'handoff.mode=button_path',
|
||||||
'[LONG_TASK_GATE_LOCK]',
|
'[LONG_TASK_GATE_LOCK]',
|
||||||
'gateStatus=fail',
|
'gateStatus=fail',
|
||||||
|
'[LONG_TASK_AUTO_CHAIN_PLAN]',
|
||||||
|
'plannerStatus=blocked_by_gate',
|
||||||
|
'derivedAction=none',
|
||||||
|
'dispatchMode=no_dispatch',
|
||||||
|
'autoChainAllowed=false',
|
||||||
|
'reason=gateStatus must pass before auto-chain planning can proceed',
|
||||||
|
'requiredEvidence=gateStatus=pass',
|
||||||
'requiredEvidence=externalizedCheckpoint',
|
'requiredEvidence=externalizedCheckpoint',
|
||||||
'requiredEvidence=concreteNextAction',
|
'requiredEvidence=concreteNextAction',
|
||||||
'requiredEvidence=buttonPathMode',
|
'requiredEvidence=buttonPathMode',
|
||||||
@@ -201,6 +209,11 @@ async function main() {
|
|||||||
handoff: { mode: 'direct_reply' },
|
handoff: { mode: 'direct_reply' },
|
||||||
}), async () => runScenario(forceRecall, requestText));
|
}), async () => runScenario(forceRecall, requestText));
|
||||||
assert.match(passInjected, /gateStatus=pass/, 'hook pass-path should pass when wrapper provides concrete progressEvidence');
|
assert.match(passInjected, /gateStatus=pass/, 'hook pass-path should pass when wrapper provides concrete progressEvidence');
|
||||||
|
assert.match(passInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook pass-path should emit auto-chain plan block');
|
||||||
|
assert.match(passInjected, /plannerStatus=pass/, 'hook pass-path should expose planner pass result');
|
||||||
|
assert.match(passInjected, /derivedAction=dispatch_spec_review/, 'hook pass-path should derive dry-run spec review dispatch');
|
||||||
|
assert.match(passInjected, /dispatchMode=dry_run_dispatch/, 'hook pass-path should stay in dry-run dispatch mode');
|
||||||
|
assert.match(passInjected, /autoChainAllowed=true/, 'hook pass-path should allow auto-chain in dry-run planner output');
|
||||||
|
|
||||||
const failInjected = await withPatchedWrapper(buildWrapperScript({
|
const failInjected = await withPatchedWrapper(buildWrapperScript({
|
||||||
classification: 'long_task',
|
classification: 'long_task',
|
||||||
@@ -213,6 +226,11 @@ async function main() {
|
|||||||
handoff: { mode: 'direct_reply' },
|
handoff: { mode: 'direct_reply' },
|
||||||
}), async () => runScenario(forceRecall, requestText));
|
}), async () => runScenario(forceRecall, requestText));
|
||||||
assert.match(failInjected, /gateStatus=fail/, 'hook fail-path should fail when wrapper exposes explicit auto-chain action without dispatch evidence');
|
assert.match(failInjected, /gateStatus=fail/, 'hook fail-path should fail when wrapper exposes explicit auto-chain action without dispatch evidence');
|
||||||
|
assert.match(failInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook fail-path should emit auto-chain plan block');
|
||||||
|
assert.match(failInjected, /plannerStatus=blocked_by_gate/, 'hook fail-path should report planner blocked by gate');
|
||||||
|
assert.match(failInjected, /derivedAction=none/, 'hook fail-path should not derive a dry-run action');
|
||||||
|
assert.match(failInjected, /dispatchMode=no_dispatch/, 'hook fail-path should remain no-dispatch');
|
||||||
|
assert.match(failInjected, /autoChainAllowed=false/, 'hook fail-path should not allow auto-chain');
|
||||||
assert.match(failInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook fail-path should mention missing dispatched-action evidence');
|
assert.match(failInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook fail-path should mention missing dispatched-action evidence');
|
||||||
assert.match(failInjected, /requiredEvidence=autoChainDispatchEvidence/, 'hook fail-path should require autoChainDispatchEvidence');
|
assert.match(failInjected, /requiredEvidence=autoChainDispatchEvidence/, 'hook fail-path should require autoChainDispatchEvidence');
|
||||||
|
|
||||||
@@ -227,6 +245,11 @@ async function main() {
|
|||||||
handoff: { mode: 'direct_reply' },
|
handoff: { mode: 'direct_reply' },
|
||||||
}), async () => runScenario(forceRecall, requestText));
|
}), async () => runScenario(forceRecall, requestText));
|
||||||
assert.match(neutralInjected, /gateStatus=pass/, 'hook neutral-path should pass when wrapper does not expose an explicit auto-chain action');
|
assert.match(neutralInjected, /gateStatus=pass/, 'hook neutral-path should pass when wrapper does not expose an explicit auto-chain action');
|
||||||
|
assert.match(neutralInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook neutral-path should emit auto-chain plan block');
|
||||||
|
assert.match(neutralInjected, /plannerStatus=none/, 'hook neutral-path should report no derived auto-chain action');
|
||||||
|
assert.match(neutralInjected, /derivedAction=none/, 'hook neutral-path should keep derivedAction as none');
|
||||||
|
assert.match(neutralInjected, /dispatchMode=no_dispatch/, 'hook neutral-path should remain no-dispatch');
|
||||||
|
assert.match(neutralInjected, /autoChainAllowed=false/, 'hook neutral-path should keep auto-chain disabled');
|
||||||
assert.doesNotMatch(neutralInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook neutral-path should not fail on auto-chain evidence when no explicit tool action exists');
|
assert.doesNotMatch(neutralInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook neutral-path should not fail on auto-chain evidence when no explicit tool action exists');
|
||||||
|
|
||||||
const originalGateLock = await fs.readFile(gateLockPath, 'utf8');
|
const originalGateLock = await fs.readFile(gateLockPath, 'utf8');
|
||||||
|
|||||||
Reference in New Issue
Block a user