Files
approved-plan-continuity-ha…/scripts/long_task_governor_wrapper.mjs

262 lines
8.9 KiB
JavaScript

#!/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');
}