fix continuity clean-room install verification

This commit is contained in:
2026-04-24 20:29:08 +08:00
parent 1cf8bf491d
commit 46fa3b8d49
17 changed files with 1765 additions and 45 deletions

View File

@@ -0,0 +1,390 @@
#!/usr/bin/env node
import fs from 'fs';
const EVIDENCE_FIELDS = Object.freeze({
externalizedCheckpoint: Object.freeze([
'externalizedCheckpointPath',
'externalizedTrigger',
'checkpointPath',
]),
concreteNextAction: Object.freeze([
'nextStep',
'requiredNextAction',
'concreteNextAction',
]),
buttonPathMode: Object.freeze([
'handoffMode',
'handoff.mode',
'replyClosureMode',
]),
progressionClaim: Object.freeze([
'progressionClaim',
'claimedProgression',
'statusSummary',
]),
executionEvidence: Object.freeze([
'executionEvidence',
'toolCallEvidence',
'dispatchEvidence',
'fileChangeEvidence',
'verificationEvidence',
'checkpointArtifactEvidence',
]),
autoChainNextAction: Object.freeze([
'autoChainNextAction',
'auto_chain_next_action',
]),
autoChainDispatchEvidence: Object.freeze([
'autoChainDispatchEvidence',
'auto_chain_dispatch_evidence',
]),
progressEvidence: Object.freeze([
'progressEvidence',
'progressEvidence.sessionKey',
'progressEvidence.runId',
'progressEvidence.modified_files',
'progressEvidence.verificationResult',
'sessionKey',
'runId',
'modified_files',
'verificationResult',
]),
});
const GATE_REQUIREMENTS = Object.freeze({
externalizedCheckpoint: Object.freeze({
evidenceKey: 'externalizedCheckpoint',
acceptedFields: EVIDENCE_FIELDS.externalizedCheckpoint,
requiredValue: 'non-empty string',
}),
concreteNextAction: Object.freeze({
evidenceKey: 'concreteNextAction',
acceptedFields: EVIDENCE_FIELDS.concreteNextAction,
requiredValue: 'non-empty string',
}),
buttonPathMode: Object.freeze({
evidenceKey: 'buttonPathMode',
acceptedFields: EVIDENCE_FIELDS.buttonPathMode,
requiredValue: 'button_path',
}),
executionEvidence: Object.freeze({
evidenceKey: 'executionEvidence',
acceptedFields: EVIDENCE_FIELDS.executionEvidence,
requiredValue: 'tool call, dispatch, file change, verification output, or checkpoint artifact evidence',
}),
autoChainDispatchEvidence: Object.freeze({
evidenceKey: 'autoChainDispatchEvidence',
acceptedFields: EVIDENCE_FIELDS.autoChainDispatchEvidence,
requiredValue: 'dispatched-action evidence for the explicit auto-chain next action',
}),
progressEvidence: Object.freeze({
evidenceKey: 'progressEvidence',
acceptedFields: EVIDENCE_FIELDS.progressEvidence,
requiredValue: 'sessionKey, runId, modified_files, verification result, or equivalent concrete progress evidence',
}),
});
function fail(code, message) {
process.stderr.write(`${code}: ${message}\n`);
process.exit(1);
}
function parseArgs(argv) {
const args = { input: '', pretty: true };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--input') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) fail('CLI_ERROR', '--input requires a value');
args.input = value;
i += 1;
} else if (arg === '--compact') {
args.pretty = false;
} else {
fail('CLI_ERROR', `unknown argument: ${arg}`);
}
}
return args;
}
function readInput(path) {
if (!path || path === '-') return fs.readFileSync(0, 'utf8');
return fs.readFileSync(path, 'utf8');
}
function parseJson(raw) {
try {
return JSON.parse(raw);
} catch {
fail('INVALID_JSON', 'input must be valid JSON');
}
}
function isLongTask(input) {
return input.classification === 'long_task';
}
function hasNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function getPathValue(input, path) {
return path.split('.').reduce((current, key) => {
if (current === null || current === undefined) return undefined;
return current[key];
}, input);
}
function hasAnyNonEmptyString(input, fieldPaths) {
return fieldPaths.some((fieldPath) => hasNonEmptyString(getPathValue(input, fieldPath)));
}
function hasAcceptedValue(input, fieldPaths, acceptedValue) {
return fieldPaths.some((fieldPath) => getPathValue(input, fieldPath) === acceptedValue);
}
function describeRequirement(requirement) {
return {
evidenceKey: requirement.evidenceKey,
acceptedFields: [...requirement.acceptedFields],
requiredValue: requirement.requiredValue,
};
}
function hasExternalizedCheckpointPath(input) {
return hasAnyNonEmptyString(input, EVIDENCE_FIELDS.externalizedCheckpoint);
}
function hasConcreteNextAction(input) {
return hasAnyNonEmptyString(input, EVIDENCE_FIELDS.concreteNextAction);
}
function wantsSilentContinuation(input) {
if (typeof input.silentContinuation === 'boolean') return input.silentContinuation;
if (typeof input.silentCandidate === 'boolean') return input.silentCandidate;
if (typeof input.needsWaiting === 'boolean' && input.needsWaiting) return true;
if (typeof input.needsSubagent === 'boolean' && input.needsSubagent) return true;
return false;
}
function claimsExecution(input) {
if (typeof input.claimedExecution === 'boolean') return input.claimedExecution;
if (typeof input.executionClaimed === 'boolean') return input.executionClaimed;
if (typeof input.status === 'string' && input.status === 'active') return true;
return false;
}
function needsOwnerDecision(input) {
if (typeof input.needsOwnerDecision === 'boolean') return input.needsOwnerDecision;
return false;
}
function usesButtonPath(input) {
return hasAcceptedValue(input, EVIDENCE_FIELDS.buttonPathMode, 'button_path');
}
function hasExecutionEvidence(input) {
return EVIDENCE_FIELDS.executionEvidence.some((fieldPath) => {
const value = getPathValue(input, fieldPath);
if (hasNonEmptyString(value)) return true;
if (Array.isArray(value)) return value.length > 0;
if (value && typeof value === 'object') return Object.keys(value).length > 0;
return false;
});
}
function hasExplicitAutoChainNextAction(input) {
return hasAnyNonEmptyString(input, EVIDENCE_FIELDS.autoChainNextAction);
}
function getExplicitAutoChainNextAction(input) {
const nextAction = EVIDENCE_FIELDS.autoChainNextAction
.map((fieldPath) => getPathValue(input, fieldPath))
.find((value) => hasNonEmptyString(value));
return hasNonEmptyString(nextAction) ? nextAction.trim() : '';
}
function isExecutableDispatchAction(action) {
if (!hasNonEmptyString(action)) return false;
return /^dispatch_[a-z0-9]+(?:_[a-z0-9]+)*$/i.test(action.trim());
}
function getNormalizedDispatchAction(value) {
if (!hasNonEmptyString(value)) return '';
const normalized = value.trim();
return isExecutableDispatchAction(normalized) ? normalized : '';
}
function getAutoChainDispatchEvidenceMatch(input) {
const nextAction = getExplicitAutoChainNextAction(input);
if (!isExecutableDispatchAction(nextAction)) return { required: false, matched: false };
for (const fieldPath of EVIDENCE_FIELDS.autoChainDispatchEvidence) {
const value = getPathValue(input, fieldPath);
if (!value) continue;
if (hasNonEmptyString(value)) {
const directMatch = getNormalizedDispatchAction(value);
if (directMatch === nextAction) {
return { required: true, matched: true };
}
continue;
}
if (typeof value !== 'object' || Array.isArray(value)) continue;
const candidates = [
value.action,
value.dispatchedAction,
value.nextAction,
value.autoChainNextAction,
value.requiredNextAction,
value.concreteNextAction,
value.event,
value.type,
value.kind,
value.dispatchType,
value.dispatchAction,
]
.map((candidate) => getNormalizedDispatchAction(candidate))
.filter(Boolean);
const declaresDispatch = [
value.dispatched === true,
value.wasDispatched === true,
value.didDispatch === true,
value.dispatchEvent === true,
value.event === 'dispatch',
value.type === 'dispatch',
value.kind === 'dispatch',
value.dispatchType === 'dispatch',
].some(Boolean);
if (declaresDispatch && candidates.includes(nextAction)) {
return { required: true, matched: true };
}
}
return { required: true, matched: false };
}
function hasAutoChainDispatchEvidence(input) {
return getAutoChainDispatchEvidenceMatch(input).matched;
}
function requiresAutoChainDispatchEvidence(input) {
return getAutoChainDispatchEvidenceMatch(input).required;
}
function hasProgressEvidence(input) {
return EVIDENCE_FIELDS.progressEvidence.some((fieldPath) => {
const value = getPathValue(input, fieldPath);
if (hasNonEmptyString(value)) return true;
if (Array.isArray(value)) return value.length > 0;
if (value && typeof value === 'object') return Object.keys(value).length > 0;
return false;
});
}
function claimsProgression(input) {
const progressionClaim = EVIDENCE_FIELDS.progressionClaim
.map((fieldPath) => getPathValue(input, fieldPath))
.find((value) => hasNonEmptyString(value));
return hasNonEmptyString(progressionClaim);
}
function claimsProgressionWithoutEvidence(input) {
if (!claimsProgression(input)) return false;
return !hasProgressEvidence(input);
}
function evaluateGate(input) {
const gateRequired = isLongTask(input);
const reasons = [];
const requiredEvidence = [];
const allowedResponseModes = [];
if (!gateRequired) {
return {
gateRequired: false,
gateStatus: 'not_applicable',
reasons: ['classification is not long_task'],
requiredEvidence: [],
allowedResponseModes: ['direct_reply'],
};
}
let failed = false;
if (wantsSilentContinuation(input) && !hasExternalizedCheckpointPath(input)) {
failed = true;
reasons.push('silent long-task cannot continue without externalized checkpoint path');
requiredEvidence.push(describeRequirement(GATE_REQUIREMENTS.externalizedCheckpoint));
allowedResponseModes.push('non_silent_follow_up');
}
if (claimsExecution(input) && !hasConcreteNextAction(input)) {
failed = true;
reasons.push('claimed execution requires evidence of a concrete next action');
requiredEvidence.push(describeRequirement(GATE_REQUIREMENTS.concreteNextAction));
allowedResponseModes.push('checkpoint_only');
}
if (needsOwnerDecision(input) && !usesButtonPath(input)) {
failed = true;
reasons.push('owner decision flow must end in button-path, not plain text');
requiredEvidence.push(describeRequirement(GATE_REQUIREMENTS.buttonPathMode));
allowedResponseModes.push('button_path');
}
if (claimsProgressionWithoutEvidence(input)) {
failed = true;
reasons.push('claimed progression without concrete progress evidence is forbidden');
requiredEvidence.push(describeRequirement(GATE_REQUIREMENTS.progressEvidence));
allowedResponseModes.push('evidence_preserving_follow_up');
}
if (requiresAutoChainDispatchEvidence(input) && !hasAutoChainDispatchEvidence(input)) {
failed = true;
reasons.push('explicit auto-chain next action requires dispatched-action evidence');
requiredEvidence.push(describeRequirement(GATE_REQUIREMENTS.autoChainDispatchEvidence));
allowedResponseModes.push('dispatch_required');
}
if (!failed) {
reasons.push('required long-task gate evidence is present or no gated condition was triggered');
allowedResponseModes.push(needsOwnerDecision(input) ? 'button_path' : 'direct_reply');
if (wantsSilentContinuation(input)) allowedResponseModes.push('silent_continuation');
}
return {
gateRequired: true,
gateStatus: failed ? 'fail' : 'pass',
reasons,
requiredEvidence,
allowedResponseModes: [...new Set(allowedResponseModes)],
};
}
function main() {
const args = parseArgs(process.argv);
const raw = readInput(args.input);
const input = parseJson(raw);
const output = evaluateGate(input);
process.stdout.write(JSON.stringify(output, null, args.pretty ? 2 : 0) + '\n');
}
export { evaluateGate };
const isDirectRun = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url));
if (isDirectRun) {
try {
main();
} catch (error) {
fail('CLI_ERROR', error && error.message ? error.message : 'unexpected error');
}
}

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
function fail(code, message) {
process.stderr.write(`${code}: ${message}\n`);
process.exit(1);
}
function parseArgs(argv) {
const args = { input: '', pretty: true };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--input') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) fail('CLI_ERROR', '--input requires a value');
args.input = value;
i += 1;
} else if (arg === '--compact') {
args.pretty = false;
} else {
fail('CLI_ERROR', `unknown argument: ${arg}`);
}
}
return args;
}
function readInput(path) {
if (!path || path === '-') return fs.readFileSync(0, 'utf8');
return fs.readFileSync(path, 'utf8');
}
function normalizeRequest(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
fail('INVALID_JSON', 'input must be valid JSON');
}
return {
requestText: data.requestText || '',
hasFilesOrSystems: Boolean(data.hasFilesOrSystems),
needsWaiting: Boolean(data.needsWaiting),
needsSubagent: Boolean(data.needsSubagent),
needsOwnerDecision: Boolean(data.needsOwnerDecision),
canReplyNow: Boolean(data.canReplyNow),
taskName: data.taskName || 'Untitled long-task',
currentStep: data.currentStep || 'Classifying request',
nextStep: data.nextStep || 'Define next actionable step',
nextReportCondition: data.nextReportCondition || 'After next meaningful milestone',
waitingOn: data.waitingOn || 'none',
blocker: data.blocker || 'none',
checkpointTrigger: data.checkpointTrigger || '',
externalizedTrigger: data.externalizedTrigger || '',
triggerKind: data.triggerKind || '',
};
}
function inferFromRequestText(input) {
const text = (input.requestText || '').toLowerCase();
const inferred = { ...input };
if (!input.canReplyNow && /\b(can( not|'t)? use|check|inspect|investigate|review|verify|fix|debug|analyze|analyse|compare|deploy|run)\b/.test(text)) {
inferred.hasFilesOrSystems = true;
}
if (!input.needsOwnerDecision && /\b(accept|reject|approve|decision|choose|pick|verdict)\b/.test(text)) {
inferred.needsOwnerDecision = true;
}
if (!input.needsWaiting && /\b(wait|later|after|async|background|follow up|follow-up)\b/.test(text)) {
inferred.needsWaiting = true;
}
if (!input.needsSubagent && /\bsubagent\b/.test(text)) {
inferred.needsSubagent = true;
}
if (!input.checkpointTrigger && inferred.needsSubagent) {
inferred.checkpointTrigger = 'when delegated work returns or the next checkpoint fires';
}
if (!input.externalizedTrigger && inferred.needsSubagent) {
inferred.externalizedTrigger = 'wrapper-derived checkpoint artifact';
}
if (!input.triggerKind && inferred.needsSubagent) {
inferred.triggerKind = 'artifact';
}
return inferred;
}
function classify(input) {
const classification = input.canReplyNow && !input.hasFilesOrSystems && !input.needsWaiting && !input.needsSubagent && !input.needsOwnerDecision
? 'general_chat'
: 'long_task';
const silentCandidate = classification === 'long_task' && (input.needsWaiting || input.needsSubagent || Boolean(input.checkpointTrigger));
const needsCheckpoint = classification === 'long_task';
return {
classification,
silentCandidate,
needsOwnerDecision: input.needsOwnerDecision,
needsCheckpoint,
needsSubagent: input.needsSubagent,
};
}
function bootstrapTaskState(input, classificationResult) {
if (classificationResult.classification !== 'long_task') return null;
return {
task_name: input.taskName,
status: input.blocker !== 'none' ? 'blocked' : (input.waitingOn !== 'none' ? 'waiting_user' : 'active'),
current_step: input.currentStep,
next_step: input.nextStep,
next_report_condition: input.nextReportCondition,
waiting_on: input.waitingOn,
blocker: input.blocker,
silent: classificationResult.silentCandidate,
};
}
function toSlug(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function ensureCheckpointArtifact(externalizedCheckpointPath, input, classificationResult) {
if (classificationResult.classification !== 'long_task') return null;
if (!classificationResult.silentCandidate) return null;
if (!externalizedCheckpointPath) return null;
const artifactPath = path.resolve(process.cwd(), externalizedCheckpointPath);
const artifact = {
kind: 'long_task_checkpoint',
triggerKind: input.triggerKind || 'artifact',
checkpointTrigger: input.checkpointTrigger || '',
currentStep: input.currentStep || '',
nextStep: input.nextStep || '',
waitingOn: input.waitingOn || '',
blocker: input.blocker || '',
};
fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
fs.writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8');
const stats = fs.statSync(artifactPath);
const readable = fs.readFileSync(artifactPath, 'utf8');
return {
absolutePath: artifactPath,
bytes: stats.size,
readable: readable.trim().length > 0,
};
}
function buildExternalizedCheckpointPath(input, classificationResult) {
if (classificationResult.classification !== 'long_task') return '';
if (!classificationResult.silentCandidate) return '';
if (!input.externalizedTrigger) return '';
const taskSeed = [input.currentStep, input.nextStep, input.waitingOn, input.blocker]
.map((value) => toSlug(value))
.filter(Boolean)
.join('-');
const stableSeed = taskSeed || 'long-task';
return `checkpoints/${stableSeed}.json`;
}
function buildProgressEvidence(input, classificationResult, externalizedCheckpointPath, checkpointArtifact) {
if (classificationResult.classification !== 'long_task') return null;
if (!classificationResult.silentCandidate) return null;
if (!externalizedCheckpointPath) return null;
if (!checkpointArtifact || checkpointArtifact.readable !== true) return null;
return {
sessionKey: toSlug([input.currentStep, input.waitingOn, input.nextStep].filter(Boolean).join('-')) || 'long-task-session',
checkpointPath: externalizedCheckpointPath,
verificationResult: `checkpoint artifact readable at ${externalizedCheckpointPath}`,
};
}
function validateSilentLaunch(input, classificationResult) {
if (!classificationResult.silentCandidate) {
return {
ok: true,
reason: 'not a silent long-task',
recommendedFallback: 'none',
requiredNextAction: 'proceed_with_normal_long_task_flow',
};
}
if (!input.checkpointTrigger) {
return {
ok: false,
reason: 'missing first forced checkpoint trigger',
recommendedFallback: 'non_silent_follow_up',
requiredNextAction: 'define_first_checkpoint_trigger_before_silent_launch',
};
}
if (!input.externalizedTrigger) {
return {
ok: false,
reason: 'missing externalized checkpoint path',
recommendedFallback: 'non_silent_follow_up',
requiredNextAction: 'bind_externalized_checkpoint_path_or_abort_silent_launch',
};
}
return {
ok: true,
reason: `${input.triggerKind || 'externalized'} trigger is defined`,
recommendedFallback: 'none',
requiredNextAction: 'proceed_with_silent_launch',
};
}
function planHandoff(classificationResult) {
if (classificationResult.needsOwnerDecision) return { mode: 'button_path' };
return { mode: 'direct_reply' };
}
function main() {
const args = parseArgs(process.argv);
const raw = readInput(args.input);
const input = inferFromRequestText(normalizeRequest(raw));
const classificationResult = classify(input);
const taskRecord = bootstrapTaskState(input, classificationResult);
const externalizedCheckpointPath = buildExternalizedCheckpointPath(input, classificationResult);
const checkpointArtifact = ensureCheckpointArtifact(externalizedCheckpointPath, input, classificationResult);
const progressEvidence = buildProgressEvidence(input, classificationResult, externalizedCheckpointPath, checkpointArtifact);
const silentLaunch = validateSilentLaunch(input, classificationResult);
const handoff = planHandoff(classificationResult);
const output = {
classification: classificationResult.classification,
silentCandidate: classificationResult.silentCandidate,
needsOwnerDecision: classificationResult.needsOwnerDecision,
needsCheckpoint: classificationResult.needsCheckpoint,
needsSubagent: classificationResult.needsSubagent,
taskRecord,
progressEvidence,
externalizedCheckpointPath,
checkpointArtifact,
silentLaunchOk: silentLaunch.ok,
silentLaunchReason: silentLaunch.reason,
recommendedFallback: silentLaunch.recommendedFallback,
requiredNextAction: silentLaunch.requiredNextAction,
handoff,
};
process.stdout.write(JSON.stringify(output, null, args.pretty ? 2 : 0) + '\n');
}
try {
main();
} catch (error) {
fail('CLI_ERROR', error && error.message ? error.message : 'unexpected error');
}

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env node
import fs from 'fs';
function fail(code, message) {
process.stderr.write(`${code}: ${message}\n`);
process.exit(1);
}
function parseArgs(argv) {
const args = { input: '', pretty: true };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--input') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) fail('CLI_ERROR', '--input requires a value');
args.input = value;
i += 1;
} else if (arg === '--compact') {
args.pretty = false;
} else {
fail('CLI_ERROR', `unknown argument: ${arg}`);
}
}
return args;
}
function readInput(path) {
if (!path || path === '-') return fs.readFileSync(0, 'utf8');
return fs.readFileSync(path, 'utf8');
}
function parseJson(raw) {
try {
return JSON.parse(raw);
} catch {
fail('INVALID_JSON', 'input must be valid JSON');
}
}
function hasNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function hasEvidenceObject(value) {
if (!value) return false;
if (hasNonEmptyString(value)) return true;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return Object.keys(value).length > 0;
return false;
}
function normalizedAction(value) {
return hasNonEmptyString(value) ? value.trim() : '';
}
function evaluatePlan(input) {
const gateStatus = normalizedAction(input?.gateStatus);
const actorStage = normalizedAction(input?.actorStage);
const requiredNextAction = normalizedAction(input?.requiredNextAction || input?.concreteNextAction || input?.nextStep);
const reviewOutcome = normalizedAction(input?.reviewOutcome).toLowerCase();
const blocker = normalizedAction(input?.blocker);
const executionEvidence = input?.executionEvidence;
const reviewEvidence = input?.reviewEvidence;
const blockerEvidence = input?.blockerEvidence;
if (gateStatus !== 'pass') {
return {
plannerStatus: 'blocked_by_gate',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'gateStatus must pass before auto-chain planning can proceed',
requiredEvidence: ['gateStatus=pass'],
autoChainAllowed: false,
};
}
if (!requiredNextAction) {
return {
plannerStatus: 'none',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'no concrete next action available for auto-chain planning',
requiredEvidence: ['concreteNextAction'],
autoChainAllowed: false,
};
}
if (actorStage === 'implementer_result' && requiredNextAction === 'request_spec_review') {
if (!hasEvidenceObject(executionEvidence)) {
return {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'implementation evidence missing for review-required next action',
requiredEvidence: ['executionEvidence'],
autoChainAllowed: false,
};
}
return {
plannerStatus: 'pass',
derivedAction: 'dispatch_spec_review',
dispatchMode: 'dry_run_dispatch',
reason: 'implementation evidence present; derived spec review dispatch in dry-run mode',
requiredEvidence: ['executionEvidence'],
autoChainAllowed: true,
};
}
if (actorStage === 'spec_review' && reviewOutcome === 'pass' && requiredNextAction === 'request_code_quality_review') {
if (!hasEvidenceObject(reviewEvidence)) {
return {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'review pass evidence missing for code quality review transition',
requiredEvidence: ['reviewEvidence'],
autoChainAllowed: false,
};
}
return {
plannerStatus: 'pass',
derivedAction: 'dispatch_code_quality_review',
dispatchMode: 'dry_run_dispatch',
reason: 'review pass evidence present; derived code quality review dispatch in dry-run mode',
requiredEvidence: ['reviewEvidence'],
autoChainAllowed: true,
};
}
if (requiredNextAction === 'fix_review_findings' || hasNonEmptyString(blocker)) {
if (!hasEvidenceObject(blockerEvidence)) {
return {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'blocker evidence missing for retry/fix transition',
requiredEvidence: ['blockerEvidence'],
autoChainAllowed: false,
};
}
return {
plannerStatus: 'pass',
derivedAction: 'dispatch_fix_slice',
dispatchMode: 'dry_run_dispatch',
reason: 'blocker evidence present; derived retry/fix dispatch in dry-run mode',
requiredEvidence: ['blockerEvidence'],
autoChainAllowed: true,
};
}
return {
plannerStatus: 'none',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
reason: 'no concrete next action matched a dry-run auto-chain transition',
requiredEvidence: ['matchedTransitionEvidence'],
autoChainAllowed: false,
};
}
function main() {
const args = parseArgs(process.argv);
const raw = readInput(args.input);
const input = parseJson(raw);
const output = evaluatePlan(input);
process.stdout.write(JSON.stringify(output, null, args.pretty ? 2 : 0) + '\n');
}
export { evaluatePlan };
const isDirectRun = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url));
if (isDirectRun) {
try {
main();
} catch (error) {
fail('CLI_ERROR', error && error.message ? error.message : 'unexpected error');
}
}

