feat: add long-task gate lock evaluator
This commit is contained in:
151
scripts/long_task_gate_lock.mjs
Normal file
151
scripts/long_task_gate_lock.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/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 isLongTask(input) {
|
||||
return input.classification === 'long_task';
|
||||
}
|
||||
|
||||
function hasExternalizedCheckpointPath(input) {
|
||||
if (typeof input.externalizedCheckpointPath === 'string' && input.externalizedCheckpointPath.trim()) return true;
|
||||
if (typeof input.externalizedTrigger === 'string' && input.externalizedTrigger.trim()) return true;
|
||||
if (typeof input.checkpointPath === 'string' && input.checkpointPath.trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasConcreteNextAction(input) {
|
||||
if (typeof input.nextStep === 'string' && input.nextStep.trim()) return true;
|
||||
if (typeof input.requiredNextAction === 'string' && input.requiredNextAction.trim()) return true;
|
||||
if (typeof input.concreteNextAction === 'string' && input.concreteNextAction.trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof input.handoffMode === 'string') return input.handoffMode === 'button_path';
|
||||
if (input.handoff && typeof input.handoff.mode === 'string') return input.handoff.mode === 'button_path';
|
||||
if (typeof input.replyClosureMode === 'string') return input.replyClosureMode === 'button_path';
|
||||
return false;
|
||||
}
|
||||
|
||||
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('externalizedCheckpointPath');
|
||||
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('nextStep');
|
||||
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('handoff.mode=button_path');
|
||||
allowedResponseModes.push('button_path');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail('CLI_ERROR', error && error.message ? error.message : 'unexpected error');
|
||||
}
|
||||
Reference in New Issue
Block a user