From 163087cf59285ec811de1dd306247924956f8230 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Fri, 14 Aug 2026 16:26:03 +0000 Subject: [PATCH 1/6] feat(workflows): compaction-resilient recovery and risk-proportional validation (#4560) Long ralplan -> ultragoal runs compacted mid-flight with only a thin state projection (goal objective/status, phase, todos) plus a generic continuation prompt, losing the precise accepted scope/progress/evidence contract and inviting drift, duplicate review generations, and zero-progress continuation loops. Boundary validation also applied the full review cohort unconditionally, inflating token cost and failure surface on low-risk single-goal work. Add a structured workflow recovery projection derived from canonical durable state (Ralplan final/index.jsonl and Ultragoal goals.json/ledger.jsonl): bounded objective, accepted scope, non-goals, acceptance criteria, unresolved decisions, current goal, measurable progress, exact next action class, and digest. Compaction renders it into the state-aware context and the post-compaction auto-continue for active recognized workflows consumes it with scope-reload, user-intent-supremacy, no-silent-scope-expansion, unchanged-basis rerun-avoidance, and bounded zero-progress escalation rules. Malformed, stale, or tampered state degrades safely to the previous behavior, and paused/terminal/unknown workflows stay inert. Add a deterministic Ultragoal validation-applicability policy computed from durable facts (trusted change set, plan shape, open review blockers, joined-cohort source hash). Low-risk single-goal boundaries may omit redundant cleaner/architect ceremony and the duplicate terminal-critic read only through a validationLaneSelection proof that must exactly mirror the runtime-computed selection; QA, sourceHash binding, receipts, provenance, and join-before-repair stay mandatory. High-risk, multi-goal, computer/shared-registry, migration, and public-contract paths keep the full heavyweight cohort, and an unchanged immutable source/evidence basis permits reusing verified boundary evidence instead of forcing a full rerun. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 4a5f19c2 Constraint: preserve executor and cleaner||architect||qa cohort parallelism (#3473/#3474) Constraint: runtime-authoritative lane selection, never free-form model self-exemption Constraint: compaction reloads the durable contract; scope expansion must be classified, never silently accepted Rejected: model-declared lane skipping | prose cannot be validated fail-closed Rejected: second durable workflow-state store | projection is read-only over existing canonical state Confidence: high Scope-risk: moderate Reversibility: trivial Tested: 19 focused tests across projection, policy, gate selection, and compaction continuation Tested: regression suites for compaction, ultragoal runtime, review, critic gate, batch release (327 pass) Not-tested: live multi-run dogfood latency deltas (documented as follow-up evidence) --- ...covery-and-risk-proportional-validation.md | 56 +++ packages/coding-agent/CHANGELOG.md | 8 + .../src/gjc-runtime/ultragoal-runtime.ts | 154 ++++++- .../ultragoal-validation-policy.ts | 170 +++++++ .../workflow-recovery-projection.ts | 415 ++++++++++++++++++ .../coding-agent/src/session/agent-session.ts | 185 +++++++- ...ion-workflow-recovery-continuation.test.ts | 191 ++++++++ .../ultragoal-validation-lanes.test.ts | 218 +++++++++ .../test/workflow-recovery-projection.test.ts | 282 ++++++++++++ 9 files changed, 1671 insertions(+), 8 deletions(-) create mode 100644 docs/workflow-recovery-and-risk-proportional-validation.md create mode 100644 packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts create mode 100644 packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts create mode 100644 packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts create mode 100644 packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts create mode 100644 packages/coding-agent/test/workflow-recovery-projection.test.ts diff --git a/docs/workflow-recovery-and-risk-proportional-validation.md b/docs/workflow-recovery-and-risk-proportional-validation.md new file mode 100644 index 0000000000..d157e28db5 --- /dev/null +++ b/docs/workflow-recovery-and-risk-proportional-validation.md @@ -0,0 +1,56 @@ +# Workflow recovery and risk-proportional validation (#4560) + +Long `ralplan -> ultragoal` runs can compact mid-flight. Before #4560, compaction preserved only a thin best-effort projection (active goal objective/status, workflow phase, open todos) plus a generic continuation prompt, and boundary validation applied the full review cohort unconditionally. This document describes the two mechanisms #4560 adds: a **structured workflow recovery projection** consumed by compaction, and a **deterministic validation-applicability policy** for Ultragoal boundary lanes. + +## Structured workflow recovery projection + +`packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts` derives a bounded projection from canonical durable state through read-only filesystem access: + +- **Ralplan**: the newest complete `final` stage row of a run's `index.jsonl` resolves the persisted plan artifact; objective/scope/non-goals/acceptance criteria/unresolved decisions are parsed from its bounded `##` sections, with a `sha256` digest over the artifact bytes. +- **Ultragoal**: `goals.json` + `ledger.jsonl` produce the aggregate objective, per-goal accepted scope, completed-goal acceptance evidence, the current goal (active/failed or first schedulable), measurable progress counters (total/completed/outstanding goals, latest joined cohort generation + frozen `sourceHash`, newest ledger event id), and the exact next action class (`continue-current-goal`, `start-next-goal`, `resolve-review-blockers`, `final-aggregate-checkpoint`, ...). + +Safety properties: + +- **Safe degradation** — malformed, stale, unreadable, or tampered durable state yields `undefined` and compaction falls back to the previous thin projection; projection failures never abort compaction. +- **Read-only** — the projection never mutates `.gjc/` state. + +### Compaction consumption + +`AgentSession`'s compaction state snapshot attaches the projection whenever an active recognized workflow (`ultragoal` first, then `ralplan`) owns the session: + +- `#compactionStateContext` renders bounded `` lines: workflow contract, accepted scope, non-goals, acceptance criteria, current goal, progress (including frozen `sourceHash`), next action, and contract digest. These flow into the compaction summary through the existing state-aware context path. +- The post-compaction auto-continue prompt for active recognized workflows replaces the generic `auto-continue.md` text with a `` block plus rules: reload the durable contract before acting, latest-user-intent supremacy, **no silent scope expansion** (work beyond accepted scope must be classified as new scope and recorded durably), no duplicate already-verified review generations when the recorded source hash and evidence basis are unchanged, and bounded zero-progress escalation. +- Inertness is preserved: paused goals, manifest-terminal phases, and unknown skills never receive the structured continuation (the generic prompt and existing skip logic stay authoritative). + +### Bounded zero-progress cycles + +Each compaction observation fingerprints the projection's contract-relevant fields (`hashWorkflowRecoveryProjection`). `trackWorkflowRecoveryZeroProgress` counts consecutive observations with an unchanged fingerprint; at `ZERO_PROGRESS_STALL_THRESHOLD` (2) the continuation prompt carries an explicit `STALLED` directive ordering a durable blocker/escalation instead of repeating the same next action. Any measurable durable progress (completed obligations, changed blocker disposition, changed source hash, goal status change) resets the counter. This bounds — but does not claim to eliminate — post-compaction continuation loops. + +## Ultragoal validation-applicability policy + +`packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts` selects expensive boundary lanes deterministically from durable facts. Selection is runtime-authoritative and inspectable; free-form model prose can never grant a reduction. + +Low-risk eligibility (the only case where redundant lanes may be omitted) requires **all** of: + +- a trusted, completely captured change set, +- exactly one outstanding goal, +- no open review blockers, +- no high-risk path (auth/security, native crates, SDK/extensibility public contract, agent-wire protocol, shared behavior registries), migration path, or computer-control-surface path. + +Everything else — including a missing or untrusted change set — is high risk and keeps the full heavyweight cohort (`cleaner || architect || qa`, join-before-repair, terminal critic). + +Omission mechanics: + +- The **QA lane can never be omitted**. Targeted verification and real-surface evidence stay mandatory at every boundary. +- A leader presenting a reduced cohort must carry a top-level `validationLaneSelection` proof (`riskClass`, `reasons`, `omittedLanes`) that exactly mirrors the runtime-computed selection. Mismatches fail closed with typed diagnostics (`reduction_not_applicable`, `selection_mismatch`, `omitted_lanes_mismatch`, `qa_lane_mandatory`) and the full cohort requirement stays in force. +- The **terminal critic** is proportional: `criticReview.verdict: OKAY` remains mandatory for final aggregates except when the run is single-goal, low-risk, blocker-free, **and** the immutable source basis is unchanged (`basisUnchanged`), in which case the already-joined cohort evidence satisfies the terminus without a duplicate critic read pass. + +### Unchanged-basis rerun avoidance + +`basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash equals the current frozen cohort source hash under review and no review blockers reopened. A changed source, a review fix, an integration-base change, incomplete capture, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. + +## Guarantees preserved + +- `sourceHash` frozen-snapshot binding, receipts, provenance, immutable cohort snapshots, join-before-repair, validation batches, and high-risk QA/live-surface evidence requirements are unchanged. +- Executor parallelism and `cleaner || architect || qa` cohort parallelism are unchanged (the policy only decides *whether* a lane applies, never how lanes that do apply are scheduled). +- Review-blocker recursion caps and terminal-critic ceilings are unchanged. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5a66226dc6..b348418a1a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,14 @@ - Managed fallback local snapshot failures now surface their one producer-boundary diagnostic immediately instead of re-issuing the identical request up to three times. The failure still never charges the provider fallback chain, advances models, or mutates credentials. +### Added + +- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and the joined cohort source hash. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. High-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, and untrusted changes retain the full cohort. + +### Changed + +- Compaction recovery for active Ralplan and Ultragoal runs now reloads a bounded structured projection from canonical durable workflow state instead of relying on summary prose alone (#4560). The projection carries objective, accepted scope, non-goals, acceptance criteria, unresolved decisions, current goal, measurable progress, exact next action, and provenance. Continuation enforces scope reload, latest-user-intent precedence, explicit classification of scope expansion, unchanged-basis rerun avoidance, and bounded zero-progress escalation; malformed, stale, tampered, paused, terminal, and unrecognized state safely retains the existing generic behavior. + - Managed fallback local buffer overflows (`local_buffer_overflow`) now surface immediately with the original local diagnostic instead of entering the bounded `unknown` retry class: re-streaming the same request reproduces the same oversized response, and a local staging failure must never charge or advance the provider fallback chain, emit `model_fallback_switched`, or rotate credentials. - Fixed Windows startup lock starvation in the SDK session index (#4544): a live detached broker holding `index.jsonl.lock` across a wedged Windows sync-family await exhausted every later launch's full 600-attempt lock budget. OS process-incarnation probes (which can spawn `powershell.exe` on Windows) now run before the machine-global index lock is taken in the heartbeat checkpoint pass, the conditional-unregister pass (now routed through the shared slow-operation choke point), and the own-pid registration derivation; the PowerShell fallback is time-bounded (`timeout` + `SIGKILL` enforcement); locked index transactions log an actionable slow-operation warning after 10s naming the exact operation; and lock exhaustion errors now identify the live owner (pid, liveness, lock path) instead of a bare attempt count. The heartbeat pass also rechecks probe freshness after the locked replay on the monotonic clock: a pid reused while the replay re-reads the log fails closed (no heartbeat this cycle) instead of checkpointing the wrong host, and a backward wall-clock step (NTP slew, manual fix, VM restore) cannot defeat the bound. A lock record carrying a foreign `owner_host_id` (shared-volume topic registry) reports its owner host with unknown liveness instead of probing a coincidental local pid. Stale-lock safety is unchanged: a proven-live owner's lock is never stolen. diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts index cfc261d40a..bb02a54f6a 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts @@ -62,6 +62,10 @@ import { writeArtifact, writeGuardedJsonAtomic, } from "./state-writer"; +import { + resolveUltragoalValidationApplicability, + type UltragoalValidationApplicability, +} from "./ultragoal-validation-policy"; import { resolveWorkflowSetting } from "./workflow-settings"; export { @@ -2500,6 +2504,94 @@ function validateDeferredCompletionQualityGate( "deferredToBatch.changeSet.changeSetHash does not match declared paths; omit changeSetHash and the runtime computes it", ); } +/** #4560: declared lane-selection proof shape on the quality gate. */ +interface DeclaredValidationLaneSelection { + riskClass: string; + reasons: string[]; + omittedLanes: string[]; +} + +function readDeclaredValidationLaneSelection(gate: JsonObject): DeclaredValidationLaneSelection | undefined { + const declared = qualityGateObject(gate.validationLaneSelection); + if (!declared) return undefined; + const riskClass = nonEmptyString(declared.riskClass); + const reasons = stringArray(declared.reasons); + const omittedLanes = stringArray(declared.omittedLanes); + if (!riskClass || !reasons || !omittedLanes) { + throw new Error( + "validationLaneSelection must carry riskClass, reasons, and omittedLanes string arrays mirroring the runtime selection", + ); + } + return { riskClass, reasons, omittedLanes }; +} + +/** + * #4560: validate a declared low-risk lane reduction against the + * runtime-computed applicability. The runtime selection is authoritative: a + * declaration that disagrees with the computed risk class, reasons, or + * omitted-lane set fails closed and the full cohort stays mandatory. + */ +function validateDeclaredValidationLaneSelection( + declared: DeclaredValidationLaneSelection, + applicability: UltragoalValidationApplicability, + found: QualityGateDiagnostics, +): boolean { + if (applicability.riskClass !== "low") { + found.add( + "validationLaneSelection", + "reduction_not_applicable", + "validationLaneSelection lane reduction is not applicable: the runtime computed a high-risk boundary; the full cohort is mandatory", + ); + return false; + } + if (declared.riskClass !== applicability.riskClass) { + found.add( + "validationLaneSelection", + "selection_mismatch", + "declared validationLaneSelection riskClass does not match the runtime-computed risk class", + ); + return false; + } + const expectedOmitted = (["cleaner", "architect"] as const).filter(lane => !applicability.lanes[lane].applicable); + const declaredOmitted = new Set(declared.omittedLanes); + if (declaredOmitted.has("qa")) { + found.add( + "validationLaneSelection", + "qa_lane_mandatory", + "validationLaneSelection can never omit the qa lane; verification is mandatory at every boundary", + ); + return false; + } + if (declaredOmitted.size !== expectedOmitted.length || expectedOmitted.some(lane => !declaredOmitted.has(lane))) { + found.add( + "validationLaneSelection", + "omitted_lanes_mismatch", + "declared validationLaneSelection omittedLanes must exactly mirror the runtime-computed inapplicable lanes", + ); + return false; + } + return true; +} + +/** #4560: newest joined cohort (generation + frozen sourceHash) in the ledger. */ +function latestJoinedCohortSourceHash( + ledger: readonly UltragoalLedgerEvent[], +): { reviewGeneration: number; sourceHash: string } | undefined { + let latest: { reviewGeneration: number; sourceHash: string } | undefined; + for (const event of ledger) { + if (event.event !== "goal_checkpointed" || event.status !== "complete") continue; + const cohort = qualityGateObject( + qualityGateObject(event.qualityGateJson)?.iteration as JsonObject | undefined, + )?.reviewCohort; + const record = qualityGateObject(cohort); + if (!record) continue; + const reviewGeneration = record.reviewGeneration; + const sourceHash = nonEmptyString(record.sourceHash); + if (typeof reviewGeneration !== "number" || !sourceHash) continue; + if (!latest || reviewGeneration >= latest.reviewGeneration) latest = { reviewGeneration, sourceHash }; + } + return latest; +} const COHORT_LANE_KEYS = ["cleaner", "architect", "qa"] as const; /** @@ -2509,7 +2601,11 @@ const COHORT_LANE_KEYS = ["cleaner", "architect", "qa"] as const; * generations are delta-only. Cohort state rides the existing `iteration` gate key so * no new top-level quality-gate key is introduced. */ -function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void { +function validateReviewCohort( + gate: JsonObject, + iteration: JsonObject, + options: { lowRiskReduced?: boolean } = {}, +): void { const cohort = qualityGateObject(iteration.reviewCohort); if (!cohort) throw new Error("qualityGate iteration.reviewCohort is required at the review boundary"); const generation = cohort.reviewGeneration; @@ -2524,7 +2620,15 @@ function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void { const unsupportedLanes = Object.keys(lanes).filter(key => !(COHORT_LANE_KEYS as readonly string[]).includes(key)); if (unsupportedLanes.length > 0) throw new Error(`iteration.reviewCohort.lanes contains unsupported lanes: ${unsupportedLanes.join(", ")}`); - for (const lane of COHORT_LANE_KEYS) { + // #4560: on a runtime-selected low-risk single-goal boundary, cleaner and + // architect may be omitted only through the deterministic + // validationLaneSelection proof validated by the caller; the QA lane and + // the frozen source hash stay mandatory, and cohort parallelism is + // untouched whenever lanes do run. + const requiredLanes: readonly (typeof COHORT_LANE_KEYS)[number][] = options.lowRiskReduced + ? COHORT_LANE_KEYS.filter(lane => lane === "qa") + : [...COHORT_LANE_KEYS]; + for (const lane of requiredLanes) { if (Array.isArray(lanes[lane])) throw new Error(`iteration.reviewCohort.lanes.${lane} must be one lane per generation, not a list`); const record = qualityGateObject(lanes[lane]); @@ -2618,6 +2722,7 @@ async function validateCompletionQualityGate( "executorQa", "iteration", "validationBatchClose", + "validationLaneSelection", "criticReview", ]); const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key)); @@ -2643,8 +2748,15 @@ async function validateCompletionQualityGate( } const allowedKeys = new Set( batchMode - ? ["architectReview", "executorQa", "iteration", "validationBatchClose", "criticReview"] - : ["architectReview", "executorQa", "iteration", "criticReview"], + ? [ + "architectReview", + "executorQa", + "iteration", + "validationBatchClose", + "criticReview", + "validationLaneSelection", + ] + : ["architectReview", "executorQa", "iteration", "criticReview", "validationLaneSelection"], ); const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key)); if (unsupportedKeys.length > 0) { @@ -2654,6 +2766,26 @@ async function validateCompletionQualityGate( `qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`, ); } + // #4560: deterministic risk/applicability selection for expensive boundary + // lanes. The runtime — never free-form model prose — decides whether a + // low-risk single-goal boundary may omit redundant review ceremony + // (cleaner/architect/terminal-critic). QA/targeted verification and the + // hash/receipt/join guarantees always remain mandatory. Anything risky or + // unprovable keeps today's full heavyweight cohort unchanged. The current + // frozen source under review is the gate's own cohort sourceHash. + const gateIteration = qualityGateObject(gate.iteration); + const gateCohortSourceHash = nonEmptyString(qualityGateObject(gateIteration?.reviewCohort)?.sourceHash); + const applicability = resolveUltragoalValidationApplicability({ + changeSet: options.changeSet, + totalGoals: options.plan?.goals.length, + completedGoals: options.plan?.goals.filter(goal => goal.status === "complete").length, + hasOpenReviewBlockers: options.plan?.goals.some(goal => goal.status === "review_blocked") ?? false, + latestCohortSourceHash: options.ledger ? latestJoinedCohortSourceHash(options.ledger)?.sourceHash : undefined, + currentSourceHash: gateCohortSourceHash ?? undefined, + }); + const laneSelection = readDeclaredValidationLaneSelection(gate); + const lowRiskReduced = + laneSelection !== undefined && validateDeclaredValidationLaneSelection(laneSelection, applicability, found); const architectReview = qualityGateObject(gate.architectReview); const executorQa = qualityGateObject(gate.executorQa); const iteration = qualityGateObject(gate.iteration); @@ -2678,8 +2810,16 @@ async function validateCompletionQualityGate( "checkpoint --status complete blocked: terminal-critic ceiling reached; requires human/leader gjc ultragoal record-critic-gate-override before completion", ); } + // #4560: terminal-critic proportionality. The critic verdict is + // mandatory on multi-goal/boundary, high-risk, or evidence-uncertain + // runs. A single-goal low-risk run whose joined cohort is clean and + // whose immutable source basis is unchanged may satisfy the terminus + // through the runtime-verified validationLaneSelection proof instead + // of duplicating the already-joined review with another read pass. const criticReview = qualityGateObject(gate.criticReview); - if (criticReview?.verdict !== "OKAY") { + const criticProportionallySatisfied = + lowRiskReduced && applicability.basisUnchanged && !applicability.hasOpenReviewBlockers; + if (criticReview?.verdict !== "OKAY" && !criticProportionallySatisfied) { found.add( "criticReview.verdict", "critic_verdict_not_okay", @@ -2763,7 +2903,9 @@ async function validateCompletionQualityGate( found.check("iteration.blockers", "non_empty_blockers", () => requireEmptyBlockers(iteration.blockers, "iteration.blockers"), ); - found.check("iteration.reviewCohort", "review_cohort_invalid", () => validateReviewCohort(gate, iteration)); + found.check("iteration.reviewCohort", "review_cohort_invalid", () => + validateReviewCohort(gate, iteration, { lowRiskReduced }), + ); if (batchMode && options.goal && options.plan && options.ledger) { found.check("validationBatchClose", "batch_close_invalid", () => validateBatchCloseQualityGate(gate, options.plan!, batchMode, options.ledger!, options.changeSet), diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts new file mode 100644 index 0000000000..6db54564e3 --- /dev/null +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts @@ -0,0 +1,170 @@ +/** + * Deterministic Ultragoal validation applicability policy (#4560). + * + * The boundary cohort (`cleaner || architect || QA`) and the terminal critic + * are expensive LLM lanes. They were introduced as unconditional per-boundary + * ceremony, which inflates token cost and failure surface on low-risk work and + * makes compaction more likely during long runs (#3473/#3474 moved review from + * per-subgoal to per-boundary; this policy makes the boundary lanes + * risk-proportional without removing them for risky work). + * + * Selection is runtime-authoritative and deterministic from durable facts + * (change set, plan shape, ledger receipts) — never free-form model prose. + * It fails closed: any condition that cannot be proven cheap is treated as + * high-risk and keeps the full heavyweight cohort. The precedent is + * `requiresComputerRedTeamSuite`, whose applicability the runtime derives from + * the computed change set and refuses to let the model self-exempt. + */ +import { + categorizeComputerChangePath, + type UltragoalChangeSet, + type UltragoalChangeSetPath, +} from "./ultragoal-runtime"; + +export type UltragoalValidationLane = "cleaner" | "architect" | "qa" | "terminal-critic"; + +export interface UltragoalValidationApplicabilityInput { + /** Trusted computed change set for the boundary (checkpoint path). */ + changeSet?: UltragoalChangeSet; + /** Current durable plan. */ + totalGoals?: number; + completedGoals?: number; + /** Open review blockers exist (review_blocked goals). */ + hasOpenReviewBlockers?: boolean; + /** Newest joined cohort sourceHash recorded in the ledger. */ + latestCohortSourceHash?: string; + /** Current frozen source hash the boundary would review. */ + currentSourceHash?: string; + /** Ledger evidence of a prior verified boundary generation. */ + priorVerifiedGeneration?: number; +} + +export interface UltragoalValidationApplicability { + /** Lane -> applicability decision with the durable facts that forced it. */ + lanes: Record; + /** True only when every heavyweight lane is applicable (full cohort). */ + heavyweight: boolean; + /** Risk classification driving the selection. */ + riskClass: "low" | "high"; + /** True when open review blockers make terminal evidence uncertain. */ + hasOpenReviewBlockers: boolean; + /** True when an unchanged immutable source basis permits evidence reuse. */ + basisUnchanged: boolean; + /** Human- and machine-inspectable selection basis, recorded in diagnostics. */ + selection: string[]; +} + +const HIGH_RISK_PATH_PREFIXES = [ + // Security/auth surfaces + "packages/coding-agent/src/session/auth", + "packages/coding-agent/src/auth", + "crates/pi-natives/src", + "crates/git-daemon", + // Public contract / SDK surfaces + "packages/coding-agent/src/sdk", + "packages/coding-agent/src/extensibility", + "packages/coding-agent/src/modes/shared/agent-wire", + // Shared behavior registries (mirrors the computer-suite conservative rule) + "packages/coding-agent/src/tools/index.ts", + "packages/coding-agent/src/tools/renderers.ts", + "packages/coding-agent/src/config/settings-schema.ts", +] as const; + +const MIGRATION_PATH_PREFIXES = [ + "packages/coding-agent/src/gjc-runtime/state-migrations", + "packages/coding-agent/src/session/session-manager", + "scripts", +] as const; + +function changePaths(changeSet: UltragoalChangeSet | undefined): UltragoalChangeSetPath[] { + return changeSet?.trusted ? changeSet.paths : []; +} + +export function isHighRiskChangePath(row: UltragoalChangeSetPath): boolean { + const candidates = [row.path, row.oldPath].filter((value): value is string => typeof value === "string"); + for (const candidate of candidates) { + for (const prefix of HIGH_RISK_PATH_PREFIXES) { + if (candidate === prefix || candidate.startsWith(`${prefix}/`)) return true; + } + } + return false; +} + +export function isMigrationChangePath(row: UltragoalChangeSetPath): boolean { + const candidates = [row.path, row.oldPath].filter((value): value is string => typeof value === "string"); + for (const candidate of candidates) { + for (const prefix of MIGRATION_PATH_PREFIXES) { + if (candidate === prefix || candidate.startsWith(`${prefix}/`)) return true; + } + } + return false; +} + +function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean { + const candidates = [row.path, row.oldPath].filter((value): value is string => typeof value === "string"); + return candidates.some(candidate => { + const category = categorizeComputerChangePath(candidate); + return category === "code" || category === "tool" || category === "settings-registry"; + }); +} + +/** + * Compute the deterministic validation applicability for a boundary. + * + * Low-risk eligibility (the only case where redundant lanes may be omitted): + * trusted change set, single outstanding goal, no open review blockers, no + * high-risk/migration/computer/public-contract path, complete capture. + * Everything else — including a missing/untrusted change set — keeps the full + * heavyweight cohort exactly as today. + */ +export function resolveUltragoalValidationApplicability( + input: UltragoalValidationApplicabilityInput, +): UltragoalValidationApplicability { + const selection: string[] = []; + const paths = changePaths(input.changeSet); + const highRiskPaths = paths.filter(isHighRiskChangePath); + const migrationPaths = paths.filter(isMigrationChangePath); + const computerPaths = paths.filter(isComputerControlSurfaceChangePath); + const multiGoal = (input.totalGoals ?? 0) - (input.completedGoals ?? 0) > 1; + const reasons: string[] = []; + if (!input.changeSet?.trusted) reasons.push("change-set-untrusted-or-missing"); + if (input.changeSet?.captureIncomplete) reasons.push("capture-incomplete"); + if (multiGoal) reasons.push("multiple-outstanding-goals"); + if (input.hasOpenReviewBlockers) reasons.push("open-review-blockers"); + if (highRiskPaths.length > 0) reasons.push("high-risk-paths"); + if (migrationPaths.length > 0) reasons.push("migration-paths"); + if (computerPaths.length > 0) reasons.push("computer-control-surface"); + // Low-risk omission requires proof of exactly one outstanding goal. + if (input.totalGoals === undefined || input.completedGoals === undefined) reasons.push("progress-unknown"); + const highRisk = reasons.length > 0; + const heavyweight = highRisk; + const lane = (applicable: boolean, why: string[]): { applicable: boolean; reasons: string[] } => ({ + applicable, + reasons: why, + }); + // Unchanged-basis reuse: only when a prior joined cohort verified the exact + // frozen source hash this boundary would review, and no blockers reopened. + const basisUnchanged = + Boolean(input.latestCohortSourceHash) && + input.currentSourceHash === input.latestCohortSourceHash && + !input.hasOpenReviewBlockers; + const lanes: UltragoalValidationApplicability["lanes"] = { + cleaner: lane(heavyweight, heavyweight ? reasons : ["low-risk-single-goal"]), + architect: lane(heavyweight, heavyweight ? reasons : ["low-risk-single-goal"]), + // QA/targeted verification always applies at a boundary; risk selection + // never removes verification, only redundant review ceremony. + qa: lane(true, ["mandatory-verification"]), + "terminal-critic": lane(heavyweight, heavyweight ? reasons : ["low-risk-single-goal"]), + }; + selection.push(`riskClass=${highRisk ? "high" : "low"}`); + selection.push(`basisUnchanged=${basisUnchanged}`); + if (reasons.length > 0) selection.push(`heavyweightReasons=${reasons.join(",")}`); + return { + lanes, + heavyweight, + riskClass: highRisk ? "high" : "low", + hasOpenReviewBlockers: Boolean(input.hasOpenReviewBlockers), + basisUnchanged, + selection, + }; +} diff --git a/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts new file mode 100644 index 0000000000..7104b25b4d --- /dev/null +++ b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts @@ -0,0 +1,415 @@ +/** + * Structured workflow recovery projection for compaction (#4560). + * + * Compaction previously preserved only a thin best-effort state projection + * (active goal objective/status, workflow phase, open todos) plus a generic + * continuation prompt. Long Ralplan/Ultragoal runs could therefore lose the + * precise accepted scope/progress/evidence contract, then drift or spin in + * zero-progress continuation loops after compaction. + * + * This module derives a bounded structured projection from the canonical + * durable state — Ralplan `final`/`index.jsonl` run artifacts and Ultragoal + * `goals.json` + `ledger.jsonl` — through read-only filesystem access. It + * never mutates workflow state and degrades safely (undefined) on malformed, + * stale, or tampered durable state so compaction falls back to the previous + * thin projection rather than failing. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { sessionPlansDir } from "./session-layout"; +import { getUltragoalPaths, readUltragoalLedger, readUltragoalPlan } from "./ultragoal-runtime"; + +/** Skills whose durable state can produce a structured recovery projection. */ +export type WorkflowRecoverySkill = "ralplan" | "ultragoal"; + +export interface WorkflowRecoveryScopeItem { + kind: "accepted" | "non_goal"; + text: string; +} + +export interface WorkflowRecoveryProjection { + skill: WorkflowRecoverySkill; + /** Canonical durable state that produced this projection. */ + source: "ralplan-final" | "ralplan-run" | "ultragoal-plan"; + /** Bounded accepted objective for the current work contract. */ + objective: string; + /** Bounded accepted scope + explicit non-goals (scope reload, not expansion). */ + scope: WorkflowRecoveryScopeItem[]; + /** Bounded acceptance criteria / verification obligations. */ + acceptanceCriteria: string[]; + /** Unresolved decisions carried from the durable contract, bounded. */ + unresolved: string[]; + /** Durable identity + integrity digest of the source state. */ + provenance: { + planPath?: string; + runId?: string; + stage?: string; + sha256?: string; + }; + /** Current goal + measurable progress counters from canonical state. */ + currentGoal?: { + goalId: string; + status: string; + objective: string; + }; + progress: { + totalGoals?: number; + completedGoals?: number; + outstandingGoals?: number; + /** Latest boundary review generation recorded in the ledger. */ + latestReviewGeneration?: number; + /** Frozen source hash of the latest joined review cohort, if any. */ + latestCohortSourceHash?: string; + /** Ledger event id of the newest event backing this projection. */ + latestLedgerEventId?: string; + }; + /** Exact next bounded action class for resumption. */ + nextAction: { + actionClass: + | "continue-current-goal" + | "start-next-goal" + | "resolve-review-blockers" + | "run-boundary-cohort" + | "final-aggregate-checkpoint" + | "awaiting-approval" + | "unknown"; + goalId?: string; + detail?: string; + }; + /** #4560: measurable-progress basis for bounding zero-progress cycles. */ + zeroProgress: { + fingerprint: string; + unchangedObservations: number; + stalled: boolean; + }; +} +/** #4560: compaction-observation memory for zero-progress bounding. */ +export interface WorkflowRecoveryZeroProgressMemory { + /** Last observed progress fingerprint per skill. */ + lastFingerprint?: string; + /** Consecutive compaction observations with an unchanged fingerprint. */ + unchangedObservations: number; +} + +/** #4560: bound repeated zero-progress continuation cycles (#4560). */ +export const ZERO_PROGRESS_STALL_THRESHOLD = 2; + +export function trackWorkflowRecoveryZeroProgress( + memory: WorkflowRecoveryZeroProgressMemory | undefined, + projection: WorkflowRecoveryProjection, +): WorkflowRecoveryZeroProgressMemory { + const fingerprint = hashWorkflowRecoveryProjection(projection); + if (!memory) return { lastFingerprint: fingerprint, unchangedObservations: 0 }; + const unchanged = memory.lastFingerprint === fingerprint ? memory.unchangedObservations + 1 : 0; + return { lastFingerprint: fingerprint, unchangedObservations: unchanged }; +} + +export function isWorkflowRecoveryStalled(memory: WorkflowRecoveryZeroProgressMemory | undefined): boolean { + return (memory?.unchangedObservations ?? 0) >= ZERO_PROGRESS_STALL_THRESHOLD; +} + +const MAX_OBJECTIVE_CHARS = 600; +const MAX_ITEM_CHARS = 240; +const MAX_SCOPE_ITEMS = 12; +const MAX_CRITERIA_ITEMS = 12; +const MAX_UNRESOLVED_ITEMS = 8; + +function boundText(value: unknown, maxChars: number): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars - 1)}…` : trimmed; +} + +function sha256File(filePath: string): Promise { + return fs + .readFile(filePath) + .then(buffer => `sha256:${crypto.createHash("sha256").update(buffer).digest("hex")}`) + .catch(() => undefined); +} + +interface ParsedHeadingSection { + title: string; + lines: string[]; +} + +/** Split a markdown artifact into bounded `## `-level sections. */ +function parseMarkdownSections(markdown: string): ParsedHeadingSection[] { + const sections: ParsedHeadingSection[] = []; + let current: ParsedHeadingSection | undefined; + for (const rawLine of markdown.split(/\r?\n/)) { + const heading = /^##\s+(.*)$/.exec(rawLine); + if (heading) { + current = { title: heading[1].trim(), lines: [] }; + sections.push(current); + } else if (current) { + current.lines.push(rawLine); + } + } + return sections.slice(0, 24); +} + +/** Extract bounded list items from a section body (normalizes nested bullets). */ +function sectionListItems(section: ParsedHeadingSection | undefined, maxItems: number): string[] { + if (!section) return []; + const items: string[] = []; + for (const line of section.lines) { + const bullet = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line); + const text = boundText(bullet ? bullet[1] : line.trim().length > 0 ? line : undefined, MAX_ITEM_CHARS); + if (text) items.push(text); + if (items.length >= maxItems) break; + } + return items; +} + +function findSection(sections: ParsedHeadingSection[], needles: readonly string[]): ParsedHeadingSection | undefined { + const normalized = needles.map(needle => needle.toLowerCase()); + return sections.find(section => normalized.some(needle => section.title.toLowerCase().includes(needle))); +} + +/** + * Extract the bounded objective from a Ralplan final plan artifact. The + * objective is the first non-empty prose line of the document (before the + * first `##` heading), which is the durable plan statement of intent. + */ +function objectiveFromMarkdown(markdown: string): string | undefined { + for (const rawLine of markdown.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.startsWith("#")) continue; + if (line.length === 0) continue; + return boundText(line, MAX_OBJECTIVE_CHARS); + } + return undefined; +} + +export interface RalplanFinalProjectionInput { + cwd: string; + sessionId: string; + runId: string; +} + +/** + * Build a recovery projection from the newest complete Ralplan `final` stage + * row of a run. Returns undefined when no parseable final artifact exists — + * compaction then degrades to the thin projection instead of guessing. + */ +export async function projectRalplanFinalRun( + input: RalplanFinalProjectionInput, +): Promise { + const runDir = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan", input.runId); + let rows: Array<{ stage: string; stage_n?: number; path?: string; sha256?: string }> = []; + try { + const text = await fs.readFile(path.join(runDir, "index.jsonl"), "utf8"); + rows = text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(line => { + try { + return JSON.parse(line) as { stage: string; stage_n?: number; path?: string; sha256?: string }; + } catch { + return undefined; + } + }) + .filter( + (row): row is { stage: string; stage_n?: number; path?: string; sha256?: string } => row !== undefined, + ); + } catch { + return undefined; + } + const finalRow = [...rows].reverse().find(row => row.stage === "final"); + if (!finalRow?.path) return undefined; + const artifactPath = path.isAbsolute(finalRow.path) ? finalRow.path : path.join(runDir, finalRow.path); + let markdown: string; + try { + markdown = await fs.readFile(artifactPath, "utf8"); + } catch { + return undefined; + } + const objective = objectiveFromMarkdown(markdown); + if (!objective) return undefined; + const sections = parseMarkdownSections(markdown); + const acceptance = + sectionListItems( + findSection(sections, ["acceptance criteria", "verification", "test plan"]), + MAX_CRITERIA_ITEMS, + ) || sectionListItems(findSection(sections, ["acceptance"]), MAX_CRITERIA_ITEMS); + const nonGoals = + sectionListItems(findSection(sections, ["non-goals", "non goals", "out of scope"]), MAX_SCOPE_ITEMS) || + sectionListItems(findSection(sections, ["non-goal"]), MAX_SCOPE_ITEMS); + const unresolved = + sectionListItems( + findSection(sections, ["intent reconciliation", "open confirmation", "unresolved"]), + MAX_UNRESOLVED_ITEMS, + ) || []; + const scope: WorkflowRecoveryScopeItem[] = sectionListItems( + findSection(sections, ["scope", "accepted scope"]), + MAX_SCOPE_ITEMS, + ).map(text => ({ kind: "accepted" as const, text })); + for (const text of nonGoals) scope.push({ kind: "non_goal" as const, text }); + const sha256 = (await sha256File(artifactPath)) ?? finalRow.sha256; + const projection: Omit = { + skill: "ralplan", + source: "ralplan-final", + objective, + scope, + acceptanceCriteria: acceptance, + unresolved, + provenance: { planPath: artifactPath, runId: input.runId, stage: "final", sha256 }, + progress: {}, + nextAction: { actionClass: "awaiting-approval" }, + }; + return withZeroProgress(projection); +} +/** + * Build a recovery projection from Ultragoal canonical durable state. The + * accepted contract is the aggregate objective plus the goal list; progress + * and next action are derived from `goals.json` status plus the newest ledger + * receipts, never from conversation memory. + */ +export async function projectUltragoalRun(input: { + cwd: string; + sessionId: string; +}): Promise { + const plan = await readUltragoalPlan(input.cwd, input.sessionId).catch(() => null); + if (!plan?.goals?.length) return undefined; + const ledger = await readUltragoalLedger(input.cwd, input.sessionId).catch(() => []); + const paths = getUltragoalPaths(input.cwd, input.sessionId); + const schedulable = plan.goals.filter(goal => goal.status !== "complete" && goal.status !== "superseded"); + const currentGoal = plan.goals.find(goal => goal.status === "active" || goal.status === "failed") ?? schedulable[0]; + const reviewBlocked = plan.goals.find(goal => goal.status === "review_blocked"); + const lastCheckpoint = [...ledger] + .reverse() + .find(event => event.event === "goal_checkpointed" && event.status === "complete"); + // Newest joined cohort generation/sourceHash across all complete checkpoints. + let latestReviewGeneration: number | undefined; + let latestCohortSourceHash: string | undefined; + for (const event of ledger) { + if (event.event !== "goal_checkpointed" || event.status !== "complete") continue; + const cohort = readCohortFromLedgerEvent(event); + if (!cohort) continue; + if (latestReviewGeneration === undefined || cohort.reviewGeneration >= latestReviewGeneration) { + latestReviewGeneration = cohort.reviewGeneration; + latestCohortSourceHash = cohort.sourceHash; + } + } + const outstanding = schedulable.length; + const scope: WorkflowRecoveryScopeItem[] = [ + { + kind: "accepted", + text: boundText(plan.gjcObjective, MAX_OBJECTIVE_CHARS) ?? "Ultragoal aggregate run", + }, + ]; + for (const goal of plan.goals.slice(0, MAX_SCOPE_ITEMS - 1)) { + const text = boundText(goal.title || goal.objective, MAX_ITEM_CHARS); + if (text) scope.push({ kind: "accepted", text: `goal ${goal.id}: ${text}` }); + } + const acceptance: string[] = []; + for (const goal of plan.goals.slice(0, MAX_CRITERIA_ITEMS)) { + // Acceptance = per-goal completion evidence currently recorded durably. + const evidence = boundText(goal.evidence, MAX_ITEM_CHARS); + if (goal.status === "complete" && evidence) acceptance.push(`${goal.id} complete: ${evidence}`); + } + let nextAction: WorkflowRecoveryProjection["nextAction"] = { actionClass: "unknown" }; + if (reviewBlocked) { + nextAction = { + actionClass: "resolve-review-blockers", + goalId: reviewBlocked.id, + detail: boundText(reviewBlocked.objective, MAX_ITEM_CHARS), + }; + } else if (outstanding === 0) { + nextAction = { actionClass: "final-aggregate-checkpoint" }; + } else if (currentGoal) { + nextAction = { + actionClass: currentGoal.status === "active" ? "continue-current-goal" : "start-next-goal", + goalId: currentGoal.id, + detail: boundText(currentGoal.objective, MAX_ITEM_CHARS), + }; + } + const projection: Omit = { + skill: "ultragoal", + source: "ultragoal-plan", + objective: boundText(plan.gjcObjective, MAX_OBJECTIVE_CHARS) ?? "Ultragoal aggregate run", + scope, + acceptanceCriteria: acceptance, + unresolved: reviewBlocked + ? [`review blockers open on ${reviewBlocked.id}`] + : schedulable + .filter(goal => goal.status === "blocked" || goal.status === "failed") + .slice(0, MAX_UNRESOLVED_ITEMS) + .map(goal => + `${goal.id} ${goal.status}: ${boundText(goal.evidence ?? "", MAX_ITEM_CHARS) ?? ""}`.trim(), + ), + provenance: { planPath: paths.goalsPath }, + currentGoal: currentGoal + ? { + goalId: currentGoal.id, + status: currentGoal.status, + objective: boundText(currentGoal.objective, MAX_OBJECTIVE_CHARS) ?? "", + } + : undefined, + progress: { + totalGoals: plan.goals.length, + completedGoals: plan.goals.filter(goal => goal.status === "complete").length, + outstandingGoals: outstanding, + latestReviewGeneration, + latestCohortSourceHash, + latestLedgerEventId: lastCheckpoint?.eventId ?? [...ledger].at(-1)?.eventId, + }, + nextAction, + }; + return withZeroProgress(projection); +} + +/** Attach a fresh zero-progress fingerprint to a built projection. */ +function withZeroProgress(projection: Omit): WorkflowRecoveryProjection { + const fingerprint = hashWorkflowRecoveryProjection(projection as WorkflowRecoveryProjection); + return { + ...projection, + zeroProgress: { fingerprint, unchangedObservations: 0, stalled: false }, + }; +} + +function readCohortFromLedgerEvent( + event: UltragoalLedgerLike, +): { reviewGeneration: number; sourceHash: string } | undefined { + const gate = event.qualityGateJson; + if (!gate || typeof gate !== "object" || Array.isArray(gate)) return undefined; + const iteration = (gate as { iteration?: unknown }).iteration; + if (!iteration || typeof iteration !== "object" || Array.isArray(iteration)) return undefined; + const cohort = (iteration as { reviewCohort?: unknown }).reviewCohort; + if (!cohort || typeof cohort !== "object" || Array.isArray(cohort)) return undefined; + const reviewGeneration = (cohort as { reviewGeneration?: unknown }).reviewGeneration; + const sourceHash = (cohort as { sourceHash?: unknown }).sourceHash; + if (typeof reviewGeneration !== "number" || typeof sourceHash !== "string") return undefined; + return { reviewGeneration, sourceHash }; +} + +interface UltragoalLedgerLike { + event?: string; + status?: string; + eventId?: string; + qualityGateJson?: unknown; +} + +/** Stable digest over the projection's contract-relevant fields. */ +export function hashWorkflowRecoveryProjection(projection: WorkflowRecoveryProjection): string { + const basis = { + skill: projection.skill, + source: projection.source, + objective: projection.objective, + scope: projection.scope, + acceptanceCriteria: projection.acceptanceCriteria, + provenance: projection.provenance, + currentGoal: projection.currentGoal, + progressBasis: { + totalGoals: projection.progress.totalGoals, + completedGoals: projection.progress.completedGoals, + outstandingGoals: projection.progress.outstandingGoals, + latestCohortSourceHash: projection.progress.latestCohortSourceHash, + }, + nextAction: projection.nextAction, + }; + return `sha256:${crypto.createHash("sha256").update(JSON.stringify(basis)).digest("hex")}`; +} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0f410e28bb..59533622d7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -177,6 +177,7 @@ export interface ForkContextSeedOptions { signal?: AbortSignal; } +import { readdir as fsReaddir } from "node:fs/promises"; import type { AuthCredentialSelector } from "@gajae-code/ai/core"; import type { MacOSPowerAssertion } from "@gajae-code/natives"; import { @@ -300,6 +301,7 @@ import { import { assertNonEmptyGjcSessionId, modeStatePath as sessionModeStatePath, + sessionPlansDir, sessionStateDir, } from "../gjc-runtime/session-layout"; import { @@ -309,6 +311,14 @@ import { registerCoordinatorRuntimeStateFinalizer, UNPROVEN_TOOL_LABEL, } from "../gjc-runtime/session-state-sidecar"; +import { + isWorkflowRecoveryStalled, + projectRalplanFinalRun, + projectUltragoalRun, + trackWorkflowRecoveryZeroProgress, + type WorkflowRecoveryProjection, + type WorkflowRecoveryZeroProgressMemory, +} from "../gjc-runtime/workflow-recovery-projection"; import { GoalRuntime } from "../goals/runtime"; import type { Goal, GoalModeState } from "../goals/state"; import type { HindsightSessionState } from "../hindsight/state"; @@ -484,7 +494,13 @@ import { ToolChoiceQueue } from "./tool-choice-queue"; import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning"; import { YieldQueue } from "./yield-queue"; +/** + * #4560: structured workflow recovery projection from canonical durable + * Ralplan/Ultragoal state, consumed by the compaction summary context and + * the post-compaction continuation prompt. + */ interface CompactionStateSnapshot { + workflowRecovery?: WorkflowRecoveryProjection; goal: { objective: string; status: Goal["status"]; enabled: boolean } | undefined; openTodos: string[]; activeSkills: Array<{ skill: string; phase: string }>; @@ -492,6 +508,89 @@ interface CompactionStateSnapshot { lastAssistantStopReason: StopReason | undefined; } +/** + * #4560: render the structured workflow recovery projection as bounded + * compaction-context lines. Scope lines mark accepted scope and non-goals so + * post-compaction continuation reloads the accepted contract instead of + * re-deriving (and potentially expanding) it from summary prose. + */ +function renderWorkflowRecoveryContext(recovery: WorkflowRecoveryProjection): string[] { + const lines: string[] = []; + const objective = sanitizeCompactionStateText(recovery.objective, 200); + lines.push(`Workflow contract (${recovery.skill}): ${objective}`); + const accepted = recovery.scope.filter(item => item.kind === "accepted").slice(0, 8); + if (accepted.length > 0) { + lines.push(`Accepted scope: ${accepted.map(item => sanitizeCompactionStateText(item.text, 120)).join("; ")}`); + } + const nonGoals = recovery.scope.filter(item => item.kind === "non_goal").slice(0, 6); + if (nonGoals.length > 0) { + lines.push(`Non-goals: ${nonGoals.map(item => sanitizeCompactionStateText(item.text, 120)).join("; ")}`); + } + if (recovery.acceptanceCriteria.length > 0) { + lines.push( + `Acceptance criteria: ${recovery.acceptanceCriteria.map(item => sanitizeCompactionStateText(item, 120)).join("; ")}`, + ); + } + if (recovery.currentGoal) { + const goal = recovery.currentGoal; + lines.push( + `Current goal: ${sanitizeCompactionStateText(goal.goalId, 40)} status=${sanitizeCompactionStateText(goal.status, 40)} ${sanitizeCompactionStateText(goal.objective, 120)}`, + ); + } + const progress = recovery.progress; + const progressParts: string[] = []; + if (progress.totalGoals !== undefined) { + progressParts.push(`goals ${progress.completedGoals ?? 0}/${progress.totalGoals}`); + } + if (progress.outstandingGoals !== undefined) progressParts.push(`outstanding ${progress.outstandingGoals}`); + if (progress.latestCohortSourceHash) progressParts.push(`sourceHash ${progress.latestCohortSourceHash}`); + if (progressParts.length > 0) lines.push(`Progress: ${progressParts.join(", ")}`); + lines.push( + `Next action: ${recovery.nextAction.actionClass}${recovery.nextAction.goalId ? ` (${recovery.nextAction.goalId})` : ""}`, + ); + if (recovery.provenance.sha256) lines.push(`Contract digest: ${recovery.provenance.sha256}`); + return lines; +} + +/** + * #4560: post-compaction continuation for recognized active workflows. + * Returns undefined when no structured projection exists (generic + * auto-continue is preserved) or when every active workflow skill is + * continuation-inert (paused/terminal/unknown stay inert). The prompt keeps + * latest-user-intent supremacy and forbids silent scope expansion: any work + * beyond the accepted contract must be classified and recorded, never assumed. + */ +function buildWorkflowRecoveryContinuationPrompt( + recovery: WorkflowRecoveryProjection | undefined, + activeSkills: ReadonlyArray<{ skill: string; phase: string }>, +): string | undefined { + if (!recovery) return undefined; + const recognized = activeSkills.some( + entry => entry.skill === recovery.skill && !isWorkflowContinuationInert(entry.skill, entry.phase), + ); + if (!recognized) return undefined; + const lines = [ + "Compaction removed earlier conversation history. Resume the active workflow from its durable contract below — do not re-derive or expand scope from the summary.", + "", + "", + ...renderWorkflowRecoveryContext(recovery), + "", + "", + "Rules:", + "- Reload this contract before acting; the durable workflow state (.gjc session state, plans, goals, ledger receipts) is authoritative over any summary prose.", + "- Resume the stated next action class unless the user's latest message supersedes it; user intent always wins.", + "- Do not expand accepted scope. Work beyond the accepted scope/non-goals must be classified as new scope and explicitly recorded (durable blocker or steering), never silently accepted.", + "- Do not repeat already-verified review generations when the recorded source hash and evidence basis are unchanged; continue from recorded progress instead.", + "- If the same next action has already been attempted with no measurable progress (same source hash, no completed obligations, same blocker state), record a durable blocker/escalation note instead of looping.", + ]; + if (recovery.zeroProgress?.stalled) { + lines.push( + `STALLED: durable progress has not changed across ${recovery.zeroProgress.unchangedObservations + 1} compaction recoveries. Do not repeat the same next action again. Record a durable blocker or escalate to the operator now.`, + ); + } + return lines.join("\n"); +} + /** Escape XML-ish metacharacters and flatten newlines so state text cannot break compaction prompt framing. */ function sanitizeCompactionStateText(value: string, maxLength: number): string { return value @@ -6142,14 +6241,24 @@ export class AgentSession { return; } if (!(await continuationAuthorized(signal))) return; + // #4560: recognized active workflows resume from their + // durable structured contract instead of the generic + // prompt; unknown/paused/terminal workflows keep the + // generic continuation and latest-user-intent supremacy. + const recoverySnapshot = await this.#compactionStateSnapshot(); + const recoveryPrompt = buildWorkflowRecoveryContinuationPrompt( + recoverySnapshot.workflowRecovery, + recoverySnapshot.activeSkills, + ); + const promptText = recoveryPrompt ?? autoContinuePrompt; await this.#promptWithMessage( { role: "developer", - content: [{ type: "text", text: autoContinuePrompt }], + content: [{ type: "text", text: promptText }], attribution: "agent", timestamp: Date.now(), }, - autoContinuePrompt, + promptText, { skipPostPromptRecoveryWait: true, skipCompactionCheck: true, @@ -11734,11 +11843,40 @@ export class AgentSession { .filter(entry => entry.active !== false) .slice(0, 5) .map(entry => ({ skill: entry.skill, phase: entry.phase ?? "unknown" })); + this.#lastCompactionActiveSkills = snapshot.activeSkills; } catch (error) { logger.warn("Failed to read workflow state for compaction snapshot", { error: error instanceof Error ? error.message : String(error), }); } + try { + // #4560: reload the durable workflow contract for recognized + // Ralplan/Ultragoal runs so compaction carries a structured + // recovery projection instead of summary prose alone. Degrades + // safely: malformed/stale/tampered durable state leaves the + snapshot.workflowRecovery = await this.#projectWorkflowRecovery(); + // #4560: track measurable durable progress across compaction + // observations so a stalled continuation loop is detected and + // escalated instead of running forever. + if (snapshot.workflowRecovery) { + this.#workflowRecoveryMemory = trackWorkflowRecoveryZeroProgress( + this.#workflowRecoveryMemory, + snapshot.workflowRecovery, + ); + snapshot.workflowRecovery = { + ...snapshot.workflowRecovery, + zeroProgress: { + ...snapshot.workflowRecovery.zeroProgress, + unchangedObservations: this.#workflowRecoveryMemory.unchangedObservations, + stalled: isWorkflowRecoveryStalled(this.#workflowRecoveryMemory), + }, + }; + } + } catch (error) { + logger.warn("Failed to project workflow recovery state for compaction snapshot", { + error: error instanceof Error ? error.message : String(error), + }); + } try { snapshot.queuedMessages = this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0; } catch (error) { @@ -11761,6 +11899,47 @@ export class AgentSession { return snapshot; } + /** + * #4560: derive the structured workflow recovery projection for an + * active recognized workflow from its canonical durable state. Returns + * undefined for inactive/unrecognized workflows (generic behavior is + * preserved) and for malformed durable state (safe degradation). + */ + async #projectWorkflowRecovery(): Promise { + const entries = this.#lastCompactionActiveSkills ?? []; + const cwd = this.sessionManager.getCwd(); + // Ultragoal runs own the live execution contract; prefer their plan. + if (entries.some(entry => entry.skill === "ultragoal")) { + const projection = await projectUltragoalRun({ cwd, sessionId: this.sessionId }).catch(() => undefined); + if (projection) return projection; + } + if (entries.some(entry => entry.skill === "ralplan")) { + const runId = await this.#latestRalplanRunId(cwd); + if (runId) { + const projection = await projectRalplanFinalRun({ cwd, sessionId: this.sessionId, runId }).catch( + () => undefined, + ); + if (projection) return projection; + } + } + return undefined; + } + /** #4560: zero-progress memory across compaction observations. */ + #workflowRecoveryMemory: WorkflowRecoveryZeroProgressMemory | undefined; + /** #4560: active skills observed by the latest compaction snapshot. */ + #lastCompactionActiveSkills: Array<{ skill: string; phase: string }> = []; + + async #latestRalplanRunId(cwd: string): Promise { + try { + const dir = sessionPlansDir(cwd, this.sessionId); + const entries = await fsReaddir(path.join(dir, "ralplan")); + const runs = entries.filter(name => name.length > 0).sort(); + return runs.at(-1); + } catch { + return undefined; + } + } + #compactionStateContext(snapshot: CompactionStateSnapshot): string[] { const context: string[] = []; const goal = snapshot.goal; @@ -11780,6 +11959,8 @@ export class AgentSession { const todos = snapshot.openTodos.map(todo => sanitizeCompactionStateText(todo, 120)); context.push(`Open todos: ${todos.join("; ")}`); } + const recovery = snapshot.workflowRecovery; + if (recovery) context.push(...renderWorkflowRecoveryContext(recovery)); return context; } diff --git a/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts b/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts new file mode 100644 index 0000000000..ca7d463d09 --- /dev/null +++ b/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@gajae-code/agent-core"; +import * as compactionModule from "@gajae-code/agent-core/compaction"; +import type { AssistantMessage } from "@gajae-code/ai"; +import { getBundledModel } from "@gajae-code/ai/models"; +import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { loadExtensions } from "@gajae-code/coding-agent/extensibility/extensions/loader"; +import { ExtensionRunner } from "@gajae-code/coding-agent/extensibility/extensions/runner"; +import { AgentSession } from "@gajae-code/coding-agent/session/agent-session"; +import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import * as activeStateModule from "@gajae-code/coding-agent/skill-state/active-state"; +import { getProjectAgentDir, TempDir } from "@gajae-code/utils"; + +function assistantMessage(stopReason: "stop" | "length" = "stop"): AssistantMessage { + return { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason, + usage: { + input: 190000, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + } as AssistantMessage; +} + +describe("AgentSession workflow recovery continuation (#4560)", () => { + let tempDir: TempDir; + let session: AgentSession; + let sessionManager: SessionManager; + let authStorage: AuthStorage; + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-4560-continuation-"); + const extensionPath = path.join(getProjectAgentDir(tempDir.path()), "extensions", "compact.ts"); + await Bun.write(extensionPath, "export default function(pi) {}"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + const extensionsResult = await loadExtensions([extensionPath], tempDir.path()); + const extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const bundledModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!bundledModel) throw new Error("Expected built-in anthropic model"); + const agent = new Agent({ + initialState: { + model: { ...bundledModel, contextWindow: 200_000 }, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() }); + session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated({ + "compaction.autoContinue": true, + "contextPromotion.enabled": false, + "todo.reminders": false, + }), + modelRegistry, + extensionRunner, + }); + vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "compacted", + shortSummary: undefined, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); + }); + + afterEach(async () => { + await session.dispose(); + authStorage.close(); + tempDir.removeSync(); + vi.restoreAllMocks(); + }); + + async function compact(stopReason: "stop" | "length" = "stop"): Promise { + const message = assistantMessage(stopReason); + sessionManager.appendMessage(message); + session.agent.emitExternalEvent({ type: "message_end", message }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [message] }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + await session.waitForIdle(); + await Bun.sleep(25); + await session.waitForIdle(); + } + + async function seedActiveSkillState(phase: string, skill = "ultragoal"): Promise { + const { sessionPath } = activeStateModule.getSkillActiveStatePaths(tempDir.path(), session.sessionId); + await Bun.write( + sessionPath, + JSON.stringify({ + version: 1, + active_skills: [{ skill, phase, active: true, updated_at: new Date().toISOString() }], + }), + ); + } + + async function seedUltragoalPlan(): Promise { + const dir = path.join(tempDir.path(), ".gjc", `_session-${session.sessionId}`, "ultragoal"); + const now = new Date().toISOString(); + await Bun.write( + path.join(dir, "goals.json"), + JSON.stringify({ + version: 1, + brief: "b", + gjcGoalMode: "aggregate", + gjcObjective: "Ship the durable recovery contract", + goals: [ + { + id: "G001", + title: "Implement", + objective: "Implement the contract", + status: "complete", + createdAt: now, + updatedAt: now, + evidence: "focused tests pass", + }, + { + id: "G002", + title: "Verify", + objective: "Verify resumption", + status: "active", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + updatedAt: now, + }), + ); + } + + it("continues from the structured workflow contract after compaction", async () => { + await seedActiveSkillState("active"); + await seedUltragoalPlan(); + session.setGoalModeState({ + enabled: true, + mode: "active", + goal: { + id: "goal-4560", + objective: "Ship the durable recovery contract", + status: "active", + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 0, + updatedAt: 0, + }, + }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(); + await compact("length"); + expect(promptSpy).toHaveBeenCalledTimes(1); + const calls = promptSpy.mock.calls.flat(4) as unknown[]; + const text = JSON.stringify(calls); + + expect(text).toContain("workflow-recovery"); + expect(text).toContain("continue-current-goal"); + expect(text).toContain("G002"); + expect(text).toContain("Accepted scope"); + }); + + it("keeps the generic prompt when no durable workflow state exists", async () => { + await seedActiveSkillState("active"); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(); + await compact("length"); + expect(promptSpy).toHaveBeenCalledTimes(1); + const calls = promptSpy.mock.calls.flat(2) as unknown[]; + const text = JSON.stringify(calls); + expect(text).not.toContain("workflow-recovery"); + }); +}); diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts new file mode 100644 index 0000000000..dc4eb5030f --- /dev/null +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + createUltragoalPlan, + validateUltragoalQualityGateReadOnly, +} from "@gajae-code/coding-agent/gjc-runtime/ultragoal-runtime"; + +const TEST_SESSION_ID = "test-session-4560"; + +async function tempDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), "pi-ultragoal-lanes-")); +} + +/** Seed a git repo whose working-tree diff is exactly `files`. */ +async function seedGitRepo(root: string, files: Record): Promise { + const { $ } = await import("bun"); + await $`git init`.cwd(root).quiet(); + await $`git config user.email test@example.com`.cwd(root).quiet(); + await $`git config user.name test`.cwd(root).quiet(); + await $`git commit --allow-empty -m base`.cwd(root).quiet(); + for (const [file, content] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await Bun.write(path.join(root, file), content); + } +} + +function baseGate(sourceHash: string, cohortLanes: Record): Record { + return { + architectReview: { + architectureStatus: "CLEAR", + productStatus: "CLEAR", + codeStatus: "CLEAR", + recommendation: "APPROVE", + evidence: "architect synthesis across architecture/product/code", + commands: ["architect lane"], + blockers: [], + }, + executorQa: { + status: "passed", + e2eStatus: "passed", + redTeamStatus: "passed", + evidence: "executor-built e2e and red-team QA results", + e2eCommands: ["bun test:e2e"], + redTeamCommands: ["bun test:red-team"], + artifactRefs: [ + { id: "ref-1", kind: "api-package-test-report", path: "artifacts/report.json", description: "api report" }, + ], + contractCoverage: [ + { + id: "cc-1", + contractRef: "C1", + obligation: "exports work", + status: "covered", + surfaceEvidenceRefs: ["se-1"], + adversarialCaseRefs: ["ac-1"], + }, + ], + surfaceEvidence: [ + { + id: "se-1", + contractRef: "C1", + surface: "api", + invocation: "bun run probe", + verdict: "passed", + artifactRefs: ["ref-1"], + }, + ], + adversarialCases: [ + { + id: "ac-1", + contractRef: "C1", + scenario: "empty input", + expectedBehavior: "no throw", + verdict: "passed", + artifactRefs: ["ref-1"], + }, + ], + blockers: [], + }, + iteration: { + status: "passed", + evidence: "clean loop", + fullRerun: true, + rerunCommands: ["bun test:e2e"], + reviewCohort: { + reviewGeneration: 1, + sourceHash, + joined: true, + lanes: cohortLanes, + }, + blockers: [], + }, + }; +} + +function lane(sourceHash: string, status = "passed"): Record { + return { status, sourceHash, evidence: "lane evidence over the frozen source", blockers: [] }; +} + +describe("ultragoal validation lane selection gate (#4560)", () => { + let root: string; + let cleanup: string[] = []; + + beforeEach(() => { + cleanup = []; + }); + + afterEach(async () => { + for (const dir of cleanup) await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + }); + + async function seedPlan(goalCount: number, files: Record): Promise { + root = await tempDir(); + cleanup.push(root); + process.env.GJC_SESSION_ID = TEST_SESSION_ID; + await seedGitRepo(root, files); + const brief = goalCount === 1 ? "Single low-risk goal" : "@goal: A\na\n@goal: B\nb"; + await createUltragoalPlan({ cwd: root, brief }); + await Bun.write(path.join(root, "artifacts", "report.json"), JSON.stringify({ ok: true })); + } + + /** Append a prior verified complete-checkpoint cohort with `sourceHash`. */ + async function seedPriorCohort(sourceHash: string): Promise { + const ledgerPath = path.join(root, ".gjc", `_session-${TEST_SESSION_ID}`, "ultragoal", "ledger.jsonl"); + const existing = await Bun.file(ledgerPath).text(); + const event = { + eventId: "prior-1", + event: "goal_checkpointed", + goalId: "G001", + status: "complete", + evidence: "prior verified boundary", + qualityGateJson: { iteration: { reviewCohort: { reviewGeneration: 1, sourceHash, joined: true } } }, + }; + await Bun.write(ledgerPath, `${existing}${JSON.stringify(event)}\n`); + } + + it("accepts a QA-only cohort with a matching low-risk lane-selection proof", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + await seedPriorCohort("sha256:frozen1"); + const gate = baseGate("sha256:frozen1", { qa: lane("sha256:frozen1") }); + gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + }); + + it("rejects the reduced cohort when the runtime computes high risk", async () => { + await seedPlan(1, { "packages/coding-agent/src/sdk/session.ts": "export const y = 2;\n" }); + const gate = baseGate("sha256:frozen2", { qa: lane("sha256:frozen2") }); + gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.code === "reduction_not_applicable")).toBe(true); + // And the cohort validation still demands the full lanes. + expect(result.errors.some(e => e.code === "review_cohort_invalid")).toBe(true); + }); + + it("rejects a reduced cohort without any lane-selection proof", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const gate = baseGate("sha256:frozen3", { qa: lane("sha256:frozen3") }); + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.code === "review_cohort_invalid")).toBe(true); + }); + + it("rejects a selection proof that tries to omit the QA lane", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const gate = baseGate("sha256:frozen4", {}); + gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect", "qa"] }; + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.code === "qa_lane_mandatory")).toBe(true); + }); + + it("keeps the full cohort mandatory for multi-goal runs even with a declared proof", async () => { + await seedPlan(2, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const gate = baseGate("sha256:frozen5", { + cleaner: lane("sha256:frozen5", "CLEAR"), + architect: lane("sha256:frozen5", "CLEAR"), + qa: lane("sha256:frozen5"), + }); + const full = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + // Full cohort on a multi-goal run validates (structural pass expected). + expect(full.errors.filter(e => e.code === "review_cohort_invalid")).toEqual([]); + const reduced = baseGate("sha256:frozen5", { qa: lane("sha256:frozen5") }); + reduced.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; + const rejected = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(reduced), + goalId: "G001", + }); + expect(rejected.valid).toBe(false); + expect(rejected.errors.some(e => e.code === "reduction_not_applicable")).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/workflow-recovery-projection.test.ts b/packages/coding-agent/test/workflow-recovery-projection.test.ts new file mode 100644 index 0000000000..7b48695cfb --- /dev/null +++ b/packages/coding-agent/test/workflow-recovery-projection.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { + isHighRiskChangePath, + isMigrationChangePath, + resolveUltragoalValidationApplicability, +} from "@gajae-code/coding-agent/gjc-runtime/ultragoal-validation-policy"; +import { + hashWorkflowRecoveryProjection, + isWorkflowRecoveryStalled, + projectRalplanFinalRun, + projectUltragoalRun, + trackWorkflowRecoveryZeroProgress, + ZERO_PROGRESS_STALL_THRESHOLD, +} from "@gajae-code/coding-agent/gjc-runtime/workflow-recovery-projection"; +import { TempDir } from "@gajae-code/utils"; + +const SESSION_ID = "sess-4560"; + +function ralplanRunDir(cwd: string, runId: string): string { + return path.join(cwd, ".gjc", `_session-${SESSION_ID}`, "plans", "ralplan", runId); +} + +const FINAL_PLAN = `Fix widget parser performance regression. + +## Decision +Use bounded lookahead instead of full-buffer regex. + +## Accepted Scope +- parser/lookahead.ts +- parser/bench fixture + +## Non-Goals +- Rewriting the tokenizer +- CLI flag changes + +## Acceptance Criteria +- bun test parser suite passes +- P95 parse latency improves +`; + +function ultragoalDir(cwd: string): string { + return path.join(cwd, ".gjc", `_session-${SESSION_ID}`, "ultragoal"); +} + +describe("workflow recovery projection (#4560)", () => { + let tempDir: TempDir; + + beforeEach(() => { + tempDir = TempDir.createSync("@pi-4560-recovery-"); + }); + + afterEach(() => { + tempDir.removeSync(); + }); + + it("projects a ralplan final run with scope, non-goals, AC, and digest", async () => { + const runDir = ralplanRunDir(tempDir.path(), "run-1"); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ stage: "planner", stage_n: 1, path: "stage-01-planner.md", sha256: "aa" })}\n${JSON.stringify({ stage: "final", stage_n: 2, path: "stage-02-final.md", sha256: "bb" })}\n`, + ); + await Bun.write(path.join(runDir, "stage-02-final.md"), FINAL_PLAN); + const projection = await projectRalplanFinalRun({ cwd: tempDir.path(), sessionId: SESSION_ID, runId: "run-1" }); + expect(projection).toBeDefined(); + expect(projection?.skill).toBe("ralplan"); + expect(projection?.objective).toContain("widget parser"); + expect(projection?.scope.some(item => item.kind === "non_goal" && item.text.includes("tokenizer"))).toBe(true); + expect(projection?.acceptanceCriteria.some(text => text.includes("parser suite"))).toBe(true); + expect(projection?.provenance.sha256).toMatch(/^sha256:/); + expect(projection?.zeroProgress.fingerprint).toMatch(/^sha256:/); + expect(projection?.zeroProgress.stalled).toBe(false); + }); + + it("degrades safely for malformed ralplan index (no final row)", async () => { + const runDir = ralplanRunDir(tempDir.path(), "run-2"); + await Bun.write(path.join(runDir, "index.jsonl"), "not-json\n"); + const projection = await projectRalplanFinalRun({ cwd: tempDir.path(), sessionId: SESSION_ID, runId: "run-2" }); + expect(projection).toBeUndefined(); + }); + + it("degrades safely when the final artifact is missing", async () => { + const runDir = ralplanRunDir(tempDir.path(), "run-3"); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ stage: "final", stage_n: 1, path: "missing.md" })}\n`, + ); + const projection = await projectRalplanFinalRun({ cwd: tempDir.path(), sessionId: SESSION_ID, runId: "run-3" }); + expect(projection).toBeUndefined(); + }); + + it("projects ultragoal durable plan with current goal, progress, and next action", async () => { + const dir = ultragoalDir(tempDir.path()); + const now = new Date().toISOString(); + await Bun.write( + path.join(dir, "goals.json"), + JSON.stringify({ + version: 1, + brief: "b", + gjcGoalMode: "aggregate", + gjcObjective: "Ship parser fix", + goals: [ + { + id: "G001", + title: "Fix parser", + objective: "Fix the parser", + status: "complete", + createdAt: now, + updatedAt: now, + evidence: "tests pass", + }, + { + id: "G002", + title: "Docs", + objective: "Document it", + status: "active", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + updatedAt: now, + }), + ); + await Bun.write( + path.join(dir, "ledger.jsonl"), + `${JSON.stringify({ eventId: "e1", event: "goal_checkpointed", goalId: "G001", status: "complete", evidence: "done" })}\n`, + ); + const projection = await projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(projection?.skill).toBe("ultragoal"); + expect(projection?.currentGoal?.goalId).toBe("G002"); + expect(projection?.progress.totalGoals).toBe(2); + expect(projection?.progress.completedGoals).toBe(1); + expect(projection?.progress.outstandingGoals).toBe(1); + expect(projection?.nextAction.actionClass).toBe("continue-current-goal"); + expect(projection?.nextAction.goalId).toBe("G002"); + }); + + it("degrades safely for tampered ultragoal plan", async () => { + const dir = ultragoalDir(tempDir.path()); + await Bun.write(path.join(dir, "goals.json"), "{not json"); + const projection = await projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(projection).toBeUndefined(); + }); + + it("bounds zero-progress cycles by durable fingerprint", () => { + const runDirBasis = { + objective: "same", + scope: [], + acceptanceCriteria: [], + unresolved: [], + provenance: {}, + progress: { totalGoals: 2, completedGoals: 1 }, + nextAction: { actionClass: "continue-current-goal" as const }, + skill: "ultragoal" as const, + source: "ultragoal-plan" as const, + }; + const progressed = { ...runDirBasis, progress: { totalGoals: 2, completedGoals: 2 } }; + const a = { ...runDirBasis, zeroProgress: { fingerprint: "f1", unchangedObservations: 0, stalled: false } }; + const unchanged = trackWorkflowRecoveryZeroProgress( + { lastFingerprint: hashWorkflowRecoveryProjection(a), unchangedObservations: 0 }, + a, + ); + expect(unchanged.unchangedObservations).toBe(1); + const stalledMemory = trackWorkflowRecoveryZeroProgress(unchanged, a); + expect(stalledMemory.unchangedObservations).toBe(ZERO_PROGRESS_STALL_THRESHOLD); + expect(isWorkflowRecoveryStalled(stalledMemory)).toBe(true); + const recovered = trackWorkflowRecoveryZeroProgress(stalledMemory, { ...a, ...progressed } as typeof a); + expect(recovered.unchangedObservations).toBe(0); + expect(isWorkflowRecoveryStalled(recovered)).toBe(false); + }); +}); + +describe("ultragoal validation applicability policy (#4560)", () => { + const lowRiskChangeSet = { + source: "checkpoint-git" as const, + paths: [{ path: "packages/coding-agent/src/widgets/parse.ts", status: "modified" as const }], + trusted: true as const, + }; + + it("selects low risk for a single-goal trusted low-risk change set", () => { + const applicability = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + }); + expect(applicability.riskClass).toBe("low"); + expect(applicability.lanes.qa.applicable).toBe(true); + expect(applicability.lanes.cleaner.applicable).toBe(false); + expect(applicability.lanes.architect.applicable).toBe(false); + expect(applicability.lanes["terminal-critic"].applicable).toBe(false); + }); + + it("keeps the full heavyweight cohort for high-risk paths", () => { + const applicability = resolveUltragoalValidationApplicability({ + changeSet: { + ...lowRiskChangeSet, + paths: [{ path: "packages/coding-agent/src/sdk/session.ts", status: "modified" as const }], + }, + totalGoals: 1, + completedGoals: 0, + }); + expect(applicability.riskClass).toBe("high"); + expect(applicability.heavyweight).toBe(true); + expect(applicability.lanes.cleaner.applicable).toBe(true); + expect(applicability.lanes.architect.applicable).toBe(true); + expect(applicability.lanes.qa.applicable).toBe(true); + }); + + it("keeps heavyweight for computer/shared-registry and migration paths", () => { + const computer = resolveUltragoalValidationApplicability({ + changeSet: { + ...lowRiskChangeSet, + paths: [{ path: "packages/coding-agent/src/tools/index.ts", status: "modified" as const }], + }, + totalGoals: 1, + completedGoals: 0, + }); + expect(computer.riskClass).toBe("high"); + const migration = resolveUltragoalValidationApplicability({ + changeSet: { + ...lowRiskChangeSet, + paths: [ + { path: "packages/coding-agent/src/gjc-runtime/state-migrations/index.ts", status: "modified" as const }, + ], + }, + totalGoals: 1, + completedGoals: 0, + }); + expect(migration.riskClass).toBe("high"); + }); + + it("fails closed on missing/untrusted change set and multi-goal runs", () => { + const missing = resolveUltragoalValidationApplicability({ totalGoals: 1, completedGoals: 0 }); + expect(missing.riskClass).toBe("high"); + expect(missing.selection.some(line => line.includes("change-set-untrusted-or-missing"))).toBe(true); + const multi = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 3, + completedGoals: 1, + }); + expect(multi.riskClass).toBe("high"); + }); + + it("permits unchanged-basis reuse only when the frozen hash matches and no blockers reopened", () => { + const unchanged = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + latestCohortSourceHash: "sha256:abc", + currentSourceHash: "sha256:abc", + }); + expect(unchanged.basisUnchanged).toBe(true); + const changed = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + latestCohortSourceHash: "sha256:abc", + currentSourceHash: "sha256:xyz", + }); + expect(changed.basisUnchanged).toBe(false); + const blocked = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + latestCohortSourceHash: "sha256:abc", + currentSourceHash: "sha256:abc", + hasOpenReviewBlockers: true, + }); + expect(blocked.basisUnchanged).toBe(false); + expect(blocked.riskClass).toBe("high"); + }); + + it("classifies high-risk and migration paths deterministically", () => { + expect(isHighRiskChangePath({ path: "packages/coding-agent/src/sdk/protocol/x.ts", status: "modified" })).toBe( + true, + ); + expect(isHighRiskChangePath({ path: "packages/utils/src/x.ts", status: "modified" })).toBe(false); + expect(isMigrationChangePath({ path: "scripts/release.ts", status: "modified" })).toBe(true); + expect(isMigrationChangePath({ path: "packages/utils/src/x.ts", status: "modified" })).toBe(false); + }); +}); From c05de96a1eadec506f8e0780817b1db3110c81dd Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Fri, 14 Aug 2026 16:59:10 +0000 Subject: [PATCH 2/6] refactor(workflows): drop unused policy input and pick ralplan run by recency (#4560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial exact-head self-review findings: priorVerifiedGeneration was declared on the applicability input but never consumed (dead contract surface), and the ralplan recovery run selection used a lexicographic name sort, which is arbitrary for non-monotonic run ids — recovery must project the run the session actually touched last, so select by newest run-directory mtime. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 77c3e8b1 Constraint: no dead contract surface on the validation policy input Confidence: high Scope-risk: narrow Reversibility: trivial Tested: focused recovery/policy/continuation + state-aware compaction suites (37 pass); coding-agent typecheck + biome clean --- .../gjc-runtime/ultragoal-validation-policy.ts | 2 -- .../coding-agent/src/session/agent-session.ts | 17 +++++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts index 6db54564e3..574d613950 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts @@ -35,8 +35,6 @@ export interface UltragoalValidationApplicabilityInput { latestCohortSourceHash?: string; /** Current frozen source hash the boundary would review. */ currentSourceHash?: string; - /** Ledger evidence of a prior verified boundary generation. */ - priorVerifiedGeneration?: number; } export interface UltragoalValidationApplicability { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 59533622d7..9da85d4a40 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -11931,10 +11931,19 @@ export class AgentSession { async #latestRalplanRunId(cwd: string): Promise { try { - const dir = sessionPlansDir(cwd, this.sessionId); - const entries = await fsReaddir(path.join(dir, "ralplan")); - const runs = entries.filter(name => name.length > 0).sort(); - return runs.at(-1); + const dir = path.join(sessionPlansDir(cwd, this.sessionId), "ralplan"); + // Pick the most recently modified run directory, not a lexicographic + // name sort: run ids are not guaranteed monotonic, and recovery must + // project the run the session actually touched last. + const entries = await fsReaddir(dir, { withFileTypes: true }); + const runDirs = entries.filter(entry => entry.isDirectory()); + if (runDirs.length === 0) return undefined; + let newest: { id: string; mtimeMs: number } | undefined; + for (const entry of runDirs) { + const mtimeMs = (await fs.promises.stat(path.join(dir, entry.name))).mtimeMs; + if (!newest || mtimeMs > newest.mtimeMs) newest = { id: entry.name, mtimeMs }; + } + return newest?.id; } catch { return undefined; } From ea1563fdca970346e3e18a3fabb429fc2d216d53 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Sat, 15 Aug 2026 15:11:22 +0000 Subject: [PATCH 3/6] fix(workflows): bind recovery and validation to durable evidence (#4560) The reconstructed workflow refactor still trusted unconstrained plan paths, counted snapshot reads as separate compactions, and allowed self-authored cohort hashes to satisfy unchanged-basis critic reuse. It also left material intent reconciliation until after expensive Ralplan consensus. Confine and digest-check Ralplan artifacts, recover active consensus actions from durable state, bind cohort hashes to the captured source basis, classify workflow enforcement and credential surfaces as high risk, add a pre-consensus intent stage, and cover forced-compaction and blocker re-review paths. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 9e4b6c2a Constraint: QA, sourceHash, receipts, provenance, join-before-repair, recursion caps, and critic ceilings remain mandatory Constraint: workflow enforcement changes classify themselves as high risk Rejected: self-declared unchanged source hashes | cannot prove evidence freshness Rejected: post-consensus-only intent interview | spends review before resolving material scope Confidence: high Scope-risk: wide Reversibility: migration-needed Tested: 366 workflow tests plus 180 compaction tests; coding-agent typecheck; manifest/default/rebrand gates Not-tested: live multi-model latency distribution --- ...covery-and-risk-proportional-validation.md | 30 ++- packages/coding-agent/CHANGELOG.md | 5 +- .../src/defaults/gjc/skills/ralplan/SKILL.md | 13 +- .../src/gjc-runtime/ralplan-runtime.ts | 3 +- .../src/gjc-runtime/ultragoal-change-set.ts | 120 ++++++++++- .../src/gjc-runtime/ultragoal-runtime.ts | 141 ++++++------- .../ultragoal-validation-policy.ts | 32 ++- .../workflow-manifest.generated.json | 24 +++ .../src/gjc-runtime/workflow-manifest.ts | 19 +- .../workflow-recovery-projection.ts | 187 +++++++++++++++--- .../coding-agent/src/session/agent-session.ts | 52 ++--- ...ion-workflow-recovery-continuation.test.ts | 54 +++++ .../test/gjc-runtime/ralplan-runtime.test.ts | 11 +- .../ultragoal-validation-lanes.test.ts | 77 ++++++-- .../test/ralplan-decision-artifacts.test.ts | 6 + .../test/workflow-recovery-projection.test.ts | 130 +++++++++++- 16 files changed, 708 insertions(+), 196 deletions(-) diff --git a/docs/workflow-recovery-and-risk-proportional-validation.md b/docs/workflow-recovery-and-risk-proportional-validation.md index d157e28db5..5d5a816d33 100644 --- a/docs/workflow-recovery-and-risk-proportional-validation.md +++ b/docs/workflow-recovery-and-risk-proportional-validation.md @@ -1,12 +1,16 @@ # Workflow recovery and risk-proportional validation (#4560) -Long `ralplan -> ultragoal` runs can compact mid-flight. Before #4560, compaction preserved only a thin best-effort projection (active goal objective/status, workflow phase, open todos) plus a generic continuation prompt, and boundary validation applied the full review cohort unconditionally. This document describes the two mechanisms #4560 adds: a **structured workflow recovery projection** consumed by compaction, and a **deterministic validation-applicability policy** for Ultragoal boundary lanes. +Long `ralplan -> ultragoal` runs can compact mid-flight. Before #4560, compaction preserved only a thin best-effort projection (active goal objective/status, workflow phase, open todos) plus a generic continuation prompt, material intent was reconciled only after expensive consensus, and boundary validation applied the full review cohort unconditionally. This document describes the three mechanisms #4560 adds: **pre-consensus material-intent reconciliation**, a **structured workflow recovery projection** consumed by compaction, and a **deterministic validation-applicability policy** for Ultragoal boundary lanes. + +## Pre-consensus material-intent reconciliation + +Ralplan now persists an `intent` stage after the Planner artifact and before Architect/Critic review. The leader cross-checks the draft against current user constraints, relevant deep-interview specs, prior plans, and explicit non-goals. Only material decisions that can change objective, scope, acceptance criteria, architecture, or verification trigger an `ask`; a plan with no material open items proceeds without empty interview ceremony. Any material correction is incorporated into a persisted Planner `revision` before review, and the review lanes receive both the reconciled plan receipt and the `intent` receipt. The existing post-consensus interview remains as a delta gate for assumptions first introduced or exposed by review, rather than re-asking already settled decisions. ## Structured workflow recovery projection `packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts` derives a bounded projection from canonical durable state through read-only filesystem access: -- **Ralplan**: the newest complete `final` stage row of a run's `index.jsonl` resolves the persisted plan artifact; objective/scope/non-goals/acceptance criteria/unresolved decisions are parsed from its bounded `##` sections, with a `sha256` digest over the artifact bytes. +- **Ralplan**: durable mode state selects the active run. During consensus, the latest confined `planner`/`revision` artifact supplies objective/scope/non-goals/acceptance criteria and the exact next action (`run-plan-review`, `revise-plan`, or `reconcile-intent`); after finalization the `final` artifact yields `awaiting-approval`. Legacy discovery orders runs by `index.jsonl` freshness and skips unfinished/malformed candidates. Artifact paths are realpath-confined to the run directory and their recorded SHA-256 must match the bytes read. - **Ultragoal**: `goals.json` + `ledger.jsonl` produce the aggregate objective, per-goal accepted scope, completed-goal acceptance evidence, the current goal (active/failed or first schedulable), measurable progress counters (total/completed/outstanding goals, latest joined cohort generation + frozen `sourceHash`, newest ledger event id), and the exact next action class (`continue-current-goal`, `start-next-goal`, `resolve-review-blockers`, `final-aggregate-checkpoint`, ...). Safety properties: @@ -24,7 +28,7 @@ Safety properties: ### Bounded zero-progress cycles -Each compaction observation fingerprints the projection's contract-relevant fields (`hashWorkflowRecoveryProjection`). `trackWorkflowRecoveryZeroProgress` counts consecutive observations with an unchanged fingerprint; at `ZERO_PROGRESS_STALL_THRESHOLD` (2) the continuation prompt carries an explicit `STALLED` directive ordering a durable blocker/escalation instead of repeating the same next action. Any measurable durable progress (completed obligations, changed blocker disposition, changed source hash, goal status change) resets the counter. This bounds — but does not claim to eliminate — post-compaction continuation loops. +Each actual compaction fingerprints the projection's contract-relevant fields (`hashWorkflowRecoveryProjection`). Snapshot reads performed by authorization and prompt assembly reuse the same counter state and do not increment it. `trackWorkflowRecoveryZeroProgress` counts consecutive compactions with an unchanged fingerprint; at `ZERO_PROGRESS_STALL_THRESHOLD` (2) the third unchanged recovery carries an explicit `STALLED` directive ordering a durable blocker/escalation instead of repeating the same next action. Any measurable durable progress (completed obligations, changed blocker disposition, changed source hash, goal status change) resets the counter. This bounds — but does not claim to eliminate — post-compaction continuation loops. ## Ultragoal validation-applicability policy @@ -32,10 +36,10 @@ Each compaction observation fingerprints the projection's contract-relevant fiel Low-risk eligibility (the only case where redundant lanes may be omitted) requires **all** of: -- a trusted, completely captured change set, +- a trusted, completely captured change set with a runtime-computed source-basis digest, - exactly one outstanding goal, - no open review blockers, -- no high-risk path (auth/security, native crates, SDK/extensibility public contract, agent-wire protocol, shared behavior registries), migration path, or computer-control-surface path. +- no high-risk path (workflow enforcement itself, auth/security, native crates, SDK/extensibility public contract, agent-wire protocol, shared behavior registries), migration path, or computer-control-surface path. Everything else — including a missing or untrusted change set — is high risk and keeps the full heavyweight cohort (`cleaner || architect || qa`, join-before-repair, terminal critic). @@ -47,7 +51,21 @@ Omission mechanics: ### Unchanged-basis rerun avoidance -`basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash equals the current frozen cohort source hash under review and no review blockers reopened. A changed source, a review fix, an integration-base change, incomplete capture, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. +`basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash and the gate's current cohort hash both equal the runtime-computed digest of the authoritative change-set basis, and no review blockers reopened. The digest binds integration base, merge base, normalized path/status rows, and the captured diff. A changed source, a review fix, an integration-base change, untracked or incompletely captured content, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. + +## Comparative and forced-compaction evidence + +The regression matrix is deterministic evidence, not a claim of identical model outputs: + +| Scenario | Baseline risk | Candidate assertion | +|---|---|---| +| Low-risk single-goal boundary | Unconditional cleaner + architect + terminal critic duplicated already-joined evidence | Runtime-authenticated lane selection may omit cleaner/architect; QA and source binding remain mandatory; critic omission additionally requires the unchanged authoritative digest | +| Multi-goal, workflow-enforcement, auth, migration, SDK/public-contract, native, computer/shared-registry, incomplete capture | A reduction could hide defects | Classified high-risk and retains the complete cohort plus terminal critic | +| Forced compaction during Ralplan review | Generic prose could lose the reviewed plan and next review action | Active run, confined plan artifact, digest, accepted/non-goal scope, and `run-plan-review`/`revise-plan`/`reconcile-intent` are restored | +| Forced compaction during Ultragoal execution and parallel executor work | Current goal and completed work could be reconstructed from stale conversation memory | Canonical goals/ledger restore current goal, completion counters, joined cohort generation/hash, and the next bounded action | +| Forced compaction during boundary review or blocker-fix re-review | Duplicate generations or scope drift | Joined cohort evidence and `resolve-review-blockers` survive compaction; zero-progress escalation counts actual compactions only | + +The focused suites covering this matrix are `workflow-recovery-projection.test.ts`, `agent-session-workflow-recovery-continuation.test.ts`, and `ultragoal-validation-lanes.test.ts`. Broader compaction, Ralplan runtime, Ultragoal runtime/review/critic, type, and visible-definition gates remain the merge boundary. ## Guarantees preserved diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b348418a1a..6e327b90b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,11 +38,12 @@ ### Added -- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and the joined cohort source hash. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. High-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, and untrusted changes retain the full cohort. +- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and a runtime-computed source-basis digest. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. Workflow-enforcement changes classify themselves as high-risk, and high-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, untracked, and untrusted changes retain the full cohort. Terminal-critic reuse additionally requires the current and prior cohort hashes to match the authoritative digest. ### Changed -- Compaction recovery for active Ralplan and Ultragoal runs now reloads a bounded structured projection from canonical durable workflow state instead of relying on summary prose alone (#4560). The projection carries objective, accepted scope, non-goals, acceptance criteria, unresolved decisions, current goal, measurable progress, exact next action, and provenance. Continuation enforces scope reload, latest-user-intent precedence, explicit classification of scope expansion, unchanged-basis rerun avoidance, and bounded zero-progress escalation; malformed, stale, tampered, paused, terminal, and unrecognized state safely retains the existing generic behavior. +- Ralplan now performs a persisted material-intent reconciliation stage before Architect/Critic consensus (#4560), asking only about unresolved decisions that can change objective, scope, non-goals, acceptance criteria, architecture, or verification. Material corrections revise the Planner artifact before review; the post-consensus interview is retained as a delta gate for assumptions introduced by review. +- Compaction recovery for active Ralplan and Ultragoal runs now reloads a bounded structured projection from canonical durable workflow state instead of relying on summary prose alone (#4560). Active Ralplan consensus restores its confined, digest-verified Planner/revision artifact and exact review/revision/reconciliation action; Ultragoal restores goals, blockers, joined cohort evidence, and next action. Continuation enforces scope reload, latest-user-intent precedence, explicit classification of scope expansion, unchanged-basis rerun avoidance, and zero-progress escalation counted once per actual compaction; malformed, stale, tampered, paused, terminal, and unrecognized state safely retains the existing generic behavior. - Managed fallback local buffer overflows (`local_buffer_overflow`) now surface immediately with the original local diagnostic instead of entering the bounded `unknown` retry class: re-streaming the same request reproduces the same oversized response, and a local staging failure must never charge or advance the provider fallback chain, emit `model_fallback_switched`, or rotate credentials. diff --git a/packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md b/packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md index 36c9db8f64..9b992a29bf 100644 --- a/packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md +++ b/packages/coding-agent/src/defaults/gjc/skills/ralplan/SKILL.md @@ -46,7 +46,7 @@ gjc ralplan --write --session-id --run-id --stage --run-id --stage --stage_n --artifact-env GJC_RALPLAN_ARTIFACT ``` -Use stages `planner`, `architect`, `critic`, `disposition`, `revision`, `post-interview`, `adr`, or `final`; increment `--stage_n` each consensus pass. The writer accepts inline markdown (or JSON for `disposition`), an artifact path prepared outside `.gjc/`, or `--artifact-env GJC_RALPLAN_ARTIFACT`, persists `stage--.md` plus `index.jsonl` under `.gjc/_session-{sessionid}/plans/ralplan//`, and copies `final` to `pending-approval.md`. Ralplan mutation blocking is enforced in code; use temp directories (`os.tmpdir()`/`$TMPDIR`, `/tmp`, `/var/tmp`) only for oversized scratch artifacts, never the repo or `.gjc/`. Staging via the `write` tool or a quoted-delimiter bash heredoc (`cat > /tmp/plan.md <<'EOF' … EOF`) into those temp roots is tolerated by the planning-phase guard. +Use stages `planner`, `intent`, `architect`, `critic`, `disposition`, `revision`, `post-interview`, `adr`, or `final`; increment `--stage_n` each consensus pass. The writer accepts inline markdown (or JSON for `disposition`), an artifact path prepared outside `.gjc/`, or `--artifact-env GJC_RALPLAN_ARTIFACT`, persists `stage--.md` plus `index.jsonl` under `.gjc/_session-{sessionid}/plans/ralplan//`, and copies `final` to `pending-approval.md`. Ralplan mutation blocking is enforced in code; use temp directories (`os.tmpdir()`/`$TMPDIR`, `/tmp`, `/var/tmp`) only for oversized scratch artifacts, never the repo or `.gjc/`. Staging via the `write` tool or a quoted-delimiter bash heredoc (`cat > /tmp/plan.md <<'EOF' … EOF`) into those temp roots is tolerated by the planning-phase guard. Restricted read-only role agents (`planner`, `architect`, `critic`) must pass markdown through `GJC_RALPLAN_ARTIFACT` with `--artifact-env GJC_RALPLAN_ARTIFACT`; their restricted bash environment disables artifact file-path ingestion. @@ -64,7 +64,12 @@ The consensus workflow: - Viable Options (>=2) with bounded pros/cons - If only one viable option remains, explicit invalidation rationale for alternatives - Deliberate mode only: pre-mortem (3 scenarios) + expanded test plan (unit/integration/e2e/observability) -2. **User feedback** *(--interactive only)*: If `--interactive` is set, use the `ask` tool to present the draft plan **plus the Principles / Drivers / Options summary** before review (Proceed to review / Request changes / Skip review). Otherwise, automatically proceed to review. +2. **Pre-consensus material-intent reconciliation** *(always before Architect/Critic)*: Reconcile material scope and intent before paying for consensus review. This is a bounded contract check, not a second planning loop. + a. Read the persisted Planner artifact plus relevant `.gjc/_session-{sessionid}/specs/deep-interview-*.md`, prior plans, and current user constraints. Extract only material unresolved decisions, assumptions that could change architecture/scope/acceptance criteria, and conflicts with an explicit prior non-goal. Cosmetic wording and implementation details that do not alter the contract are not material. + b. When material open items exist, use the `ask` tool one at a time, highest-impact first, with concrete options. When none exist, proceed without an empty ceremony or user prompt. + c. Persist the check with `gjc ralplan --write --stage intent --stage_n --artifact-env GJC_RALPLAN_ARTIFACT --json`. The artifact must list evidence inspected, resolved material decisions, retained non-goals, and either `material-open-items: none` or the still-open items. + d. If reconciliation changes objective, scope, non-goals, acceptance criteria, or verification obligations, resume the persisted Planner and persist a `revision` before review. Architect and Critic receive the reconciled Planner/revision receipt plus the `intent` receipt; they never review the superseded pre-reconciliation draft. + e. With `--interactive`, also present the reconciled draft plus Principles / Drivers / Options summary (Proceed to review / Request changes / Skip review). Without `--interactive`, proceed automatically once material intent is resolved. 3. **Review fan-out after Planner persistence**: launch the Architect and Critic ONCE per run as detached, resumable review lanes against the same immutable Planner receipt/path/sha/stage_n. Their pass-1 fan-out remains parallel when Critic is **plan-only** and does not consume Architect output (see **Persisted role agents** below). - **Architect lane**: challenge architecture, surface tradeoff tensions, and enrich thin plans with synthesis or missed sub-scope. Persist with `gjc ralplan --write --stage architect --stage_n --artifact-env GJC_RALPLAN_ARTIFACT --architect-id --architect-resumable --lane-verdict --json`, then return receipt/path plus `CLEAR`/`WATCH`/`BLOCK` and `APPROVE`/`COMMENT`/`REQUEST CHANGES`. - **Plan-only Critic lane**: independently check quality, principle-option consistency, alternatives, risks, acceptance criteria, and verification; when the plan is thin, request concrete expansion rather than only defects. Persist with `gjc ralplan --write --stage critic --stage_n --artifact-env GJC_RALPLAN_ARTIFACT --critic-id --critic-resumable --lane-verdict --json`, then return receipt/path plus `OKAY`/`ITERATE`/`REJECT`. @@ -92,8 +97,8 @@ The consensus workflow: f. Repeat this loop until Critic returns `OKAY` **and** Architect is `CLEAR`/`APPROVE` for the same Planner artifact/pass, or 5 iterations are reached g. If 5 iterations are reached without Critic `OKAY` plus Architect `CLEAR`/`APPROVE`, **stop opening further planner/revision passes**. Preserve the best version as a terminal `PLANNING-STUCK` result; do not route it to automatic or explicit execution. h. **Runtime budget (#3165):** native `gjc ralplan --write` refuses a new `planner`/`revision` that would open consensus iteration **> max** (default **5**, overridable via `gjc.ralplan.maxIterations`, integer 1..20, using the workflow-settings precedence below). Cap uses the same iteration definition as the HUD (`planner`/`revision` openers in `index.jsonl`). Overflow exits **3**, prints operator-visible **`PLANNING-STUCK`** on stdout (and stderr detail; JSON includes `planning_stuck: true`), and still allows `architect`/`critic` within an already-opened pass plus `post-interview`/`adr`/`final` so the best plan can be escalated to `pending approval` without dispatch. A new `--run-id` starts a fresh budget. -6. **Post-ralplan interview** (intent reconciliation gate): After the review join gate has both Critic `OKAY` and Architect `CLEAR`/`APPROVE` for the same Planner artifact/pass, and before the plan is finalized, reconcile the consensus plan against the user's actual intent. The goal is to make sure ralplan did not silently bake in assumptions that conflict with what the user wants. - a. **Collect open items** from the run: every assumption the Planner/Architect/Critic resolved by assumption rather than by stated fact, every ambiguity flagged during review, and every decision the loop made without explicit user input. Source these from the persisted `planner`/`architect`/`critic`/`revision` stage artifacts, not from memory. +6. **Final intent verification** (post-consensus delta gate): After the review join gate has both Critic `OKAY` and Architect `CLEAR`/`APPROVE` for the same Planner artifact/pass, verify only intent deltas introduced or newly exposed by consensus. The pre-consensus `intent` receipt is the baseline; do not re-ask decisions already settled there. + a. **Collect new open items** from the run: assumptions or conflicts introduced after the latest `intent` receipt, plus any material ambiguity first exposed by Architect/Critic. Source these from persisted artifacts, not memory. b. **Cross-check prior context for conflicts**: glob `.gjc/_session-{sessionid}/specs/deep-interview-*.md` and other prior specs/plans/context relevant by topic. For each, list points where the consensus plan contradicts, weakens, or expands beyond a previously crystallized decision, constraint, or non-goal. Cite the conflicting artifact and line/section. c. **Reconcile with the user via the `ask` tool (always, regardless of `--interactive`)**: Never stop idle with plain-text prose after the consensus loop. Every reconciliation question MUST go through the `ask` tool with contextual options plus free-text. - If open items exist, confirm the open assumptions and conflicts **one at a time** with the `ask` tool, weakest/highest-impact first, polishing intent. If any confirmation reveals that the plan diverges from user intent, route the consolidated correction back into the re-review loop (step 5b Planner revision) and re-run Architect + Critic before returning here. Cap at the same 5-iteration ceiling. diff --git a/packages/coding-agent/src/gjc-runtime/ralplan-runtime.ts b/packages/coding-agent/src/gjc-runtime/ralplan-runtime.ts index 8ecaa1e203..3edb5f2c4e 100644 --- a/packages/coding-agent/src/gjc-runtime/ralplan-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/ralplan-runtime.ts @@ -63,7 +63,7 @@ import { * 2. **Artifact write**: `gjc ralplan --write --stage --stage_n * (--artifact | --artifact-env GJC_RALPLAN_ARTIFACT) * [--run-id ] [--session-id ] [--lane-verdict ] [--json]` persists Planner / Architect - * / Critic / disposition / revision / post-interview / ADR / final artifacts under + * / Critic / intent / disposition / revision / post-interview / ADR / final artifacts under * `.gjc/plans/ralplan//`, maintains an `index.jsonl` audit log, copies `final` * stages to `pending-approval.md`, and advances the HUD chip to reflect the latest * persisted stage. Disposition stage artifacts are fail-closed JSON documents that @@ -78,6 +78,7 @@ export interface RalplanCommandResult { const KNOWN_STAGES = [ "planner", + "intent", "architect", "critic", "disposition", diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts index 06f7fdb536..343d50c412 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts @@ -1,10 +1,105 @@ -import { - categorizeComputerChangePath, - normalizeChangeSetPath, - type UltragoalChangeSet, - type UltragoalChangeSetPath, - type UltragoalChangeStatus, -} from "./ultragoal-runtime"; +import * as crypto from "node:crypto"; +import * as path from "node:path"; + +export type UltragoalChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown"; +export type UltragoalChangeCategory = + | "code" + | "generated-binding" + | "tool" + | "settings-registry" + | "prompt-doc-behavior" + | "docs-static" + | "other"; + +export interface UltragoalChangeSetPath { + path: string; + status: UltragoalChangeStatus; + oldPath?: string; + category?: UltragoalChangeCategory; + [key: string]: unknown; +} + +export interface UltragoalChangeSet { + source: "checkpoint-git" | "review-pr" | "review-branch" | "review-worktree" | "review-spec"; + baseRef?: string; + headRef?: string; + mergeBase?: string; + paths: UltragoalChangeSetPath[]; + rawDiffStat?: string; + rawDiff?: string; + untrackedContentHash?: string; + captureIncomplete?: boolean; + trusted: true; + [key: string]: unknown; +} + +export function normalizeRepoPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +export function normalizeChangeSetPath(value: string): string { + return value.replace(/^\.\//, ""); +} + +export function categorizeComputerChangePath(pathValue: string): UltragoalChangeCategory { + const normalized = normalizeRepoPath(pathValue); + if (normalized.startsWith("crates/pi-natives/src/computer/")) return "code"; + if (/^packages\/natives\/native\/index\.(?:d\.ts|js)$/.test(normalized)) return "generated-binding"; + if ( + normalized === "packages/coding-agent/src/tools/computer.ts" || + normalized.startsWith("packages/coding-agent/src/tools/computer/") + ) + return "tool"; + if ( + normalized === "packages/coding-agent/src/config/settings-schema.ts" || + normalized === "packages/coding-agent/src/tools/index.ts" || + normalized === "packages/coding-agent/src/tools/renderers.ts" + ) + return "settings-registry"; + if ( + normalized === "packages/coding-agent/src/prompts/tools/computer.md" || + normalized === "packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md" || + normalized === "packages/coding-agent/src/prompts/agents/executor.md" + ) + return "prompt-doc-behavior"; + if (normalized === "docs/tools/computer.md" || normalized === "docs/computer-use/README.md") return "docs-static"; + return "other"; +} + +export function computeUltragoalReviewSourceHash(changeSet: UltragoalChangeSet | undefined): string | undefined { + if (!changeSet?.trusted || changeSet.captureIncomplete || changeSet.rawDiff === undefined) return undefined; + if (changeSet.paths.some(row => row.status === "unknown")) return undefined; + const basis = { + source: changeSet.source, + baseRef: changeSet.baseRef, + headRef: changeSet.headRef, + mergeBase: changeSet.mergeBase, + paths: changeSet.paths.map(row => ({ + path: normalizeRepoPath(row.path), + status: row.status, + oldPath: row.oldPath ? normalizeRepoPath(row.oldPath) : undefined, + })), + rawDiff: changeSet.rawDiff, + untrackedContentHash: changeSet.untrackedContentHash, + }; + return `sha256:${crypto.createHash("sha256").update(JSON.stringify(basis)).digest("hex")}`; +} + +async function hashUntrackedFiles(cwd: string, paths: readonly UltragoalChangeSetPath[]): Promise { + if (paths.length === 0) return undefined; + try { + const hasher = crypto.createHash("sha256"); + for (const row of [...paths].sort((left, right) => left.path.localeCompare(right.path))) { + hasher.update(row.path); + hasher.update("\0"); + hasher.update(Buffer.from(await Bun.file(path.join(cwd, row.path)).arrayBuffer())); + hasher.update("\0"); + } + return `sha256:${hasher.digest("hex")}`; + } catch { + return undefined; + } +} export async function spawnText( command: string[], @@ -177,11 +272,13 @@ export async function computeCheckpointChangeSet(cwd: string): Promise 0 && !untrackedContentHash), trusted: true, }; } diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts index bb02a54f6a..83332f7dd5 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts @@ -62,12 +62,48 @@ import { writeArtifact, writeGuardedJsonAtomic, } from "./state-writer"; +import { + categorizeComputerChangePath, + ciDevChangedPathRows, + computeCheckpointChangeSet, + computeUltragoalReviewSourceHash, + mergeChangeSetPaths, + normalizeChangeSetPath, + parseGitNameStatus, + parseGitUntrackedPaths, + parseUnifiedDiffPaths, + resolveGitBase, + spawnText, + type UltragoalChangeCategory, + type UltragoalChangeSet, + type UltragoalChangeSetPath, + type UltragoalChangeStatus, +} from "./ultragoal-change-set"; import { resolveUltragoalValidationApplicability, type UltragoalValidationApplicability, } from "./ultragoal-validation-policy"; import { resolveWorkflowSetting } from "./workflow-settings"; +export { + categorizeComputerChangePath, + ciDevChangedPathRows, + computeCheckpointChangeSet, + computeUltragoalReviewSourceHash, + mergeChangeSetPaths, + normalizeChangeSetPath, + normalizeRepoPath, + parseGitNameStatus, + parseGitUntrackedPaths, + parseUnifiedDiffPaths, + resolveGitBase, + spawnText, + type UltragoalChangeCategory, + type UltragoalChangeSet, + type UltragoalChangeSetPath, + type UltragoalChangeStatus, +} from "./ultragoal-change-set"; + export { captureUltragoalRecoverySnapshot, parseStrictTerminalTranscript, @@ -1530,33 +1566,6 @@ function formatExpectedKindWords(words: string[]): string { export type SurfaceFamily = "web" | "cli" | "native" | "api-package" | "algorithm-math" | "unknown"; -export type UltragoalChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown"; -export type UltragoalChangeCategory = - | "code" - | "generated-binding" - | "tool" - | "settings-registry" - | "prompt-doc-behavior" - | "docs-static" - | "other"; -export interface UltragoalChangeSetPath extends JsonObject { - path: string; - status: UltragoalChangeStatus; - oldPath?: string; - category?: UltragoalChangeCategory; -} -export interface UltragoalChangeSet extends JsonObject { - source: "checkpoint-git" | "review-pr" | "review-branch" | "review-worktree" | "review-spec"; - baseRef?: string; - headRef?: string; - mergeBase?: string; - paths: UltragoalChangeSetPath[]; - rawDiffStat?: string; - rawDiff?: string; - captureIncomplete?: boolean; - trusted: true; -} - const MANDATORY_COMPUTER_CASE_IDS = [ "kill-switch-bypass", "suspended-enforcement", @@ -1566,40 +1575,6 @@ const MANDATORY_COMPUTER_CASE_IDS = [ "runaway-loop-halt", "blast-radius", ] as const; -const TOOLS_INDEX_PATH = "packages/coding-agent/src/tools/index.ts"; - -export function normalizeRepoPath(value: string): string { - return value.replaceAll("\\\\", "/").replace(/^\.\//, ""); -} - -export function normalizeChangeSetPath(value: string): string { - return value.replace(/^\.\//, ""); -} - -export function categorizeComputerChangePath(value: string): UltragoalChangeCategory { - const normalized = normalizeRepoPath(value); - if (normalized.startsWith("crates/pi-natives/src/computer/")) return "code"; - if (/^packages\/natives\/native\/index\.(?:d\.ts|js)$/.test(normalized)) return "generated-binding"; - if ( - normalized === "packages/coding-agent/src/tools/computer.ts" || - normalized.startsWith("packages/coding-agent/src/tools/computer/") - ) - return "tool"; - if ( - normalized === TOOLS_INDEX_PATH || - normalized === "packages/coding-agent/src/tools/renderers.ts" || - normalized === "packages/coding-agent/src/config/settings-schema.ts" - ) - return "settings-registry"; - if ( - normalized === "packages/coding-agent/src/prompts/tools/computer.md" || - normalized === "packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md" || - normalized === "packages/coding-agent/src/prompts/agents/executor.md" - ) - return "prompt-doc-behavior"; - if (normalized === "docs/tools/computer.md" || normalized === "docs/computer-use/README.md") return "docs-static"; - return "other"; -} function isComputerControlSurfaceCategory(category: UltragoalChangeCategory): boolean { // Shared behavior registries are intentionally conservative: a path-only or @@ -2552,6 +2527,14 @@ function validateDeclaredValidationLaneSelection( ); return false; } + if (declared.reasons.length !== 0) { + found.add( + "validationLaneSelection.reasons", + "reasons_mismatch", + "declared validationLaneSelection reasons must exactly mirror the runtime-computed low-risk reason set", + ); + return false; + } const expectedOmitted = (["cleaner", "architect"] as const).filter(lane => !applicability.lanes[lane].applicable); const declaredOmitted = new Set(declared.omittedLanes); if (declaredOmitted.has("qa")) { @@ -2775,6 +2758,14 @@ async function validateCompletionQualityGate( // frozen source under review is the gate's own cohort sourceHash. const gateIteration = qualityGateObject(gate.iteration); const gateCohortSourceHash = nonEmptyString(qualityGateObject(gateIteration?.reviewCohort)?.sourceHash); + const authoritativeSourceHash = computeUltragoalReviewSourceHash(options.changeSet); + if (gateCohortSourceHash && authoritativeSourceHash && gateCohortSourceHash !== authoritativeSourceHash) { + found.add( + "iteration.reviewCohort.sourceHash", + "source_hash_mismatch", + "iteration.reviewCohort.sourceHash must equal the runtime-computed digest of the authoritative current source basis", + ); + } const applicability = resolveUltragoalValidationApplicability({ changeSet: options.changeSet, totalGoals: options.plan?.goals.length, @@ -2782,8 +2773,12 @@ async function validateCompletionQualityGate( hasOpenReviewBlockers: options.plan?.goals.some(goal => goal.status === "review_blocked") ?? false, latestCohortSourceHash: options.ledger ? latestJoinedCohortSourceHash(options.ledger)?.sourceHash : undefined, currentSourceHash: gateCohortSourceHash ?? undefined, + authoritativeSourceHash, + }); + let laneSelection: DeclaredValidationLaneSelection | undefined; + found.check("validationLaneSelection", "selection_invalid", () => { + laneSelection = readDeclaredValidationLaneSelection(gate); }); - const laneSelection = readDeclaredValidationLaneSelection(gate); const lowRiskReduced = laneSelection !== undefined && validateDeclaredValidationLaneSelection(laneSelection, applicability, found); const architectReview = qualityGateObject(gate.architectReview); @@ -4354,28 +4349,6 @@ async function readOptionalExecutorQa(cwd: string, value: string | undefined): P return structured as JsonObject; } -import { - ciDevChangedPathRows, - computeCheckpointChangeSet, - mergeChangeSetPaths, - parseGitNameStatus, - parseGitUntrackedPaths, - parseUnifiedDiffPaths, - resolveGitBase, - spawnText, -} from "./ultragoal-change-set"; - -export { - ciDevChangedPathRows, - computeCheckpointChangeSet, - mergeChangeSetPaths, - parseGitNameStatus, - parseGitUntrackedPaths, - parseUnifiedDiffPaths, - resolveGitBase, - spawnText, -}; - function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | undefined { const kind = nonEmptyString(source.kind); if (kind === "spec") { diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts index 574d613950..4aff201c7f 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts @@ -17,9 +17,10 @@ */ import { categorizeComputerChangePath, + normalizeRepoPath, type UltragoalChangeSet, type UltragoalChangeSetPath, -} from "./ultragoal-runtime"; +} from "./ultragoal-change-set"; export type UltragoalValidationLane = "cleaner" | "architect" | "qa" | "terminal-critic"; @@ -35,6 +36,8 @@ export interface UltragoalValidationApplicabilityInput { latestCohortSourceHash?: string; /** Current frozen source hash the boundary would review. */ currentSourceHash?: string; + /** Runtime-computed digest of the authoritative current source basis. */ + authoritativeSourceHash?: string; } export interface UltragoalValidationApplicability { @@ -54,10 +57,17 @@ export interface UltragoalValidationApplicability { const HIGH_RISK_PATH_PREFIXES = [ // Security/auth surfaces - "packages/coding-agent/src/session/auth", - "packages/coding-agent/src/auth", + "packages/coding-agent/src/session/auth-storage.ts", + "packages/coding-agent/src/session/secure-token-file.ts", + "packages/coding-agent/src/session/startup-auth-config.ts", + "packages/coding-agent/src/runtime-api-key.ts", + "packages/coding-agent/src/runtime-credential-selector.ts", + "packages/coding-agent/src/secrets", "crates/pi-natives/src", "crates/git-daemon", + // Workflow enforcement surfaces must never grade their own weakening as low-risk. + "packages/coding-agent/src/gjc-runtime", + "packages/coding-agent/src/session/agent-session.ts", // Public contract / SDK surfaces "packages/coding-agent/src/sdk", "packages/coding-agent/src/extensibility", @@ -69,8 +79,9 @@ const HIGH_RISK_PATH_PREFIXES = [ ] as const; const MIGRATION_PATH_PREFIXES = [ - "packages/coding-agent/src/gjc-runtime/state-migrations", - "packages/coding-agent/src/session/session-manager", + "packages/coding-agent/src/gjc-runtime/state-migrations.ts", + "packages/coding-agent/src/session/session-manager.ts", + "packages/coding-agent/src/session/session-manager-internal.ts", "scripts", ] as const; @@ -79,7 +90,9 @@ function changePaths(changeSet: UltragoalChangeSet | undefined): UltragoalChange } export function isHighRiskChangePath(row: UltragoalChangeSetPath): boolean { - const candidates = [row.path, row.oldPath].filter((value): value is string => typeof value === "string"); + const candidates = [row.path, row.oldPath] + .filter((value): value is string => typeof value === "string") + .map(normalizeRepoPath); for (const candidate of candidates) { for (const prefix of HIGH_RISK_PATH_PREFIXES) { if (candidate === prefix || candidate.startsWith(`${prefix}/`)) return true; @@ -89,7 +102,9 @@ export function isHighRiskChangePath(row: UltragoalChangeSetPath): boolean { } export function isMigrationChangePath(row: UltragoalChangeSetPath): boolean { - const candidates = [row.path, row.oldPath].filter((value): value is string => typeof value === "string"); + const candidates = [row.path, row.oldPath] + .filter((value): value is string => typeof value === "string") + .map(normalizeRepoPath); for (const candidate of candidates) { for (const prefix of MIGRATION_PATH_PREFIXES) { if (candidate === prefix || candidate.startsWith(`${prefix}/`)) return true; @@ -127,6 +142,7 @@ export function resolveUltragoalValidationApplicability( const reasons: string[] = []; if (!input.changeSet?.trusted) reasons.push("change-set-untrusted-or-missing"); if (input.changeSet?.captureIncomplete) reasons.push("capture-incomplete"); + if (!input.authoritativeSourceHash) reasons.push("source-basis-unverified"); if (multiGoal) reasons.push("multiple-outstanding-goals"); if (input.hasOpenReviewBlockers) reasons.push("open-review-blockers"); if (highRiskPaths.length > 0) reasons.push("high-risk-paths"); @@ -144,7 +160,9 @@ export function resolveUltragoalValidationApplicability( // frozen source hash this boundary would review, and no blockers reopened. const basisUnchanged = Boolean(input.latestCohortSourceHash) && + Boolean(input.authoritativeSourceHash) && input.currentSourceHash === input.latestCohortSourceHash && + input.currentSourceHash === input.authoritativeSourceHash && !input.hasOpenReviewBlockers; const lanes: UltragoalValidationApplicability["lanes"] = { cleaner: lane(heavyweight, heavyweight ? reasons : ["low-risk-single-goal"]), diff --git a/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json b/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json index 71c1dd384f..230c284596 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json +++ b/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json @@ -806,6 +806,9 @@ "id": "planner", "initial": true }, + { + "id": "intent" + }, { "id": "architect" }, @@ -848,9 +851,19 @@ "transitions": [ { "from": "planner", + "to": "intent", + "verb": "write-artifact" + }, + { + "from": "intent", "to": "architect", "verb": "write-artifact" }, + { + "from": "intent", + "to": "revision", + "verb": "write-artifact" + }, { "from": "architect", "to": "critic", @@ -876,6 +889,11 @@ "to": "revision", "verb": "write-artifact" }, + { + "from": "revision", + "to": "intent", + "verb": "write-artifact" + }, { "from": "revision", "to": "post-interview", @@ -916,6 +934,11 @@ "to": "handoff", "verb": "handoff" }, + { + "from": "intent", + "to": "handoff", + "verb": "handoff" + }, { "from": "architect", "to": "handoff", @@ -1099,6 +1122,7 @@ ], "enumValues": [ "planner", + "intent", "architect", "critic", "disposition", diff --git a/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts b/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts index 971a60c120..a38f93120c 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts +++ b/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts @@ -216,6 +216,7 @@ export const WORKFLOW_MANIFEST: Record skill: "ralplan", states: [ "planner", + "intent", "architect", "critic", "disposition", @@ -227,12 +228,15 @@ export const WORKFLOW_MANIFEST: Record ], terminalStates: ["final", "handoff"], transitions: [ - { from: "planner", to: "architect", verb: "write-artifact" }, + { from: "planner", to: "intent", verb: "write-artifact" }, + { from: "intent", to: "architect", verb: "write-artifact" }, + { from: "intent", to: "revision", verb: "write-artifact" }, { from: "architect", to: "critic", verb: "write-artifact" }, { from: "critic", to: "disposition", verb: "write-artifact" }, { from: "architect", to: "disposition", verb: "write-artifact" }, { from: "disposition", to: "revision", verb: "write-artifact" }, { from: "critic", to: "revision", verb: "write-artifact" }, + { from: "revision", to: "intent", verb: "write-artifact" }, { from: "revision", to: "post-interview", verb: "write-artifact" }, { from: "critic", to: "post-interview", verb: "write-artifact" }, { from: "disposition", to: "post-interview", verb: "write-artifact" }, @@ -241,6 +245,7 @@ export const WORKFLOW_MANIFEST: Record { from: "revision", to: "adr", verb: "write-artifact" }, { from: "adr", to: "final", verb: "write-artifact" }, { from: "planner", to: "handoff", verb: "handoff" }, + { from: "intent", to: "handoff", verb: "handoff" }, { from: "architect", to: "handoff", verb: "handoff" }, { from: "critic", to: "handoff", verb: "handoff" }, { from: "disposition", to: "handoff", verb: "handoff" }, @@ -258,7 +263,17 @@ export const WORKFLOW_MANIFEST: Record { name: "stage", type: "enum", - enumValues: ["planner", "architect", "critic", "disposition", "revision", "post-interview", "adr", "final"], + enumValues: [ + "planner", + "intent", + "architect", + "critic", + "disposition", + "revision", + "post-interview", + "adr", + "final", + ], appliesToVerbs: ["write-artifact"], }, { name: "stage_n", type: "number", appliesToVerbs: ["write-artifact"] }, diff --git a/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts index 7104b25b4d..180a459cc0 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts +++ b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts @@ -17,7 +17,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { sessionPlansDir } from "./session-layout"; +import { modeStatePath, sessionPlansDir } from "./session-layout"; import { getUltragoalPaths, readUltragoalLedger, readUltragoalPlan } from "./ultragoal-runtime"; /** Skills whose durable state can produce a structured recovery projection. */ @@ -71,6 +71,9 @@ export interface WorkflowRecoveryProjection { | "start-next-goal" | "resolve-review-blockers" | "run-boundary-cohort" + | "run-plan-review" + | "revise-plan" + | "reconcile-intent" | "final-aggregate-checkpoint" | "awaiting-approval" | "unknown"; @@ -168,6 +171,27 @@ function findSection(sections: ParsedHeadingSection[], needles: readonly string[ return sections.find(section => normalized.some(needle => section.title.toLowerCase().includes(needle))); } +function findSectionExact( + sections: ParsedHeadingSection[], + titles: readonly string[], +): ParsedHeadingSection | undefined { + const normalized = new Set(titles.map(title => title.toLowerCase())); + return sections.find(section => normalized.has(section.title.toLowerCase())); +} + +async function resolveRalplanArtifactPath(runDir: string, recordedPath: string): Promise { + const candidate = path.isAbsolute(recordedPath) ? recordedPath : path.resolve(runDir, recordedPath); + try { + const [runReal, artifactReal] = await Promise.all([fs.realpath(runDir), fs.realpath(candidate)]); + const relative = path.relative(runReal, artifactReal); + if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined; + const stat = await fs.stat(artifactReal); + return stat.isFile() ? artifactReal : undefined; + } catch { + return undefined; + } +} + /** * Extract the bounded objective from a Ralplan final plan artifact. The * objective is the first non-empty prose line of the document (before the @@ -189,16 +213,22 @@ export interface RalplanFinalProjectionInput { runId: string; } +interface RalplanProjectionInput extends RalplanFinalProjectionInput { + lastReviewVerdict?: string; + lastReviewVerdictLane?: string; +} + /** * Build a recovery projection from the newest complete Ralplan `final` stage * row of a run. Returns undefined when no parseable final artifact exists — * compaction then degrades to the thin projection instead of guessing. */ -export async function projectRalplanFinalRun( - input: RalplanFinalProjectionInput, +async function projectRalplanRunInternal( + input: RalplanProjectionInput, + finalOnly: boolean, ): Promise { const runDir = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan", input.runId); - let rows: Array<{ stage: string; stage_n?: number; path?: string; sha256?: string }> = []; + let rows: Array<{ stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown }> = []; try { const text = await fs.readFile(path.join(runDir, "index.jsonl"), "utf8"); rows = text @@ -207,20 +237,26 @@ export async function projectRalplanFinalRun( .filter(line => line.length > 0) .map(line => { try { - return JSON.parse(line) as { stage: string; stage_n?: number; path?: string; sha256?: string }; + const parsed = JSON.parse(line) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as { stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown }) + : undefined; } catch { return undefined; } }) - .filter( - (row): row is { stage: string; stage_n?: number; path?: string; sha256?: string } => row !== undefined, + .filter((row): row is { stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown } => + Boolean(row), ); } catch { return undefined; } const finalRow = [...rows].reverse().find(row => row.stage === "final"); - if (!finalRow?.path) return undefined; - const artifactPath = path.isAbsolute(finalRow.path) ? finalRow.path : path.join(runDir, finalRow.path); + const planRow = [...rows].reverse().find(row => row.stage === "revision" || row.stage === "planner"); + const artifactRow = finalOnly ? finalRow : (finalRow ?? planRow); + if (typeof artifactRow?.path !== "string" || artifactRow.path.trim().length === 0) return undefined; + const artifactPath = await resolveRalplanArtifactPath(runDir, artifactRow.path); + if (!artifactPath) return undefined; let markdown: string; try { markdown = await fs.readFile(artifactPath, "utf8"); @@ -230,38 +266,134 @@ export async function projectRalplanFinalRun( const objective = objectiveFromMarkdown(markdown); if (!objective) return undefined; const sections = parseMarkdownSections(markdown); + const primaryAcceptance = sectionListItems( + findSection(sections, ["acceptance criteria", "verification", "test plan"]), + MAX_CRITERIA_ITEMS, + ); const acceptance = - sectionListItems( - findSection(sections, ["acceptance criteria", "verification", "test plan"]), - MAX_CRITERIA_ITEMS, - ) || sectionListItems(findSection(sections, ["acceptance"]), MAX_CRITERIA_ITEMS); - const nonGoals = - sectionListItems(findSection(sections, ["non-goals", "non goals", "out of scope"]), MAX_SCOPE_ITEMS) || - sectionListItems(findSection(sections, ["non-goal"]), MAX_SCOPE_ITEMS); - const unresolved = - sectionListItems( - findSection(sections, ["intent reconciliation", "open confirmation", "unresolved"]), - MAX_UNRESOLVED_ITEMS, - ) || []; + primaryAcceptance.length > 0 + ? primaryAcceptance + : sectionListItems(findSectionExact(sections, ["acceptance"]), MAX_CRITERIA_ITEMS); + const nonGoals = sectionListItems( + findSectionExact(sections, ["non-goals", "non goals", "non-goal", "out of scope"]), + MAX_SCOPE_ITEMS, + ); + const unresolved = sectionListItems( + findSection(sections, ["intent reconciliation", "open confirmation", "unresolved"]), + MAX_UNRESOLVED_ITEMS, + ); const scope: WorkflowRecoveryScopeItem[] = sectionListItems( - findSection(sections, ["scope", "accepted scope"]), + findSectionExact(sections, ["scope", "accepted scope"]), MAX_SCOPE_ITEMS, ).map(text => ({ kind: "accepted" as const, text })); for (const text of nonGoals) scope.push({ kind: "non_goal" as const, text }); - const sha256 = (await sha256File(artifactPath)) ?? finalRow.sha256; + const sha256 = await sha256File(artifactPath); + if (!sha256) return undefined; + if (typeof artifactRow.sha256 === "string") { + const recorded = artifactRow.sha256.startsWith("sha256:") ? artifactRow.sha256 : `sha256:${artifactRow.sha256}`; + if (recorded !== sha256) return undefined; + } + const stage = typeof artifactRow.stage === "string" ? artifactRow.stage : "unknown"; + const latestStage = typeof rows.at(-1)?.stage === "string" ? rows.at(-1)?.stage : undefined; + let nextAction: WorkflowRecoveryProjection["nextAction"]; + if (stage === "final") { + nextAction = { actionClass: "awaiting-approval" }; + } else if (latestStage === "critic") { + nextAction = + input.lastReviewVerdictLane === "critic" && input.lastReviewVerdict === "OKAY" + ? { actionClass: "reconcile-intent" } + : { actionClass: "revise-plan" }; + } else { + nextAction = { actionClass: "run-plan-review" }; + } const projection: Omit = { skill: "ralplan", - source: "ralplan-final", + source: stage === "final" ? "ralplan-final" : "ralplan-run", objective, scope, acceptanceCriteria: acceptance, unresolved, - provenance: { planPath: artifactPath, runId: input.runId, stage: "final", sha256 }, + provenance: { planPath: artifactPath, runId: input.runId, stage, sha256 }, progress: {}, - nextAction: { actionClass: "awaiting-approval" }, + nextAction, }; return withZeroProgress(projection); } + +export async function projectRalplanFinalRun( + input: RalplanFinalProjectionInput, +): Promise { + return await projectRalplanRunInternal(input, true); +} + +export async function projectRalplanRun( + input: RalplanProjectionInput, +): Promise { + return await projectRalplanRunInternal(input, false); +} + +function isSafeRunId(value: unknown): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + value === value.trim() && + path.basename(value) === value && + value !== "." && + value !== ".." + ); +} + +/** + * Project the active Ralplan run recorded in durable mode state. When legacy + * state has no run id, fall back to complete runs ordered by index freshness, + * skipping unfinished or malformed candidates instead of letting them shadow + * the newest usable final contract. + */ +export async function projectLatestRalplanRun(input: { + cwd: string; + sessionId: string; +}): Promise { + const root = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan"); + try { + const stateText = await fs.readFile(modeStatePath(input.cwd, input.sessionId, "ralplan"), "utf8"); + const state = JSON.parse(stateText) as { + run_id?: unknown; + last_review_verdict?: unknown; + last_review_verdict_lane?: unknown; + }; + if (isSafeRunId(state.run_id)) { + return await projectRalplanRun({ + ...input, + runId: state.run_id, + lastReviewVerdict: typeof state.last_review_verdict === "string" ? state.last_review_verdict : undefined, + lastReviewVerdictLane: + typeof state.last_review_verdict_lane === "string" ? state.last_review_verdict_lane : undefined, + }); + } + } catch { + // Legacy or malformed mode state falls back to bounded run discovery. + } + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + const candidates = await Promise.all( + entries + .filter(entry => entry.isDirectory() && isSafeRunId(entry.name)) + .map(async entry => ({ + runId: entry.name, + mtimeMs: + (await fs.stat(path.join(root, entry.name, "index.jsonl")).catch(() => undefined))?.mtimeMs ?? -1, + })), + ); + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs || left.runId.localeCompare(right.runId)); + for (const candidate of candidates) { + const projection = await projectRalplanRun({ ...input, runId: candidate.runId }); + if (projection) return projection; + } + } catch { + return undefined; + } + return undefined; +} /** * Build a recovery projection from Ultragoal canonical durable state. The * accepted contract is the aggregate objective plus the goal list; progress @@ -274,7 +406,8 @@ export async function projectUltragoalRun(input: { }): Promise { const plan = await readUltragoalPlan(input.cwd, input.sessionId).catch(() => null); if (!plan?.goals?.length) return undefined; - const ledger = await readUltragoalLedger(input.cwd, input.sessionId).catch(() => []); + const ledger = await readUltragoalLedger(input.cwd, input.sessionId).catch(() => null); + if (!ledger) return undefined; const paths = getUltragoalPaths(input.cwd, input.sessionId); const schedulable = plan.goals.filter(goal => goal.status !== "complete" && goal.status !== "superseded"); const currentGoal = plan.goals.find(goal => goal.status === "active" || goal.status === "failed") ?? schedulable[0]; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9da85d4a40..fa13563654 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -177,7 +177,6 @@ export interface ForkContextSeedOptions { signal?: AbortSignal; } -import { readdir as fsReaddir } from "node:fs/promises"; import type { AuthCredentialSelector } from "@gajae-code/ai/core"; import type { MacOSPowerAssertion } from "@gajae-code/natives"; import { @@ -301,7 +300,6 @@ import { import { assertNonEmptyGjcSessionId, modeStatePath as sessionModeStatePath, - sessionPlansDir, sessionStateDir, } from "../gjc-runtime/session-layout"; import { @@ -313,7 +311,7 @@ import { } from "../gjc-runtime/session-state-sidecar"; import { isWorkflowRecoveryStalled, - projectRalplanFinalRun, + projectLatestRalplanRun, projectUltragoalRun, trackWorkflowRecoveryZeroProgress, type WorkflowRecoveryProjection, @@ -11806,7 +11804,9 @@ export class AgentSession { this.setTodoPhases(phases.filter(p => p.tasks.length > 0)); } - async #compactionStateSnapshot(): Promise { + async #compactionStateSnapshot( + options: { trackWorkflowRecoveryProgress?: boolean } = {}, + ): Promise { const snapshot: CompactionStateSnapshot = { goal: undefined, openTodos: [], @@ -11854,15 +11854,18 @@ export class AgentSession { // Ralplan/Ultragoal runs so compaction carries a structured // recovery projection instead of summary prose alone. Degrades // safely: malformed/stale/tampered durable state leaves the - snapshot.workflowRecovery = await this.#projectWorkflowRecovery(); + snapshot.workflowRecovery = + snapshot.goal?.status === "paused" ? undefined : await this.#projectWorkflowRecovery(); // #4560: track measurable durable progress across compaction // observations so a stalled continuation loop is detected and // escalated instead of running forever. - if (snapshot.workflowRecovery) { + if (snapshot.workflowRecovery && options.trackWorkflowRecoveryProgress) { this.#workflowRecoveryMemory = trackWorkflowRecoveryZeroProgress( this.#workflowRecoveryMemory, snapshot.workflowRecovery, ); + } + if (snapshot.workflowRecovery && this.#workflowRecoveryMemory) { snapshot.workflowRecovery = { ...snapshot.workflowRecovery, zeroProgress: { @@ -11906,7 +11909,9 @@ export class AgentSession { * preserved) and for malformed durable state (safe degradation). */ async #projectWorkflowRecovery(): Promise { - const entries = this.#lastCompactionActiveSkills ?? []; + const entries = (this.#lastCompactionActiveSkills ?? []).filter( + entry => !isWorkflowContinuationInert(entry.skill, entry.phase), + ); const cwd = this.sessionManager.getCwd(); // Ultragoal runs own the live execution contract; prefer their plan. if (entries.some(entry => entry.skill === "ultragoal")) { @@ -11914,13 +11919,8 @@ export class AgentSession { if (projection) return projection; } if (entries.some(entry => entry.skill === "ralplan")) { - const runId = await this.#latestRalplanRunId(cwd); - if (runId) { - const projection = await projectRalplanFinalRun({ cwd, sessionId: this.sessionId, runId }).catch( - () => undefined, - ); - if (projection) return projection; - } + const projection = await projectLatestRalplanRun({ cwd, sessionId: this.sessionId }).catch(() => undefined); + if (projection) return projection; } return undefined; } @@ -11929,26 +11929,6 @@ export class AgentSession { /** #4560: active skills observed by the latest compaction snapshot. */ #lastCompactionActiveSkills: Array<{ skill: string; phase: string }> = []; - async #latestRalplanRunId(cwd: string): Promise { - try { - const dir = path.join(sessionPlansDir(cwd, this.sessionId), "ralplan"); - // Pick the most recently modified run directory, not a lexicographic - // name sort: run ids are not guaranteed monotonic, and recovery must - // project the run the session actually touched last. - const entries = await fsReaddir(dir, { withFileTypes: true }); - const runDirs = entries.filter(entry => entry.isDirectory()); - if (runDirs.length === 0) return undefined; - let newest: { id: string; mtimeMs: number } | undefined; - for (const entry of runDirs) { - const mtimeMs = (await fs.promises.stat(path.join(dir, entry.name))).mtimeMs; - if (!newest || mtimeMs > newest.mtimeMs) newest = { id: entry.name, mtimeMs }; - } - return newest?.id; - } catch { - return undefined; - } - } - #compactionStateContext(snapshot: CompactionStateSnapshot): string[] { const context: string[] = []; const goal = snapshot.goal; @@ -15073,7 +15053,7 @@ export class AgentSession { const compactionAbortController = new AbortController(); this.#compactionAbortController = compactionAbortController; // Take this invocation's state snapshot for the summarizer context. - const compactionStateSnapshot = await this.#compactionStateSnapshot(); + const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true }); try { if (!this.model) { @@ -17128,7 +17108,7 @@ export class AgentSession { if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" }; await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); if (autoCompactionSignal.aborted) return await emitAborted(); - const compactionStateSnapshot = await this.#compactionStateSnapshot(); + const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true }); if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) { return await emitAborted(); } diff --git a/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts b/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts index ca7d463d09..28bd78f16c 100644 --- a/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts +++ b/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as crypto from "node:crypto"; import * as path from "node:path"; import { Agent } from "@gajae-code/agent-core"; import * as compactionModule from "@gajae-code/agent-core/compaction"; @@ -151,6 +152,27 @@ describe("AgentSession workflow recovery continuation (#4560)", () => { ); } + async function seedRalplanReview(): Promise { + const runId = "review-run"; + const runDir = path.join(tempDir.path(), ".gjc", `_session-${session.sessionId}`, "plans", "ralplan", runId); + const plan = `Plan the durable recovery contract.\n\n## Accepted Scope\n- recovery projection\n\n## Non-Goals\n- unrelated UI changes\n\n## Acceptance Criteria\n- forced compaction resumes plan review\n`; + const artifactPath = path.join(runDir, "stage-01-planner.md"); + await Bun.write(artifactPath, plan); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ + stage: "planner", + stage_n: 1, + path: artifactPath, + sha256: crypto.createHash("sha256").update(plan).digest("hex"), + })}\n`, + ); + await Bun.write( + path.join(tempDir.path(), ".gjc", `_session-${session.sessionId}`, "state", "ralplan-state.json"), + JSON.stringify({ run_id: runId, current_phase: "planner", active: true }), + ); + } + it("continues from the structured workflow contract after compaction", async () => { await seedActiveSkillState("active"); await seedUltragoalPlan(); @@ -177,6 +199,38 @@ describe("AgentSession workflow recovery continuation (#4560)", () => { expect(text).toContain("continue-current-goal"); expect(text).toContain("G002"); expect(text).toContain("Accepted scope"); + expect(text).not.toContain("STALLED:"); + }); + + it("counts zero progress once per compaction rather than once per snapshot", async () => { + await seedActiveSkillState("active"); + await seedUltragoalPlan(); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(); + await compact("length"); + expect(JSON.stringify(promptSpy.mock.calls.at(-1))).not.toContain("STALLED:"); + await compact("length"); + expect(JSON.stringify(promptSpy.mock.calls.at(-1))).not.toContain("STALLED:"); + await compact("length"); + expect(JSON.stringify(promptSpy.mock.calls.at(-1))).toContain("STALLED:"); + }); + + it("keeps terminal workflow phases continuation-inert", async () => { + await seedActiveSkillState("handoff"); + await seedUltragoalPlan(); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(); + await compact("length"); + expect(JSON.stringify(promptSpy.mock.calls)).not.toContain("workflow-recovery"); + }); + + it("resumes the exact ralplan review action after forced compaction", async () => { + await seedActiveSkillState("planner", "ralplan"); + await seedRalplanReview(); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(); + await compact("length"); + const text = JSON.stringify(promptSpy.mock.calls); + expect(text).toContain("workflow-recovery"); + expect(text).toContain("run-plan-review"); + expect(text).toContain("recovery projection"); }); it("keeps the generic prompt when no durable workflow state exists", async () => { diff --git a/packages/coding-agent/test/gjc-runtime/ralplan-runtime.test.ts b/packages/coding-agent/test/gjc-runtime/ralplan-runtime.test.ts index 726bdb5a91..8f7efb833e 100644 --- a/packages/coding-agent/test/gjc-runtime/ralplan-runtime.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ralplan-runtime.test.ts @@ -869,11 +869,12 @@ describe("native gjc ralplan runtime — run-state phase coherence", () => { for (const [stage, stageN] of [ ["planner", "1"], - ["architect", "2"], - ["critic", "3"], - ["revision", "4"], - ["post-interview", "5"], - ["adr", "6"], + ["intent", "2"], + ["architect", "3"], + ["critic", "4"], + ["revision", "5"], + ["post-interview", "6"], + ["adr", "7"], ] as const) { const result = await runNativeRalplanCommand( ["--write", "--stage", stage, "--stage_n", stageN, "--artifact", `# ${stage}`, "--run-id", runId], diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts index dc4eb5030f..0454964452 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts @@ -4,6 +4,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { + computeCheckpointChangeSet, + computeUltragoalReviewSourceHash, createUltragoalPlan, validateUltragoalQualityGateReadOnly, } from "@gajae-code/coding-agent/gjc-runtime/ultragoal-runtime"; @@ -20,9 +22,14 @@ async function seedGitRepo(root: string, files: Record): Promise await $`git init`.cwd(root).quiet(); await $`git config user.email test@example.com`.cwd(root).quiet(); await $`git config user.name test`.cwd(root).quiet(); - await $`git commit --allow-empty -m base`.cwd(root).quiet(); - for (const [file, content] of Object.entries(files)) { + await Bun.write(path.join(root, ".gitignore"), ".gjc/\nartifacts/\n"); + for (const file of Object.keys(files)) { await fs.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await Bun.write(path.join(root, file), "// base\n"); + } + await $`git add .`.cwd(root).quiet(); + await $`git commit -m base`.cwd(root).quiet(); + for (const [file, content] of Object.entries(files)) { await Bun.write(path.join(root, file), content); } } @@ -122,6 +129,13 @@ describe("ultragoal validation lane selection gate (#4560)", () => { await Bun.write(path.join(root, "artifacts", "report.json"), JSON.stringify({ ok: true })); } + async function sourceHash(): Promise { + const changeSet = await computeCheckpointChangeSet(root); + const hash = computeUltragoalReviewSourceHash(changeSet); + if (!hash) throw new Error("expected an authoritative source hash"); + return hash; + } + /** Append a prior verified complete-checkpoint cohort with `sourceHash`. */ async function seedPriorCohort(sourceHash: string): Promise { const ledgerPath = path.join(root, ".gjc", `_session-${TEST_SESSION_ID}`, "ultragoal", "ledger.jsonl"); @@ -139,8 +153,9 @@ describe("ultragoal validation lane selection gate (#4560)", () => { it("accepts a QA-only cohort with a matching low-risk lane-selection proof", async () => { await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); - await seedPriorCohort("sha256:frozen1"); - const gate = baseGate("sha256:frozen1", { qa: lane("sha256:frozen1") }); + const frozen = await sourceHash(); + await seedPriorCohort(frozen); + const gate = baseGate(frozen, { qa: lane(frozen) }); gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; const result = await validateUltragoalQualityGateReadOnly({ cwd: root, @@ -153,7 +168,8 @@ describe("ultragoal validation lane selection gate (#4560)", () => { it("rejects the reduced cohort when the runtime computes high risk", async () => { await seedPlan(1, { "packages/coding-agent/src/sdk/session.ts": "export const y = 2;\n" }); - const gate = baseGate("sha256:frozen2", { qa: lane("sha256:frozen2") }); + const frozen = await sourceHash(); + const gate = baseGate(frozen, { qa: lane(frozen) }); gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; const result = await validateUltragoalQualityGateReadOnly({ cwd: root, @@ -168,7 +184,8 @@ describe("ultragoal validation lane selection gate (#4560)", () => { it("rejects a reduced cohort without any lane-selection proof", async () => { await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); - const gate = baseGate("sha256:frozen3", { qa: lane("sha256:frozen3") }); + const frozen = await sourceHash(); + const gate = baseGate(frozen, { qa: lane(frozen) }); const result = await validateUltragoalQualityGateReadOnly({ cwd: root, qualityGateJson: JSON.stringify(gate), @@ -180,7 +197,7 @@ describe("ultragoal validation lane selection gate (#4560)", () => { it("rejects a selection proof that tries to omit the QA lane", async () => { await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); - const gate = baseGate("sha256:frozen4", {}); + const gate = baseGate(await sourceHash(), {}); gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect", "qa"] }; const result = await validateUltragoalQualityGateReadOnly({ cwd: root, @@ -193,10 +210,11 @@ describe("ultragoal validation lane selection gate (#4560)", () => { it("keeps the full cohort mandatory for multi-goal runs even with a declared proof", async () => { await seedPlan(2, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); - const gate = baseGate("sha256:frozen5", { - cleaner: lane("sha256:frozen5", "CLEAR"), - architect: lane("sha256:frozen5", "CLEAR"), - qa: lane("sha256:frozen5"), + const frozen = await sourceHash(); + const gate = baseGate(frozen, { + cleaner: lane(frozen, "CLEAR"), + architect: lane(frozen, "CLEAR"), + qa: lane(frozen), }); const full = await validateUltragoalQualityGateReadOnly({ cwd: root, @@ -205,7 +223,7 @@ describe("ultragoal validation lane selection gate (#4560)", () => { }); // Full cohort on a multi-goal run validates (structural pass expected). expect(full.errors.filter(e => e.code === "review_cohort_invalid")).toEqual([]); - const reduced = baseGate("sha256:frozen5", { qa: lane("sha256:frozen5") }); + const reduced = baseGate(frozen, { qa: lane(frozen) }); reduced.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; const rejected = await validateUltragoalQualityGateReadOnly({ cwd: root, @@ -215,4 +233,39 @@ describe("ultragoal validation lane selection gate (#4560)", () => { expect(rejected.valid).toBe(false); expect(rejected.errors.some(e => e.code === "reduction_not_applicable")).toBe(true); }); + + it("rejects a reused self-declared cohort hash after the source changes", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const frozen = await sourceHash(); + await seedPriorCohort(frozen); + await Bun.write(path.join(root, "packages/utils/src/helper.ts"), "export const x = 2;\n"); + const gate = baseGate(frozen, { qa: lane(frozen) }); + gate.validationLaneSelection = { riskClass: "low", reasons: [], omittedLanes: ["cleaner", "architect"] }; + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.valid).toBe(false); + expect(result.errors.some(error => error.code === "source_hash_mismatch")).toBe(true); + expect(result.errors.some(error => error.code === "critic_verdict_not_okay")).toBe(true); + }); + + it("rejects fabricated low-risk selection reasons", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const frozen = await sourceHash(); + const gate = baseGate(frozen, { qa: lane(frozen) }); + gate.validationLaneSelection = { + riskClass: "low", + reasons: ["model-says-safe"], + omittedLanes: ["cleaner", "architect"], + }; + const result = await validateUltragoalQualityGateReadOnly({ + cwd: root, + qualityGateJson: JSON.stringify(gate), + goalId: "G001", + }); + expect(result.valid).toBe(false); + expect(result.errors.some(error => error.code === "reasons_mismatch")).toBe(true); + }); }); diff --git a/packages/coding-agent/test/ralplan-decision-artifacts.test.ts b/packages/coding-agent/test/ralplan-decision-artifacts.test.ts index b2d14faead..c019f6d41e 100644 --- a/packages/coding-agent/test/ralplan-decision-artifacts.test.ts +++ b/packages/coding-agent/test/ralplan-decision-artifacts.test.ts @@ -33,6 +33,12 @@ const criticApprovalContractPatterns = [ ] as const; const ralplanReviewPipelineContractPatterns = [ + /Pre-consensus material-intent reconciliation/u, + /always before Architect\/Critic/u, + /--stage intent/u, + /When material open items exist, use the `ask` tool one at a time/u, + /When none exist, proceed without an empty ceremony or user prompt/u, + /they never review the superseded pre-reconciliation draft/u, /Review fan-out after Planner persistence/u, /launch the Architect and Critic ONCE per run as detached, resumable review lanes/u, /Plan-only Critic lane/u, diff --git a/packages/coding-agent/test/workflow-recovery-projection.test.ts b/packages/coding-agent/test/workflow-recovery-projection.test.ts index 7b48695cfb..d6df627105 100644 --- a/packages/coding-agent/test/workflow-recovery-projection.test.ts +++ b/packages/coding-agent/test/workflow-recovery-projection.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as crypto from "node:crypto"; import * as path from "node:path"; import { isHighRiskChangePath, @@ -8,6 +9,7 @@ import { import { hashWorkflowRecoveryProjection, isWorkflowRecoveryStalled, + projectLatestRalplanRun, projectRalplanFinalRun, projectUltragoalRun, trackWorkflowRecoveryZeroProgress, @@ -56,9 +58,10 @@ describe("workflow recovery projection (#4560)", () => { it("projects a ralplan final run with scope, non-goals, AC, and digest", async () => { const runDir = ralplanRunDir(tempDir.path(), "run-1"); + const digest = crypto.createHash("sha256").update(FINAL_PLAN).digest("hex"); await Bun.write( path.join(runDir, "index.jsonl"), - `${JSON.stringify({ stage: "planner", stage_n: 1, path: "stage-01-planner.md", sha256: "aa" })}\n${JSON.stringify({ stage: "final", stage_n: 2, path: "stage-02-final.md", sha256: "bb" })}\n`, + `${JSON.stringify({ stage: "planner", stage_n: 1, path: "stage-01-planner.md", sha256: "aa" })}\n${JSON.stringify({ stage: "final", stage_n: 2, path: "stage-02-final.md", sha256: digest })}\n`, ); await Bun.write(path.join(runDir, "stage-02-final.md"), FINAL_PLAN); const projection = await projectRalplanFinalRun({ cwd: tempDir.path(), sessionId: SESSION_ID, runId: "run-1" }); @@ -72,6 +75,50 @@ describe("workflow recovery projection (#4560)", () => { expect(projection?.zeroProgress.stalled).toBe(false); }); + it("rejects escaped, non-string, and digest-mismatched ralplan artifacts", async () => { + const outsidePath = path.join(tempDir.path(), "outside.md"); + await Bun.write(outsidePath, "secret outside contract\n"); + for (const [runId, artifactPath, sha256] of [ + ["absolute", outsidePath, undefined], + ["relative", "../../../../../outside.md", undefined], + ["typed", 123, undefined], + ["digest", "stage-01-final.md", "0".repeat(64)], + ] as const) { + const runDir = ralplanRunDir(tempDir.path(), runId); + await Bun.write(path.join(runDir, "stage-01-final.md"), FINAL_PLAN); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ stage: "final", stage_n: 1, path: artifactPath, sha256 })}\n`, + ); + await expect( + projectRalplanFinalRun({ cwd: tempDir.path(), sessionId: SESSION_ID, runId }), + ).resolves.toBeUndefined(); + } + }); + + it("uses the durable active ralplan run and skips unfinished legacy candidates", async () => { + const validDir = ralplanRunDir(tempDir.path(), "valid-run"); + await Bun.write(path.join(validDir, "stage-01-final.md"), FINAL_PLAN); + await Bun.write( + path.join(validDir, "index.jsonl"), + `${JSON.stringify({ stage: "final", stage_n: 1, path: "stage-01-final.md" })}\n`, + ); + const unfinishedDir = ralplanRunDir(tempDir.path(), "unfinished-run"); + await Bun.write( + path.join(unfinishedDir, "index.jsonl"), + `${JSON.stringify({ stage: "planner", stage_n: 1, path: "stage-01-planner.md" })}\n`, + ); + const discovered = await projectLatestRalplanRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(discovered?.provenance.runId).toBe("valid-run"); + + await Bun.write( + path.join(tempDir.path(), ".gjc", `_session-${SESSION_ID}`, "state", "ralplan-state.json"), + JSON.stringify({ run_id: "unfinished-run" }), + ); + const activeUnfinished = await projectLatestRalplanRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(activeUnfinished).toBeUndefined(); + }); + it("degrades safely for malformed ralplan index (no final row)", async () => { const runDir = ralplanRunDir(tempDir.path(), "run-2"); await Bun.write(path.join(runDir, "index.jsonl"), "not-json\n"); @@ -124,7 +171,16 @@ describe("workflow recovery projection (#4560)", () => { ); await Bun.write( path.join(dir, "ledger.jsonl"), - `${JSON.stringify({ eventId: "e1", event: "goal_checkpointed", goalId: "G001", status: "complete", evidence: "done" })}\n`, + `${JSON.stringify({ + eventId: "e1", + event: "goal_checkpointed", + goalId: "G001", + status: "complete", + evidence: "parallel executor work joined before boundary review", + qualityGateJson: { + iteration: { reviewCohort: { reviewGeneration: 2, sourceHash: "sha256:frozen", joined: true } }, + }, + })}\n`, ); const projection = await projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); expect(projection?.skill).toBe("ultragoal"); @@ -132,10 +188,42 @@ describe("workflow recovery projection (#4560)", () => { expect(projection?.progress.totalGoals).toBe(2); expect(projection?.progress.completedGoals).toBe(1); expect(projection?.progress.outstandingGoals).toBe(1); + expect(projection?.progress.latestReviewGeneration).toBe(2); + expect(projection?.progress.latestCohortSourceHash).toBe("sha256:frozen"); expect(projection?.nextAction.actionClass).toBe("continue-current-goal"); expect(projection?.nextAction.goalId).toBe("G002"); }); + it("recovers blocker-fix re-review as the exact next action", async () => { + const dir = ultragoalDir(tempDir.path()); + const now = new Date().toISOString(); + await Bun.write( + path.join(dir, "goals.json"), + JSON.stringify({ + version: 1, + brief: "b", + gjcGoalMode: "aggregate", + gjcObjective: "Ship recovery", + goals: [ + { + id: "G001", + title: "Ship", + objective: "Ship", + status: "review_blocked", + createdAt: now, + updatedAt: now, + evidence: "joined cohort found a blocker", + }, + ], + createdAt: now, + updatedAt: now, + }), + ); + const projection = await projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(projection?.nextAction).toMatchObject({ actionClass: "resolve-review-blockers", goalId: "G001" }); + expect(projection?.unresolved).toContain("review blockers open on G001"); + }); + it("degrades safely for tampered ultragoal plan", async () => { const dir = ultragoalDir(tempDir.path()); await Bun.write(path.join(dir, "goals.json"), "{not json"); @@ -143,6 +231,25 @@ describe("workflow recovery projection (#4560)", () => { expect(projection).toBeUndefined(); }); + it("degrades safely for a truncated ultragoal ledger", async () => { + const dir = ultragoalDir(tempDir.path()); + const now = new Date().toISOString(); + await Bun.write( + path.join(dir, "goals.json"), + JSON.stringify({ + version: 1, + brief: "b", + gjcGoalMode: "aggregate", + gjcObjective: "Ship parser fix", + goals: [{ id: "G001", title: "Fix", objective: "Fix", status: "active", createdAt: now, updatedAt: now }], + createdAt: now, + updatedAt: now, + }), + ); + await Bun.write(path.join(dir, "ledger.jsonl"), '{"event":"goal_started"}\n{"event":'); + await expect(projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID })).resolves.toBeUndefined(); + }); + it("bounds zero-progress cycles by durable fingerprint", () => { const runDirBasis = { objective: "same", @@ -183,6 +290,7 @@ describe("ultragoal validation applicability policy (#4560)", () => { changeSet: lowRiskChangeSet, totalGoals: 1, completedGoals: 0, + authoritativeSourceHash: "sha256:current", }); expect(applicability.riskClass).toBe("low"); expect(applicability.lanes.qa.applicable).toBe(true); @@ -228,6 +336,21 @@ describe("ultragoal validation applicability policy (#4560)", () => { completedGoals: 0, }); expect(migration.riskClass).toBe("high"); + expect( + isHighRiskChangePath({ path: "packages/coding-agent/src/session/auth-storage.ts", status: "modified" }), + ).toBe(true); + expect( + isHighRiskChangePath({ + path: "./packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts", + status: "modified", + }), + ).toBe(true); + expect( + isMigrationChangePath({ + path: "packages\\coding-agent\\src\\session\\session-manager.ts", + status: "modified", + }), + ).toBe(true); }); it("fails closed on missing/untrusted change set and multi-goal runs", () => { @@ -249,6 +372,7 @@ describe("ultragoal validation applicability policy (#4560)", () => { completedGoals: 0, latestCohortSourceHash: "sha256:abc", currentSourceHash: "sha256:abc", + authoritativeSourceHash: "sha256:abc", }); expect(unchanged.basisUnchanged).toBe(true); const changed = resolveUltragoalValidationApplicability({ @@ -257,6 +381,7 @@ describe("ultragoal validation applicability policy (#4560)", () => { completedGoals: 0, latestCohortSourceHash: "sha256:abc", currentSourceHash: "sha256:xyz", + authoritativeSourceHash: "sha256:xyz", }); expect(changed.basisUnchanged).toBe(false); const blocked = resolveUltragoalValidationApplicability({ @@ -265,6 +390,7 @@ describe("ultragoal validation applicability policy (#4560)", () => { completedGoals: 0, latestCohortSourceHash: "sha256:abc", currentSourceHash: "sha256:abc", + authoritativeSourceHash: "sha256:abc", hasOpenReviewBlockers: true, }); expect(blocked.basisUnchanged).toBe(false); From cfc34be028101594fb50aab81bf91be58842b412 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Sat, 15 Aug 2026 15:29:44 +0000 Subject: [PATCH 4/6] fix(workflows): expose and enforce frozen source evidence (#4560) Generation-two review found that the authoritative cohort digest was required but unavailable through a supported command, making honest quality-gate construction impossible. Recovery also still accepted Ralplan rows that omitted their recorded digest. Expose a read-only quality-gate source-hash command, require digest-bearing confined Ralplan rows, reject tampered mode/index state, hash untracked symlinks by link identity, preserve the legacy planner-to-architect edge, and make planning-stuck recovery terminal. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 3d8f7a11 Constraint: the leader must obtain sourceHash from the runtime on the frozen snapshot Constraint: current Ralplan writer receipts require matching SHA-256 provenance Rejected: diagnostic-only hash discovery | forces an edit/retry loop at the terminal gate Confidence: high Scope-risk: moderate Reversibility: trivial Tested: 147 focused workflow tests; coding-agent typecheck; live source-hash CLI invocation --- ...covery-and-risk-proportional-validation.md | 6 +- packages/coding-agent/CHANGELOG.md | 2 +- .../defaults/gjc/skills/ultragoal/SKILL.md | 3 +- .../src/gjc-runtime/ultragoal-change-set.ts | 16 ++++- .../src/gjc-runtime/ultragoal-runtime.ts | 26 ++++++- .../workflow-manifest.generated.json | 5 ++ .../src/gjc-runtime/workflow-manifest.ts | 2 + .../workflow-recovery-projection.ts | 72 +++++++++++-------- .../coding-agent/src/session/agent-session.ts | 4 -- .../gjc-runtime/ultragoal-runtime.test.ts | 2 +- .../ultragoal-validation-lanes.test.ts | 32 +++++++-- .../workflow-manifest-phase-sets.test.ts | 8 +++ .../test/workflow-recovery-projection.test.ts | 43 ++++++++++- 13 files changed, 176 insertions(+), 45 deletions(-) diff --git a/docs/workflow-recovery-and-risk-proportional-validation.md b/docs/workflow-recovery-and-risk-proportional-validation.md index 5d5a816d33..c248237629 100644 --- a/docs/workflow-recovery-and-risk-proportional-validation.md +++ b/docs/workflow-recovery-and-risk-proportional-validation.md @@ -28,7 +28,7 @@ Safety properties: ### Bounded zero-progress cycles -Each actual compaction fingerprints the projection's contract-relevant fields (`hashWorkflowRecoveryProjection`). Snapshot reads performed by authorization and prompt assembly reuse the same counter state and do not increment it. `trackWorkflowRecoveryZeroProgress` counts consecutive compactions with an unchanged fingerprint; at `ZERO_PROGRESS_STALL_THRESHOLD` (2) the third unchanged recovery carries an explicit `STALLED` directive ordering a durable blocker/escalation instead of repeating the same next action. Any measurable durable progress (completed obligations, changed blocker disposition, changed source hash, goal status change) resets the counter. This bounds — but does not claim to eliminate — post-compaction continuation loops. +Each compaction attempt fingerprints the projection's contract-relevant fields (`hashWorkflowRecoveryProjection`). Snapshot reads performed by authorization and prompt assembly reuse the same counter state and do not increment it. `trackWorkflowRecoveryZeroProgress` counts consecutive attempts with an unchanged fingerprint; at `ZERO_PROGRESS_STALL_THRESHOLD` (2) the third unchanged recovery attempt carries an explicit `STALLED` directive ordering a durable blocker/escalation instead of repeating the same next action. An attempt that aborts after its tracked snapshot still consumes one observation; any measurable durable progress (completed obligations, changed blocker disposition, changed source hash, goal status change) resets the counter. This bounds — but does not claim to eliminate — post-compaction continuation loops. ## Ultragoal validation-applicability policy @@ -46,12 +46,12 @@ Everything else — including a missing or untrusted change set — is high risk Omission mechanics: - The **QA lane can never be omitted**. Targeted verification and real-surface evidence stay mandatory at every boundary. -- A leader presenting a reduced cohort must carry a top-level `validationLaneSelection` proof (`riskClass`, `reasons`, `omittedLanes`) that exactly mirrors the runtime-computed selection. Mismatches fail closed with typed diagnostics (`reduction_not_applicable`, `selection_mismatch`, `omitted_lanes_mismatch`, `qa_lane_mandatory`) and the full cohort requirement stays in force. +- A leader presenting a reduced cohort must carry a top-level `validationLaneSelection` proof (`riskClass`, `reasons`, `omittedLanes`) that exactly mirrors the runtime-computed selection. Mismatches fail closed with typed diagnostics (`reduction_not_applicable`, `selection_mismatch`, `reasons_mismatch`, `omitted_lanes_mismatch`, `qa_lane_mandatory`, `selection_invalid`, `source_hash_mismatch`) and the full cohort requirement stays in force. - The **terminal critic** is proportional: `criticReview.verdict: OKAY` remains mandatory for final aggregates except when the run is single-goal, low-risk, blocker-free, **and** the immutable source basis is unchanged (`basisUnchanged`), in which case the already-joined cohort evidence satisfies the terminus without a duplicate critic read pass. ### Unchanged-basis rerun avoidance -`basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash and the gate's current cohort hash both equal the runtime-computed digest of the authoritative change-set basis, and no review blockers reopened. The digest binds integration base, merge base, normalized path/status rows, and the captured diff. A changed source, a review fix, an integration-base change, untracked or incompletely captured content, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. +Run `gjc ultragoal quality-gate source-hash --json` on the clean frozen snapshot to obtain the only accepted cohort hash. `basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash and the gate's current cohort hash both equal that runtime-computed digest, and no review blockers reopened. The digest binds integration base, merge base, normalized path/status rows, captured diff, and the identity/content of untracked files without following symlink targets. A changed source, a review fix, an integration-base change, incompletely captured content, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. ## Comparative and forced-compaction evidence diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6e327b90b0..cde7909a84 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,7 +38,7 @@ ### Added -- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and a runtime-computed source-basis digest. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. Workflow-enforcement changes classify themselves as high-risk, and high-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, untracked, and untrusted changes retain the full cohort. Terminal-critic reuse additionally requires the current and prior cohort hashes to match the authoritative digest. +- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and a runtime-computed source-basis digest exposed by `gjc ultragoal quality-gate source-hash`. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. Workflow-enforcement changes classify themselves as high-risk, untracked files are content/identity hashed without following symlinks, and high-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, and untrusted changes retain the full cohort. Terminal-critic reuse additionally requires the current and prior cohort hashes to match the authoritative digest. ### Changed diff --git a/packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md b/packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md index dddb1350da..dfda16da45 100644 --- a/packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md +++ b/packages/coding-agent/src/defaults/gjc/skills/ultragoal/SKILL.md @@ -50,6 +50,7 @@ gjc ultragoal create-goals --brief "" gjc ultragoal create-goals --brief-file gjc ultragoal complete-goals gjc ultragoal complete-goals --retry-failed +gjc ultragoal quality-gate source-hash --json gjc ultragoal quality-gate validate --quality-gate-json [--goal-id ] [--json] gjc ultragoal checkpoint --goal-id --status complete --evidence "" --quality-gate-json gjc ultragoal checkpoint --goal-id --status failed --evidence "" @@ -299,7 +300,7 @@ The heavyweight gate runs **once per boundary generation**, not once per story a One generation freezes the change set and reviews it exactly once: 1. Run implementation verification for the boundary's cumulative change set. -2. **Freeze the change set.** Compute one immutable `sourceHash` over the reviewed source. Every lane in this generation inspects that same frozen snapshot; a lane verdict carrying a different `sourceHash` is rejected. +2. **Freeze the change set.** Run `gjc ultragoal quality-gate source-hash --json` on the clean reviewed snapshot and use its `sourceHash` exactly. The runtime binds this digest to the integration base, merge base, normalized changed paths, captured diff, and untracked-content digest. Every lane in this generation inspects that same frozen snapshot; a lane verdict carrying a different `sourceHash` is rejected. Any later source or base change requires rerunning this command and starting a new generation. 3. **Run the cohort lanes on the frozen snapshot** — at most one `cleaner`, one `architect`, and one `qa` lane per generation. They may run in parallel because they share the frozen source; a second architect or QA lane in the same generation is rejected. The `cleaner` lane is the internal ai-slop-cleaner skill fragment run over the frozen change set: a read-only detector that emits an `AI SLOP CLEANUP REPORT`, and it still runs and records a passed/no-op report when there are no relevant edits. Its BLOCKING findings join the cohort findings rather than starting their own fix loop; advisory findings are included in the gate report only and are not written to the Ultragoal ledger. 4. Delegate an `architect` review covering all three lanes: - architecture-side: system boundaries, layering, data/control flow, operational risks. diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts index 343d50c412..80aed11002 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts @@ -1,4 +1,5 @@ import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; export type UltragoalChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown"; @@ -89,10 +90,23 @@ async function hashUntrackedFiles(cwd: string, paths: readonly UltragoalChangeSe if (paths.length === 0) return undefined; try { const hasher = crypto.createHash("sha256"); + const root = path.resolve(cwd); for (const row of [...paths].sort((left, right) => left.path.localeCompare(right.path))) { + const filePath = path.resolve(root, row.path); + const relative = path.relative(root, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined; + const stat = await fs.lstat(filePath); hasher.update(row.path); hasher.update("\0"); - hasher.update(Buffer.from(await Bun.file(path.join(cwd, row.path)).arrayBuffer())); + if (stat.isSymbolicLink()) { + hasher.update("symlink\0"); + hasher.update(await fs.readlink(filePath)); + } else if (stat.isFile()) { + hasher.update("file\0"); + hasher.update(Buffer.from(await Bun.file(filePath).arrayBuffer())); + } else { + return undefined; + } hasher.update("\0"); } return `sha256:${hasher.digest("hex")}`; diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts index 83332f7dd5..26d7c010e1 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts @@ -4837,6 +4837,7 @@ function renderUltragoalHelp(args: readonly string[]): string | null { "", "USAGE", " $ gjc ultragoal quality-gate init [--surface ...] --out ", + " $ gjc ultragoal quality-gate source-hash [--json]", " $ gjc ultragoal quality-gate validate --quality-gate-json [--goal-id ] [--json]", "", "FLAGS", @@ -4848,6 +4849,7 @@ function renderUltragoalHelp(args: readonly string[]): string | null { "", "EXAMPLES", " $ gjc ultragoal quality-gate init --surface web --surface api --out ./quality-gate.json", + " $ gjc ultragoal quality-gate source-hash --json", " $ gjc ultragoal quality-gate validate --quality-gate-json ./quality-gate.json --json", "", ].join("\n"); @@ -4871,6 +4873,7 @@ function renderUltragoalHelp(args: readonly string[]): string | null { " record-critic-verdict", " record-critic-gate-override", " quality-gate init", + " quality-gate source-hash", " quality-gate validate", "", @@ -5256,6 +5259,27 @@ async function dispatchUltragoalCommand( case "quality-gate": { const positional = args.filter(arg => !arg.startsWith("-")); const subcommand = positional[1]; + if (subcommand === "source-hash") { + const changeSet = await computeCheckpointChangeSet(cwd); + const sourceHash = computeUltragoalReviewSourceHash(changeSet); + if (!sourceHash) { + return { + status: 1, + stderr: + "Unable to compute an authoritative source hash: change-set capture is incomplete, untrusted, or contains unknown-status paths.\n", + }; + } + const payload = { + sourceHash, + baseRef: changeSet?.baseRef, + mergeBase: changeSet?.mergeBase, + headRef: changeSet?.headRef, + pathCount: changeSet?.paths.length ?? 0, + }; + return json + ? { status: 0, stdout: `${JSON.stringify(payload, null, 2)}\n` } + : { status: 0, stdout: `${sourceHash}\n` }; + } if (subcommand === "init") { const out = flagValue(args, "--out"); if (!out?.trim()) { @@ -5279,7 +5303,7 @@ async function dispatchUltragoalCommand( if (subcommand !== "validate") { return { status: 1, - stderr: `Unknown gjc ultragoal quality-gate subcommand: ${subcommand ?? "(missing)"}; supported: init, validate\n`, + stderr: `Unknown gjc ultragoal quality-gate subcommand: ${subcommand ?? "(missing)"}; supported: init, source-hash, validate\n`, }; } const qualityGateJson = flagValue(args, "--quality-gate-json"); diff --git a/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json b/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json index 230c284596..c395409962 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json +++ b/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json @@ -854,6 +854,11 @@ "to": "intent", "verb": "write-artifact" }, + { + "from": "planner", + "to": "architect", + "verb": "write-artifact" + }, { "from": "intent", "to": "architect", diff --git a/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts b/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts index a38f93120c..48b9dccdbe 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts +++ b/packages/coding-agent/src/gjc-runtime/workflow-manifest.ts @@ -229,6 +229,8 @@ export const WORKFLOW_MANIFEST: Record terminalStates: ["final", "handoff"], transitions: [ { from: "planner", to: "intent", verb: "write-artifact" }, + // Legacy in-flight runs may have persisted planner before the intent stage existed. + { from: "planner", to: "architect", verb: "write-artifact" }, { from: "intent", to: "architect", verb: "write-artifact" }, { from: "intent", to: "revision", verb: "write-artifact" }, { from: "architect", to: "critic", verb: "write-artifact" }, diff --git a/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts index 180a459cc0..d959ac25c9 100644 --- a/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts +++ b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts @@ -218,6 +218,15 @@ interface RalplanProjectionInput extends RalplanFinalProjectionInput { lastReviewVerdictLane?: string; } +interface RalplanProjectionRow { + stage?: unknown; + stage_n?: unknown; + path?: unknown; + sha256?: unknown; + event?: unknown; + planning_stuck?: unknown; +} + /** * Build a recovery projection from the newest complete Ralplan `final` stage * row of a run. Returns undefined when no parseable final artifact exists — @@ -228,33 +237,27 @@ async function projectRalplanRunInternal( finalOnly: boolean, ): Promise { const runDir = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan", input.runId); - let rows: Array<{ stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown }> = []; + const rows: RalplanProjectionRow[] = []; try { const text = await fs.readFile(path.join(runDir, "index.jsonl"), "utf8"); - rows = text - .split(/\r?\n/) - .map(line => line.trim()) - .filter(line => line.length > 0) - .map(line => { - try { - const parsed = JSON.parse(line) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as { stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown }) - : undefined; - } catch { - return undefined; - } - }) - .filter((row): row is { stage?: unknown; stage_n?: unknown; path?: unknown; sha256?: unknown } => - Boolean(row), - ); + for (const line of text.split(/\r?\n/).map(value => value.trim())) { + if (line.length === 0) continue; + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + rows.push(parsed as RalplanProjectionRow); + } } catch { return undefined; } const finalRow = [...rows].reverse().find(row => row.stage === "final"); const planRow = [...rows].reverse().find(row => row.stage === "revision" || row.stage === "planner"); const artifactRow = finalOnly ? finalRow : (finalRow ?? planRow); - if (typeof artifactRow?.path !== "string" || artifactRow.path.trim().length === 0) return undefined; + if ( + typeof artifactRow?.path !== "string" || + artifactRow.path.trim().length === 0 || + typeof artifactRow.sha256 !== "string" + ) + return undefined; const artifactPath = await resolveRalplanArtifactPath(runDir, artifactRow.path); if (!artifactPath) return undefined; let markdown: string; @@ -289,14 +292,15 @@ async function projectRalplanRunInternal( for (const text of nonGoals) scope.push({ kind: "non_goal" as const, text }); const sha256 = await sha256File(artifactPath); if (!sha256) return undefined; - if (typeof artifactRow.sha256 === "string") { - const recorded = artifactRow.sha256.startsWith("sha256:") ? artifactRow.sha256 : `sha256:${artifactRow.sha256}`; - if (recorded !== sha256) return undefined; - } + const recorded = artifactRow.sha256.startsWith("sha256:") ? artifactRow.sha256 : `sha256:${artifactRow.sha256}`; + if (!/^sha256:[0-9a-f]{64}$/.test(recorded) || recorded !== sha256) return undefined; const stage = typeof artifactRow.stage === "string" ? artifactRow.stage : "unknown"; const latestStage = typeof rows.at(-1)?.stage === "string" ? rows.at(-1)?.stage : undefined; + const planningStuck = rows.some(row => row.event === "planning_stuck" && row.planning_stuck === true); let nextAction: WorkflowRecoveryProjection["nextAction"]; - if (stage === "final") { + if (planningStuck) { + nextAction = { actionClass: "awaiting-approval", detail: "planning-stuck" }; + } else if (stage === "final") { nextAction = { actionClass: "awaiting-approval" }; } else if (latestStage === "critic") { nextAction = @@ -354,13 +358,26 @@ export async function projectLatestRalplanRun(input: { sessionId: string; }): Promise { const root = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan"); + let stateText: string | undefined; try { - const stateText = await fs.readFile(modeStatePath(input.cwd, input.sessionId, "ralplan"), "utf8"); - const state = JSON.parse(stateText) as { + stateText = await fs.readFile(modeStatePath(input.cwd, input.sessionId, "ralplan"), "utf8"); + } catch (error) { + if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) { + return undefined; + } + } + if (stateText !== undefined) { + let state: { run_id?: unknown; last_review_verdict?: unknown; last_review_verdict_lane?: unknown; }; + try { + state = JSON.parse(stateText) as typeof state; + } catch { + return undefined; + } + if (state.run_id !== undefined && !isSafeRunId(state.run_id)) return undefined; if (isSafeRunId(state.run_id)) { return await projectRalplanRun({ ...input, @@ -370,8 +387,6 @@ export async function projectLatestRalplanRun(input: { typeof state.last_review_verdict_lane === "string" ? state.last_review_verdict_lane : undefined, }); } - } catch { - // Legacy or malformed mode state falls back to bounded run discovery. } try { const entries = await fs.readdir(root, { withFileTypes: true }); @@ -534,6 +549,7 @@ export function hashWorkflowRecoveryProjection(projection: WorkflowRecoveryProje objective: projection.objective, scope: projection.scope, acceptanceCriteria: projection.acceptanceCriteria, + unresolved: projection.unresolved, provenance: projection.provenance, currentGoal: projection.currentGoal, progressBasis: { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index fa13563654..5b58ca77bd 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -11856,9 +11856,6 @@ export class AgentSession { // safely: malformed/stale/tampered durable state leaves the snapshot.workflowRecovery = snapshot.goal?.status === "paused" ? undefined : await this.#projectWorkflowRecovery(); - // #4560: track measurable durable progress across compaction - // observations so a stalled continuation loop is detected and - // escalated instead of running forever. if (snapshot.workflowRecovery && options.trackWorkflowRecoveryProgress) { this.#workflowRecoveryMemory = trackWorkflowRecoveryZeroProgress( this.#workflowRecoveryMemory, @@ -15162,7 +15159,6 @@ export class AgentSession { if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); } - const compactionEntryId = this.sessionManager.appendCompaction( summary, shortSummary, diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts index f19f6cd669..1942f81666 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts @@ -2289,7 +2289,7 @@ describe("native GJC ultragoal runtime", () => { const unknown = await runNativeUltragoalCommand(["quality-gate", "collect"], root); expect(unknown.status).toBe(1); - expect(unknown.stderr).toContain("supported: init, validate"); + expect(unknown.stderr).toContain("supported: init, source-hash, validate"); const missing = await runNativeUltragoalCommand(["quality-gate", "validate"], root); expect(missing.status).toBe(1); diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts index 0454964452..9e1dad73a3 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts @@ -7,6 +7,7 @@ import { computeCheckpointChangeSet, computeUltragoalReviewSourceHash, createUltragoalPlan, + runNativeUltragoalCommand, validateUltragoalQualityGateReadOnly, } from "@gajae-code/coding-agent/gjc-runtime/ultragoal-runtime"; @@ -130,12 +131,35 @@ describe("ultragoal validation lane selection gate (#4560)", () => { } async function sourceHash(): Promise { - const changeSet = await computeCheckpointChangeSet(root); - const hash = computeUltragoalReviewSourceHash(changeSet); - if (!hash) throw new Error("expected an authoritative source hash"); - return hash; + const result = await runNativeUltragoalCommand(["quality-gate", "source-hash", "--json"], root); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout ?? "{}") as { sourceHash?: unknown }; + if (typeof payload.sourceHash !== "string") throw new Error("expected an authoritative source hash"); + return payload.sourceHash; } + it("prints the same authoritative source hash through the supported CLI surface", async () => { + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const cliHash = await sourceHash(); + const internalHash = computeUltragoalReviewSourceHash(await computeCheckpointChangeSet(root)); + if (!internalHash) throw new Error("expected internal authoritative source hash"); + expect(cliHash).toBe(internalHash); + }); + + it("hashes an untracked symlink by link identity without reading its external target", async () => { + if (process.platform === "win32") return; + await seedPlan(1, { "packages/utils/src/helper.ts": "export const x = 1;\n" }); + const outside = await tempDir(); + cleanup.push(outside); + const target = path.join(outside, "secret.txt"); + await Bun.write(target, "first secret\n"); + await fs.symlink(target, path.join(root, "external-link")); + const before = await sourceHash(); + await Bun.write(target, "changed secret\n"); + const after = await sourceHash(); + expect(after).toBe(before); + }); + /** Append a prior verified complete-checkpoint cohort with `sourceHash`. */ async function seedPriorCohort(sourceHash: string): Promise { const ledgerPath = path.join(root, ".gjc", `_session-${TEST_SESSION_ID}`, "ultragoal", "ledger.jsonl"); diff --git a/packages/coding-agent/test/gjc-runtime/workflow-manifest-phase-sets.test.ts b/packages/coding-agent/test/gjc-runtime/workflow-manifest-phase-sets.test.ts index 32fa5ecf89..1f4c4924d5 100644 --- a/packages/coding-agent/test/gjc-runtime/workflow-manifest-phase-sets.test.ts +++ b/packages/coding-agent/test/gjc-runtime/workflow-manifest-phase-sets.test.ts @@ -51,4 +51,12 @@ describe("workflow manifest phase sets", () => { ]), ); }); + + it("routes new ralplan runs through intent while retaining the legacy in-flight review edge", () => { + const manifest = getSkillManifest("ralplan"); + expect(manifest.states.map(state => state.id)).toContain("intent"); + expect(manifest.transitions).toContainEqual({ from: "planner", to: "intent", verb: "write-artifact" }); + expect(manifest.transitions).toContainEqual({ from: "intent", to: "architect", verb: "write-artifact" }); + expect(manifest.transitions).toContainEqual({ from: "planner", to: "architect", verb: "write-artifact" }); + }); }); diff --git a/packages/coding-agent/test/workflow-recovery-projection.test.ts b/packages/coding-agent/test/workflow-recovery-projection.test.ts index d6df627105..ef6ff7a94f 100644 --- a/packages/coding-agent/test/workflow-recovery-projection.test.ts +++ b/packages/coding-agent/test/workflow-recovery-projection.test.ts @@ -82,6 +82,7 @@ describe("workflow recovery projection (#4560)", () => { ["absolute", outsidePath, undefined], ["relative", "../../../../../outside.md", undefined], ["typed", 123, undefined], + ["missing-digest", "stage-01-final.md", undefined], ["digest", "stage-01-final.md", "0".repeat(64)], ] as const) { const runDir = ralplanRunDir(tempDir.path(), runId); @@ -99,9 +100,10 @@ describe("workflow recovery projection (#4560)", () => { it("uses the durable active ralplan run and skips unfinished legacy candidates", async () => { const validDir = ralplanRunDir(tempDir.path(), "valid-run"); await Bun.write(path.join(validDir, "stage-01-final.md"), FINAL_PLAN); + const validDigest = crypto.createHash("sha256").update(FINAL_PLAN).digest("hex"); await Bun.write( path.join(validDir, "index.jsonl"), - `${JSON.stringify({ stage: "final", stage_n: 1, path: "stage-01-final.md" })}\n`, + `${JSON.stringify({ stage: "final", stage_n: 1, path: "stage-01-final.md", sha256: validDigest })}\n`, ); const unfinishedDir = ralplanRunDir(tempDir.path(), "unfinished-run"); await Bun.write( @@ -119,6 +121,45 @@ describe("workflow recovery projection (#4560)", () => { expect(activeUnfinished).toBeUndefined(); }); + it("keeps planning-stuck recovery terminal instead of reopening review", async () => { + const runDir = ralplanRunDir(tempDir.path(), "stuck-run"); + await Bun.write(path.join(runDir, "stage-01-planner.md"), FINAL_PLAN); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ + stage: "planner", + stage_n: 1, + path: "stage-01-planner.md", + sha256: crypto.createHash("sha256").update(FINAL_PLAN).digest("hex"), + })}\n${JSON.stringify({ event: "planning_stuck", planning_stuck: true })}\n`, + ); + await Bun.write( + path.join(tempDir.path(), ".gjc", `_session-${SESSION_ID}`, "state", "ralplan-state.json"), + JSON.stringify({ run_id: "stuck-run" }), + ); + const projection = await projectLatestRalplanRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + expect(projection?.nextAction).toEqual({ actionClass: "awaiting-approval", detail: "planning-stuck" }); + }); + + it("does not fall back to another run when ralplan mode state is tampered", async () => { + const runDir = ralplanRunDir(tempDir.path(), "valid-run"); + await Bun.write(path.join(runDir, "stage-01-final.md"), FINAL_PLAN); + await Bun.write( + path.join(runDir, "index.jsonl"), + `${JSON.stringify({ + stage: "final", + stage_n: 1, + path: "stage-01-final.md", + sha256: crypto.createHash("sha256").update(FINAL_PLAN).digest("hex"), + })}\n`, + ); + await Bun.write( + path.join(tempDir.path(), ".gjc", `_session-${SESSION_ID}`, "state", "ralplan-state.json"), + "{not-json", + ); + await expect(projectLatestRalplanRun({ cwd: tempDir.path(), sessionId: SESSION_ID })).resolves.toBeUndefined(); + }); + it("degrades safely for malformed ralplan index (no final row)", async () => { const runDir = ralplanRunDir(tempDir.path(), "run-2"); await Bun.write(path.join(runDir, "index.jsonl"), "not-json\n"); From dc9e61ba5c18067e500972b33eaedfdaefbb4d35 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Sat, 15 Aug 2026 15:55:36 +0000 Subject: [PATCH 5/6] fix(workflows): scope CI change paths to their workspace (#4560) Affected-task CI exports repository-wide CI_DEV_CHANGED_PATHS into every test process. Standalone temporary git repositories inherited those unrelated unknown-status paths, so the authoritative source-hash command correctly failed closed even though their own change sets were complete. Bind CI changed-path evidence only when the inspected repository is inside GITHUB_WORKSPACE, preserve known Git status over duplicate CI pathname-only rows, and cover both independent-repository isolation and canonical-workspace fail-closed participation. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 61af32d9 Constraint: unknown-status paths remain fail-closed in the authoritative workspace Rejected: deleting CI_DEV_CHANGED_PATHS only in the test | leaves production nested-repository hashing environment-dependent Confidence: high Scope-risk: narrow Reversibility: trivial Tested: validation-lanes under representative CI_DEV_CHANGED_PATHS/GITHUB_WORKSPACE; change-set tests; ultragoal runtime/review; coding-agent typecheck --- ...covery-and-risk-proportional-validation.md | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- .../src/gjc-runtime/ultragoal-change-set.ts | 16 +++++- .../gjc-runtime/ultragoal-change-set.test.ts | 54 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/workflow-recovery-and-risk-proportional-validation.md b/docs/workflow-recovery-and-risk-proportional-validation.md index c248237629..3984868f1a 100644 --- a/docs/workflow-recovery-and-risk-proportional-validation.md +++ b/docs/workflow-recovery-and-risk-proportional-validation.md @@ -51,7 +51,7 @@ Omission mechanics: ### Unchanged-basis rerun avoidance -Run `gjc ultragoal quality-gate source-hash --json` on the clean frozen snapshot to obtain the only accepted cohort hash. `basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash and the gate's current cohort hash both equal that runtime-computed digest, and no review blockers reopened. The digest binds integration base, merge base, normalized path/status rows, captured diff, and the identity/content of untracked files without following symlink targets. A changed source, a review fix, an integration-base change, incompletely captured content, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. +Run `gjc ultragoal quality-gate source-hash --json` on the clean frozen snapshot to obtain the only accepted cohort hash. `basisUnchanged` is true only when the newest ledger-recorded joined cohort source hash and the gate's current cohort hash both equal that runtime-computed digest, and no review blockers reopened. The digest binds integration base, merge base, normalized path/status rows, captured diff, and the identity/content of untracked files without following symlink targets. CI changed-path metadata participates only when the inspected Git root equals its authoritative `GITHUB_WORKSPACE`; an independent nested/temp repository cannot inherit unrelated outer-workspace paths. A changed source, a review fix, an integration-base change, incompletely captured content, or invalidated evidence forces a full rerun exactly as before; cohort parallelism and the frozen-source-hash lane binding are untouched whenever lanes run. ## Comparative and forced-compaction evidence diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cde7909a84..97c27178b5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,7 +38,7 @@ ### Added -- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and a runtime-computed source-basis digest exposed by `gjc ultragoal quality-gate source-hash`. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. Workflow-enforcement changes classify themselves as high-risk, untracked files are content/identity hashed without following symlinks, and high-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, and untrusted changes retain the full cohort. Terminal-critic reuse additionally requires the current and prior cohort hashes to match the authoritative digest. +- Ultragoal boundary validation is now risk-proportional and runtime-authoritative (#4560): a deterministic applicability policy selects expensive boundary lanes from trusted change-set facts, plan shape, open review blockers, and a runtime-computed source-basis digest exposed by `gjc ultragoal quality-gate source-hash`. Low-risk single-goal boundaries may omit redundant cleaner and architect review only through an exact runtime-validated `validationLaneSelection` proof; QA, targeted verification, frozen `sourceHash` binding, receipts, provenance, and join-before-repair remain mandatory. Workflow-enforcement changes classify themselves as high-risk, untracked files are content/identity hashed without following symlinks, and CI changed-path metadata is bound only when the inspected Git root is the authoritative `GITHUB_WORKSPACE`, so outer workflow metadata cannot poison an independent nested/test repository. High-risk, multi-goal, computer/shared-registry, migration, security, native, SDK/public-contract, agent-wire, incomplete, and untrusted changes retain the full cohort. Terminal-critic reuse additionally requires the current and prior cohort hashes to match the authoritative digest. ### Changed diff --git a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts index 80aed11002..aeb32cbba1 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-change-set.ts @@ -243,13 +243,25 @@ export function ciDevChangedPathRows(): UltragoalChangeSetPath[] { export function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChangeSetPath[] { const byKey = new Map(); - for (const row of groups.flat()) byKey.set(`${row.oldPath ?? ""}\u0000${row.path}`, row); + for (const row of groups.flat()) { + const key = `${row.oldPath ?? ""}\u0000${row.path}`; + const existing = byKey.get(key); + if (existing && existing.status !== "unknown" && row.status === "unknown") continue; + byKey.set(key, row); + } return [...byKey.values()]; } export async function computeCheckpointChangeSet(cwd: string): Promise { - const ciChangedPaths = ciDevChangedPathRows(); + let ciChangedPaths = ciDevChangedPathRows(); const inGit = await spawnText(["git", "rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 3000 }); + const workspace = process.env.GITHUB_WORKSPACE?.trim(); + if (workspace) { + const topLevel = inGit.ok + ? await spawnText(["git", "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 3000 }) + : undefined; + if (!topLevel?.ok || path.resolve(topLevel.stdout.trim()) !== path.resolve(workspace)) ciChangedPaths = []; + } if (!inGit.ok || inGit.stdout.trim() !== "true") { if (ciChangedPaths.length === 0) return { source: "checkpoint-git", paths: [], captureIncomplete: true, trusted: true }; diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-change-set.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-change-set.test.ts index 7291a1bee6..835ce7b3d3 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-change-set.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-change-set.test.ts @@ -4,12 +4,66 @@ import * as os from "node:os"; import * as path from "node:path"; import { computeCheckpointChangeSet, + computeUltragoalReviewSourceHash, + mergeChangeSetPaths, parseGitNameStatus, parseGitUntrackedPaths, spawnText, } from "@gajae-code/coding-agent/gjc-runtime/ultragoal-change-set"; describe("ultragoal change-set extraction", () => { + it("keeps authoritative Git status when CI path metadata only knows the pathname", () => { + expect( + mergeChangeSetPaths([ + [{ path: "packages/utils/src/helper.ts", status: "modified" }], + [{ path: "packages/utils/src/helper.ts", status: "unknown" }], + ]), + ).toEqual([{ path: "packages/utils/src/helper.ts", status: "modified" }]); + }); + + it("ignores outer-workspace CI paths for an independent repo but binds canonical workspace evidence", async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "ultragoal-ci-workspace-")); + const root = await fs.mkdtemp(path.join(workspace, "nested-independent-")); + const savedWorkspace = process.env.GITHUB_WORKSPACE; + const savedChangedPaths = process.env.CI_DEV_CHANGED_PATHS; + try { + expect(await Bun.spawn(["git", "init"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited).toBe(0); + await Bun.write(path.join(root, "tracked.txt"), "baseline\n"); + expect( + await Bun.spawn(["git", "add", "tracked.txt"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited, + ).toBe(0); + expect( + await Bun.spawn( + ["git", "-c", "user.name=GJC Test", "-c", "user.email=test@example.invalid", "commit", "-m", "baseline"], + { cwd: root, stdout: "ignore", stderr: "ignore" }, + ).exited, + ).toBe(0); + expect( + await Bun.spawn(["git", "branch", "dev"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited, + ).toBe(0); + await Bun.write(path.join(root, "tracked.txt"), "changed\n"); + process.env.GITHUB_WORKSPACE = workspace; + process.env.CI_DEV_CHANGED_PATHS = "outer-only.ts"; + const independent = await computeCheckpointChangeSet(root); + expect(independent?.paths.map(row => row.path)).toEqual(["tracked.txt"]); + expect(computeUltragoalReviewSourceHash(independent)).toMatch(/^sha256:[0-9a-f]{64}$/); + + process.env.GITHUB_WORKSPACE = root; + const canonical = await computeCheckpointChangeSet(root); + expect(canonical?.paths).toContainEqual({ path: "outer-only.ts", status: "unknown", category: "other" }); + expect(computeUltragoalReviewSourceHash(canonical)).toBeUndefined(); + } finally { + if (savedWorkspace === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = savedWorkspace; + if (savedChangedPaths === undefined) delete process.env.CI_DEV_CHANGED_PATHS; + else process.env.CI_DEV_CHANGED_PATHS = savedChangedPaths; + await Promise.all([ + fs.rm(workspace, { recursive: true, force: true }), + fs.rm(root, { recursive: true, force: true }), + ]); + } + }); + it("preserves rename paths and categories", () => { expect(parseGitNameStatus("R100\told.ts\tpackages/coding-agent/src/tools/computer.ts\n")).toEqual([ { From 7f42da1013cd063e0d6deb57a5d4a5599d442757 Mon Sep 17 00:00:00 2001 From: Yeachan Heo Date: Sat, 15 Aug 2026 16:15:44 +0000 Subject: [PATCH 6/6] test(workflows): isolate synthetic CI path fixtures (#4560) The production workspace binding correctly ignored outer CI_DEV_CHANGED_PATHS for independent repositories, but several Ultragoal suites intentionally use that variable as synthetic change-set input for temporary repositories. GitHub's outer GITHUB_WORKSPACE made those fixtures indistinguishable from accidental environment leakage and caused the authoritative runtime shard to fail. Clear and restore GITHUB_WORKSPACE only around the suites that explicitly simulate CI_DEV_CHANGED_PATHS fallback, leaving production unknown-status and canonical-workspace behavior unchanged. Co-authored-by: DoYunHa <102889891+HaD0Yun@users.noreply.github.com> Lore-id: 7b149ac0 Constraint: test isolation must not alter production fail-closed hashing Rejected: weakening workspace equality in production | reintroduces the original nested-repository leak Confidence: high Scope-risk: narrow Reversibility: trivial Tested: ultragoal-runtime under representative GitHub CI env 181 pass; related synthetic CI suites 35 pass; scope suites 20 pass; coding-agent typecheck --- .../test/gjc-runtime/ultragoal-critic-gate.test.ts | 4 ++++ .../coding-agent/test/gjc-runtime/ultragoal-dogfood.test.ts | 5 +++++ .../gjc-runtime/ultragoal-durable-completion-release.test.ts | 5 +++++ .../coding-agent/test/gjc-runtime/ultragoal-review.test.ts | 5 +++++ .../coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts | 5 +++++ 5 files changed, 24 insertions(+) diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-critic-gate.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-critic-gate.test.ts index cd5c755658..06cd5dd38d 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-critic-gate.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-critic-gate.test.ts @@ -34,6 +34,7 @@ const ORIGINAL_GJC_SESSION_ID = process.env.GJC_SESSION_ID; // not falsely triggered by captureIncomplete or git-command timeouts under // parallel shard load. const ORIGINAL_CI_DEV_CHANGED_PATHS = process.env.CI_DEV_CHANGED_PATHS; +const ORIGINAL_GITHUB_WORKSPACE = process.env.GITHUB_WORKSPACE; const tempRoots: string[] = []; async function tempDir(): Promise { @@ -44,6 +45,7 @@ async function tempDir(): Promise { beforeEach(() => { process.env.GJC_SESSION_ID = TEST_SESSION_ID; + delete process.env.GITHUB_WORKSPACE; process.env.CI_DEV_CHANGED_PATHS = "packages/coding-agent/test/gjc-runtime/ultragoal-critic-gate.test.ts"; }); @@ -56,6 +58,8 @@ afterAll(() => { else process.env.GJC_SESSION_ID = ORIGINAL_GJC_SESSION_ID; if (ORIGINAL_CI_DEV_CHANGED_PATHS === undefined) delete process.env.CI_DEV_CHANGED_PATHS; else process.env.CI_DEV_CHANGED_PATHS = ORIGINAL_CI_DEV_CHANGED_PATHS; + if (ORIGINAL_GITHUB_WORKSPACE === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = ORIGINAL_GITHUB_WORKSPACE; }); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PNG_CRC_TABLE = new Uint32Array(256).map((_, index) => { diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-dogfood.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-dogfood.test.ts index 31f4f9ed79..78a831bc01 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-dogfood.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-dogfood.test.ts @@ -14,6 +14,7 @@ const TEST_SESSION_ID = "test-session"; const tempRoots: string[] = []; let savedSessionId: string | undefined; let savedCiDevChangedPaths: string | undefined; +let savedGithubWorkspace: string | undefined; afterEach(async () => { await Promise.all(tempRoots.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); @@ -24,15 +25,19 @@ afterAll(() => { else process.env.GJC_SESSION_ID = savedSessionId; if (savedCiDevChangedPaths === undefined) delete process.env.CI_DEV_CHANGED_PATHS; else process.env.CI_DEV_CHANGED_PATHS = savedCiDevChangedPaths; + if (savedGithubWorkspace === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = savedGithubWorkspace; }); beforeAll(() => { savedSessionId = process.env.GJC_SESSION_ID; savedCiDevChangedPaths = process.env.CI_DEV_CHANGED_PATHS; + savedGithubWorkspace = process.env.GITHUB_WORKSPACE; }); beforeEach(() => { process.env.GJC_SESSION_ID = TEST_SESSION_ID; + delete process.env.GITHUB_WORKSPACE; // Temp dirs live outside the enclosing git work tree (os.tmpdir) so // computeCheckpointChangeSet falls through to the CI_DEV_CHANGED_PATHS-only // path. Pin a non-computer path so the mandatory computer red-team suite is diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-durable-completion-release.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-durable-completion-release.test.ts index c8f408105f..ec94710060 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-durable-completion-release.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-durable-completion-release.test.ts @@ -29,12 +29,15 @@ const tempRoots: string[] = []; let savedSessionId: string | undefined; let savedSessionFile: string | undefined; let savedCiDevChangedPaths: string | undefined; +let savedGithubWorkspace: string | undefined; beforeEach(() => { savedSessionId = process.env.GJC_SESSION_ID; savedSessionFile = process.env.GJC_SESSION_FILE; + savedGithubWorkspace = process.env.GITHUB_WORKSPACE; process.env.GJC_SESSION_ID = TEST_SESSION_ID; delete process.env.GJC_SESSION_FILE; + delete process.env.GITHUB_WORKSPACE; // Temp dirs live outside the enclosing git work tree (os.tmpdir) so // computeCheckpointChangeSet falls through to the CI_DEV_CHANGED_PATHS-only // path. Pin a non-computer path so the mandatory computer red-team suite is @@ -52,6 +55,8 @@ afterEach(async () => { else process.env.GJC_SESSION_FILE = savedSessionFile; if (savedCiDevChangedPaths === undefined) delete process.env.CI_DEV_CHANGED_PATHS; else process.env.CI_DEV_CHANGED_PATHS = savedCiDevChangedPaths; + if (savedGithubWorkspace === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = savedGithubWorkspace; await Promise.all(tempRoots.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); }); diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-review.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-review.test.ts index b98fe8981a..2a564efede 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-review.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-review.test.ts @@ -16,6 +16,7 @@ const TEST_SESSION_ID = "test-session"; const tempRoots: string[] = []; let savedSessionId: string | undefined; let savedCiDevChangedPaths: string | undefined; +let savedGithubWorkspace: string | undefined; async function runGit(cwd: string, args: string[]): Promise { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); @@ -30,10 +31,12 @@ async function runGit(cwd: string, args: string[]): Promise { beforeAll(() => { savedSessionId = process.env.GJC_SESSION_ID; savedCiDevChangedPaths = process.env.CI_DEV_CHANGED_PATHS; + savedGithubWorkspace = process.env.GITHUB_WORKSPACE; }); beforeEach(() => { process.env.GJC_SESSION_ID = TEST_SESSION_ID; + delete process.env.GITHUB_WORKSPACE; // Temp dirs live outside the enclosing git work tree (os.tmpdir) and each // inits its own standalone git repo. computeCheckpointChangeSet still // merges CI_DEV_CHANGED_PATHS into the computed change set. Pin a @@ -63,6 +66,8 @@ afterAll(() => { else process.env.GJC_SESSION_ID = savedSessionId; if (savedCiDevChangedPaths === undefined) delete process.env.CI_DEV_CHANGED_PATHS; else process.env.CI_DEV_CHANGED_PATHS = savedCiDevChangedPaths; + if (savedGithubWorkspace === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = savedGithubWorkspace; }); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PNG_CRC_TABLE = new Uint32Array(256).map((_, index) => { diff --git a/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts b/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts index 1942f81666..45dab832b4 100644 --- a/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts @@ -51,6 +51,7 @@ let savedSessionFile: string | undefined; // computer surface was touched. batchTempDir overrides this with its own batch // paths; the explicit CI-leak tests override within their own scope. const ORIGINAL_CI_DEV_CHANGED_PATHS = process.env.CI_DEV_CHANGED_PATHS; +const ORIGINAL_GITHUB_WORKSPACE = process.env.GITHUB_WORKSPACE; const NON_COMPUTER_TEST_PATH = "packages/coding-agent/test/gjc-runtime/ultragoal-runtime.test.ts"; beforeEach(() => { @@ -58,6 +59,8 @@ beforeEach(() => { savedSessionFile = process.env.GJC_SESSION_FILE; process.env.GJC_SESSION_ID = TEST_SESSION_ID; delete process.env.GJC_SESSION_FILE; + // These fixtures intentionally simulate CI path evidence for independent temp repos. + delete process.env.GITHUB_WORKSPACE; process.env.CI_DEV_CHANGED_PATHS = NON_COMPUTER_TEST_PATH; }); @@ -92,6 +95,8 @@ afterEach(async () => { else process.env.GJC_SESSION_FILE = savedSessionFile; if (ORIGINAL_CI_DEV_CHANGED_PATHS === undefined) delete process.env.CI_DEV_CHANGED_PATHS; else process.env.CI_DEV_CHANGED_PATHS = ORIGINAL_CI_DEV_CHANGED_PATHS; + if (ORIGINAL_GITHUB_WORKSPACE === undefined) delete process.env.GITHUB_WORKSPACE; + else process.env.GITHUB_WORKSPACE = ORIGINAL_GITHUB_WORKSPACE; await Promise.all(tempRoots.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); });