From 340cc542165ae478280d4dccd180acd7a7492c53 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:14:51 +0900 Subject: [PATCH 01/11] Add plan-local decision waits and IDLE Stop release --- .../pabcd-state/src/goalplan-cli.ts | 94 ++++++++- .../components/pabcd-state/src/goalplan.ts | 191 ++++++++++++++++-- .../components/pabcd-state/src/hook.ts | 5 +- .../test/goalplan-public-surface.test.ts | 100 ++++++++- .../test/hook-continuation.test.ts | 82 ++++++++ .../pabcd-state/test/orchestrate-cli.test.ts | 2 +- .../test/work-phase-states.test.ts | 121 +++++++++++ 7 files changed, 572 insertions(+), 23 deletions(-) diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts index 78dde175..e6d584dc 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts @@ -13,6 +13,9 @@ */ import { addGoalplanTask, + askGoalplanDecision, + decideGoalplanDecision, + openDecisionIdsForPhase, buildGoalplan, completeGoalplanTask, goalplanDefinitionIntegrityReasons, @@ -59,6 +62,8 @@ export type GoalplanVerb = | "add-task" | "complete-task" | "meet-criterion" + | "ask" + | "decide" | "help"; export interface GoalplanCliArgs { @@ -98,6 +103,10 @@ export interface GoalplanCliArgs { dependsOn?: string[]; /** `add-task` / `complete-task`: which work phase owns the task. */ workPhaseId?: string; + workPhaseIds?: string[]; + question?: string; + recommendation?: string; + answer?: string; /** `complete-task`: the outcome evidence a done task must carry. */ outcome?: string; /** `meet-criterion`: the captured evidence a met criterion must carry. */ @@ -124,12 +133,15 @@ const VERBS: ReadonlySet = new Set([ "add-task", "complete-task", "meet-criterion", + "ask", + "decide", ]); type GoalplanFlag = | "--objective" | "--slug" | "--criterion" | "--cwd" | "--session" | "--batch-json" | "--surface" | "--presented" | "--id" | "--title" | "--work-phase" - | "--outcome" | "--schema-version" | "--evidence" | "--json" | "--depends-on"; + | "--outcome" | "--schema-version" | "--evidence" | "--json" | "--depends-on" + | "--question" | "--recommendation" | "--answer"; type VerbRule = { allowed: ReadonlySet; @@ -148,6 +160,8 @@ const VERB_RULES: Readonly> = { "add-task": { allowed: new Set(["--session", "--work-phase", "--id", "--title", "--depends-on", "--cwd"]), repeatable: new Set(["--depends-on"]), usage: "add-task --session --work-phase --id --title [--depends-on ]... [--cwd ]" }, "complete-task": { allowed: new Set(["--session", "--work-phase", "--id", "--outcome", "--cwd"]), repeatable: new Set(), usage: "complete-task --session --work-phase --id --outcome [--cwd ]" }, "meet-criterion": { allowed: new Set(["--session", "--id", "--evidence", "--cwd"]), repeatable: new Set(), usage: "meet-criterion --session --id --evidence [--cwd ]" }, + ask: { allowed: new Set(["--session", "--id", "--question", "--recommendation", "--work-phase", "--cwd"]), repeatable: new Set(["--work-phase"]), usage: "ask --session --id --question [--recommendation ] [--work-phase ]... [--cwd ]" }, + decide: { allowed: new Set(["--session", "--id", "--answer", "--cwd"]), repeatable: new Set(), usage: "decide --session --id --answer [--cwd ]" }, help: { allowed: new Set(), repeatable: new Set(), usage: "--help" }, }; @@ -163,12 +177,12 @@ export function parseGoalplanCliArgs(argv: string[], cwd: string): GoalplanCliAr } if (!VERBS.has(verb)) { return { - error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion); run cxc loop --help`, + error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion|ask|decide); run cxc loop --help`, }; } const selected = verb as GoalplanVerb; const rule = VERB_RULES[selected]; - const out: GoalplanCliArgs = { verb: selected, cwd, criteria: [], dependsOn: [] }; + const out: GoalplanCliArgs = { verb: selected, cwd, criteria: [], dependsOn: [], workPhaseIds: [] }; const seen = new Set(); const reject = (message: string): GoalplanCliParseError => ({ error: `${selected}: ${message}` }); for (let i = 1; i < argv.length; i++) { @@ -213,7 +227,17 @@ export function parseGoalplanCliArgs(argv: string[], cwd: string): GoalplanCliAr case "--presented": out.presented = value; break; case "--id": out.id = value; break; case "--title": out.title = value; break; - case "--work-phase": out.workPhaseId = value; break; + case "--work-phase": { + if (selected !== "ask") { out.workPhaseId = value; break; } + const phaseId = value.trim(); + if (!phaseId) return reject("--work-phase requires one non-empty id"); + if (out.workPhaseIds!.includes(phaseId)) return reject(`--work-phase must not repeat id '${phaseId}'`); + out.workPhaseIds!.push(phaseId); + break; + } + case "--question": out.question = value; break; + case "--recommendation": out.recommendation = value; break; + case "--answer": out.answer = value; break; case "--outcome": out.outcome = value; break; case "--schema-version": { const parsed = Number(value); @@ -438,6 +462,13 @@ function runReady(args: GoalplanCliArgs, plan: Goalplan): GoalplanCliResult { const phases = readyWorkPhases(plan); const tasks = readyTasks(plan); + const openDecisions = (plan.decisions ?? []).filter((decision) => decision.status === "open") + .map(({ id, question, recommendation, askedAt }) => ({ id, question, ...(recommendation === undefined ? {} : { recommendation }), askedAt })); + const awaitingDecisions = plan.workPhases + .filter((wp) => wp.status === "pending" || wp.status === "in_progress") + .map((wp) => ({ workPhaseId: wp.id, decisionIds: openDecisionIdsForPhase(plan, wp).filter((id) => + (plan.decisions ?? []).some((decision) => decision.id === id && decision.status === "open")) })) + .filter((entry) => entry.decisionIds.length > 0); if (args.json === true) { return { output: JSON.stringify({ @@ -455,6 +486,7 @@ function runReady(args: GoalplanCliArgs, plan: Goalplan): GoalplanCliResult { id: entry.task.id, title: entry.task.title, })), + ...(plan.decisions === undefined ? {} : { openDecisions, awaitingDecisions }), }), code: 0, }; @@ -467,9 +499,52 @@ function runReady(args: GoalplanCliArgs, plan: Goalplan): GoalplanCliResult { lines.push(tasks.length > 0 ? `readyTasks: ${tasks.map((entry) => `${entry.workPhaseId}/${entry.task.id} (${entry.task.title})`).join("; ")}` : "readyTasks: none"); + if (plan.decisions !== undefined) { + lines.push(openDecisions.length > 0 + ? `openDecisions: ${openDecisions.map((decision) => `${decision.id} (${decision.question})`).join("; ")}` + : "openDecisions: none"); + lines.push(awaitingDecisions.length > 0 + ? `awaitingDecisions: ${awaitingDecisions.map((entry) => `${entry.workPhaseId}: ${entry.decisionIds.join(", ")}`).join("; ")}` + : "awaitingDecisions: none"); + } return { output: lines.join("\n"), code: 0 }; } +/** Record a host-submitted question or its answer under the goalplan write lock. */ +function runDecision(args: GoalplanCliArgs): GoalplanCliResult { + const session = (args.session ?? "").trim(); + if (!session) return { output: `loop ${args.verb}: --session is required`, code: 1 }; + if (!isCanonicalSessionId(session)) return { output: `loop ${args.verb}: session id is not canonical`, code: 1 }; + const slug = readState(args.cwd, session).slug; + if (!slug) return { output: `loop ${args.verb}: session '${session}' has no bound goalplan - run \`cxc loop init --session ${session}\` first`, code: 1 }; + const id = (args.id ?? "").trim(); + if (args.verb === "ask" && (!id || !(args.question ?? "").trim())) { + return { output: "loop ask: --id and non-empty --question are required", code: 1 }; + } + if (args.verb === "decide" && (!id || !(args.answer ?? "").trim())) { + return { output: "loop decide: --id and non-empty --answer are required", code: 1 }; + } + type DecisionCommit = { kind: "rejected"; reason: string } | { kind: "changed" } | { kind: "unchanged"; reason: string }; + const locked = withGoalplanWriteLock(args.cwd, slug, (plan) => { + const result = args.verb === "ask" + ? askGoalplanDecision(plan, { + id, question: args.question!, recommendation: args.recommendation, + workPhaseIds: args.workPhaseIds ?? [], askedAt: new Date().toISOString(), + }) + : decideGoalplanDecision(plan, id, args.answer!, new Date().toISOString()); + if (result.kind === "rejected") return { kind: "rejected", reason: result.reason }; + if (result.kind === "unchanged") return { kind: "unchanged", reason: result.reason }; + writeGoalplan(args.cwd, result.plan); + return { kind: "changed" }; + }); + if (locked.kind === "locked" || locked.kind === "unreadable") { + return { output: `loop ${args.verb}: ${locked.reason}`, code: 1 }; + } + if (locked.value.kind === "rejected") return { output: `loop ${args.verb}: ${locked.value.reason}`, code: 1 }; + if (locked.value.kind === "unchanged") return { output: `loop ${args.verb}: ${locked.value.reason}; nothing to do`, code: 0 }; + return { output: `loop ${args.verb}: ${slug} ${id} applied`, code: 0 }; +} + /** * 060 wp6: the three lifecycle verbs share one locked read-modify-write. * @@ -610,6 +685,12 @@ function renderPlanLines(plan: Goalplan, lock?: GoalplanWriteLockStatus): string for (const c of plan.criteria) { lines.push(` - ${c.id} [${c.status}] ${c.scenario}`); } + for (const decision of plan.decisions ?? []) { + if (decision.status !== "open") continue; + lines.push(` - ${decision.id} [open] ${decision.question}`); + const waiting = plan.workPhases.filter((wp) => wp.awaitsDecision?.includes(decision.id)); + lines.push(` waiting: ${waiting.map((wp) => wp.id).join(", ") || "none"}`); + } return lines.join("\n"); } @@ -623,7 +704,7 @@ export function renderGoalplanHelp(): string { "cxc loop — durable goalplan for a multi-cycle PABCD loop", "", "Usage:", - ...(["init", "show", "validate", "steer", "add-criterion", "add-work-phase", "ready", "add-task", "complete-task", "meet-criterion", "help"] as const) + ...(["init", "show", "validate", "steer", "add-criterion", "add-work-phase", "ready", "add-task", "complete-task", "meet-criterion", "ask", "decide", "help"] as const) .map((verb) => ` cxc loop ${VERB_RULES[verb].usage}`), "", "Notes:", @@ -639,6 +720,8 @@ export function renderGoalplanHelp(): string { " additionally require an approved finalGate, and no verb in this build opens a", " final-gate review round, so opt in only if you can record that gate yourself.", " meet-criterion requires non-empty captured evidence for the same reason.", + " Send the question through the host first, then record it with ask; ask never sends a message.", + " Record the user's reply with decide. It changes only the decision record.", "", "steer --batch-json expects an object with:", ' { "idempotencyKey": "", "rationale": "", "evidence": "",', @@ -710,6 +793,7 @@ export function runGoalplanCli(args: GoalplanCliArgs): GoalplanCliResult { } if (args.verb === "steer") return runSteer(args); + if (args.verb === "ask" || args.verb === "decide") return runDecision(args); if (args.verb === "add-criterion" || args.verb === "add-work-phase") return runAddOp(args); diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts index 7fd75cc7..4bc77b96 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts @@ -130,12 +130,24 @@ export interface GoalplanWorkPhase { criteriaIds: string[]; /** Work-phase ids in this plan that must be `done` first; see GoalplanTask.dependsOn. */ dependsOn?: string[]; + /** Decision ids; any open target pauses this phase without changing status. */ + awaitsDecision?: string[]; /** why a `blocked` phase cannot proceed; cleared when it is unblocked. */ blockedReason?: string; /** the phase that took over the work; required on a `superseded` phase. */ supersededBy?: string; } +export interface GoalplanDecision { + id: string; + question: string; + recommendation?: string; + status: "open" | "decided"; + answer?: string; + askedAt: string; + decidedAt?: string; +} + export interface GoalplanHostLink { /** true only after a freeze-boundary arm (the MAIN session created a goal). */ armed: boolean; @@ -222,6 +234,7 @@ export interface Goalplan { /** the durable work-phase cursor the FSM does NOT hold across a D-close. */ activeWorkPhaseId: string | null; workPhases: GoalplanWorkPhase[]; + decisions?: GoalplanDecision[]; criteria: GoalplanCriterion[]; host: GoalplanHostLink; /** review rounds, oldest first. Absent on plans created before 010. */ @@ -504,6 +517,37 @@ function reviveDependsOn(value: unknown): string[] | undefined | "invalid" { return ids; } +function validIsoTime(value: unknown): value is string { + if (typeof value !== "string") return false; + const date = new Date(value); + return Number.isFinite(date.valueOf()) && date.toISOString() === value; +} + +function reviveDecisions(value: unknown): GoalplanDecision[] | undefined | "invalid" { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return "invalid"; + const decisions: GoalplanDecision[] = []; + for (const item of value) { + if (typeof item !== "object" || item === null || Array.isArray(item)) return "invalid"; + const d = item as Record; + if (typeof d.id !== "string" || !LIFECYCLE_ID_RE.test(d.id) + || typeof d.question !== "string" || !d.question.trim() + || !validIsoTime(d.askedAt) + || (d.recommendation !== undefined && (typeof d.recommendation !== "string" || !d.recommendation.trim()))) return "invalid"; + if (d.status === "open") { + if (d.answer !== undefined || d.decidedAt !== undefined) return "invalid"; + decisions.push({ id: d.id, question: d.question, status: "open", askedAt: d.askedAt, + ...(d.recommendation === undefined ? {} : { recommendation: d.recommendation as string }) }); + } else if (d.status === "decided") { + if (typeof d.answer !== "string" || !d.answer.trim() || !validIsoTime(d.decidedAt)) return "invalid"; + decisions.push({ id: d.id, question: d.question, status: "decided", answer: d.answer, + askedAt: d.askedAt, decidedAt: d.decidedAt, + ...(d.recommendation === undefined ? {} : { recommendation: d.recommendation as string }) }); + } else return "invalid"; + } + return decisions; +} + /** Best-effort structural validation; a malformed object reads as absent (null). */ function reviveGoalplan(parsed: unknown, expectedSlug?: string): Goalplan | null { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; @@ -525,6 +569,8 @@ function reviveGoalplan(parsed: unknown, expectedSlug?: string): Goalplan | null if (typeof w.id !== "string" || typeof w.title !== "string") return null; const phaseDependsOn = reviveDependsOn(w.dependsOn); if (phaseDependsOn === "invalid") return null; + const awaitsDecision = reviveDependsOn(w.awaitsDecision); + if (awaitsDecision === "invalid") return null; const status: WorkPhaseStatus = w.status === "in_progress" || w.status === "done" || w.status === "blocked" || w.status === "superseded" ? w.status @@ -548,6 +594,7 @@ function reviveGoalplan(parsed: unknown, expectedSlug?: string): Goalplan | null : []; const phase: GoalplanWorkPhase = { id: w.id, title: w.title, status, tasks, criteriaIds }; if (phaseDependsOn !== undefined) phase.dependsOn = phaseDependsOn; + if (awaitsDecision !== undefined) phase.awaitsDecision = awaitsDecision; if (typeof w.blockedReason === "string") phase.blockedReason = w.blockedReason; if (typeof w.supersededBy === "string") phase.supersededBy = w.supersededBy; workPhases.push(phase); @@ -580,6 +627,8 @@ function reviveGoalplan(parsed: unknown, expectedSlug?: string): Goalplan | null }; const reviewRounds = reviveReviewRounds(o.reviewRounds); + const decisions = reviveDecisions(o.decisions); + if (decisions === "invalid") return null; const plan: Goalplan = { objective: o.objective, @@ -594,6 +643,7 @@ function reviveGoalplan(parsed: unknown, expectedSlug?: string): Goalplan | null // Only attach the 010 fields when they are actually present, so a plan written // before this feature round-trips byte-identical. if (reviewRounds !== undefined) plan.reviewRounds = reviewRounds; + if (decisions !== undefined) plan.decisions = decisions; if (typeof o.activePlanAuditRoundId === "string") plan.activePlanAuditRoundId = o.activePlanAuditRoundId; if (typeof o.activeFinalGateRoundId === "string") plan.activeFinalGateRoundId = o.activeFinalGateRoundId; if (typeof o.schemaVersion === "number" && Number.isFinite(o.schemaVersion)) { @@ -822,6 +872,7 @@ function firstInvalidField(parsed: unknown): string { for (const rawWp of o.workPhases) { const wp = rawWp as Record; if (reviveDependsOn(wp.dependsOn) === "invalid") return "workPhases[].dependsOn"; + if (reviveDependsOn(wp.awaitsDecision) === "invalid") return "workPhases[].awaitsDecision"; for (const rawTask of Array.isArray(wp.tasks) ? wp.tasks : []) { if (typeof rawTask !== "object" || rawTask === null) continue; const task = rawTask as Record; @@ -839,6 +890,7 @@ function firstInvalidField(parsed: unknown): string { if (typeof o.host !== "object" || o.host === null || typeof (o.host as Record).armed !== "boolean") { return "host (needs armed/armedAt/source)"; } + if (reviveDecisions(o.decisions) === "invalid") return "decisions"; if (o.steeringLog !== undefined && !Array.isArray(o.steeringLog)) return "steeringLog"; return "(unknown)"; } @@ -983,11 +1035,21 @@ function taskDependenciesMet(phase: GoalplanWorkPhase, task: GoalplanTask): bool ); } +export function openDecisionIdsForPhase(plan: Goalplan, wp: GoalplanWorkPhase): string[] { + // Only a unique, decided target releases a reference. Missing/duplicate ids fail closed. + return [...new Set(wp.awaitsDecision ?? [])].filter((id) => { + const matches = (plan.decisions ?? []).filter((decision) => decision.id === id); + return !(matches.length === 1 && matches[0].status === "decided"); + }); +} + +function workPhaseReadyConditionsMet(plan: Goalplan, wp: GoalplanWorkPhase): boolean { + return workPhaseDependenciesMet(plan, wp) && openDecisionIdsForPhase(plan, wp).length === 0; +} + function isRunnablePhase(plan: Goalplan, wp: GoalplanWorkPhase): boolean { - return ( - (wp.status === "pending" || wp.status === "in_progress") - && workPhaseDependenciesMet(plan, wp) - ); + return (wp.status === "pending" || wp.status === "in_progress") + && workPhaseReadyConditionsMet(plan, wp); } export function readyWorkPhases(plan: Goalplan): GoalplanWorkPhase[] { @@ -1060,6 +1122,8 @@ export function dependencyWaitReasons(plan: Goalplan): string[] { unmetPhaseDependencies.map((id) => describePhaseDependency(plan, id)), )); } + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); if (wp.status !== "pending" && wp.status !== "in_progress") continue; for (const task of wp.tasks.filter((candidate) => candidate.status === "pending")) { const unmetTaskDependencies = unmetTaskDependencyIds(wp, task); @@ -1097,6 +1161,8 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { reasons.push( `work-phase ${wp.id} is blocked${wp.blockedReason ? ` (${wp.blockedReason})` : ""}`, ); + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); continue; } const unmetPhaseDependencies = unmetPhaseDependencyIds(plan, wp); @@ -1105,8 +1171,10 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { `work-phase ${wp.id}`, unmetPhaseDependencies.map((id) => describePhaseDependency(plan, id)), )); - continue; } + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); + if (unmetPhaseDependencies.length > 0) continue; for (const task of wp.tasks.filter((candidate) => candidate.status === "pending")) { const unmetTaskDependencies = unmetTaskDependencyIds(wp, task); if (unmetTaskDependencies.length > 0) { @@ -1120,6 +1188,31 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { return reasons.length > 0 ? { reasons } : null; } +/** IDLE can yield only when actual open user decisions account for all remaining work. */ +export function remainingWorkAwaitsDecisions(plan: Goalplan): boolean { + const remaining = remainingWorkPhases(plan); + if (remaining.length === 0) return false; + const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); + const waiting = (phase: GoalplanWorkPhase, visiting: Set): boolean => { + if (phase.status !== "pending" && phase.status !== "in_progress") return false; + if ((phase.awaitsDecision ?? []).some((id) => { + const matches = (plan.decisions ?? []).filter((decision) => decision.id === id); + return matches.length === 1 && matches[0].status === "open"; + })) return true; + if (visiting.has(phase.id)) return false; + visiting.add(phase.id); + const result = (phase.dependsOn ?? []).some((id) => { + const dependency = byId.get(id); + return dependency !== undefined && dependency.status !== "done" && waiting(dependency, visiting); + }); + visiting.delete(phase.id); + return result; + }; + const waitingPhases = remaining.filter((phase) => waiting(phase, new Set())); + if (waitingPhases.length !== remaining.length) return false; + return unmetCriteria(plan).every((criterion) => waitingPhases.some((phase) => phase.criteriaIds.includes(criterion.id))); +} + const LIFECYCLE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; export type GoalplanLifecycleResult = @@ -1127,6 +1220,55 @@ export type GoalplanLifecycleResult = | { kind: "unchanged"; plan: Goalplan; reason: string } | { kind: "rejected"; reason: string }; +export function askGoalplanDecision( + plan: Goalplan, + input: { id: string; question: string; recommendation?: string; workPhaseIds: string[]; askedAt: string }, +): GoalplanLifecycleResult { + const id = input.id.trim(); + const question = input.question.trim(); + const recommendation = input.recommendation?.trim(); + const workPhaseIds = input.workPhaseIds.map((phaseId) => phaseId.trim()); + if (!LIFECYCLE_ID_RE.test(id)) return { kind: "rejected", reason: "decision id must be a short lowercase id, e.g. dec-1" }; + if (!question) return { kind: "rejected", reason: "decision question must not be empty" }; + if (input.recommendation !== undefined && !recommendation) return { kind: "rejected", reason: "decision recommendation must not be empty" }; + if (!validIsoTime(input.askedAt)) return { kind: "rejected", reason: "decision askedAt must be an ISO timestamp" }; + if (plan.decisions?.some((decision) => decision.id === id)) return { kind: "rejected", reason: `decision '${id}' is already in this plan` }; + const duplicate = plan.decisions?.find((decision) => decision.status === "open" && decision.question.trim() === question); + if (duplicate) return { kind: "rejected", reason: `question is already open as decision '${duplicate.id}'` }; + if (workPhaseIds.some((phaseId) => !phaseId) || new Set(workPhaseIds).size !== workPhaseIds.length) { + return { kind: "rejected", reason: "--work-phase requires distinct non-empty ids" }; + } + for (const phaseId of workPhaseIds) { + const phase = plan.workPhases.find((wp) => wp.id === phaseId); + if (!phase) return { kind: "rejected", reason: `work phase '${phaseId}' is not in this plan` }; + if (phase.status === "done" || phase.status === "superseded") { + return { kind: "rejected", reason: `work phase '${phaseId}' is ${phase.status} and cannot await a decision` }; + } + } + const decision: GoalplanDecision = { id, question, status: "open", askedAt: input.askedAt, + ...(recommendation === undefined ? {} : { recommendation }) }; + const next: Goalplan = { ...plan, decisions: [...(plan.decisions ?? []), decision], + workPhases: plan.workPhases.map((wp) => workPhaseIds.includes(wp.id) + ? { ...wp, awaitsDecision: [...(wp.awaitsDecision ?? []), id] } : wp) }; + const reasons = goalplanDefinitionIntegrityReasons(next); + return reasons.length ? { kind: "rejected", reason: reasons.join("; ") } : { kind: "changed", plan: next }; +} + +export function decideGoalplanDecision( + plan: Goalplan, id: string, answer: string, decidedAt: string, +): GoalplanLifecycleResult { + const decision = plan.decisions?.find((candidate) => candidate.id === id.trim()); + if (!decision) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + if (!answer.trim()) return { kind: "rejected", reason: "decision answer must not be empty" }; + if (!validIsoTime(decidedAt)) return { kind: "rejected", reason: "decision decidedAt must be an ISO timestamp" }; + if (decision.status === "decided") return decision.answer === answer.trim() + ? { kind: "unchanged", plan, reason: `decision '${id.trim()}' is already decided` } + : { kind: "rejected", reason: `decision '${id.trim()}' already has a different answer` }; + const next: Goalplan = { ...plan, decisions: plan.decisions!.map((candidate) => candidate.id === decision.id + ? { ...candidate, status: "decided" as const, answer: answer.trim(), decidedAt } : candidate) }; + return { kind: "changed", plan: next }; +} + export function addGoalplanTask( plan: Goalplan, workPhaseId: string, @@ -1317,6 +1459,22 @@ export function goalplanDefinitionIntegrityReasons(plan: Goalplan): string[] { for (const id of duplicateIds(plan.workPhases.map((phase) => phase.id))) { reasons.push(`duplicate work phase id '${id}' makes dependency references ambiguous`); } + const decisionsById = new Map((plan.decisions ?? []).map((decision) => [decision.id, decision])); + for (const id of duplicateIds((plan.decisions ?? []).map((decision) => decision.id))) { + reasons.push(`duplicate decision id '${id}' makes awaitsDecision references ambiguous`); + } + for (const phase of plan.workPhases) { + for (const id of duplicateIds(phase.awaitsDecision ?? [])) { + reasons.push(`work phase ${phase.id} awaits decision '${id}' more than once`); + } + for (const id of new Set(phase.awaitsDecision ?? [])) { + const decision = decisionsById.get(id); + if (!decision) reasons.push(`work phase ${phase.id} awaits unknown decision '${id}'`); + else if (phase.status === "done" && decision.status === "open") { + reasons.push(`work phase ${phase.id} is done while decision ${id} is open`); + } + } + } for (const phase of plan.workPhases) { // 감사 라운드 1 BLOCKER 1: 같은 참조를 여러 번 쓴 dependsOn이 같은 사유를 반복하면 // goal-gate의 slice(0, 4)가 한 문장으로 네 칸을 채워 다른 진단을 가린다. wp2 reviver는 @@ -1795,8 +1953,11 @@ export function closeFixedWorkPhase( // status alone let a target through whose dependency turned blocked after the // marker was written — advanceWorkPhase() answers no_active there, and recovery // must not answer ok. - if (!workPhaseDependenciesMet(plan, current)) { - return { kind: "dependencies_unmet", unmet: unmetPhaseDependencyIds(plan, current) }; + if (!workPhaseReadyConditionsMet(plan, current)) { + return { kind: "dependencies_unmet", unmet: [ + ...unmetPhaseDependencyIds(plan, current), + ...openDecisionIdsForPhase(plan, current).map((id) => `decision:${id}`), + ] }; } // CYCLE-COMPLETION-01, unchanged wording and unchanged variant: an open task keeps @@ -1823,10 +1984,10 @@ export function closeFixedWorkPhase( let next: { id: string } | undefined; if (recordedNext === undefined) { const after = closedWorkPhases.slice(currentIdx + 1).find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(closedPlan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(closedPlan, wp), ); next = after ?? closedWorkPhases.slice(0, currentIdx).find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(closedPlan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(closedPlan, wp), ); } else if (recordedNext === null) { next = undefined; @@ -1871,11 +2032,11 @@ export function closeFixedWorkPhase( // and it can only do that against a normalized cursor. next = closedWorkPhases.find( (wp) => wp.id === plan.activeWorkPhaseId && wp.id !== workPhaseId - && wp.status === "in_progress" && workPhaseDependenciesMet(closedPlan, wp), + && wp.status === "in_progress" && workPhaseReadyConditionsMet(closedPlan, wp), ); } else if (named.status !== "pending" && named.status !== "in_progress") { return { kind: "successor_lost", successorId: recordedNext, reason: "not_runnable" }; - } else if (!workPhaseDependenciesMet(closedPlan, named)) { + } else if (!workPhaseReadyConditionsMet(closedPlan, named)) { return { kind: "successor_lost", successorId: recordedNext, reason: "dependencies_unmet" }; } else { next = named; @@ -1925,7 +2086,7 @@ export function absentSuccessorDetail( ? "is gone too" : reason === "not_runnable" ? "can no longer be started" - : "now waits for another work-phase"; + : "now waits for a prerequisite or decision"; } export type ResumeAbsentTargetResult = @@ -1954,7 +2115,7 @@ export function resumeAbsentTarget( // successor waiting on the same unmet dependency was refused with the target present and // activated with it gone. A dangling dependsOn reads as not-done here by design, and the // pending branch already refused that plan. - if (!workPhaseDependenciesMet(plan, named)) { + if (!workPhaseReadyConditionsMet(plan, named)) { return { kind: "successor_lost", successorId: recordedNext, reason: "dependencies_unmet" }; } // Running: the activation happened too, but only if the cursor agrees. §45 established @@ -2044,11 +2205,11 @@ export function effectiveActiveWorkPhaseId(plan: Goalplan): string | null { if (cur && isRunnablePhase(plan, cur)) return cur.id; } const inProgress = plan.workPhases.find( - (wp) => wp.status === "in_progress" && workPhaseDependenciesMet(plan, wp), + (wp) => wp.status === "in_progress" && isRunnablePhase(plan, wp), ); if (inProgress) return inProgress.id; const pending = plan.workPhases.find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(plan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(plan, wp), ); return pending?.id ?? null; } diff --git a/plugins/codexclaw/components/pabcd-state/src/hook.ts b/plugins/codexclaw/components/pabcd-state/src/hook.ts index 85dcba0c..77b5cd2c 100644 --- a/plugins/codexclaw/components/pabcd-state/src/hook.ts +++ b/plugins/codexclaw/components/pabcd-state/src/hook.ts @@ -74,6 +74,7 @@ import { readGoalplan, readyTasks, readyWorkPhases, + remainingWorkAwaitsDecisions, unmetCriteria, withGoalplanWriteLock, writeGoalplan, @@ -1822,7 +1823,9 @@ export function handleStop( // plan gets a bounded arming block — "IDLE is not the end while work remains". if (!inFlight) { if (!goalActive) return ""; - if (!state.slug || !safeReadBoundGoalplan(payload.cwd, state.slug)) return ""; + const plan = state.slug ? safeReadBoundGoalplan(payload.cwd, state.slug) : null; + if (!plan) return ""; + if (remainingWorkAwaitsDecisions(plan)) return ""; // bail: don't pile on during context-pressure/compaction recovery. if (isContextPressureTail(readTranscriptTail(payload.transcript_path))) return ""; const count = bumpStopCounter(payload.cwd, state); diff --git a/plugins/codexclaw/components/pabcd-state/test/goalplan-public-surface.test.ts b/plugins/codexclaw/components/pabcd-state/test/goalplan-public-surface.test.ts index 26b20f11..f7741eb9 100644 --- a/plugins/codexclaw/components/pabcd-state/test/goalplan-public-surface.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/goalplan-public-surface.test.ts @@ -492,7 +492,7 @@ test("help lists repeated dependency syntax and required outcome", () => { // unknown-verb 거부 문구가 새 동사 넷을 포함하고 기존 여섯을 순서대로 남긴다. // 다음 verb 추가가 이 문구를 다시 빠뜨리면 여기서 RED가 난다. assert.deepEqual(parseGoalplanCliArgs(["redy"], "/tmp"), { - error: "unknown loop verb 'redy' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion); run cxc loop --help", + error: "unknown loop verb 'redy' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion|ask|decide); run cxc loop --help", }); }); @@ -680,3 +680,101 @@ test("the built CLI rejects a misplaced or misspelled flag before any write", () } } }); + +test("ask records an open decision and hides only linked work phases", () => { + const plan = fixture(); + plan.workPhases.push({ id: "wp-free", title: "free", status: "pending", tasks: [{ id: "free-task", title: "free", status: "pending" }], criteriaIds: [] }); + const { cwd, session } = workspace(plan); + const asked = cli(cwd, ["ask", "--session", session, "--id", "dec-1", "--question", "Choose API", "--recommendation", "Use v2", "--work-phase", "wp-live"]); + assert.equal(asked.code, 0, asked.output); + const back = readGoalplan(cwd, plan.slug)!; + assert.equal(back.decisions?.[0]?.status, "open"); + assert.match(back.decisions?.[0]?.askedAt ?? "", /^\d{4}-\d\d-\d\dT/); + assert.deepEqual(back.workPhases.find((wp) => wp.id === "wp-live")?.awaitsDecision, ["dec-1"]); + assert.deepEqual(readyWorkPhases(back).map((wp) => wp.id), ["wp-free"]); + assert.deepEqual(readyTasks(back).map(({ workPhaseId }) => workPhaseId), ["wp-free"]); + const ready = cli(cwd, ["ready", "--session", session, "--json"]); + assert.equal(ready.code, 0, ready.output); + const data = JSON.parse(ready.output); + assert.equal(data.openDecisions[0].id, "dec-1"); + assert.deepEqual(data.awaitingDecisions, [{ workPhaseId: "wp-live", decisionIds: ["dec-1"] }]); + assert.match(cli(cwd, ["show", "--session", session]).output, /Choose API[\s\S]*waiting: wp-live/); +}); + +test("decide releases linked phases without unblocking explicit blocks", () => { + const plan = fixture(); + plan.workPhases.push({ id: "wp-explicit", title: "explicit", status: "blocked", blockedReason: "vendor", tasks: [], criteriaIds: [] }); + const { cwd, session } = workspace(plan); + assert.equal(cli(cwd, ["ask", "--session", session, "--id", "dec-1", "--question", "Choose API", "--work-phase", "wp-live", "--work-phase", "wp-explicit"]).code, 0); + assert.equal(cli(cwd, ["decide", "--session", session, "--id", "dec-1", "--answer", "Use v2"]).code, 0); + const back = readGoalplan(cwd, plan.slug)!; + assert.equal(back.decisions?.[0]?.answer, "Use v2"); + assert.equal(back.decisions?.[0]?.status, "decided"); + assert.match(back.decisions?.[0]?.decidedAt ?? "", /^\d{4}-/); + assert.deepEqual(readyWorkPhases(back).map((wp) => wp.id), ["wp-live"]); + assert.equal(back.workPhases.find((wp) => wp.id === "wp-explicit")?.blockedReason, "vendor"); + assert.deepEqual(JSON.parse(cli(cwd, ["ready", "--session", session, "--json"]).output).awaitingDecisions, []); +}); + +test("ask rejects duplicate open question and unknown phase without a write", () => { + const plan = fixture(); + const { cwd, session } = workspace(plan); + assert.equal(cli(cwd, ["ask", "--session", session, "--id", "dec-1", "--question", "Choose API", "--work-phase", "wp-live"]).code, 0); + const before = planText(cwd, plan.slug), ledger = ledgerText(cwd, plan.slug); + const duplicate = cli(cwd, ["ask", "--session", session, "--id", "dec-2", "--question", " Choose API "]); + assert.equal(duplicate.code, 1); + assert.match(duplicate.output, /dec-1/); + const unknown = cli(cwd, ["ask", "--session", session, "--id", "dec-2", "--question", "Other", "--work-phase", "ghost"]); + assert.equal(unknown.code, 1); + assert.match(unknown.output, /ghost/); + assert.equal(planText(cwd, plan.slug), before); + assert.equal(ledgerText(cwd, plan.slug), ledger); +}); + +test("ask and decide enforce per-verb flags before writing", () => { + const plan = fixture(); + const { cwd, session } = workspace(plan); + const before = planText(cwd, plan.slug), ledger = ledgerText(cwd, plan.slug); + for (const argv of [ + ["ask", "--session", session, "--id", "dec-1", "--answer", "x"], + ["decide", "--session", session, "--id", "dec-1", "--question", "x"], + ["ask", "--session", session, "--id", "dec-1", "--question", "x", "--question", "y"], + ["ask", "--session", session, "--id", "dec-1", "--question", "x", "--work-phase", "wp-live", "--work-phase", "wp-live"], + ["ask", "--session", session, "--id", "dec-1", "--question="], + ["decide", "--session", session, "--id", "dec-1", "--answer"], + ]) assert.equal("error" in parseGoalplanCliArgs(argv, cwd), true, argv.join(" ")); + assert.equal(planText(cwd, plan.slug), before); + assert.equal(ledgerText(cwd, plan.slug), ledger); + assert.match(renderGoalplanHelp(), /ask --session --id --question /); + assert.match(renderGoalplanHelp(), /decide --session --id --answer /); +}); + +test("decide is idempotent only for the same answer", () => { + const plan = fixture(); + const { cwd, session } = workspace(plan); + cli(cwd, ["ask", "--session", session, "--id", "dec-1", "--question", "Choose API"]); + assert.equal(cli(cwd, ["decide", "--session", session, "--id", "dec-1", "--answer", "Use v2"]).code, 0); + const before = planText(cwd, plan.slug); + assert.equal(cli(cwd, ["decide", "--session", session, "--id", "dec-1", "--answer", "Use v2"]).code, 0); + assert.equal(planText(cwd, plan.slug), before); + assert.equal(cli(cwd, ["decide", "--session", session, "--id", "dec-1", "--answer", "Use v3"]).code, 1); + assert.equal(planText(cwd, plan.slug), before); +}); + +test("ready rejects dangling and duplicate decision references", () => { + const plan = fixture(); + const { cwd, session } = workspace(plan); + plan.workPhases.find((wp) => wp.id === "wp-live")!.awaitsDecision = ["ghost"]; + writeGoalplan(cwd, plan); + const dangling = cli(cwd, ["ready", "--session", session]); + assert.equal(dangling.code, 1); + assert.match(dangling.output, /awaits unknown decision 'ghost'/); + plan.decisions = [{ id: "ghost", question: "Choose", status: "open", askedAt: "2026-09-28T00:00:00.000Z" }]; + plan.workPhases.find((wp) => wp.id === "wp-live")!.awaitsDecision = ["ghost", "ghost"]; + writeGoalplan(cwd, plan); + assert.match(cli(cwd, ["ready", "--session", session]).output, /more than once/); + plan.workPhases.find((wp) => wp.id === "wp-live")!.awaitsDecision = ["ghost"]; + plan.decisions.push({ ...plan.decisions[0], question: "Again" }); + writeGoalplan(cwd, plan); + assert.match(cli(cwd, ["ready", "--session", session]).output, /duplicate decision id/); +}); diff --git a/plugins/codexclaw/components/pabcd-state/test/hook-continuation.test.ts b/plugins/codexclaw/components/pabcd-state/test/hook-continuation.test.ts index 45293de2..16664324 100644 --- a/plugins/codexclaw/components/pabcd-state/test/hook-continuation.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/hook-continuation.test.ts @@ -1451,3 +1451,85 @@ test("wp6: Stop reason keeps a single blocked phase when no work is ready", () = rmSync(cwd, { recursive: true, force: true }); } }); + +function decisionStopPlan(cwd: string, sessionId: string, phases: ReturnType["workPhases"], criteria: ReturnType["criteria"] = []): ReturnType { + const plan = buildGoalplan({ objective: `decision stop ${sessionId}` }); + plan.workPhases = phases; + plan.criteria = criteria; + plan.decisions = [{ id: "dec-1", question: "Choose API", status: "open", askedAt: "2026-09-28T00:00:00.000Z" }]; + writeGoalplan(cwd, plan); + writeState(cwd, { ...defaultState(sessionId), slug: plan.slug }); + return plan; +} + +const waitingPhase = (id: string, criteriaIds: string[] = []) => ({ id, title: id, status: "pending" as const, tasks: [], criteriaIds, awaitsDecision: ["dec-1"] }); + +test("IDLE Stop releases when every remaining phase awaits an open decision", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-idle", status: "active" }], () => { + decisionStopPlan(cwd, "dec-idle", [waitingPhase("linked")]); + assert.equal(handleStop(stop(cwd, "dec-idle")), ""); + assert.equal(readState(cwd, "dec-idle").stopBlockTotal, 0); + }); +}); + +test("IDLE Stop still blocks when an independent phase is runnable", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-free", status: "active" }], () => { + decisionStopPlan(cwd, "dec-free", [waitingPhase("linked"), { id: "free", title: "free", status: "pending", tasks: [], criteriaIds: [] }]); + assert.equal(JSON.parse(handleStop(stop(cwd, "dec-free")).trim()).decision, "block"); + }); +}); + +test("IDLE Stop blocks again after decide releases the wait", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-answered", status: "active" }], () => { + const plan = decisionStopPlan(cwd, "dec-answered", [waitingPhase("linked")]); + assert.equal(handleStop(stop(cwd, "dec-answered")), ""); + plan.decisions = [{ ...plan.decisions![0], status: "decided", answer: "Use v2", decidedAt: "2026-09-28T01:00:00.000Z" }]; + writeGoalplan(cwd, plan); + assert.match(JSON.parse(handleStop(stop(cwd, "dec-answered")).trim()).reason, /cxc orchestrate P/); + }); +}); + +test("IDLE Stop still blocks when one phase waits on a decision and another is blocked for another reason", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-blocked", status: "active" }], () => { + decisionStopPlan(cwd, "dec-blocked", [waitingPhase("linked"), { id: "blocked", title: "blocked", status: "blocked", blockedReason: "vendor", tasks: [], criteriaIds: [] }]); + assert.equal(JSON.parse(handleStop(stop(cwd, "dec-blocked")).trim()).decision, "block"); + }); +}); + +test("IDLE Stop releases when an in-progress phase gained an open decision mid-cycle and its dependents wait on it", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-chain", status: "active" }], () => { + decisionStopPlan(cwd, "dec-chain", [{ ...waitingPhase("root"), status: "in_progress" }, { id: "child", title: "child", status: "pending", tasks: [], criteriaIds: [], dependsOn: ["root"] }]); + assert.equal(handleStop(stop(cwd, "dec-chain")), ""); + }); +}); + +test("IDLE Stop still blocks when an independent criterion is unmet", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-criterion", status: "active" }], () => { + decisionStopPlan(cwd, "dec-criterion", [waitingPhase("linked")], [{ id: "c-1", scenario: "independent", expectedEvidence: "proof", capturedEvidence: null, status: "open" }]); + assert.equal(JSON.parse(handleStop(stop(cwd, "dec-criterion")).trim()).decision, "block"); + }); +}); + +test("IDLE Stop releases when every unmet criterion belongs to a decision-waiting phase", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-criterion-linked", status: "active" }], () => { + decisionStopPlan(cwd, "dec-criterion-linked", [waitingPhase("linked", ["c-1"])], [{ id: "c-1", scenario: "linked", expectedEvidence: "proof", capturedEvidence: null, status: "open" }]); + assert.equal(handleStop(stop(cwd, "dec-criterion-linked")), ""); + }); +}); + +test("dangling decision reference does not release IDLE Stop", () => { + const cwd = freshCwd(); + withGoalsDb([{ thread_id: "dec-dangling", status: "active" }], () => { + const plan = decisionStopPlan(cwd, "dec-dangling", [waitingPhase("linked")]); + plan.decisions = []; + writeGoalplan(cwd, plan); + assert.equal(JSON.parse(handleStop(stop(cwd, "dec-dangling")).trim()).decision, "block"); + }); +}); diff --git a/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts b/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts index adcb0a74..955a3480 100644 --- a/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts @@ -1866,7 +1866,7 @@ test("an absent target refuses a running successor whose dependency is unmet", ( const result = runOrchestrateCli(parsedDclose(cwd, id)); assert.equal(result.code, 1, result.output); - assert.match(result.output, /now waits for another work-phase/); + assert.match(result.output, /now waits for a prerequisite or decision/); // Fail closed: no plan write, no ledger row, and the marker stays for a real repair. assert.equal(readFileSync(join(cwd, ".codexclaw/goalplans", slug, "goalplan.json"), "utf8"), before); assert.equal(readState(cwd, id).dcloseRecovery?.nextWorkPhaseId, "wp-2"); diff --git a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts index 1631a73c..babbe36a 100644 --- a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts @@ -14,6 +14,11 @@ import { join } from "node:path"; import { advanceWorkPhase, buildGoalplan, + closeFixedWorkPhase, + resumeAbsentTarget, + goalplanDefinitionIntegrityReasons, + readGoalplanDetailed, + remainingWorkAwaitsDecisions, dependencyDeadlock, dependencyWaitReasons, effectiveActiveWorkPhaseId, @@ -21,6 +26,7 @@ import { nextOpenTask, readGoalplan, remainingWorkPhases, + readyWorkPhases, validateGoalplan, writeGoalplan, type Goalplan, @@ -324,3 +330,118 @@ test("wp4: dependency wait reasons include phase and task waits", () => { "task build/t-dependent waits for task build/t-upstream (pending)", ]); }); + +test("open decision excludes linked phase from cursor close and successor selection", () => { + const p = plan([ + phase("linked", "in_progress", { awaitsDecision: ["dec-1"] }), + phase("free", "pending"), + ], { activeWorkPhaseId: "linked", decisions: [{ id: "dec-1", question: "Choose", status: "open", askedAt: "2026-09-28T00:00:00.000Z" }] }); + assert.equal(effectiveActiveWorkPhaseId(p), "free"); + const advanced = advanceWorkPhase(p); + assert.equal(advanced.kind, "ok"); + if (advanced.kind === "ok") { + assert.equal(advanced.plan.workPhases[0].status, "in_progress"); + assert.equal(advanced.plan.workPhases[1].status, "done"); + assert.match(dependencyDeadlock(advanced.plan)?.reasons.join(" ") ?? "", /linked awaits decision dec-1/); + } +}); + +test("decision wait reasons appear beside ready independent work", () => { + const p = plan([phase("free", "pending"), phase("linked", "pending", { awaitsDecision: ["dec-1"] })], + { decisions: [{ id: "dec-1", question: "Choose", status: "open", askedAt: "2026-09-28T00:00:00.000Z" }] }); + assert.equal(dependencyDeadlock(p), null); + assert.match(dependencyWaitReasons(p).join(" "), /work-phase linked awaits decision dec-1/); +}); + +test("legacy plan round trips without decision fields", () => { + const p = plan([phase("free", "pending")]); + const back = roundTrip(p)!; + assert.equal("decisions" in back, false); + assert.equal("awaitsDecision" in back.workPhases[0], false); + assert.deepEqual(readyWorkPhases(back).map((wp) => wp.id), ["free"]); +}); + +test("E8 fails a done phase waiting on an open decision but permits an unrelated open decision", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const linked = plan([phase("done", "done", { awaitsDecision: ["dec-1"] })], { decisions: [d] }); + assert.match(validateGoalplan(linked).reasons.join(" "), /done while decision dec-1 is open/); + const unlinked = plan([phase("done", "done")], { decisions: [d] }); + assert.equal(validateGoalplan(unlinked).ok, true); + const pending = plan([phase("linked", "pending", { awaitsDecision: ["dec-1"] })], { decisions: [d] }); + assert.match(validateGoalplan(pending).reasons.join(" "), /work phase\(s\) not done/); +}); + +test("missing decision id keeps the phase waiting", () => { + const p = plan([phase("linked", "pending", { awaitsDecision: ["ghost"] })], { activeWorkPhaseId: "linked" }); + assert.equal(effectiveActiveWorkPhaseId(p), null); +}); + +test("duplicate decision id keeps the phase waiting", () => { + const decided = { id: "dec-1", question: "Choose", status: "decided" as const, answer: "yes", askedAt: "2026-09-28T00:00:00.000Z", decidedAt: "2026-09-28T01:00:00.000Z" }; + const open = { id: "dec-1", question: "Again", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + for (const decisions of [[decided, open], [open, decided]]) { + const p = plan([phase("linked", "pending", { awaitsDecision: ["dec-1"] })], { activeWorkPhaseId: "linked", decisions }); + assert.equal(effectiveActiveWorkPhaseId(p), null); + } +}); + +test("invalid decision fields and dangling references fail closed", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-dec-invalid-")); + const p = plan([phase("linked", "pending", { awaitsDecision: ["dec-1"] })], + { decisions: [{ id: "dec-1", question: "Choose", status: "open", askedAt: "2026-09-28T00:00:00.000Z" }] }); + writeGoalplan(cwd, p); + const file = join(goalplanDir(cwd, p.slug), "goalplan.json"); + const original = JSON.parse(readFileSync(file, "utf8")) as Goalplan; + for (const [change, field] of [ + [(raw: Goalplan) => { raw.decisions = [{ ...raw.decisions![0], askedAt: "bad" }]; }, "decisions"], + [(raw: Goalplan) => { raw.workPhases[0].awaitsDecision = [""]; }, "workPhases[].awaitsDecision"], + ] as const) { + const raw = structuredClone(original); + change(raw); + writeFileSync(file, JSON.stringify(raw)); + assert.equal(readGoalplan(cwd, p.slug), null); + assert.equal((readGoalplanDetailed(cwd, p.slug).diagnostic as { field: string }).field, field); + } + writeFileSync(file, JSON.stringify(original)); + const dangling = { ...p, decisions: [] }; + assert.match(goalplanDefinitionIntegrityReasons(dangling).join(" "), /awaits unknown decision 'dec-1'/); + assert.deepEqual(readyWorkPhases(dangling), []); + assert.match(goalplanDefinitionIntegrityReasons({ ...p, decisions: [p.decisions![0], p.decisions![0]] }).join(" "), /duplicate decision id/); + assert.match(goalplanDefinitionIntegrityReasons({ ...p, workPhases: [phase("linked", "pending", { awaitsDecision: ["dec-1", "dec-1"] })] }).join(" "), /more than once/); +}); + +test("decision-waiting successor cannot activate through close or absent-target recovery", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const p = plan([phase("current", "in_progress"), phase("linked", "pending", { awaitsDecision: ["dec-1"] })], + { activeWorkPhaseId: "current", decisions: [d] }); + const closed = closeFixedWorkPhase(p, "current"); + assert.equal(closed.kind, "ok"); + if (closed.kind === "ok") assert.equal(closed.plan.activeWorkPhaseId, null); + assert.equal(closeFixedWorkPhase(p, "current", "linked").kind, "successor_lost"); + assert.equal(resumeAbsentTarget(p, "linked").kind, "successor_lost"); + const currentWaits = { ...p, workPhases: [phase("current", "in_progress", { awaitsDecision: ["dec-1"] }), p.workPhases[1]] }; + assert.equal(closeFixedWorkPhase(currentWaits, "current").kind, "dependencies_unmet"); +}); + +test("duplicate decision id blocks successor and absent-target recovery in either order", () => { + const decided = { id: "dec-1", question: "Choose", status: "decided" as const, answer: "yes", askedAt: "2026-09-28T00:00:00.000Z", decidedAt: "2026-09-28T01:00:00.000Z" }; + const open = { id: "dec-1", question: "Again", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + for (const decisions of [[decided, open], [open, decided]]) { + const p = plan([phase("current", "in_progress"), phase("linked", "pending", { awaitsDecision: ["dec-1"] })], { decisions }); + assert.equal(closeFixedWorkPhase(p, "current").kind, "ok"); + assert.equal(closeFixedWorkPhase(p, "current", "linked").kind, "successor_lost"); + assert.equal(resumeAbsentTarget(p, "linked").kind, "successor_lost"); + } +}); + +test("remaining work awaits decisions only for actual open answers and covered criteria", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const root = phase("root", "in_progress", { awaitsDecision: ["dec-1"], criteriaIds: ["c-1"] }); + const child = phase("child", "pending", { dependsOn: ["root"] }); + const criterion = { id: "c-1", scenario: "chosen", expectedEvidence: "proof", capturedEvidence: null, status: "open" as const }; + const p = plan([root, child], { decisions: [d], criteria: [criterion] }); + assert.equal(remainingWorkAwaitsDecisions(p), true); + assert.equal(remainingWorkAwaitsDecisions({ ...p, decisions: [] }), false); + assert.equal(remainingWorkAwaitsDecisions({ ...p, criteria: [...p.criteria, { ...criterion, id: "c-2" }] }), false); + assert.equal(remainingWorkAwaitsDecisions({ ...p, workPhases: [...p.workPhases, phase("free", "pending")] }), false); +}); From 1673ce56b8b62fb8bd5708a5cba9bbcda838a2a5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:15:03 +0900 Subject: [PATCH 02/11] Document goalplan question and answer workflow --- plugins/codexclaw/skills/dev/references/async-questions.md | 2 ++ .../codexclaw/skills/loop/references/durable-goalplan.md | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/codexclaw/skills/dev/references/async-questions.md b/plugins/codexclaw/skills/dev/references/async-questions.md index 0e9dfb1a..0d38e95b 100644 --- a/plugins/codexclaw/skills/dev/references/async-questions.md +++ b/plugins/codexclaw/skills/dev/references/async-questions.md @@ -53,6 +53,8 @@ earlier answer must wait. Do not inherit the blocking tool's three-question limi within the authorized scope. A missing reply never blocks completion of optional work. Required input or approval remains a real dependency: silence and preselection are never consent; only the dependent action stays pending. +For a bound goalplan, after the host confirms a question was submitted, run `cxc loop ask --session --id --question [--recommendation ] [--work-phase ]...` to keep it in the existing plan. Record each dependent work phase. Check `cxc loop show` before sending a similar question after compaction. When the user answers, run `cxc loop decide --session --id --answer ` and resume only newly runnable phases. `ask` does not deliver a question; CLI success is no proof of host submission. Link a phase only while its action truly requires the reply. + 4. A later user message supplies the answer. Match it to the pending decision, update assumptions and affected work, and retain the original objective unless the user changes it. An ambiguous reply leaves the unresolved part pending. diff --git a/plugins/codexclaw/skills/loop/references/durable-goalplan.md b/plugins/codexclaw/skills/loop/references/durable-goalplan.md index f6a490bf..de80b491 100644 --- a/plugins/codexclaw/skills/loop/references/durable-goalplan.md +++ b/plugins/codexclaw/skills/loop/references/durable-goalplan.md @@ -49,7 +49,7 @@ This is the on-disk shape under `.codexclaw/goalplans//goalplan.json` - `objective`, `slug`, `createdAt`, `updatedAt`. - `workPhases[]` — each `{ id, title, status: pending|in_progress|done, dependsOn?, tasks[], criteriaIds[] }`. - `workPhase.dependsOn` names prerequisite work phases. `activeWorkPhaseId` marks the current one. + `workPhase.dependsOn` names prerequisite work phases. Optional `workPhase.awaitsDecision?: string[]` names decisions that must be answered before this phase can run. `activeWorkPhaseId` marks the current one. `workPhases[]` is APPEND-friendly mid-loop: when a new independent unit is discovered (LOOP-UNIT-CHAIN-01), add its work-phase (+ criteria) as a P-phase amendment instead of treating the plan as frozen at init or ending the goal. @@ -57,6 +57,7 @@ This is the on-disk shape under `.codexclaw/goalplans//goalplan.json` Task ids and task dependency references are phase-local: `task.dependsOn` names existing task ids in the same work phase, never a task in another phase. A done task carries a non-empty `outcome`; a pending task has no outcome. +- Optional `decisions[]` — each `{ id, question, recommendation?, status: open|decided, answer?, askedAt, decidedAt? }`. Open decisions have no answer or decidedAt; decided decisions require both. Decision ids are short lowercase ids. Absent and empty arrays remain distinct on disk, as do absent and empty `awaitsDecision` arrays. Old plans acquire neither field on read/write. Only linked pending or in-progress phases wait; an unrelated open decision does not pause the goal. - `criteria[]` — each `{ id, scenario, surface, presented?, expectedEvidence, capturedEvidence, status: open|met }`. `scenario` is the `--criterion` text and `surface` is one of `logic` (default), `web`, `tui` or `desktop`, set by `add-criterion --surface` on a session-bound plan @@ -94,12 +95,16 @@ This is the on-disk shape under `.codexclaw/goalplans//goalplan.json` - `cxc loop ready (--slug | --objective | --session ) [--json] [--cwd ]` - `cxc loop add-task --session --work-phase --id --title [--depends-on ]... [--cwd ]` - `cxc loop complete-task --session --work-phase --id --outcome [--cwd ]` +- `cxc loop ask --session --id --question [--recommendation ] [--work-phase ]... [--cwd ]` — record a question after sending it through the host. It never sends a message. Name each dependent phase. +- `cxc loop decide --session --id --answer [--cwd ]` — record the user reply. This changes only the decision record; phase status and blockedReason stay as they were. - `cxc loop meet-criterion --session --id --evidence [--cwd ]` — `--id` takes a generated `c-N` id; read it from `cxc loop show` or the goalplan file. - `cxc goalplan *` — deprecated alias for the same behavior during migration. The parser rejects unknown flags, stray positionals, missing values, and flags belonging to another verb before dispatch. Every value flag also accepts `--flag=value`, which is the way to pass a value that starts with `--`. +`ready --json` includes `openDecisions` and `awaitingDecisions` when the plan has a decisions field; `show` displays each open question and its waiting phases. At IDLE, Stop releases when every remaining phase and unmet criterion waits on an open user decision; the goal remains active and completion is still gated. + Repeat `--depends-on` once per prerequisite; comma-separated values are one id. Existing dependencies are not edited after creation. `complete-task` and `meet-criterion` require non-empty proof text. From 5b65c18a25d441c196974b39cae5e3acc5f629cc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:15:44 +0900 Subject: [PATCH 03/11] build: regenerate dist for goalplan decisions --- .../pabcd-state/dist/goalplan-cli.js | 92 ++++++++- .../components/pabcd-state/dist/goalplan.js | 191 ++++++++++++++++-- .../components/pabcd-state/dist/hook.js | 5 +- 3 files changed, 268 insertions(+), 20 deletions(-) diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js index 80698288..17aefe6f 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js @@ -13,6 +13,9 @@ */ import { addGoalplanTask, + askGoalplanDecision, + decideGoalplanDecision, + openDecisionIdsForPhase, buildGoalplan, completeGoalplanTask, goalplanDefinitionIntegrityReasons, @@ -106,6 +109,12 @@ import { applySteeringBatch } from "./steering.js"; + + + + + + @@ -124,6 +133,8 @@ const VERBS = new Set ([ "add-task", "complete-task", "meet-criterion", + "ask", + "decide", ]); @@ -137,6 +148,7 @@ const VERBS = new Set ([ + const VERB_RULES = { init: { allowed: new Set(["--objective", "--session", "--criterion", "--schema-version", "--cwd"]), repeatable: new Set(["--criterion"]), usage: "init --objective [--session ] [--criterion ]... [--schema-version ] [--cwd ]" }, show: { allowed: new Set(["--slug", "--objective", "--session", "--cwd"]), repeatable: new Set(), usage: "show (--slug | --objective | --session ) [--cwd ]" }, @@ -148,6 +160,8 @@ const VERB_RULES = { "add-task": { allowed: new Set(["--session", "--work-phase", "--id", "--title", "--depends-on", "--cwd"]), repeatable: new Set(["--depends-on"]), usage: "add-task --session --work-phase --id --title [--depends-on ]... [--cwd ]" }, "complete-task": { allowed: new Set(["--session", "--work-phase", "--id", "--outcome", "--cwd"]), repeatable: new Set(), usage: "complete-task --session --work-phase --id --outcome [--cwd ]" }, "meet-criterion": { allowed: new Set(["--session", "--id", "--evidence", "--cwd"]), repeatable: new Set(), usage: "meet-criterion --session --id --evidence [--cwd ]" }, + ask: { allowed: new Set(["--session", "--id", "--question", "--recommendation", "--work-phase", "--cwd"]), repeatable: new Set(["--work-phase"]), usage: "ask --session --id --question [--recommendation ] [--work-phase ]... [--cwd ]" }, + decide: { allowed: new Set(["--session", "--id", "--answer", "--cwd"]), repeatable: new Set(), usage: "decide --session --id --answer [--cwd ]" }, help: { allowed: new Set(), repeatable: new Set(), usage: "--help" }, }; @@ -163,12 +177,12 @@ export function parseGoalplanCliArgs(argv , cwd ) } if (!VERBS.has(verb)) { return { - error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion); run cxc loop --help`, + error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase|ready|add-task|complete-task|meet-criterion|ask|decide); run cxc loop --help`, }; } const selected = verb ; const rule = VERB_RULES[selected]; - const out = { verb: selected, cwd, criteria: [], dependsOn: [] }; + const out = { verb: selected, cwd, criteria: [], dependsOn: [], workPhaseIds: [] }; const seen = new Set (); const reject = (message ) => ({ error: `${selected}: ${message}` }); for (let i = 1; i < argv.length; i++) { @@ -213,7 +227,17 @@ export function parseGoalplanCliArgs(argv , cwd ) case "--presented": out.presented = value; break; case "--id": out.id = value; break; case "--title": out.title = value; break; - case "--work-phase": out.workPhaseId = value; break; + case "--work-phase": { + if (selected !== "ask") { out.workPhaseId = value; break; } + const phaseId = value.trim(); + if (!phaseId) return reject("--work-phase requires one non-empty id"); + if (out.workPhaseIds .includes(phaseId)) return reject(`--work-phase must not repeat id '${phaseId}'`); + out.workPhaseIds .push(phaseId); + break; + } + case "--question": out.question = value; break; + case "--recommendation": out.recommendation = value; break; + case "--answer": out.answer = value; break; case "--outcome": out.outcome = value; break; case "--schema-version": { const parsed = Number(value); @@ -438,6 +462,13 @@ function runReady(args , plan ) { const phases = readyWorkPhases(plan); const tasks = readyTasks(plan); + const openDecisions = (plan.decisions ?? []).filter((decision) => decision.status === "open") + .map(({ id, question, recommendation, askedAt }) => ({ id, question, ...(recommendation === undefined ? {} : { recommendation }), askedAt })); + const awaitingDecisions = plan.workPhases + .filter((wp) => wp.status === "pending" || wp.status === "in_progress") + .map((wp) => ({ workPhaseId: wp.id, decisionIds: openDecisionIdsForPhase(plan, wp).filter((id) => + (plan.decisions ?? []).some((decision) => decision.id === id && decision.status === "open")) })) + .filter((entry) => entry.decisionIds.length > 0); if (args.json === true) { return { output: JSON.stringify({ @@ -455,6 +486,7 @@ function runReady(args , plan ) { id: entry.task.id, title: entry.task.title, })), + ...(plan.decisions === undefined ? {} : { openDecisions, awaitingDecisions }), }), code: 0, }; @@ -467,9 +499,52 @@ function runReady(args , plan ) { lines.push(tasks.length > 0 ? `readyTasks: ${tasks.map((entry) => `${entry.workPhaseId}/${entry.task.id} (${entry.task.title})`).join("; ")}` : "readyTasks: none"); + if (plan.decisions !== undefined) { + lines.push(openDecisions.length > 0 + ? `openDecisions: ${openDecisions.map((decision) => `${decision.id} (${decision.question})`).join("; ")}` + : "openDecisions: none"); + lines.push(awaitingDecisions.length > 0 + ? `awaitingDecisions: ${awaitingDecisions.map((entry) => `${entry.workPhaseId}: ${entry.decisionIds.join(", ")}`).join("; ")}` + : "awaitingDecisions: none"); + } return { output: lines.join("\n"), code: 0 }; } +/** Record a host-submitted question or its answer under the goalplan write lock. */ +function runDecision(args ) { + const session = (args.session ?? "").trim(); + if (!session) return { output: `loop ${args.verb}: --session is required`, code: 1 }; + if (!isCanonicalSessionId(session)) return { output: `loop ${args.verb}: session id is not canonical`, code: 1 }; + const slug = readState(args.cwd, session).slug; + if (!slug) return { output: `loop ${args.verb}: session '${session}' has no bound goalplan - run \`cxc loop init --session ${session}\` first`, code: 1 }; + const id = (args.id ?? "").trim(); + if (args.verb === "ask" && (!id || !(args.question ?? "").trim())) { + return { output: "loop ask: --id and non-empty --question are required", code: 1 }; + } + if (args.verb === "decide" && (!id || !(args.answer ?? "").trim())) { + return { output: "loop decide: --id and non-empty --answer are required", code: 1 }; + } + + const locked = withGoalplanWriteLock (args.cwd, slug, (plan) => { + const result = args.verb === "ask" + ? askGoalplanDecision(plan, { + id, question: args.question , recommendation: args.recommendation, + workPhaseIds: args.workPhaseIds ?? [], askedAt: new Date().toISOString(), + }) + : decideGoalplanDecision(plan, id, args.answer , new Date().toISOString()); + if (result.kind === "rejected") return { kind: "rejected", reason: result.reason }; + if (result.kind === "unchanged") return { kind: "unchanged", reason: result.reason }; + writeGoalplan(args.cwd, result.plan); + return { kind: "changed" }; + }); + if (locked.kind === "locked" || locked.kind === "unreadable") { + return { output: `loop ${args.verb}: ${locked.reason}`, code: 1 }; + } + if (locked.value.kind === "rejected") return { output: `loop ${args.verb}: ${locked.value.reason}`, code: 1 }; + if (locked.value.kind === "unchanged") return { output: `loop ${args.verb}: ${locked.value.reason}; nothing to do`, code: 0 }; + return { output: `loop ${args.verb}: ${slug} ${id} applied`, code: 0 }; +} + /** * 060 wp6: the three lifecycle verbs share one locked read-modify-write. * @@ -610,6 +685,12 @@ function renderPlanLines(plan , lock ) for (const c of plan.criteria) { lines.push(` - ${c.id} [${c.status}] ${c.scenario}`); } + for (const decision of plan.decisions ?? []) { + if (decision.status !== "open") continue; + lines.push(` - ${decision.id} [open] ${decision.question}`); + const waiting = plan.workPhases.filter((wp) => wp.awaitsDecision?.includes(decision.id)); + lines.push(` waiting: ${waiting.map((wp) => wp.id).join(", ") || "none"}`); + } return lines.join("\n"); } @@ -623,7 +704,7 @@ export function renderGoalplanHelp() { "cxc loop — durable goalplan for a multi-cycle PABCD loop", "", "Usage:", - ...(["init", "show", "validate", "steer", "add-criterion", "add-work-phase", "ready", "add-task", "complete-task", "meet-criterion", "help"] ) + ...(["init", "show", "validate", "steer", "add-criterion", "add-work-phase", "ready", "add-task", "complete-task", "meet-criterion", "ask", "decide", "help"] ) .map((verb) => ` cxc loop ${VERB_RULES[verb].usage}`), "", "Notes:", @@ -639,6 +720,8 @@ export function renderGoalplanHelp() { " additionally require an approved finalGate, and no verb in this build opens a", " final-gate review round, so opt in only if you can record that gate yourself.", " meet-criterion requires non-empty captured evidence for the same reason.", + " Send the question through the host first, then record it with ask; ask never sends a message.", + " Record the user's reply with decide. It changes only the decision record.", "", "steer --batch-json expects an object with:", ' { "idempotencyKey": "", "rationale": "", "evidence": "",', @@ -710,6 +793,7 @@ export function runGoalplanCli(args ) { } if (args.verb === "steer") return runSteer(args); + if (args.verb === "ask" || args.verb === "decide") return runDecision(args); if (args.verb === "add-criterion" || args.verb === "add-work-phase") return runAddOp(args); diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js index 8d211227..a20ac94b 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js @@ -138,6 +138,18 @@ export const DEFAULT_NEW_SCHEMA_VERSION = 1; + + + + + + + + + + + + @@ -272,6 +284,7 @@ export const DEFAULT_NEW_SCHEMA_VERSION = 1; + const MAX_SLUG_BYTES = 128; @@ -504,6 +517,37 @@ function reviveDependsOn(value ) { return ids; } +function validIsoTime(value ) { + if (typeof value !== "string") return false; + const date = new Date(value); + return Number.isFinite(date.valueOf()) && date.toISOString() === value; +} + +function reviveDecisions(value ) { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return "invalid"; + const decisions = []; + for (const item of value) { + if (typeof item !== "object" || item === null || Array.isArray(item)) return "invalid"; + const d = item ; + if (typeof d.id !== "string" || !LIFECYCLE_ID_RE.test(d.id) + || typeof d.question !== "string" || !d.question.trim() + || !validIsoTime(d.askedAt) + || (d.recommendation !== undefined && (typeof d.recommendation !== "string" || !d.recommendation.trim()))) return "invalid"; + if (d.status === "open") { + if (d.answer !== undefined || d.decidedAt !== undefined) return "invalid"; + decisions.push({ id: d.id, question: d.question, status: "open", askedAt: d.askedAt, + ...(d.recommendation === undefined ? {} : { recommendation: d.recommendation }) }); + } else if (d.status === "decided") { + if (typeof d.answer !== "string" || !d.answer.trim() || !validIsoTime(d.decidedAt)) return "invalid"; + decisions.push({ id: d.id, question: d.question, status: "decided", answer: d.answer, + askedAt: d.askedAt, decidedAt: d.decidedAt, + ...(d.recommendation === undefined ? {} : { recommendation: d.recommendation }) }); + } else return "invalid"; + } + return decisions; +} + /** Best-effort structural validation; a malformed object reads as absent (null). */ function reviveGoalplan(parsed , expectedSlug ) { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; @@ -525,6 +569,8 @@ function reviveGoalplan(parsed , expectedSlug ) if (typeof w.id !== "string" || typeof w.title !== "string") return null; const phaseDependsOn = reviveDependsOn(w.dependsOn); if (phaseDependsOn === "invalid") return null; + const awaitsDecision = reviveDependsOn(w.awaitsDecision); + if (awaitsDecision === "invalid") return null; const status = w.status === "in_progress" || w.status === "done" || w.status === "blocked" || w.status === "superseded" ? w.status @@ -548,6 +594,7 @@ function reviveGoalplan(parsed , expectedSlug ) : []; const phase = { id: w.id, title: w.title, status, tasks, criteriaIds }; if (phaseDependsOn !== undefined) phase.dependsOn = phaseDependsOn; + if (awaitsDecision !== undefined) phase.awaitsDecision = awaitsDecision; if (typeof w.blockedReason === "string") phase.blockedReason = w.blockedReason; if (typeof w.supersededBy === "string") phase.supersededBy = w.supersededBy; workPhases.push(phase); @@ -580,6 +627,8 @@ function reviveGoalplan(parsed , expectedSlug ) }; const reviewRounds = reviveReviewRounds(o.reviewRounds); + const decisions = reviveDecisions(o.decisions); + if (decisions === "invalid") return null; const plan = { objective: o.objective, @@ -594,6 +643,7 @@ function reviveGoalplan(parsed , expectedSlug ) // Only attach the 010 fields when they are actually present, so a plan written // before this feature round-trips byte-identical. if (reviewRounds !== undefined) plan.reviewRounds = reviewRounds; + if (decisions !== undefined) plan.decisions = decisions; if (typeof o.activePlanAuditRoundId === "string") plan.activePlanAuditRoundId = o.activePlanAuditRoundId; if (typeof o.activeFinalGateRoundId === "string") plan.activeFinalGateRoundId = o.activeFinalGateRoundId; if (typeof o.schemaVersion === "number" && Number.isFinite(o.schemaVersion)) { @@ -822,6 +872,7 @@ function firstInvalidField(parsed ) { for (const rawWp of o.workPhases) { const wp = rawWp ; if (reviveDependsOn(wp.dependsOn) === "invalid") return "workPhases[].dependsOn"; + if (reviveDependsOn(wp.awaitsDecision) === "invalid") return "workPhases[].awaitsDecision"; for (const rawTask of Array.isArray(wp.tasks) ? wp.tasks : []) { if (typeof rawTask !== "object" || rawTask === null) continue; const task = rawTask ; @@ -839,6 +890,7 @@ function firstInvalidField(parsed ) { if (typeof o.host !== "object" || o.host === null || typeof (o.host ).armed !== "boolean") { return "host (needs armed/armedAt/source)"; } + if (reviveDecisions(o.decisions) === "invalid") return "decisions"; if (o.steeringLog !== undefined && !Array.isArray(o.steeringLog)) return "steeringLog"; return "(unknown)"; } @@ -983,11 +1035,21 @@ function taskDependenciesMet(phase , task ) ); } +export function openDecisionIdsForPhase(plan , wp ) { + // Only a unique, decided target releases a reference. Missing/duplicate ids fail closed. + return [...new Set(wp.awaitsDecision ?? [])].filter((id) => { + const matches = (plan.decisions ?? []).filter((decision) => decision.id === id); + return !(matches.length === 1 && matches[0].status === "decided"); + }); +} + +function workPhaseReadyConditionsMet(plan , wp ) { + return workPhaseDependenciesMet(plan, wp) && openDecisionIdsForPhase(plan, wp).length === 0; +} + function isRunnablePhase(plan , wp ) { - return ( - (wp.status === "pending" || wp.status === "in_progress") - && workPhaseDependenciesMet(plan, wp) - ); + return (wp.status === "pending" || wp.status === "in_progress") + && workPhaseReadyConditionsMet(plan, wp); } export function readyWorkPhases(plan ) { @@ -1060,6 +1122,8 @@ export function dependencyWaitReasons(plan ) { unmetPhaseDependencies.map((id) => describePhaseDependency(plan, id)), )); } + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); if (wp.status !== "pending" && wp.status !== "in_progress") continue; for (const task of wp.tasks.filter((candidate) => candidate.status === "pending")) { const unmetTaskDependencies = unmetTaskDependencyIds(wp, task); @@ -1097,6 +1161,8 @@ export function dependencyDeadlock(plan ) { reasons.push( `work-phase ${wp.id} is blocked${wp.blockedReason ? ` (${wp.blockedReason})` : ""}`, ); + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); continue; } const unmetPhaseDependencies = unmetPhaseDependencyIds(plan, wp); @@ -1105,8 +1171,10 @@ export function dependencyDeadlock(plan ) { `work-phase ${wp.id}`, unmetPhaseDependencies.map((id) => describePhaseDependency(plan, id)), )); - continue; } + const decisionIds = openDecisionIdsForPhase(plan, wp); + if (decisionIds.length > 0) reasons.push(`work-phase ${wp.id} awaits decision ${decisionIds.join(", ")}`); + if (unmetPhaseDependencies.length > 0) continue; for (const task of wp.tasks.filter((candidate) => candidate.status === "pending")) { const unmetTaskDependencies = unmetTaskDependencyIds(wp, task); if (unmetTaskDependencies.length > 0) { @@ -1120,6 +1188,31 @@ export function dependencyDeadlock(plan ) { return reasons.length > 0 ? { reasons } : null; } +/** IDLE can yield only when actual open user decisions account for all remaining work. */ +export function remainingWorkAwaitsDecisions(plan ) { + const remaining = remainingWorkPhases(plan); + if (remaining.length === 0) return false; + const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); + const waiting = (phase , visiting ) => { + if (phase.status !== "pending" && phase.status !== "in_progress") return false; + if ((phase.awaitsDecision ?? []).some((id) => { + const matches = (plan.decisions ?? []).filter((decision) => decision.id === id); + return matches.length === 1 && matches[0].status === "open"; + })) return true; + if (visiting.has(phase.id)) return false; + visiting.add(phase.id); + const result = (phase.dependsOn ?? []).some((id) => { + const dependency = byId.get(id); + return dependency !== undefined && dependency.status !== "done" && waiting(dependency, visiting); + }); + visiting.delete(phase.id); + return result; + }; + const waitingPhases = remaining.filter((phase) => waiting(phase, new Set())); + if (waitingPhases.length !== remaining.length) return false; + return unmetCriteria(plan).every((criterion) => waitingPhases.some((phase) => phase.criteriaIds.includes(criterion.id))); +} + const LIFECYCLE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; @@ -1127,6 +1220,55 @@ const LIFECYCLE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; +export function askGoalplanDecision( + plan , + input , +) { + const id = input.id.trim(); + const question = input.question.trim(); + const recommendation = input.recommendation?.trim(); + const workPhaseIds = input.workPhaseIds.map((phaseId) => phaseId.trim()); + if (!LIFECYCLE_ID_RE.test(id)) return { kind: "rejected", reason: "decision id must be a short lowercase id, e.g. dec-1" }; + if (!question) return { kind: "rejected", reason: "decision question must not be empty" }; + if (input.recommendation !== undefined && !recommendation) return { kind: "rejected", reason: "decision recommendation must not be empty" }; + if (!validIsoTime(input.askedAt)) return { kind: "rejected", reason: "decision askedAt must be an ISO timestamp" }; + if (plan.decisions?.some((decision) => decision.id === id)) return { kind: "rejected", reason: `decision '${id}' is already in this plan` }; + const duplicate = plan.decisions?.find((decision) => decision.status === "open" && decision.question.trim() === question); + if (duplicate) return { kind: "rejected", reason: `question is already open as decision '${duplicate.id}'` }; + if (workPhaseIds.some((phaseId) => !phaseId) || new Set(workPhaseIds).size !== workPhaseIds.length) { + return { kind: "rejected", reason: "--work-phase requires distinct non-empty ids" }; + } + for (const phaseId of workPhaseIds) { + const phase = plan.workPhases.find((wp) => wp.id === phaseId); + if (!phase) return { kind: "rejected", reason: `work phase '${phaseId}' is not in this plan` }; + if (phase.status === "done" || phase.status === "superseded") { + return { kind: "rejected", reason: `work phase '${phaseId}' is ${phase.status} and cannot await a decision` }; + } + } + const decision = { id, question, status: "open", askedAt: input.askedAt, + ...(recommendation === undefined ? {} : { recommendation }) }; + const next = { ...plan, decisions: [...(plan.decisions ?? []), decision], + workPhases: plan.workPhases.map((wp) => workPhaseIds.includes(wp.id) + ? { ...wp, awaitsDecision: [...(wp.awaitsDecision ?? []), id] } : wp) }; + const reasons = goalplanDefinitionIntegrityReasons(next); + return reasons.length ? { kind: "rejected", reason: reasons.join("; ") } : { kind: "changed", plan: next }; +} + +export function decideGoalplanDecision( + plan , id , answer , decidedAt , +) { + const decision = plan.decisions?.find((candidate) => candidate.id === id.trim()); + if (!decision) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + if (!answer.trim()) return { kind: "rejected", reason: "decision answer must not be empty" }; + if (!validIsoTime(decidedAt)) return { kind: "rejected", reason: "decision decidedAt must be an ISO timestamp" }; + if (decision.status === "decided") return decision.answer === answer.trim() + ? { kind: "unchanged", plan, reason: `decision '${id.trim()}' is already decided` } + : { kind: "rejected", reason: `decision '${id.trim()}' already has a different answer` }; + const next = { ...plan, decisions: plan.decisions .map((candidate) => candidate.id === decision.id + ? { ...candidate, status: "decided" , answer: answer.trim(), decidedAt } : candidate) }; + return { kind: "changed", plan: next }; +} + export function addGoalplanTask( plan , workPhaseId , @@ -1317,6 +1459,22 @@ export function goalplanDefinitionIntegrityReasons(plan ) { for (const id of duplicateIds(plan.workPhases.map((phase) => phase.id))) { reasons.push(`duplicate work phase id '${id}' makes dependency references ambiguous`); } + const decisionsById = new Map((plan.decisions ?? []).map((decision) => [decision.id, decision])); + for (const id of duplicateIds((plan.decisions ?? []).map((decision) => decision.id))) { + reasons.push(`duplicate decision id '${id}' makes awaitsDecision references ambiguous`); + } + for (const phase of plan.workPhases) { + for (const id of duplicateIds(phase.awaitsDecision ?? [])) { + reasons.push(`work phase ${phase.id} awaits decision '${id}' more than once`); + } + for (const id of new Set(phase.awaitsDecision ?? [])) { + const decision = decisionsById.get(id); + if (!decision) reasons.push(`work phase ${phase.id} awaits unknown decision '${id}'`); + else if (phase.status === "done" && decision.status === "open") { + reasons.push(`work phase ${phase.id} is done while decision ${id} is open`); + } + } + } for (const phase of plan.workPhases) { // 감사 라운드 1 BLOCKER 1: 같은 참조를 여러 번 쓴 dependsOn이 같은 사유를 반복하면 // goal-gate의 slice(0, 4)가 한 문장으로 네 칸을 채워 다른 진단을 가린다. wp2 reviver는 @@ -1795,8 +1953,11 @@ export function closeFixedWorkPhase( // status alone let a target through whose dependency turned blocked after the // marker was written — advanceWorkPhase() answers no_active there, and recovery // must not answer ok. - if (!workPhaseDependenciesMet(plan, current)) { - return { kind: "dependencies_unmet", unmet: unmetPhaseDependencyIds(plan, current) }; + if (!workPhaseReadyConditionsMet(plan, current)) { + return { kind: "dependencies_unmet", unmet: [ + ...unmetPhaseDependencyIds(plan, current), + ...openDecisionIdsForPhase(plan, current).map((id) => `decision:${id}`), + ] }; } // CYCLE-COMPLETION-01, unchanged wording and unchanged variant: an open task keeps @@ -1823,10 +1984,10 @@ export function closeFixedWorkPhase( let next ; if (recordedNext === undefined) { const after = closedWorkPhases.slice(currentIdx + 1).find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(closedPlan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(closedPlan, wp), ); next = after ?? closedWorkPhases.slice(0, currentIdx).find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(closedPlan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(closedPlan, wp), ); } else if (recordedNext === null) { next = undefined; @@ -1871,11 +2032,11 @@ export function closeFixedWorkPhase( // and it can only do that against a normalized cursor. next = closedWorkPhases.find( (wp) => wp.id === plan.activeWorkPhaseId && wp.id !== workPhaseId - && wp.status === "in_progress" && workPhaseDependenciesMet(closedPlan, wp), + && wp.status === "in_progress" && workPhaseReadyConditionsMet(closedPlan, wp), ); } else if (named.status !== "pending" && named.status !== "in_progress") { return { kind: "successor_lost", successorId: recordedNext, reason: "not_runnable" }; - } else if (!workPhaseDependenciesMet(closedPlan, named)) { + } else if (!workPhaseReadyConditionsMet(closedPlan, named)) { return { kind: "successor_lost", successorId: recordedNext, reason: "dependencies_unmet" }; } else { next = named; @@ -1925,7 +2086,7 @@ export function absentSuccessorDetail( ? "is gone too" : reason === "not_runnable" ? "can no longer be started" - : "now waits for another work-phase"; + : "now waits for a prerequisite or decision"; } @@ -1954,7 +2115,7 @@ export function resumeAbsentTarget( // successor waiting on the same unmet dependency was refused with the target present and // activated with it gone. A dangling dependsOn reads as not-done here by design, and the // pending branch already refused that plan. - if (!workPhaseDependenciesMet(plan, named)) { + if (!workPhaseReadyConditionsMet(plan, named)) { return { kind: "successor_lost", successorId: recordedNext, reason: "dependencies_unmet" }; } // Running: the activation happened too, but only if the cursor agrees. §45 established @@ -2044,11 +2205,11 @@ export function effectiveActiveWorkPhaseId(plan ) { if (cur && isRunnablePhase(plan, cur)) return cur.id; } const inProgress = plan.workPhases.find( - (wp) => wp.status === "in_progress" && workPhaseDependenciesMet(plan, wp), + (wp) => wp.status === "in_progress" && isRunnablePhase(plan, wp), ); if (inProgress) return inProgress.id; const pending = plan.workPhases.find( - (wp) => wp.status === "pending" && workPhaseDependenciesMet(plan, wp), + (wp) => wp.status === "pending" && isRunnablePhase(plan, wp), ); return pending?.id ?? null; } diff --git a/plugins/codexclaw/components/pabcd-state/dist/hook.js b/plugins/codexclaw/components/pabcd-state/dist/hook.js index d47d7258..8ce42ed1 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/hook.js +++ b/plugins/codexclaw/components/pabcd-state/dist/hook.js @@ -74,6 +74,7 @@ import { readGoalplan, readyTasks, readyWorkPhases, + remainingWorkAwaitsDecisions, unmetCriteria, withGoalplanWriteLock, writeGoalplan, @@ -1822,7 +1823,9 @@ export function handleStop( // plan gets a bounded arming block — "IDLE is not the end while work remains". if (!inFlight) { if (!goalActive) return ""; - if (!state.slug || !safeReadBoundGoalplan(payload.cwd, state.slug)) return ""; + const plan = state.slug ? safeReadBoundGoalplan(payload.cwd, state.slug) : null; + if (!plan) return ""; + if (remainingWorkAwaitsDecisions(plan)) return ""; // bail: don't pile on during context-pressure/compaction recovery. if (isContextPressureTail(readTranscriptTail(payload.transcript_path))) return ""; const count = bumpStopCounter(payload.cwd, state); From c715dbd92145c5ddef7b5f68a80c64a66df65220 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:15:44 +0900 Subject: [PATCH 04/11] docs: publish measured test count (3704) --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index 82a189fe..1d09231d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,680 tests + 3,704 tests 29 skills 31 hooks Documentation diff --git a/README.md b/README.md index 5560778a..1857db65 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,680 tests + 3,704 tests 29 skills 31 hooks Documentation diff --git a/README.zh.md b/README.zh.md index cd7ae04d..0bbc6829 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,680 tests + 3,704 tests 29 skills 31 hooks Documentation From 4d6dfb5cbba6f9c17bf092806186244e341a946c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:16:01 +0900 Subject: [PATCH 05/11] docs(changelog): goalplan pending decisions (#262) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 988480d9..0c6d0248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to codexclaw are documented here. The format follows ### Changed +- Goalplans can record pending user decisions: `cxc loop ask --session --id --question [--recommendation ] [--work-phase ]...` links a question the agent already asked to the phases that wait on it, and `cxc loop decide --session --id --answer ` records the answer. Linked phases are not runnable while the decision is open; unrelated phases stay ready. When every remaining phase and unmet criterion waits on an open decision, the Stop hook lets an IDLE turn end instead of asking to start another phase; the goal stays active and cannot be completed early. Old plans load unchanged (#262). - The absolute Stop continuation cap (24) now counts per genuine user turn instead of per session, and the release prints one notice per turn (#254). ### Fixed From f28994d1351ccde5bc507f14724d170f49aa1aad Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:22:08 +0900 Subject: [PATCH 06/11] fix(pabcd-state): decide refuses ambiguous ids; IDLE release requires an intact plan (#262) --- .../components/pabcd-state/dist/goalplan.js | 8 ++++++-- .../components/pabcd-state/src/goalplan.ts | 8 ++++++-- .../test/work-phase-states.test.ts | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js index a20ac94b..1f6d4702 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js @@ -1190,6 +1190,8 @@ export function dependencyDeadlock(plan ) { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan ) { + // A plan with broken references must keep prompting the agent to repair it. + if (goalplanDefinitionIntegrityReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); @@ -1257,8 +1259,10 @@ export function askGoalplanDecision( export function decideGoalplanDecision( plan , id , answer , decidedAt , ) { - const decision = plan.decisions?.find((candidate) => candidate.id === id.trim()); - if (!decision) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + const matches = (plan.decisions ?? []).filter((candidate) => candidate.id === id.trim()); + if (matches.length === 0) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + if (matches.length > 1) return { kind: "rejected", reason: `decision id '${id.trim()}' is ambiguous (${matches.length} entries); repair the plan first` }; + const decision = matches[0]; if (!answer.trim()) return { kind: "rejected", reason: "decision answer must not be empty" }; if (!validIsoTime(decidedAt)) return { kind: "rejected", reason: "decision decidedAt must be an ISO timestamp" }; if (decision.status === "decided") return decision.answer === answer.trim() diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts index 4bc77b96..cc67699f 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts @@ -1190,6 +1190,8 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan: Goalplan): boolean { + // A plan with broken references must keep prompting the agent to repair it. + if (goalplanDefinitionIntegrityReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); @@ -1257,8 +1259,10 @@ export function askGoalplanDecision( export function decideGoalplanDecision( plan: Goalplan, id: string, answer: string, decidedAt: string, ): GoalplanLifecycleResult { - const decision = plan.decisions?.find((candidate) => candidate.id === id.trim()); - if (!decision) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + const matches = (plan.decisions ?? []).filter((candidate) => candidate.id === id.trim()); + if (matches.length === 0) return { kind: "rejected", reason: `decision '${id.trim()}' is not in this plan` }; + if (matches.length > 1) return { kind: "rejected", reason: `decision id '${id.trim()}' is ambiguous (${matches.length} entries); repair the plan first` }; + const decision = matches[0]; if (!answer.trim()) return { kind: "rejected", reason: "decision answer must not be empty" }; if (!validIsoTime(decidedAt)) return { kind: "rejected", reason: "decision decidedAt must be an ISO timestamp" }; if (decision.status === "decided") return decision.answer === answer.trim() diff --git a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts index babbe36a..f4171b36 100644 --- a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts @@ -15,6 +15,7 @@ import { advanceWorkPhase, buildGoalplan, closeFixedWorkPhase, + decideGoalplanDecision, resumeAbsentTarget, goalplanDefinitionIntegrityReasons, readGoalplanDetailed, @@ -445,3 +446,22 @@ test("remaining work awaits decisions only for actual open answers and covered c assert.equal(remainingWorkAwaitsDecisions({ ...p, criteria: [...p.criteria, { ...criterion, id: "c-2" }] }), false); assert.equal(remainingWorkAwaitsDecisions({ ...p, workPhases: [...p.workPhases, phase("free", "pending")] }), false); }); + + +test("IDLE release refuses a plan with broken references", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const waiting = phase("root", "in_progress", { awaitsDecision: ["dec-1"] }); + const base = plan([waiting], { decisions: [d] }); + assert.equal(remainingWorkAwaitsDecisions(base), true); + assert.equal(remainingWorkAwaitsDecisions(plan([phase("root", "in_progress", { awaitsDecision: ["dec-1", "ghost"] })], { decisions: [d] })), false); + assert.equal(remainingWorkAwaitsDecisions(plan([waiting, phase("child", "pending", { dependsOn: ["missing"] })], { decisions: [d] })), false); +}); + +test("decide refuses an ambiguous decision id and leaves the plan unchanged", () => { + const a = { id: "dec-1", question: "First", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const b = { id: "dec-1", question: "Second", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const p = plan([phase("root", "pending", { awaitsDecision: ["dec-1"] })], { decisions: [a, b] }); + const result = decideGoalplanDecision(p, "dec-1", "yes", "2026-09-28T01:00:00.000Z"); + assert.equal(result.kind, "rejected"); + assert.match((result as { reason: string }).reason, /ambiguous/); +}); From cc5ce06910bd583dec47b95b51c6c8af7fb207e9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:22:08 +0900 Subject: [PATCH 07/11] docs: publish measured test count (3706) --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index 1d09231d..49610d5b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,704 tests + 3,706 tests 29 skills 31 hooks Documentation diff --git a/README.md b/README.md index 1857db65..850f8494 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,704 tests + 3,706 tests 29 skills 31 hooks Documentation diff --git a/README.zh.md b/README.zh.md index 0bbc6829..8244581b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,704 tests + 3,706 tests 29 skills 31 hooks Documentation From 5cb377deb40df9e6e3f423c783e29e8f60a775ab Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:23:55 +0900 Subject: [PATCH 08/11] fix(pabcd-state): IDLE release also requires dependency-completion integrity (#262) --- plugins/codexclaw/components/pabcd-state/dist/goalplan.js | 3 ++- plugins/codexclaw/components/pabcd-state/src/goalplan.ts | 3 ++- .../components/pabcd-state/test/work-phase-states.test.ts | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js index 1f6d4702..6220dfb4 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js @@ -1191,7 +1191,8 @@ export function dependencyDeadlock(plan ) { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan ) { // A plan with broken references must keep prompting the agent to repair it. - if (goalplanDefinitionIntegrityReasons(plan).length > 0) return false; + if (goalplanDefinitionIntegrityReasons(plan).length > 0 || + goalplanDependencyCompletionReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts index cc67699f..9d7eb33d 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts @@ -1191,7 +1191,8 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan: Goalplan): boolean { // A plan with broken references must keep prompting the agent to repair it. - if (goalplanDefinitionIntegrityReasons(plan).length > 0) return false; + if (goalplanDefinitionIntegrityReasons(plan).length > 0 || + goalplanDependencyCompletionReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); diff --git a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts index f4171b36..ff314171 100644 --- a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts @@ -465,3 +465,10 @@ test("decide refuses an ambiguous decision id and leaves the plan unchanged", () assert.equal(result.kind, "rejected"); assert.match((result as { reason: string }).reason, /ambiguous/); }); + + +test("IDLE release refuses a plan whose done phase depends on unfinished work", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const p = plan([phase("root", "pending", { awaitsDecision: ["dec-1"] }), phase("child", "done", { dependsOn: ["root"] })], { decisions: [d] }); + assert.equal(remainingWorkAwaitsDecisions(p), false); +}); From 40a490dc15f014b0c5b2231fc1637c3af7f3d688 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:23:55 +0900 Subject: [PATCH 09/11] docs: publish measured test count (3707) --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index 49610d5b..ed7d27b8 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,706 tests + 3,707 tests 29 skills 31 hooks Documentation diff --git a/README.md b/README.md index 850f8494..600030c6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,706 tests + 3,707 tests 29 skills 31 hooks Documentation diff --git a/README.zh.md b/README.zh.md index 8244581b..d2197d63 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,706 tests + 3,707 tests 29 skills 31 hooks Documentation From 04916fd5764b3a0e317fb8810ef1fc81bf6eb88b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:26:02 +0900 Subject: [PATCH 10/11] fix(pabcd-state): IDLE decision release refuses every structural E8 reason (#262) --- .../components/pabcd-state/dist/goalplan.js | 22 +++++++++++++++++-- .../components/pabcd-state/src/goalplan.ts | 22 +++++++++++++++++-- .../test/work-phase-states.test.ts | 9 ++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js index 6220dfb4..c03c80a4 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js @@ -1191,8 +1191,7 @@ export function dependencyDeadlock(plan ) { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan ) { // A plan with broken references must keep prompting the agent to repair it. - if (goalplanDefinitionIntegrityReasons(plan).length > 0 || - goalplanDependencyCompletionReasons(plan).length > 0) return false; + if (goalplanStructuralReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); @@ -1708,6 +1707,25 @@ function supersededIntegrityReasons(plan ) { return out; } +/** + * Every E8 reason that means the plan itself is broken, as opposed to work that is + * simply not finished yet. The IDLE decision release refuses any of these so the + * agent keeps being prompted to repair the plan. + */ +export function goalplanStructuralReasons(plan ) { + const reasons = []; + if (typeof plan.schemaVersion === "number" && plan.schemaVersion > SUPPORTED_MAX_SCHEMA_VERSION) { + reasons.push(`schemaVersion ${plan.schemaVersion} is newer than this build supports`); + } + reasons.push(...goalplanDefinitionIntegrityReasons(plan), ...goalplanDependencyCompletionReasons(plan)); + for (const c of plan.criteria) { + if (c.status === "met" && (c.capturedEvidence ?? "").trim().length === 0) reasons.push(`criterion ${c.id} marked met but has no captured evidence`); + } + for (const wp of doneWorkPhasesWithPendingTasks(plan)) reasons.push(`work phase ${wp.id} is marked done but still has open task(s)`); + reasons.push(...supersededIntegrityReasons(plan)); + return reasons; +} + /** Marker path: promotion to v2 is recorded outside the plan file as well. */ export function schemaMarkerPath(cwd , slug ) { return join(goalplanDir(cwd, slug), "schema-v2.marker"); diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts index 9d7eb33d..92bc5e20 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts @@ -1191,8 +1191,7 @@ export function dependencyDeadlock(plan: Goalplan): DependencyDeadlock | null { /** IDLE can yield only when actual open user decisions account for all remaining work. */ export function remainingWorkAwaitsDecisions(plan: Goalplan): boolean { // A plan with broken references must keep prompting the agent to repair it. - if (goalplanDefinitionIntegrityReasons(plan).length > 0 || - goalplanDependencyCompletionReasons(plan).length > 0) return false; + if (goalplanStructuralReasons(plan).length > 0) return false; const remaining = remainingWorkPhases(plan); if (remaining.length === 0) return false; const byId = new Map(plan.workPhases.map((phase) => [phase.id, phase])); @@ -1708,6 +1707,25 @@ function supersededIntegrityReasons(plan: Goalplan): string[] { return out; } +/** + * Every E8 reason that means the plan itself is broken, as opposed to work that is + * simply not finished yet. The IDLE decision release refuses any of these so the + * agent keeps being prompted to repair the plan. + */ +export function goalplanStructuralReasons(plan: Goalplan): string[] { + const reasons: string[] = []; + if (typeof plan.schemaVersion === "number" && plan.schemaVersion > SUPPORTED_MAX_SCHEMA_VERSION) { + reasons.push(`schemaVersion ${plan.schemaVersion} is newer than this build supports`); + } + reasons.push(...goalplanDefinitionIntegrityReasons(plan), ...goalplanDependencyCompletionReasons(plan)); + for (const c of plan.criteria) { + if (c.status === "met" && (c.capturedEvidence ?? "").trim().length === 0) reasons.push(`criterion ${c.id} marked met but has no captured evidence`); + } + for (const wp of doneWorkPhasesWithPendingTasks(plan)) reasons.push(`work phase ${wp.id} is marked done but still has open task(s)`); + reasons.push(...supersededIntegrityReasons(plan)); + return reasons; +} + /** Marker path: promotion to v2 is recorded outside the plan file as well. */ export function schemaMarkerPath(cwd: string, slug: string): string { return join(goalplanDir(cwd, slug), "schema-v2.marker"); diff --git a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts index ff314171..97348cfa 100644 --- a/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/work-phase-states.test.ts @@ -472,3 +472,12 @@ test("IDLE release refuses a plan whose done phase depends on unfinished work", const p = plan([phase("root", "pending", { awaitsDecision: ["dec-1"] }), phase("child", "done", { dependsOn: ["root"] })], { decisions: [d] }); assert.equal(remainingWorkAwaitsDecisions(p), false); }); + + +test("IDLE release refuses invalid superseded phases and other structural breaks", () => { + const d = { id: "dec-1", question: "Choose", status: "open" as const, askedAt: "2026-09-28T00:00:00.000Z" }; + const waiting = phase("root", "pending", { awaitsDecision: ["dec-1"] }); + assert.equal(remainingWorkAwaitsDecisions(plan([waiting, phase("old", "superseded", { supersededBy: "ghost" })], { decisions: [d] })), false); + assert.equal(remainingWorkAwaitsDecisions(plan([waiting, phase("gone", "superseded")], { decisions: [d] })), false); + assert.equal(remainingWorkAwaitsDecisions(plan([waiting, phase("old", "superseded", { supersededBy: "root" })], { decisions: [d] })), true); +}); From e59c9d8ee825e60eccdfdd0e71e75be7e18b2d11 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 28 Sep 2026 03:26:02 +0900 Subject: [PATCH 11/11] docs: publish measured test count (3708) --- README.ko.md | 2 +- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index ed7d27b8..d2965583 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,707 tests + 3,708 tests 29 skills 31 hooks Documentation diff --git a/README.md b/README.md index 600030c6..275275d8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,707 tests + 3,708 tests 29 skills 31 hooks Documentation diff --git a/README.zh.md b/README.zh.md index d2197d63..0d721a8f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,707 tests + 3,708 tests 29 skills 31 hooks Documentation