From 10722fbba53c5fbe2b24e308ac5f125292607043 Mon Sep 17 00:00:00 2001 From: Eve Date: Thu, 23 Apr 2026 09:31:45 +0800 Subject: [PATCH] feat: add long-task gate lock evaluator --- scripts/long_task_gate_lock.mjs | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 scripts/long_task_gate_lock.mjs diff --git a/scripts/long_task_gate_lock.mjs b/scripts/long_task_gate_lock.mjs new file mode 100644 index 0000000..24f9524 --- /dev/null +++ b/scripts/long_task_gate_lock.mjs @@ -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'); +}