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..3984868f1a --- /dev/null +++ b/docs/workflow-recovery-and-risk-proportional-validation.md @@ -0,0 +1,74 @@ +# 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, 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**: 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: + +- **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 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 + +`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 with a runtime-computed source-basis digest, +- exactly one outstanding goal, +- no open review blockers, +- 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). + +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`, `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 + +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 + +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 + +- `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..97c27178b5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,15 @@ - 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 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 + +- 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. - 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/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/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/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..aeb32cbba1 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,119 @@ -import { - categorizeComputerChangePath, - normalizeChangeSetPath, - type UltragoalChangeSet, - type UltragoalChangeSetPath, - type UltragoalChangeStatus, -} from "./ultragoal-runtime"; +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"; +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"); + 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"); + 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")}`; + } catch { + return undefined; + } +} export async function spawnText( command: string[], @@ -134,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 }; @@ -177,11 +298,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 cfc261d40a..26d7c010e1 100644 --- a/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-runtime.ts @@ -62,8 +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, @@ -1526,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", @@ -1562,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 @@ -2500,6 +2479,102 @@ 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; + } + 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")) { + 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 +2584,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 +2603,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 +2705,7 @@ async function validateCompletionQualityGate( "executorQa", "iteration", "validationBatchClose", + "validationLaneSelection", "criticReview", ]); const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key)); @@ -2643,8 +2731,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 +2749,38 @@ 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 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, + 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, + authoritativeSourceHash, + }); + let laneSelection: DeclaredValidationLaneSelection | undefined; + found.check("validationLaneSelection", "selection_invalid", () => { + 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 +2805,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 +2898,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), @@ -4212,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") { @@ -4722,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", @@ -4733,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"); @@ -4756,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", "", @@ -5141,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()) { @@ -5164,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/ultragoal-validation-policy.ts b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts new file mode 100644 index 0000000000..4aff201c7f --- /dev/null +++ b/packages/coding-agent/src/gjc-runtime/ultragoal-validation-policy.ts @@ -0,0 +1,186 @@ +/** + * 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, + normalizeRepoPath, + type UltragoalChangeSet, + type UltragoalChangeSetPath, +} from "./ultragoal-change-set"; + +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; + /** Runtime-computed digest of the authoritative current source basis. */ + authoritativeSourceHash?: string; +} + +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-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", + "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.ts", + "packages/coding-agent/src/session/session-manager.ts", + "packages/coding-agent/src/session/session-manager-internal.ts", + "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") + .map(normalizeRepoPath); + 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") + .map(normalizeRepoPath); + 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 (!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"); + 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) && + 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"]), + 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-manifest.generated.json b/packages/coding-agent/src/gjc-runtime/workflow-manifest.generated.json index 71c1dd384f..c395409962 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" }, @@ -846,11 +849,26 @@ "handoff" ], "transitions": [ + { + "from": "planner", + "to": "intent", + "verb": "write-artifact" + }, { "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", @@ -876,6 +894,11 @@ "to": "revision", "verb": "write-artifact" }, + { + "from": "revision", + "to": "intent", + "verb": "write-artifact" + }, { "from": "revision", "to": "post-interview", @@ -916,6 +939,11 @@ "to": "handoff", "verb": "handoff" }, + { + "from": "intent", + "to": "handoff", + "verb": "handoff" + }, { "from": "architect", "to": "handoff", @@ -1099,6 +1127,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..48b9dccdbe 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,17 @@ 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" }, { 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 +247,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 +265,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 new file mode 100644 index 0000000000..d959ac25c9 --- /dev/null +++ b/packages/coding-agent/src/gjc-runtime/workflow-recovery-projection.ts @@ -0,0 +1,564 @@ +/** + * 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 { modeStatePath, 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" + | "run-plan-review" + | "revise-plan" + | "reconcile-intent" + | "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))); +} + +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 + * 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; +} + +interface RalplanProjectionInput extends RalplanFinalProjectionInput { + lastReviewVerdict?: string; + 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 — + * compaction then degrades to the thin projection instead of guessing. + */ +async function projectRalplanRunInternal( + input: RalplanProjectionInput, + finalOnly: boolean, +): Promise { + const runDir = path.join(sessionPlansDir(input.cwd, input.sessionId), "ralplan", input.runId); + const rows: RalplanProjectionRow[] = []; + try { + const text = await fs.readFile(path.join(runDir, "index.jsonl"), "utf8"); + 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 || + typeof artifactRow.sha256 !== "string" + ) + return undefined; + const artifactPath = await resolveRalplanArtifactPath(runDir, artifactRow.path); + if (!artifactPath) return undefined; + 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 primaryAcceptance = sectionListItems( + findSection(sections, ["acceptance criteria", "verification", "test plan"]), + MAX_CRITERIA_ITEMS, + ); + const acceptance = + 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( + 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); + if (!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 (planningStuck) { + nextAction = { actionClass: "awaiting-approval", detail: "planning-stuck" }; + } else 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: stage === "final" ? "ralplan-final" : "ralplan-run", + objective, + scope, + acceptanceCriteria: acceptance, + unresolved, + provenance: { planPath: artifactPath, runId: input.runId, stage, sha256 }, + progress: {}, + 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"); + let stateText: string | undefined; + try { + 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, + 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, + }); + } + } + 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 + * 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(() => 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]; + 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, + unresolved: projection.unresolved, + 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..5b58ca77bd 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -309,6 +309,14 @@ import { registerCoordinatorRuntimeStateFinalizer, UNPROVEN_TOOL_LABEL, } from "../gjc-runtime/session-state-sidecar"; +import { + isWorkflowRecoveryStalled, + projectLatestRalplanRun, + 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 +492,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 +506,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 +6239,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, @@ -11697,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: [], @@ -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 = + snapshot.goal?.status === "paused" ? undefined : await this.#projectWorkflowRecovery(); + if (snapshot.workflowRecovery && options.trackWorkflowRecoveryProgress) { + this.#workflowRecoveryMemory = trackWorkflowRecoveryZeroProgress( + this.#workflowRecoveryMemory, + snapshot.workflowRecovery, + ); + } + if (snapshot.workflowRecovery && this.#workflowRecoveryMemory) { + 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,33 @@ 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 ?? []).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")) { + const projection = await projectUltragoalRun({ cwd, sessionId: this.sessionId }).catch(() => undefined); + if (projection) return projection; + } + if (entries.some(entry => entry.skill === "ralplan")) { + const projection = await projectLatestRalplanRun({ cwd, sessionId: this.sessionId }).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 }> = []; + #compactionStateContext(snapshot: CompactionStateSnapshot): string[] { const context: string[] = []; const goal = snapshot.goal; @@ -11780,6 +11945,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; } @@ -14883,7 +15050,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) { @@ -14992,7 +15159,6 @@ export class AgentSession { if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); } - const compactionEntryId = this.sessionManager.appendCompaction( summary, shortSummary, @@ -16938,7 +17104,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 new file mode 100644 index 0000000000..28bd78f16c --- /dev/null +++ b/packages/coding-agent/test/agent-session-workflow-recovery-continuation.test.ts @@ -0,0 +1,245 @@ +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"; +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, + }), + ); + } + + 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(); + 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"); + 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 () => { + 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/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-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([ { 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 f19f6cd669..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 }))); }); @@ -2289,7 +2294,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 new file mode 100644 index 0000000000..9e1dad73a3 --- /dev/null +++ b/packages/coding-agent/test/gjc-runtime/ultragoal-validation-lanes.test.ts @@ -0,0 +1,295 @@ +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 { + computeCheckpointChangeSet, + computeUltragoalReviewSourceHash, + createUltragoalPlan, + runNativeUltragoalCommand, + 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 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); + } +} + +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 })); + } + + async function sourceHash(): Promise { + 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"); + 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" }); + 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, + 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 frozen = await sourceHash(); + 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(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 frozen = await sourceHash(); + const gate = baseGate(frozen, { qa: lane(frozen) }); + 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(await sourceHash(), {}); + 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 frozen = await sourceHash(); + const gate = baseGate(frozen, { + cleaner: lane(frozen, "CLEAR"), + architect: lane(frozen, "CLEAR"), + qa: lane(frozen), + }); + 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(frozen, { qa: lane(frozen) }); + 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); + }); + + 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/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/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 new file mode 100644 index 0000000000..ef6ff7a94f --- /dev/null +++ b/packages/coding-agent/test/workflow-recovery-projection.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as crypto from "node:crypto"; +import * as path from "node:path"; +import { + isHighRiskChangePath, + isMigrationChangePath, + resolveUltragoalValidationApplicability, +} from "@gajae-code/coding-agent/gjc-runtime/ultragoal-validation-policy"; +import { + hashWorkflowRecoveryProjection, + isWorkflowRecoveryStalled, + projectLatestRalplanRun, + 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"); + 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: 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" }); + 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("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], + ["missing-digest", "stage-01-final.md", 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); + 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", sha256: validDigest })}\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("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"); + 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: "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"); + 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?.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"); + const projection = await projectUltragoalRun({ cwd: tempDir.path(), sessionId: SESSION_ID }); + 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", + 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, + authoritativeSourceHash: "sha256:current", + }); + 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"); + 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", () => { + 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", + authoritativeSourceHash: "sha256:abc", + }); + expect(unchanged.basisUnchanged).toBe(true); + const changed = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + latestCohortSourceHash: "sha256:abc", + currentSourceHash: "sha256:xyz", + authoritativeSourceHash: "sha256:xyz", + }); + expect(changed.basisUnchanged).toBe(false); + const blocked = resolveUltragoalValidationApplicability({ + changeSet: lowRiskChangeSet, + totalGoals: 1, + completedGoals: 0, + latestCohortSourceHash: "sha256:abc", + currentSourceHash: "sha256:abc", + authoritativeSourceHash: "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); + }); +});