View File

@@ -61,21 +61,32 @@ async function prepareTempWorkspace() {
[plannerPath, path.join(tempWorkspace, 'scripts', 'plan_long_task_auto_chain.mjs')],
[continuityGatePath, path.join(tempWorkspace, 'scripts', 'approved_plan_continuity_gate.mjs')],
[handlerPath, path.join(tempWorkspace, 'hooks', 'force-recall', 'handler.ts')],
[path.join(repoRoot, 'docs', 'RULEBOOK.md'), path.join(tempWorkspace, 'docs', 'RULEBOOK.md')],
[path.join(repoRoot, 'SOUL.md'), path.join(tempWorkspace, 'SOUL.md')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'index.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'index.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'adapters', 'generic-preflight.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters', 'generic-preflight.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'config', 'schema.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'schema.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs')],
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'engine.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'engine.mjs')],
];
for (const [src, dest] of copies) {
await fs.copyFile(src, dest);
}
await fs.writeFile(
path.join(tempWorkspace, 'docs', 'RULEBOOK.md'),
'# Test Fixture RULEBOOK\n\nMinimal clean-room fixture generated by scripts/test_force_recall_long_task_preflight.mjs.\n',
'utf8',
);
await fs.writeFile(
path.join(tempWorkspace, 'SOUL.md'),
'# Test Fixture SOUL\n\nMinimal clean-room fixture generated by scripts/test_force_recall_long_task_preflight.mjs.\n',
'utf8',
);
return tempWorkspace;
}
@@ -370,6 +381,8 @@ async function main() {
assert.match(passInjected, /Do not stop at this completed-task boundary/, 'hook pass-path should explicitly forbid stopping at the completed-task boundary');
assert.match(passInjected, /Auto-dispatch the next task in the same approved plan, unless waiting_user, blocked, pending_verification, or high-risk stop applies/, 'hook pass-path should explain the auto-next obligation exceptions');
assert.match(passInjected, /Do not stop at this completed-task boundary/, 'hook pass-path should hard-gate the completed-task boundary');
assert.match(passInjected, /Do not hand control back to the user with an ordinary progress update while auto-next is still obligatory/, 'hook pass-path should forbid ordinary progress handoff when auto-next obligation is active');
assert.match(passInjected, /If you cannot prove the next dispatch, convert this into an explicit continuity failure instead of a normal status report/, 'hook pass-path should require failure conversion instead of normal progress reporting');
assert.doesNotMatch(passInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\][\s\S]*status=pass/, 'hook pass-path should not let approved-plan continuity pass on dry-run dispatch alone');
const failInjected = await withPatchedWrapper(buildWrapperScript({

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const gateScript = path.join(__dirname, 'long_task_gate_lock.mjs');
const scenarios = [
{
name: 'ordinary chat -> gateStatus=not_applicable',
input: {
classification: 'ordinary_chat',
message: 'just answer directly',
},
expected: {
gateRequired: false,
gateStatus: 'not_applicable',
reasonIncludes: 'classification is not long_task',
allowedResponseModesIncludes: 'direct_reply',
requiredEvidenceLength: 0,
},
},
{
name: 'long-task missing externalized checkpoint -> gateStatus=fail',
input: {
classification: 'long_task',
silentContinuation: true,
},
expected: {
gateRequired: true,
gateStatus: 'fail',
reasonIncludes: 'silent long-task cannot continue without externalized checkpoint path',
allowedResponseModesIncludes: 'non_silent_follow_up',
requiredEvidenceKey: 'externalizedCheckpoint',
},
},
{
name: 'long-task with explicit externalized checkpoint + concrete next action -> gateStatus=pass',
input: {
classification: 'long_task',
silentContinuation: true,
claimedExecution: true,
externalizedCheckpointPath: 'checkpoints/task-42.md',
concreteNextAction: 'Run the queued verifier and report back with output.',
},
expected: {
gateRequired: true,
gateStatus: 'pass',
reasonIncludes: 'required long-task gate evidence is present or no gated condition was triggered',
allowedResponseModesIncludes: 'silent_continuation',
allowedResponseModesIncludesAlso: 'direct_reply',
requiredEvidenceLength: 0,
},
},
{
name: 'owner decision without button-path -> gateStatus=fail',
input: {
classification: 'long_task',
needsOwnerDecision: true,
replyClosureMode: 'plain_text',
},
expected: {
gateRequired: true,
gateStatus: 'fail',
reasonIncludes: 'owner decision flow must end in button-path, not plain text',
allowedResponseModesIncludes: 'button_path',
requiredEvidenceKey: 'buttonPathMode',
},
},
{
name: 'owner decision with button-path -> gateStatus=pass',
input: {
classification: 'long_task',
needsOwnerDecision: true,
replyClosureMode: 'button_path',
},
expected: {
gateRequired: true,
gateStatus: 'pass',
reasonIncludes: 'required long-task gate evidence is present or no gated condition was triggered',
allowedResponseModesIncludes: 'button_path',
requiredEvidenceLength: 0,
},
},
];
function runGate(input) {
const result = spawnSync(process.execPath, [gateScript, '--compact'], {
input: JSON.stringify(input),
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(`gate script failed with status=${result.status}: ${result.stderr || result.stdout}`);
}
let parsed;
try {
parsed = JSON.parse(result.stdout);
} catch (error) {
throw new Error(`gate script returned invalid JSON: ${error.message}\nstdout=${result.stdout}`);
}
return parsed;
}
function requireCoreFields(output) {
assert.equal(typeof output.gateRequired, 'boolean', 'gateRequired should be boolean');
assert.equal(typeof output.gateStatus, 'string', 'gateStatus should be string');
assert.ok(Array.isArray(output.reasons), 'reasons should be an array');
assert.ok(Array.isArray(output.requiredEvidence), 'requiredEvidence should be an array');
assert.ok(Array.isArray(output.allowedResponseModes), 'allowedResponseModes should be an array');
}
function assertScenario(output, expected) {
assert.equal(output.gateRequired, expected.gateRequired, 'gateRequired mismatch');
assert.equal(output.gateStatus, expected.gateStatus, 'gateStatus mismatch');
if (expected.reasonIncludes) {
assert.ok(
output.reasons.some((reason) => reason.includes(expected.reasonIncludes)),
`expected reasons to include: ${expected.reasonIncludes}`,
);
}
if (expected.allowedResponseModesIncludes) {
assert.ok(
output.allowedResponseModes.includes(expected.allowedResponseModesIncludes),
`expected allowedResponseModes to include: ${expected.allowedResponseModesIncludes}`,
);
}
if (expected.allowedResponseModesIncludesAlso) {
assert.ok(
output.allowedResponseModes.includes(expected.allowedResponseModesIncludesAlso),
`expected allowedResponseModes to include: ${expected.allowedResponseModesIncludesAlso}`,
);
}
if (typeof expected.requiredEvidenceLength === 'number') {
assert.equal(
output.requiredEvidence.length,
expected.requiredEvidenceLength,
'requiredEvidence length mismatch',
);
}
if (expected.requiredEvidenceKey) {
assert.ok(
output.requiredEvidence.some((entry) => entry && entry.evidenceKey === expected.requiredEvidenceKey),
`expected requiredEvidence to include key: ${expected.requiredEvidenceKey}`,
);
}
}
const results = [];
let failed = false;
for (const scenario of scenarios) {
try {
const output = runGate(scenario.input);
requireCoreFields(output);
assertScenario(output, scenario.expected);
results.push({
scenario: scenario.name,
ok: true,
gateRequired: output.gateRequired,
gateStatus: output.gateStatus,
reasons: output.reasons,
requiredEvidenceKeys: output.requiredEvidence.map((entry) => entry.evidenceKey),
allowedResponseModes: output.allowedResponseModes,
assertion: 'pass',
});
} catch (error) {
failed = true;
results.push({
scenario: scenario.name,
ok: false,
assertion: 'fail',
error: error instanceof Error ? error.message : String(error),
});
}
}
const summary = {
total: results.length,
passed: results.filter((entry) => entry.ok).length,
failed: results.filter((entry) => !entry.ok).length,
};
process.stdout.write(`${JSON.stringify({ summary, results }, null, 2)}\n`);
if (failed) process.exit(1);

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import { execFileSync, spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.mjs');
const fixtures = [
{
name: 'example',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_example.json'),
assert(output) {
assert.equal(output.classification, 'long_task');
},
},
{
name: 'borderline wrapper inference',
input: {
requestText: 'Inspect the current hook and compare it to the wrapper outputs before replying.',
canReplyNow: false,
},
assert(output) {
assert.equal(output.classification, 'long_task');
assert.equal(output.needsCheckpoint, true);
},
},
{
name: 'invalid silent',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_invalid_silent_example.json'),
assert(output) {
assert.equal(output.silentLaunchOk, false);
},
},
{
name: 'general chat',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_general_chat_example.json'),
assert(output) {
assert.equal(output.classification, 'general_chat');
},
},
{
name: 'non-silent long task',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_non_silent_long_task_example.json'),
assert(output) {
assert.equal(output.classification, 'long_task');
assert.equal(output.silentCandidate, false);
},
},
{
name: 'owner decision',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_silent_owner_decision_example.json'),
assert(output) {
assert.equal(output.handoff.mode, 'button_path');
},
},
{
name: 'subagent wait',
file: path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_subagent_wait_example.json'),
assert(output) {
assert.equal(output.silentCandidate, true);
assert.ok(output.progressEvidence && typeof output.progressEvidence === 'object', 'subagent wait: missing progressEvidence');
assert.equal(typeof output.progressEvidence.sessionKey, 'string', 'subagent wait: missing progressEvidence.sessionKey');
assert.ok(output.progressEvidence.sessionKey.length > 0, 'subagent wait: empty progressEvidence.sessionKey');
assert.equal(typeof output.externalizedCheckpointPath, 'string', 'subagent wait: missing externalizedCheckpointPath');
assert.ok(output.externalizedCheckpointPath.length > 0, 'subagent wait: empty externalizedCheckpointPath');
assert.equal('task_name' in (output.progressEvidence ?? {}), false, 'subagent wait: progressEvidence must not backfill taskRecord.task_name');
assert.equal(JSON.stringify(output.progressEvidence).includes('Wait for delegated log survey'), false, 'subagent wait: progressEvidence must not derive from taskRecord.task_name');
},
},
];
function runFixture(fixture) {
const args = fixture.file
? [wrapperPath, '--compact', '--input', fixture.file]
: [wrapperPath, '--compact'];
const options = {
cwd: repoRoot,
encoding: 'utf8',
};
if (fixture.input) {
options.input = `${JSON.stringify(fixture.input)}\n`;
}
const stdout = execFileSync(process.execPath, args, options);
let output;
try {
output = JSON.parse(stdout);
} catch (error) {
throw new Error(`Fixture \"${fixture.name}\" did not produce valid JSON: ${error.message}\nOutput: ${stdout}`);
}
assert.ok(output.classification !== undefined, `${fixture.name}: missing classification`);
assert.ok(output.silentCandidate !== undefined, `${fixture.name}: missing silentCandidate`);
assert.ok(output.silentLaunchOk !== undefined, `${fixture.name}: missing silentLaunchOk`);
assert.ok(output.requiredNextAction !== undefined, `${fixture.name}: missing requiredNextAction`);
assert.ok(output.handoff && output.handoff.mode !== undefined, `${fixture.name}: missing handoff.mode`);
fixture.assert(output);
return {
name: fixture.name,
output,
};
}
function assertErrorCase(name, args, expectedStderr, input) {
const result = spawnSync(process.execPath, [wrapperPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
input,
});
assert.notEqual(result.status, 0, `${name}: expected non-zero exit`);
assert.equal(result.stdout, '', `${name}: expected empty stdout`);
assert.equal(result.stderr.trim(), expectedStderr, `${name}: unexpected stderr`);
}
function main() {
const results = fixtures.map(runFixture);
const realismWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'wrapper-realism-'));
try {
const realismInput = path.join(repoRoot, 'docs', '_artifacts', 'long_task_governor_wrapper_subagent_wait_example.json');
const stdout = execFileSync(process.execPath, [wrapperPath, '--compact', '--input', realismInput], {
cwd: realismWorkspace,
encoding: 'utf8',
});
const output = JSON.parse(stdout);
assert.equal(typeof output.externalizedCheckpointPath, 'string', 'realism: missing externalizedCheckpointPath');
assert.ok(output.externalizedCheckpointPath.length > 0, 'realism: empty externalizedCheckpointPath');
const artifactPath = path.join(realismWorkspace, output.externalizedCheckpointPath);
assert.ok(fs.existsSync(artifactPath), `realism: checkpoint artifact missing at ${artifactPath}`);
const artifactBody = fs.readFileSync(artifactPath, 'utf8');
assert.ok(artifactBody.trim().length > 0, 'realism: checkpoint artifact should be readable and non-empty');
assert.equal('task_name' in (output.progressEvidence ?? {}), false, 'realism: progressEvidence must not include task_name fallback');
assert.equal(artifactBody.includes('Wait for delegated log survey'), false, 'realism: checkpoint artifact must not fall back to taskRecord.task_name');
results.push({
name: 'real checkpoint artifact',
output: {
classification: output.classification,
silentCandidate: output.silentCandidate,
silentLaunchOk: output.silentLaunchOk,
requiredNextAction: output.requiredNextAction,
handoff: output.handoff,
},
});
} finally {
fs.rmSync(realismWorkspace, { recursive: true, force: true });
}
assertErrorCase('invalid json', ['--compact'], 'INVALID_JSON: input must be valid JSON', 'not-json\n');
assertErrorCase('missing input value', ['--input'], 'CLI_ERROR: --input requires a value');
assertErrorCase('unknown argument', ['--bogus'], 'CLI_ERROR: unknown argument: --bogus');
const summary = {
passed: results.length,
fixtures: results.map(({ name, output }) => ({
name,
classification: output.classification,
silentCandidate: output.silentCandidate,
silentLaunchOk: output.silentLaunchOk,
requiredNextAction: output.requiredNextAction,
handoffMode: output.handoff.mode,
})),
errorCases: 3,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
main();

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const plannerScript = path.join(__dirname, 'plan_long_task_auto_chain.mjs');
const scenarios = [
{
name: 'implementer result with review-required next action -> review dispatch',
input: {
gateStatus: 'pass',
actorStage: 'implementer_result',
requiredNextAction: 'request_spec_review',
executionEvidence: {
modifiedFiles: ['scripts/example.mjs'],
verificationResult: 'tests pass',
},
},
expected: {
plannerStatus: 'pass',
derivedAction: 'dispatch_spec_review',
dispatchMode: 'dry_run_dispatch',
autoChainAllowed: true,
reasonIncludes: 'implementation evidence present',
requiredEvidenceIncludes: 'executionEvidence',
},
},
{
name: 'spec review PASS -> code quality review dispatch',
input: {
gateStatus: 'pass',
actorStage: 'spec_review',
reviewOutcome: 'pass',
requiredNextAction: 'request_code_quality_review',
reviewEvidence: {
reviewer: 'spec-reviewer',
verdict: 'pass',
},
},
expected: {
plannerStatus: 'pass',
derivedAction: 'dispatch_code_quality_review',
dispatchMode: 'dry_run_dispatch',
autoChainAllowed: true,
reasonIncludes: 'review pass evidence present',
requiredEvidenceIncludes: 'reviewEvidence',
},
},
{
name: 'explicit blocker -> retry/fix action',
input: {
gateStatus: 'pass',
actorStage: 'review_result',
blocker: 'tests failed in review',
requiredNextAction: 'fix_review_findings',
blockerEvidence: {
reviewer: 'qa-reviewer',
finding: 'tests failed',
},
},
expected: {
plannerStatus: 'pass',
derivedAction: 'dispatch_fix_slice',
dispatchMode: 'dry_run_dispatch',
autoChainAllowed: true,
reasonIncludes: 'blocker evidence present',
requiredEvidenceIncludes: 'blockerEvidence',
},
},
{
name: 'no concrete next action -> none',
input: {
gateStatus: 'pass',
actorStage: 'implementer_result',
executionEvidence: {
modifiedFiles: ['scripts/example.mjs'],
},
},
expected: {
plannerStatus: 'none',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
autoChainAllowed: false,
reasonIncludes: 'no concrete next action',
requiredEvidenceIncludes: 'concreteNextAction',
},
},
{
name: 'gate fail refuses auto-chain',
input: {
gateStatus: 'fail',
actorStage: 'implementer_result',
requiredNextAction: 'request_spec_review',
executionEvidence: {
modifiedFiles: ['scripts/example.mjs'],
},
},
expected: {
plannerStatus: 'blocked_by_gate',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
autoChainAllowed: false,
reasonIncludes: 'gateStatus must pass',
requiredEvidenceIncludes: 'gateStatus=pass',
},
},
{
name: 'textual review request without implementation evidence -> blocked_by_evidence',
input: {
gateStatus: 'pass',
actorStage: 'implementer_result',
requiredNextAction: 'request_spec_review',
},
expected: {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
autoChainAllowed: false,
reasonIncludes: 'implementation evidence missing',
requiredEvidenceIncludes: 'executionEvidence',
},
},
{
name: 'spec review pass without review evidence -> blocked_by_evidence',
input: {
gateStatus: 'pass',
actorStage: 'spec_review',
reviewOutcome: 'pass',
requiredNextAction: 'request_code_quality_review',
},
expected: {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
autoChainAllowed: false,
reasonIncludes: 'review pass evidence missing',
requiredEvidenceIncludes: 'reviewEvidence',
},
},
{
name: 'fix slice without blocker evidence -> blocked_by_evidence',
input: {
gateStatus: 'pass',
actorStage: 'review_result',
blocker: 'hook_preflight_blocker',
requiredNextAction: 'fix_review_findings',
},
expected: {
plannerStatus: 'blocked_by_evidence',
derivedAction: 'none',
dispatchMode: 'no_dispatch',
autoChainAllowed: false,
reasonIncludes: 'blocker evidence missing',
requiredEvidenceIncludes: 'blockerEvidence',
},
},
];
function runPlanner(input) {
const result = spawnSync(process.execPath, [plannerScript, '--compact'], {
input: JSON.stringify(input),
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(`planner script failed with status=${result.status}: ${result.stderr || result.stdout}`);
}
let parsed;
try {
parsed = JSON.parse(result.stdout);
} catch (error) {
throw new Error(`planner script returned invalid JSON: ${error.message}\nstdout=${result.stdout}`);
}
return parsed;
}
function requireCoreFields(output) {
assert.equal(typeof output.plannerStatus, 'string', 'plannerStatus should be string');
assert.equal(typeof output.derivedAction, 'string', 'derivedAction should be string');
assert.equal(typeof output.dispatchMode, 'string', 'dispatchMode should be string');
assert.equal(typeof output.reason, 'string', 'reason should be string');
assert.ok(Array.isArray(output.requiredEvidence), 'requiredEvidence should be an array');
assert.equal(typeof output.autoChainAllowed, 'boolean', 'autoChainAllowed should be boolean');
}
function assertScenario(output, expected) {
assert.equal(output.plannerStatus, expected.plannerStatus, 'plannerStatus mismatch');
assert.equal(output.derivedAction, expected.derivedAction, 'derivedAction mismatch');
assert.equal(output.dispatchMode, expected.dispatchMode, 'dispatchMode mismatch');
assert.equal(output.autoChainAllowed, expected.autoChainAllowed, 'autoChainAllowed mismatch');
assert.match(output.reason, new RegExp(expected.reasonIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.ok(
output.requiredEvidence.includes(expected.requiredEvidenceIncludes),
`expected requiredEvidence to include: ${expected.requiredEvidenceIncludes}`,
);
}
const results = [];
let failed = false;
for (const scenario of scenarios) {
try {
const output = runPlanner(scenario.input);
requireCoreFields(output);
assertScenario(output, scenario.expected);
results.push({
scenario: scenario.name,
ok: true,
plannerStatus: output.plannerStatus,
derivedAction: output.derivedAction,
dispatchMode: output.dispatchMode,
autoChainAllowed: output.autoChainAllowed,
reason: output.reason,
requiredEvidence: output.requiredEvidence,
});
} catch (error) {
failed = true;
results.push({
scenario: scenario.name,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
const summary = {
total: results.length,
passed: results.filter((entry) => entry.ok).length,
failed: results.filter((entry) => !entry.ok).length,
};
process.stdout.write(`${JSON.stringify({ summary, results }, null, 2)}\n`);
if (failed) process.exit(1);