Files
automaton/plugins/automaton-guard/plugin.ts
T
gitea 21f16b7da2
CI / build (push) Has been cancelled
Fix verdict parsing + plug stale-task enforcement hole
- task.py: remove naive substring fallback from parse_verdict_status(),
  only parse ## Status: header; no header → ambiguous (REFEREE)
- status.py: add _auto_update_verdict_on_complete() — when transitioning
  human_intervention→complete, auto-update VERDICT.md to PASS
- status.py: add stale-task detection to --can-edit — deny edits if
  all edit tasks have .state mtime >30 min old (reason: stale_task)
- status.py: add --touch command to reset task activity clock
- guard plugin: handle stale_task reason with specific error message
- Update test to match new verdict parsing behavior
2026-06-15 21:55:17 -04:00

86 lines
3.5 KiB
TypeScript

import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin"
const STATUS_SCRIPT = process.env.HOME + "/.automaton/scripts/status.py"
const PROJECT_ROOT = process.cwd()
async function checkCanEdit(file?: string): Promise<{ allowed: boolean; reason: string; task?: string; staleTask?: string; staleMinutes?: number }> {
const { execSync } = await import("child_process")
let cmd = `python3 "${STATUS_SCRIPT}" --can-edit --project "${PROJECT_ROOT}"`
if (file) {
cmd += ` --file "${file}"`
}
cmd += " --json"
try {
const output = execSync(cmd, { encoding: "utf-8", timeout: 5000 })
const lines = output.trim().split("\n")
const jsonLine = lines[lines.length - 1]
const result = JSON.parse(jsonLine)
return {
allowed: result.allowed,
reason: result.reason,
task: result.primary_task?.task,
staleTask: result.stale_task,
staleMinutes: result.stale_minutes,
}
} catch (e: any) {
if (e.status === 1) {
const stderr = (e.stderr || "").trim()
const stdout = (e.stdout || "").trim()
const lines = (stdout || stderr).split("\n")
const jsonLine = lines[lines.length - 1]
try {
const result = JSON.parse(jsonLine)
return {
allowed: false,
reason: result.reason,
staleTask: result.stale_task,
staleMinutes: result.stale_minutes,
}
} catch {
return { allowed: false, reason: stderr || "Denied by automaton" }
}
}
return { allowed: true, reason: "status.py not available, allowing edit" }
}
}
export default (async ({ client, project, directory }: PluginInput): Promise<Hooks> => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "edit" && input.tool !== "write") {
return
}
const filePath = input.args?.file_path || input.args?.path || input.args?.[0] || ""
if (!filePath) {
return
}
const { allowed, reason, task, staleTask, staleMinutes } = await checkCanEdit(filePath)
if (!allowed) {
if (reason === "stale_task") {
throw new Error(
`[AUTOMATON GUARD] BLOCKED: Task '${staleTask || "unknown"}' has been in edit phase for ${staleMinutes || "?"} minutes (stale).\n\n` +
`To continue work on this task, touch it first:\n` +
` python ~/.automaton/scripts/status.py --touch --task ${staleTask || "<task>"} --project ${directory}\n\n` +
`Or create a new task for new work:\n` +
` python ~/.automaton/scripts/status.py --create-task <name> --project ${directory}\n` +
` python ~/.automaton/scripts/status.py --transition implement --task <name> --project ${directory}`
);
}
const msg = reason === "no_edit_tasks"
? `BLOCKED: No task in implement or doc_review phase. Create or transition a task first.`
: reason === "out_of_scope"
? `BLOCKED: File is outside the project scope.`
: reason === "wrong_phase"
? `BLOCKED: Current task is not in an edit-allowed phase. Transition it to implement or doc_review first.`
: `BLOCKED: ${reason || "Edit denied by automaton framework"}`;
throw new Error(`[AUTOMATON GUARD] ${msg}\n\nTo proceed:\n1. Create a task: python ~/.automaton/scripts/status.py --create-task <name> --project ${directory}\n2. Transition it: python ~/.automaton/scripts/status.py --transition implement --task <name> --project ${directory}`);
}
},
}
}) satisfies Plugin