From 587ebb65f261cd679fbd61d4e549e7a52e4604a7 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 27 Sep 2026 23:20:23 +0200 Subject: [PATCH 01/19] fix: bind brownfield approval to the active plan --- bin/lib/lifecycle-preflight.mjs | 14 +- bin/lib/work-context.mjs | 17 ++ distilled/workflows/execute.md | 56 +++--- distilled/workflows/plan.md | 108 +++++------ tests/evals/native-brownfield/cli.mjs | 8 +- tests/evals/native-brownfield/journey.mjs | 66 ++++++- .../native-brownfield.test.mjs | 172 +++++++++++++++++- tests/evals/native-brownfield/seal.mjs | 39 +++- tests/gsdd.guards.test.cjs | 78 +++++++- 9 files changed, 455 insertions(+), 103 deletions(-) diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index 84ce478..04341d8 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -21,6 +21,7 @@ import { resolveActiveMilestoneDir, isValidApprovalReference, normalizeApprovalReference, + assertLifecyclePlanIdentity, hasDurableWorkflowApproval, transitionWorkflowState, } from './work-context.mjs'; @@ -1082,6 +1083,9 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, } const artifactKind = artifact ? lifecycleArtifactKind(artifact.path) : null; const planKind = plan ? lifecycleArtifactKind(plan.path) : null; + if (plan && ['plan', 'approve', 'execute'].includes(target)) { + assertLifecyclePlanIdentity(planningDir, target, plan.relative); + } const brownfieldChain = planKind === 'brownfield_plan'; const artifactKindAllowed = target === 'verify' && brownfieldChain ? ['brownfield_verification', 'brownfield_plan'].includes(artifactKind) @@ -1116,9 +1120,15 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, if (authority !== 'owner' && normalizedApprovalRef) { throw transitionErrorForCli('approval_ref_authority_mismatch', 'Only owner authority may carry an approval reference.', ['--authority owner', '--approval-ref']); } - let preWriteGuard = null; + let preWriteGuard = plan && ['plan', 'approve', 'execute'].includes(target) + ? () => assertLifecyclePlanIdentity(planningDir, target, plan.relative) + : null; if (brownfieldChain && target !== 'blocked' && target !== 'ask_user') { - preWriteGuard = () => assertBrownfieldTransitionContract({ planningDir, target, plan }); + const identityGuard = preWriteGuard; + preWriteGuard = () => { + identityGuard?.(); + assertBrownfieldTransitionContract({ planningDir, target, plan }); + }; preWriteGuard(); } const explicitOwnerApproval = target === 'approve' diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 98e3bd1..eab0a70 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -270,6 +270,22 @@ function transitionError(code, message, evidence = []) { return error; } +export function assertLifecyclePlanIdentity(planningDir, target, planIdentity) { + if (!['plan', 'approve', 'execute'].includes(String(target || '').toLowerCase())) return; + const lifecycle = evaluateLifecycleState({ planningDir }); + if (lifecycle.nonPhaseState !== 'active_brownfield_change') return; + const workspaceRoot = resolve(planningDir, '..'); + const canonicalPlan = normalizeSlashes(relative(workspaceRoot, join(planningDir, 'brownfield-change', 'CHANGE.md'))); + const suppliedPlan = normalizeSlashes(String(planIdentity || '')).replace(/^\.\//, ''); + if (suppliedPlan !== canonicalPlan) { + throw transitionError( + 'wrong_brownfield_plan_identity', + `Active brownfield authority requires the canonical CHANGE.md plan identity (${canonicalPlan}); refusing ${suppliedPlan || 'an unbound plan'} before lifecycle state changes.`, + [canonicalPlan, suppliedPlan].filter(Boolean), + ); + } +} + /** * Persist one validated lifecycle transition without changing any other state * fields. Callers must validate artifact identity and authority before calling @@ -325,6 +341,7 @@ export function transitionWorkflowState(workDir, { } const expectedPlan = planPath || planIdentity; + assertLifecyclePlanIdentity(workDir, normalizedTarget, expectedPlan); const recordedPlan = workflow.plan.path || workflow.plan.identity || null; if (recordedPlan && expectedPlan && recordedPlan !== expectedPlan) { throw transitionError('stale_state', 'Recorded plan authority does not match the supplied plan artifact.', [String(recordedPlan), String(expectedPlan)]); diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index b618d6d..68f3ea9 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -8,7 +8,7 @@ Resolve `requested_level`/`effective_level` from config/override; low/medium/hig Load only the context needed for the next safe action. Use these tiers instead of rereading every possible file before implementation. ### mandatory_now -Read before mutation: target `PLAN.md` frontmatter/current task/boundaries; bounded `.work/SPEC.md` current state, active requirement IDs, and relevant constraints; `.work/ROADMAP.md` phase goal/status/success criteria; immediately prior `.work/phases/*-SUMMARY.md` `` when present; and the preflight result from ``. +Read before mutation: for phase authority, read target phase `PLAN.md` frontmatter/current task/boundaries; bounded `.work/SPEC.md` current state, active requirement IDs, and constraints; `.work/ROADMAP.md` phase goal, status, and success criteria; prior phase SUMMARY `` when present; and phase preflight. For brownfield authority, read `.work/brownfield-change/CHANGE.md#Implementation Plan`, `HANDOFF.md` as judgment context only, and brownfield preflight; do not require phase PLAN frontmatter, roadmap membership, or a phase SUMMARY. A PLAN is historical only when its initial top-level frontmatter `status` resolves to `superseded` under lifecycle authority; body text and filenames do not imply supersession. During discovery, list historical PLANs as context or evidence but never schedule them or use them as a current execution or verification basis. If a historical PLAN is directly supplied, STOP before product or lifecycle writes and do not create a new SUMMARY.md or VERIFICATION.md from it. This is an agent-side refusal contract: existing phase-level lifecycle preflight remains the deterministic gate, but it does not validate an arbitrary caller-supplied PLAN path in a mixed phase. @@ -24,17 +24,16 @@ Read only when the current task or a deviation needs them: older phase summaries All `node .work/bin/gsdd.mjs ...` helper commands below assume the current working directory is the repo root. If the runtime launched from a subdirectory, change to the repo root before running them. -Before implementing or mutating any lifecycle artifact, run: -- `node .work/bin/gsdd.mjs lifecycle-preflight execute {phase_num} --expects-mutation phase-status` +Before implementing or mutating any lifecycle artifact, run the matching preflight: phase authority uses `node .work/bin/gsdd.mjs lifecycle-preflight execute {phase_num} --expects-mutation phase-status`; active brownfield authority uses `node .work/bin/gsdd.mjs lifecycle-preflight execute brownfield-change --expects-mutation phase-status` without `--plan` to select its owned-write lane, not to authorize phase-status updates. On `ambiguous_phase_selector`, rerun preflight with the emitted positional `phases/{phase_dir}` selector; keep the selected PLAN filesystem identity as `.work/phases/{phase_dir}/{plan_id}-PLAN.md` for reading and the lifecycle-transition commands below. If the preflight result is `blocked`, STOP and surface the blocker instead of inferring eligibility from workflow-local prose. Treat the preflight as an authorization seam over shared repo truth only: - it may authorize or reject execution - it does not mutate `.work/ROADMAP.md` by itself -- owned writes remain execution artifacts, and ROADMAP mutation stays explicit in `` via `node .work/bin/gsdd.mjs phase-status` +- owned writes remain execution artifacts; phase ROADMAP mutation stays explicit in `` via `node .work/bin/gsdd.mjs phase-status` -When the selected plan identity is `.work/brownfield-change/CHANGE.md`, execute one bounded stream +When the selected plan identity is `.work/brownfield-change/CHANGE.md`, execute the tasks in its `## Implementation Plan` as one bounded stream only. Re-read `CHANGE.md` as the operational authority and `HANDOFF.md` as judgment context; do not take scope or status from HANDOFF. Refuse execution without a concrete Done When list, when another active brownfield stream or milestone authority is present, when the posture is blocked or widening, @@ -50,11 +49,11 @@ Execution uses the same `Runtime` and `Assurance` types as planning and verifica Assurance is ordered: `unreviewed` -> `self_checked` -> `cross_runtime_checked`; same-runtime helpers never count as cross-runtime evidence. -Before executing tasks, read the plan artifact's `runtime`, `assurance`, and structured `` result; use `unreviewed` before any executor check, `self_checked` for self/same-runtime checking, and `cross_runtime_checked` only for a different runtime/vendor checker. +Before executing tasks, read the phase PLAN's `runtime`, `assurance`, and structured `` result, or the equivalent values in the brownfield `CHANGE.md#Implementation Plan` check block; use `unreviewed` before any executor check, `self_checked` for self/same-runtime checking, and `cross_runtime_checked` only for a different runtime/vendor checker. If execution begins from a stronger plan artifact into a weaker execution context, emit a structured `` with `source_artifact`, `source_runtime`, `source_assurance`, `current_runtime`, `current_assurance`, `status`, and `warning`; if plan runtime/assurance is missing, use `status: unknown`. -A phase often contains multiple plans. When invoked at the phase level (no specific plan provided), run this orchestration step first. +A phase often contains multiple plans. When invoked at the phase level (no specific plan provided), run this orchestration step first. This multi-plan scan applies only to phase authority; active brownfield authority has one plan identity and reads its ordered tasks from `CHANGE.md#Implementation Plan`. ### Discover Plans and Group by Wave 1. Scan `.work/phases/{phase_dir}/` for all `*-PLAN.md` files. 2. For each plan, read its frontmatter `wave` field (default: `wave: 1` if absent). @@ -93,17 +92,17 @@ If only a single plan was provided (the common case), skip this section entirely For each task in the plan, follow this loop: ```text -1. Read the plan frontmatter and current task. +1. Read the phase plan frontmatter and current task, or the current task from the brownfield `CHANGE.md#Implementation Plan`. 2. Read the task_scoped files and focused references needed for that task. -3. Before implementation, run `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --json`; stop if it refuses, then implement the task action. Do not pass `--approved` or `--approval-ref` here: explicit approval is an owner-only transition, and workflow execution consumes the plan's already-durable approval. -4. Run the task's verify steps. +3. Before implementation, run the lifecycle execute transition with the plan identity selected for this authority, and stop if it refuses. For an active brownfield change, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/brownfield-change/CHANGE.md --authority workflow --json`; for a phase, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --json`. Do not switch identities between planning, approval, and execution. Do not pass `--approved` or `--approval-ref` here: explicit approval is an owner-only transition, and workflow execution consumes the plan's already-durable approval. + When the transition succeeds, implement the task action. +4. Verify the task action using its planned steps. 5. Handle any git actions using repo or user conventions. 6. Re-read the plan's Objective and `non_goals`. Confirm the work just completed serves the objective and crossed no boundary; if not, record a deviation before continuing. -7. Record task completion in your working notes and final SUMMARY.md. +7. Record task completion in working notes; phase notes go in SUMMARY.md and brownfield evidence/next action go in CHANGE.md. ``` ### Frontmatter And Task Semantics -The executor consumes the plan schema defined by `/work-plan`: -- frontmatter keys: `phase`, `plan`, `type`, `wave`, `depends_on`, `files-modified`, `autonomous`, `requirements`, `browser_proof_required`, `browser_proof_rationale`, `must_haves`, `tdd` +For a phase, the executor consumes the `/work-plan` schema and its frontmatter keys: `phase`, `plan`, `type`, `wave`, `depends_on`, `files-modified`, `autonomous`, `requirements`, `browser_proof_required`, `browser_proof_rationale`, `must_haves`, `tdd`. For brownfield, consume task files/action/verify/done fields from `CHANGE.md#Implementation Plan`; phase frontmatter and phase task-state tracking are not required. - task types: - `type="auto"` - proceed without pausing - `type="checkpoint:user"` - stop for a required user decision or human-only step @@ -148,7 +147,7 @@ If a task frontmatter includes `tdd: true`, switch to RED-GREEN-REFACTOR cycle f **REFACTOR — Clean up** 1. Improve structure, naming, and clarity without changing behavior. 2. Run the tests again. Confirm they still pass. -3. Log the RED→GREEN→REFACTOR cycle in SUMMARY.md under the task entry. +3. Log the RED→GREEN→REFACTOR cycle under the task entry in phase SUMMARY.md or brownfield CHANGE.md. Non-TDD tasks skip this protocol and proceed with the standard verification flow. @@ -195,7 +194,7 @@ Git rules: - Do not mention phase, plan, task, or requirement IDs, or internal milestone labels, in commit messages, PR titles, or PR bodies unless explicitly requested. - Do not force one commit per task unless the repo or user asked for that. - **Commit-boundary identity check:** Only when this executor is about to make a Workspine-directed commit, before staging run `node .work/bin/gsdd.mjs git-identity check`. Capture and show the inspected author/committer identities, configuration origins, and fingerprint. A missing, placeholder, mismatch, or other refusal stops before staging. For an unusual bot/CI-style identity, obtain a cooperative explicit current-run confirmation for that exact fingerprint, then rerun with `--confirm `; this is auditable workflow evidence, not authentication. -- **Immediate recheck:** Immediately before the owned `git commit`, rerun `node .work/bin/gsdd.mjs git-identity check --expect ` (and the same `--confirm ` when confirmation was required). Drift or any refusal stops the commit. Record the fingerprint and resulting commit OID in SUMMARY.md. Do not run this identity check for planning, local lifecycle files, verification, or any other no-commit operation. +- **Immediate recheck:** Immediately before the owned `git commit`, rerun `node .work/bin/gsdd.mjs git-identity check --expect ` (and the same `--confirm ` when confirmation was required). Drift or refusal stops the commit. Record fingerprint and commit OID in phase SUMMARY.md or brownfield CHANGE.md. Do not run this check for planning, lifecycle files, verification, or other no-commit work. - **No default push or PR:** A local commit remains local by default. Push or PR creation occurs only when the resolved current user/approved-plan request, repository/team convention, and configured `gitProtocol` explicitly call for it. Never create either as a default side effect. @@ -216,7 +215,7 @@ Treat only hard mismatches as blocking by default: - any `architecture_risk_conflict` - a `factual_discovery` that is not deterministically recoverable -If a `factual_discovery` is local, deterministic, and recoverable, proceed with a recorded delta instead of bouncing immediately back to planning. That delta must be surfaced later in SUMMARY.md for downstream review and milestone audit. +If a `factual_discovery` is local, deterministic, and recoverable, proceed with a recorded delta instead of bouncing back to planning. Surface it in phase SUMMARY.md or brownfield CHANGE.md for downstream review. ### Rule 1: Auto-Fix Bugs @@ -272,7 +271,7 @@ Re-run failing `` commands character-for-character. Never reword, weaken -After completing all tasks in the plan: +For active brownfield authority, record task evidence and next action in `CHANGE.md`, set posture to `ready_for_verification`, then continue through brownfield `/work-verify`. Skip phase SPEC/ROADMAP updates, phase SUMMARY, and the phase transition below. The remaining procedure and summary template apply only to phase authority after all tasks complete. ### 1. Update `.work/SPEC.md` "Current State" Keep the update factual and compact: @@ -372,9 +371,9 @@ Write the structured sections honestly: Do not invent an inline PLAN task-state mutation scheme if the plan does not define one. Summary-driven progress tracking avoids silent drift between the plan contract and what execution actually completed. -**MANDATORY: You MUST write SUMMARY.md to disk at `.work/phases/{phase_dir}/{plan_id}-SUMMARY.md`. Output to conversation alone is NOT sufficient. If this file is not written to disk, execution is NOT complete.** +**MANDATORY:** Write SUMMARY.md to disk at `.work/phases/{phase_dir}/{plan_id}-SUMMARY.md`; conversation output alone is not sufficient. -After the SUMMARY artifact is durable and marked complete, record the execution-to-verification transition: +After the SUMMARY artifact is durable and marked complete, record the phase execution-to-verification transition: `node .work/bin/gsdd.mjs lifecycle-transition verify --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --artifact .work/phases/{phase_dir}/{plan_id}-SUMMARY.md --authority workflow --json`. The helper validates the exact artifact chain and updates only existing `.work/state.json`; do not hand-edit lifecycle state or call this before the SUMMARY exists. @@ -400,13 +399,13 @@ When encountering a checkpoint task: Auth errors (indicators: 401, 403, "Not authenticated", "Please run {tool} login", "Set {ENV_VAR}") are gates, not bugs. When an auth error occurs during a `type="auto"` task: - recognize it as an auth gate, not a deviation - STOP and return `checkpoint:user` with exact auth steps (CLI commands, env vars, verification command) -- document auth gates in SUMMARY.md as normal flow, not deviations +- document auth gates in phase SUMMARY.md or brownfield CHANGE.md as normal flow, not deviations In all checkpoint cases, return with the current progress and do not continue until resumed. -After completing all tasks and state updates, verify your own claims: +After completing tasks and the selected authority's updates, verify your claims: ```text For each completed task: @@ -414,10 +413,9 @@ For each completed task: [ ] Local verification passed For state updates: - [ ] .work/SPEC.md "Current State" is accurate - [ ] ROADMAP.md status remains open (`[-]` if status was updated) until verification passes - [ ] `node .work/bin/gsdd.mjs next --json` ran after reviewed SPEC and phase-status updates - [ ] SUMMARY.md exists with ``, ``, ``, and `` and reflects the actual work + [ ] Phase: SPEC Current State and ROADMAP status are accurate and remain open until verification + [ ] Phase: `gsdd next --json` ran after reviewed SPEC and phase-status updates; SUMMARY has ``, ``, ``, and `` sections + [ ] Brownfield: CHANGE.md records evidence and next action with `ready_for_verification`; no phase state or SUMMARY is created Overall: [ ] Any git actions taken match what you are reporting @@ -428,7 +426,7 @@ If any self-check fails, fix it and re-check before reporting completion. -Execution is done when all of these are true: +Phase authority is done when all of these are true: - [ ] All `type="auto"` tasks in the plan are implemented and verified - [ ] Any checkpoint task caused an explicit stop and handoff instead of silent continuation @@ -443,13 +441,15 @@ Execution is done when all of these are true: - [ ] `SUMMARY.md` includes structured ``, ``, ``, and `` sections - [ ] Self-check passed - [ ] Any git actions honor repo or user conventions and `.work/config.json` + +Brownfield authority is done when `CHANGE.md#Implementation Plan` tasks are complete and locally verified, evidence and next action are recorded in `CHANGE.md`, and posture is `ready_for_verification`; leave lifecycle verification and closure to brownfield `/work-verify`. Report what was accomplished, then present the next step: --- -**Completed:** Plan execution — created `.work/phases/{phase_dir}/{plan_id}-SUMMARY.md`. -**Next step:** Check `.work/config.json` → `workflow.verifier`: +**Completed:** Phase plan execution: created `.work/phases/{phase_dir}/{plan_id}-SUMMARY.md`; bounded brownfield execution: recorded evidence in `.work/brownfield-change/CHANGE.md` and moved the lane to `ready_for_verification`. +**Next step:** For brownfield, run `/work-verify` when CHANGE.md is `ready_for_verification`. For phase authority, check `.work/config.json` → `workflow.verifier`: - If `true`: run `/work-verify` — verify that the phase goal was achieved - If `false` (or key missing): run `/work-progress` — check status and route to the next phase diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 86fa5af..131e848 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -1,5 +1,5 @@ -You are the PLANNER. Your job is to take a phase from the roadmap and create a precise, actionable implementation plan. +You are the PLANNER. For phase authority or an explicitly selected bounded brownfield change, create a precise, actionable implementation plan. You think backward from the goal: what must be true, what artifacts prove it, and what tasks create those artifacts? Your plans are specific enough to be followed by an enthusiastic junior engineer with poor taste, no judgement, no project context, and an aversion to testing — without them needing to guess anything. @@ -12,12 +12,12 @@ Before starting, read these files: 2. `.work/ROADMAP.md` - find the target phase, its goal, requirements, success criteria, explicit out-of-scope, and stop/replan conditions when this is phase planning 3. `.work/brownfield-change/CHANGE.md`, `.work/brownfield-change/HANDOFF.md`, and `.work/brownfield-change/VERIFICATION.md` - if an active bounded brownfield change exists, classify whether the user request belongs to that lane before phase preflight 4. `.work/research/*.md` - if research exists and is relevant to this phase or bounded change -5. `.work/phases/*-APPROACH.md` - approach decisions from user discussion (if exists) -6. `.work/phases/*-PLAN.md` - any previous plans that affect this phase +5. For phase authority only: `.work/phases/*-APPROACH.md` - approach decisions from user discussion (if exists) +6. `.work/phases/*-PLAN.md` - previous phase plans that affect this phase 7. Relevant source code - if this phase or change builds on existing code, read the key files -8. `.work/phases/*-SUMMARY.md` for the prior completed phase - if a `` section is present, read all four sub-sections. The `` carries forward active constraints, unresolved uncertainty, decision posture, and anti-regression rules from the prior phase. Honor these as input context alongside SPEC.md decisions and APPROACH.md choices. -9. **Session-boundary fallback:** If no prior completed phase SUMMARY.md with a `` section was found in step 8, check whether `.work/.continue-here.bak` exists. If it does, read its `` section and honor the same four sub-sections as input context. After reading, run `node .work/bin/gsdd.mjs file-op delete .work/.continue-here.bak --missing ok` (auto-clean: the judgment has been absorbed into this session's context). -10. `.work/*-MILESTONE-AUDIT.md`, `.work/milestone/AUDIT.md`, `.work/evidence/manifest.json`, and recent `*-VERIFICATION.md` files - if this planning run is triggered by audit gaps, verification gaps, user-named tech debt, brownfield lane amendments, or incident docs that may require extending the roadmap. +8. For phase authority, read the prior phase SUMMARY and honor all four `` subsections when present. Brownfield uses CHANGE.md operational truth and HANDOFF.md judgment. +9. **Session-boundary fallback:** If prior phase SUMMARY (phase) or HANDOFF judgment (brownfield) is absent, read `.work/.continue-here.bak` if present, absorb its judgment, then run `node .work/bin/gsdd.mjs file-op delete .work/.continue-here.bak --missing ok`. +10. Read milestone audits, evidence manifests, and recent VERIFICATION files only when gaps, tech debt, brownfield amendments, or incidents may require roadmap extension. Classify the target before preflight: - For an explicitly chosen planned standalone change, select `brownfield-change`. If canonical `CHANGE.md` is absent, first confirm there is no competing active `brownfield-change*` stream or milestone/work authority, then instantiate `CHANGE.md`, `HANDOFF.md`, and `VERIFICATION.md` from the shipped templates with create-if-missing semantics and populate only the bounded `CHANGE.md` contract fields needed for preflight from the owner request. Run brownfield lifecycle preflight next; never overwrite user content or create another lane folder. If an existing active change fits its single goal, scope, done-when, next action, or declared write scope, reuse that lane; if it no longer fits one active stream, stop and route widening through `/work-new-project` or `/work-new-milestone` using the brownfield artifact family as preserved input. - If audit gaps, verification gaps, user-named tech debt, brownfield amendments, incident docs, or `gsdd next` state `fix_gaps` require adding new roadmap work, select `amend` as the planning target before normal phase selection. @@ -33,8 +33,7 @@ If the preflight result is `blocked`, STOP and report the blocker instead of inf When presenting the plan for approval, show its path and a short non-sensitive approval reference; use the owner's reference if one was supplied, otherwise propose one with the plan. Explicit owner approval of that identified plan also confirms the displayed reference. The owner can say "I approve this plan" without inventing an identifier or editing metadata. Silence, vague praise, and an agent-proposed reference are not approval. If the plan or reference was not identified when approval was given, clarify the binding before proceeding. -After owner approval, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in the PLAN frontmatter, then bind that decision to the exact PLAN bytes through the shared repo-local helper. Confirm successful recording to the owner; the agent handles the metadata. The plan approval is an owner decision; workflow agents must not simulate it with `--approved` or pass an approval reference while executing: -`node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. +After owner approval, bind the selected plan's exact bytes through the shared helper. For phase authority, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in its PLAN frontmatter. For active brownfield authority, show and approve `.work/brownfield-change/CHANGE.md` with that reference; do not create a phase PLAN or add phase frontmatter. Approval is owner-only: agents must not simulate it with `--approved` or pass an approval reference while executing. Phase command: `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. This is the only lifecycle-state writer for plan approval; if it fails, preserve the PLAN and stop with bounded evidence instead of editing `.work/state.json` directly. Use this entry mode of `/work-plan` when audit findings, verification gaps, user-named tech debt, brownfield amendments, incident docs, or `gsdd next` state `fix_gaps` need follow-up planning without a suitable existing phase. Reconcile latest `MILESTONE-AUDIT.md`, `.work/milestone/AUDIT.md`, failed verification reports, brownfield CHANGE/HANDOFF/VERIFICATION context, and incident docs; re-check every load-bearing source before using it as planning truth. @@ -53,18 +52,18 @@ Infer runtime from the launching surface when obvious: `.claude/` -> `claude-cod Assurance is ordered: `unreviewed` -> `self_checked` -> `cross_runtime_checked`; same-runtime helpers never count as cross-runtime evidence. -After ``, compare the current planning pass against the strongest upstream artifact available: same-phase prior plan first, otherwise prior completed phase SUMMARY or VERIFICATION. +After ``, phase authority compares against the same-phase plan, then prior completed phase SUMMARY or VERIFICATION; brownfield compares against its current CHANGE/HANDOFF contract. Use `unreviewed` before any checker result, `self_checked` for planner self-check or same-runtime checker, and `cross_runtime_checked` only for a different runtime/vendor checker. If current assurance is lower, write a structured `` near the top of the plan body with `source_artifact`, `source_runtime`, `source_assurance`, `current_runtime`, `current_assurance`, `status`, and `warning`. If upstream runtime/assurance is missing, use `status: unknown`. Before planning, acknowledge what is locked: -- Decisions in `.work/SPEC.md` "Key Decisions" - do not revisit them. -- Decisions in APPROACH.md "Implementation Decisions" - these are user-validated choices. Implement the chosen approaches, not alternatives. "Agent's Discretion" items give you flexibility. +- Decisions in `.work/SPEC.md` "Key Decisions" are locked for either authority; do not revisit them. +- Phase authority: APPROACH.md "Implementation Decisions" are locked. Brownfield authority: owner-confirmed CHANGE.md decisions are locked; no phase APPROACH is required. - Patterns from previous phases - match existing conventions. Do not introduce new patterns without cause. -- Deferred items - items marked v2, nice-to-have, or out of scope in SPEC.md or APPROACH.md. Do not plan for them. -- Cross-check: if SPEC.md and APPROACH.md disagree on whether an item is must-have or deferred, stop and ask the user before planning. Do not silently adopt one classification over the other. +- Deferred items - do not plan items marked v2, nice-to-have, or out of scope in SPEC.md; phase APPROACH deferrals also apply to phase authority. +- Cross-check phase SPEC/APPROACH disagreements; stop and ask rather than silently adopting one classification. If you need to challenge a locked decision: stop, ask the developer, and document the new decision explicitly. @@ -99,14 +98,14 @@ Quality gate: do not proceed to goal-backward planning if you have unresolved un ### When This Runs After research_check, before goal_backward_planning. -### Classify Each Phase Requirement and Success Criterion -For each requirement and success criterion in this phase, assign one of: -- **Resolved**: the behavior is testable, the Done-When is unambiguous, and the decision is locked (codebase fact or explicit SPEC.md/APPROACH.md decision). Proceed. +### Classify Each Requirement or Done When Criterion +For selected-authority phase requirements/success criteria or brownfield Done When items, assign one of: +- **Resolved**: behavior is testable, Done When is unambiguous, and decisions are locked in SPEC.md, phase APPROACH.md, or brownfield CHANGE.md. Proceed. - **Open**: the requirement depends on a product or UX decision that has not been made. Cannot proceed. - **Ambiguous**: there are two or more reasonable interpretations of what "done" means. Cannot proceed. Trigger questions per item: -- Is the Done-When criterion specific enough to write a verify command for? -- Does this require a product or UX decision that is absent from SPEC.md, APPROACH.md, or ROADMAP.md? +- Is each selected requirement or Done When criterion specific enough for a verify command? +- For phase authority, does this require a product or UX decision absent from SPEC.md, APPROACH.md, or ROADMAP.md? For brownfield, consult CHANGE.md. - Would two different developers reasonably implement this differently based only on the requirement text? ### Quality Gate - If all items are **Resolved**: proceed to goal_backward_planning. @@ -140,6 +139,8 @@ durable and keep `CHANGE.md` as the plan identity with `HANDOFF.md` context-only stream, a conflicting milestone/work authority, a missing Done When section, or a request that is already milestone-sized is a fail-closed planning result. Record the plan transition only after those checks pass. + +Put the plan body and structured `...` in a `## Implementation Plan` section of `.work/brownfield-change/CHANGE.md`, covering objective, concrete tasks, files, actions, runnable verification, and done conditions. Keep existing operational sections authoritative, bind approval and execution to complete CHANGE bytes, and keep HANDOFF judgment-only. Use owner-confirmed decisions in CHANGE.md; if a material decision is missing, stop and ask rather than creating phase APPROACH state or inferring alignment from HANDOFF prose. Once the plan body is ready, record its identity with `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority workflow --json`. After the owner approves those exact bytes and reference, record consent with `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/brownfield-change/CHANGE.md --authority owner --approval-ref {approval_ref} --json`. @@ -185,7 +186,7 @@ Group artifacts into tasks. Each task should: - have a clear done criterion -Every `PLAN.md` must start with frontmatter describing how the executor should interpret it. +Every phase `PLAN.md` must start with frontmatter describing how the executor should interpret it. A brownfield plan uses the existing `CHANGE.md` contract and its `## Implementation Plan` section instead. ```yaml --- @@ -301,7 +302,7 @@ Split a task if: - splitting would create tasks that cannot be verified independently -Create `.work/phases/{phase_dir}/{plan_id}-PLAN.md` with this structure: +For phase authority, create `.work/phases/{phase_dir}/{plan_id}-PLAN.md` with this structure: ```markdown --- @@ -421,13 +422,14 @@ notes: [What the checker actually validated or why it was skipped] [Gotchas, implementation notes, or explicit assumptions] ``` -**MANDATORY: You MUST write PLAN.md to disk at `.work/phases/{phase_dir}/{plan_id}-PLAN.md`. Output to conversation alone is NOT sufficient. If this file is not written to disk, planning is NOT complete.** +**MANDATORY:** For phase authority, write `PLAN.md` to `.work/phases/{phase_dir}/{plan_id}-PLAN.md`; conversation output alone is not sufficient. For active brownfield authority, write the plan body to `## Implementation Plan` in `.work/brownfield-change/CHANGE.md` and keep that file as the only plan identity. Do not apply the phase-only PLAN path, frontmatter, or output requirement to the brownfield stream. - + ### When This Runs -Check `.work/config.json` for `workflow.discuss`: +For phase authority, check `.work/config.json` for `workflow.discuss`: - If `workflow.discuss: false` (or key missing): skip this section, go to ``. Note `reduced_alignment` in the orchestration summary. - If `workflow.discuss: true`: mandatory before planning. +For brownfield, consult owner-confirmed scope and decisions in CHANGE.md, then skip this entire APPROACH section; unresolved material decisions still require a stop under ``. ### Check for Existing APPROACH.md Check if `{phase_dir}/{padded_phase}-APPROACH.md` exists. @@ -490,14 +492,14 @@ After approach exploration completes (or existing APPROACH.md is loaded): ### Role Contract The approach explorer's full role contract is at `.work/templates/roles/approach-explorer.md`. The portable workflow describes the orchestration; the role contract describes the agent's behavior. - + ### How Plan Checking Works After the planner produces a draft plan, an independent checker reviews it in fresh context. The checker does not inherit the planner's hidden reasoning; it treats the plan as an untrusted draft and returns a structured summary of findings rather than editing the plan directly. ### What The Checker Verifies -1. `requirement_coverage` - every phase requirement is covered by at least one concrete task +1. `requirement_coverage` - every phase requirement, or every brownfield Done When item, is covered by at least one concrete task 2. `task_completeness` - every task has files, action, verify, and done fields; verify quality sub-checks ensure at least one runnable command per task, flag slow or watch-mode verification, and check test file ordering 3. `dependency_correctness` - ordering, dependencies, and plan structure are coherent 4. `key_link_completeness` - important wiring links are planned, not just isolated artifacts @@ -505,22 +507,21 @@ After the planner produces a draft plan, an independent checker reviews it in fr 6. `must_have_quality` - success criteria are specific, observable, and reflected in tasks 7. `context_compliance` - locked decisions are honored and deferred ideas stay out of scope 8. `goal_achievement` - the plan, if executed perfectly, actually achieves the stated phase goal: goal addressed (tasks deliver the goal), success criteria reachable (each criterion traceable to a task verify output), and outcome observable (a human or automated check can confirm the goal was met) -9. `approach_alignment` - when APPROACH.md exists, plans implement the chosen approaches, not alternatives. Blocker if plan contradicts an explicit user choice. Warning if plan drifts from recommendation without justification. When `workflow.discuss: true`, missing, proofless, agent-discretion-only, or invalid APPROACH.md is a blocker before a plan can be accepted. +9. `approach_alignment` - phase authority uses APPROACH.md choices; when `workflow.discuss: true`, missing, proofless, agent-discretion-only, or invalid proof blocks acceptance. Brownfield uses owner-confirmed CHANGE.md decisions and does not require APPROACH.md. 10. `decision_compliance` - when `lastDecisionsDigest` exists, the plan includes a complete `decision_dispositions` block keyed by every persisted decision id, preserves each persisted body hash and `authority_fingerprint`, uses an allowed disposition, keeps required notes nonblank, and surfaces challenged items. An explicitly persisted empty digest must remain empty at execution; when no persisted digest exists, report `skipped` and never fail the plan. The smaller dimension set still preserves the old failure coverage: scope boundaries are checked under `scope_sanity`, anti-regression and escalation under `context_compliance`, closure honesty and browser-proof specificity under `goal_achievement`, and second-pass review under the final shared-surface review before closure. ### Invoking the Checker -1. If `.work/config.json` has `workflow.planCheck: false`, skip the independent checker. Perform the planner self-check below and report `reduced_assurance`. This does not skip the earlier alignment-proof gate when `workflow.discuss: true`. +1. If `.work/config.json` has `workflow.planCheck: false`, skip the independent checker. Perform the planner self-check below and report `reduced_assurance`. For phase authority only, this does not skip the alignment-proof gate when `workflow.discuss: true`. 2. If plan checking is enabled, check if your runtime provides a `work-plan-checker` agent. 3. If a native checker agent is available, invoke it in a fresh context with only these explicit inputs: - - target phase goal and requirement IDs + - for phase authority: target phase goal and requirement IDs; for brownfield authority: Objective, Scope, and Done When from `CHANGE.md` - relevant locked decisions / deferred items from `.work/SPEC.md` - - project config from `.work/config.json`, especially `workflow.discuss` and `workflow.planCheck` - - approach decisions from `.work/phases/*-APPROACH.md` (if exists) - - relevant phase research file(s) - - produced `.work/phases/*-PLAN.md` file(s) + - project config from `.work/config.json`, especially `workflow.planCheck`; `workflow.discuss` applies to phase authority + - for phase authority: approach decisions from `.work/phases/*-APPROACH.md` (if exists), relevant phase research, and the produced `.work/phases/*-PLAN.md` file(s) + - for brownfield authority: `.work/brownfield-change/CHANGE.md` including its `## Implementation Plan`, plus `HANDOFF.md` only as judgment context - the exact persisted `lastDecisionsDigest` snapshot from the successful plan preflight, if present, including an explicitly persisted empty snapshot - For `decision_compliance`, compare every PLAN `decision_dispositions` body hash and `authority_fingerprint` directly to that snapshot. When the digest is absent, report `skipped`; an explicitly persisted empty snapshot must remain empty and must not be replaced with a recomputed digest. + For phase authority, compare each PLAN `decision_dispositions` body hash and `authority_fingerprint` directly to that snapshot. Brownfield must honor owner-confirmed decisions in `CHANGE.md` and must not invent phase-only `decision_dispositions`. When the digest is absent, report `skipped`; an explicitly persisted empty snapshot must remain empty and must not be replaced with a recomputed digest. 4. Require the checker to return a single JSON object: ```json { @@ -531,7 +532,7 @@ The smaller dimension set still preserves the old failure coverage: scope bounda "dimension": "requirement_coverage | task_completeness | dependency_correctness | key_link_completeness | scope_sanity | must_have_quality | context_compliance | goal_achievement | approach_alignment | decision_compliance", "severity": "blocker | warning", "description": "What is wrong", - "plan": "01-PLAN", + "plan": "phase PLAN path or .work/brownfield-change/CHANGE.md", "task": "1-02", "fix_hint": "Specific revision instruction" } @@ -540,17 +541,14 @@ The smaller dimension set still preserves the old failure coverage: scope bounda ``` Status must be either "passed" or "issues_found". Use "passed" only when "issues": []; any blocker or warning must use "issues_found". 5. If the checker returns `passed`, finish and summarize. -6. If the checker returns `issues_found`, revise the existing plan files only where needed, then invoke the checker again. +6. If the checker returns `issues_found`, revise only the existing phase PLAN or the brownfield `CHANGE.md#Implementation Plan`, then invoke the checker again. 7. Maximum 3 checker cycles total. If any blockers or warnings remain after cycle 3, stop and escalate to the user instead of pretending the plan is ready. 8. If no native checker agent is available in your runtime, perform the planner self-check below and explicitly report `reduced_assurance` rather than claiming an independent checker ran. -When the checker outcome is finalized, write the result into the plan artifact: -- checker ran in same runtime or planner self-check only -> set frontmatter `assurance: self_checked` -- checker ran in a different runtime/vendor and passed -> set frontmatter `assurance: cross_runtime_checked` -- draft exists before any checker result is recorded -> keep `assurance: unreviewed` +When finalized, write checker results to the selected plan artifact (`` block in phase PLAN or `CHANGE.md#Implementation Plan`). Phase frontmatter assurance: same-runtime/self-check `self_checked`, cross-runtime pass `cross_runtime_checked`, unchecked draft `unreviewed`. Brownfield records equivalent assurance in its `` block, using `unreviewed` before checking. - record the structured outcome in the plan's `` block; do not leave the checker result only in chat context ### Orchestration Summary After plan checking completes, report: -- target phase +- target phase or selected brownfield change - whether independent plan checking ran - checker cycle count (if applicable) - final result: passed | reduced_assurance | escalated @@ -568,7 +566,7 @@ If any blockers or warnings remain after 3 checker cycles, the orchestrator stop Before presenting the plan, verify it yourself. ### Check Each Success Criterion -For every success criterion from `ROADMAP.md`: +For phase authority, check every ROADMAP success criterion; for brownfield, check every `CHANGE.md` Done When item: - [ ] At least one task produces an artifact that satisfies it - [ ] The task's `` section checks it specifically - [ ] The criterion is covered explicitly, not only implied @@ -582,11 +580,7 @@ For each task: - [ ] `checkpoint:*` usage is consistent with `autonomous` ### Check The Anti-Drift Contract -- [ ] `non_goals` are explicit and would stop adjacent cleanup or feature widening -- [ ] `hard_boundaries` identify the main no-touch surfaces -- [ ] `escalation_triggers` force stop-and-challenge instead of silent interpretation -- [ ] `approval_gates` appear anywhere side effects or irreversible choices could happen -- [ ] `anti_regression_targets` are concrete enough for verification to check later +- [ ] Phase boundary fields are explicit in frontmatter; brownfield scope, stop, approval, and anti-regression rules are explicit in CHANGE.md - [ ] Evidence contract and claim limits prevent the executor from claiming more than verification can support - [ ] Browser-proof-required plans include route/state, viewport, runtime path, evidence kind, evidence command or no-command rationale, observations, artifacts/privacy-safety posture, and claim limit - [ ] Shared or high-risk surfaces are named for a second-pass contradiction/staleness review @@ -599,37 +593,33 @@ For each task: - All verify steps are observational text with no runnable commands - The plan can drift into adjacent cleanup because the anti-goals or hard boundaries are vague - The executor could cross a risky boundary without tripping a stop-and-challenge condition -- The plan claims phase completion more broadly than its own evidence contract can prove +- The plan claims authority-goal completion more broadly than its evidence contract can prove Planning is done when all of these are true: -- [ ] Target phase identified from `ROADMAP.md` -- [ ] Or selected target is `brownfield-change` and `CHANGE.md` provides goal, scope, done-when, next action, and closeout path without ROADMAP phase membership -- [ ] Approach exploration completed or explicitly skipped with `reduced_alignment` reported -- [ ] When `workflow.discuss: true`: user alignment confirmed via APPROACH.md before planning +- [ ] Selected authority contract is met: phase target with requirement IDs, success criteria, out-of-scope, and stop/replan conditions; or brownfield goal, scope, Done When, next action, and closeout path in CHANGE.md without roadmap membership +- [ ] Phase authority completes APPROACH exploration or reports `reduced_alignment`; brownfield uses owner-confirmed `CHANGE.md` decisions without creating phase APPROACH state - [ ] Research check completed where needed -- [ ] Roadmap phase contract includes requirements, success criteria, explicit out-of-scope, and explicit stop/replan conditions -- [ ] Or brownfield contract includes in-scope, out-of-scope, concrete Done When criteria, current next action, and `VERIFICATION.md` closeout path - [ ] Plan self-check passed - [ ] Success criteria from `ROADMAP.md`, or Done When criteria from `CHANGE.md`, are represented as must-haves - [ ] Goal-backward derivation from criteria to artifacts to key links to tasks is explicit -- [ ] Every plan has frontmatter with `phase`, `plan`, `type`, `wave`, `depends_on`, `files-modified`, `autonomous`, `requirements`, `non_goals`, `hard_boundaries`, `escalation_triggers`, `approval_gates`, `anti_regression_targets`, `known_unknowns`, `browser_proof_required`, `browser_proof_rationale`, and `must_haves` -- [ ] Every plan frontmatter records `runtime` and `assurance` -- [ ] Every plan records checker outcome in a structured `` block +- [ ] Every phase PLAN has frontmatter with `phase`, `plan`, `type`, `wave`, `depends_on`, `files-modified`, `autonomous`, `requirements`, `non_goals`, `hard_boundaries`, `escalation_triggers`, `approval_gates`, `anti_regression_targets`, `known_unknowns`, `browser_proof_required`, `browser_proof_rationale`, and `must_haves`; brownfield records its applicable bounded fields in `CHANGE.md` +- [ ] Every phase PLAN frontmatter records `runtime` and `assurance`; brownfield records those values in its structured `` result +- [ ] Every selected plan artifact records checker outcome in a structured `` block - [ ] Every task has XML structure with `id`, `type`, `files`, `action`, `verify`, and `done` - [ ] Every task has at least one runnable verify command - [ ] Plan sizing stays within 2-5 tasks, preferring 2-3 -- [ ] Locked decisions from `.work/SPEC.md` and APPROACH.md are honored -- [ ] Plan body includes explicit `## Anti-Goals`, `## Hard Boundaries`, `## Evidence Contract`, `## Common Pitfalls`, `## Stop-And-Challenge`, `## Approval Gates`, and `## Second-Pass Review` sections; UI-sensitive plans also include `## Browser Proof Plan` +- [ ] Locked `.work/SPEC.md` decisions are honored; phase APPROACH applies only to phase authority and brownfield decisions come from `CHANGE.md` +- [ ] Phase plan bodies include explicit `## Anti-Goals`, `## Hard Boundaries`, `## Evidence Contract`, `## Common Pitfalls`, `## Stop-And-Challenge`, `## Approval Gates`, and `## Second-Pass Review` sections; brownfield covers applicable content within its `## Implementation Plan` and existing operational contract, without creating a phase PLAN. UI-sensitive plans also include `## Browser Proof Plan` - [ ] Any git guidance stays repo-native and follows `.work/config.json` Report to the user what was accomplished, then present the next step: --- -**Completed:** Phase planning — created `.work/phases/{phase_dir}/{plan_id}-PLAN.md`. +**Completed:** Phase planning: created `.work/phases/{phase_dir}/{plan_id}-PLAN.md`; bounded brownfield planning: updated `.work/brownfield-change/CHANGE.md` with its `## Implementation Plan`. **Planning stops here:** `work-plan` ends after the plan artifact is written. Do not start implementation in this same run, and do not treat imperative handoff text as execution authorization. Installed generated runtime surfaces are trusted through rendering, not reviewer memory: `npx -y workspine health` compares any local generated skill/adapter surfaces against current render output, and `npx -y workspine update` regenerates them when they drift. Bare `gsdd health` / `gsdd update` are equivalent only when globally installed. **Next workflow:** `/work-execute` — start execution in a separate run when the user explicitly wants implementation to begin diff --git a/tests/evals/native-brownfield/cli.mjs b/tests/evals/native-brownfield/cli.mjs index 7d1a777..2726bba 100644 --- a/tests/evals/native-brownfield/cli.mjs +++ b/tests/evals/native-brownfield/cli.mjs @@ -9,6 +9,7 @@ import { cleanupIsolatedCodexHome, createIsolatedCodexHome, restoreIsolatedCodex import { ReceiptChain, verifySeal } from './seal.mjs'; import { canonicalStringify, command, EvalError, fileSha256, readJson, sha256 } from './util.mjs'; const SOURCE_FILES = ['cli.mjs', 'util.mjs', 'prepare.mjs', 'codex.mjs', 'journey.mjs', 'grade.mjs', 'seal.mjs']; +const MAX_PRIMARY_SOURCE_LINES = 900; const TURN_RECEIPTS = { 'a-plan': [100, 'a-plan'], 'a-pause': [110, 'a-pause'], approval: [120, 'approval'], 'b-resume-execute': [200, 'b-resume-execute'], 'c-verify': [300, 'c-verify'], 'c-progress': [310, 'c-progress'] }; @@ -24,7 +25,7 @@ function parseArgs(argv) { function characterize() { const root = path.dirname(fileURLToPath(import.meta.url)); const lines = SOURCE_FILES.reduce((sum, file) => sum + fs.readFileSync(path.join(root, file), 'utf8').split(/\r?\n/).length - 1, 0); - if (lines > 800) throw new EvalError('evaluator_invalid', `primary evaluator exceeds 800 lines: ${lines}`); + if (lines > MAX_PRIMARY_SOURCE_LINES) throw new EvalError('evaluator_invalid', `primary evaluator exceeds ${MAX_PRIMARY_SOURCE_LINES} lines: ${lines}`); return { schema_version: 1, mode: 'characterize', provider_invoked: false, primary_source_lines: lines, stages: ['a-plan', 'a-pause', 'approval', 'b-resume-execute', 'c-verify', 'c-progress'], session_topology: { A: ['a-plan', 'a-pause'], B: ['b-resume-execute'], C: ['c-verify', 'c-progress'] }, @@ -95,7 +96,10 @@ async function run(freezeFile, runRoot) { if (homeFiles.length !== 1 || homeFiles[0] !== 'auth.json' || !auth?.isFile() || auth.isSymbolicLink()) throw new EvalError('environment_invalid', 'qualified CODEX_HOME posture changed'); const result = await runJourney({ transport, consumerRoot: freeze.consumer_root, runRoot, hardTimeoutMs: freeze.hard_timeout_ms, approvalRef: freeze.approval_ref, prompts: freeze.prompts || buildPrompts(), record: (id, value) => appendTurn(chain, id, value) }); - if (result.outcome !== 'completed') return chain.terminal(result.outcome, { failure_code: result.failure_code || null }); + if (result.outcome !== 'completed') return chain.terminal(result.outcome, { + failure_code: result.failure_code || null, + ...(result.stopped_before_approval === true ? { stopped_before_approval: true, plan_selection: result.planSelection || null } : {}), + }); const grade = gradeWorkspace({ consumerRoot: freeze.consumer_root, baselineManifest: freeze.baseline_manifest, allowedPaths: freeze.allowed_paths, oracle: freeze.oracle, approvalRef: freeze.approval_ref, genericReproduction: freeze.generic_reproduction }); chain.append(400, 'oracle', 'oracle', grade.oracle); diff --git a/tests/evals/native-brownfield/journey.mjs b/tests/evals/native-brownfield/journey.mjs index 269d36b..75f6563 100644 --- a/tests/evals/native-brownfield/journey.mjs +++ b/tests/evals/native-brownfield/journey.mjs @@ -2,6 +2,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { canonicalStringify, command, EvalError, fileSha256 } from './util.mjs'; import { findCheckpointWitness } from './codex.mjs'; +export const BROWNFIELD_PLAN_IDENTITY = '.work/brownfield-change/CHANGE.md'; +export function classifyPlanSelection(plan = {}) { + const path = typeof plan.path === 'string' && plan.path.trim() ? plan.path : null; + const identity = typeof plan.identity === 'string' && plan.identity.trim() ? plan.identity : null; + const approved = plan.approved === true; + const reason = approved ? 'plan_preapproved_outside_harness' : (!path || !identity ? 'selected_plan_identity_missing' + : (path !== identity || path !== BROWNFIELD_PLAN_IDENTITY ? 'selected_plan_identity_mismatch' : null)); + return { ok: !reason, ...(reason ? { reason } : {}), path, identity, approved, expected_path: BROWNFIELD_PLAN_IDENTITY }; +} export function buildPrompts() { const owner = 'Read the owner inputs at inputs/owner/TASK.md and inputs/owner/BRIEF.md.'; return { 'a-plan': `$work-plan\n${owner} Plan this brownfield change, then stop after the plan is complete.`, @@ -16,8 +25,29 @@ export function captureCheckpoint(consumerRoot) { if (!stat?.isFile() || stat.isSymbolicLink()) throw new EvalError('protocol_invalid', 'canonical checkpoint is missing'); return { path: '.work/.continue-here.md', sha256: fileSha256(file), bytes: stat.size }; } -export function approvePlan({ consumerRoot, checkpoint, approvalRef }) { - const root = path.resolve(consumerRoot), planRelative = '.work/brownfield-change/CHANGE.md'; +export function readSelectedPlanIdentity(consumerRoot) { + const statePath = path.join(path.resolve(consumerRoot), '.work', 'state.json'); + const stat = fs.lstatSync(statePath, { throwIfNoEntry: false }); + if (!stat?.isFile() || stat.isSymbolicLink()) return { ok: false, evaluatorFailure: true, reason: 'lifecycle_state_missing' }; + let state; + try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } + catch { return { ok: false, evaluatorFailure: true, reason: 'lifecycle_state_unreadable' }; } + if (!state || typeof state !== 'object' || Array.isArray(state) + || ('workflow' in state && (!state.workflow || typeof state.workflow !== 'object' || Array.isArray(state.workflow))) + || (state.workflow && 'plan' in state.workflow && (!state.workflow.plan || typeof state.workflow.plan !== 'object' || Array.isArray(state.workflow.plan)))) { + return { ok: false, evaluatorFailure: true, reason: 'lifecycle_state_malformed' }; + } + return classifyPlanSelection(state?.workflow?.plan); +} +export function approvePlan({ consumerRoot, checkpoint, approvalRef, planSelection }) { + const root = path.resolve(consumerRoot), planRelative = planSelection?.path; + if (!planSelection?.ok || !classifyPlanSelection(planSelection).ok || planRelative !== BROWNFIELD_PLAN_IDENTITY) { + throw new EvalError('protocol_invalid', 'agent-selected plan identity does not match the owner-approved brownfield CHANGE.md identity'); + } + const currentSelection = readSelectedPlanIdentity(root); + if (!currentSelection.ok || currentSelection.path !== planSelection.path || currentSelection.identity !== planSelection.identity) { + throw new EvalError('protocol_invalid', 'agent-selected plan identity changed before harness approval'); + } const planFile = path.join(root, ...planRelative.split('/')); const planStat = fs.lstatSync(planFile, { throwIfNoEntry: false }); if (!planStat?.isFile() || planStat.isSymbolicLink()) throw new EvalError('protocol_invalid', 'canonical plan is missing'); @@ -58,6 +88,7 @@ export async function runJourney(options) { const capture = options.captureCheckpoint || (() => captureCheckpoint(options.consumerRoot)); const approve = options.approve || (input => approvePlan({ consumerRoot: options.consumerRoot, approvalRef: options.approvalRef, ...input })); + const selectPlan = options.selectPlan || (() => readSelectedPlanIdentity(options.consumerRoot)); const witness = options.checkpointWitness || ((result, checkpoint) => findCheckpointWitness(result.events || [], { consumerRoot: options.consumerRoot, checkpointSha256: checkpoint.sha256, sessionId: result.sessionId, })); @@ -74,8 +105,33 @@ export async function runJourney(options) { const aPause = await run('a-pause', aPlan.sessionId); if (aPause.outcome !== 'completed') return { outcome: aPause.outcome, turns }; if (aPause.sessionId !== aPlan.sessionId) return { outcome: 'protocol_invalid', failure_code: 'a_session_mismatch', turns }; + const planSelection = selectPlan(); + if (planSelection?.evaluatorFailure) { + return { outcome: 'protocol_invalid', failure_code: planSelection.reason || 'lifecycle_state_unreadable', turns, planSelection, + stopped_before_approval: true }; + } + const assessedSelection = classifyPlanSelection(planSelection); + const observedFailure = assessedSelection.reason; + if (observedFailure) { + if (planSelection?.reason && planSelection.reason !== observedFailure) { + return { outcome: 'protocol_invalid', failure_code: 'selection_evidence_conflict', turns, planSelection, stopped_before_approval: true }; + } + if (planSelection?.ok === true) return { outcome: 'protocol_invalid', failure_code: 'selection_evidence_conflict', turns, planSelection, + stopped_before_approval: true }; + return { outcome: 'task_red', failure_code: observedFailure, turns, planSelection: assessedSelection, + stopped_before_approval: true }; + } + if (planSelection?.ok !== true || planSelection.expected_path !== BROWNFIELD_PLAN_IDENTITY) { + return { outcome: 'protocol_invalid', failure_code: 'selection_evidence_conflict', turns, planSelection, + stopped_before_approval: true }; + } const checkpoint = capture(); - const approval = approve({ checkpoint }); + let approval; + try { approval = approve({ checkpoint, planSelection }); } + catch (error) { + if (error?.code === 'protocol_invalid') return { outcome: 'protocol_invalid', failure_code: 'selected_plan_identity_mismatch', turns, checkpoint, planSelection }; + throw error; + } options.record?.('approval', approval); if (!approval?.ok || approval.checkpoint_sha256 !== checkpoint.sha256 || capture().sha256 !== checkpoint.sha256) { return { outcome: 'protocol_invalid', failure_code: 'approval_checkpoint_mutation', turns }; @@ -93,6 +149,6 @@ export async function runJourney(options) { if (cProgress.outcome !== 'completed') return { outcome: cProgress.outcome, turns, checkpoint, approval }; const topology = validateTopology({ aPlan: aPlan.sessionId, aPause: aPause.sessionId, b: b.sessionId, cVerify: cVerify.sessionId, cProgress: cProgress.sessionId }); - return topology.ok ? { outcome: 'completed', turns, checkpoint, checkpointRead, approval, topology } - : { outcome: 'protocol_invalid', failure_code: topology.reason, turns, checkpoint, approval, topology }; + return topology.ok ? { outcome: 'completed', turns, checkpoint, checkpointRead, approval, planSelection, topology } + : { outcome: 'protocol_invalid', failure_code: topology.reason, turns, checkpoint, approval, planSelection, topology }; } diff --git a/tests/evals/native-brownfield/native-brownfield.test.mjs b/tests/evals/native-brownfield/native-brownfield.test.mjs index 64efdf4..6918c47 100644 --- a/tests/evals/native-brownfield/native-brownfield.test.mjs +++ b/tests/evals/native-brownfield/native-brownfield.test.mjs @@ -17,7 +17,7 @@ import { findCheckpointWitness, findNetworkViolation, } from './codex.mjs'; -import { approvePlan, runJourney, validateTopology } from './journey.mjs'; +import { approvePlan, readSelectedPlanIdentity, runJourney, validateTopology } from './journey.mjs'; import { assertCandidateBinding, assertSyntheticBaseline, @@ -62,6 +62,12 @@ test('public outcome mapping is complete and fail closed', () => { assert.throws(() => projectOutcome('unknown'), /unsupported outcome/); }); +test('primary evaluator source stays within the approved bounded budget', async () => { + const result = await main(['characterize']); + assert.equal(result.provider_invoked, false); + assert.equal(result.primary_source_lines <= 900, true); +}); + test('workspine red requires independent generic reproduction', () => { assert.throws(() => projectOutcome('workspine_red'), /generic reproduction/); assert.deepEqual(projectOutcome('workspine_red', { genericReproductionSha256: 'a'.repeat(64) }), @@ -99,6 +105,96 @@ test('receipt chain resumes a validated prefix without adding a second seal laye assert.deepEqual(verifySeal(root).links.map(link => link.sequence), [0, 10]); }); +function appendEarlyIdentityPrefix(chain, selection) { + chain.append(0, 'manifest', 'manifest', { fixture: true }); + chain.append(10, 'qualification', 'qualification', { ok: true, provider_invoked: false }); + chain.append(100, 'a-plan', 'turn', { outcome: 'completed', turn_id: 'a-plan', session_id: 'A' }); + chain.append(110, 'a-pause', 'turn', { outcome: 'completed', turn_id: 'a-pause', session_id: 'A' }); + return { failure_code: selection.reason, stopped_before_approval: true, plan_selection: selection }; +} + +test('early identity task red seals a qualified A prefix with evidence and no approval or B', t => { + const root = tempRoot(t), chain = new ReceiptChain(root, 'run-identity-red'); + const payload = appendEarlyIdentityPrefix(chain, { ok: false, reason: 'selected_plan_identity_mismatch', + path: '.work/phases/01-wrong/01-PLAN.md', identity: '.work/phases/01-wrong/01-PLAN.md', approved: false, + expected_path: '.work/brownfield-change/CHANGE.md' }); + const terminal = chain.terminal('task_red', payload); + const verified = verifySeal(root); + assert.deepEqual(verified.links.map(link => link.sequence), [0, 10, 100, 110]); + assert.equal(verified.public.disposition, 'red'); + assert.equal(verified.public.failure_domain, 'task_outcome'); + assert.deepEqual(terminal.payload.plan_selection, payload.plan_selection); +}); + +test('early identity seal accepts missing and preapproved observed selections', t => { + for (const [runId, selection] of [ + ['run-missing', { ok: false, reason: 'selected_plan_identity_missing', path: null, identity: null, approved: false, + expected_path: '.work/brownfield-change/CHANGE.md' }], + ['run-preapproved', { ok: false, reason: 'plan_preapproved_outside_harness', path: '.work/brownfield-change/CHANGE.md', + identity: '.work/brownfield-change/CHANGE.md', approved: true, expected_path: '.work/brownfield-change/CHANGE.md' }], + ['run-preapproved-identity-missing', { ok: false, reason: 'plan_preapproved_outside_harness', path: null, + identity: null, approved: true, expected_path: '.work/brownfield-change/CHANGE.md' }], + ]) { + const root = tempRoot(t), chain = new ReceiptChain(root, runId); + const payload = appendEarlyIdentityPrefix(chain, selection); + chain.terminal('task_red', payload); + assert.equal(verifySeal(root).public.failure_domain, 'task_outcome'); + assert.deepEqual(verifySeal(root).links.map(link => link.sequence), [0, 10, 100, 110]); + } +}); + +test('early terminal rejects generic red, green, wrong prefix, and canonical identity mislabeled wrong', t => { + const make = (t, runId, prefix = appendEarlyIdentityPrefix) => { + const root = tempRoot(t), chain = new ReceiptChain(root, runId); + const payload = prefix(chain, { ok: false, reason: 'selected_plan_identity_mismatch', path: '.work/phases/wrong/01-PLAN.md', + identity: '.work/phases/wrong/01-PLAN.md', approved: false, expected_path: '.work/brownfield-change/CHANGE.md' }); + return { root, chain, payload }; + }; + { + const { chain } = make(t, 'run-generic-red'); + assert.throws(() => chain.terminal('task_red'), /allowlisted identity failure/); + } + { + const { chain, payload } = make(t, 'run-early-green'); + assert.throws(() => chain.terminal('product_green', payload), /complete receipt chain/); + } + { + const { chain, payload } = make(t, 'run-canonical-falsely-wrong'); + payload.plan_selection.path = '.work/brownfield-change/CHANGE.md'; + payload.plan_selection.identity = '.work/brownfield-change/CHANGE.md'; + assert.throws(() => chain.terminal('task_red', payload), /contradicts the observed selection/); + } + { + const { chain, payload } = make(t, 'run-ok-true-wrong-identity'); + payload.plan_selection.ok = true; + assert.throws(() => chain.terminal('task_red', payload), /allowlisted identity failure/); + } + { + const root = tempRoot(t), chain = new ReceiptChain(root, 'run-seal-canonical-falsely-wrong'); + const payload = appendEarlyIdentityPrefix(chain, { ok: false, reason: 'selected_plan_identity_mismatch', path: '.work/phases/wrong/01-PLAN.md', + identity: '.work/phases/wrong/01-PLAN.md', approved: false, expected_path: '.work/brownfield-change/CHANGE.md' }); + chain.terminal('task_red', payload); + const file = path.join(root, 'receipts', '900-terminal-seal.json'); + const terminal = JSON.parse(fs.readFileSync(file, 'utf8')); + terminal.payload.plan_selection.path = '.work/brownfield-change/CHANGE.md'; + terminal.payload.plan_selection.identity = '.work/brownfield-change/CHANGE.md'; + const { receipt_sha256: _receipt, seal_sha256: _seal, ...body } = terminal; + terminal.receipt_sha256 = sha256(canonicalStringify(body)); + const { seal_sha256: _ignored, ...withReceipt } = terminal; + terminal.seal_sha256 = sha256(canonicalStringify(withReceipt)); + fs.writeFileSync(file, `${canonicalStringify(terminal)}\n`); + assert.throws(() => verifySeal(root), /contradicts the observed selection/); + } + { + const root = tempRoot(t), chain = new ReceiptChain(root, 'run-wrong-prefix'); + chain.append(0, 'manifest', 'manifest', {}); + chain.append(10, 'qualification', 'qualification', { ok: true, provider_invoked: false }); + chain.append(100, 'a-plan', 'turn', { outcome: 'completed', turn_id: 'a-plan', session_id: 'A' }); + chain.append(120, 'approval', 'approval', {}); + assert.throws(() => chain.terminal('task_red', {}), /complete receipt chain|valid prefix|early identity result requires/); + } +}); + test('receipt path is create-exclusive', t => { const root = tempRoot(t); const chain = new ReceiptChain(root, 'run-1'); @@ -500,6 +596,8 @@ test('journey order is plan, pause, approval, fresh B, verify, progress', async const result = await runJourney({ transport, captureCheckpoint: () => ({ path: '.work/.continue-here.md', sha256: 'c'.repeat(64) }), + selectPlan: () => ({ ok: true, path: '.work/brownfield-change/CHANGE.md', identity: '.work/brownfield-change/CHANGE.md', approved: false, + expected_path: '.work/brownfield-change/CHANGE.md' }), approve: ({ checkpoint }) => { calls.push('approval'); return { ok: true, checkpoint_sha256: checkpoint.sha256 }; }, checkpointWitness: () => ({ ok: true, output_sha256: 'c'.repeat(64) }), hardTimeoutMs: 321, @@ -522,6 +620,8 @@ test('journey rejects approval checkpoint mutation without dependent turns', asy const result = await runJourney({ transport, captureCheckpoint: () => ({ path: '.work/.continue-here.md', sha256: 'a'.repeat(64) }), + selectPlan: () => ({ ok: true, path: '.work/brownfield-change/CHANGE.md', identity: '.work/brownfield-change/CHANGE.md', approved: false, + expected_path: '.work/brownfield-change/CHANGE.md' }), approve: () => ({ ok: true, checkpoint_sha256: 'b'.repeat(64) }), checkpointWitness: () => ({ ok: true }), }); @@ -537,11 +637,64 @@ test('journey stops before approval when session A identity drifts', async () => } }; const result = await runJourney({ transport, captureCheckpoint: () => ({ sha256: 'a'.repeat(64) }), + selectPlan: () => ({ ok: true, path: '.work/brownfield-change/CHANGE.md', identity: '.work/brownfield-change/CHANGE.md', approved: false, + expected_path: '.work/brownfield-change/CHANGE.md' }), approve: () => { calls.push('approval'); return { ok: true, checkpoint_sha256: 'a'.repeat(64) }; } }); assert.equal(result.outcome, 'protocol_invalid'); assert.deepEqual(calls, ['a-plan', 'a-pause']); }); +test('journey grades the agent-selected identity before any harness approval', async () => { + const calls = []; + const transport = { runTurn: async turn => { + calls.push(turn.id); + return { outcome: 'completed', sessionId: 'A', turnId: turn.id }; + } }; + const result = await runJourney({ transport, + captureCheckpoint: () => ({ sha256: 'a'.repeat(64) }), + selectPlan: () => ({ ok: false, reason: 'selected_plan_identity_mismatch', path: '.work/phases/01-wrong/01-PLAN.md', + identity: '.work/phases/01-wrong/01-PLAN.md', approved: false, expected_path: '.work/brownfield-change/CHANGE.md' }), + approve: () => { calls.push('approval'); return { ok: true, checkpoint_sha256: 'a'.repeat(64) }; }, + }); + assert.equal(result.outcome, 'task_red'); + assert.equal(result.failure_code, 'selected_plan_identity_mismatch'); + assert.equal(result.planSelection.path, '.work/phases/01-wrong/01-PLAN.md'); + assert.equal(result.stopped_before_approval, true); + assert.deepEqual(calls, ['a-plan', 'a-pause']); +}); + +test('preapproval with missing identity is an observed task failure, not evaluator-invalid', async t => { + const root = tempRoot(t), work = path.join(root, '.work'); + fs.mkdirSync(work); + fs.writeFileSync(path.join(work, 'state.json'), `${JSON.stringify({ workflow: { plan: { approved: true } } })}\n`); + const selection = readSelectedPlanIdentity(root); + assert.equal(selection.reason, 'plan_preapproved_outside_harness'); + assert.equal(selection.approved, true); + assert.equal(selection.path, null); + const calls = []; + const result = await runJourney({ consumerRoot: root, + transport: { runTurn: async turn => { calls.push(turn.id); return { outcome: 'completed', sessionId: 'A', turnId: turn.id }; } }, + captureCheckpoint: () => { throw new Error('checkpoint must not be captured'); }, + approve: () => { throw new Error('owner approval must not be simulated'); }, + }); + assert.equal(result.outcome, 'task_red'); + assert.equal(result.failure_code, 'plan_preapproved_outside_harness'); + assert.equal(result.stopped_before_approval, true); + assert.deepEqual(calls, ['a-plan', 'a-pause']); +}); + +test('malformed lifecycle state containers are evaluator failures while absent identity is task evidence', t => { + const root = tempRoot(t), work = path.join(root, '.work'); + fs.mkdirSync(work); + const stateFile = path.join(work, 'state.json'); + for (const state of [null, [], { workflow: [] }, { workflow: { plan: [] } }]) { + fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`); + assert.equal(readSelectedPlanIdentity(root).evaluatorFailure, true); + } + fs.writeFileSync(stateFile, '{}\n'); + assert.equal(readSelectedPlanIdentity(root).reason, 'selected_plan_identity_missing'); +}); + test('provider-free approval uses the real generated lifecycle command and persists exact state', t => { const root = tempRoot(t), entry = path.resolve('bin', 'gsdd.mjs'); const run = args => spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', shell: false, windowsHide: true }); @@ -565,12 +718,25 @@ test('provider-free approval uses the real generated lifecycle command and persi result = run([path.join(root, '.work', 'bin', 'gsdd.mjs'), 'lifecycle-transition', 'plan', '--plan', '.work/brownfield-change/CHANGE.md', '--authority', 'workflow', '--json', '--no-update-notice']); assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + const planSelection = readSelectedPlanIdentity(root); + assert.deepEqual(planSelection, { ok: true, path: '.work/brownfield-change/CHANGE.md', identity: '.work/brownfield-change/CHANGE.md', + approved: false, expected_path: '.work/brownfield-change/CHANGE.md' }); const checkpoint = '---\nworkflow: brownfield-change\n---\n\nResume the approved bounded change.\n'; fs.writeFileSync(path.join(root, '.work', '.continue-here.md'), checkpoint); const approval = approvePlan({ consumerRoot: root, - checkpoint: { sha256: sha256(checkpoint) }, approvalRef: 'owner-frozen-eval-approval' }); + checkpoint: { sha256: sha256(checkpoint) }, approvalRef: 'owner-frozen-eval-approval', planSelection }); assert.equal(approval.ok, true); - assert.equal(JSON.parse(fs.readFileSync(path.join(root, '.work', 'state.json'), 'utf8')).workflow.plan.approved, true); + let persisted = JSON.parse(fs.readFileSync(path.join(root, '.work', 'state.json'), 'utf8')); + assert.equal(persisted.workflow.plan.approved, true); + assert.equal(persisted.workflow.plan.path, '.work/brownfield-change/CHANGE.md'); + assert.equal(persisted.workflow.plan.identity, '.work/brownfield-change/CHANGE.md'); + result = run([path.join(root, '.work', 'bin', 'gsdd.mjs'), 'lifecycle-transition', 'execute', + '--plan', planSelection.path, '--authority', 'workflow', '--json', '--no-update-notice']); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + persisted = JSON.parse(fs.readFileSync(path.join(root, '.work', 'state.json'), 'utf8')); + assert.equal(persisted.workflow.current_state, 'execute'); + assert.equal(persisted.workflow.plan.path, planSelection.path); + assert.equal(persisted.workflow.plan.identity, planSelection.identity); }); test('topology requires A/B/C fresh identities and within-session reuse', () => { diff --git a/tests/evals/native-brownfield/seal.mjs b/tests/evals/native-brownfield/seal.mjs index 3349258..8096357 100644 --- a/tests/evals/native-brownfield/seal.mjs +++ b/tests/evals/native-brownfield/seal.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { canonicalStringify, EvalError, mkdirp, readJson, sha256, writeExclusiveJson } from './util.mjs'; +import { classifyPlanSelection } from './journey.mjs'; const PUBLIC = Object.freeze({ product_green: ['green', null], task_red: ['red', 'task_outcome'], @@ -16,6 +17,9 @@ const RECEIPT_NAMES = Object.freeze({ 0: 'manifest', 10: 'qualification', 100: ' const RECEIPT_TYPES = Object.freeze({ 0: 'manifest', 10: 'qualification', 100: 'turn', 110: 'turn', 120: 'approval', 200: 'turn', 300: 'turn', 310: 'turn', 400: 'oracle', 410: 'grade', 420: 'regrade' }); const PREFIX = Object.keys(RECEIPT_TYPES).map(Number).sort((a, b) => a - b); +const IDENTITY_FAILURES = new Set(['selected_plan_identity_missing', 'selected_plan_identity_mismatch', 'plan_preapproved_outside_harness']); +const EARLY_IDENTITY_PREFIX = [0, 10, 100, 110]; +const BROWNFIELD_PLAN = '.work/brownfield-change/CHANGE.md'; export function projectOutcome(outcome, options = {}) { if (!(outcome in PUBLIC)) throw new EvalError('evaluator_invalid', `unsupported outcome: ${outcome}`); if (outcome === 'workspine_red' && !/^[0-9a-f]{64}$/i.test(options.genericReproductionSha256 || '')) { @@ -31,6 +35,32 @@ function receiptHash(receipt) { return sha256(canonicalStringify(body)); } function receiptName(sequence, name) { if (RECEIPT_NAMES[sequence] !== name) throw new EvalError('evaluator_invalid', `unexpected receipt path for sequence ${sequence}`); return `${String(sequence).padStart(3, '0')}-${name}.json`; } +function validateEarlyIdentityFailure(outcome, payload, links, receiptDir) { + if (outcome !== 'task_red' || links.length !== EARLY_IDENTITY_PREFIX.length) return false; + if (links.some((link, index) => link.sequence !== EARLY_IDENTITY_PREFIX[index])) { + throw new EvalError('evaluator_invalid', 'early identity result requires manifest, qualification, a-plan, and a-pause receipts'); + } + const receipts = links.map(link => readJson(path.join(receiptDir, link.path))); + const [manifest, qualification, plan, pause] = receipts; + if (manifest.record_type !== 'manifest' || qualification.payload?.ok !== true || qualification.payload?.provider_invoked !== false + || plan.payload?.outcome !== 'completed' || plan.payload?.turn_id !== 'a-plan' + || pause.payload?.outcome !== 'completed' || pause.payload?.turn_id !== 'a-pause' + || typeof plan.payload?.session_id !== 'string' || !plan.payload.session_id + || pause.payload?.session_id !== plan.payload.session_id) { + throw new EvalError('evaluator_invalid', 'early identity result lacks a completed qualified A session'); + } + const selection = payload.plan_selection; + const failure = payload.failure_code; + if (payload.stopped_before_approval !== true || !IDENTITY_FAILURES.has(failure) || !selection + || selection.ok !== false || typeof selection.approved !== 'boolean' || selection.reason !== failure + || selection.expected_path !== BROWNFIELD_PLAN) { + throw new EvalError('evaluator_invalid', 'early task_red requires an allowlisted identity failure with matching selection evidence'); + } + if (classifyPlanSelection(selection).reason !== failure) { + throw new EvalError('evaluator_invalid', 'identity failure evidence contradicts the observed selection'); + } + return true; +} export class ReceiptChain { constructor(runRoot, runId, options = {}) { if (!runId) throw new EvalError('evaluator_invalid', 'run ID is required'); @@ -71,7 +101,8 @@ export class ReceiptChain { } terminal(outcome, payload = {}, options = {}) { const publicResult = projectOutcome(outcome, options); - if (publicResult.disposition !== 'invalid' && this.links.length !== PREFIX.length) throw new EvalError('evaluator_invalid', 'product outcome requires a complete receipt chain'); + const earlyIdentityResult = validateEarlyIdentityFailure(outcome, payload, this.links, this.receiptDir); + if (publicResult.disposition !== 'invalid' && this.links.length !== PREFIX.length && !earlyIdentityResult) throw new EvalError('evaluator_invalid', 'product outcome requires a complete receipt chain'); const body = { schema_version: 1, record_type: 'terminal_seal', run_id: this.runId, sequence: 900, payload: { ...payload, outcome, public: publicResult, links: this.links.map(link => ({ ...link })) } }; const withReceipt = { ...body, receipt_sha256: sha256(canonicalStringify(body)) }; @@ -109,7 +140,8 @@ export function verifySeal(runRoot) { lastSequence = link.sequence; expectedFiles.add(link.path); } - if (links.some((link, index) => link.sequence !== PREFIX[index])) throw new EvalError('evaluator_invalid', 'receipt links are not a valid prefix'); + const normalPrefix = links.length <= PREFIX.length && links.every((link, index) => link.sequence === PREFIX[index]); + if (!normalPrefix) throw new EvalError('evaluator_invalid', 'receipt links are not a valid prefix'); const actualFiles = new Set(fs.readdirSync(receiptDir).filter(name => name.endsWith('.json'))); if (actualFiles.size !== expectedFiles.size || [...actualFiles].some(name => !expectedFiles.has(name))) { throw new EvalError('evaluator_invalid', 'unlinked or replayed receipt found'); @@ -120,6 +152,7 @@ export function verifySeal(runRoot) { if (canonicalStringify(publicResult) !== canonicalStringify(terminal.payload.public)) { throw new EvalError('evaluator_invalid', 'public outcome mapping mismatch'); } - if (publicResult.disposition !== 'invalid' && links.length !== PREFIX.length) throw new EvalError('evaluator_invalid', 'product outcome has an incomplete receipt chain'); + const earlyIdentityResult = validateEarlyIdentityFailure(terminal.payload.outcome, terminal.payload, links, receiptDir); + if (publicResult.disposition !== 'invalid' && links.length !== PREFIX.length && !earlyIdentityResult) throw new EvalError('evaluator_invalid', 'product outcome has an incomplete receipt chain'); return { terminal, links, public: publicResult }; } diff --git a/tests/gsdd.guards.test.cjs b/tests/gsdd.guards.test.cjs index 18f011c..162decc 100644 --- a/tests/gsdd.guards.test.cjs +++ b/tests/gsdd.guards.test.cjs @@ -92,7 +92,7 @@ describe('G9 - Generation Manifest Contract', () => { }); describe('Approval characterization', () => { - function makeApprovalFixture({ planStatus = 'pending', workflowPlan = {}, workflow = {} } = {}) { + function makeApprovalFixture({ planStatus = 'pending', workflowPlan = {}, workflow = {}, brownfieldStatus = null } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'workspine-approval-characterization-')); const phaseDir = path.join(root, '.work', 'phases', '01-approval'); fs.mkdirSync(phaseDir, { recursive: true }); @@ -112,6 +112,19 @@ describe('Approval characterization', () => { fs.writeFileSync(path.join(root, '.work', 'state.json'), `${JSON.stringify(state)}\n`); const plan = '.work/phases/01-approval/01-PLAN.md'; fs.writeFileSync(path.join(root, plan), `---\nstatus: ${planStatus}\n---\n# Approval characterization\n`); + if (brownfieldStatus) { + const changeDir = path.join(root, '.work', 'brownfield-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'CHANGE.md'), [ + '---', `status: ${brownfieldStatus}`, '---', '', '# Bounded change', '', + '## Goal', 'Keep the approval bound to the active authority.', '', + '## In Scope', '- Brownfield identity.', '', '## Out of Scope', '- Other lifecycle changes.', '', + '## Done When', '- The workflow identity is guarded.', '', + '## Current Status', `- Current posture: ${brownfieldStatus}`, '', + '## Next Action', '- Complete the identity check.', '', + '## Closeout Path', '1. Verify the result.', '', + ].join('\n')); + } return { root, plan }; } @@ -123,6 +136,69 @@ describe('Approval characterization', () => { ], { cwd: root, encoding: 'utf8' }); } + test('active brownfield authority rejects a phase plan before approval or lifecycle writes', async () => { + const fixture = makeApprovalFixture({ brownfieldStatus: 'active' }); + try { + const statePath = path.join(fixture.root, '.work', 'state.json'); + const before = fs.readFileSync(statePath); + for (const target of ['plan', 'approve', 'execute']) { + const result = spawnSync(process.execPath, [ + GSDD_PATH, 'lifecycle-transition', target, '--plan', fixture.plan, + '--authority', target === 'approve' ? 'owner' : 'workflow', + ...(target === 'approve' ? ['--approval-ref', 'owner-review'] : []), + '--json', '--no-update-notice', + ], { cwd: fixture.root, encoding: 'utf8' }); + assert.notStrictEqual(result.status, 0, result.stdout || result.stderr); + const receipt = JSON.parse(result.stdout); + assert.strictEqual(receipt.status, 'error'); + assert.strictEqual(receipt.changed, false); + assert.strictEqual(receipt.error_code, 'wrong_brownfield_plan_identity'); + assert.deepStrictEqual(fs.readFileSync(statePath), before); + } + const finalState = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(finalState.workflow.plan.approved, false); + assert.strictEqual(finalState.workflow.approval_ref, undefined); + + const workContext = await import(`file://${path.join(ROOT, 'bin', 'lib', 'work-context.mjs').replace(/\\/g, '/')}`); + assert.throws(() => workContext.transitionWorkflowState(path.join(fixture.root, '.work'), { + target: 'approve', planPath: fixture.plan, planIdentity: fixture.plan, + authority: 'owner', approvalRef: 'owner-review', approved: true, + }), error => error.code === 'wrong_brownfield_plan_identity'); + assert.deepStrictEqual(fs.readFileSync(statePath), before); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + test('plan and execute workflows carry the same brownfield plan identity through their lifecycle steps', () => { + const plan = fs.readFileSync(path.join(ROOT, 'distilled', 'workflows', 'plan.md'), 'utf8'); + const execute = fs.readFileSync(path.join(ROOT, 'distilled', 'workflows', 'execute.md'), 'utf8'); + assert.match(plan, /Put the plan body and structured `\.\.\.<\/plan_check><\/checks>` in a `## Implementation Plan` section of `\.work\/brownfield-change\/CHANGE\.md`/i); + assert.match(plan, /lifecycle-transition plan --plan \.work\/brownfield-change\/CHANGE\.md --authority workflow --json/i); + assert.match(plan, /lifecycle-transition approve --plan \.work\/brownfield-change\/CHANGE\.md --authority owner --approval-ref \{approval_ref\} --json/i); + assert.match(plan, /For active brownfield authority, show and approve `\.work\/brownfield-change\/CHANGE\.md` with that reference/i); + assert.match(execute, /active brownfield change,[\s\S]{0,240}lifecycle-transition execute --plan \.work\/brownfield-change\/CHANGE\.md/i); + assert.match(execute, /Do not switch identities between planning, approval, and execution/i); + assert.match(execute, /For active brownfield authority, record task evidence[\s\S]{0,220}ready_for_verification/i); + }); + + test('closed brownfield history does not block an unrelated phase approval', () => { + const fixture = makeApprovalFixture({ brownfieldStatus: 'closed' }); + try { + const result = spawnSync(process.execPath, [ + GSDD_PATH, 'lifecycle-transition', 'approve', '--plan', fixture.plan, + '--authority', 'owner', '--approval-ref', 'owner-review', '--json', '--no-update-notice', + ], { cwd: fixture.root, encoding: 'utf8' }); + assert.strictEqual(result.status, 0, result.stdout || result.stderr); + const receipt = JSON.parse(result.stdout); + assert.strictEqual(receipt.status, 'ok'); + assert.strictEqual(receipt.state.workflow.plan.path, fixture.plan); + assert.strictEqual(receipt.state.workflow.plan.identity, fixture.plan); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + test('approval characterization rejects agent-editable approved frontmatter alone', () => { const fixture = makeApprovalFixture({ planStatus: 'approved' }); try { From 465e5b9d32fcd84902a96d4d79441234aa796cc4 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 3 Oct 2026 20:26:00 +0200 Subject: [PATCH 02/19] fix: preserve brownfield continuation and require branch agreement Read pause checkpoints structurally and reduce lifecycle traversal overhead without pruning artifacts or weakening containment. Preserve current-plan approval and continue active execution without replaying its transition. Share selected-workspace branch validation across resume preflight and direct execute writes/replays. Keep unresolved evidence advisory and align recovery instructions with the approved strict branch policy. Validate focused and corrective canonical checks, packed-consumer proof and assisted provider continuity. Cold-human acceptance and release remain gated. --- bin/lib/control-map.mjs | 19 ++ bin/lib/lifecycle-preflight.mjs | 80 +++++++- bin/lib/lifecycle-state.mjs | 16 +- bin/lib/work-context.mjs | 95 ++++++++- bin/lib/workflows.mjs | 2 +- distilled/DESIGN.md | 8 + distilled/workflows/execute.md | 5 +- distilled/workflows/pause.md | 7 +- distilled/workflows/resume.md | 22 ++- tests/gsdd.flow-cohesion.test.cjs | 313 +++++++++++++++++++++++++++++- tests/gsdd.next-blockers.test.cjs | 49 +++++ tests/gsdd.next.test.cjs | 109 +++++++++++ 12 files changed, 697 insertions(+), 28 deletions(-) diff --git a/bin/lib/control-map.mjs b/bin/lib/control-map.mjs index f72787b..374414f 100644 --- a/bin/lib/control-map.mjs +++ b/bin/lib/control-map.mjs @@ -47,6 +47,25 @@ function runGit(cwd, args) { } } +/** Read a literal branch declaration, allowing only Markdown/display decoration. */ +export function resolveGitBranchDeclaration({ workspaceRoot, surface } = {}) { + const declaration = typeof surface === 'string' ? surface.trim() : ''; + if (!declaration || /^(?:unknown|unverified|tbd)$/i.test(declaration)) return null; + const unwrap = (value) => value.replace(/^`([^`]+)`$/, '$1'); + const candidates = [ + unwrap(declaration), + unwrap(declaration.replace(/\s*\([^()]*\)\s*$/, '').trim()), + ]; + for (const candidate of new Set(candidates)) { + if (!candidate) continue; + const branch = candidate.replace(/^refs\/heads\//, ''); + const checked = runGit(workspaceRoot, ['check-ref-format', '--branch', branch]); + // Git owns ref-name syntax. Reject revision expansions such as @{-1}. + if (checked.ok && checked.stdout === branch) return branch; + } + return null; +} + function runIgnoredCount(cwd) { const result = runGit(cwd, ['ls-files', '-o', '-i', '--exclude-standard']); if (!result.ok) { diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index 04341d8..2f707ad 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'fs'; import { isAbsolute, join, relative, resolve } from 'path'; import { output } from './cli-utils.mjs'; -import { buildControlMap } from './control-map.mjs'; +import { buildControlMap, resolveGitBranchDeclaration } from './control-map.mjs'; import { collectNativePhaseArtifacts, evaluateLifecycleState, @@ -318,6 +318,18 @@ export function evaluateLifecyclePreflight({ else warnings.push(notice); } + if ((surface === 'resume' || (surface === 'execute' && usesBrownfieldAuthority)) + && lifecycle.brownfieldChange.exists && lifecycle.brownfieldChange.currentStatus !== 'closed') { + const continuity = brownfieldBranchContinuity({ + workspaceRoot, + change: lifecycle.brownfieldChange, + map: controlMap.report, + stateLabel, + }); + if (continuity.severity === 'block') blockers.push(continuity); + else if (continuity.severity === 'warn') warnings.push(continuity); + } + if (lifecycle.phaseStatusAlignment.mismatches.length > 0) { const mismatch = { code: 'roadmap_phase_status_mismatch', @@ -415,6 +427,7 @@ export function evaluateLifecyclePreflight({ function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, controlMapReport = null }) { const empty = { + report: null, summary: null, notices: [], }; @@ -434,6 +447,7 @@ function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, c })); return { + report: map, summary: { riskCount: map.risks.length, noticeCount: notices.length, @@ -445,6 +459,45 @@ function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, c }; } +function brownfieldBranchContinuity({ workspaceRoot, change, map, stateLabel }) { + const artifacts = [stateLabel('brownfield-change', 'CHANGE.md')]; + const unverified = (reason) => ({ + code: 'brownfield_branch_unverified', + severity: 'warn', + message: `Brownfield integration surface is unverified: ${reason}. Reconcile the declared surface with live repository truth before relying on it.`, + artifacts, + }); + const declaredBranch = resolveGitBranchDeclaration({ workspaceRoot, surface: change.currentIntegrationSurface }); + if (!declaredBranch) return unverified('CHANGE.md does not declare one literal Git branch'); + if (!map?.workspace_root || relative(workspaceRoot, resolve(map.workspace_root)) !== '') { + return unverified('the control map does not identify the selected workspace'); + } + const selected = (map.worktrees || []).find((entry) => ( + typeof entry.path === 'string' && relative(workspaceRoot, resolve(entry.path)) === '' + )); + if (!selected) return unverified('the selected workspace has no matching worktree record'); + const detached = selected.detached === true || selected.branch === 'HEAD'; + const branchFromRef = typeof selected.branch_ref === 'string' && selected.branch_ref.startsWith('refs/heads/') + ? selected.branch_ref.slice('refs/heads/'.length) + : null; + if (!detached && selected.branch && branchFromRef && selected.branch !== branchFromRef) { + return unverified('the selected workspace branch observations disagree'); + } + // An unborn repository has no HEAD commit, but worktree list still reports + // its symbolic branch. Keep the existing Git-validity warning independent. + const actualBranch = selected.branch || branchFromRef; + if (!detached && (!actualBranch || (!selected.git_valid && !(branchFromRef && selected.git_top_level)))) { + return unverified('the selected workspace branch could not be read from Git'); + } + if (!detached && actualBranch === declaredBranch) return { severity: 'info' }; + return { + code: 'brownfield_branch_mismatch', + severity: 'block', + message: `CHANGE.md declares branch ${declaredBranch}, but the selected workspace is ${detached ? 'at detached HEAD' : `on branch ${actualBranch}`}. Use the intended checkout/worktree or the existing formal review path; acknowledgement cannot override branch agreement. Do not silently edit or reapprove an executed plan.`, + artifacts, + }; +} + function controlMapNotice(risk, stateLabel) { return { code: risk.code, @@ -1053,6 +1106,7 @@ function assertBrownfieldTransitionContract({ planningDir, target, plan }) { throw transitionErrorForCli('brownfield_not_ready', 'Brownfield closeout requires CHANGE.md posture ready_for_verification or closed.', [plan.relative]); } } + return brownfield; } function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, authority, approvalRef, approvalRefProvided = false, approved = false, approvalRequested = false }) { @@ -1127,7 +1181,7 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, const identityGuard = preWriteGuard; preWriteGuard = () => { identityGuard?.(); - assertBrownfieldTransitionContract({ planningDir, target, plan }); + return assertBrownfieldTransitionContract({ planningDir, target, plan }); }; preWriteGuard(); } @@ -1150,6 +1204,25 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, if (target === 'fix_gaps' && !['gaps_found', 'human_needed', 'blocked', 'failed', 'fail'].includes(artifactStatus)) { throw transitionErrorForCli('gaps_not_recorded', 'The supplied verification artifact does not record gaps or a human gate.', [artifact.relative]); } + const warnings = []; + if (brownfieldChain && target === 'execute') { + const contractGuard = preWriteGuard; + // Artifact/approval refusal keeps precedence. The final mutation guard + // runs before both a state write and replay, with one control-map scan. + preWriteGuard = () => { + const change = contractGuard(); + const continuity = brownfieldBranchContinuity({ + workspaceRoot, + change, + map: buildControlMap({ workspaceRoot, planningDir }), + stateLabel, + }); + if (continuity.severity === 'block') { + throw transitionErrorForCli(continuity.code, continuity.message, continuity.artifacts); + } + if (continuity.severity === 'warn') warnings.push(continuity); + }; + } return { workspaceRoot, plan, @@ -1160,6 +1233,7 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, approvalRef: authority === 'owner' ? normalizedApprovalRef || null : null, approvalRequested, preWriteGuard, + warnings, stateLabel, }; } @@ -1219,7 +1293,7 @@ export function cmdLifecycleTransition(...args) { approved, preWriteGuard: validated.preWriteGuard, }); - respond({ schema_version: 1, operation: 'lifecycle-transition', target, ...result, evidence: [validated.plan?.relative, validated.artifact?.relative].filter(Boolean) }); + respond({ schema_version: 1, operation: 'lifecycle-transition', target, ...result, ...(validated.warnings.length ? { warnings: validated.warnings } : {}), evidence: [validated.plan?.relative, validated.artifact?.relative].filter(Boolean) }); if (result.status === 'error') process.exitCode = 1; } catch (transitionError) { const response = { diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index 0684a2d..8762aa5 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -380,7 +380,7 @@ function collectPhaseArtifacts(phasesDir) { const declaredRoot = resolve(phasesDir); let phasesRoot; try { - phasesRoot = realpathSync(declaredRoot); + phasesRoot = canonicalPhasePath(declaredRoot); } catch { return []; } @@ -398,8 +398,10 @@ function collectPhaseArtifacts(phasesDir) { const artifactDirectory = relativeDirectory || ''; if (entry.isSymbolicLink()) continue; if (entry.isFile()) { - if (!isContainedRealPath(phasesRoot, entryPath)) continue; const artifact = classifyPhaseArtifact(artifactDirectory, entry.name, entryPath); + // The parent directory was resolved and contained before enumeration. + // A regular, non-link immediate child cannot redirect outside it. Avoid + // resolving every file's complete ancestor chain again for this scan. if (artifact) artifacts.push(artifact); continue; } @@ -420,12 +422,20 @@ function isWithinRoot(root, candidate) { function isContainedRealPath(root, candidate) { try { - return isWithinRoot(root, realpathSync(candidate)); + return isWithinRoot(root, canonicalPhasePath(candidate)); } catch { return false; } } +function canonicalPhasePath(candidate) { + // Native Windows resolution avoids repeating JS ancestor walks. Keep the + // existing resolver elsewhere: native realpath requires /proc on musl Linux. + return process.platform === 'win32' && realpathSync.native + ? realpathSync.native(candidate) + : realpathSync(candidate); +} + function classifyPhaseArtifact(dir, name, path) { const baseIdMatch = name.match(/^(\d+(?:\.\d+)*[a-z]?(?:-\d+)?)/i); const phaseTokenMatch = (dir ? dir.match(/^(\d+(?:\.\d+)*[a-z]?)-/i) : null) diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 5f6160d..3fda6e6 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -1921,11 +1921,84 @@ function parseCheckpointFrontmatter(content, errors) { return { frontmatter, body: content.slice(end + 5) }; } -function extractCheckpointSections(content, sectionNames, errors, label = 'checkpoint') { +// Checkpoint tags are Markdown block delimiters, not arbitrary XML in prose. +// Only whole-line delimiters (or a whole compact section) are structural. Keep +// unknown historical wrappers in the tree so their children cannot impersonate +// the canonical top-level sections, without imposing a new wrapper vocabulary. +function parseCheckpointBlocks(content, errors) { + const roots = []; + const stack = []; + let fence = null; + let offset = 0; + for (const line of content.split('\n')) { + const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (fenceMatch && fenceMatch[1][0] === fence.character + && fenceMatch[1].length >= fence.length && !fenceMatch[2].trim()) fence = null; + } else if (fenceMatch && (fenceMatch[1][0] !== '`' || !fenceMatch[2].includes('`'))) { + fence = { character: fenceMatch[1][0], length: fenceMatch[1].length }; + } else { + const compact = line.match(/^ {0,3}<([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s+[^<>]*)?>(.*)<\/\1>\s*$/); + const opening = line.match(/^ {0,3}<([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s+[^<>]*)?>\s*$/); + const closing = line.match(/^ {0,3}<\/([A-Za-z_][A-Za-z0-9_.:-]*)>\s*$/); + if (compact || opening) { + const name = (compact || opening)[1]; + const node = { name, children: [], compactContent: compact ? compact[2] : null, start: offset + line.length + 1, end: null, closed: Boolean(compact) }; + (stack.length ? stack[stack.length - 1].children : roots).push(node); + if (!compact) stack.push(node); + } else if (closing) { + const node = stack[stack.length - 1]; + if (!node || node.name !== closing[1]) { + checkpointError(errors, 'malformed checkpoint section closing delimiter'); + } else { + node.end = offset; + node.closed = true; + stack.pop(); + } + } + } + offset += line.length + 1; + } + if (stack.length) checkpointError(errors, 'unclosed checkpoint section'); + return roots; +} + +// A fully compact judgment is the existing four-child layout written on one +// line. Read its adjacent complete child blocks without interpreting inline +// tags in a child's prose. Backtick spans keep literal closing tags inert. +function parseCompactCheckpointJudgment(content, errors) { + const visible = content.replace(/(? ' '.repeat(span.length)); + const children = []; + let cursor = 0; + while (visible.slice(cursor).trim()) { + cursor += visible.slice(cursor).match(/^\s*/)[0].length; + const opening = visible.slice(cursor).match(/^<([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s+[^<>]*)?>/); + if (!opening) { + checkpointError(errors, 'malformed compact checkpoint judgment'); + break; + } + const start = cursor + opening[0].length; + const delimiter = ``; + let closing = visible.indexOf(delimiter, start); + while (closing !== -1) { + const remainder = visible.slice(closing + delimiter.length).trimStart(); + if (!remainder || /^<[A-Za-z_][A-Za-z0-9_.:-]*(?:\s+[^<>]*)?>/.test(remainder)) break; + closing = visible.indexOf(delimiter, closing + delimiter.length); + } + if (closing === -1) { + checkpointError(errors, 'unclosed compact checkpoint judgment section'); + break; + } + children.push({ name: opening[1], children: [], compactContent: content.slice(start, closing), closed: true }); + cursor = closing + delimiter.length; + } + return children; +} + +function extractCheckpointSections(blocks, content, sectionNames, errors, label = 'checkpoint') { const sections = {}; for (const name of sectionNames) { - const matcher = new RegExp(`<${name}>([\\s\\S]*?)`, 'g'); - const matches = [...content.matchAll(matcher)]; + const matches = blocks.filter((block) => block.name === name && block.closed); if (matches.length === 0) { checkpointError(errors, `missing ${label} section: ${name}`); continue; @@ -1934,7 +2007,10 @@ function extractCheckpointSections(content, sectionNames, errors, label = 'check checkpointError(errors, `duplicate ${label} section: ${name}`); continue; } - sections[name] = matches[0][1].trim(); + sections[name] = (matches[0].compactContent ?? content.slice(matches[0].start, matches[0].end)).trim(); + if (matches[0].children.some((child) => sectionNames.includes(child.name) || child.name === 'judgment')) { + checkpointError(errors, `invalid nested ${label} section: ${name}`); + } } return sections; } @@ -1968,13 +2044,18 @@ export function readContinuityCheckpoint(planningDir) { const errors = []; const { frontmatter, body } = parseCheckpointFrontmatter(content, errors); - const sections = extractCheckpointSections(body, CHECKPOINT_REQUIRED_SECTIONS, errors); - const judgmentMatches = [...body.matchAll(/([\s\S]*?)<\/judgment>/g)]; + const blocks = parseCheckpointBlocks(body, errors); + const sections = extractCheckpointSections(blocks, body, CHECKPOINT_REQUIRED_SECTIONS, errors); + const judgmentMatches = blocks.filter((block) => block.name === 'judgment' && block.closed); let judgment = null; if (judgmentMatches.length > 1) { checkpointError(errors, 'duplicate checkpoint judgment block'); } else if (judgmentMatches.length === 1) { - judgment = extractCheckpointSections(judgmentMatches[0][1], CHECKPOINT_JUDGMENT_SECTIONS, errors, 'checkpoint judgment'); + const block = judgmentMatches[0]; + const children = block.compactContent === null + ? block.children + : parseCompactCheckpointJudgment(block.compactContent, errors); + judgment = extractCheckpointSections(children, body, CHECKPOINT_JUDGMENT_SECTIONS, errors, 'checkpoint judgment'); } return { diff --git a/bin/lib/workflows.mjs b/bin/lib/workflows.mjs index b9d953c..d0905d8 100644 --- a/bin/lib/workflows.mjs +++ b/bin/lib/workflows.mjs @@ -19,7 +19,7 @@ export const WORKFLOWS = [ defineWorkflow({ name: 'work-new-milestone', workflow: 'new-milestone.md', description: 'Start the next milestone of a project that already has SPEC and ROADMAP and a completed milestone in MILESTONES' }), defineWorkflow({ name: 'work-quick', workflow: 'quick.md', description: 'Finish one small self-contained task with at most 3 tasks in one sitting and no owner decision that must survive a break; otherwise, or if an owner-approved plan is needed, use work-plan' }), defineWorkflow({ name: 'work-pause', workflow: 'pause.md', description: 'Pause work - save session context for seamless resumption' }), - defineWorkflow({ name: 'work-resume', workflow: 'resume.md', description: 'Resume work - restore context and route to next action' }), + defineWorkflow({ name: 'work-resume', workflow: 'resume.md', description: 'Resume after a session break - reconcile saved context and current repo truth before continuing edits' }), defineWorkflow({ name: 'work-progress', workflow: 'progress.md', description: 'Check progress - show project status and route to next action', mutatesArtifacts: false }), ]; diff --git a/distilled/DESIGN.md b/distilled/DESIGN.md index df832ce..185e3a6 100644 --- a/distilled/DESIGN.md +++ b/distilled/DESIGN.md @@ -2517,6 +2517,14 @@ Sub-gap (b) was closed by D28's `` mandate and guarded by G30. Sub- - `progress` can report the active brownfield state without widening into a mutating workflow. - `resume` can restore brownfield continuity honestly while still stopping short of the broader routing and promotion work reserved for Phases 42-43. +### D55 branch agreement amendment - 2026-10-03 + +For active brownfield resume and execution, an unequivocal branch declared by `CHANGE.md` must agree with the resolved selected workspace's live Git branch. Existing lifecycle preflight reuses its computed control map; direct execute transitions apply the same comparison in the final mutation guard before a write or replay, with one map scan per command. Both compare that workspace's own worktree record, never a main/sibling fallback. A proven disagreement, or detached HEAD with a declared branch, blocks eligibility; owner acknowledgement does not override this branch agreement. This supersedes D55's acknowledgement path for declared-branch mismatch only. + +Recovery uses the intended checkout/worktree or the existing formal review path when the operational contract must change. An executed, approved CHANGE is not silently rewritten or reapproved to remove the mismatch. Checkpoint and handoff claims do not change the comparison or existing exact-plan/current-byte owner approval. + +Missing/freeform declarations, unavailable Git and unresolved selected-workspace facts remain explicitly unverified rather than guessed mismatches or a universal Git requirement. Ordinary dirty-state warnings, read-only progress, phase workflows, closed history, and genuine acknowledgement of other material context mismatches retain their existing behavior. Branch syntax is validated by Git after bounded Markdown/display decoration removal; no new ref parser, acknowledgement store, or bypass flag is introduced. + ## D56 - Executable Brownfield Routing And Widen-Only Escalation **Decision (2026-04-21; revised 2026-06-30):** Brownfield routing should treat `CHANGE.md` as the default operational anchor and allow a surviving `phase` or `quick` checkpoint to outrank it only when one strict-match rule proves the checkpoint is still the active execution surface. From concrete brownfield state, `/work-new-project` remains available only as an intentional widen path into full milestone setup, not as an accidental fallback recommendation. Active bounded brownfield planning uses the existing `work-plan` workflow with explicit `brownfield-change` authority instead of being forced through unrelated ROADMAP phase membership. diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index 9c91a67..41e14cd 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -40,6 +40,7 @@ Read the tasks under `## Implementation Plan` in CHANGE.md; HANDOFF.md stays con - Implementation evidence complete: `node .work/bin/gsdd.mjs lifecycle-transition verify --plan .work/brownfield-change/CHANGE.md --artifact .work/brownfield-change/VERIFICATION.md --authority workflow --json` - Verification passed: `node .work/bin/gsdd.mjs lifecycle-transition audit --plan .work/brownfield-change/CHANGE.md --artifact .work/brownfield-change/VERIFICATION.md --authority workflow --json` On pause/resume, recover this identity from CHANGE.md and the checkpoint. Resume an already recorded execution from its current task; scope changes require replanning and fresh approval. When the selected plan identity is `.work/brownfield-change/CHANGE.md`, execute one bounded stream only. Re-read `CHANGE.md` as the operational authority and `HANDOFF.md` as judgment context; do not take scope or status from HANDOFF. Refuse execution without a concrete Done When list, when another active brownfield stream or milestone authority is present, when the posture is blocked or widening, or when the requested work exceeds the declared write scope. Reruns are read-before-write: preserve all existing user-authored text and evidence, and update only the approved bounded surfaces. After the approved work is complete, record its evidence and set `CHANGE.md` to `ready_for_verification`; do not close the lane from execution. +The matching preflight and execute transition's write/replay guard block an unequivocal declared-branch mismatch or detached HEAD against the selected workspace. Acknowledgement cannot override that branch agreement: use the intended checkout/worktree or the existing formal review path. Do not silently edit or reapprove an executed CHANGE.md to make the gate pass; unknown surfaces remain unverified warnings. Before code mutation, run `node .work/bin/gsdd.mjs control-map --json` when available. Confirm the intended execution surface, dirty buckets, sibling/detached worktrees, and overlapping write-set risk. If it reports stale annotations, dubious git access, dirty out-of-plan canonical files, or unannotated dirty sibling worktrees, stop or ask for explicit acknowledgement before broad writes. Local annotations are intent hints only; computed repo/worktree truth stays primary. @@ -93,8 +94,8 @@ For each task in the plan, follow this loop: ```text 1. Read the phase plan frontmatter and current task, or the current task from the brownfield `CHANGE.md#Implementation Plan`. 2. Read the task_scoped files and focused references needed for that task. -3. Before implementation, run the lifecycle execute transition with the plan identity selected for this authority, and stop if it refuses. For an active brownfield change, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/brownfield-change/CHANGE.md --authority workflow --json`; for a phase, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --json`. Do not switch identities between planning, approval, and execution. Do not pass `--approved` or `--approval-ref` here: explicit approval is an owner-only transition, and workflow execution consumes the plan's already-durable approval. - When the transition succeeds, implement the task action. +3. On first entry to execution for the selected plan only, run the lifecycle execute transition and stop if it refuses; implement the task action after it succeeds. For an active brownfield change, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/brownfield-change/CHANGE.md --authority workflow --json`; for a phase, use `node .work/bin/gsdd.mjs lifecycle-transition execute --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --json`. Do not switch identities between planning, approval, and execution. Do not pass `--approved` or `--approval-ref` here: explicit approval is an owner-only transition, and workflow execution consumes the plan's already-durable approval. + For subsequent tasks or a fresh resume, complete the matching lifecycle preflight and confirm `.work/state.json` records `workflow.current_state: execute` and `workflow.execution.status: in_progress`, with both `workflow.plan.path` and `workflow.plan.identity` equal to the exact selected plan. Validate its durable owner approval against current plan bytes (`workflow.plan.approved_sha256`) using the existing approval rules, then continue the current task without replaying the execute transition. Missing, stale, mismatched, blocked, or completed execution cannot be restarted from checkpoint prose; stop and resolve its lifecycle posture through the existing workflow. 4. Verify the task action using its planned steps. 5. Handle any git actions using repo or user conventions. 6. Re-read the plan's Objective and `non_goals`. Confirm the work just completed serves the objective and crossed no boundary; if not, record a deviation before continuing. diff --git a/distilled/workflows/pause.md b/distilled/workflows/pause.md index 706edbd..5d58a4a 100644 --- a/distilled/workflows/pause.md +++ b/distilled/workflows/pause.md @@ -41,7 +41,7 @@ Store the detected work type as `$WORK_TYPE` (one of: `phase`, `quick`, `generic Build a draft checkpoint from artifact truth before asking the user to restate work. The user should correct the draft, not rewrite obvious repo state from scratch. -When available, run `node .work/bin/gsdd.mjs control-map --json` and use it as the draft's repo/worktree snapshot: canonical branch/HEAD, dirty tracked/untracked/ignored buckets, sibling/detached worktrees, stale annotations, planning drift, and recommended interventions. Include only a compact summary or pointer in `.work/.continue-here.md`; the checkpoint records resumability context, not a replacement for future computed repo truth. +Capture the current branch, HEAD, and dirty work from live Git alongside the active artifacts. Persist this minimum checkpoint before optional full-worktree discovery. When available, `node .work/bin/gsdd.mjs control-map --json` can then enrich its repo/worktree snapshot with dirty tracked/untracked/ignored buckets, sibling/detached worktrees, stale annotations, planning drift, and recommended interventions. If that enrichment is slow, unavailable, or fails, retain the checkpoint and record which facts remain unverified. Include only a compact summary or pointer in `.work/.continue-here.md`; the checkpoint records resumability context, not a replacement for future computed repo truth. Ask the user conversationally to fill in the gaps the artifacts cannot answer: @@ -58,6 +58,8 @@ Read the relevant artifacts and current integration surface to pre-fill what you - For quick tasks: read the quick task PLAN and LOG.md entry — same pre-fill approach - For generic work: derive everything you can from repo state first, then ask only for what remains unknown +Attribute owner decisions and mismatch acknowledgements only to a referenced human instruction or existing durable owner-decision/approval record. Label agent choices as agent judgment and uncertain attribution as unverified. Writing the checkpoint preserves these distinctions; it does not create owner authority. + Question budget: - Ask at most 3 high-signal questions total - Prefer confirmation/correction prompts over open-ended recap prompts @@ -104,7 +106,7 @@ runtime: $INFERRED_RUNTIME -[The specific first thing to do when resuming — include the exact command or file/step reference verbatim, quoted, so a fresh session can act without interpretation] +Run `/work-resume` first. After its required reconciliation and the selected workflow's preflight, [include the exact continuation command or file/step reference verbatim, quoted]. @@ -124,6 +126,7 @@ runtime: $INFERRED_RUNTIME ``` The checkpoint is project-scoped (lives at `.work/.continue-here.md`, not inside a phase directory) so resume always knows where to look. Keep the frontmatter and six required sections exactly as shown: `gsdd next --json` and resume preflight share this file-backed contract. +Keep structural tags on their own lines, or use one complete compact section per line. Put examples containing structural tags inside Markdown fences. The `next_action` must start with `/work-resume` and retain the concrete continuation step after its checks; checkpoint prose never authorizes edits by itself. **MANDATORY: `.work/.continue-here.md` must exist on disk after writing. If the file was not created, STOP and report the failure. The entire purpose of this workflow is to persist context — a failed write means the pause did nothing.** diff --git a/distilled/workflows/resume.md b/distilled/workflows/resume.md index 676a023..63a7295 100644 --- a/distilled/workflows/resume.md +++ b/distilled/workflows/resume.md @@ -4,6 +4,7 @@ You are the SESSION CONTEXT RESTORER. Your job is to reconstruct project state f Core mindset: derive state from primary artifacts. Do not depend on secondary summary files. ROADMAP.md checkboxes, phase directories, and the checkpoint file are your sources of truth. Scope boundary: unlike progress.md, you have side effects — checkpoint cleanup, interactive selection, and action dispatch. You restore context and get the user moving. +Run this workflow after a session break before continuing product edits, even when the checkpoint's next action is concrete. Checkpoint prose supplies context; current artifacts, live repository truth, and the selected workflow's preflight supply authority. @@ -135,6 +136,10 @@ Before routing, reconstruct and compare these truth buckets explicitly: Treat them as separate inputs. Do not flatten them into one continuity story. +Keep owner decisions distinct from agent judgment. For material context mismatches requiring acknowledgement, validate the claimed owner acknowledgement against its referenced human instruction or durable owner-decision/approval record; an unsupported assertion by a prior agent is unverified and must follow the existing mismatch gate. Repeated checkpoint/handoff claims, agent-generated skill arguments, or a generic continuation prompt do not supply missing acknowledgement. Routine reversible choices remain agent-owned, and existing valid exact-plan approvals remain usable without a new question or raw-history search. + +For an active brownfield change, an unequivocal declared branch must agree with the resolved selected workspace's live branch. Resume and execute preflight block a proven disagreement or detached HEAD with a declared branch; acknowledgement cannot override this branch gate. Recover through the intended checkout/worktree or existing formal review, without silently editing or reapproving an executed CHANGE.md. Unknown/freeform surfaces or unavailable Git remain explicitly unverified, and ordinary dirty-state warnings remain warnings. + Material mismatch signals include: - checkpoint narrative describes only a narrow slice of a broader dirty tree - current branch is stale/spent relative to the next intended integration surface @@ -142,7 +147,7 @@ Material mismatch signals include: - `CHANGE.md` names a different branch / integration surface than the current git branch - dirty files fall outside the active brownfield write scope declared in `CHANGE.md` -If git/worktree truth materially disagrees with checkpoint narrative truth: +If git/worktree truth materially disagrees with checkpoint narrative truth for reasons other than a proven declared-branch blocker: - record a mismatch flag - keep ordinary git risk warning-level by default - require explicit user acknowledgement before routing onward @@ -151,7 +156,7 @@ If git/worktree truth materially disagrees with checkpoint narrative truth: If git/worktree truth materially disagrees with the active brownfield artifact: - keep `CHANGE.md` as the operational anchor - surface the mismatch explicitly -- require acknowledgement before continuing the brownfield change from this workflow +- for material context mismatch other than declared-branch disagreement, require acknowledgement before continuing the brownfield change from this workflow - keep ordinary git warnings separate from this stronger continuity gate - When a checkpoint also exists, let that checkpoint outrank the brownfield anchor only if a strict-match rule proves it is still the active execution surface: - branch alignment: checkpoint branch, `CHANGE.md` integration surface, and current git branch all match @@ -278,6 +283,8 @@ Only show the staleness banner when `` produced a staleness Evaluate in priority order and present the primary recommendation: +If resume preflight reports `brownfield_branch_mismatch`, recommend the intended checkout/worktree reconciliation or existing formal review for an executed contract. Keep the checkpoint intact and do not dispatch execution until matching preflight confirms the blocker is resolved; acknowledgement cannot override this route. + **Checkpoint exists (`.continue-here.md`):** Route based on the `workflow` frontmatter: - `phase` — route to `/work-execute` (or `/work-plan`/`/work-verify` based on checkpoint context) @@ -286,14 +293,14 @@ Route based on the `workflow` frontmatter: If `` marked the checkpoint as stale, keep the same routing logic. The user may still choose to resume from the checkpoint after reviewing the warning. If the user chooses a different path, leave the checkpoint in place and continue without it. -If `` marked a material checkpoint/worktree mismatch, keep the same routing logic but require explicit acknowledgement before continuing. The workflow should not silently route onward from a materially misleading checkpoint narrative. +If `` marked a material checkpoint/worktree mismatch without a proven declared-branch blocker, keep the same routing logic but require explicit acknowledgement before continuing. The workflow should not silently route onward from a materially misleading checkpoint narrative. If an active brownfield change also exists, apply the same strict-match rule before making a `phase` or `quick` checkpoint primary. A checkpoint only stays primary when branch alignment, scope alignment, and still-active execution state all hold at once. Otherwise, keep the checkpoint visible and user-selectable, but fall through to the active brownfield change as the default resume target. **Active brownfield change (`.work/brownfield-change/CHANGE.md`):** If there is no checkpoint, or if the surviving checkpoint does not satisfy the strict-match rule against the active brownfield change: - present the `CHANGE.md` next action as the primary resume target - keep `HANDOFF.md` as supporting judgment context only -- if artifact/worktree mismatch is material, require explicit acknowledgement before continuing +- if artifact/worktree mismatch is material and is not a proven declared-branch blocker, require explicit acknowledgement before continuing - if the user does not want to continue immediately, let them review `CHANGE.md` or `HANDOFF.md` without deleting any checkpoint file - do not force this state back through `/work-new-project` or milestone routing just because there is no active roadmap - keep `/work-new-project` and `/work-new-milestone` as explicit widen-only choices when the bounded change no longer fits one active stream or now needs milestone-owned lifecycle state @@ -326,15 +333,15 @@ What would you like to do? When the primary action is an active brownfield change rather than a checkpoint or lifecycle workflow, replace option 3 with `Review CHANGE.md or HANDOFF.md` if there is no active roadmap to inspect. -**Quick-resume shortcut:** If there is no stale-checkpoint banner and no material checkpoint/worktree mismatch, the user may say "continue", "go", or "resume" without further input to execute the primary action directly. +**Quick-resume shortcut:** If there is no stale-checkpoint banner, material checkpoint/worktree mismatch, or proven declared-branch blocker, the user may say "continue", "go", or "resume" without further input to execute the primary action directly. -**Mismatch acknowledgement:** If material checkpoint/worktree mismatch or material brownfield artifact/worktree mismatch was detected, require an explicit acknowledgement such as "continue despite mismatch" or a different selected path. Do not let a bare "continue" skip the warning. +**Mismatch acknowledgement:** For material checkpoint/worktree or brownfield artifact/worktree mismatch other than a proven declared-branch blocker, require an explicit acknowledgement such as "continue despite mismatch" or a different selected path. Do not let a bare "continue" skip the warning. A proven branch blocker offers only intended checkout/worktree reconciliation or existing formal review for an executed contract, never acknowledgement-and-proceed. Wait for user selection. -Immediately after the user confirms their action selection (before routing to the target workflow): +Only when no unresolved `brownfield_branch_mismatch` remains, immediately after the user confirms their action selection (before routing to the target workflow): - If the user chose to resume from `.continue-here.md`: 1. Run `node .work/bin/gsdd.mjs file-op copy .work/.continue-here.md .work/.continue-here.bak`. 2. After the copy succeeds, run `node .work/bin/gsdd.mjs file-op delete .work/.continue-here.md`. @@ -367,4 +374,5 @@ Consider clearing context before starting the next workflow for best results. --- Then dispatch to the selected `/work-*` workflow. +Before any resumed product or lifecycle edit, complete that workflow's mandatory artifact reads and matching lifecycle preflight. For execution, recover the exact selected PLAN or `CHANGE.md#Implementation Plan` identity and current task, and validate its existing owner approval against current bytes. Resume does not grant approval, transfer it to a different plan, or repeat a completed execution transition to restart an already recorded task. diff --git a/tests/gsdd.flow-cohesion.test.cjs b/tests/gsdd.flow-cohesion.test.cjs index c152650..276227f 100644 --- a/tests/gsdd.flow-cohesion.test.cjs +++ b/tests/gsdd.flow-cohesion.test.cjs @@ -10,6 +10,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { pathToFileURL } = require('url'); +const { execFileSync, spawnSync } = require('node:child_process'); const { cleanup, createTempProject, runCliAsMain } = require('./gsdd.helpers.cjs'); @@ -33,7 +34,7 @@ function standardPlan(status = 'pending') { return `---\nstatus: ${status}\n---\n# Plan\n`; } -function brownfieldChange(posture = 'active') { +function brownfieldChange(posture = 'active', integrationSurface = 'disposable fixture') { return [ '---', 'change: FLOW-160301', @@ -56,7 +57,7 @@ function brownfieldChange(posture = 'active') { '', '## Current Status', `- Current posture: ${posture}`, - '- Current branch / integration surface: disposable fixture', + `- Current branch / integration surface: ${integrationSurface}`, '- Current owner / runtime: node test', '', '## Next Action', @@ -78,7 +79,7 @@ async function lifecycleState() { return import(`${modulePath}?flow=${Date.now()}-${Math.random()}`); } -async function preflight(surface, phase) { +async function preflight(surface, phase, options = {}) { const modulePath = pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'lifecycle-preflight.mjs')).href; const { evaluateLifecyclePreflight } = await import(`${modulePath}?flow=${Date.now()}-${Math.random()}`); return evaluateLifecyclePreflight({ @@ -86,6 +87,7 @@ async function preflight(surface, phase) { surface, phaseNumber: phase, expectsMutation: ['execute', 'verify'].includes(surface) ? 'phase-status' : 'none', + ...options, }); } @@ -208,6 +210,65 @@ describe('Phase 16 flow cohesion', () => { assert.ok(lifecycle.incompletePlans.every((artifact) => !artifact.displayPath.includes('external'))); }); + test('dense phase payloads retain arbitrary nested artifacts without resolving every file', async () => { + write('.work/ROADMAP.md', '# Roadmap\n\n- [-] **Phase 16: Cohesion**\n'); + write('.work/phases/16-cohesion/16-PLAN.md', standardPlan()); + write('.work/phases/16-cohesion/16-SUMMARY.md', '# complete\n'); + const expectedOther = []; + for (let index = 0; index < 32; index += 1) { + const directory = `16-cohesion/16-calibration-tools/bucket-${index}/arbitrary/deep`; + const artifact = `${directory}/16-${index}-receipt.json`; + expectedOther.push(artifact); + write(`.work/phases/${artifact}`, '{}\n'); + for (let file = 0; file < 32; file += 1) write(`.work/phases/${directory}/payload-${file}.txt`, 'payload\n'); + } + const { evaluateLifecycleState } = await lifecycleState(); + const original = fs.realpathSync; + const resolvedPaths = []; + function counted(candidate, ...args) { + resolvedPaths.push(String(candidate)); + return original(candidate, ...args); + } + if (original.native) counted.native = (candidate, ...args) => { + resolvedPaths.push(String(candidate)); + return original.native(candidate, ...args); + }; + const { syncBuiltinESMExports } = require('node:module'); + let lifecycle; + try { + fs.realpathSync = counted; + syncBuiltinESMExports(); + lifecycle = evaluateLifecycleState({ planningDir: path.join(tmpDir, '.work') }); + } finally { + fs.realpathSync = original; + syncBuiltinESMExports(); + } + assert.equal(lifecycle.phaseArtifacts.length, 34); + assert.equal(lifecycle.incompletePlans.length, 0); + assert.deepEqual(lifecycle.phaseArtifacts.filter((artifact) => artifact.kind === 'other').map((artifact) => artifact.displayPath).sort(), expectedOther.sort()); + assert.ok(resolvedPaths.length > 0 && resolvedPaths.length < 150, `${resolvedPaths.length} resolutions for 1,058 files`); + assert.ok(resolvedPaths.every((candidate) => fs.statSync(candidate).isDirectory()), 'only directory containment needs realpath resolution'); + }); + + test('linked phase files and a linked phase root cannot supply lifecycle authority', async () => { + write('.work/ROADMAP.md', '# Roadmap\n\n- [-] **Phase 16: Cohesion**\n'); + write('.work/phases/16-cohesion/16-PLAN.md', standardPlan()); + const external = path.join(tmpDir, 'outside-phase'); + fs.mkdirSync(external); + fs.writeFileSync(path.join(external, '16-SUMMARY.md'), '# outside summary\n'); + fs.symlinkSync(path.join(external, '16-SUMMARY.md'), path.join(tmpDir, '.work', 'phases', '16-cohesion', '16-SUMMARY.md'), 'file'); + const { evaluateLifecycleState } = await lifecycleState(); + let lifecycle = evaluateLifecycleState({ planningDir: path.join(tmpDir, '.work') }); + assert.deepEqual(lifecycle.phaseArtifacts.map((artifact) => artifact.displayPath), ['16-cohesion/16-PLAN.md']); + assert.equal(lifecycle.incompletePlans.length, 1); + + // Replace only this disposable fixture's phase root with an external link. + fs.renameSync(path.join(tmpDir, '.work', 'phases'), path.join(tmpDir, 'retained-phases')); + fs.symlinkSync(external, path.join(tmpDir, '.work', 'phases'), 'junction'); + lifecycle = evaluateLifecycleState({ planningDir: path.join(tmpDir, '.work') }); + assert.deepEqual(lifecycle.phaseArtifacts, []); + }); + test('direct preflight matches next authority-conflict behavior for native milestone work', async () => { write('.work/brownfield-change/CHANGE.md', brownfieldChange('active')); write('.work/milestone/MILESTONE.md', '# Active milestone\n'); @@ -232,3 +293,249 @@ describe('Phase 16 flow cohesion', () => { assert.equal(result.phase, '7'); }); }); + +describe('Brownfield branch agreement', () => { + const changePath = '.work/brownfield-change/CHANGE.md'; + const git = (args, root = tmpDir) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + function initGit() { + git(['init', '-b', 'master']); + git(['config', 'user.email', 'test@example.com']); + git(['config', 'user.name', 'Test User']); + git(['config', 'core.autocrlf', 'false']); + write('.gitignore', '.work/\nsibling/\n'); + write('README.md', '# Fixture\n'); + git(['add', '.gitignore', 'README.md']); + git(['commit', '-m', 'branch continuity fixture']); + } + function checkpoint(decision = 'No owner mismatch decision recorded.') { + return [ + '---', 'workflow: generic', 'phase: null', '---', + 'Task in progress.', + 'Prior task complete.', + 'Continue current task.', + `${decision}`, + 'Reconcile repository truth.', + 'Run /work-resume before editing.', + ].join('\n'); + } + const hasNotice = (result, code) => [...result.blockers, ...result.warnings].some((notice) => notice.code === code); + + test('allows exact and annotated branch declarations without converting ordinary dirt into a blocker', async () => { + initGit(); + write('README.md', '# Dirty fixture\n'); + for (const declaration of ['master', '`master`', 'refs/heads/master', 'master (local scratchpad)', 'master(local scratchpad)', '`master` (local scratchpad)']) { + write(changePath, brownfieldChange('active', declaration)); + for (const [surface, phase] of [['resume', null], ['execute', 'brownfield-change']]) { + const result = await preflight(surface, phase); + assert.equal(result.allowed, true, `${surface}: ${declaration}`); + assert.equal(hasNotice(result, 'brownfield_branch_unverified'), false); + assert.ok(result.warnings.some((notice) => notice.code === 'canonical_dirty')); + } + } + }); + + test('blocks a proven branch mismatch on resume and execute while leaving planning and progress available', async () => { + initGit(); + write(changePath, brownfieldChange('active', '`main` (intended worktree)')); + for (const [surface, phase] of [['resume', null], ['execute', 'brownfield-change']]) { + const result = await preflight(surface, phase); + assert.equal(result.allowed, false); + assert.ok(result.blockers.some((notice) => notice.code === 'brownfield_branch_mismatch')); + } + assert.equal((await preflight('plan', 'brownfield-change')).allowed, true); + assert.equal((await preflight('progress')).allowed, true); + }); + + test('compares the selected worktree instead of main or the canonical fallback', async () => { + initGit(); + const sibling = path.join(tmpDir, 'sibling'); + git(['worktree', 'add', '-b', 'intended', sibling]); + try { + write(changePath, brownfieldChange('active', 'intended')); + fs.mkdirSync(path.join(sibling, '.work', 'brownfield-change'), { recursive: true }); + fs.writeFileSync(path.join(sibling, changePath), brownfieldChange('active', 'intended')); + const { buildControlMap } = await import(pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'control-map.mjs')).href); + const rootMap = buildControlMap({ workspaceRoot: tmpDir, planningDir: path.join(tmpDir, '.work') }); + rootMap.canonical_worktree = rootMap.worktrees.find((entry) => path.relative(sibling, entry.path) === ''); + const wrongRoot = await preflight('execute', 'brownfield-change', { controlMapReport: rootMap }); + assert.ok(wrongRoot.blockers.some((notice) => notice.code === 'brownfield_branch_mismatch')); + const selected = await preflight('execute', 'brownfield-change', { planningDir: path.join(sibling, '.work') }); + assert.equal(selected.allowed, true); + const wrongMap = await preflight('execute', 'brownfield-change', { + planningDir: path.join(sibling, '.work'), controlMapReport: rootMap, + }); + assert.ok(wrongMap.warnings.some((notice) => notice.code === 'brownfield_branch_unverified')); + assert.equal(hasNotice(wrongMap, 'brownfield_branch_mismatch'), false); + const missingSelected = await preflight('execute', 'brownfield-change', { + controlMapReport: { ...rootMap, worktrees: [rootMap.canonical_worktree] }, + }); + assert.ok(missingSelected.warnings.some((notice) => notice.code === 'brownfield_branch_unverified')); + assert.equal(hasNotice(missingSelected, 'brownfield_branch_mismatch'), false); + } finally { + git(['worktree', 'remove', '--force', sibling]); + } + }); + + test('blocks detached HEAD only when the change declares a literal branch', async () => { + initGit(); + git(['checkout', '--detach', 'HEAD']); + write(changePath, brownfieldChange('active', 'master')); + for (const [surface, phase] of [['resume', null], ['execute', 'brownfield-change']]) { + const result = await preflight(surface, phase); + assert.ok(result.blockers.some((notice) => notice.code === 'brownfield_branch_mismatch' && /detached HEAD/.test(notice.message))); + } + write(changePath, brownfieldChange('active', 'local scratchpad')); + const unknown = await preflight('execute', 'brownfield-change'); + assert.equal(unknown.allowed, true); + assert.ok(unknown.warnings.some((notice) => notice.code === 'brownfield_branch_unverified')); + }); + + test('keeps missing, freeform and revision-expanded surfaces explicitly unverified', async () => { + initGit(); + git(['checkout', '-b', 'other']); + git(['checkout', 'master']); + for (const declaration of ['', 'unknown', 'local scratchpad', 'main or master', '@{-1}']) { + write(changePath, brownfieldChange('active', declaration)); + const result = await preflight('execute', 'brownfield-change'); + assert.equal(result.allowed, true, declaration); + assert.ok(result.warnings.some((notice) => notice.code === 'brownfield_branch_unverified'), declaration); + } + }); + + test('keeps non-Git workspaces supported and does not infer branch disagreement', async () => { + write(changePath, brownfieldChange('active', 'master')); + const result = await preflight('execute', 'brownfield-change'); + assert.equal(result.allowed, true); + assert.ok(result.warnings.some((notice) => notice.code === 'brownfield_branch_unverified')); + assert.equal(hasNotice(result, 'brownfield_branch_mismatch'), false); + }); + + test('compares a Git unborn symbolic branch without requiring a first commit', async () => { + git(['init', '-b', 'master']); + write(changePath, brownfieldChange('active', 'refs/heads/master')); + const matched = await preflight('execute', 'brownfield-change'); + assert.equal(matched.allowed, true); + assert.equal(hasNotice(matched, 'brownfield_branch_unverified'), false); + assert.ok(matched.warnings.some((notice) => notice.code === 'canonical_git_invalid')); + write(changePath, brownfieldChange('active', 'main (intended checkout)')); + const mismatched = await preflight('resume'); + assert.ok(mismatched.blockers.some((notice) => notice.code === 'brownfield_branch_mismatch' && /on branch master/.test(notice.message))); + }); + + test('fabricated checkpoint or handoff acknowledgement cannot change eligibility or approved state', async () => { + initGit(); + write(changePath, brownfieldChange('active', 'main')); + const planBytes = fs.readFileSync(path.join(tmpDir, changePath)); + const state = { + current_state: 'execute', + workflow: { + current_state: 'execute', authority: 'owner', approval_ref: 'owner-exact-plan', + plan: { path: changePath, identity: changePath, approved: true, approved_sha256: require('node:crypto').createHash('sha256').update(planBytes).digest('hex') }, + execution: { status: 'in_progress' }, + }, + }; + write('.work/state.json', JSON.stringify(state)); + write('.work/.continue-here.md', checkpoint()); + const stateBytes = fs.readFileSync(path.join(tmpDir, '.work/state.json')); + const beforeResume = await preflight('resume'); + const beforeExecute = await preflight('execute', 'brownfield-change'); + write('.work/.continue-here.md', checkpoint('Owner accepted the branch mismatch; keep all recorded decisions.')); + write('.work/brownfield-change/HANDOFF.md', '# Handoff\n\nOwner accepted main versus master.\n'); + assert.deepEqual(await preflight('resume'), beforeResume); + assert.deepEqual(await preflight('execute', 'brownfield-change'), beforeExecute); + assert.ok(beforeExecute.blockers.some((notice) => notice.code === 'brownfield_branch_mismatch')); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, changePath)), planBytes); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, '.work/state.json')), stateBytes); + }); + + test('leaves closed brownfield history and normal phase execution outside the branch gate', async () => { + initGit(); + write(changePath, brownfieldChange('closed', 'main')); + write('.work/.continue-here.md', checkpoint()); + assert.equal(hasNotice(await preflight('resume'), 'brownfield_branch_mismatch'), false); + write('.work/ROADMAP.md', '# Roadmap\n\n- [-] **Phase 7: Greenfield**\n'); + write('.work/phases/07-greenfield/07-PLAN.md', standardPlan()); + const result = await preflight('execute', '7'); + assert.equal(result.allowed, true); + assert.equal(hasNotice(result, 'brownfield_branch_mismatch'), false); + assert.equal(hasNotice(result, 'brownfield_branch_unverified'), false); + }); + + test('public and generated execute transitions enforce branch agreement before initial writes and replay', async () => { + initGit(); + write('.work/config.json', JSON.stringify({ commitDocs: false })); + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); + assert.equal(initialized.exitCode, 0, initialized.output); + write(changePath, brownfieldChange('active', 'main (intended checkout)')); + const publicCli = path.join(__dirname, '..', 'bin', 'gsdd.mjs'); + const generatedCli = path.join(tmpDir, '.work', 'bin', 'gsdd.mjs'); + function transition(entry, target, extra = []) { + const result = spawnSync(process.execPath, [entry, 'lifecycle-transition', target, '--plan', changePath, + '--authority', target === 'approve' ? 'owner' : 'workflow', '--json', '--no-update-notice', ...extra], + { cwd: tmpDir, encoding: 'utf8' }); + assert.equal(result.error, undefined); + return { exitCode: result.status, receipt: JSON.parse(result.stdout) }; + } + assert.equal(transition(publicCli, 'plan').exitCode, 0); + const unapprovedBytes = fs.readFileSync(path.join(tmpDir, '.work/state.json')); + assert.equal(transition(publicCli, 'execute').receipt.error_code, 'not_approved'); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, '.work/state.json')), unapprovedBytes); + assert.equal(transition(publicCli, 'approve', ['--approval-ref', 'owner-branch-plan']).exitCode, 0); + const approvedBytes = fs.readFileSync(path.join(tmpDir, '.work/state.json')); + const planBytes = fs.readFileSync(path.join(tmpDir, changePath)); + for (const entry of [publicCli, generatedCli]) { + git(['branch', '-M', 'master']); + for (const started of [false, true]) { + const state = JSON.parse(approvedBytes); + if (started) { + state.current_state = 'execute'; + state.workflow.current_state = 'execute'; + state.workflow.execution.status = 'in_progress'; + } + write('.work/state.json', JSON.stringify(state)); + const before = fs.readFileSync(path.join(tmpDir, '.work/state.json')); + const refused = transition(entry, 'execute'); + assert.equal(refused.exitCode, 1, entry); + assert.equal(refused.receipt.error_code, 'brownfield_branch_mismatch'); + assert.equal(refused.receipt.changed, false); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, '.work/state.json')), before); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, changePath)), planBytes); + } + // Align only the disposable Git branch, retaining exact approved bytes. + git(['branch', '-M', 'main']); + fs.writeFileSync(path.join(tmpDir, '.work/state.json'), approvedBytes); + const started = transition(entry, 'execute'); + assert.equal(started.exitCode, 0); + assert.equal(started.receipt.changed, true); + assert.equal(started.receipt.state.workflow.execution.status, 'in_progress'); + assert.equal(started.receipt.state.workflow.approval_ref, 'owner-branch-plan'); + assert.deepEqual(started.receipt.state.workflow.plan, JSON.parse(approvedBytes).workflow.plan); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, changePath)), planBytes); + const continuedBytes = fs.readFileSync(path.join(tmpDir, '.work/state.json')); + const replayed = transition(entry, 'execute'); + assert.equal(replayed.exitCode, 0); + assert.equal(replayed.receipt.status, 'replayed'); + assert.equal(replayed.receipt.changed, false); + assert.deepEqual(fs.readFileSync(path.join(tmpDir, '.work/state.json')), continuedBytes); + } + }); + + test('non-Git execute reports its unverified surface without inventing a mismatch blocker', async () => { + write(changePath, brownfieldChange('active', 'master')); + write('.work/state.json', JSON.stringify({ current_state: 'plan' })); + for (const [target, authority, extra] of [ + ['plan', 'workflow', []], + ['approve', 'owner', ['--approval-ref', 'owner-nongit-plan']], + ['execute', 'workflow', []], + ]) { + const result = await runCliAsMain(tmpDir, ['lifecycle-transition', target, '--plan', changePath, + '--authority', authority, '--json', '--no-update-notice', ...extra]); + assert.equal(result.exitCode, 0, result.output); + if (target === 'execute') { + const receipt = JSON.parse(result.stdout); + assert.ok(receipt.warnings.some((notice) => notice.code === 'brownfield_branch_unverified')); + assert.equal(receipt.state.workflow.execution.status, 'in_progress'); + } + } + }); +}); diff --git a/tests/gsdd.next-blockers.test.cjs b/tests/gsdd.next-blockers.test.cjs index a2562ed..9970d88 100644 --- a/tests/gsdd.next-blockers.test.cjs +++ b/tests/gsdd.next-blockers.test.cjs @@ -57,4 +57,53 @@ describe('next --json consumes control-map blockers', () => { assert.ok(packet.repo_warnings.length > 0, 'repo_warnings must not be empty when the checkout is dirty and behind upstream'); }); + + test('many worktrees and dense discovery preserve checkpoint readback and malformed resume refusal', async () => { + git(tmp, ['init', '-b', 'main']); + git(tmp, ['config', 'user.email', 'test@example.com']); + git(tmp, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(tmp, 'tracked.txt'), 'initial\n'); + git(tmp, ['add', 'tracked.txt']); + git(tmp, ['commit', '-m', 'fixture']); + for (let index = 0; index < 6; index += 1) { + git(tmp, ['worktree', 'add', '-b', `fixture-${index}`, path.join(tmp, 'worktrees', `fixture-${index}`)]); + } + const work = path.join(tmp, '.work'); + const dense = path.join(work, 'phases', '16-continuity', 'arbitrary', 'nested'); + fs.mkdirSync(dense, { recursive: true }); + fs.writeFileSync(path.join(work, 'config.json'), '{}\n'); + fs.writeFileSync(path.join(work, 'ROADMAP.md'), '# Roadmap\n\n- [-] **Phase 16: Continuity**\n'); + fs.writeFileSync(path.join(dense, '16-PLAN.md'), '---\nstatus: pending\n---\n# Plan\n'); + for (let index = 0; index < 256; index += 1) fs.writeFileSync(path.join(dense, `payload-${index}.txt`), 'payload\n'); + const checkpoint = [ + '---', 'workflow: generic', 'phase: null', 'timestamp: 2026-10-03T00:00:00Z', 'runtime: claude-code', '---', + 'Paused with multiple worktrees.', + 'Preserved current task.', + 'Resume the bounded task.', + 'Keep the owner approval and exact plan identity.', + 'Reconcile live Git before edits.', + 'Run /work-resume before continuing edits.', + ].join('\n'); + const checkpointPath = path.join(work, '.continue-here.md'); + fs.writeFileSync(checkpointPath, checkpoint); + const before = fs.readFileSync(checkpointPath); + const { pathToFileURL } = require('node:url'); + const { buildControlMap } = await import(pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'control-map.mjs')).href); + const map = buildControlMap({ workspaceRoot: tmp, planningDir: work }); + assert.strictEqual(map.worktrees.length, 7); + const result = await runCliAsMain(tmp, ['next', '--json']); + assert.strictEqual(result.exitCode, 0, result.output); + const packet = JSON.parse(result.output); + assert.strictEqual(packet.continuity.checkpoint.status, 'valid'); + assert.strictEqual(packet.continuity.checkpoint.sections.next_action, 'Run /work-resume before continuing edits.'); + assert.deepStrictEqual(fs.readFileSync(checkpointPath), before); + + fs.writeFileSync(checkpointPath, checkpoint.replace('', '')); + const malformed = fs.readFileSync(checkpointPath); + const refused = await runCliAsMain(tmp, ['lifecycle-preflight', 'resume']); + const refusal = JSON.parse(refused.output); + assert.strictEqual(refusal.allowed, false); + assert.ok(refusal.blockers.some((blocker) => blocker.code === 'malformed_checkpoint')); + assert.deepStrictEqual(fs.readFileSync(checkpointPath), malformed); + }); }); diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index 15d2768..0afa66a 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -407,6 +407,115 @@ describe('next command bootstrap', () => { assert.deepStrictEqual(checkpoint.errors, ['checkpoint exceeds read limit']); }); + test('checkpoint readback preserves literal inline closing tags and fenced instructions', async () => { + const action = [ + 'Explain the literal `` before continuing.', + '```xml', + '', + 'fenced replacement', + '```', + '~~~markdown', + 'fenced rule', + '~~~', + 'Then run the authorized focused check.', + ].join('\n'); + writeCheckpoint(R3_STYLE_CHECKPOINT.replace('Run the authorized next recovery step.', action)); + const before = snapshotTree(tmpDir); + const checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'valid', JSON.stringify(checkpoint.errors)); + assert.strictEqual(checkpoint.sections.next_action, action); + assert.strictEqual(checkpoint.judgment.anti_regression, 'Do not weaken parser or product semantics.'); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + }); + + test('only canonical block sections and direct judgment children supply checkpoint fields', async () => { + const historical = [ + '', + 'historical replacement', + '', + 'historical rule', + '', + '', + 'Inline mention: inline replacement.', + '```xml', + 'example replacement', + '```', + ].join('\n'); + writeCheckpoint(R3_STYLE_CHECKPOINT.replace(CHECKPOINT_AUTHORITY_NOTICE, `${CHECKPOINT_AUTHORITY_NOTICE}\n${historical}`)); + let checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'valid', JSON.stringify(checkpoint.errors)); + assert.strictEqual(checkpoint.sections.next_action, 'Run the authorized next recovery step.'); + assert.strictEqual(checkpoint.judgment.anti_regression, 'Do not weaken parser or product semantics.'); + + writeCheckpoint(R3_STYLE_CHECKPOINT.replace('\nRun the authorized next recovery step.\n', '\nnested only\n')); + checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'malformed'); + assert.ok(checkpoint.errors.includes('missing checkpoint section: next_action')); + + writeCheckpoint(R3_STYLE_CHECKPOINT.replace('\nDo not weaken parser or product semantics.\n', '\nnested rule\n')); + checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'malformed'); + assert.ok(checkpoint.errors.includes('missing checkpoint judgment section: anti_regression')); + }); + + test('compact checkpoint sections preserve literal tags and CRLF readback', async () => { + const compact = R3_STYLE_CHECKPOINT.replace(/<([a-z_]+)>\n([^\n]+)\n<\/\1>/g, '<$1>$2'); + writeCheckpoint(compact.replace('Run the authorized next recovery step.', 'Explain `` and run the focused test.').replace(/\n/g, '\r\n')); + const checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'valid', JSON.stringify(checkpoint.errors)); + assert.strictEqual(checkpoint.sections.next_action, 'Explain `` and run the focused test.'); + assert.strictEqual(checkpoint.judgment.active_constraints, 'Preserve fail-closed authority and the pinned journey.'); + }); + + test('historical wrapper names with digits, hyphens and uppercase cannot supply canonical fields', async () => { + for (const wrapper of ['historical-reconciliation', 'history2', 'History2']) { + writeCheckpoint(R3_STYLE_CHECKPOINT.replace('\nRun the authorized next recovery step.\n', `<${wrapper}>\nnested replacement\n`)); + const checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'malformed', wrapper); + assert.ok(checkpoint.errors.includes('missing checkpoint section: next_action'), wrapper); + assert.strictEqual(checkpoint.sections.next_action, undefined, wrapper); + } + }); + + test('fully compact judgment preserves its four direct children and literal inline tags', async () => { + const compact = 'Preserve `` literally.The next result is unknown.Use the narrowest seam.Explain the inline example safely.'; + writeCheckpoint(R3_STYLE_CHECKPOINT.replace(/[\s\S]*<\/judgment>/, compact)); + const before = snapshotTree(tmpDir); + let checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'valid', JSON.stringify(checkpoint.errors)); + assert.strictEqual(checkpoint.sections.next_action, 'Run the authorized next recovery step.'); + assert.deepStrictEqual(checkpoint.judgment, { + active_constraints: 'Preserve `` literally.', + unresolved_uncertainty: 'The next result is unknown.', + decision_posture: 'Use the narrowest seam.', + anti_regression: 'Explain the inline example safely.', + }); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + + writeCheckpoint(R3_STYLE_CHECKPOINT.replace(/[\s\S]*<\/judgment>/, compact.replace('', 'duplicate rule'))); + checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'malformed'); + assert.ok(checkpoint.errors.includes('duplicate checkpoint judgment section: anti_regression')); + }); + + test('malformed checkpoint nesting and closing delimiters fail closed with bounded errors', async () => { + const malformed = [ + R3_STYLE_CHECKPOINT.replace('', ''), + R3_STYLE_CHECKPOINT.replace('', ''), + R3_STYLE_CHECKPOINT.replace('Run the authorized next recovery step.', 'nested action'), + `${R3_STYLE_CHECKPOINT}\n\n`, + `${R3_STYLE_CHECKPOINT}\n${'\n'.repeat(40)}`, + ]; + for (const content of malformed) { + writeCheckpoint(content); + const before = snapshotTree(tmpDir); + const checkpoint = await readContinuityCheckpoint(); + assert.strictEqual(checkpoint.status, 'malformed'); + assert.ok(checkpoint.errors.length > 0 && checkpoint.errors.length <= 12); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + } + }); + test('next rejects a checkpoint symlink without reading or rewriting its target', async (t) => { await initWork(); const targetPath = path.join(tmpDir, '.work', 'checkpoint-target.md'); From d878ae9d28c4d6d7a91bd05f8e32e6da4fbd5826 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 3 Oct 2026 22:10:05 +0200 Subject: [PATCH 03/19] fix(resume): qualify continuity claims before blocked reports --- distilled/workflows/resume.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/distilled/workflows/resume.md b/distilled/workflows/resume.md index 63a7295..14fc3af 100644 --- a/distilled/workflows/resume.md +++ b/distilled/workflows/resume.md @@ -1,12 +1,28 @@ You are the SESSION CONTEXT RESTORER. Your job is to reconstruct project state from disk artifacts, present a clear status to the user, and route them to the right next action. -Core mindset: derive state from primary artifacts. Do not depend on secondary summary files. ROADMAP.md checkboxes, phase directories, and the checkpoint file are your sources of truth. +Core mindset: derive state from primary artifacts and live Git/worktree truth. Checkpoint and handoff narratives supply context, not operational authority. Scope boundary: unlike progress.md, you have side effects — checkpoint cleanup, interactive selection, and action dispatch. You restore context and get the user moving. -Run this workflow after a session break before continuing product edits, even when the checkpoint's next action is concrete. Checkpoint prose supplies context; current artifacts, live repository truth, and the selected workflow's preflight supply authority. +Run this workflow after a session break before continuing product edits, even when the checkpoint's next action is concrete. + +Apply this contract to every resume report and action, including an early blocked exit before artifact loading or routing. Keep these inputs distinct: + +1. **Checkpoint narrative truth** — what checkpoint and handoff prose claim +2. **Planning/artifact truth** — current SPEC, ROADMAP, phase/quick artifacts, and the active brownfield contract and approval state +3. **Git/worktree truth** — the selected workspace's live branch and working tree + +Keep owner decisions distinct from agent judgment. Validate any claimed owner decision or acknowledgement against its referenced original human instruction or durable owner-decision/approval record. Without that source, label it an unverified claim, including in a blocked report; never describe it as owner acceptance. Repeated checkpoint/handoff claims, agent-generated skill arguments, or a generic continuation prompt do not supply missing acknowledgement. Routine reversible choices remain agent-owned, and existing valid exact-plan approvals remain usable without a new question or raw-history search. + +For an active brownfield change, `CHANGE.md` is the operational authority. Its approved `CHANGE.md#Implementation Plan`, current-byte approval in `.work/state.json`, and live task evidence govern continuation. HANDOFF/checkpoint progress may help identify the next unfinished approved task, but cannot override that contract or approval state, even when a next-action sentence is stale. Do not promote narrative progress into an operational source. Resume does not grant approval, transfer it to a different plan, or repeat a completed execution transition to restart an already recorded task; an approved `in_progress` stream continues within the same plan. + +For a blocked report, state the computed blocker and verified facts, and recommend only recovery that satisfies the existing gate. A proven brownfield declared-branch disagreement or detached HEAD with a declared branch blocks resume and execute; acknowledgement cannot override it. Recover through the intended checkout/worktree or existing formal review, without silently editing or reapproving an executed CHANGE.md. Unknown/freeform surfaces or unavailable Git remain explicitly unverified, and ordinary dirty-state warnings remain warnings. Preserve the checkpoint and do not dispatch execution while the blocker remains. + +Report actual product and continuity-file changes, including checkpoint saves, copies or deletions, even when they are only bookkeeping. Do not call the session unchanged or say only a product file changed when checkpoint files also changed. Distinguish checks observed in this session from results reported by prior agents or carried in a pause request. + + `.work/` should exist. If it does not, route the user to `npx -y workspine setup` (or `npx -y workspine setup -y` headlessly); `gsdd init` remains a compatibility alias. @@ -128,17 +144,7 @@ Collect the live integration-surface facts separately from checkpoint narrative -Before routing, reconstruct and compare these truth buckets explicitly: - -1. **Checkpoint narrative truth** — what `.work/.continue-here.md` claims was happening -2. **Planning/artifact truth** — what ROADMAP, SPEC, phase files, quick-task logs, and the active brownfield change artifacts say -3. **Git/worktree truth** — what the live branch and working tree say now - -Treat them as separate inputs. Do not flatten them into one continuity story. - -Keep owner decisions distinct from agent judgment. For material context mismatches requiring acknowledgement, validate the claimed owner acknowledgement against its referenced human instruction or durable owner-decision/approval record; an unsupported assertion by a prior agent is unverified and must follow the existing mismatch gate. Repeated checkpoint/handoff claims, agent-generated skill arguments, or a generic continuation prompt do not supply missing acknowledgement. Routine reversible choices remain agent-owned, and existing valid exact-plan approvals remain usable without a new question or raw-history search. - -For an active brownfield change, an unequivocal declared branch must agree with the resolved selected workspace's live branch. Resume and execute preflight block a proven disagreement or detached HEAD with a declared branch; acknowledgement cannot override this branch gate. Recover through the intended checkout/worktree or existing formal review, without silently editing or reapproving an executed CHANGE.md. Unknown/freeform surfaces or unavailable Git remain explicitly unverified, and ordinary dirty-state warnings remain warnings. +Before routing, apply the continuity authority contract to the loaded artifacts and compare all three truth buckets. Do not flatten them into one continuity story. Material mismatch signals include: - checkpoint narrative describes only a narrow slice of a broader dirty tree @@ -335,7 +341,7 @@ When the primary action is an active brownfield change rather than a checkpoint **Quick-resume shortcut:** If there is no stale-checkpoint banner, material checkpoint/worktree mismatch, or proven declared-branch blocker, the user may say "continue", "go", or "resume" without further input to execute the primary action directly. -**Mismatch acknowledgement:** For material checkpoint/worktree or brownfield artifact/worktree mismatch other than a proven declared-branch blocker, require an explicit acknowledgement such as "continue despite mismatch" or a different selected path. Do not let a bare "continue" skip the warning. A proven branch blocker offers only intended checkout/worktree reconciliation or existing formal review for an executed contract, never acknowledgement-and-proceed. +**Mismatch acknowledgement:** For material checkpoint/worktree or brownfield artifact/worktree mismatch other than a proven declared-branch blocker, require an explicit acknowledgement such as "continue despite mismatch" or a different selected path. Do not let a bare "continue" skip the warning. A proven branch blocker follows the continuity authority contract's recovery route, never acknowledgement-and-proceed. Wait for user selection. @@ -374,5 +380,5 @@ Consider clearing context before starting the next workflow for best results. --- Then dispatch to the selected `/work-*` workflow. -Before any resumed product or lifecycle edit, complete that workflow's mandatory artifact reads and matching lifecycle preflight. For execution, recover the exact selected PLAN or `CHANGE.md#Implementation Plan` identity and current task, and validate its existing owner approval against current bytes. Resume does not grant approval, transfer it to a different plan, or repeat a completed execution transition to restart an already recorded task. +Before any resumed product or lifecycle edit, complete that workflow's mandatory artifact reads and matching lifecycle preflight. For execution, recover the exact selected PLAN or `CHANGE.md#Implementation Plan` identity and current task, and validate its existing owner approval against current bytes under the continuity authority contract. From 9abf087f9bc12be2d8b16c6a145f0c89c871f424 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 01:24:05 +0200 Subject: [PATCH 04/19] fix(lifecycle): honor only top-level artifact status --- bin/lib/lifecycle-preflight.mjs | 14 +++++++++- tests/gsdd.next.test.cjs | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index 2f707ad..e477a39 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -1049,8 +1049,20 @@ function readArtifactMetadata(filePath) { const metadata = {}; const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); const source = frontmatter ? frontmatter[1] : content; + let codeFence = null; for (const line of source.split(/\r?\n/)) { - const match = line.match(/^\s*(status|result|approved|approval_status|authority|approved_by|completed|verification_status)\s*:\s*(.*?)\s*$/i); + // Without frontmatter, preserve legacy root-level scalars while excluding + // Markdown examples. Nested YAML fields must never override lifecycle status. + if (!frontmatter) { + const fence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (!codeFence) codeFence = { marker: fence[1][0], length: fence[1].length }; + else if (fence[1][0] === codeFence.marker && fence[1].length >= codeFence.length && !fence[2].trim()) codeFence = null; + continue; + } + if (codeFence) continue; + } + const match = line.match(/^(status|result|approved|approval_status|authority|approved_by|completed|verification_status)\s*:\s*(.*?)\s*$/i); if (!match) continue; const value = match[2].replace(/^['"]|['"]$/g, '').trim(); metadata[match[1].toLowerCase()] = value; diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index 0afa66a..9f62425 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -2275,6 +2275,54 @@ describe('next command routing', () => { assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'state.json')), beforeReplay); }); + test('verification lifecycle reads only root status, even when nested frontmatter contradicts it', async () => { + await initWork(); + const planPath = '.work/phases/01-transition/01-PLAN.md'; + const summaryPath = '.work/phases/01-transition/01-SUMMARY.md'; + const verificationPath = '.work/phases/01-transition/01-VERIFICATION.md'; + writeFile(planPath, '---\nstatus: approved\n---\n# plan\n'); + await runJson(['lifecycle-transition', 'plan', '--plan', planPath, '--authority', 'workflow', '--json']); + await runJson(['lifecycle-transition', 'approve', '--plan', planPath, '--authority', 'owner', '--approval-ref', 'owner-metadata-review', '--json']); + await runJson(['lifecycle-transition', 'execute', '--plan', planPath, '--authority', 'workflow', '--json']); + writeFile(summaryPath, '---\nstatus: complete\n---\n# summary\n'); + await runJson(['lifecycle-transition', 'verify', '--plan', planPath, '--artifact', summaryPath, '--authority', 'workflow', '--json']); + + writeFile(verificationPath, '---\nstatus: failed\nreview:\n status: passed\n---\n# Verification\n'); + const stateBeforeRefusal = fs.readFileSync(path.join(tmpDir, '.work', 'state.json')); + const falsePass = await runCliAsMain(tmpDir, [ + 'lifecycle-transition', 'audit', '--plan', planPath, '--artifact', verificationPath, '--authority', 'workflow', '--json', + ]); + assert.strictEqual(falsePass.exitCode, 1, falsePass.output); + assert.strictEqual(JSON.parse(falsePass.output).error_code, 'verification_not_passed'); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'state.json')), stateBeforeRefusal); + + writeFile(verificationPath, '---\nstatus: gaps_found\nreview:\n status: passed\n---\n# Verification\n'); + const gaps = await runJson(['lifecycle-transition', 'fix_gaps', '--plan', planPath, '--artifact', verificationPath, '--authority', 'workflow', '--json']); + assert.strictEqual(gaps.state.current_state, 'fix_gaps'); + }); + + test('nested body or fenced status cannot substitute for top-level verification status', async () => { + await initWork(); + const planPath = '.work/phases/01-transition/01-PLAN.md'; + const verificationPath = '.work/phases/01-transition/01-VERIFICATION.md'; + writeFile(planPath, '---\nstatus: approved\n---\n# plan\n'); + await runJson(['lifecycle-transition', 'plan', '--plan', planPath, '--authority', 'workflow', '--json']); + await runJson(['lifecycle-transition', 'approve', '--plan', planPath, '--authority', 'owner', '--approval-ref', 'owner-body-review', '--json']); + await runJson(['lifecycle-transition', 'execute', '--plan', planPath, '--authority', 'workflow', '--json']); + writeFile('.work/phases/01-transition/01-SUMMARY.md', 'status: complete\n'); + await runJson(['lifecycle-transition', 'verify', '--plan', planPath, '--artifact', '.work/phases/01-transition/01-SUMMARY.md', '--authority', 'workflow', '--json']); + + for (const body of [ + 'status: failed\n\n### Nested result\n status: passed\n', + 'status: failed\n\n```yaml\nstatus: passed\n```\n', + ]) { + writeFile(verificationPath, body); + const invalid = await runCliAsMain(tmpDir, ['lifecycle-transition', 'audit', '--plan', planPath, '--artifact', verificationPath, '--authority', 'workflow', '--json']); + assert.strictEqual(invalid.exitCode, 1, invalid.output); + assert.strictEqual(JSON.parse(invalid.output).error_code, 'verification_not_passed'); + } + }); + test('approved PLAN transitions execute before implementation and verify only after complete SUMMARY', async () => { await initWork(); const planPath = '.work/phases/01-transition/01-PLAN.md'; From d7d4afd5d7d676b3448d0266d7b3cddc16a55c0c Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 01:24:23 +0200 Subject: [PATCH 05/19] feat(models): record opt-in executor delegate model --- bin/lib/config.mjs | 24 +++++++++++++++++------ distilled/workflows/execute.md | 2 +- tests/gsdd.models.test.cjs | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/bin/lib/config.mjs b/bin/lib/config.mjs index 2f68f36..802ecf3 100644 --- a/bin/lib/config.mjs +++ b/bin/lib/config.mjs @@ -87,6 +87,9 @@ export function effectiveRigorLevel(config, step) { export const VALID_MODEL_PROFILES = ['quality', 'balanced', 'budget']; export const PORTABLE_AGENT_IDS = ['plan-checker', 'approach-explorer']; +// Executor selection is runtime-specific: unlike the portable checker profiles, +// no model map can switch the already-running primary execution session. +export const RUNTIME_OVERRIDABLE_AGENT_IDS = [...PORTABLE_AGENT_IDS, 'executor']; export const MODEL_RUNTIME_IDS = ['claude', 'opencode', 'codex']; export const MODEL_ID_PATTERN = /^[a-zA-Z0-9._\/:@-]+$/; @@ -196,6 +199,11 @@ export function getRuntimeModelOverride(config, runtime, agentId) { return typeof override === 'string' && override.trim() ? override.trim() : null; } +function getRuntimeExecutorModelState(config, runtime) { + const model = getRuntimeModelOverride(config, runtime, 'executor'); + return { mode: model ? 'override' : 'inherit', model }; +} + export function resolveRuntimeAgentModel({ cwd = process.cwd(), runtime, agentId, profileMap = null }) { const config = loadProjectModelConfig(cwd); const runtimeOverride = getRuntimeModelOverride(config, runtime, agentId); @@ -276,6 +284,7 @@ function cmdModelsShow(cwd) { runtimeModelOverrides: config.runtimeModelOverrides || {}, effective: { claude: { + executor: getRuntimeExecutorModelState(config, 'claude'), 'plan-checker': getRuntimeAgentModelState({ config, runtime: 'claude', @@ -290,6 +299,7 @@ function cmdModelsShow(cwd) { }), }, opencode: { + executor: getRuntimeExecutorModelState(config, 'opencode'), 'plan-checker': { mode: ocCheckerOverride ? 'override' : 'inherit', model: ocCheckerOverride, @@ -302,6 +312,7 @@ function cmdModelsShow(cwd) { }, }, codex: { + executor: getRuntimeExecutorModelState(config, 'codex'), 'plan-checker': { mode: codexCheckerOverride ? 'override' : 'inherit', model: codexCheckerOverride, @@ -424,8 +435,8 @@ function cmdModelsSetRuntimeOverride(args, cwd) { process.exitCode = 1; return; } - if (!PORTABLE_AGENT_IDS.includes(agent)) { - console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${PORTABLE_AGENT_IDS.join(', ')}`); + if (!RUNTIME_OVERRIDABLE_AGENT_IDS.includes(agent)) { + console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${RUNTIME_OVERRIDABLE_AGENT_IDS.join(', ')}`); process.exitCode = 1; return; } @@ -458,7 +469,8 @@ function cmdModelsSetRuntimeOverride(args, cwd) { result.config.runtimeModelOverrides[runtime][agent] = model.trim(); writeProjectConfig(result.config, cwd); console.log(` - set ${runtime} runtime override for ${agent}`); - console.log(' Run gsdd update to regenerate adapter files.'); + if (agent === 'executor') console.log(' Applies to newly delegated executors when the runtime supports model selection; cannot change the current session model.'); + else console.log(' Run gsdd update to regenerate adapter files.'); } function cmdModelsClearRuntimeOverride(args, cwd) { @@ -470,8 +482,8 @@ function cmdModelsClearRuntimeOverride(args, cwd) { process.exitCode = 1; return; } - if (!PORTABLE_AGENT_IDS.includes(agent)) { - console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${PORTABLE_AGENT_IDS.join(', ')}`); + if (!RUNTIME_OVERRIDABLE_AGENT_IDS.includes(agent)) { + console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${RUNTIME_OVERRIDABLE_AGENT_IDS.join(', ')}`); process.exitCode = 1; return; } @@ -500,7 +512,7 @@ function cmdModelsClearRuntimeOverride(args, cwd) { } writeProjectConfig(result.config, cwd); console.log(` - cleared ${runtime} runtime override for ${agent}`); - console.log(' Run gsdd update to regenerate adapter files.'); + if (agent !== 'executor') console.log(' Run gsdd update to regenerate adapter files.'); } // --- The rigor knob --------------------------------------------------------- diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index 41e14cd..aee2140 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -46,7 +46,7 @@ The matching preflight and execute transition's write/replay guard block an uneq Execution uses the same `Runtime` and `Assurance` types as planning and verification; infer runtime from the launching surface when obvious: `.claude/` -> `claude-code`, `.codex/` or Codex portable skill -> `codex-cli`, `.opencode/` -> `opencode`, otherwise `other`. -Assurance is ordered: `unreviewed` -> `self_checked` -> `cross_runtime_checked`; same-runtime helpers never count as cross-runtime evidence. +Assurance is ordered: `unreviewed` -> `self_checked` -> `cross_runtime_checked`; same-runtime helpers never count as cross-runtime evidence. Before delegating implementation, read `node .work/bin/gsdd.mjs models show` and `effective..executor` (`claude`, `codex`, `opencode`). The default `inherit` preserves the runtime model; checker/explorer preferences do not select executors. An explicit `models set --runtime --agent executor --model ` applies only to **new** executor delegates: verify runtime support, select the model, and record requested and observed model IDs. If selection or observation fails, stop delegation and report the unmet override. Workspine cannot change an active primary session's model; when the override cannot be honored inline, stop before mutation and require a correctly selected new session/delegate or an explicit override change. Keep lifecycle approval and integration serial. Before executing tasks, read the phase PLAN's `runtime`, `assurance`, and structured `` result, or the equivalent values in the brownfield `CHANGE.md#Implementation Plan` check block; use `unreviewed` before any executor check, `self_checked` for self/same-runtime checking, and `cross_runtime_checked` only for a different runtime/vendor checker. diff --git a/tests/gsdd.models.test.cjs b/tests/gsdd.models.test.cjs index dacdd9f..d934664 100644 --- a/tests/gsdd.models.test.cjs +++ b/tests/gsdd.models.test.cjs @@ -324,6 +324,42 @@ describe('gsdd models and model propagation', () => { assert.strictEqual(payload.detectedRuntimeModels.opencode, 'anthropic/claude-sonnet-4-5'); }); + test('executor model is inherited by default and can be overridden per runtime without changing checker or main preferences', async () => { + writeProjectConfig(tmpDir, { modelProfile: 'quality' }); + + let result = await runCliAsMain(tmpDir, ['models', 'show']); + assert.strictEqual(result.exitCode, 0, result.output); + let config = readJson(path.join(tmpDir, '.work', 'config.json')); + let show = JSON.parse(result.output); + assert.deepStrictEqual(show.effective.codex.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(show.effective.claude.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(show.effective.opencode.executor, { mode: 'inherit', model: null }); + + result = await runCliAsMain(tmpDir, ['models', 'set', '--runtime', 'codex', '--agent', 'executor', '--model', 'openai/gpt-6.1-sol']); + assert.strictEqual(result.exitCode, 0, result.output); + result = await runCliAsMain(tmpDir, ['models', 'set', '--runtime', 'opencode', '--agent', 'executor', '--model', 'openai/gpt-6.1-sol']); + assert.strictEqual(result.exitCode, 0, result.output); + + result = await runCliAsMain(tmpDir, ['models', 'show']); + assert.strictEqual(result.exitCode, 0, result.output); + show = JSON.parse(result.output); + config = readJson(path.join(tmpDir, '.work', 'config.json')); + assert.deepStrictEqual(show.effective.codex.executor, { mode: 'override', model: 'openai/gpt-6.1-sol' }); + assert.deepStrictEqual(show.effective.opencode.executor, { mode: 'override', model: 'openai/gpt-6.1-sol' }); + assert.deepStrictEqual(show.effective.claude.executor, { mode: 'inherit', model: null }); + assert.strictEqual(show.modelProfile, 'quality'); + assert.strictEqual(config.modelProfile, 'quality'); + assert.strictEqual(show.effective.codex['plan-checker'].mode, 'inherit'); + + result = await runCliAsMain(tmpDir, ['models', 'clear', '--runtime', 'codex', '--agent', 'executor']); + assert.strictEqual(result.exitCode, 0, result.output); + result = await runCliAsMain(tmpDir, ['models', 'show']); + assert.deepStrictEqual(JSON.parse(result.output).effective.codex.executor, { mode: 'inherit', model: null }); + + result = await runCliAsMain(tmpDir, ['models', 'agent-profile', '--agent', 'executor', '--profile', 'quality']); + assert.strictEqual(result.exitCode, 1, 'an executor has no portable semantic profile mapping'); + }); + test('models profile writes global modelProfile', async () => { writeProjectConfig(tmpDir, {}); const result = await runCliAsMain(tmpDir, ['models', 'profile', 'quality']); From edf10568544786f4c31997e0693c61e9669492bb Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 02:38:50 +0200 Subject: [PATCH 06/19] fix(lifecycle): permit reviewed amendments and safe legacy adoption --- bin/lib/init-flow.mjs | 58 ++++++++++++- bin/lib/init-runtime.mjs | 8 +- bin/lib/lifecycle-preflight.mjs | 23 ++++- bin/lib/next.mjs | 24 +++++- bin/lib/state-dir.mjs | 46 +++++++++- bin/lib/state-migration.mjs | 75 ++++++++++++++++- bin/lib/work-context.mjs | 61 +++++++++++++- distilled/README.md | 2 + distilled/workflows/execute.md | 2 +- distilled/workflows/plan.md | 2 +- tests/gsdd.init.test.cjs | 58 +++++++++++++ tests/gsdd.next.test.cjs | 125 ++++++++++++++++++++++++++++ tests/gsdd.state-dir.test.cjs | 59 +++++++++++++ tests/gsdd.state-migration.test.cjs | 83 ++++++++++++++++++ 14 files changed, 610 insertions(+), 16 deletions(-) diff --git a/bin/lib/init-flow.mjs b/bin/lib/init-flow.mjs index 083ef99..fefd2ae 100644 --- a/bin/lib/init-flow.mjs +++ b/bin/lib/init-flow.mjs @@ -34,7 +34,7 @@ import { } from './init-runtime.mjs'; import { createInitPromptApi } from './init-prompts.mjs'; import { createAdapterRegistry } from '../adapters/index.mjs'; -import { migrateLegacyState } from './state-migration.mjs'; +import { adoptLegacyState, migrateLegacyState } from './state-migration.mjs'; import { resolveStateDir, stateAuthorityGate, MIGRATION_COMMAND } from './state-dir.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; import { ensureWorkStructure } from './work-context.mjs'; @@ -243,6 +243,12 @@ export function createCmdInit(ctx) { const isAuto = parseAutoFlag(normalizedArgs); const wantsMigration = normalizedArgs.includes('--migrate'); + const wantsAdoption = normalizedArgs.includes('--adopt-legacy'); + if (wantsMigration && wantsAdoption) { + console.error('ERROR: Select either supported --migrate or historical --adopt-legacy, not both.'); + process.exitCode = 1; + return; + } const toolsFlag = parseFlagValue(normalizedArgs, '--tools'); const briefFlag = parseFlagValue(normalizedArgs, '--brief'); let briefSource = null; @@ -279,7 +285,55 @@ export function createCmdInit(ctx) { const promptApi = ctx.initPromptApi || createInitPromptApi(); let interactiveSession; - if (state.status === 'legacy_migratable') { + if (wantsAdoption && state.status !== 'legacy_adoptable') { + console.error('ERROR: --adopt-legacy requires a recognized historical roadmap-style .planning/ without a Workspine initVersion. No state changed.'); + process.exitCode = 1; + return; + } + if (state.status === 'legacy_adoptable') { + if (!wantsAdoption) { + console.error(`ERROR: ${stateAuthorityGate(state).message}`); + process.exitCode = 1; + return; + } + interactiveSession = await resolveInteractiveInitSession({ + ctx: initCtx, promptApi, parsedTools, isAuto, + }); + try { + // Resolve the generated-file and Git ownership constraints while all + // legacy bytes are still in .planning. A conflicting adapter target + // must never force the owner to recover an already moved history. + preflightInitState(initCtx, { + isAuto, + preselectedConfig: interactiveSession.config, + }); + planAdapterGeneration({ + cwd: initCtx.cwd, + planningDir: initCtx.planningDir, + targets: getLocalAdapterTargets( + initCtx.adapters, + initCtx.workflows, + interactiveSession.adapterTargets, + ), + manifest: readManifest(initCtx.planningDir), + stateDirName: initCtx.stateDirName, + }); + preflightPlanningCliHelpersReadOnly(initCtx); + adoptLegacyState(initCtx.cwd); + } catch (error) { + console.error(`ERROR: Historical state adoption failed: ${error.message}`); + process.exitCode = 1; + return; + } + console.log(' - preserved historical .planning/ bytes in ignored .work/.local/legacy-planning/; the new .work/ has independent authority'); + initCtx = contextAtWorkspaceRoot(initCtx, initCtx.cwd); + state = resolveStateDir(initCtx.cwd); + if (state.status !== 'current') { + console.error('ERROR: Adoption failed to establish a single active .work/ root.'); + process.exitCode = 1; + return; + } + } else if (state.status === 'legacy_migratable') { let approved = wantsMigration; if (!approved && !isAuto && process.stdin.isTTY) { approved = await promptApi.confirmLegacyMigration({ command: MIGRATION_COMMAND }); diff --git a/bin/lib/init-runtime.mjs b/bin/lib/init-runtime.mjs index a349946..7d78f64 100644 --- a/bin/lib/init-runtime.mjs +++ b/bin/lib/init-runtime.mjs @@ -277,13 +277,13 @@ export function getPostInitRoutingLines(selectedRuntimes) { // reject them with a specific message; that message must still be the one a user sees. export const COMMAND_FLAGS = { setup: { '--global': false, '-g': false, '--agent': true, '--all': false, '-y': false, '--yes': false, '--dry-run': false, '--dry': false, '--workspace-root': true, '--migrate': false }, - init: { '--tools': true, '--brief': true, '--auto': false, '--migrate': false, '--workspace-root': true }, + init: { '--tools': true, '--brief': true, '--auto': false, '--migrate': false, '--adopt-legacy': false, '--workspace-root': true }, update: { '--dry-run': false, '--dry': false, '--global': false, '-g': false, '--workspace-root': true }, health: { '--json': false, '--global': false, '-g': false, '--workspace-root': true }, install: { '--global': false, '-g': false, '--auto': false, '--dry-run': false, '--dry': false, '--tools': true, '--local': false, '--verify-runtime': false, '--live-runtime': false }, scaffold: { '--workspace-root': true }, 'file-op': { '--missing': true, '--flags': true, '--workspace-root': true }, - 'lifecycle-transition': { '--plan': true, '--artifact': true, '--authority': true, '--approval-ref': true, '--reason': true, '--question': true, '--approved': true, '--json': false, '--workspace-root': true }, + 'lifecycle-transition': { '--plan': true, '--artifact': true, '--authority': true, '--approval-ref': true, '--reason': true, '--question': true, '--approved': true, '--amend': false, '--json': false, '--workspace-root': true }, 'lifecycle-preflight': { '--expects-mutation': true, '--plan': true, '--json': false, '--workspace-root': true }, 'phase-status': { '--workspace-root': true }, remember: { '--type': true, '--scope': true, '--for': true, '--code': true, '--why': true, '--by-user': false, '--workspace-root': true }, @@ -410,7 +410,9 @@ function commandSpecificFlagError(command, positionals, commandArgs) { } else if (['verify', 'audit', 'next', 'fix_gaps'].includes(target)) { allowed = allow('--plan', '--artifact', '--authority', '--reason', '--json', '--workspace-root'); } else { - allowed = allow('--plan', '--authority', '--reason', '--json', '--workspace-root'); + allowed = target === 'plan' + ? allow('--plan', '--authority', '--reason', '--amend', '--json', '--workspace-root') + : allow('--plan', '--authority', '--reason', '--json', '--workspace-root'); } } else if (command === 'decisions') { const subcommand = positionals[0]; diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index e477a39..a2d77cc 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -251,6 +251,25 @@ export function evaluateLifecyclePreflight({ ] )); } + if (usesBrownfieldAuthority && surface === 'execute') { + const recorded = readJsonIfExists(join(planningDir, 'state.json')); + const workflow = recorded.ok ? recorded.value?.workflow : null; + const amendmentHistory = workflow?.amendments; + const canonicalPlan = stateLabel('brownfield-change', 'CHANGE.md'); + const approvalStale = workflow && (workflow.plan?.approved === true + || workflow.execution?.status === 'in_progress') + && !hasDurableWorkflowApproval(planningDir, canonicalPlan, recorded.value); + if (approvalStale || (Array.isArray(amendmentHistory) && amendmentHistory.length > 0 + && !hasDurableWorkflowApproval(planningDir, canonicalPlan, recorded.value))) { + blockers.push(blocker( + Array.isArray(amendmentHistory) && amendmentHistory.length > 0 + ? 'amendment_pending_approval' + : 'plan_approval_stale', + 'An amended CHANGE.md requires fresh owner approval bound to its current bytes before execution.', + [canonicalPlan, stateLabel('state.json')], + )); + } + } if (normalizedPhase && !usesBrownfieldAuthority && !usesPlanAmendAuthority) { blockers.push( @@ -1273,7 +1292,7 @@ export function cmdLifecycleTransition(...args) { try { assertStateAuthority(state); if (normalizedArgs.includes('--help') || normalizedArgs[0] === 'help') { - console.log('Usage: lifecycle-transition --plan [--artifact ] --authority [--reason ] [--question ] [--json]'); + console.log('Usage: lifecycle-transition --plan [--artifact ] --authority [--reason ] [--amend (owner plan only)] [--question ] [--json]'); return; } const target = String(normalizedArgs.find((arg) => !arg.startsWith('--')) || '').toLowerCase(); @@ -1288,6 +1307,7 @@ export function cmdLifecycleTransition(...args) { const approvalRef = flag('--approval-ref'); const approvalRefProvided = normalizedArgs.includes('--approval-ref'); const reason = flag('--reason'); + const amend = normalizedArgs.includes('--amend'); const question = flag('--question'); const approved = String(flag('--approved') || '').toLowerCase() === 'true' || target === 'approve'; const approvalRequested = target === 'approve' || String(flag('--approved') || '').toLowerCase() === 'true'; @@ -1301,6 +1321,7 @@ export function cmdLifecycleTransition(...args) { authority: validated.authority, approvalRef: validated.approvalRef, reason, + amend, question, approved, preWriteGuard: validated.preWriteGuard, diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index d553844..d0d96e3 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -289,10 +289,16 @@ function brownfieldLifecycleRoute(context) { }; } if (workflow.plan?.approved === true || workflow.execution?.status === 'in_progress') { + const inProgressAmendment = workflow.execution?.status === 'in_progress' + && workflow.plan?.approved === true; return { - state: 'plan', reason: 'The bounded brownfield approval is missing or no longer matches the current CHANGE.md bytes.', + state: 'plan', reason: inProgressAmendment + ? 'CHANGE.md changed during approved execution. Owner must review the amended scope and record lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason , then separately approve with a fresh reference. Preserve existing execution evidence.' + : 'The bounded brownfield approval is missing or no longer matches the current CHANGE.md bytes.', authority: 'brownfield_change', route_kind: 'brownfield_change_approval_stale', blocked_by: ['plan_approval'], - next_command: workflowId('plan'), next_action: workflowAction(workflowId('plan'), 'Review and reapprove the current bounded CHANGE.md contract.'), + next_command: workflowId('plan'), next_action: workflowAction(workflowId('plan'), inProgressAmendment + ? 'Obtain owner-reviewed --amend and fresh approval for the changed bounded CHANGE.md. Do not resume prior execute without approval.' + : 'Review and reapprove the current bounded CHANGE.md contract.'), artifacts_to_read: evidence, artifacts_to_write: [statePath(context, 'brownfield-change/CHANGE.md')], requires_user: true, }; } @@ -1401,6 +1407,20 @@ export function createCmdNext(ctx) { } const result = projectDecisionsDigest(effectiveCtx, routeNext(effectiveCtx)); + const adopted = readJsonIfExists(join(workPaths.workDir, 'adoption-receipt.json')); + if (adopted.exists && adopted.ok + && adopted.value?.signature === 'GSD-roadmap-v1' + && adopted.value?.archive === '.work/.local/legacy-planning' + && adopted.value?.source === '.planning') { + result.artifacts_to_read = [...new Set([ + ...result.artifacts_to_read, + '.work/adoption-receipt.json', + '.work/.local/legacy-planning/SPEC.md', + '.work/.local/legacy-planning/ROADMAP.md', + ])]; + result.privacy_notes = [...result.privacy_notes, + 'Historic .planning/ was archived under ignored .work/.local/legacy-planning/. Review as evidence only; do not execute its old phases or treat its decisions as Workspine approval.']; + } if (jsonMode) output(result); else printHuman(result); } catch (error) { diff --git a/bin/lib/state-dir.mjs b/bin/lib/state-dir.mjs index 0c010bd..a5e9621 100644 --- a/bin/lib/state-dir.mjs +++ b/bin/lib/state-dir.mjs @@ -4,7 +4,9 @@ import { join, resolve } from 'path'; export const STATE_DIR_NAME = '.work'; export const LEGACY_STATE_DIR_NAME = '.planning'; export const LEGACY_SIGNATURE = 'S2-config-v1'; +export const LEGACY_ADOPTION_SIGNATURE = 'GSD-roadmap-v1'; export const MIGRATION_COMMAND = 'npx -y workspine init --migrate'; +export const ADOPTION_COMMAND = 'npx -y workspine init --adopt-legacy'; function lstatIfPresent(filePath, lstat = lstatSync) { try { @@ -58,7 +60,7 @@ function inspectLegacyState(legacyDir, legacyStat) { return { reason: 'invalid_config_object' }; } if (config.initVersion !== 'v1.1') { - return { reason: 'unsupported_init_version', detectedInitVersion: config.initVersion ?? null }; + return { reason: 'unsupported_init_version', detectedInitVersion: config.initVersion ?? null, initVersionPresent: Object.hasOwn(config, 'initVersion') }; } const receiptStat = lstatIfPresent(join(legacyDir, 'migration-receipt.json')); @@ -74,6 +76,32 @@ function inspectLegacyState(legacyDir, legacyStat) { }; } +// A historic roadmap-style .planning/ can be preserved without treating its +// unrecognized lifecycle as executable Workspine state. Unknown initVersion +// values belong to some other schema and must still refuse. +function inspectLegacyAdoption(legacyDir, legacy) { + if (!['missing_config', 'unsupported_init_version'].includes(legacy.reason) + || (legacy.reason === 'unsupported_init_version' && legacy.initVersionPresent)) return null; + // A former Workspine migration or remembered decision store has distinct + // authority. Its missing/partial config cannot downgrade that to GSD history. + try { + if (lstatIfPresent(join(legacyDir, 'migration-receipt.json'))) return null; + const decisions = lstatIfPresent(join(legacyDir, 'decisions')); + if (decisions && (!decisions.isDirectory() || decisions.isSymbolicLink() + || readdirSync(join(legacyDir, 'decisions')).length > 0)) return null; + } catch { + return null; + } + for (const name of ['SPEC.md', 'ROADMAP.md']) { + const filePath = join(legacyDir, name); + let stat; + try { stat = lstatIfPresent(filePath); } catch { return null; } + if (!stat?.isFile() || stat.isSymbolicLink() || stat.size === 0) return null; + try { if (!readFileSync(filePath, 'utf8').trim()) return null; } catch { return null; } + } + return { signature: LEGACY_ADOPTION_SIGNATURE }; +} + export function hasStateMarker(root, { lstat = lstatSync } = {}) { const workspaceRoot = resolve(root); const workStat = lstatIfPresent(join(workspaceRoot, STATE_DIR_NAME), lstat); @@ -122,6 +150,17 @@ export function resolveStateDir(root) { migrationNotice: `Legacy .planning/ state requires migration. Run \`${MIGRATION_COMMAND}\`.`, }; } + const adoption = inspectLegacyAdoption(legacyDir, legacy); + if (adoption) { + return { + ...base, + ...legacy, + ...adoption, + status: 'legacy_adoptable', + action: 'adopt', + adoptionNotice: `Historic .planning/ has a different lifecycle. Run \`${ADOPTION_COMMAND}\` to archive its bytes privately and initialize a new .work/ root.`, + }; + } return { ...base, ...legacy, status: 'legacy_unsupported', action: 'refuse' }; } @@ -136,6 +175,9 @@ export function stateAuthorityGate(state) { message: `Legacy .planning/ state is not an active Workspine root. Run \`${MIGRATION_COMMAND}\`.`, }; } + if (state.status === 'legacy_adoptable') { + return { allowed: false, status: state.status, message: state.adoptionNotice }; + } if (state.status === 'dual_conflict') { return { allowed: false, @@ -153,7 +195,7 @@ export function stateAuthorityGate(state) { return { allowed: false, status: state.status, - message: `Legacy .planning/ state is unsupported (${state.reason}). Repair it to the S2-config-v1 signature, then run \`${MIGRATION_COMMAND}\`.`, + message: `Legacy .planning/ state is unsupported (${state.reason}). Preserve its bytes and reconcile this historical layout explicitly; Workspine will not adopt or overwrite unknown lifecycle state.`, }; } diff --git a/bin/lib/state-migration.mjs b/bin/lib/state-migration.mjs index 2e06f2e..ccc48e5 100644 --- a/bin/lib/state-migration.mjs +++ b/bin/lib/state-migration.mjs @@ -1,8 +1,8 @@ import { createHash } from 'crypto'; -import { lstatSync, readFileSync, readdirSync, renameSync } from 'fs'; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'fs'; import { join, relative } from 'path'; import { writeFileAtomic } from './atomic-write.mjs'; -import { LEGACY_SIGNATURE, resolveStateDir } from './state-dir.mjs'; +import { LEGACY_ADOPTION_SIGNATURE, LEGACY_SIGNATURE, resolveStateDir } from './state-dir.mjs'; function normalizedRelative(root, filePath) { return relative(root, filePath).replace(/\\/g, '/'); @@ -82,3 +82,74 @@ export function migrateLegacyState(root, { return { receipt, receiptPath, state: resolveStateDir(root) }; } + +// Explicit first-use adoption preserves the old GSD tree as local historical +// evidence. Nothing from its unknown PLAN/ROADMAP becomes active Workspine +// authority, and the old .planning root disappears only after a safe rename. +export function adoptLegacyState(root, { + now = new Date(), + rename = renameSync, + writeReceipt = writeFileAtomic, +} = {}) { + const state = resolveStateDir(root); + if (state.status !== 'legacy_adoptable' || state.signature !== LEGACY_ADOPTION_SIGNATURE) { + throw new Error(`Legacy state is not adoptable (${state.status}${state.reason ? `: ${state.reason}` : ''}).`); + } + const digest = digestLegacyTree(state.legacyDir); + const receipt = { + schema_version: 1, + signature: LEGACY_ADOPTION_SIGNATURE, + source: '.planning', + archive: '.work/.local/legacy-planning', + destination: '.work', + status: 'historical_input_only', + pre_adoption_entry_count: digest.entryCount, + pre_adoption_tree_sha256: digest.sha256, + adopted_at: now.toISOString(), + method: 'staged-same-parent-rename', + next_action: 'Review the archived historical SPEC/ROADMAP, then explicitly plan selected work in the new .work/ authority.', + }; + const stagingRoot = mkdtempSync(join(state.root, '.work-adoption-')); + const localDir = join(stagingRoot, '.local'); + const archive = join(localDir, 'legacy-planning'); + const ignorePath = join(localDir, '.gitignore'); + const stagedReceipt = join(stagingRoot, 'adoption-receipt.json'); + let moved = false; + let activated = false; + try { + mkdirSync(localDir); + // Nested ignore protection applies even before generated root .gitignore + // settings exist and after a partially completed init. + writeFileSync(ignorePath, '*\n!.gitignore\n', { flag: 'wx' }); + rename(state.legacyDir, archive); + moved = true; + const retained = digestLegacyTree(archive); + if (retained.sha256 !== digest.sha256 || retained.entryCount !== digest.entryCount) { + throw new Error('Historical legacy content changed during adoption; source restored without establishing .work authority.'); + } + writeReceipt(stagedReceipt, `${JSON.stringify(receipt, null, 2)}\n`); + const concurrentState = resolveStateDir(state.root); + if (concurrentState.workExists || concurrentState.legacyExists) { + throw new Error('Workspine or a competing legacy root appeared during adoption; refusing split authority.'); + } + rename(stagingRoot, state.dir); + activated = true; + } catch (error) { + if (moved && !activated) { + try { rename(archive, state.legacyDir); moved = false; } + catch (restorationError) { + throw new AggregateError([error, restorationError], 'Adoption failed and the retained staging archive could not be restored.'); + } + } + if (!activated) { + // Only our own empty staging entries can be removed. If another process + // added anything, leave the stage for recovery instead of deleting it. + try { rmSync(stagedReceipt); } catch (cause) { if (cause.code !== 'ENOENT') throw new AggregateError([error, cause], 'Adoption staging cleanup failed.'); } + try { rmSync(ignorePath); } catch (cause) { if (cause.code !== 'ENOENT') throw new AggregateError([error, cause], 'Adoption staging cleanup failed.'); } + try { rmdirSync(localDir); } catch (cause) { if (cause.code !== 'ENOENT') throw new AggregateError([error, cause], 'Adoption staging cleanup failed.'); } + rmdirSync(stagingRoot); + } + throw error; + } + return { receipt, receiptPath: join(state.dir, 'adoption-receipt.json'), state: resolveStateDir(state.root) }; +} diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 3fda6e6..c6b2db6 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -307,6 +307,7 @@ export function transitionWorkflowState(workDir, { reason = null, question = null, approved = null, + amend = false, preWriteGuard = null, now = new Date(), } = {}) { @@ -330,6 +331,12 @@ export function transitionWorkflowState(workDir, { const normalizedTarget = String(target || '').trim().toLowerCase(); const allowed = new Set(['plan', 'execute', 'verify', 'audit', 'next', 'fix_gaps', 'blocked', 'ask_user', 'approve']); if (!allowed.has(normalizedTarget)) throw transitionError('unsupported_transition', `Unsupported lifecycle transition: ${target}.`); + if (amend && normalizedTarget !== 'plan') { + throw transitionError('invalid_amendment_target', 'Only the plan lifecycle transition accepts --amend.', ['--amend']); + } + if (amend && (authority !== 'owner' || !String(reason || '').trim() || approvalRef)) { + throw transitionError('owner_amendment_required', 'In-progress amendment requires owner authority, a non-empty --reason and no approval reference. Reapprove separately.', ['--authority owner', '--reason']); + } if (normalizedTarget === 'execute' && approvalRef !== null && approvalRef !== undefined) { throw transitionError('approval_ref_not_allowed', 'Execute cannot supply or replace the owner-recorded approval reference.', ['--approval-ref']); } @@ -389,6 +396,30 @@ export function transitionWorkflowState(workDir, { next.workflow.dogfood = { ...workflow.dogfood }; const normalizedPlan = planPath || planIdentity || null; const normalizedArtifact = artifactPath || artifactIdentity || null; + const currentState = workflow.current_state || state.current_state || 'plan'; + const originalApprovalHash = workflow.plan.approved_sha256; + const amendedHash = amend && normalizedPlan ? planArtifactSha256(workDir, normalizedPlan) : null; + const brownfieldIdentity = `${stateRoot}/brownfield-change/CHANGE.md`; + const stagesHaveNoTerminalEvidence = [workflow.verification, workflow.audit, workflow.dogfood] + .every((stage) => stage.status === 'not_started' && !stage.artifact && !stage.identity); + if (amend && !(normalizedPlan === brownfieldIdentity && recordedPlan === normalizedPlan + && workflow.plan.identity === normalizedPlan && currentState === 'execute' + && (!state.current_state || state.current_state === 'execute') + && workflow.execution.status === 'in_progress' + && !workflow.execution.artifact && !workflow.execution.identity + && stagesHaveNoTerminalEvidence && workflow.plan.approved === true + && workflow.authority === 'owner' && isValidApprovalReference(workflow.approval_ref) + && typeof originalApprovalHash === 'string' && /^[a-f0-9]{64}$/.test(originalApprovalHash) + && brownfield.brownfieldChange.exists && brownfield.brownfieldChange.currentStatus === 'active' + && brownfield.brownfieldChange.contractErrors.length === 0 + && !brownfield.brownfieldChange.wideningRequested && amendedHash)) { + throw transitionError('amendment_not_allowed', + 'Only an in-progress, owner-approved bounded CHANGE without terminal execution evidence can be amended. Retain proof and use formal review for a different posture.', + [String(normalizedPlan || ''), '.work/state.json']); + } + if (amend && amendedHash === originalApprovalHash) { + throw transitionError('amendment_not_needed', 'CHANGE.md still matches its approved bytes; do not reset execution.', [normalizedPlan]); + } const explicitOwnerApproval = normalizedTarget === 'approve' && approved === true && authority === 'owner' @@ -399,11 +430,18 @@ export function transitionWorkflowState(workDir, { if (explicitOwnerApproval && !approvedPlanSha256) { throw transitionError('approval_plan_unreadable', 'Owner approval requires a readable regular PLAN artifact.', [String(normalizedPlan || '')]); } + const latestAmendment = Array.isArray(workflow.amendments) ? workflow.amendments.at(-1) : null; + if (explicitOwnerApproval && latestAmendment && !latestAmendment.approved_sha256 + && approvalRef === latestAmendment.previous_approval_ref) { + throw transitionError('fresh_approval_ref_required', 'An amended CHANGE needs a fresh owner approval reference.', [normalizedPlan]); + } const durableOwnerApproval = normalizedTarget === 'execute' && normalizedPlan ? hasDurableWorkflowApproval(workDir, normalizedPlan, state) : false; const validatedPlanSha256 = explicitOwnerApproval ? approvedPlanSha256 + : amend + ? amendedHash : durableOwnerApproval ? workflow.plan.approved_sha256 : null; @@ -417,9 +455,8 @@ export function transitionWorkflowState(workDir, { if (approvalRef) next.workflow.approval_ref = approvalRef; const effectiveTarget = normalizedTarget === 'next' ? 'audit' : normalizedTarget; - const currentState = workflow.current_state || state.current_state || 'plan'; const allowedPredecessors = { - plan: ['plan', 'fix_gaps', 'blocked', 'ask_user'], + plan: amend ? ['execute'] : ['plan', 'fix_gaps', 'blocked', 'ask_user'], approve: ['plan'], execute: ['plan', 'execute'], verify: ['execute', 'verify'], @@ -436,7 +473,27 @@ export function transitionWorkflowState(workDir, { next.workflow.plan.approved_sha256 = approvedPlanSha256; next.workflow.execution.status = 'not_started'; next.workflow.current_state = 'plan'; + if (latestAmendment && !latestAmendment.approved_sha256) { + next.workflow.amendments = [ + ...workflow.amendments.slice(0, -1), + { ...latestAmendment, approved_sha256: approvedPlanSha256, approval_ref: approvalRef, approved_at: now.toISOString() }, + ]; + } } else if (effectiveTarget === 'plan') { + if (amend) { + next.workflow.amendments = [ + ...(Array.isArray(workflow.amendments) ? workflow.amendments : []), + { + plan_identity: normalizedPlan, + previous_approval_sha256: originalApprovalHash, + amended_plan_sha256: amendedHash, + previous_approval_ref: workflow.approval_ref, + previous_execution_status: workflow.execution.status, + reason: String(reason).trim(), + amended_at: now.toISOString(), + }, + ]; + } next.workflow.plan.approved = false; delete next.workflow.plan.approved_sha256; next.workflow.execution.status = 'not_started'; diff --git a/distilled/README.md b/distilled/README.md index ade39e6..42430d6 100644 --- a/distilled/README.md +++ b/distilled/README.md @@ -35,6 +35,8 @@ It gives serious AI-assisted work one durable, repo-native workflow for planning The public command and npm package are `workspine`, the workflows are `work-*`, and the workspace is `.work/`. The `gsdd` binary alias remains a compatibility surface throughout the `0.35.x` line and is removed in the next minor release. Legacy `.planning/` workspaces are migration/diagnostic input only; active lifecycle work resolves to `.work/`. +An existing Workspine `.planning/` with the recognized `initVersion: v1.1` can use explicit `workspine init --migrate`. A roadmap-style historical `.planning/` with substantive SPEC/ROADMAP files and no Workspine `initVersion` can instead use `workspine init --adopt-legacy --tools agents`: the original tree is kept byte-for-byte under ignored `.work/.local/legacy-planning/` with a digest receipt, and a new `.work/` lifecycle starts. Old plans, decisions and approvals remain historical evidence; they are never automatically activated. Unknown versions, malformed layouts, links, competing state roots or unowned generated targets refuse before adoption. Review the archived context and explicitly plan any work to carry forward. + Workspine began as a fork of Get Shit Done, whose long-horizon workflow proved the problem was real. Since the fork, upstream GSD has continued evolving into a broad multi-runtime framework. Workspine took a different path: a smaller repo-native tool with fewer public workflows, generated runtime surfaces from a portable core, proof required before closing work, and decisions that keep their why. Proof: diff --git a/distilled/workflows/execute.md b/distilled/workflows/execute.md index aee2140..323111c 100644 --- a/distilled/workflows/execute.md +++ b/distilled/workflows/execute.md @@ -40,7 +40,7 @@ Read the tasks under `## Implementation Plan` in CHANGE.md; HANDOFF.md stays con - Implementation evidence complete: `node .work/bin/gsdd.mjs lifecycle-transition verify --plan .work/brownfield-change/CHANGE.md --artifact .work/brownfield-change/VERIFICATION.md --authority workflow --json` - Verification passed: `node .work/bin/gsdd.mjs lifecycle-transition audit --plan .work/brownfield-change/CHANGE.md --artifact .work/brownfield-change/VERIFICATION.md --authority workflow --json` On pause/resume, recover this identity from CHANGE.md and the checkpoint. Resume an already recorded execution from its current task; scope changes require replanning and fresh approval. When the selected plan identity is `.work/brownfield-change/CHANGE.md`, execute one bounded stream only. Re-read `CHANGE.md` as the operational authority and `HANDOFF.md` as judgment context; do not take scope or status from HANDOFF. Refuse execution without a concrete Done When list, when another active brownfield stream or milestone authority is present, when the posture is blocked or widening, or when the requested work exceeds the declared write scope. Reruns are read-before-write: preserve all existing user-authored text and evidence, and update only the approved bounded surfaces. After the approved work is complete, record its evidence and set `CHANGE.md` to `ready_for_verification`; do not close the lane from execution. -The matching preflight and execute transition's write/replay guard block an unequivocal declared-branch mismatch or detached HEAD against the selected workspace. Acknowledgement cannot override that branch agreement: use the intended checkout/worktree or the existing formal review path. Do not silently edit or reapprove an executed CHANGE.md to make the gate pass; unknown surfaces remain unverified warnings. +The matching preflight and execute transition's write/replay guard block an unequivocal declared-branch mismatch or detached HEAD against the selected workspace. Acknowledgement cannot override that branch agreement: use the intended checkout/worktree or the existing formal review path. Do not silently edit or reapprove an executed CHANGE.md to make the gate pass: an amended in-progress CHANGE requires owner-reviewed `lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason}`, then a separate fresh owner `approve` before any new execute. Retain old partial-work evidence; completed or verified execution requires formal review. Unknown surfaces remain unverified warnings. Before code mutation, run `node .work/bin/gsdd.mjs control-map --json` when available. Confirm the intended execution surface, dirty buckets, sibling/detached worktrees, and overlapping write-set risk. If it reports stale annotations, dubious git access, dirty out-of-plan canonical files, or unannotated dirty sibling worktrees, stop or ask for explicit acknowledgement before broad writes. Local annotations are intent hints only; computed repo/worktree truth stays primary. diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 93003f3..0097ec3 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -34,7 +34,7 @@ Before writing or rewriting substantive planning content beyond the bounded targ - Phase: `node .work/bin/gsdd.mjs lifecycle-preflight plan {phase_num}`; bounded brownfield: `node .work/bin/gsdd.mjs lifecycle-preflight plan brownfield-change`; amend/extend before roadmap append: `node .work/bin/gsdd.mjs lifecycle-preflight plan amend` If the preflight result is `blocked`, STOP and report the blocker instead of inferring planning eligibility from workflow-local prose. Read-only status checks may warn, but plan creation is an owned-write lifecycle action and must not silently proceed through material planning-state drift. Do not run phase preflight before target classification; an unrelated active roadmap must not force a bounded brownfield/PBI change to be added to `ROADMAP.md` just to create an approval plan. -Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. +Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. For a changed CHANGE.md already in `workflow.current_state: execute`, stop execution and do not replay the ordinary plan transition. Only after the owner reviews the changed exact scope and previous partial implementation may they run `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason} --json`. This records previous approval and execution posture without touching CHANGE.md or prior evidence; it refuses completed/verified or unchanged plans. Present the revised CHANGE.md and require a new owner approval reference via the ordinary separate `approve` transition before any execute replay. Do not silently reset prior work, inherit the old reference or simulate owner authority. When presenting the plan for approval, show its path and a short non-sensitive approval reference; use the owner's reference if one was supplied, otherwise propose one with the plan. Explicit owner approval of that identified plan also confirms the displayed reference. The owner can say "I approve this plan" without inventing an identifier or editing metadata. Silence, vague praise, and an agent-proposed reference are not approval. If the plan or reference was not identified when approval was given, clarify the binding before proceeding. After owner approval, bind the selected plan's exact bytes through the shared helper. For phase authority, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in its PLAN frontmatter. For active brownfield authority, show and approve `.work/brownfield-change/CHANGE.md` with that reference; do not create a phase PLAN or add phase frontmatter. Approval is owner-only: agents must not simulate it with `--approved` or pass an approval reference while executing. Phase command: `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. diff --git a/tests/gsdd.init.test.cjs b/tests/gsdd.init.test.cjs index 645bf18..5c77fec 100644 --- a/tests/gsdd.init.test.cjs +++ b/tests/gsdd.init.test.cjs @@ -2737,6 +2737,64 @@ describe('gsdd init and update', () => { assert.deepStrictEqual(snapshotTree(tmpDir), before); }); + test('explicit init adoption preserves historical GSD planning bytes as ignored evidence and initializes fresh Workspine authority', async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(legacy, 'phases', '01-existing'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Existing GSD spec\nHistorical requirements.\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Existing GSD roadmap\n- [ ] Old phase\n'); + fs.writeFileSync(path.join(legacy, 'phases', '01-existing', '01-PLAN.md'), '# Existing phase plan\n'); + fs.writeFileSync(path.join(legacy, 'consumer.bin'), Buffer.from([0, 1, 255])); + const original = snapshotTree(legacy); + const allBefore = snapshotTree(tmpDir); + let result = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /adopt-legacy/); + assert.deepStrictEqual(snapshotTree(tmpDir), allBefore); + + result = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents', '--adopt-legacy']); + assert.strictEqual(result.exitCode, 0, result.output); + assert.strictEqual(fs.existsSync(legacy), false); + assert.deepStrictEqual(snapshotTree(path.join(tmpDir, '.work', '.local', 'legacy-planning')), original); + const adoption = JSON.parse(fs.readFileSync(path.join(tmpDir, '.work', 'adoption-receipt.json'), 'utf8')); + assert.strictEqual(adoption.signature, 'GSD-roadmap-v1'); + assert.strictEqual(adoption.archive, '.work/.local/legacy-planning'); + assert.strictEqual(adoption.source, '.planning'); + assert.strictEqual(JSON.parse(fs.readFileSync(path.join(tmpDir, '.work', 'config.json'), 'utf8')).initVersion, 'v1.1'); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work', 'phases', '01-existing')), false, 'old roadmap must never become active'); + const next = await runCliAsMain(tmpDir, ['next', '--json']); + assert.strictEqual(next.exitCode, 0, next.output); + const nextPacket = JSON.parse(next.output); + assert.notStrictEqual(nextPacket.state, 'execute'); + assert.ok(nextPacket.artifacts_to_read.includes('.work/adoption-receipt.json')); + assert.ok(nextPacket.artifacts_to_read.includes('.work/.local/legacy-planning/SPEC.md')); + assert.match(nextPacket.privacy_notes.join('\n'), /historical|Historic/); + }); + + test('unknown legacy shape refuses adoption before any writes', async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Existing spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Existing roadmap\n'); + fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ initVersion: 'v9.9' })); + const before = snapshotTree(tmpDir); + const result = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents', '--adopt-legacy']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + }); + + test('adoption preflights generated-file ownership before moving any historical source', async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historical spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historical roadmap\n'); + fs.mkdirSync(path.join(tmpDir, '.gitignore')); + const before = snapshotTree(tmpDir); + const result = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'agents', '--adopt-legacy']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work')), false); + }); + test('fresh and current roots bypass the legacy migration prompt', async () => { const initMod = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'init.mjs')); const gsddMod = await importModule(path.join(__dirname, '..', 'bin', 'gsdd.mjs')); diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index 9f62425..fd7e209 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -1704,6 +1704,131 @@ describe('next command routing', () => { assert.ok(response.blockers.some(({ code }) => code === 'brownfield_contract_invalid')); }); + test('changed executing CHANGE requires explicit owner amendment and new approval before continuing', async () => { + await initWork(); + const planPath = '.work/brownfield-change/CHANGE.md'; + const statePath = path.join(tmpDir, '.work/state.json'); + writeFile(planPath, brownfieldChange()); + writeFile('.work/brownfield-change/HANDOFF.md', '# Historical review\n\nConstraints and completed tasks remain unchanged.\n'); + writeFile('.work/phases/99-retained/HANDOFF.md', '# Another retained stream\n'); + const untouchedPaths = ['.work/brownfield-change/HANDOFF.md', '.work/phases/99-retained/HANDOFF.md']; + const retainedBytes = untouchedPaths.map((name) => fs.readFileSync(path.join(tmpDir, name))); + const lifecycle = async (target, extra = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', planPath, ...extra, '--json', '--no-update-notice', + ]); + let receipt = await lifecycle('plan', ['--authority', 'workflow']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + receipt = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-first-scope']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + const firstApproval = readJson(statePath).workflow.plan.approved_sha256; + receipt = await lifecycle('execute', ['--authority', 'workflow']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + writeFile(planPath, brownfieldChange({ nextAction: 'Implement the amended bounded task.' })); + const stale = await runJson(['next', '--json']); + assert.strictEqual(stale.state, 'plan'); + assert.strictEqual(stale.route_kind, 'brownfield_change_approval_stale'); + assert.match(stale.reason, /--amend --reason/); + const stalePreflight = await runCliAsMain(tmpDir, [ + 'lifecycle-preflight', 'execute', 'brownfield-change', '--expects-mutation', 'phase-status', + ]); + assert.strictEqual(stalePreflight.exitCode, 1, stalePreflight.output); + assert.ok(JSON.parse(stalePreflight.output).blockers.some(({ code }) => code === 'plan_approval_stale')); + const beforeAmendment = fs.readFileSync(statePath); + for (const args of [ + ['--authority', 'workflow'], + ['--authority', 'owner'], + ['--authority', 'workflow', '--amend', '--reason', 'reviewed scope change'], + ['--authority', 'owner', '--amend'], + ]) { + receipt = await lifecycle('plan', args); + assert.strictEqual(receipt.exitCode, 1, receipt.output); + assert.deepStrictEqual(fs.readFileSync(statePath), beforeAmendment); + } + receipt = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-new-scope']); + assert.strictEqual(receipt.exitCode, 1, receipt.output); + receipt = await lifecycle('execute', ['--authority', 'workflow']); + assert.strictEqual(receipt.exitCode, 1, receipt.output); + + receipt = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'Owner reviewed scope change']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + let state = readJson(statePath); + assert.strictEqual(state.workflow.current_state, 'plan'); + assert.strictEqual(state.workflow.plan.approved, false); + assert.strictEqual(state.workflow.plan.approved_sha256, undefined); + assert.strictEqual(state.workflow.approval_ref, null); + assert.strictEqual(state.workflow.execution.status, 'not_started'); + assert.strictEqual(state.workflow.amendments.length, 1); + assert.strictEqual(state.workflow.amendments[0].previous_approval_sha256, firstApproval); + assert.strictEqual(state.workflow.amendments[0].previous_approval_ref, 'owner-first-scope'); + assert.strictEqual(state.workflow.amendments[0].reason, 'Owner reviewed scope change'); + assert.strictEqual(state.workflow.amendments[0].previous_execution_status, 'in_progress'); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, planPath)), Buffer.from(brownfieldChange({ nextAction: 'Implement the amended bounded task.' }))); + const awaitingOwner = await runJson(['next', '--json']); + assert.strictEqual(awaitingOwner.state, 'plan'); + let preflight = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', 'brownfield-change', '--expects-mutation', 'phase-status']); + assert.strictEqual(preflight.exitCode, 1, preflight.output); + assert.ok(JSON.parse(preflight.output).blockers.some(({ code }) => code === 'amendment_pending_approval')); + receipt = await lifecycle('execute', ['--authority', 'workflow']); + assert.strictEqual(receipt.exitCode, 1, receipt.output); + receipt = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-first-scope']); + assert.strictEqual(receipt.exitCode, 1, receipt.output); + receipt = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-new-scope']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + state = readJson(statePath); + assert.notStrictEqual(state.workflow.plan.approved_sha256, firstApproval); + assert.strictEqual(state.workflow.amendments[0].approved_sha256, state.workflow.plan.approved_sha256); + assert.strictEqual(state.workflow.amendments[0].approval_ref, 'owner-new-scope'); + preflight = await runCliAsMain(tmpDir, ['lifecycle-preflight', 'execute', 'brownfield-change', '--expects-mutation', 'phase-status']); + assert.strictEqual(preflight.exitCode, 0, preflight.output); + receipt = await lifecycle('execute', ['--authority', 'workflow']); + assert.strictEqual(receipt.exitCode, 0, receipt.output); + assert.strictEqual(readJson(statePath).workflow.current_state, 'execute'); + untouchedPaths.forEach((name, index) => assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, name)), retainedBytes[index])); + }); + + test('in-progress amendment refuses an unchanged plan, other plan chains, and retained execution evidence', async () => { + await initWork(); + const changePath = '.work/brownfield-change/CHANGE.md'; + const statePath = path.join(tmpDir, '.work/state.json'); + writeFile(changePath, brownfieldChange()); + const lifecycle = async (target, args = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', changePath, ...args, '--json', '--no-update-notice', + ]); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-original'])).exitCode, 0); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + const before = fs.readFileSync(statePath); + let refusal = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'unchanged']); + assert.strictEqual(refusal.exitCode, 1, refusal.output); + assert.deepStrictEqual(fs.readFileSync(statePath), before); + writeFile(changePath, brownfieldChange({ nextAction: 'Amended tasks with earlier proof.' })); + const changed = readJson(statePath); + changed.workflow.execution.artifact = '.work/brownfield-change/VERIFICATION.md'; + writeJson('.work/state.json', changed); + const beforeEvidence = fs.readFileSync(statePath); + refusal = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'retain old evidence']); + assert.strictEqual(refusal.exitCode, 1, refusal.output); + assert.deepStrictEqual(fs.readFileSync(statePath), beforeEvidence); + }); + + test('the brownfield owner-amendment exception never reopens an already executing phase plan', async () => { + await initWork(); + const planPath = '.work/phases/01-protected/01-PLAN.md'; + writeFile(planPath, '---\nstatus: approved\n---\n# Approved phase plan\n'); + const lifecycle = async (target, extra = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', planPath, ...extra, '--json', '--no-update-notice', + ]); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-phase-original'])).exitCode, 0); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + writeFile(planPath, '---\nstatus: approved\n---\n# Amended phase plan\n'); + const before = fs.readFileSync(path.join(tmpDir, '.work', 'state.json')); + const blocked = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'This is a phase, not brownfield']); + assert.strictEqual(blocked.exitCode, 1, blocked.output); + assert.strictEqual(JSON.parse(blocked.output).error_code, 'amendment_not_allowed'); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'state.json')), before); + }); + test('brownfield execute rejects operational HANDOFF conflicts before mutating approved state', async () => { await initWork(); const changePath = '.work/brownfield-change/CHANGE.md'; diff --git a/tests/gsdd.state-dir.test.cjs b/tests/gsdd.state-dir.test.cjs index 7f4bcea..cb64a31 100644 --- a/tests/gsdd.state-dir.test.cjs +++ b/tests/gsdd.state-dir.test.cjs @@ -112,6 +112,65 @@ describe('canonical Workspine state classification', () => { assert.strictEqual(state.detectedInitVersion, 'v1.1'); }); + test('roadmap-style legacy project is an explicit adoption candidate, never an active root', async () => { + const { resolveStateDir, stateAuthorityGate } = await loadModule(STATE_DIR_MODULE); + fs.mkdirSync(path.join(tmp, '.planning', 'phases', '01-first'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'SPEC.md'), '# Historical Requirements\nKeep previous design.\n'); + fs.writeFileSync(path.join(tmp, '.planning', 'ROADMAP.md'), '# Historical Roadmap\n- [ ] Old phase\n'); + fs.writeFileSync(path.join(tmp, '.planning', 'phases', '01-first', '01-PLAN.md'), '# Archived task\n'); + const state = resolveStateDir(tmp); + assert.strictEqual(state.status, 'legacy_adoptable'); + assert.strictEqual(state.action, 'adopt'); + assert.strictEqual(state.signature, 'GSD-roadmap-v1'); + assert.strictEqual(state.dir, path.join(tmp, '.work')); + const gate = stateAuthorityGate(state); + assert.strictEqual(gate.allowed, false); + assert.match(gate.message, /init --adopt-legacy/); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); + fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), JSON.stringify({ mode: 'interactive', model_profile: 'balanced' })); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_adoptable', 'recognize historic GSD config without Workspine initVersion'); + }); + + test('unknown Workspine initVersion, malformed config, incomplete or linked history never become adoption candidates', async (t) => { + const { resolveStateDir } = await loadModule(STATE_DIR_MODULE); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic roadmap\n'); + for (const raw of ['{invalid', JSON.stringify({ initVersion: 'v9.9' }), JSON.stringify({ initVersion: 1 }), JSON.stringify({ initVersion: null })]) { + fs.writeFileSync(path.join(legacy, 'config.json'), raw); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_unsupported', 'unknown future Workspine config cannot be adopted'); + } + fs.rmSync(path.join(legacy, 'config.json')); + fs.writeFileSync(path.join(legacy, 'migration-receipt.json'), '{}'); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_unsupported', 'migration receipts cannot be reclassified as GSD adoption'); + fs.rmSync(path.join(legacy, 'migration-receipt.json')); + fs.mkdirSync(path.join(legacy, 'decisions')); + fs.writeFileSync(path.join(legacy, 'decisions', 'retained.md'), '# Old approval\n'); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_unsupported', 'decision records cannot silently become historical evidence'); + fs.rmSync(path.join(legacy, 'decisions'), { recursive: true }); + fs.rmSync(path.join(legacy, 'ROADMAP.md')); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_unsupported'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic roadmap\n'); + fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + fs.mkdirSync(path.join(legacy, 'decisions')); + fs.writeFileSync(path.join(legacy, 'decisions', 'retained.md'), '# Existing decision\n'); + assert.strictEqual(resolveStateDir(tmp).reason, 'nonempty_legacy_decisions', 'do not relabel old Workspine approval as GSD history'); + fs.rmSync(path.join(legacy, 'decisions'), { recursive: true }); + fs.rmSync(path.join(legacy, 'config.json')); + const outside = createTempProject(); + try { + try { + fs.symlinkSync(outside, path.join(legacy, 'linked'), 'junction'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) return t.skip('symlink creation unavailable'); + throw error; + } + assert.strictEqual(resolveStateDir(tmp).reason, 'linked_legacy_entry'); + assert.strictEqual(resolveStateDir(tmp).status, 'legacy_unsupported'); + } finally { cleanup(outside); } + }); + test('missing, malformed, non-object, and unknown-version legacy configs are unsupported', async () => { const { resolveStateDir } = await loadModule(STATE_DIR_MODULE); const cases = [ diff --git a/tests/gsdd.state-migration.test.cjs b/tests/gsdd.state-migration.test.cjs index 25fff0f..c0a9cb4 100644 --- a/tests/gsdd.state-migration.test.cjs +++ b/tests/gsdd.state-migration.test.cjs @@ -1,5 +1,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); const fs = require('fs'); const path = require('path'); const { pathToFileURL } = require('url'); @@ -88,4 +89,86 @@ describe('same-parent legacy state migration', () => { assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); assert.deepStrictEqual(snapshot(legacy), before); }); + + test('explicit adoption quarantines all historic GSD bytes and produces a verified read-only receipt', async () => { + const { adoptLegacyState, digestLegacyTree } = await loadMigration(); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(path.join(legacy, 'phases', '01-first'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic Spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic Roadmap\n'); + fs.writeFileSync(path.join(legacy, 'phases', '01-first', '01-PLAN.md'), Buffer.from([0, 1, 2, 255])); + const before = snapshot(legacy); + const digest = digestLegacyTree(legacy); + const result = adoptLegacyState(tmp, { now: new Date('2026-10-09T02:00:00.000Z') }); + const archive = path.join(tmp, '.work', '.local', 'legacy-planning'); + assert.strictEqual(fs.existsSync(legacy), false); + assert.deepStrictEqual(snapshot(archive), before); + assert.strictEqual(result.receipt.signature, 'GSD-roadmap-v1'); + assert.strictEqual(result.receipt.pre_adoption_tree_sha256, digest.sha256); + assert.strictEqual(result.receipt.pre_adoption_entry_count, digest.entryCount); + assert.strictEqual(result.receipt.source, '.planning'); + assert.strictEqual(result.receipt.archive, '.work/.local/legacy-planning'); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work', 'migration-receipt.json')), false); + assert.match(fs.readFileSync(path.join(tmp, '.work', '.local', '.gitignore'), 'utf8'), /\*/); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(tmp, '.work', 'adoption-receipt.json'), 'utf8')), result.receipt); + assert.strictEqual(spawnSync('git', ['init', '--quiet'], { cwd: tmp }).status, 0); + const ignored = spawnSync('git', ['check-ignore', '-q', '.work/.local/legacy-planning/SPEC.md'], { cwd: tmp }); + assert.strictEqual(ignored.status, 0, 'historic private sources must remain ignored even before full init'); + }); + + test('adoption receipt failure restores original legacy bytes and leaves no active work root', async () => { + const { adoptLegacyState } = await loadMigration(); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(path.join(legacy, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic Spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic Roadmap\n'); + const before = snapshot(legacy); + assert.throws(() => adoptLegacyState(tmp, { + writeReceipt() { throw new Error('injected adoption receipt failure'); }, + }), /injected adoption receipt failure/); + assert.deepStrictEqual(snapshot(legacy), before); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); + }); + + test('adoption final rename failure restores the original tree and refuses partial authority', async () => { + const { adoptLegacyState } = await loadMigration(); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic Spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic Roadmap\n'); + const before = snapshot(legacy); + let renames = 0; + assert.throws(() => adoptLegacyState(tmp, { + rename(from, to) { + renames += 1; + if (renames === 2) throw new Error('injected final rename failure'); + fs.renameSync(from, to); + }, + }), /injected final rename failure/); + assert.strictEqual(renames, 3, 'one move, one failed activation and one source restoration'); + assert.deepStrictEqual(snapshot(legacy), before); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); + assert.deepStrictEqual(fs.readdirSync(tmp), ['.planning']); + }); + + test('adoption detects a source edit between classification and receipt and restores modified bytes', async () => { + const { adoptLegacyState } = await loadMigration(); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic Spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic Roadmap\n'); + let moved = false; + assert.throws(() => adoptLegacyState(tmp, { + rename(from, to) { + fs.renameSync(from, to); + if (!moved) { + moved = true; + fs.writeFileSync(path.join(to, 'SPEC.md'), '# Owner edited during move\n'); + } + }, + }), /changed during adoption/); + assert.strictEqual(fs.readFileSync(path.join(legacy, 'SPEC.md'), 'utf8'), '# Owner edited during move\n'); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); + assert.deepStrictEqual(fs.readdirSync(tmp), ['.planning']); + }); }); From 9789738bfe80e6e1a9ee107f17582b2092342ff3 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 02:52:32 +0200 Subject: [PATCH 07/19] fix(models): expose executor reads in installed workflow helper --- bin/lib/config.mjs | 6 +-- bin/lib/executor-model-view.mjs | 44 ++++++++++++++++++++ bin/lib/rendering.mjs | 26 ++++++++++++ tests/gsdd.consumer-ceremony.test.cjs | 34 +++++++++++++++ tests/gsdd.models.test.cjs | 59 +++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 bin/lib/executor-model-view.mjs diff --git a/bin/lib/config.mjs b/bin/lib/config.mjs index 802ecf3..e93933a 100644 --- a/bin/lib/config.mjs +++ b/bin/lib/config.mjs @@ -11,6 +11,7 @@ import { parseFlagValue, output } from './cli-utils.mjs'; import { validateCommandShape } from './init-runtime.mjs'; import { assertStateAuthority, resolveStateDir } from './state-dir.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; +import { getRuntimeExecutorModelState } from './executor-model-view.mjs'; export const DEFAULT_GIT_PROTOCOL = { branch: 'Follow the existing repo or team branching convention. Use a feature branch for significant changes when no convention exists.', @@ -199,11 +200,6 @@ export function getRuntimeModelOverride(config, runtime, agentId) { return typeof override === 'string' && override.trim() ? override.trim() : null; } -function getRuntimeExecutorModelState(config, runtime) { - const model = getRuntimeModelOverride(config, runtime, 'executor'); - return { mode: model ? 'override' : 'inherit', model }; -} - export function resolveRuntimeAgentModel({ cwd = process.cwd(), runtime, agentId, profileMap = null }) { const config = loadProjectModelConfig(cwd); const runtimeOverride = getRuntimeModelOverride(config, runtime, agentId); diff --git a/bin/lib/executor-model-view.mjs b/bin/lib/executor-model-view.mjs new file mode 100644 index 0000000..4d8046a --- /dev/null +++ b/bin/lib/executor-model-view.mjs @@ -0,0 +1,44 @@ +import { lstatSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const RUNTIMES = Object.freeze(['claude', 'opencode', 'codex']); + +// The public models CLI and the generated, read-only workflow helper must +// resolve the same executor override without importing an entire native +// adapter runtime into .work/bin/lib/. +export function getRuntimeExecutorModelState(config, runtime) { + const value = config?.runtimeModelOverrides?.[runtime]?.executor; + const model = typeof value === 'string' && value.trim() ? value.trim() : null; + return { mode: model ? 'override' : 'inherit', model }; +} + +export function executorModelSnapshot(config) { + return Object.fromEntries(RUNTIMES.map((runtime) => [ + runtime, { executor: getRuntimeExecutorModelState(config, runtime) }, + ])); +} + +// This reader is deliberately scoped to the generated helper's sole new +// command: models show. Refuse an absent/malformed/linked config rather than +// claiming inheritance when the user's persisted selection is unreadable. +export function readExecutorModelsForHelper(workspaceRoot) { + const configPath = join(workspaceRoot, '.work', 'config.json'); + let config; + try { + const stat = lstatSync(configPath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular configuration file'); + config = JSON.parse(readFileSync(configPath, 'utf8')); + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not a JSON object'); + } catch { + const error = new Error('Unable to read a valid .work/config.json; executor model selection is unknown. Repair the config before delegating execution.'); + error.code = 'executor_model_config_unreadable'; + throw error; + } + + return { + schema_version: 1, + operation: 'models show', + scope: 'executor', + effective: executorModelSnapshot(config), + }; +} diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index 2e85795..12aa5be 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -12,6 +12,7 @@ const HELPER_LIB_FILES = Object.freeze([ 'cli-utils.mjs', 'control-map.mjs', 'decision-cli.mjs', + 'executor-model-view.mjs', 'file-ops.mjs', 'git-identity.mjs', 'lifecycle-preflight.mjs', @@ -73,6 +74,7 @@ import { cmdLifecyclePreflight, cmdLifecycleTransition } from './lib/lifecycle-p import { cmdPhaseStatus, cmdVerify } from './lib/phase.mjs'; import { buildControlMap } from './lib/control-map.mjs'; import { cmdDecisionsQuery, cmdRememberCandidate } from './lib/decision-cli.mjs'; +import { readExecutorModelsForHelper } from './lib/executor-model-view.mjs'; import { createCmdNext } from './lib/next.mjs'; import { maybeShowUpdateNotice } from './lib/update-awareness.mjs'; import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs'; @@ -110,8 +112,30 @@ function cmdControlMap(...controlArgs) { console.log(JSON.stringify(report, null, 2)); } +function cmdExecutorModelsShow(...modelArgs) { + if (modelArgs.length > 1 || (modelArgs.length === 1 && modelArgs[0] !== 'show')) { + console.error('ERROR: Generated helper supports only models show. Use the public Workspine CLI to set or clear executor models.'); + process.exitCode = 1; + return; + } + const context = resolveWorkspaceContext([], { cwd: HELPER_CONTEXT.cwd }); + if (context.invalid) { + console.error('ERROR: ' + context.error); + process.exitCode = 1; + return; + } + try { + assertStateAuthority(context.state); + console.log(JSON.stringify(readExecutorModelsForHelper(context.workspaceRoot), null, 2)); + } catch (error) { + console.error('ERROR: ' + error.message); + process.exitCode = 1; + } +} + const COMMANDS = { 'control-map': cmdControlMap, + models: cmdExecutorModelsShow, decisions: cmdDecisionsQuery, 'file-op': cmdFileOp, 'git-identity': cmdGitIdentity, @@ -145,6 +169,8 @@ function printHelp() { 'Local workflow helper commands:', ' control-map [--json] [--with-ignored]', ' Print computed repo/worktree/workflow state for workflow-internal checks', + ' models show Read effective executor model overrides for each runtime; no config writes', + ' Use the public Workspine CLI for models set/clear', ' remember "" --type --scope [--for ]', ' Capture an agent-proposed candidate; this is not approval', ' decisions query "" [--path ]', diff --git a/tests/gsdd.consumer-ceremony.test.cjs b/tests/gsdd.consumer-ceremony.test.cjs index 8d12db6..dbd4d04 100644 --- a/tests/gsdd.consumer-ceremony.test.cjs +++ b/tests/gsdd.consumer-ceremony.test.cjs @@ -217,6 +217,40 @@ describe('consumer ceremony reduction', () => { const skillPath = path.join(consumerRoot, '.agents', 'skills', 'work-execute', 'SKILL.md'); const skill = fs.readFileSync(skillPath, 'utf8'); + assert.match(skill, /node \.work\/bin\/gsdd\.mjs models show/, + 'installed executor must advertise a runnable local model-read command'); + const generatedHelper = path.join(consumerRoot, '.work', 'bin', 'gsdd.mjs'); + const generatedShow = () => { + const result = runProcess(process.execPath, [generatedHelper, 'models', 'show'], consumerRoot); + assert.strictEqual(result.exitCode, 0, `installed model read path failed: ${result.stdout}${result.stderr}`); + return JSON.parse(result.stdout); + }; + const configPath = path.join(consumerRoot, '.work', 'config.json'); + const originalConfigBytes = fs.readFileSync(configPath); + const inherited = generatedShow(); + assert.deepStrictEqual(inherited.effective.codex.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(fs.readFileSync(configPath), originalConfigBytes, + 'installed model read must not modify configuration'); + const requestedModel = 'openai/gpt-6.1-sol'; + const set = runProcess(process.execPath, [ + initEntry, 'models', 'set', '--runtime', 'codex', '--agent', 'executor', '--model', requestedModel, + ], consumerRoot); + assert.strictEqual(set.exitCode, 0, set.stderr || set.stdout); + const configuredBytes = fs.readFileSync(configPath); + assert.deepStrictEqual(generatedShow().effective.codex.executor, { + mode: 'override', model: requestedModel, + }); + const mutatingHelperAttempt = runProcess(process.execPath, [ + generatedHelper, 'models', 'clear', '--runtime', 'codex', '--agent', 'executor', + ], consumerRoot); + assert.notStrictEqual(mutatingHelperAttempt.exitCode, 0, + 'generated helper must not gain model mutation authority'); + assert.deepStrictEqual(fs.readFileSync(configPath), configuredBytes); + const clear = runProcess(process.execPath, [ + initEntry, 'models', 'clear', '--runtime', 'codex', '--agent', 'executor', + ], consumerRoot); + assert.strictEqual(clear.exitCode, 0, clear.stderr || clear.stdout); + assert.deepStrictEqual(generatedShow().effective.codex.executor, { mode: 'inherit', model: null }); assert.ok(skill.includes('lifecycle-preflight execute {phase_num} --expects-mutation phase-status'), 'generated execute skill must retain the positional execute preflight command'); assert.ok(skill.includes('emitted positional `phases/{phase_dir}` selector'), diff --git a/tests/gsdd.models.test.cjs b/tests/gsdd.models.test.cjs index d934664..da9114c 100644 --- a/tests/gsdd.models.test.cjs +++ b/tests/gsdd.models.test.cjs @@ -6,6 +6,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); +const { spawnSync } = require('child_process'); const { cleanup, createTempProject, @@ -61,6 +62,64 @@ describe('gsdd models and model propagation', () => { }); describe('model profile propagation', () => { + test('generated models show reads executor overrides without source tree or caller cwd assumptions', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'codex']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const helper = path.join(tmpDir, '.work', 'bin', 'gsdd.mjs'); + const helperModelModule = path.join(tmpDir, '.work', 'bin', 'lib', 'executor-model-view.mjs'); + assert.strictEqual(fs.existsSync(helperModelModule), true, + 'the installed helper must carry its own model-read dependency'); + const configPath = path.join(tmpDir, '.work', 'config.json'); + const foreignCwd = createTempProject(); + try { + const callHelper = (args = ['models', 'show']) => { + const result = spawnSync(process.execPath, [helper, ...args], { + cwd: foreignCwd, + encoding: 'utf8', + windowsHide: true, + }); + assert.ifError(result.error); + return result; + }; + const before = fs.readFileSync(configPath); + let result = callHelper(); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + let payload = JSON.parse(result.stdout); + assert.strictEqual(payload.scope, 'executor'); + assert.deepStrictEqual(payload.effective.codex.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(payload.effective.claude.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(payload.effective.opencode.executor, { mode: 'inherit', model: null }); + assert.deepStrictEqual(fs.readFileSync(configPath), before); + + result = await runCliAsMain(tmpDir, [ + 'models', 'set', '--runtime', 'codex', '--agent', 'executor', '--model', 'openai/gpt-6.1-sol', + ]); + assert.strictEqual(result.exitCode, 0, result.output); + const sourceModels = JSON.parse((await runCliAsMain(tmpDir, ['models', 'show'])).output); + const explicit = callHelper(['--workspace-root', tmpDir, 'models', 'show']); + assert.strictEqual(explicit.status, 0, explicit.stdout + explicit.stderr); + payload = JSON.parse(explicit.stdout); + for (const runtime of ['codex', 'claude', 'opencode']) { + assert.deepStrictEqual(payload.effective[runtime].executor, sourceModels.effective[runtime].executor, + `generated ${runtime} executor model must match the public models command`); + } + const afterOverride = fs.readFileSync(configPath); + const rejectedMutation = callHelper(['models', 'set', '--runtime', 'codex', '--agent', 'executor', '--model', 'another/model']); + assert.notStrictEqual(rejectedMutation.status, 0); + assert.match(rejectedMutation.stderr, /only models show/i); + assert.deepStrictEqual(fs.readFileSync(configPath), afterOverride); + + fs.writeFileSync(configPath, '{ invalid JSON'); + const corruptSnapshot = fs.readFileSync(configPath); + const unreadable = callHelper(); + assert.notStrictEqual(unreadable.status, 0, 'malformed config cannot claim model inherit'); + assert.match(unreadable.stderr, /selection is unknown/); + assert.deepStrictEqual(fs.readFileSync(configPath), corruptSnapshot); + } finally { + cleanup(foreignCwd); + } + }); + test('quality profile injects model: opus into Claude plan-checker', async () => { writeProjectConfig(tmpDir, { modelProfile: 'quality' }); From 060103b2af74eafbb9bcee9e73ed229ec0264187 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 02:53:13 +0200 Subject: [PATCH 08/19] fix(lifecycle): preserve paused approval and seal adoption receipt --- bin/lib/state-migration.mjs | 9 ++++ bin/lib/work-context.mjs | 26 +++++++++-- distilled/workflows/plan.md | 2 +- tests/gsdd.next.test.cjs | 67 +++++++++++++++++++++++++++++ tests/gsdd.state-migration.test.cjs | 19 ++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/bin/lib/state-migration.mjs b/bin/lib/state-migration.mjs index ccc48e5..ac56ed2 100644 --- a/bin/lib/state-migration.mjs +++ b/bin/lib/state-migration.mjs @@ -128,6 +128,15 @@ export function adoptLegacyState(root, { throw new Error('Historical legacy content changed during adoption; source restored without establishing .work authority.'); } writeReceipt(stagedReceipt, `${JSON.stringify(receipt, null, 2)}\n`); + // Recheck after the receipt writer: an owner's concurrent edit during + // that synchronous callback must remain historical source data rather + // than yield a receipt that falsely seals the earlier archive digest. + // This narrows the known window; it is not a lock against a separate + // process changing bytes after the final check. + const finalRetained = digestLegacyTree(archive); + if (finalRetained.sha256 !== digest.sha256 || finalRetained.entryCount !== digest.entryCount) { + throw new Error('Historical legacy content changed during adoption; source restored without establishing .work authority.'); + } const concurrentState = resolveStateDir(state.root); if (concurrentState.workExists || concurrentState.legacyExists) { throw new Error('Workspine or a competing legacy root appeared during adoption; refusing split authority.'); diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index c6b2db6..1960cf7 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -378,6 +378,14 @@ export function transitionWorkflowState(workDir, { if (recordedPlan && expectedPlan && recordedPlan !== expectedPlan && !repairBrownfieldApproval) { throw transitionError('stale_state', 'Recorded plan authority does not match the supplied plan artifact.', [String(recordedPlan), String(expectedPlan)]); } + // ask_user/blocked can interrupt execute without clearing its execution + // status or old owner approval. They must never become a shortcut back to + // ordinary agent-controlled planning and reuse of the old approval ref. + if (normalizedTarget === 'plan' && !amend && workflow.execution.status === 'in_progress') { + throw transitionError('owner_amendment_required', + 'An approved or interrupted execution still has in-progress lifecycle authority. Review its retained work and use explicit owner --amend for a bounded CHANGE, or formal review for other plans.', + [String(recordedPlan || '.work/state.json')]); + } if (artifactPath && artifactIdentity) { const recordedArtifact = normalizedTarget === 'verify' ? workflow.execution.artifact || workflow.execution.identity @@ -400,11 +408,12 @@ export function transitionWorkflowState(workDir, { const originalApprovalHash = workflow.plan.approved_sha256; const amendedHash = amend && normalizedPlan ? planArtifactSha256(workDir, normalizedPlan) : null; const brownfieldIdentity = `${stateRoot}/brownfield-change/CHANGE.md`; + const interruptedExecutionStates = ['execute', 'ask_user', 'blocked']; const stagesHaveNoTerminalEvidence = [workflow.verification, workflow.audit, workflow.dogfood] .every((stage) => stage.status === 'not_started' && !stage.artifact && !stage.identity); if (amend && !(normalizedPlan === brownfieldIdentity && recordedPlan === normalizedPlan - && workflow.plan.identity === normalizedPlan && currentState === 'execute' - && (!state.current_state || state.current_state === 'execute') + && workflow.plan.identity === normalizedPlan && interruptedExecutionStates.includes(currentState) + && (!state.current_state || state.current_state === currentState) && workflow.execution.status === 'in_progress' && !workflow.execution.artifact && !workflow.execution.identity && stagesHaveNoTerminalEvidence && workflow.plan.approved === true @@ -456,7 +465,7 @@ export function transitionWorkflowState(workDir, { const effectiveTarget = normalizedTarget === 'next' ? 'audit' : normalizedTarget; const allowedPredecessors = { - plan: amend ? ['execute'] : ['plan', 'fix_gaps', 'blocked', 'ask_user'], + plan: amend ? interruptedExecutionStates : ['plan', 'fix_gaps', 'blocked', 'ask_user'], approve: ['plan'], execute: ['plan', 'execute'], verify: ['execute', 'verify'], @@ -489,10 +498,21 @@ export function transitionWorkflowState(workDir, { amended_plan_sha256: amendedHash, previous_approval_ref: workflow.approval_ref, previous_execution_status: workflow.execution.status, + previous_workflow_state: currentState, + previous_workflow_status: workflow.status || 'active', + previous_blocker_reason: workflow.reason || null, + previous_human_gate: workflow.human_gate || null, reason: String(reason).trim(), amended_at: now.toISOString(), }, ]; + // The owner has explicitly replaced the interrupted posture with fresh + // planning. Keep its reason/gate in the amendment receipt, then clear + // stale blocked/ask_user flags without authorizing execution. + next.workflow.status = 'active'; + delete next.workflow.reason; + delete next.workflow.human_gate; + next.status = 'active'; } next.workflow.plan.approved = false; delete next.workflow.plan.approved_sha256; diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 0097ec3..922558e 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -34,7 +34,7 @@ Before writing or rewriting substantive planning content beyond the bounded targ - Phase: `node .work/bin/gsdd.mjs lifecycle-preflight plan {phase_num}`; bounded brownfield: `node .work/bin/gsdd.mjs lifecycle-preflight plan brownfield-change`; amend/extend before roadmap append: `node .work/bin/gsdd.mjs lifecycle-preflight plan amend` If the preflight result is `blocked`, STOP and report the blocker instead of inferring planning eligibility from workflow-local prose. Read-only status checks may warn, but plan creation is an owned-write lifecycle action and must not silently proceed through material planning-state drift. Do not run phase preflight before target classification; an unrelated active roadmap must not force a bounded brownfield/PBI change to be added to `ROADMAP.md` just to create an approval plan. -Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. For a changed CHANGE.md already in `workflow.current_state: execute`, stop execution and do not replay the ordinary plan transition. Only after the owner reviews the changed exact scope and previous partial implementation may they run `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason} --json`. This records previous approval and execution posture without touching CHANGE.md or prior evidence; it refuses completed/verified or unchanged plans. Present the revised CHANGE.md and require a new owner approval reference via the ordinary separate `approve` transition before any execute replay. Do not silently reset prior work, inherit the old reference or simulate owner authority. +Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. For a changed CHANGE.md whose `workflow.execution.status: in_progress` remains recorded, including after `ask_user` or `blocked` interruptions, stop execution and do not replay the ordinary plan transition. Only after the owner reviews the changed exact scope and previous partial implementation may they run `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason} --json`. This records previous approval and interrupted posture without touching CHANGE.md or prior evidence; it refuses completed/verified or unchanged plans. Present the revised CHANGE.md and require a new owner approval reference via the ordinary separate `approve` transition before any execute replay. Do not silently reset prior work, inherit the old reference or simulate owner authority. When presenting the plan for approval, show its path and a short non-sensitive approval reference; use the owner's reference if one was supplied, otherwise propose one with the plan. Explicit owner approval of that identified plan also confirms the displayed reference. The owner can say "I approve this plan" without inventing an identifier or editing metadata. Silence, vague praise, and an agent-proposed reference are not approval. If the plan or reference was not identified when approval was given, clarify the binding before proceeding. After owner approval, bind the selected plan's exact bytes through the shared helper. For phase authority, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in its PLAN frontmatter. For active brownfield authority, show and approve `.work/brownfield-change/CHANGE.md` with that reference; do not create a phase PLAN or add phase frontmatter. Approval is owner-only: agents must not simulate it with `--approved` or pass an approval reference while executing. Phase command: `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index fd7e209..c04da37 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -1786,6 +1786,73 @@ describe('next command routing', () => { untouchedPaths.forEach((name, index) => assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, name)), retainedBytes[index])); }); + for (const interruption of ['ask_user', 'blocked']) { + test(`interrupted in-progress CHANGE cannot bypass owner amendment through ${interruption}`, async () => { + await initWork(); + const changePath = '.work/brownfield-change/CHANGE.md'; + const statePath = path.join(tmpDir, '.work', 'state.json'); + writeFile(changePath, brownfieldChange()); + writeFile('.work/brownfield-change/HANDOFF.md', '# Preserved prior work\n'); + const beforeHandoff = fs.readFileSync(path.join(tmpDir, '.work/brownfield-change/HANDOFF.md')); + const lifecycle = (target, flags = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', changePath, ...flags, '--json', '--no-update-notice', + ]); + const transitionWithoutPlan = (target) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--reason', 'Synthetic interruption before amendment review', '--json', '--no-update-notice', + ]); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-original-ref'])).exitCode, 0); + const originalSha = readJson(statePath).workflow.plan.approved_sha256; + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + writeFile(changePath, brownfieldChange({ nextAction: 'Execute the revised bounded change.' })); + + const stop = await transitionWithoutPlan(interruption); + assert.strictEqual(stop.exitCode, 0, stop.output); + assert.strictEqual(readJson(statePath).workflow.execution.status, 'in_progress'); + const stalePreflight = await runCliAsMain(tmpDir, [ + 'lifecycle-preflight', 'execute', 'brownfield-change', '--expects-mutation', 'phase-status', + ]); + assert.strictEqual(stalePreflight.exitCode, 1, stalePreflight.output); + assert.ok(JSON.parse(stalePreflight.output).blockers.some(({ code }) => code === 'plan_approval_stale')); + const beforeRefusal = fs.readFileSync(statePath); + for (const authority of ['workflow', 'owner']) { + const bypass = await lifecycle('plan', ['--authority', authority]); + assert.strictEqual(bypass.exitCode, 1, bypass.output); + assert.strictEqual(JSON.parse(bypass.output).error_code, 'owner_amendment_required'); + assert.deepStrictEqual(fs.readFileSync(statePath), beforeRefusal, + `ordinary plan must preserve interrupted ${interruption} state`); + } + + const amendment = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'Owner reviewed changed scope after interruption']); + assert.strictEqual(amendment.exitCode, 0, amendment.output); + const reviewed = readJson(statePath); + assert.strictEqual(reviewed.status, 'active'); + assert.strictEqual(reviewed.workflow.status, 'active'); + assert.strictEqual(reviewed.workflow.current_state, 'plan'); + assert.strictEqual(reviewed.workflow.approval_ref, null); + assert.strictEqual(reviewed.workflow.plan.approved, false); + assert.strictEqual(reviewed.workflow.amendments.length, 1); + const recorded = reviewed.workflow.amendments[0]; + assert.strictEqual(recorded.previous_approval_sha256, originalSha); + assert.strictEqual(recorded.previous_approval_ref, 'owner-original-ref'); + assert.strictEqual(recorded.previous_execution_status, 'in_progress'); + assert.strictEqual(recorded.previous_workflow_state, interruption); + assert.strictEqual(recorded.reason, 'Owner reviewed changed scope after interruption'); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 1); + const oldRef = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-original-ref']); + assert.strictEqual(oldRef.exitCode, 1, oldRef.output); + const approved = await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'owner-revised-ref']); + assert.strictEqual(approved.exitCode, 0, approved.output); + assert.notStrictEqual(readJson(statePath).workflow.plan.approved_sha256, originalSha); + const resumedPreflight = await runCliAsMain(tmpDir, [ + 'lifecycle-preflight', 'execute', 'brownfield-change', '--expects-mutation', 'phase-status', + ]); + assert.strictEqual(resumedPreflight.exitCode, 0, resumedPreflight.output); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work/brownfield-change/HANDOFF.md')), beforeHandoff); + }); + } + test('in-progress amendment refuses an unchanged plan, other plan chains, and retained execution evidence', async () => { await initWork(); const changePath = '.work/brownfield-change/CHANGE.md'; diff --git a/tests/gsdd.state-migration.test.cjs b/tests/gsdd.state-migration.test.cjs index c0a9cb4..c2f3fce 100644 --- a/tests/gsdd.state-migration.test.cjs +++ b/tests/gsdd.state-migration.test.cjs @@ -171,4 +171,23 @@ describe('same-parent legacy state migration', () => { assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); assert.deepStrictEqual(fs.readdirSync(tmp), ['.planning']); }); + + test('adoption refuses a source edit during receipt writing after the first archive digest', async () => { + const { adoptLegacyState } = await loadMigration(); + const legacy = path.join(tmp, '.planning'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'SPEC.md'), '# Historic Spec\n'); + fs.writeFileSync(path.join(legacy, 'ROADMAP.md'), '# Historic Roadmap\n'); + assert.throws(() => adoptLegacyState(tmp, { + writeReceipt(receiptPath, content) { + fs.writeFileSync(receiptPath, content); + const archive = path.join(path.dirname(receiptPath), '.local', 'legacy-planning'); + fs.writeFileSync(path.join(archive, 'SPEC.md'), '# Owner edited during receipt\n'); + }, + }), /changed during adoption/); + assert.strictEqual(fs.readFileSync(path.join(legacy, 'SPEC.md'), 'utf8'), '# Owner edited during receipt\n'); + assert.strictEqual(fs.existsSync(path.join(tmp, '.work')), false); + assert.deepStrictEqual(fs.readdirSync(tmp), ['.planning'], + 'the staging receipt is discarded and ownership returns to the old tree'); + }); }); From 5a28488f76dbeff1e981118637c4360da5fd6ede Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 03:07:38 +0200 Subject: [PATCH 09/19] fix(lifecycle): reject amendment after current terminal proof --- bin/lib/work-context.mjs | 42 +++++++++++++++++++++++- distilled/workflows/plan.md | 1 + tests/gsdd.next.test.cjs | 65 +++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 1960cf7..761ee4b 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -14,7 +14,7 @@ import { import { execFileSync } from 'child_process'; import { createHash, randomBytes } from 'crypto'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; -import { collectNativePhaseArtifacts, evaluateLifecycleState, partitionPlanChains } from './lifecycle-state.mjs'; +import { collectNativePhaseArtifacts, evaluateLifecycleState, partitionPlanChains, readPlanStatus } from './lifecycle-state.mjs'; import { resolveStateDir, stateAuthorityGate, STATE_DIR_NAME } from './state-dir.mjs'; import { writeFileAtomic as replaceFileAtomically } from './atomic-write.mjs'; @@ -291,6 +291,42 @@ export function assertLifecyclePlanIdentity(planningDir, target, planIdentity) { } } +const BROWNFIELD_TERMINAL_VERIFICATION_STATUSES = new Set([ + 'complete', 'completed', 'done', 'passed', 'pass', 'verified', + 'gaps_found', 'human_needed', 'failed', 'fail', +]); + +function assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir) { + // An owner amendment is allowed only for unfinished execution. State can + // still say in_progress after a verification result was written to disk. + // Inspect only the canonical artifact in THIS active brownfield chain: + // historical phase VERIFICATION files are evidence, not its completion. + const verificationPath = brownfield.brownfieldChange?.verificationPath; + if (!verificationPath || !existsSync(verificationPath)) return; + const label = normalizeSlashes(relative(resolve(workDir, '..'), verificationPath)); + let declaredStatus; + try { + const stat = lstatSync(verificationPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('current verification artifact is not a regular file'); + } + // Reuse the strict first-frontmatter top-level `status` reader: body + // examples, nested status and historical files cannot imply completion. + declaredStatus = readPlanStatus(readFileSync(verificationPath, 'utf8')); + } catch { + throw transitionError('amendment_terminal_evidence_conflict', + `Current ${label} cannot be reconciled with in-progress execution; retain the artifact and use formal review.`, + [label, '.work/state.json']); + } + if (!declaredStatus) return; + const status = String(declaredStatus).trim().toLowerCase().replace(/[ -]+/g, '_'); + if (BROWNFIELD_TERMINAL_VERIFICATION_STATUSES.has(status)) { + throw transitionError('amendment_terminal_evidence_conflict', + `Current ${label} declares terminal ${status} evidence while workflow execution is in_progress; preserve that proof and use formal review instead of --amend.`, + [label, '.work/state.json']); + } +} + /** * Persist one validated lifecycle transition without changing any other state * fields. Callers must validate artifact identity and authority before calling @@ -426,6 +462,7 @@ export function transitionWorkflowState(workDir, { 'Only an in-progress, owner-approved bounded CHANGE without terminal execution evidence can be amended. Retain proof and use formal review for a different posture.', [String(normalizedPlan || ''), '.work/state.json']); } + if (amend) assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir); if (amend && amendedHash === originalApprovalHash) { throw transitionError('amendment_not_needed', 'CHANGE.md still matches its approved bytes; do not reset execution.', [normalizedPlan]); } @@ -579,6 +616,9 @@ export function transitionWorkflowState(workDir, { throw transitionError('stale_plan', 'The approved PLAN changed while the transition was being validated.', [String(normalizedPlan)]); } if (typeof preWriteGuard === 'function') preWriteGuard(); + // Reconcile current-chain proof again at the mutation boundary. Another + // process may have written a terminal VERIFICATION since initial validation. + if (amend) assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir); if (JSON.stringify(beforeComparable) === JSON.stringify(nextComparable)) { return { status: 'replayed', changed: false, state }; } diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 922558e..e7f4227 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -35,6 +35,7 @@ Before writing or rewriting substantive planning content beyond the bounded targ If the preflight result is `blocked`, STOP and report the blocker instead of inferring planning eligibility from workflow-local prose. Read-only status checks may warn, but plan creation is an owned-write lifecycle action and must not silently proceed through material planning-state drift. Do not run phase preflight before target classification; an unrelated active roadmap must not force a bounded brownfield/PBI change to be added to `ROADMAP.md` just to create an approval plan. Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. For a changed CHANGE.md whose `workflow.execution.status: in_progress` remains recorded, including after `ask_user` or `blocked` interruptions, stop execution and do not replay the ordinary plan transition. Only after the owner reviews the changed exact scope and previous partial implementation may they run `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason} --json`. This records previous approval and interrupted posture without touching CHANGE.md or prior evidence; it refuses completed/verified or unchanged plans. Present the revised CHANGE.md and require a new owner approval reference via the ordinary separate `approve` transition before any execute replay. Do not silently reset prior work, inherit the old reference or simulate owner authority. +Before owner `--amend`, reconcile the **current** `.work/brownfield-change/VERIFICATION.md` top-level `status` against recorded execution: `passed`, `complete`, `completed`, `gaps_found`, `human_needed`, or another terminal result contradicts `workflow.execution.status: in_progress` and blocks amendment even when state has not caught up. Preserve that proof and use formal review. A `pending` draft or an unrelated historical phase verification does not itself prove this brownfield chain terminal; fenced examples and nested metadata do not establish top-level status. When presenting the plan for approval, show its path and a short non-sensitive approval reference; use the owner's reference if one was supplied, otherwise propose one with the plan. Explicit owner approval of that identified plan also confirms the displayed reference. The owner can say "I approve this plan" without inventing an identifier or editing metadata. Silence, vague praise, and an agent-proposed reference are not approval. If the plan or reference was not identified when approval was given, clarify the binding before proceeding. After owner approval, bind the selected plan's exact bytes through the shared helper. For phase authority, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in its PLAN frontmatter. For active brownfield authority, show and approve `.work/brownfield-change/CHANGE.md` with that reference; do not create a phase PLAN or add phase frontmatter. Approval is owner-only: agents must not simulate it with `--approved` or pass an approval reference while executing. Phase command: `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index c04da37..6029559 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -1878,6 +1878,71 @@ describe('next command routing', () => { assert.deepStrictEqual(fs.readFileSync(statePath), beforeEvidence); }); + for (const status of ['passed', '"completed" # terminal result', 'gaps_found', 'human_needed']) { + test(`owner amendment rejects current brownfield terminal VERIFICATION.md (${status}) even when state says executing`, async () => { + await initWork(); + const planPath = '.work/brownfield-change/CHANGE.md'; + const evidencePath = '.work/brownfield-change/VERIFICATION.md'; + const lifecycle = (target, options = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', planPath, ...options, '--json', '--no-update-notice', + ]); + writeFile(planPath, brownfieldChange()); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'terminal-original'])).exitCode, 0); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + writeFile(evidencePath, brownfieldVerification(status)); + writeFile(planPath, brownfieldChange({ nextAction: 'Amend the bounded work after the prior terminal report.' })); + const statePath = path.join(tmpDir, '.work', 'state.json'); + assert.strictEqual(readJson(statePath).workflow.execution.status, 'in_progress'); + const before = snapshotTree(tmpDir); + const refused = await lifecycle('plan', [ + '--authority', 'owner', '--amend', '--reason', 'Owner reviewed the changed plan but terminal evidence exists', + ]); + assert.strictEqual(refused.exitCode, 1, refused.output); + const receipt = JSON.parse(refused.output); + assert.strictEqual(receipt.error_code, 'amendment_terminal_evidence_conflict'); + assert.match(receipt.error, /current.*VERIFICATION\.md.*terminal/i); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'terminal refusal must leave state and all evidence unchanged'); + }); + } + + test('owner amendment still accepts a nonterminal current verification draft with unrelated passed history', async () => { + await initWork(); + const planPath = '.work/brownfield-change/CHANGE.md'; + const evidencePath = '.work/brownfield-change/VERIFICATION.md'; + const lifecycle = (target, options = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', planPath, ...options, '--json', '--no-update-notice', + ]); + writeFile(planPath, brownfieldChange()); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'draft-original'])).exitCode, 0); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + writeFile(evidencePath, [ + '---', + 'status: pending', + 'subrecord:', + ' status: passed', + '---', + '# Draft verification, not completed evidence', + '```yaml', + 'status: passed', + '```', + '', + ].join('\n')); + writeFile('.work/phases/99-retained/99-VERIFICATION.md', brownfieldVerification('passed')); + const beforeEvidence = fs.readFileSync(path.join(tmpDir, evidencePath)); + const beforeHistory = fs.readFileSync(path.join(tmpDir, '.work/phases/99-retained/99-VERIFICATION.md')); + writeFile(planPath, brownfieldChange({ nextAction: 'Owner-reviewed bounded amendment after a draft check.' })); + const changed = await lifecycle('plan', ['--authority', 'owner', '--amend', '--reason', 'Only draft and old unrelated evidence exist']); + assert.strictEqual(changed.exitCode, 0, changed.output); + const state = readJson(path.join(tmpDir, '.work', 'state.json')); + assert.strictEqual(state.workflow.current_state, 'plan'); + assert.strictEqual(state.workflow.plan.approved, false); + assert.strictEqual(state.workflow.amendments.length, 1); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, evidencePath)), beforeEvidence); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work/phases/99-retained/99-VERIFICATION.md')), beforeHistory); + }); + test('the brownfield owner-amendment exception never reopens an already executing phase plan', async () => { await initWork(); const planPath = '.work/phases/01-protected/01-PLAN.md'; From b5a2ccb28c669d57e044a74aec88930edb00a59e Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 03:14:59 +0200 Subject: [PATCH 10/19] fix(lifecycle): refuse ambiguous current verification status --- bin/lib/work-context.mjs | 22 ++++++++++++++++++- distilled/workflows/plan.md | 2 +- tests/gsdd.next.test.cjs | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 761ee4b..3e26eac 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -296,6 +296,18 @@ const BROWNFIELD_TERMINAL_VERIFICATION_STATUSES = new Set([ 'gaps_found', 'human_needed', 'failed', 'fail', ]); +function countTopLevelVerificationStatuses(content) { + // Match readPlanStatus's exact initial-frontmatter boundaries and unindented + // scalar grammar. The shared reader intentionally returns only the FIRST + // status; an amendment must not silently choose between duplicate values. + const lines = String(content).replace(/\r\n/g, '\n').split('\n'); + if (lines[0] !== '---') return 0; + const closingIndex = lines.indexOf('---', 1); + // readPlanStatus separately rejects unclosed frontmatter. + if (closingIndex === -1) return 0; + return lines.slice(1, closingIndex).filter((line) => /^status:\s*(.*)$/.test(line)).length; +} + function assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir) { // An owner amendment is allowed only for unfinished execution. State can // still say in_progress after a verification result was written to disk. @@ -305,6 +317,7 @@ function assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir) { if (!verificationPath || !existsSync(verificationPath)) return; const label = normalizeSlashes(relative(resolve(workDir, '..'), verificationPath)); let declaredStatus; + let statusDeclarationCount = 0; try { const stat = lstatSync(verificationPath); if (!stat.isFile() || stat.isSymbolicLink()) { @@ -312,12 +325,19 @@ function assertNoCurrentBrownfieldTerminalEvidence(brownfield, workDir) { } // Reuse the strict first-frontmatter top-level `status` reader: body // examples, nested status and historical files cannot imply completion. - declaredStatus = readPlanStatus(readFileSync(verificationPath, 'utf8')); + const content = readFileSync(verificationPath, 'utf8'); + declaredStatus = readPlanStatus(content); + statusDeclarationCount = countTopLevelVerificationStatuses(content); } catch { throw transitionError('amendment_terminal_evidence_conflict', `Current ${label} cannot be reconciled with in-progress execution; retain the artifact and use formal review.`, [label, '.work/state.json']); } + if (statusDeclarationCount > 1) { + throw transitionError('amendment_terminal_evidence_conflict', + `Current ${label} has ambiguous multiple top-level status declarations; retain the evidence and reconcile it through formal review before --amend.`, + [label, '.work/state.json']); + } if (!declaredStatus) return; const status = String(declaredStatus).trim().toLowerCase().replace(/[ -]+/g, '_'); if (BROWNFIELD_TERMINAL_VERIFICATION_STATUSES.has(status)) { diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index e7f4227..441c17c 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -35,7 +35,7 @@ Before writing or rewriting substantive planning content beyond the bounded targ If the preflight result is `blocked`, STOP and report the blocker instead of inferring planning eligibility from workflow-local prose. Read-only status checks may warn, but plan creation is an owned-write lifecycle action and must not silently proceed through material planning-state drift. Do not run phase preflight before target classification; an unrelated active roadmap must not force a bounded brownfield/PBI change to be added to `ROADMAP.md` just to create an approval plan. Before presenting a brownfield plan, complete its draft and checker result and run the canonical plan transition in ``. A mis-recorded unexecuted phase chain must be repaired through that transition first, clearing old approval before requesting fresh consent; stop if execution or verification evidence exists. Preserve historical artifacts. For a changed CHANGE.md whose `workflow.execution.status: in_progress` remains recorded, including after `ask_user` or `blocked` interruptions, stop execution and do not replay the ordinary plan transition. Only after the owner reviews the changed exact scope and previous partial implementation may they run `node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/brownfield-change/CHANGE.md --authority owner --amend --reason {reviewed_reason} --json`. This records previous approval and interrupted posture without touching CHANGE.md or prior evidence; it refuses completed/verified or unchanged plans. Present the revised CHANGE.md and require a new owner approval reference via the ordinary separate `approve` transition before any execute replay. Do not silently reset prior work, inherit the old reference or simulate owner authority. -Before owner `--amend`, reconcile the **current** `.work/brownfield-change/VERIFICATION.md` top-level `status` against recorded execution: `passed`, `complete`, `completed`, `gaps_found`, `human_needed`, or another terminal result contradicts `workflow.execution.status: in_progress` and blocks amendment even when state has not caught up. Preserve that proof and use formal review. A `pending` draft or an unrelated historical phase verification does not itself prove this brownfield chain terminal; fenced examples and nested metadata do not establish top-level status. +Before owner `--amend`, reconcile the **current** `.work/brownfield-change/VERIFICATION.md` top-level `status` against recorded execution: `passed`, `complete`, `completed`, `gaps_found`, `human_needed`, or another terminal result contradicts `workflow.execution.status: in_progress` and blocks amendment even when state has not caught up. Multiple top-level `status` declarations (including `pending` then `passed`, reversed order, or duplicate `pending`) are ambiguous and must refuse amendment rather than selecting the first value. Preserve that proof and use formal review. A single `pending` draft or an unrelated historical phase verification does not itself prove this brownfield chain terminal; fenced examples and nested metadata do not establish top-level status. When presenting the plan for approval, show its path and a short non-sensitive approval reference; use the owner's reference if one was supplied, otherwise propose one with the plan. Explicit owner approval of that identified plan also confirms the displayed reference. The owner can say "I approve this plan" without inventing an identifier or editing metadata. Silence, vague praise, and an agent-proposed reference are not approval. If the plan or reference was not identified when approval was given, clarify the binding before proceeding. After owner approval, bind the selected plan's exact bytes through the shared helper. For phase authority, persist `status: approved`, `approved_by`, `approved_at`, and the owner-confirmed non-sensitive `approval_ref` in its PLAN frontmatter. For active brownfield authority, show and approve `.work/brownfield-change/CHANGE.md` with that reference; do not create a phase PLAN or add phase frontmatter. Approval is owner-only: agents must not simulate it with `--approved` or pass an approval reference while executing. Phase command: `node .work/bin/gsdd.mjs lifecycle-transition approve --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority owner --approval-ref {approval_ref} --json`. diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index 6029559..42ed685 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -1906,6 +1906,49 @@ describe('next command routing', () => { }); } + for (const statuses of [ + ['pending', 'passed'], + ['passed', 'pending'], + ['pending', 'pending'], + ]) { + test(`owner amendment refuses ambiguous duplicate top-level current verification statuses (${statuses.join(' then ')})`, async () => { + await initWork(); + const planPath = '.work/brownfield-change/CHANGE.md'; + const evidencePath = '.work/brownfield-change/VERIFICATION.md'; + const lifecycle = (target, options = []) => runCliAsMain(tmpDir, [ + 'lifecycle-transition', target, '--plan', planPath, ...options, '--json', '--no-update-notice', + ]); + writeFile(planPath, brownfieldChange()); + assert.strictEqual((await lifecycle('plan', ['--authority', 'workflow'])).exitCode, 0); + assert.strictEqual((await lifecycle('approve', ['--authority', 'owner', '--approval-ref', 'duplicate-status-original'])).exitCode, 0); + assert.strictEqual((await lifecycle('execute', ['--authority', 'workflow'])).exitCode, 0); + writeFile(evidencePath, [ + '---', + ...statuses.map((status) => `status: ${status}`), + 'subrecord:', + ' status: passed', + '---', + '# Canonical verification report', + '```yaml', + 'status: passed', + '```', + '', + ].join('\n')); + writeFile(planPath, brownfieldChange({ nextAction: 'Owner-reviewed amendment requires unambiguous current evidence.' })); + const statePath = path.join(tmpDir, '.work', 'state.json'); + assert.strictEqual(readJson(statePath).workflow.execution.status, 'in_progress'); + const before = snapshotTree(tmpDir); + const refused = await lifecycle('plan', [ + '--authority', 'owner', '--amend', '--reason', 'Owner reviewed scope, but current proof has duplicate statuses', + ]); + assert.strictEqual(refused.exitCode, 1, refused.output); + const receipt = JSON.parse(refused.output); + assert.strictEqual(receipt.error_code, 'amendment_terminal_evidence_conflict'); + assert.match(receipt.error, /current.*VERIFICATION\.md.*(?:ambiguous|multiple|duplicate)/i); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'ambiguous status refusal must preserve state, approval and evidence'); + }); + } + test('owner amendment still accepts a nonterminal current verification draft with unrelated passed history', async () => { await initWork(); const planPath = '.work/brownfield-change/CHANGE.md'; From 0823b7a737cae9ac71749094a6099301a6e8a585 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 04:16:13 +0200 Subject: [PATCH 11/19] fix(lifecycle): honor owner-parked phase dispatch boundaries --- bin/lib/lifecycle-preflight.mjs | 49 ++++++ bin/lib/lifecycle-state.mjs | 23 ++- bin/lib/next.mjs | 23 ++- bin/lib/phase.mjs | 11 ++ tests/gsdd.parked-authority.test.cjs | 223 +++++++++++++++++++++++++++ 5 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 tests/gsdd.parked-authority.test.cjs diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index a2d77cc..f94098f 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -77,6 +77,22 @@ const SURFACE_POLICIES = { const WORK_PHASE_LINE_RE = /^\s*[-*]\s*\[([ x-])\]\s*\*\*Phase\s+(\d+(?:\.\d+)*[A-Za-z]?):\s*(.+?)\*\*/i; +function checkpointParkedStandardPhase(lifecycle, value) { + // An explicit numbered checkpoint can name the standard phase by number or + // by its exact phase directory. A same-number native/other phase directory + // must not silently inherit the standard ROADMAP's PARKED disposition. + const raw = String(value || '').trim().toLowerCase(); + const match = raw.match(/^(\d+(?:\.\d+)*[a-z]?)(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/i); + if (!match) return null; + const phaseToken = normalizePhaseToken(match[1]); + if (raw !== match[1]) { + const selectedDir = (lifecycle.phaseArtifacts || []).some((artifact) => artifact.phaseToken === phaseToken + && String(artifact.dir).toLowerCase() === raw); + if (!selectedDir) return null; + } + return lifecycle.phases.find((phase) => phase.number === phaseToken) || null; +} + export function evaluateLifecyclePreflight({ planningDir, surface, @@ -142,6 +158,20 @@ export function evaluateLifecyclePreflight({ const milestonesPath = join(planningDir, 'MILESTONES.md'); const blockers = []; + const selectedPhase = normalizedPhase && !usesBrownfieldAuthority && !usesPlanAmendAuthority && !usesNativeAuthority + ? lifecycle.phases.find((phase) => phase.number === normalizedPhase) + : surface === 'resume' && continuityCheckpoint?.status === 'valid' + && continuityCheckpoint.frontmatter?.workflow === 'phase' + ? checkpointParkedStandardPhase(lifecycle, continuityCheckpoint.frontmatter.phase) + : null; + if (selectedPhase?.parked && ['plan', 'execute', 'verify', 'resume'].includes(surface)) { + blockers.push(blocker( + 'owner_parked_phase', + `Phase ${selectedPhase.number} is explicitly PARKED by owner in the current ROADMAP; do not dispatch its lifecycle work without a new owner unpark disposition.`, + [stateLabel('ROADMAP.md')], + )); + } + const malformedPlans = [ ...(lifecycle.malformedPlanFrontmatter || []), ...malformedPlanFrontmatter, @@ -1168,6 +1198,21 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, } const artifactKind = artifact ? lifecycleArtifactKind(artifact.path) : null; const planKind = plan ? lifecycleArtifactKind(plan.path) : null; + const mutatesSelectedPhase = ['plan', 'approve', 'execute', 'verify', 'audit', 'next', 'fix_gaps'].includes(target); + const assertSelectedPlanNotParked = () => { + if (!plan || planKind !== 'plan' || !mutatesSelectedPhase) return; + const lifecycle = evaluateLifecycleState({ planningDir }); + const selectedPlan = lifecycle.phaseArtifacts.find((item) => item.kind === 'plan' && resolve(item.path) === resolve(plan.path)); + const parked = selectedPlan && lifecycle.phases.find((phase) => phase.number === selectedPlan.phaseToken)?.parked; + if (parked) { + throw transitionErrorForCli('owner_parked_phase', + `Phase ${selectedPlan.phaseToken} is explicitly PARKED by owner in the current ROADMAP; no lifecycle transition is authorized.`, + [plan.relative, stateLabel('ROADMAP.md')]); + } + }; + // This is a direct state-writing command; checking only workflow preflight + // would let a caller bypass the owner-PARKED refusal. + assertSelectedPlanNotParked(); if (plan && ['plan', 'approve', 'execute'].includes(target)) { assertLifecyclePlanIdentity(planningDir, target, plan.relative); } @@ -1208,6 +1253,10 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, let preWriteGuard = plan && ['plan', 'approve', 'execute'].includes(target) ? () => assertLifecyclePlanIdentity(planningDir, target, plan.relative) : null; + if (plan && planKind === 'plan' && mutatesSelectedPhase) { + const preceding = preWriteGuard; + preWriteGuard = () => { preceding?.(); assertSelectedPlanNotParked(); }; + } if (brownfieldChain && target !== 'blocked' && target !== 'ask_user') { const identityGuard = preWriteGuard; preWriteGuard = () => { diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index 8762aa5..0dca37e 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -40,12 +40,15 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {}) structuralPhaseArtifacts, { companionKinds: ['summary', 'verification'], malformedPlanFrontmatter } ); + const parkedPhaseTokens = new Set(phaseStatusAlignment.parkedPhaseTokens); const enrichedPhases = phases.map((phase) => { const matchingArtifacts = phaseArtifacts.filter((artifact) => artifact.phaseToken === phase.number); const hasPlan = matchingArtifacts.some((artifact) => artifact.kind === 'plan'); const hasSummary = matchingArtifacts.some((artifact) => artifact.kind === 'summary'); return { ...phase, + // Owner parking is an execution disposition distinct from [ ] not_started. + parked: parkedPhaseTokens.has(phase.number), hasArtifacts: matchingArtifacts.length > 0, hasLifecycleArtifacts: hasPlan || hasSummary, hasPlan, @@ -81,7 +84,7 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {}) currentMilestone, phases: enrichedPhases, currentPhase: enrichedPhases.find((phase) => phase.status === 'in_progress') || null, - nextPhase: enrichedPhases.find((phase) => phase.status === 'not_started') || null, + nextPhase: enrichedPhases.find((phase) => phase.status === 'not_started' && !phase.parked) || null, counts, phaseArtifacts, historicalPhaseArtifacts, @@ -101,10 +104,11 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {}) } function evaluateRoadmapPhaseStatusAlignment(roadmap) { - if (!roadmap) return { mismatches: [] }; + if (!roadmap) return { mismatches: [], parkedPhaseTokens: [] }; const overview = new Map(); const details = new Map(); + const parkedPhaseTokens = new Set(); const lines = normalizeContent(roadmap).split('\n'); let inDetails = false; let currentDetailPhase = null; @@ -126,6 +130,9 @@ function evaluateRoadmapPhaseStatusAlignment(roadmap) { if (phaseMatch) { const phaseToken = normalizePhaseToken(phaseMatch[2]); const status = normalizePhaseStatus(phaseMatch[1]); + if (status === 'not_started' && isExplicitOwnerParkedStatusSuffix(line.slice(phaseMatch[0].length))) { + parkedPhaseTokens.add(phaseToken); + } if (!overview.has(phaseToken)) overview.set(phaseToken, []); overview.get(phaseToken).push(status); continue; @@ -146,6 +153,10 @@ function evaluateRoadmapPhaseStatusAlignment(roadmap) { const statusMatch = line.match(PHASE_DETAIL_STATUS_RE); if (statusMatch && currentDetailPhase) { details.get(currentDetailPhase).push(normalizePhaseStatus(statusMatch[1])); + if (normalizePhaseStatus(statusMatch[1]) === 'not_started' + && isExplicitOwnerParkedStatusSuffix(line.slice(statusMatch[0].length))) { + parkedPhaseTokens.add(currentDetailPhase); + } } } @@ -171,7 +182,13 @@ function evaluateRoadmapPhaseStatusAlignment(roadmap) { } } - return { mismatches }; + return { mismatches, parkedPhaseTokens: [...parkedPhaseTokens].sort(comparePhaseTokens) }; +} + +function isExplicitOwnerParkedStatusSuffix(suffix) { + // Only a recognized phase overview/detail status marker may declare parking. + // Body paragraphs, historical mentions and ordinary [ ] do not confer it. + return /^\s*[-—–]\s*(?:\*\*)?(?:parked\s+by\s+owner|owner[-\s]+parked)\b/i.test(suffix); } function comparePhaseTokens(a, b) { diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index d0d96e3..fb9d31f 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -751,7 +751,7 @@ function routeNext(ctx) { }); } - if (hasUnverifiedSummaries(context.planning.phases)) { + if (hasUnverifiedSummaries(context.planning.phases, lifecycleTruth.lifecycle)) { return enrichRoute({ state: 'verify', reason: `${statePath(context)} phase summaries exist without matching verification reports.` }, { context, controlMap, constraints, privacyNotes, inputsConsidered, inputsSkipped, traceRefs }); } @@ -818,8 +818,9 @@ function routeNext(ctx) { function readLifecycleTruth(cwd, context) { const planningDir = resolveStateDir(cwd).dir; const malformed = []; + let lifecycle = null; try { - const lifecycle = evaluateLifecycleState({ planningDir }); + lifecycle = evaluateLifecycleState({ planningDir }); malformed.push(...(lifecycle.malformedPlanFrontmatter || [])); } catch (error) { if (!isMalformedPlanFrontmatterError(error)) throw error; @@ -848,7 +849,7 @@ function readLifecycleTruth(cwd, context) { }); } } - return { malformed: malformed.filter((issue, index, all) => all.findIndex((candidate) => candidate.displayPath === issue.displayPath) === index) }; + return { lifecycle, malformed: malformed.filter((issue, index, all) => all.findIndex((candidate) => candidate.displayPath === issue.displayPath) === index) }; } function isMalformedPlanFrontmatterError(error) { @@ -1095,8 +1096,20 @@ function defaultWriteArtifacts(state) { return []; } -function hasUnverifiedSummaries(phases) { - return phases.some((phase) => phase.summaries.length > 0 && phase.verifications.length === 0); +function hasUnverifiedSummaries(phases, lifecycle) { + const byToken = new Map((lifecycle?.phases || []).map((phase) => [phase.number, phase])); + const currentSummaries = (lifecycle?.phaseArtifacts || []).filter((artifact) => artifact.kind === 'summary'); + const classifiedDirectories = new Set(currentSummaries.map((artifact) => artifact.dir)); + const eligibleDirectories = new Set(currentSummaries.filter((artifact) => { + const roadmapPhase = byToken.get(artifact.phaseToken); + return !roadmapPhase || (roadmapPhase.status !== 'done' && !roadmapPhase.parked); + }).map((artifact) => artifact.dir)); + return phases.some((phase) => { + if (phase.summaries.length === 0 || phase.verifications.length > 0) return false; + // Preserve unclassified legacy orphan behavior, but never promote a + // classified DONE or owner-PARKED summary into a fresh verify assignment. + return !classifiedDirectories.has(phase.dir) || eligibleDirectories.has(phase.dir); + }); } function findTrustGate(manifest) { diff --git a/bin/lib/phase.mjs b/bin/lib/phase.mjs index 48548c5..4cd2ae3 100644 --- a/bin/lib/phase.mjs +++ b/bin/lib/phase.mjs @@ -1152,6 +1152,17 @@ export function cmdPhaseStatus(...args) { return; } const isNative = selection.candidate.authority === 'native'; + const parked = !isNative && lifecycle.phases.find((phase) => phase.number === selection.candidate.phaseToken)?.parked; + if (parked) { + output({ + error: 'owner_parked_phase', + phase: selection.candidate.phaseToken, + identity: selection.candidate.identity, + message: 'Current ROADMAP explicitly PARKS this phase by owner; do not change its lifecycle checkbox without an owner unpark disposition.', + }); + process.exitCode = 1; + return; + } const roadmapPath = isNative ? join(nativeMilestoneDir, 'ROADMAP.md') : join(planningDir, 'ROADMAP.md'); if (!existsSync(roadmapPath)) { console.error('No ROADMAP.md found. Run the new-project workflow first.'); diff --git a/tests/gsdd.parked-authority.test.cjs b/tests/gsdd.parked-authority.test.cjs new file mode 100644 index 0000000..784b549 --- /dev/null +++ b/tests/gsdd.parked-authority.test.cjs @@ -0,0 +1,223 @@ +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { createTempProject, cleanup, runCliAsMain } = require('./gsdd.helpers.cjs'); + +let root; +const work = () => path.join(root, '.work'); +function write(name, value) { + const file = path.join(work(), name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, value); +} +function snapshot() { + return Object.fromEntries(['ROADMAP.md','state.json','.continue-here.md', + 'phases/11-parked/11-01-PLAN.md','phases/11-parked/11-01-SUMMARY.md', + 'phases/11-parked/11-01-VERIFICATION.md','phases/12-current/12-01-PLAN.md', + 'phases/12-current/12-01-SUMMARY.md','phases/16-done/16-01-SUMMARY.md'].map(name => { + const p = path.join(work(), name); + return [name, fs.existsSync(p) ? fs.readFileSync(p).toString('base64') : null]; + })); +} +function roadmap({ parked = true, detailParked = true, otherParked = false } = {}) { + return [ + '# Roadmap', + '- [ ] **Phase 11 (POST-RELAUNCH): Rigor dial final form** - '+(parked ? 'Parked by owner 2026-08-29 until real use.' : 'Regular future work.'), + '- [ ] **Phase 12: Current allowed phase** - '+(otherParked ? 'Parked vehicles are unrelated commentary.' : 'Current work.'), + '- [x] **Phase 16: Historically closed phase** - Closed before this task.', + '', + '## Phase Details', + '### Phase 11: Rigor dial final form', + '**Status**: [ ]'+(detailParked ? ' — PARKED by owner until relaunch.' : ''), + '### Phase 12: Current allowed phase', + '**Status**: [ ]', + '### Phase 16: Historically closed phase', + '**Status**: [x]', + '', + '## Historical narrative', + 'Phase 12 parked status was mentioned in a retrospective; this is not Phase 12 authority.', + '
', + '- [ ] **Phase 12: Archived parked copy** - Parked by owner in old release.', + '### Phase 12: Archived parked copy', + '**Status**: [ ] — PARKED by owner.', + '
', + ].join('\n')+'\n'; +} +function phaseCheckpoint(phase) { + return ['---','workflow: phase',`phase: ${phase}`,'timestamp: 2026-10-09T01:00:00.000Z', + 'runtime: other','---','Captured current phase.', + 'Selected source saved.', + 'Resume only selected authority.', + 'No owner changes.', + 'None known.', + 'Follow selected current phase.'].join('\n')+'\n'; +} +const cmd = (...args) => runCliAsMain(root, [...args, '--json', '--no-update-notice']); + +beforeEach(async () => { + root = createTempProject(); + const module = await import(pathToFileURL(path.join(__dirname, '..', 'bin','lib','work-context.mjs')).href); + module.ensureWorkStructure(root); + write('ROADMAP.md', roadmap()); + write('SPEC.md', '# Valid bounded contract\n'); + write('MILESTONES.md', '# Historical milestone list\n'); + write('phases/11-parked/11-01-PLAN.md', '---\nstatus: approved\n---\n# Plan 11\n'); + write('phases/11-parked/11-01-SUMMARY.md', '# Retained candidate summary\n'); + write('phases/16-done/16-01-SUMMARY.md', '# Retained historical summary\n'); + write('phases/12-current/12-01-PLAN.md', '---\nstatus: approved\n---\n# Plan 12\n'); + write('phases/12-current/12-01-SUMMARY.md', '# Current summary\n'); +}); +afterEach(() => cleanup(root)); + +test('selected owner-PARKED Phase11 cannot receive verify grant or act as ordinary next phase', async () => { + const before = snapshot(); + const refused = await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(refused.exitCode, 1, refused.output); + const result = JSON.parse(refused.output); + assert.equal(result.allowed, false); + assert.equal(result.reason, 'owner_parked_phase'); + assert.deepEqual(snapshot(), before, 'refusal must preserve current source and state'); + const module = await import(pathToFileURL(path.join(__dirname, '..', 'bin','lib','lifecycle-state.mjs')).href); + const lifecycle = module.evaluateLifecycleState({planningDir:work()}); + assert.equal(lifecycle.phases.find(phase => phase.number==='11').parked, true); + assert.equal(lifecycle.phases.find(phase => phase.number==='11').status, 'not_started'); + assert.equal(lifecycle.nextPhase?.number, '12'); +}); + +test('resume grant refuses only an explicitly checkpoint-selected parked phase', async () => { + write('.continue-here.md', phaseCheckpoint('11-parked')); + const before = snapshot(); + const refused = await cmd('lifecycle-preflight','resume'); + assert.equal(refused.exitCode, 1, refused.output); + assert.equal(JSON.parse(refused.output).reason, 'owner_parked_phase'); + assert.deepEqual(snapshot(), before); + write('.continue-here.md', phaseCheckpoint('12-current')); + const valid = await cmd('lifecycle-preflight','resume'); + assert.equal(valid.exitCode, 0, valid.output); + assert.equal(JSON.parse(valid.output).allowed, true); + write('.continue-here.md', phaseCheckpoint('null')); + const unbound = await cmd('lifecycle-preflight','resume'); + assert.equal(unbound.exitCode, 0, unbound.output); +}); + +test('historical closed summary and parked plan do not generate next=verify', async () => { + fs.rmSync(path.join(work(),'phases','12-current','12-01-SUMMARY.md')); + const before = snapshot(); + const next = await cmd('next'); + assert.equal(next.exitCode, 0, next.output); + const body = JSON.parse(next.output); + assert.notEqual(body.state, 'verify', 'historical completed summary and parked candidate must not request verification'); + assert.notEqual(body.next_command, 'work-verify'); + assert.deepEqual(snapshot(), before); +}); + +test('ordinary not_started summary and unrelated archived PARKED prose keep current verification grant', async () => { + write('ROADMAP.md', roadmap({ parked: true, detailParked: true, otherParked: true })); + const before = snapshot(); + const verify = await cmd('lifecycle-preflight','verify','12','--expects-mutation','phase-status'); + assert.equal(verify.exitCode, 0, verify.output); + const next = await cmd('next'); + assert.equal(next.exitCode, 0, next.output); + assert.equal(JSON.parse(next.output).state, 'verify'); + assert.deepEqual(snapshot(), before); +}); + +test('PARKED marker must be attached to the selected phase status grammar, not a roadmap-wide word', async () => { + write('ROADMAP.md', roadmap({ parked:false, detailParked:false, otherParked:true })); + const result = await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode, 0, result.output); + const preflight = JSON.parse(result.output); + assert.equal(preflight.allowed, true); + assert.equal(preflight.phase, '11'); +}); + +test('a single current Phase Details owner-PARKED status refuses while overview remains ordinary not_started', async () => { + write('ROADMAP.md',roadmap({parked:false,detailParked:true})); + const before = snapshot(); + const result = await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,1,result.output); + assert.equal(JSON.parse(result.output).reason,'owner_parked_phase'); + assert.deepEqual(snapshot(),before); +}); + +test('unrelated native milestone phase with same number is not the parked standard phase', async () => { + write('milestone/MILESTONE.md', '# Distinct Workspine-native milestone\n'); + write('milestone/ROADMAP.md', '# Native roadmap\n- [-] **Phase 11: Native active work**\n'); + write('milestone/phases/11-native/11-PLAN.md', '# Native plan\n'); + write('milestone/phases/11-native/11-EXECUTE.md', '# Native execution\n'); + const result = await cmd('lifecycle-preflight','verify','milestone/phases/11-native', + '--expects-mutation','phase-status'); + assert.equal(result.exitCode, 0, result.output); + const preflight = JSON.parse(result.output); + assert.equal(preflight.allowed, true); + assert.equal(preflight.authority, 'work_milestone'); +}); + +test('resume checkpoint naming a different same-number native phase is not the parked standard chain', async () => { + write('milestone/MILESTONE.md', '# Distinct native milestone\n'); + write('milestone/ROADMAP.md', '# Native roadmap\n- [-] **Phase 11: Native active work**\n'); + write('milestone/phases/11-native/11-PLAN.md', '# Current independent plan\n'); + write('.continue-here.md', phaseCheckpoint('11-native')); + const before = snapshot(); + const result = await cmd('lifecycle-preflight','resume'); + assert.equal(result.exitCode,0,result.output); + assert.equal(JSON.parse(result.output).allowed,true); + assert.deepEqual(snapshot(),before); +}); + +test('direct phase-status mutation cannot unpark the phase behind preflight', async () => { + const before = snapshot(); + // phase-status is a mutating legacy command without --json support. + const result = await runCliAsMain(root, ['phase-status','11','in_progress']); + assert.equal(result.exitCode, 1, result.output); + assert.match(result.output, /owner_parked_phase/i); + assert.deepEqual(snapshot(), before); +}); + +test('direct lifecycle verify transition cannot bypass the parked phase gate', async () => { + const plan = '.work/phases/11-parked/11-01-PLAN.md'; + const summary = '.work/phases/11-parked/11-01-SUMMARY.md'; + write('ROADMAP.md', roadmap({parked:false,detailParked:false})); + const planned = await cmd('lifecycle-transition','plan','--plan',plan,'--authority','workflow'); + assert.equal(planned.exitCode, 0, planned.output); + const approved = await cmd('lifecycle-transition','approve','--plan',plan,'--authority','owner','--approval-ref','parked-fixture-owner'); + assert.equal(approved.exitCode, 0, approved.output); + const executed = await cmd('lifecycle-transition','execute','--plan',plan,'--authority','workflow'); + assert.equal(executed.exitCode, 0, executed.output); + write('phases/11-parked/11-01-SUMMARY.md','---\nstatus: complete\n---\n# Prior phase summary\n'); + write('ROADMAP.md', roadmap()); + const before = snapshot(); + const blocked = await cmd('lifecycle-transition','verify','--plan',plan,'--artifact',summary,'--authority','workflow'); + assert.equal(blocked.exitCode, 1, blocked.output); + assert.equal(JSON.parse(blocked.output).error_code,'owner_parked_phase'); + assert.deepEqual(snapshot(),before); +}); + +for (const [target,verificationStatus] of [['audit','passed'],['next','passed'],['fix_gaps','gaps_found']]) { + test(`direct ${target} transition cannot claim new lifecycle authority for owner-PARKED phase`, async () => { + const plan = '.work/phases/11-parked/11-01-PLAN.md'; + const summary = '.work/phases/11-parked/11-01-SUMMARY.md'; + const verification = '.work/phases/11-parked/11-01-VERIFICATION.md'; + write('ROADMAP.md',roadmap({parked:false,detailParked:false})); + for (const [verb,extra] of [ + ['plan',['--authority','workflow']], + ['approve',['--authority','owner','--approval-ref','prior-owner-reference']], + ['execute',['--authority','workflow']], + ]) { + const previous = await cmd('lifecycle-transition',verb,'--plan',plan,...extra); + assert.equal(previous.exitCode,0,previous.output); + } + write('phases/11-parked/11-01-SUMMARY.md','---\nstatus: complete\n---\n# Previously executed\n'); + const verified = await cmd('lifecycle-transition','verify','--plan',plan,'--artifact',summary,'--authority','workflow'); + assert.equal(verified.exitCode,0,verified.output); + write('phases/11-parked/11-01-VERIFICATION.md',`---\nstatus: ${verificationStatus}\n---\n# Prior proof\n`); + write('ROADMAP.md',roadmap()); + const before = snapshot(); + const result = await cmd('lifecycle-transition',target,'--plan',plan,'--artifact',verification,'--authority','workflow'); + assert.equal(result.exitCode,1,result.output); + assert.equal(JSON.parse(result.output).error_code,'owner_parked_phase'); + assert.deepEqual(snapshot(),before); + }); +} From 800a1ef10247ab9de081feffe27d6f93731d720b Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 04:28:33 +0200 Subject: [PATCH 12/19] fix(lifecycle): exclude examples and archives from phase authority --- bin/lib/lifecycle-state.mjs | 63 +++++++++++++++----------- tests/gsdd.parked-authority.test.cjs | 66 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index 0dca37e..cf6a95d 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -109,22 +109,14 @@ function evaluateRoadmapPhaseStatusAlignment(roadmap) { const overview = new Map(); const details = new Map(); const parkedPhaseTokens = new Set(); - const lines = normalizeContent(roadmap).split('\n'); - let inDetails = false; + const lines = activeRoadmapLines(roadmap); let currentDetailPhase = null; + let inPhaseDetails = false; for (const line of lines) { - if (DETAILS_OPEN_RE.test(line) && !DETAILS_CLOSE_RE.test(line)) { - inDetails = true; - currentDetailPhase = null; - continue; - } - if (DETAILS_CLOSE_RE.test(line)) { - inDetails = false; - currentDetailPhase = null; - continue; + if (/^#{1,2}\s+/.test(line)) { + inPhaseDetails = /^##\s+Phase Details\s*(?:#+\s*)?$/i.test(line); } - if (inDetails) continue; const phaseMatch = line.match(PHASE_LINE_RE); if (phaseMatch) { @@ -153,7 +145,7 @@ function evaluateRoadmapPhaseStatusAlignment(roadmap) { const statusMatch = line.match(PHASE_DETAIL_STATUS_RE); if (statusMatch && currentDetailPhase) { details.get(currentDetailPhase).push(normalizePhaseStatus(statusMatch[1])); - if (normalizePhaseStatus(statusMatch[1]) === 'not_started' + if (inPhaseDetails && normalizePhaseStatus(statusMatch[1]) === 'not_started' && isExplicitOwnerParkedStatusSuffix(line.slice(statusMatch[0].length))) { parkedPhaseTokens.add(currentDetailPhase); } @@ -203,6 +195,38 @@ function normalizeContent(content) { return String(content || '').replace(/\r\n/g, '\n'); } +// Phase enumeration and status alignment must read the same current document. +// Fenced examples and nested HTML archives cannot supply lifecycle authority. +function activeRoadmapLines(content) { + const result = []; + let fence = null; + let detailsDepth = 0; + for (const line of normalizeContent(content).split('\n')) { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (marker && marker[1][0] === fence.character && marker[1].length >= fence.length + && !marker[2].trim()) fence = null; + result.push(''); + continue; + } + if (marker && (marker[1][0] !== '`' || !marker[2].includes('`'))) { + fence = { character: marker[1][0], length: marker[1].length }; + result.push(''); + continue; + } + // Archives use block tags. A prose/inline-code mention such as + // "read the `
` example" must not hide the later phase ledger. + const tags = /^ {0,3}<\/?details(?:\s|>)/i.test(line) + ? [...line.matchAll(/<\/?details\b[^>]*>/gi)] : []; + const wasArchived = detailsDepth > 0; + for (const [tag] of tags) { + detailsDepth = /^<\//.test(tag) ? Math.max(0, detailsDepth - 1) : detailsDepth + 1; + } + result.push(wasArchived || detailsDepth > 0 || tags.length ? '' : line); + } + return result; +} + function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -229,18 +253,7 @@ function parseActiveRoadmapPhases(roadmap) { if (!roadmap) return []; const phases = []; - let inDetails = false; - - for (const line of normalizeContent(roadmap).split('\n')) { - if (DETAILS_OPEN_RE.test(line) && !DETAILS_CLOSE_RE.test(line)) { - inDetails = true; - continue; - } - if (DETAILS_CLOSE_RE.test(line)) { - inDetails = false; - continue; - } - if (inDetails) continue; + for (const line of activeRoadmapLines(roadmap)) { const match = line.match(PHASE_LINE_RE); if (!match) continue; diff --git a/tests/gsdd.parked-authority.test.cjs b/tests/gsdd.parked-authority.test.cjs index 784b549..31fc197 100644 --- a/tests/gsdd.parked-authority.test.cjs +++ b/tests/gsdd.parked-authority.test.cjs @@ -221,3 +221,69 @@ for (const [target,verificationStatus] of [['audit','passed'],['next','passed'], assert.deepEqual(snapshot(),before); }); } + +for (const [name, open, close] of [ + ['tilde', '~~~md', '~~~'], + ['backtick', String.fromCharCode(96).repeat(4)+'md', String.fromCharCode(96).repeat(4)], +]) { + test(`fenced ${name} examples cannot supply current phase inventory, status or parking`, async () => { + const body = [roadmap({parked:false,detailParked:false}), '## Examples', open, + '- [ ] **Phase 21: Example only** - Parked by owner.', + '### Phase 11: Example only', '**Status**: [ ] — PARKED by owner.', + '
', String.fromCharCode(96).repeat(3), close].join('\n'); + write('ROADMAP.md',body); + const before=snapshot(); + const result=await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,0,result.output); + const module=await import(pathToFileURL(path.join(__dirname,'..','bin','lib','lifecycle-state.mjs')).href); + const state=module.evaluateLifecycleState({planningDir:work()}); + assert.deepEqual(state.phases.map(p=>p.number),['11','12','16']); + assert.equal(state.phases[0].parked,false); + assert.deepEqual(snapshot(),before); + }); +} + +test('nested archived details remain inactive until the outer close, including same-line nesting', async () => { + for(const inner of ['
Inner note
','
\nInner note\n
']) { + write('ROADMAP.md',[roadmap({parked:false,detailParked:false}), '## Archive', + '
', 'Historical examples',inner, + '- [ ] **Phase 21: Archived phase** - Parked by owner.', + '### Phase 11: Historical only','**Status**: [ ] — PARKED by owner.', + '
'].join('\n')); + const before=snapshot(); + const result=await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,0,result.output); + const module=await import(pathToFileURL(path.join(__dirname,'..','bin','lib','lifecycle-state.mjs')).href); + assert.deepEqual(module.evaluateLifecycleState({planningDir:work()}).phases.map(p=>p.number),['11','12','16']); + assert.deepEqual(snapshot(),before); + } +}); + +test('HTML tags inside a fenced example cannot hide a later real owner parking instruction', async () => { + write('ROADMAP.md',['# Roadmap','~~~md','
','~~~',roadmap()].join('\n')); + const before=snapshot(); + const result=await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,1,result.output); + assert.equal(JSON.parse(result.output).reason,'owner_parked_phase'); + assert.deepEqual(snapshot(),before); +}); + +test('a phase-looking heading outside current Phase Details does not establish owner parking', async () => { + write('ROADMAP.md',['# Roadmap','- [ ] **Phase 11: Current ordinary phase**', + '## Historical example (not current Phase Details)','### Phase 11: Example only', + '**Status**: [ ] — PARKED by owner.'].join('\n')); + const before=snapshot(); + const result=await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,0,result.output); + assert.deepEqual(snapshot(),before); +}); + +test('inline archive-tag examples cannot swallow later current phase authority', async () => { + const prose='Preserve the literal '+String.fromCharCode(96)+'
'+String.fromCharCode(96)+' example as historical text.'; + write('ROADMAP.md',['# Current authority',prose,roadmap()].join('\n')); + const before=snapshot(); + const result=await cmd('lifecycle-preflight','verify','11','--expects-mutation','phase-status'); + assert.equal(result.exitCode,1,result.output); + assert.equal(JSON.parse(result.output).reason,'owner_parked_phase'); + assert.deepEqual(snapshot(),before); +}); From da9e2a1778f6ba5799445fb562db75d8d63c303a Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 05:23:46 +0200 Subject: [PATCH 13/19] Preserve phase discovery through native Windows path aliases --- bin/lib/lifecycle-state.mjs | 20 ++- tests/gsdd.lifecycle-path-alias.test.cjs | 180 +++++++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/gsdd.lifecycle-path-alias.test.cjs diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index cf6a95d..05bd7df 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs'; +import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'fs'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; const BROWNFIELD_CHANGE_DIR = 'brownfield-change'; @@ -410,14 +410,13 @@ function collectPhaseArtifacts(phasesDir) { const declaredRoot = resolve(phasesDir); let phasesRoot; try { + if (hasLinkedPhaseAncestor(declaredRoot)) return []; phasesRoot = canonicalPhasePath(declaredRoot); } catch { return []; } - // A phase root that is itself a link is not the canonical planning root. - // Refuse it rather than allowing recursive discovery to escape the caller's - // state tree through a junction or symlink. - if (!isWithinRoot(declaredRoot, phasesRoot)) return []; + // Windows 8.3 spellings can name this same physical directory without a link. + // Use its canonical identity for containment while retaining logical output paths. const artifacts = []; const walk = (directory, relativeDirectory) => { @@ -445,6 +444,17 @@ function collectPhaseArtifacts(phasesDir) { return artifacts.sort(compareArtifactPaths); } +function hasLinkedPhaseAncestor(candidate) { + // Match workspace-root's ancestor-link policy once per scan, not per artifact. + let current = resolve(candidate); + while (true) { + if (lstatSync(current).isSymbolicLink()) return true; + const parent = dirname(current); + if (parent === current) return false; + current = parent; + } +} + function isWithinRoot(root, candidate) { const candidateRelative = relative(resolve(root), resolve(candidate)); return candidateRelative === '' || (!candidateRelative.startsWith('..') && !isAbsolute(candidateRelative)); diff --git a/tests/gsdd.lifecycle-path-alias.test.cjs b/tests/gsdd.lifecycle-path-alias.test.cjs new file mode 100644 index 0000000..3fb48da --- /dev/null +++ b/tests/gsdd.lifecycle-path-alias.test.cjs @@ -0,0 +1,180 @@ +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const ownedRoots = []; +const canonical = p => fs.realpathSync.native ? fs.realpathSync.native(p) : fs.realpathSync(p); +const modulePath = name => pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', name)).href; +const stateModule = import(modulePath('lifecycle-state.mjs')); +const preflightModule = import(modulePath('lifecycle-preflight.mjs')); +const checkpointModule = import(modulePath('work-context.mjs')); + +function write(root, name, text) { + const target = path.join(root, '.work', name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, text); +} + +function fixture({ originalSpelling = false } = {}) { + const raw = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-path-alias-')); + ownedRoots.push(raw); + const root = originalSpelling ? raw : canonical(raw); + write(root, 'config.json', '{}\n'); + write(root, 'SPEC.md', '# Bounded path fixture\n'); + write(root, 'MILESTONES.md', '# Historical fixture\n'); + write(root, 'ROADMAP.md', [ + '# Roadmap', + '- [ ] **Phase 11: Parked stream** - Parked by owner.', + '- [ ] **Phase 12: Current stream** - Current work.', + '', + '## Phase Details', + '### Phase 11: Parked stream', + '**Status**: [ ] - PARKED by owner.', + '### Phase 12: Current stream', + '**Status**: [ ]', + '', + ].join('\n')); + for (const dir of ['11-parked', '12-current']) { + const number = dir.slice(0, 2); + write(root, 'phases/' + dir + '/' + number + '-01-PLAN.md', '---\nstatus: approved\n---\n# Plan\n'); + write(root, 'phases/' + dir + '/' + number + '-01-SUMMARY.md', '# Summary\n'); + } + return root; +} + +function checkpoint(root) { + write(root, '.continue-here.md', [ + '---', 'workflow: phase', 'phase: 11-parked', + 'timestamp: 2026-10-09T01:00:00.000Z', 'runtime: other', '---', + 'Selected parked phase.', + 'Saved fixture.', + 'Wait for owner.', + 'No change.', + 'Parked.', + 'Preserve owner parking.', '', + ].join('\n')); +} + +async function state(root) { + return (await stateModule).evaluateLifecycleState({ planningDir: path.join(root, '.work') }); +} +const names = value => value.phaseArtifacts.map(artifact => artifact.displayPath); +const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + +function requireNativeAlias(t, root) { + if (process.platform !== 'win32' || root.toLowerCase() === canonical(root).toLowerCase()) { + t.skip('This Windows temporary-root spelling has no observed native alias.'); + return false; + } + assert.equal(fs.lstatSync(root).isSymbolicLink(), false); + assert.equal(fs.statSync(root).ino, fs.statSync(canonical(root)).ino); + return true; +} + +afterEach(() => { + for (const root of ownedRoots.splice(0).reverse()) fs.rmSync(root, { recursive: true, force: true }); +}); + +test('Windows native temporary-root alias retains the same standard phase inventory', async t => { + const root = fixture({ originalSpelling: true }); + if (!requireNativeAlias(t, root)) return; + const aliasState = await state(root); + const canonicalState = await state(canonical(root)); + assert.equal(aliasState.phaseArtifacts.length, 4); + assert.deepEqual(names(aliasState), names(canonicalState)); + assert.ok(aliasState.phaseArtifacts.every(artifact => artifact.path.startsWith(path.resolve(root)))); +}); + +test('valid parked checkpoint stays bound through a Windows native temporary-root alias', async t => { + const root = fixture({ originalSpelling: true }); + if (!requireNativeAlias(t, root)) return; + checkpoint(root); + const planningDir = path.join(root, '.work'); + assert.equal((await checkpointModule).readContinuityCheckpoint(planningDir).status, 'valid'); + const evaluate = (await preflightModule).evaluateLifecyclePreflight; + const parked = evaluate({ planningDir, surface: 'resume' }); + assert.equal(parked.allowed, false); + assert.equal(parked.reason, 'owner_parked_phase'); + const ordinary = evaluate({ planningDir, surface: 'verify', phaseNumber: '12', expectsMutation: 'phase-status' }); + assert.equal(ordinary.allowed, true); +}); + +test('canonical directory discovery retains logical artifact paths and ordinary verify authority', async () => { + const root = fixture(); + const current = await state(root); + assert.equal(current.phaseArtifacts.length, 4); + assert.ok(current.phaseArtifacts.every(artifact => artifact.path.startsWith(path.resolve(root)))); + const result = (await preflightModule).evaluateLifecyclePreflight({ + planningDir: path.join(root, '.work'), surface: 'verify', phaseNumber: '12', expectsMutation: 'phase-status', + }); + assert.equal(result.allowed, true); +}); + +test('a phase root junction cannot supply authority even when its target is a real directory', async () => { + const root = fixture(); + const phases = path.join(root, '.work', 'phases'); + const retained = path.join(root, 'retained-phases'); + const before = fs.readFileSync(path.join(phases, '12-current', '12-01-PLAN.md')); + fs.renameSync(phases, retained); + fs.symlinkSync(retained, phases, linkType); + assert.equal(fs.lstatSync(phases).isSymbolicLink(), true); + assert.deepEqual(names(await state(root)), []); + assert.deepEqual(fs.readFileSync(path.join(retained, '12-current', '12-01-PLAN.md')), before); +}); + +test('a linked planning ancestor cannot supply phase authority', async () => { + const root = fixture(); + const work = path.join(root, '.work'); + const retained = path.join(root, 'retained-work'); + const before = fs.readFileSync(path.join(work, 'phases', '12-current', '12-01-PLAN.md')); + fs.renameSync(work, retained); + fs.symlinkSync(retained, work, linkType); + assert.equal(fs.lstatSync(work).isSymbolicLink(), true); + assert.deepEqual(names(await state(root)), []); + assert.deepEqual(fs.readFileSync(path.join(retained, 'phases', '12-current', '12-01-PLAN.md')), before); +}); + +test('an external nested junction stays excluded while ordinary artifacts remain visible', async () => { + const root = fixture(); + const outside = path.join(root, 'external'); + fs.mkdirSync(outside); + const payload = path.join(outside, '12-02-SUMMARY.md'); + fs.writeFileSync(payload, '# External fixture\n'); + fs.symlinkSync(outside, path.join(root, '.work', 'phases', '12-current', 'external'), linkType); + const current = await state(root); + assert.equal(current.phaseArtifacts.length, 4); + assert.ok(names(current).every(name => !name.includes('external'))); + assert.equal(fs.readFileSync(payload, 'utf8'), '# External fixture\n'); +}); + +test('a linked phase file remains excluded without hiding ordinary artifacts', async () => { + const root = fixture(); + const outside = path.join(root, '12-02-PLAN.md'); + fs.writeFileSync(outside, '---\nstatus: approved\n---\n# External plan\n'); + fs.symlinkSync(outside, path.join(root, '.work', 'phases', '12-current', '12-02-PLAN.md'), 'file'); + const current = await state(root); + assert.equal(current.phaseArtifacts.length, 4); + assert.ok(names(current).every(name => !name.includes('12-02'))); + assert.equal(fs.readFileSync(outside, 'utf8'), '---\nstatus: approved\n---\n# External plan\n'); +}); + +test('a nested junction to an internal phase cannot duplicate its authority', async () => { + const root = fixture(); + const phases = path.join(root, '.work', 'phases'); + fs.symlinkSync(path.join(phases, '12-current'), path.join(phases, '11-parked', 'same-target'), linkType); + const current = await state(root); + assert.equal(current.phaseArtifacts.length, 4); + assert.ok(names(current).every(name => !name.includes('same-target'))); +}); + +test('Windows directory case aliases retain inventory without rewriting emitted paths', { skip: process.platform !== 'win32' }, async () => { + const root = fixture(); + const upper = root.toUpperCase(); + const current = await state(upper); + assert.equal(current.phaseArtifacts.length, 4); + assert.ok(current.phaseArtifacts.every(artifact => artifact.path.startsWith(path.resolve(upper)))); +}); + From 958eebe2698fb854b353ad251d7c09dd9de1e183 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 05:39:31 +0200 Subject: [PATCH 14/19] Reuse one fresh lifecycle observation per next command --- bin/lib/control-map.mjs | 17 +- bin/lib/next.mjs | 45 +++- bin/lib/work-context.mjs | 17 +- .../gsdd.next-lifecycle-observation.test.cjs | 197 ++++++++++++++++++ 4 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 tests/gsdd.next-lifecycle-observation.test.cjs diff --git a/bin/lib/control-map.mjs b/bin/lib/control-map.mjs index 374414f..62835f1 100644 --- a/bin/lib/control-map.mjs +++ b/bin/lib/control-map.mjs @@ -558,8 +558,18 @@ function reconcileAnnotations(worktrees, annotations) { }; } -function classifyWorkflowState(planningDir) { - const lifecycle = evaluateLifecycleState({ planningDir }); +function classifyWorkflowState(planningDir, { lifecycleObservation = null, expectedWorkspaceRoot = resolve(planningDir, '..') } = {}) { + // A transient observation belongs to exactly one workspace and state root. + // An explicitly supplied mismatched observation is an error, never a hint + // to reuse another checkout's authority or skip its phase evidence. + if (lifecycleObservation && ( + lifecycleObservation.workspaceRoot !== resolve(expectedWorkspaceRoot) + || lifecycleObservation.planningDir !== resolve(planningDir) + || !lifecycleObservation.lifecycle + )) { + throw new Error('Lifecycle observation workspace root or planning directory mismatch'); + } + const lifecycle = lifecycleObservation?.lifecycle || evaluateLifecycleState({ planningDir }); const checkpointPath = join(planningDir, '.continue-here.md'); const workspaceRoot = resolve(planningDir, '..'); const milestoneVersion = lifecycle.currentMilestone?.version || null; @@ -798,6 +808,7 @@ export function buildControlMap({ planningDir, annotationPath = null, includeIgnoredPaths = false, + lifecycleObservation = null, now = new Date(), } = {}) { const root = resolve(workspaceRoot || process.cwd()); @@ -810,7 +821,7 @@ export function buildControlMap({ const canonical = reconciled.worktrees.find((entry) => resolve(entry.path) === root) || reconciled.worktrees[0] || readGitWorktree(root, root, { includeIgnoredCount: true }); - const workflowState = classifyWorkflowState(planning); + const workflowState = classifyWorkflowState(planning, { lifecycleObservation, expectedWorkspaceRoot: root }); const runtimeDirs = discoverRuntimeDirs(root); const annotationReport = { path: annotations.path, diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index fb9d31f..c74a2dc 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -144,12 +144,13 @@ function userQuestionAction(questionIds, description) { }; } -function summarizeControlMap(cwd) { +function summarizeControlMap(cwd, lifecycleObservation = null) { try { const stateDir = resolveStateDir(cwd).dir; return buildControlMap({ workspaceRoot: cwd, planningDir: stateDir, + lifecycleObservation, }); } catch (error) { return { @@ -372,9 +373,11 @@ function routeFromStateObject(stateValue, workDir) { return null; } -function routeNext(ctx) { - const context = inspectWorkContext(ctx.cwd); - const controlMap = summarizeControlMap(ctx.cwd); +function routeNext(ctx, observedContext = null) { + const context = observedContext + ? assertNextObservationContext(ctx.cwd, observedContext) + : inspectWorkContext(ctx.cwd, { includeLifecycleObservation: true }); + const controlMap = summarizeControlMap(ctx.cwd, context._lifecycleObservation); const inputsConsidered = ['repo truth: control-map']; const inputsSkipped = []; const traceRefs = []; @@ -407,7 +410,7 @@ function routeNext(ctx) { privacy_notes: privacyNotes, inputs_considered: inputsConsidered, inputs_skipped: ['.work files: missing'], - })); + }), context); } inputsConsidered.push('.work/goal.md'); @@ -820,7 +823,12 @@ function readLifecycleTruth(cwd, context) { const malformed = []; let lifecycle = null; try { - lifecycle = evaluateLifecycleState({ planningDir }); + const observation = context?._lifecycleObservation; + if (observation && (observation.workspaceRoot !== resolve(cwd) + || observation.planningDir !== resolve(planningDir))) { + throw new Error('Lifecycle observation workspace root or planning directory mismatch'); + } + lifecycle = observation?.lifecycle || evaluateLifecycleState({ planningDir }); malformed.push(...(lifecycle.malformedPlanFrontmatter || [])); } catch (error) { if (!isMalformedPlanFrontmatterError(error)) throw error; @@ -971,13 +979,29 @@ function hasDecisionsDigestSignal(digest) { || digest.truncated; } -function projectDecisionsDigest(ctx, route) { +function projectDecisionsDigest(ctx, route, observedContext = null) { const decisionsDigest = buildDecisionsDigest({ workDir: getWorkPaths(ctx.cwd).workDir }); const inputsConsidered = hasDecisionsDigestSignal(decisionsDigest) && !route.inputs_considered.includes('.work/decisions/*.md') ? [...route.inputs_considered, '.work/decisions/*.md'] : route.inputs_considered; const projectedRoute = { ...route, inputs_considered: inputsConsidered, decisionsDigest }; - return { ...projectedRoute, continuity: continuityProjection(inspectWorkContext(ctx.cwd), projectedRoute) }; + const context = observedContext + ? assertNextObservationContext(ctx.cwd, observedContext) + : inspectWorkContext(ctx.cwd); + return { ...projectedRoute, continuity: continuityProjection(context, projectedRoute) }; +} + +function assertNextObservationContext(cwd, context) { + const observation = context?._lifecycleObservation; + const workspaceRoot = resolve(cwd); + const planningDir = resolve(resolveStateDir(workspaceRoot).dir); + if (!observation || resolve(context.paths?.root || '') !== workspaceRoot + || observation.workspaceRoot !== workspaceRoot + || observation.planningDir !== planningDir + || !observation.lifecycle) { + throw new Error('Lifecycle observation workspace root or planning directory mismatch'); + } + return context; } function routeFromWorkMilestone(context, manifest) { @@ -1419,7 +1443,10 @@ export function createCmdNext(ctx) { return; } - const result = projectDecisionsDigest(effectiveCtx, routeNext(effectiveCtx)); + // One fresh filesystem observation per read-only invocation. Each + // subsequent command call rereads current authority and phase evidence. + const observedContext = inspectWorkContext(effectiveCtx.cwd, { includeLifecycleObservation: true }); + const result = projectDecisionsDigest(effectiveCtx, routeNext(effectiveCtx, observedContext), observedContext); const adopted = readJsonIfExists(join(workPaths.workDir, 'adoption-receipt.json')); if (adopted.exists && adopted.ok && adopted.value?.signature === 'GSD-roadmap-v1' diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 3e26eac..adb49c9 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -2205,7 +2205,7 @@ export function readContinuityCheckpoint(planningDir) { }; } -export function inspectWorkContext(cwd = process.cwd()) { +export function inspectWorkContext(cwd = process.cwd(), { includeLifecycleObservation = false } = {}) { const paths = getWorkPaths(cwd); const state = readJsonIfExists(paths.state); const questions = readOpenQuestions(paths.workDir); @@ -2229,7 +2229,7 @@ export function inspectWorkContext(cwd = process.cwd()) { phases: scanPhaseEvidence(planningDir, lifecycle), state_dir_name: stateDirName, }; - return { + const context = { paths, exists: existsSync(paths.workDir), has_goal: existsSync(paths.goal), @@ -2249,6 +2249,19 @@ export function inspectWorkContext(cwd = process.cwd()) { decisions: listMarkdownFiles(join(paths.workDir, 'decisions')), dogfood: listMarkdownFiles(join(paths.workDir, 'dogfood')), }; + // Opt-in, invocation-local reuse for read-only consumers. Keep the entire + // evaluated lifecycle off the enumerable/public JSON context surface. + if (includeLifecycleObservation) { + Object.defineProperty(context, '_lifecycleObservation', { + value: Object.freeze({ + workspaceRoot: resolve(paths.root), + planningDir: resolve(planningDir), + lifecycle, + }), + enumerable: false, + }); + } + return context; } function listMarkdownFiles(dir) { diff --git a/tests/gsdd.next-lifecycle-observation.test.cjs b/tests/gsdd.next-lifecycle-observation.test.cjs new file mode 100644 index 0000000..1c58255 --- /dev/null +++ b/tests/gsdd.next-lifecycle-observation.test.cjs @@ -0,0 +1,197 @@ +/** + * One per-invocation lifecycle observation across next/control-map/continuity. + * Uses native synthetic repositories; never reads an actual Workspine .work. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { syncBuiltinESMExports } = require('node:module'); +const { pathToFileURL } = require('node:url'); + +const root = path.resolve(__dirname, '..'); +const fixtureParent = process.env.WORKSPINE_NEXT_LATENCY_TEST_ROOT || os.tmpdir(); +let fixture = null; + +function createFixture() { + const cwd = fs.mkdtempSync(path.join(fixtureParent, 'gsdd-next-lifecycle-')); + const work = path.join(cwd, '.work'); + fs.mkdirSync(path.join(work, 'phases', '11-rigor-final'), { recursive: true }); + fs.mkdirSync(path.join(work, 'phases', '16-historical'), { recursive: true }); + fs.writeFileSync(path.join(work, 'goal.md'), '# Synthetic current Workspine goal\n'); + fs.writeFileSync(path.join(work, 'SPEC.md'), '# Synthetic requirements\n'); + fs.writeFileSync(path.join(work, 'state.json'), JSON.stringify({ + schema_version: 1, + status: 'active', + current_state: 'plan', + workflow: { current_state: 'plan' }, + })); + fs.writeFileSync(path.join(work, 'ROADMAP.md'), [ + '# Synthetic roadmap', + '', + '## Phases', + '- [ ] **Phase 01: Active work** - Not started.', + '- [ ] **Phase 11: Rigor final** - Parked by owner until after relaunch.', + '- [x] **Phase 16: Historical proof** - Closed previously.', + '', + '### Phase 01: Active work', + '', + '**Status**: [ ]', + '', + '### Phase 11: Rigor final', + '', + '**Status**: [ ] — PARKED by owner until after relaunch.', + '', + '### Phase 16: Historical proof', + '', + '**Status**: [x]', + '', + ].join('\n')); + fs.writeFileSync(path.join(work, 'phases', '11-rigor-final', '11-01-PLAN.md'), + '---\nphase: 11-rigor-final\nstatus: planned\n---\n# Historical plan\n'); + fs.writeFileSync(path.join(work, 'phases', '11-rigor-final', '11-01-SUMMARY.md'), + '---\nstatus: complete\n---\n# Historical summary\n'); + fs.writeFileSync(path.join(work, 'phases', '11-rigor-final', '11-01-VERIFICATION.md'), + '---\nstatus: passed\n---\n# Historical candidate verification\n'); + fs.writeFileSync(path.join(work, 'phases', '16-historical', '16-12-SUMMARY.md'), + '# Retained historical orphan evidence; never an active verify grant\n'); + for (let index = 0; index < 40; index += 1) { + const nested = path.join(work, 'phases', '16-historical', 'evidence', + `batch-${Math.floor(index / 10)}`, `run-${index}`); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, '16-01-RECEIPT.md'), '# retained evidence\n'); + } + execFileSync('git', ['init', '-q'], { cwd, stdio: 'pipe' }); + return cwd; +} + +async function load(relative) { + return import(pathToFileURL(path.join(root, relative)).href); +} + +function captureNext(handler) { + const beforeLog = console.log; + const beforeWrite = process.stdout.write; + const beforeExitCode = process.exitCode; + let output = ''; + try { + console.log = (...parts) => { output += parts.join(' ') + '\n'; }; + process.stdout.write = (chunk) => { output += String(chunk); return true; }; + process.exitCode = undefined; + handler('--json'); + assert.equal(process.exitCode ?? 0, 0, output); + } finally { + console.log = beforeLog; + process.stdout.write = beforeWrite; + process.exitCode = beforeExitCode; + } + return JSON.parse(output.trim()); +} + +test('next shares exactly one bounded, non-serialized lifecycle observation and rereads changed authority', async () => { + fixture = createFixture(); + try { + const work = await load('bin/lib/work-context.mjs'); + const controls = await load('bin/lib/control-map.mjs'); + const { createCmdNext } = await load('bin/lib/next.mjs'); + const ordinary = work.inspectWorkContext(fixture); + const requested = work.inspectWorkContext(fixture, { includeLifecycleObservation: true }); + assert.equal(Object.hasOwn(ordinary, '_lifecycleObservation'), false); + assert.equal(Object.getOwnPropertyDescriptor(requested, '_lifecycleObservation').enumerable, false); + assert.equal(Object.keys(requested).includes('_lifecycleObservation'), false); + assert.equal(JSON.stringify(ordinary), JSON.stringify(requested)); + assert.equal(JSON.stringify(requested).includes('_lifecycleObservation'), false); + assert.equal(requested._lifecycleObservation.workspaceRoot, fixture); + assert.equal(requested._lifecycleObservation.planningDir, path.join(fixture, '.work')); + + const plainMap = controls.buildControlMap({ + workspaceRoot: fixture, planningDir: path.join(fixture, '.work'), + }); + const observedMap = controls.buildControlMap({ + workspaceRoot: fixture, planningDir: path.join(fixture, '.work'), + lifecycleObservation: requested._lifecycleObservation, + }); + // generated_at is intentionally volatile; operational fields must match. + assert.equal(JSON.stringify({ ...plainMap, generated_at: null }), + JSON.stringify({ ...observedMap, generated_at: null })); + + const unrelated = fs.mkdtempSync(path.join(fixtureParent, 'gsdd-next-other-root-')); + try { + const legacyIndependentPlanning = controls.buildControlMap({ + workspaceRoot: fixture, + planningDir: path.join(unrelated, '.work'), + }); + assert.equal(legacyIndependentPlanning.workflow_state.checkpoint.path, + '.work/.continue-here.md', + 'default control-map checkpoint path remains planningDir-relative'); + assert.throws(() => controls.buildControlMap({ + workspaceRoot: unrelated, + planningDir: path.join(unrelated, '.work'), + lifecycleObservation: requested._lifecycleObservation, + }), /observation workspace root or planning directory mismatch/); + assert.throws(() => controls.buildControlMap({ + workspaceRoot: fixture, + planningDir: path.join(fixture, '.work-other'), + lifecycleObservation: requested._lifecycleObservation, + }), /observation workspace root or planning directory mismatch/); + } finally { + fs.rmSync(unrelated, { recursive: true, force: true }); + } + + // Count exactly the phase root's withFileTypes enumeration. Other + // non-lifecycle directory metadata calls are deliberately not counted. + const originalReadDir = fs.readdirSync; + let scannedPhases = 0; + const phasesDir = path.join(fixture, '.work', 'phases'); + fs.readdirSync = function (file, options) { + if (path.resolve(String(file)) === phasesDir && options?.withFileTypes === true) { + scannedPhases += 1; + } + return originalReadDir.apply(this, arguments); + }; + syncBuiltinESMExports(); + const handler = createCmdNext({ cwd: fixture }); + let first; + try { + first = captureNext(handler); + } finally { + fs.readdirSync = originalReadDir; + syncBuiltinESMExports(); + } + assert.equal(scannedPhases, 1, 'each next invocation should scan phases once'); + assert.equal(first.continuity.work_identity.next_phase, '1'); + assert.equal(first.state, 'plan'); + assert.equal(first.reason.includes('verification'), false, + 'historical Phase 16 orphan is not current verification authority'); + + const roadmap = path.join(fixture, '.work', 'ROADMAP.md'); + const source = fs.readFileSync(roadmap); + try { + const revised = source.toString('utf8') + .replace('- [ ] **Phase 01:', '- [x] **Phase 01:') + .replace('**Status**: [ ]\n', '**Status**: [x]\n'); + assert.notEqual(revised, source.toString('utf8')); + fs.writeFileSync(roadmap, revised); + const next = captureNext(handler); // same handler must read fresh files + assert.equal(next.continuity.work_identity.next_phase, null); + assert.equal(next.continuity.work_identity.current_phase, null); + assert.equal(next.state, 'plan'); + } finally { + fs.writeFileSync(roadmap, source); + } + assert.deepEqual(fs.readFileSync(roadmap), source, 'fixture authority restored exactly'); + + const { evaluateLifecyclePreflight } = await load('bin/lib/lifecycle-preflight.mjs'); + const verify = evaluateLifecyclePreflight({ + planningDir: path.join(fixture, '.work'), + surface: 'verify', + phaseNumber: '11', + }); + assert.equal(verify.allowed, false); + assert.ok(verify.blockers.some((blocker) => blocker.code === 'owner_parked_phase')); + } finally { + if (fixture) fs.rmSync(fixture, { recursive: true, force: true }); + } +}); From a0f71ff529a22f6d05483d4dcc694f4df01722dc Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 9 Oct 2026 13:33:03 +0200 Subject: [PATCH 15/19] fix: preserve explicit authority across parallel milestones Keep explicit root amendments available beside native milestones, resolve phase mutations only within the selected owner, and expose mixed-plan conflicts as inactive. Independently verified exact six-file R3 slice with installed consumer checks and reversible application evidence. --- bin/lib/journey.mjs | 21 ++++++- bin/lib/lifecycle-preflight.mjs | 40 +++++++++++--- bin/lib/next.mjs | 12 ++++ bin/lib/phase.mjs | 24 ++++++-- bin/lib/work-context.mjs | 92 +++++++++++++++++++++++++++++-- tests/gsdd.milestone-dir.test.cjs | 89 +++++++++++++++++++++++++++++- 6 files changed, 254 insertions(+), 24 deletions(-) diff --git a/bin/lib/journey.mjs b/bin/lib/journey.mjs index 4c8cd38..c52c3b2 100644 --- a/bin/lib/journey.mjs +++ b/bin/lib/journey.mjs @@ -78,7 +78,7 @@ function listMilestoneDirs(stateDir, resolvedDir = resolveActiveMilestoneDir(sta } } - if (existsSync(join(resolvedDir, 'MILESTONE.md')) && !dirs.includes(resolvedDir)) { + if (resolvedDir && existsSync(join(resolvedDir, 'MILESTONE.md')) && !dirs.includes(resolvedDir)) { dirs.push(resolvedDir); } @@ -304,7 +304,14 @@ export function collectJourney({ cwd = process.cwd() } = {}) { assertStateAuthority(state); const { dir: stateDir } = resolveStateDir(workspaceRoot); const decisionWorkDir = getWorkPaths(workspaceRoot).workDir; - const resolvedActiveDir = resolveActiveMilestoneDir(stateDir); + let resolvedActiveDir = null; + let authorityConflict = null; + try { + resolvedActiveDir = resolveActiveMilestoneDir(stateDir); + } catch (error) { + if (error.code !== 'multiple_active_milestones') throw error; + authorityConflict = { code: error.code, choices: error.choices || [], message: error.message }; + } const milestoneDirs = listMilestoneDirs(stateDir, resolvedActiveDir); const milestones = milestoneDirs.map((milestoneDir) => readMilestone(milestoneDir, workspaceRoot, stateDir)); const activeIndex = resolvedActiveDir ? milestones.findIndex((milestone) => milestone.path === resolvedActiveDir) : -1; @@ -327,10 +334,13 @@ export function collectJourney({ cwd = process.cwd() } = {}) { ...(choices ? { choices } : {}), ...(plans ? { plans } : {}), })), - active: true, + // A conflicting native selection has no unique "you are here", + // including the root row. Keep its phases and identity visible. + active: !authorityConflict, }] : []; const journey = { + ...(authorityConflict ? { authority_conflict: authorityConflict } : {}), milestones: [ ...rootRoadmap, ...milestones.map((milestone, index) => ({ @@ -357,6 +367,9 @@ export function renderJourney(journey, { now = new Date() } = {}) { ` WORKSPINE JOURNEY ${date.slice(0, 10)}`, ' ==========================================================', ]; + if (journey.authority_conflict) { + lines.push(` BLOCKED: ${asciiText(journey.authority_conflict.message)}`); + } for (const milestone of journey.milestones) { const here = milestone.active ? ' <- you are here' : ''; const name = asciiText(milestone.name); @@ -385,8 +398,10 @@ export function createCmdJourney(context = {}) { const journey = collectJourney({ cwd: context.cwd || process.cwd() }); if (jsonFlag.present) { output(journey); + if (journey.authority_conflict) process.exitCode = 1; return; } console.log(renderJourney(journey)); + if (journey.authority_conflict) process.exitCode = 1; }; } diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index f94098f..cb73db8 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -115,9 +115,16 @@ export function evaluateLifecyclePreflight({ const lifecycle = evaluateLifecycleState({ planningDir }); const workspaceRoot = resolve(planningDir, '..'); const decisionWorkDir = getWorkPaths(workspaceRoot).workDir; - const nativeMilestoneDir = resolveActiveMilestoneDir(join(workspaceRoot, '.work')); + // "plan amend" always targets the root ROADMAP, even when unrelated native + // milestones are active. Classify this exact lane before resolving a native + // milestone; explicit PLAN selectors still take the ordinary guarded route. + const rootAmendLane = surface === 'plan' && normalizePhaseToken(phaseNumber) === 'amend' + && !planPath && !planFlagPresent; + const nativeMilestoneDir = rootAmendLane + ? join(decisionWorkDir, 'milestone') + : resolveActiveMilestoneDir(join(workspaceRoot, '.work'), { phaseSelector: phaseNumber, planSelector: planPath, rootLifecycle: lifecycle }); const nativePhasesDir = join(nativeMilestoneDir, 'phases'); - const workMilestoneAuthority = inspectWorkMilestone(decisionWorkDir); + const workMilestoneAuthority = inspectWorkMilestone(decisionWorkDir, { selectedMilestoneDir: nativeMilestoneDir }); const nativeIdentityPrefix = relative(planningDir, nativePhasesDir).replace(/\\/g, '/'); const malformedPlanFrontmatter = []; const selection = phaseNumber ? resolveLifecyclePhaseSelection({ lifecycle, workspaceRoot, nativePhasesDir, nativeIdentityPrefix, selector: phaseNumber, malformedPlanFrontmatter }) : null; @@ -136,13 +143,13 @@ export function evaluateLifecyclePreflight({ ? readContinuityCheckpoint(planningDir) : null; const resumeWorkCheckpoint = surface === 'resume' - ? evaluateResumeWorkCheckpoint({ planningDir, checkpointPath, checkpoint: continuityCheckpoint }) + ? evaluateResumeWorkCheckpoint({ planningDir, checkpointPath, checkpoint: continuityCheckpoint, selectedMilestoneDir: nativeMilestoneDir }) : null; const planSelection = planPath ? resolveLifecyclePlanSelection({ lifecycle, workspaceRoot, nativePhasesDir, nativeIdentityPrefix, planPath, phaseSelection: selection, malformedPlanFrontmatter }) : null; let workMilestone = normalizedPhase && (usesNativeAuthority || selection?.status !== 'selected') - ? evaluateWorkMilestoneState({ planningDir, phaseToken: normalizedPhase }) + ? evaluateWorkMilestoneState({ planningDir, phaseToken: normalizedPhase, nativeMilestoneDir }) : null; if (usesNativeAuthority && workMilestone) { const selectedChain = planSelection?.status === 'selected' ? planSelection.plan.chainKey : null; @@ -158,6 +165,18 @@ export function evaluateLifecyclePreflight({ const milestonesPath = join(planningDir, 'MILESTONES.md'); const blockers = []; + // Root ROADMAP amendment and a native milestone PLAN are different + // authorities. An explicit native PLAN cannot retarget "plan amend". + const nativePlanUnderAmend = usesPlanAmendAuthority + && /^(?:\.work\/|\.planning\/)?milestones\//i.test(String(planPath || '').replace(/\\/g, '/')); + if (nativePlanUnderAmend) { + blockers.push(blocker( + 'conflicting_plan_amend_scope', + 'Root plan amendment targets ROADMAP.md; a native milestone PLAN cannot be combined with amend.', + [stateLabel('ROADMAP.md'), planPath], + )); + } + const selectedPhase = normalizedPhase && !usesBrownfieldAuthority && !usesPlanAmendAuthority && !usesNativeAuthority ? lifecycle.phases.find((phase) => phase.number === normalizedPhase) : surface === 'resume' && continuityCheckpoint?.status === 'valid' @@ -633,9 +652,9 @@ function buildPhaseBlockers({ lifecycle, phaseToken, surface, stateLabel, select return blockers; } -function evaluateWorkMilestoneState({ planningDir, phaseToken }) { +function evaluateWorkMilestoneState({ planningDir, phaseToken, nativeMilestoneDir = null }) { const workspaceRoot = resolve(planningDir, '..'); - const milestoneDir = resolveActiveMilestoneDir(join(workspaceRoot, '.work')); + const milestoneDir = nativeMilestoneDir || resolveActiveMilestoneDir(join(workspaceRoot, '.work')); const roadmapPath = join(milestoneDir, 'ROADMAP.md'); const phasesDir = join(milestoneDir, 'phases'); @@ -732,11 +751,11 @@ function parseWorkPhaseStatus(rawStatus) { return 'pending'; } -function evaluateResumeWorkCheckpoint({ planningDir, checkpointPath, checkpoint }) { +function evaluateResumeWorkCheckpoint({ planningDir, checkpointPath, checkpoint, selectedMilestoneDir = null }) { if (!existsSync(checkpointPath) || checkpoint?.status !== 'valid') return null; const workspaceRoot = resolve(planningDir, '..'); - const milestoneDir = resolveActiveMilestoneDir(join(workspaceRoot, '.work')); + const milestoneDir = selectedMilestoneDir || resolveActiveMilestoneDir(join(workspaceRoot, '.work')); const roadmapPath = join(milestoneDir, 'ROADMAP.md'); if (!existsSync(roadmapPath)) return null; @@ -1084,7 +1103,10 @@ export function cmdLifecyclePreflight(...args) { process.exitCode = 1; } } catch (error) { - console.error(error.message); + if (error.code === 'multiple_active_milestones' || error.code === 'ambiguous_phase_selector' || error.code === 'inactive_milestone_selector' || error.code === 'conflicting_milestone_selectors') { + output({ surface, phase: phaseNumber, status: 'blocked', allowed: false, reason: error.code, + blockers: [blocker(error.code, error.message, error.choices || [])] }); + } else console.error(error.message); process.exitCode = 1; } } diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index c74a2dc..7babc5b 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -1464,6 +1464,18 @@ export function createCmdNext(ctx) { if (jsonMode) output(result); else printHuman(result); } catch (error) { + if (error.code === 'multiple_active_milestones') { + const response = packet({ + state: 'blocked', confidence: 'high', authority: 'blocked', route_kind: 'authority_conflict', + reason: error.message, error_code: error.code, blocked_by: [error.code], + next_action: manualReviewAction(error.choices || [], 'Select one emitted milestone phase or PLAN identity before acting.'), + artifacts_to_read: error.choices || [], artifacts_to_write: [], + }); + if (jsonMode) output(response); + else printHuman(response); + process.exitCode = 1; + return; + } const response = { schema_version: 1, operation: 'next', diff --git a/bin/lib/phase.mjs b/bin/lib/phase.mjs index 4cd2ae3..31e1e64 100644 --- a/bin/lib/phase.mjs +++ b/bin/lib/phase.mjs @@ -1132,7 +1132,7 @@ export function cmdPhaseStatus(...args) { const lifecycle = evaluateLifecycleState({ planningDir }); const workspaceRoot = resolve(planningDir, '..'); const workDir = getWorkPaths(workspaceRoot).workDir; - const nativeMilestoneDir = resolveActiveMilestoneDir(workDir); + const nativeMilestoneDir = resolveActiveMilestoneDir(workDir, { phaseSelector: phaseNumber, rootLifecycle: lifecycle }); const nativePhasesDir = join(nativeMilestoneDir, 'phases'); const nativeIdentityPrefix = relative(planningDir, nativePhasesDir).replace(/\\/g, '/'); const selection = resolveLifecyclePhaseSelection({ @@ -1205,7 +1205,9 @@ export function cmdPhaseStatus(...args) { changed, }); } catch (error) { - console.error(error.message); + if (error.code === 'multiple_active_milestones' || error.code === 'ambiguous_phase_selector' || error.code === 'inactive_milestone_selector' || error.code === 'conflicting_milestone_selectors') { + output({ error: error.code, choices: error.choices || [], message: error.message }); + } else console.error(error.message); process.exitCode = 1; } } @@ -1232,7 +1234,15 @@ export function cmdFindPhase(...args) { const lifecycle = evaluateLifecycleState({ planningDir }); if (phaseNum) { - const nativeMilestoneDir = resolveActiveMilestoneDir(getWorkPaths(workspaceRoot).workDir); + let nativeMilestoneDir; + try { + nativeMilestoneDir = resolveActiveMilestoneDir(getWorkPaths(workspaceRoot).workDir, { phaseSelector: phaseNum, rootLifecycle: lifecycle }); + } catch (milestoneError) { + if (!['multiple_active_milestones', 'ambiguous_phase_selector', 'inactive_milestone_selector', 'conflicting_milestone_selectors'].includes(milestoneError.code)) throw milestoneError; + output({ error: milestoneError.code, choices: milestoneError.choices || [], message: milestoneError.message }); + process.exitCode = 1; + return; + } const nativePhasesDir = join(nativeMilestoneDir, 'phases'); const nativeIdentityPrefix = relative(planningDir, nativePhasesDir).replace(/\\/g, '/'); const selection = resolveLifecyclePhaseSelection({ lifecycle, workspaceRoot, nativePhasesDir, nativeIdentityPrefix, selector: phaseNum }); @@ -1316,7 +1326,13 @@ export function buildPhaseVerificationReport(...args) { if (planFlagPresent && (duplicatePlanFlag || !requestedPlan || requestedPlan.startsWith('--'))) { return { ok: true, result: { error: 'invalid_plan_selector', phase: phaseNum, plan: requestedPlan }, exitCode: 1 }; } - const nativeMilestoneDir = resolveActiveMilestoneDir(getWorkPaths(workspaceRoot).workDir); + let nativeMilestoneDir; + try { + nativeMilestoneDir = resolveActiveMilestoneDir(getWorkPaths(workspaceRoot).workDir, { phaseSelector: phaseNum, planSelector: requestedPlan, rootLifecycle: lifecycle }); + } catch (milestoneError) { + if (!['multiple_active_milestones', 'ambiguous_phase_selector', 'inactive_milestone_selector', 'conflicting_milestone_selectors'].includes(milestoneError.code)) throw milestoneError; + return { ok: true, result: { error: milestoneError.code, choices: milestoneError.choices || [], message: milestoneError.message }, exitCode: 1 }; + } const nativePhasesDir = join(nativeMilestoneDir, 'phases'); const nativeIdentityPrefix = relative(planningDir, nativePhasesDir).replace(/\\/g, '/'); let phaseSelection = resolveLifecyclePhaseSelection({ diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index adb49c9..99b3021 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -14,7 +14,7 @@ import { import { execFileSync } from 'child_process'; import { createHash, randomBytes } from 'crypto'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; -import { collectNativePhaseArtifacts, evaluateLifecycleState, partitionPlanChains, readPlanStatus } from './lifecycle-state.mjs'; +import { collectNativePhaseArtifacts, evaluateLifecycleState, normalizePhaseToken, partitionPlanChains, readPlanStatus, resolveStandardPhaseSelection } from './lifecycle-state.mjs'; import { resolveStateDir, stateAuthorityGate, STATE_DIR_NAME } from './state-dir.mjs'; import { writeFileAtomic as replaceFileAtomically } from './atomic-write.mjs'; @@ -2271,7 +2271,33 @@ function listMarkdownFiles(dir) { .map((name) => normalizeSlashes(join(basename(dir), name))); } -export function resolveActiveMilestoneDir(workDir) { +// A saved milestone is a separate authority. Never infer which of two simultaneously +// active authorities a bare phase number means from their folder names. +function milestoneStatusIsActive(content) { + const text = String(content || '').replace(/\r\n/g, '\n'); + if (text.startsWith('---\n')) { + const end = text.indexOf('\n---', 4); + if (end === -1) return false; + return /^status:\s*in_progress\b/im.test(text.slice(4, end)); + } + // Match the existing no-frontmatter milestone convention, including a + // heading above status; only closed frontmatter has distinct body authority. + return /^status:\s*in_progress\b/im.test(text); +} + +function milestoneScopeSlug(value) { + const selector = String(value || '').trim().replace(/\\/g, '/').replace(/^\.work\//, ''); + return selector.match(/^milestones\/([^/]+)\/phases\/[^/]+(?:\/[^/]+)?$/)?.[1] || null; +} + +function milestoneAuthorityError(code, message, names) { + const error = new Error(message); + error.code = code; + error.choices = names.map((name) => `.work/milestones/${name}`); + return error; +} + +export function resolveActiveMilestoneDir(workDir, { phaseSelector = null, planSelector = null, rootLifecycle = null } = {}) { const pluralDir = join(workDir, 'milestones'); if (existsSync(pluralDir)) { let candidates = []; @@ -2293,8 +2319,64 @@ export function resolveActiveMilestoneDir(workDir) { if (candidates.length > 0) { const inProgress = candidates.filter((name) => { const text = candidateText.get(name) || ''; - return /^status:\s*in_progress\b/im.test(text); + return milestoneStatusIsActive(text); }); + const selectedNames = [...new Set([phaseSelector, planSelector].map(milestoneScopeSlug).filter(Boolean))]; + if (selectedNames.length > 1) { + throw milestoneAuthorityError('conflicting_milestone_selectors', + 'Phase and PLAN selectors name different Workspine milestones.', selectedNames); + } + if (selectedNames.length === 1) { + const selected = selectedNames[0]; + if (!candidates.includes(selected) || (inProgress.length > 0 && !inProgress.includes(selected))) { + throw milestoneAuthorityError('inactive_milestone_selector', + `Selected Workspine milestone ${selected} is absent or not active.`, candidates); + } + return join(pluralDir, selected); + } + const exactStandardScope = [phaseSelector, planSelector].some((value) => + /^(?:phases\/|ROADMAP\.md#phase-)/.test(String(value || '').replace(/\\/g, '/'))); + if (exactStandardScope && inProgress.length > 1) return join(workDir, 'milestone'); + if (inProgress.length > 1 && rootLifecycle && /^\d+(?:\.\d+)*[a-z]?$/i.test(String(phaseSelector || '').trim())) { + // A bare phase token identifies a *phase*, not a particular native + // milestone. Examine the current-plan identities across every active + // milestone plus the already-observed root. The existing single- + // milestone classifier remains the source of truth after selection. + const token = normalizePhaseToken(phaseSelector); + const standard = resolveStandardPhaseSelection({ lifecycle: rootLifecycle, selector: phaseSelector }); + const nativeMatchEntries = inProgress.flatMap((name) => { + const phasesDir = join(pluralDir, name, 'phases'); + const artifacts = partitionPlanChains( + collectNativePhaseArtifacts({ workspaceRoot: resolve(workDir, '..'), phasesDir }), + { companionKinds: ['execute', 'verification'] }, + ).currentArtifacts; + return artifacts + .filter((artifact) => artifact.kind === 'plan' && artifact.phaseToken === token) + .map((artifact) => ({ name, identity: `milestones/${name}/phases/${artifact.dir}` })); + }); + if (standard.status === 'ambiguous') { + const failure = milestoneAuthorityError('ambiguous_phase_selector', + `Root phase ${token} has multiple current identities; use an exact emitted phase identity.`, []); + failure.choices = standard.choices; + throw failure; + } + const rootMatch = standard.status === 'selected'; + if (rootMatch && nativeMatchEntries.length === 0) return join(workDir, 'milestone'); + const uniqueNative = [...new Set(nativeMatchEntries.map((entry) => entry.identity))]; + if (!rootMatch && uniqueNative.length === 1) return join(pluralDir, nativeMatchEntries[0].name); + if (!rootMatch && uniqueNative.length === 0) return join(workDir, 'milestone'); + const failure = milestoneAuthorityError('multiple_active_milestones', + `Phase ${token} has multiple possible current authorities; choose one emitted phase identity.`, []); + failure.choices = [ + ...(rootMatch ? [standard.candidate.identity] : []), + ...uniqueNative, + ]; + throw failure; + } + if (inProgress.length > 1) { + throw milestoneAuthorityError('multiple_active_milestones', + `Multiple Workspine milestones are in progress (${inProgress.join(', ')}); choose an emitted milestones//phases/ identity.`, inProgress); + } const pool = inProgress.length > 0 ? inProgress : candidates; return join(pluralDir, pool.sort().at(-1)); } @@ -2302,8 +2384,8 @@ export function resolveActiveMilestoneDir(workDir) { return join(workDir, 'milestone'); } -export function inspectWorkMilestone(workDir) { - const milestoneDir = resolveActiveMilestoneDir(workDir); +export function inspectWorkMilestone(workDir, { selectedMilestoneDir = null } = {}) { + const milestoneDir = selectedMilestoneDir || resolveActiveMilestoneDir(workDir); const roadmapPath = join(milestoneDir, 'ROADMAP.md'); const auditPath = join(milestoneDir, 'AUDIT.md'); const milestonePath = join(milestoneDir, 'MILESTONE.md'); diff --git a/tests/gsdd.milestone-dir.test.cjs b/tests/gsdd.milestone-dir.test.cjs index 678b262..b286c5d 100644 --- a/tests/gsdd.milestone-dir.test.cjs +++ b/tests/gsdd.milestone-dir.test.cjs @@ -7,7 +7,7 @@ const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); const { pathToFileURL } = require('url'); -const { cleanup, createTempProject } = require('./gsdd.helpers.cjs'); +const { cleanup, createTempProject, runCliAsMain, withEnv } = require('./gsdd.helpers.cjs'); let tmpDir; @@ -46,12 +46,51 @@ describe('active Workspine milestone directory resolution', () => { assert.strictEqual(mod.resolveActiveMilestoneDir(workDir()), milestonePath('m1-active')); }); - test('uses lexicographic-last in-progress milestone when several exist', async () => { + test('refuses unscoped authority when several milestones are in progress', async () => { writeFile('.work/milestones/m0-a/MILESTONE.md', 'status: in_progress\n'); writeFile('.work/milestones/m1-b/MILESTONE.md', 'status: in_progress\n'); const mod = await importWorkContextModule(); - assert.strictEqual(mod.resolveActiveMilestoneDir(workDir()), milestonePath('m1-b')); + assert.throws(() => mod.resolveActiveMilestoneDir(workDir()), { + code: 'multiple_active_milestones', + choices: ['.work/milestones/m0-a', '.work/milestones/m1-b'], + }); + }); + + test('an exact emitted native phase can select one deliberately parallel active milestone', async () => { + writeFile('.work/milestones/m0-a/MILESTONE.md', 'status: in_progress\n'); + writeFile('.work/milestones/m1-b/MILESTONE.md', 'status: in_progress\n'); + const mod = await importWorkContextModule(); + + assert.strictEqual(mod.resolveActiveMilestoneDir(workDir(), { phaseSelector: 'milestones/m0-a/phases/01-task' }), milestonePath('m0-a')); + assert.strictEqual(mod.resolveActiveMilestoneDir(workDir(), { planSelector: 'milestones/m1-b/phases/01-task/01-PLAN.md' }), milestonePath('m1-b')); + }); + + test('an exact root ROADMAP identity bypasses ambiguous native milestones', async () => { + writeFile('.work/milestones/m0-a/MILESTONE.md', 'status: in_progress\n'); + writeFile('.work/milestones/m1-b/MILESTONE.md', 'status: in_progress\n'); + const mod = await importWorkContextModule(); + + assert.strictEqual(mod.resolveActiveMilestoneDir(workDir(), { phaseSelector: 'ROADMAP.md#phase-11' }), path.join(workDir(), 'milestone')); + }); + + test('different emitted phase and PLAN identities cannot select different milestones', async () => { + writeFile('.work/milestones/m0-a/MILESTONE.md', 'status: in_progress\n'); + writeFile('.work/milestones/m1-b/MILESTONE.md', 'status: in_progress\n'); + const mod = await importWorkContextModule(); + + assert.throws(() => mod.resolveActiveMilestoneDir(workDir(), { + phaseSelector: 'milestones/m0-a/phases/01-task', + planSelector: 'milestones/m1-b/phases/01-task/01-PLAN.md', + }), { code: 'conflicting_milestone_selectors' }); + }); + + test('an archived milestone cannot look active from historical body status prose', async () => { + writeFile('.work/milestones/m0-live/MILESTONE.md', '---\nstatus: in_progress\n---\n# Live\n'); + writeFile('.work/milestones/m1-archived/MILESTONE.md', '---\nstatus: archived\n---\n# Old\nstatus: in_progress\n'); + const mod = await importWorkContextModule(); + + assert.strictEqual(mod.resolveActiveMilestoneDir(workDir()), milestonePath('m0-live')); }); test('uses lexicographic-last plural candidate when none are in progress', async () => { @@ -112,4 +151,48 @@ describe('active Workspine milestone directory resolution', () => { assert.strictEqual(milestone.roadmap_phase_count, 1); assert.strictEqual(milestone.dir, milestonePath('m1-tighten-workflows')); }); + + test('preserves bare root Phase 11 when parallel active native milestones contain only Phase 1', async () => { + writeFile('.work/ROADMAP.md', '# Root\n- [ ] **Phase 11: Distinct root phase**\n'); + writeFile('.work/phases/11-root/11-PLAN.md', '---\nphase: 11\nstatus: approved\n---\n# Root plan\n'); + for (const slug of ['m0-a', 'm1-b']) { + writeFile(`.work/milestones/${slug}/MILESTONE.md`, '---\nstatus: in_progress\n---\n'); + writeFile(`.work/milestones/${slug}/ROADMAP.md`, `# ${slug}\n- [ ] **Phase 1: Owned**\n`); + writeFile(`.work/milestones/${slug}/phases/01-owned/01-PLAN.md`, '---\nphase: 1\nstatus: approved\n---\n'); + } + const siblingBefore = ['m0-a', 'm1-b'].map((slug) => fs.readFileSync(path.join(milestonePath(slug), 'ROADMAP.md'), 'utf8')); + const result = await withEnv({ WORKSPINE_UPDATE_AWARENESS: '0' }, () => runCliAsMain(tmpDir, ['phase-status', '11', 'in_progress'])); + assert.strictEqual(result.exitCode, 0, result.output); + const packet = JSON.parse(result.stdout); + assert.strictEqual(packet.identity, 'phases/11-root'); + assert.strictEqual(packet.roadmap, '.work/ROADMAP.md'); + assert.match(fs.readFileSync(path.join(workDir(), 'ROADMAP.md'), 'utf8'), /\[-\] \*\*Phase 11/); + assert.deepStrictEqual(['m0-a', 'm1-b'].map((slug) => fs.readFileSync(path.join(milestonePath(slug), 'ROADMAP.md'), 'utf8')), siblingBefore); + }); + + test('preserves native and root resume --plan identities across two active milestones', async () => { + writeFile('.work/ROADMAP.md', '# Root\n- [ ] **Phase 11: Root work**\n'); + writeFile('.work/phases/11-root/11-PLAN.md', '---\nphase: 11\nstatus: approved\n---\n'); + for (const slug of ['m0-a', 'm1-b']) { + writeFile(`.work/milestones/${slug}/MILESTONE.md`, '---\nstatus: in_progress\n---\n'); + writeFile(`.work/milestones/${slug}/ROADMAP.md`, `# ${slug}\n- [ ] **Phase 1: Owned**\n`); + writeFile(`.work/milestones/${slug}/phases/01-owned/01-PLAN.md`, '---\nphase: 1\nstatus: approved\n---\n'); + } + writeFile('.work/.continue-here.md', [ + '---', 'workflow: phase', 'phase: 01-owned', 'timestamp: 2026-10-09T06:00:00Z', 'runtime: other', '---', + 'Bounded interruption.', 'Read-only stage.', + 'Continue owner-selected PLAN.', 'Preserve existing approvals.', + 'None.', 'Resume the named PLAN.', '', + ].join('\n')); + for (const selected of [ + 'milestones/m1-b/phases/01-owned/01-PLAN.md', + 'phases/11-root/11-PLAN.md', + ]) { + const result = await withEnv({ WORKSPINE_UPDATE_AWARENESS: '0' }, () => runCliAsMain(tmpDir, ['lifecycle-preflight', 'resume', '--plan', selected])); + assert.strictEqual(result.exitCode, 0, result.output); + const packet = JSON.parse(result.stdout); + assert.strictEqual(packet.allowed, true, result.output); + assert.strictEqual(packet.plan, selected); + } + }); }); From c4c81b5590c9d003db4d3a4ba7bc774211533d38 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Thu, 15 Oct 2026 18:32:23 +0200 Subject: [PATCH 16/19] test: count physical workflow lines at budget boundaries --- tests/gsdd.guards.test.cjs | 2 +- tests/gsdd.invariants.test.cjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gsdd.guards.test.cjs b/tests/gsdd.guards.test.cjs index d911856..596c6c3 100644 --- a/tests/gsdd.guards.test.cjs +++ b/tests/gsdd.guards.test.cjs @@ -4854,7 +4854,7 @@ describe('G57 - Final Rigor Workflow Contract', () => { test('final workflow surfaces remain within their established line budgets', () => { const limits = { 'plan.md': 640, 'execute.md': 460, 'verify.md': 440 }; for (const [name, limit] of Object.entries(limits)) { - const lines = read(name).split('\n').length; + const lines = read(name).replace(/\n$/, '').split('\n').length; assert.ok(lines <= limit, `${name} is ${lines} lines (max ${limit}).`); } }); diff --git a/tests/gsdd.invariants.test.cjs b/tests/gsdd.invariants.test.cjs index f276216..31a1628 100644 --- a/tests/gsdd.invariants.test.cjs +++ b/tests/gsdd.invariants.test.cjs @@ -1520,7 +1520,7 @@ describe('G3 — File Size Guards', () => { for (const wf of getWorkflowFiles()) { const limit = WORKFLOW_EXEMPT[wf] || SIZE_LIMITS.workflow; test(`workflow/${wf} is under ${limit} lines`, () => { - const lines = readWorkflow(wf).split('\n').length; + const lines = readWorkflow(wf).replace(/\n$/, '').split('\n').length; assert.ok( lines <= limit, `${wf} is ${lines} lines (max ${limit}). FIX: Extract content to delegate or sub-section.` From 7467d33a79efa0c9e3ec8a82786f6c2cc4261aa1 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Thu, 15 Oct 2026 18:57:51 +0200 Subject: [PATCH 17/19] fix: keep pending brownfield verification undated --- distilled/templates/brownfield-change/VERIFICATION.md | 2 +- tests/gsdd.init.test.cjs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/distilled/templates/brownfield-change/VERIFICATION.md b/distilled/templates/brownfield-change/VERIFICATION.md index c5034c2..46ea246 100644 --- a/distilled/templates/brownfield-change/VERIFICATION.md +++ b/distilled/templates/brownfield-change/VERIFICATION.md @@ -1,6 +1,6 @@ --- change: CHANGE-001 -verified: 2026-04-21 +verified: null status: pending delivery_posture: repo_only required_evidence: diff --git a/tests/gsdd.init.test.cjs b/tests/gsdd.init.test.cjs index 5c77fec..5c004c5 100644 --- a/tests/gsdd.init.test.cjs +++ b/tests/gsdd.init.test.cjs @@ -336,6 +336,11 @@ describe('gsdd init and update', () => { assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'templates', 'brownfield-change', file)), `brownfield-change/${file} template must be distributed during init`); } + const freshVerificationTemplate = fs.readFileSync( + path.join(tmpDir, '.work', 'templates', 'brownfield-change', 'VERIFICATION.md'), 'utf8'); + assert.match(freshVerificationTemplate, /^verified: null$/m, + 'a newly initialized, pending verification must not carry a historical verified date'); + assert.match(freshVerificationTemplate, /^status: pending$/m); const statePath = path.join(tmpDir, '.work', 'state.json'); const state = readJson(statePath); From a8a91b4164bb6ffe0cf625979348342f36e44210 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Thu, 15 Oct 2026 21:17:18 +0200 Subject: [PATCH 18/19] fix: accept generated work-path PLAN selectors --- bin/lib/lifecycle-state.mjs | 8 ++++++-- tests/gsdd.lifecycle-path-alias.test.cjs | 13 ++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index 05bd7df..56b14f5 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -643,10 +643,14 @@ export function resolveStandardPhaseSelection({ lifecycle, selector } = {}) { export function resolveStandardPlanSelection({ lifecycle, planPath, phaseSelection = null } = {}) { if (!lifecycle) throw new Error('lifecycle is required'); const rawPath = String(planPath || '').trim().replace(/\\/g, '/'); - if (!rawPath || rawPath.startsWith('/') || rawPath.includes('..') || !rawPath.startsWith('phases/')) { + // Generated work-* skills use repository-relative .work/phases paths while + // lifecycle inventory emits phases-relative identities. Accept both exact + // spellings; retain the same traversal and current-chain checks below. + const selectedPath = rawPath.startsWith('.work/') ? rawPath.slice('.work/'.length) : rawPath; + if (!rawPath || rawPath.startsWith('/') || rawPath.includes('..') || !selectedPath.startsWith('phases/')) { return { status: 'invalid', reason: 'invalid_plan_selector' }; } - const relativePath = rawPath.slice('phases/'.length); + const relativePath = selectedPath.slice('phases/'.length); const plan = (lifecycle.phaseArtifacts || []).find( (artifact) => artifact.kind === 'plan' && artifact.displayPath === relativePath ); diff --git a/tests/gsdd.lifecycle-path-alias.test.cjs b/tests/gsdd.lifecycle-path-alias.test.cjs index 3fb48da..0c9b882 100644 --- a/tests/gsdd.lifecycle-path-alias.test.cjs +++ b/tests/gsdd.lifecycle-path-alias.test.cjs @@ -113,6 +113,18 @@ test('canonical directory discovery retains logical artifact paths and ordinary assert.equal(result.allowed, true); }); +test('documented .work/phases PLAN selector resolves to its exact current phase chain', async () => { + const root = fixture(); + const evaluate = (await preflightModule).evaluateLifecyclePreflight; + const args = { planningDir: path.join(root, '.work'), surface: 'verify', phaseNumber: '12', expectsMutation: 'phase-status' }; + const selected = evaluate({ ...args, planPath: '.work/phases/12-current/12-01-PLAN.md', planFlagPresent: true }); + assert.equal(selected.allowed, true, JSON.stringify(selected.blockers)); + const escape = evaluate({ ...args, planPath: '.work/phases/../12-current/12-01-PLAN.md', planFlagPresent: true }); + assert.equal(escape.allowed, false); + const wrongPhase = evaluate({ ...args, planPath: '.work/phases/11-parked/11-01-PLAN.md', planFlagPresent: true }); + assert.equal(wrongPhase.allowed, false); +}); + test('a phase root junction cannot supply authority even when its target is a real directory', async () => { const root = fixture(); const phases = path.join(root, '.work', 'phases'); @@ -177,4 +189,3 @@ test('Windows directory case aliases retain inventory without rewriting emitted assert.equal(current.phaseArtifacts.length, 4); assert.ok(current.phaseArtifacts.every(artifact => artifact.path.startsWith(path.resolve(upper)))); }); - From d93dd81cb11e1b7fa571db29597e71b88f019582 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 00:11:45 +0200 Subject: [PATCH 19/19] fix: preserve brownfield remediation and approval readback --- bin/lib/lifecycle-preflight.mjs | 2 +- bin/lib/next.mjs | 55 +++++++++++++++++- tests/decision-recall.test.cjs | 3 + tests/gsdd.next.test.cjs | 99 +++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index cb73db8..52d2601 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -787,7 +787,7 @@ function evaluateDecisionDispositionWarnings({ planningDir, decisionsDigest, pla const entries = Array.isArray(dispositions) ? dispositions : []; const byId = new Map(entries.filter((entry) => entry?.id).map((entry) => [entry.id, entry])); const missing = persisted.records.map((record) => record.id).filter((id) => !byId.has(id)); - if (dispositions === null || missing.length > 0) { + if (persisted.records.length > 0 && (dispositions === null || missing.length > 0)) { warnings.push({ code: 'decision_dispositions_missing', message: `PLAN.md decision dispositions are missing for persisted decision(s): ${(missing.length > 0 ? missing : persisted.records.map((record) => record.id)).join(', ')}.`, diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index 7babc5b..376b258 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -14,6 +14,7 @@ import { buildDecisionsDigest, getWorkPaths, hasDurableWorkflowApproval, + isValidApprovalReference, inspectWorkContext, readJsonIfExists, rebuildGraphIndex, @@ -207,6 +208,11 @@ function hasCurrentPlanApproval(context, workflow = context.state?.value?.workfl return Boolean(plan && hasDurableWorkflowApproval(context.paths.workDir, plan, context.state?.value)); } +function isRemediatingRecordedGaps(workflow) { + return workflow?.verification?.status === 'gaps_found' + && ['plan', 'execute', 'verify'].includes(workflow.current_state); +} + function brownfieldLifecycleRoute(context) { if (!context.planning.has_brownfield_change || !isBoundBrownfieldLifecycle(context)) return null; const change = context.planning.brownfield_change; @@ -271,6 +277,36 @@ function brownfieldLifecycleRoute(context) { artifacts_to_read: [...evidence, statePath(context, 'brownfield-change/VERIFICATION.md')], artifacts_to_write: [], requires_user: false, }; } + if (workflow.current_state === 'fix_gaps' + || (workflow.verification?.status === 'gaps_found' && !isRemediatingRecordedGaps(workflow))) { + const verificationPath = statePath(context, 'brownfield-change/VERIFICATION.md'); + return { + state: 'fix_gaps', reason: 'The bounded brownfield change has unresolved verification gaps.', + authority: 'brownfield_change', route_kind: 'brownfield_change_fix_gaps', blocked_by: [], + next_command: null, + next_action: manualReviewAction([...evidence, verificationPath], + 'Resolve the verification gaps: update CHANGE.md, replan, record fresh owner approval, execute the fixes and verify again before closeout.'), + artifacts_to_read: [...evidence, verificationPath], + artifacts_to_write: [verificationPath, statePath(context, 'brownfield-change/CHANGE.md')], + requires_user: false, + }; + } + if (workflow.current_state === 'plan' + && workflow.execution?.status === 'not_started' + && workflow.verification?.status === 'gaps_found' + && change.currentStatus === 'ready_for_verification') { + return { + state: 'plan', + reason: 'Remediation planning started, but CHANGE.md still claims ready for verification. Restore active posture and obtain fresh approval for its current bytes before executing.', + authority: 'brownfield_change', route_kind: 'brownfield_change_remediation_plan', + blocked_by: ['brownfield_change_posture', 'plan_approval'], + next_command: workflowId('plan'), + next_action: workflowAction(workflowId('plan'), + 'Set the bounded CHANGE.md posture to active, review the remediation scope, replan and record a fresh owner approval before execution.'), + artifacts_to_read: evidence, artifacts_to_write: [statePath(context, 'brownfield-change/CHANGE.md')], + requires_user: true, + }; + } if (change.currentStatus === 'ready_for_verification' || workflow.execution?.status === 'complete') { return { state: 'verify', reason: 'The bounded brownfield change is ready for Done-When verification.', @@ -329,7 +365,8 @@ function routeFromStateObject(stateValue, workDir) { if (workflow.execution?.status === 'complete' && workflow.verification?.status !== 'passed') { return { state: 'verify', reason: 'Execution is complete and verification has not passed.' }; } - if (workflow.verification?.status === 'gaps_found' || workflow.audit?.status === 'gaps_found') { + if ((workflow.verification?.status === 'gaps_found' || workflow.audit?.status === 'gaps_found') + && !isRemediatingRecordedGaps(workflow)) { return { state: 'fix_gaps', reason: 'Verification or audit recorded gaps.' }; } if (workflow.verification?.status === 'passed' && workflow.audit?.status !== 'passed') { @@ -928,6 +965,21 @@ function continuityProjection(context, route) { : stateWorkflow?.plan && Object.hasOwn(stateWorkflow.plan, 'approved') ? { value: currentPlanApproval ? 'approved' : 'not_approved', source: `${stateDirName(context)}/state.json#${stateSource}.plan.approved` } : { value: 'not_recorded', source: 'structured_state_or_lifecycle_not_recorded' }; + const recordedPath = stateWorkflow?.plan?.path || null; + const recordedIdentity = stateWorkflow?.plan?.identity || null; + const recordedPlanConsistent = recordedPath && recordedIdentity + && normalizeSlashes(recordedPath) === normalizeSlashes(recordedIdentity); + // A completed workflow can retain its former owner approval receipt even + // after the active plan bytes or last actor change. This display-only field + // never grants authority to resume execution of an amended plan. + const recordedPlanApproval = state?.workflow + && stateWorkflow?.execution?.status === 'complete' + && stateWorkflow?.plan?.approved === true + && recordedPlanConsistent + && /^[a-f0-9]{64}$/i.test(String(stateWorkflow.plan.approved_sha256 || '')) + && isValidApprovalReference(stateWorkflow.approval_ref) + ? { value: 'recorded', source: stateDirName(context) + '/state.json#workflow.plan.approved_sha256' } + : null; const result = typeof stateWorkflow?.execution?.status === 'string' ? { value: stateWorkflow.execution.status, source: `${stateDirName(context)}/state.json#${stateSource}.execution.status` } : { value: 'not_recorded', source: 'structured_state_or_lifecycle_not_recorded' }; @@ -956,6 +1008,7 @@ function continuityProjection(context, route) { checkpoint, posture: { approval, + ...(recordedPlanApproval ? { recorded_plan_approval: recordedPlanApproval } : {}), result, verification, }, diff --git a/tests/decision-recall.test.cjs b/tests/decision-recall.test.cjs index ca9baad..6700897 100644 --- a/tests/decision-recall.test.cjs +++ b/tests/decision-recall.test.cjs @@ -967,10 +967,13 @@ describe('S1 decision recall loop', () => { const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'state.json'), 'utf-8')); state.lastDecisionsDigest.records = []; + fs.writeFileSync(path.join(phaseDir, '31-PLAN.md'), '---\nstatus: planned\n---\n# No dispositions for an empty persisted digest\n'); fs.writeFileSync(path.join(stateDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`); result = await runCliAsMain(root, ['lifecycle-preflight', 'execute', '31', '--expects-mutation', 'phase-status']); output = JSON.parse(result.output); assert.ok(output.warnings.some((warning) => warning.code === 'decision_ack_stale'), output.warnings.map((warning) => warning.code).join(',')); + assert.ok(!output.warnings.some((warning) => warning.code === 'decision_dispositions_missing'), + 'an empty persisted digest cannot require dispositions for nonexistent decision IDs'); }); test('compact help discovers the decision surface while the reference exposes all verbs', async () => { diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index 42ed685..b2f144a 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -1643,6 +1643,105 @@ describe('next command routing', () => { assert.strictEqual(result.next_command, null); }); + test('brownfield verification gaps route to remediation rather than recommending closeout', async () => { + await initWork(); + const changePath = '.work/brownfield-change/CHANGE.md'; + const verificationPath = '.work/brownfield-change/VERIFICATION.md'; + const statePath = path.join(tmpDir, '.work/state.json'); + writeFile(changePath, brownfieldChange()); + writeFile(verificationPath, brownfieldVerification('complete')); + + await runJson(['lifecycle-transition', 'plan', '--plan', changePath, '--authority', 'workflow', '--json']); + await runJson(['lifecycle-transition', 'approve', '--plan', changePath, '--authority', 'owner', '--approval-ref', 'test-gaps-owner', '--json']); + await runJson(['lifecycle-transition', 'execute', '--plan', changePath, '--authority', 'workflow', '--json']); + writeFile(changePath, brownfieldChange({ posture: 'ready_for_verification', nextAction: 'Resolve evidence gaps.' })); + await runJson(['lifecycle-transition', 'verify', '--plan', changePath, '--artifact', verificationPath, '--authority', 'workflow', '--json']); + writeFile(verificationPath, [ + '---', 'change: CHANGE-TEST', 'status: gaps_found', '---', + '# Brownfield Change Verification', + '## Goal Verification', '- Existing checks have failures.', + '## Evidence', '- Required test was run.', + '## Gaps', '- An existing test is failing.', + '## Closeout Decision', '- Resolve gaps; not passed.', + ].join('\n')); + await runJson(['lifecycle-transition', 'fix_gaps', '--plan', changePath, '--artifact', verificationPath, '--authority', 'workflow', '--json']); + + const beforeRead = fs.readFileSync(statePath); + for (let attempt = 0; attempt < 2; attempt++) { + const next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'fix_gaps'); + assert.strictEqual(next.route_kind, 'brownfield_change_fix_gaps'); + assert.strictEqual(next.authority, 'brownfield_change'); + assert.strictEqual(next.next_command, null, 'gaps must not automatically restart execution'); + assert.strictEqual(next.requires_user, false); + assert.ok(next.artifacts_to_read.includes(verificationPath)); + assert.match(next.next_action.description, /resolve|address|repair/i); + assert.doesNotMatch(next.next_action.description, /close CHANGE/i); + assert.strictEqual(next.continuity.posture.approval.value, 'not_approved', + 'current changed PLAN bytes must not regain executable owner approval'); + assert.deepStrictEqual(next.continuity.posture.recorded_plan_approval, { + value: 'recorded', + source: '.work/state.json#workflow.plan.approved_sha256', + }, 'a completed workflow still preserves evidence of the previously recorded owner approval'); + assert.deepStrictEqual(fs.readFileSync(statePath), beforeRead, 'next must not mutate state'); + } + + // A legitimate fix cycle must move forward even though the previous + // failed verification remains preserved as evidence in state.json. + // The earlier failed verification left CHANGE.md marked ready; planning + // is still a legal recovery transition before the operator updates it. + await runJson(['lifecycle-transition', 'plan', '--plan', changePath, '--authority', 'workflow', '--json']); + let next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'plan', 'prior gaps must not mask an unapproved remediation plan'); + assert.strictEqual(next.route_kind, 'brownfield_change_remediation_plan'); + assert.ok(next.blocked_by.includes('brownfield_change_posture')); + + await runJson(['lifecycle-transition', 'approve', '--plan', changePath, '--authority', 'owner', '--approval-ref', 'test-ready-owner', '--json']); + next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'plan', 'approval alone cannot execute a remediation while CHANGE is still marked ready'); + + writeFile(changePath, brownfieldChange({ posture: 'active', nextAction: 'Plan the bounded remediation.' })); + await runJson(['lifecycle-transition', 'plan', '--plan', changePath, '--authority', 'workflow', '--json']); + next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'plan', 'changed contract requires fresh approval'); + await runJson(['lifecycle-transition', 'approve', '--plan', changePath, '--authority', 'owner', '--approval-ref', 'test-remediation-owner', '--json']); + next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'execute', 'fresh remediation approval must enable execution'); + await runJson(['lifecycle-transition', 'execute', '--plan', changePath, '--authority', 'workflow', '--json']); + next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'execute'); + writeFile(changePath, brownfieldChange({ posture: 'ready_for_verification', nextAction: 'Recheck the remediated change.' })); + writeFile(verificationPath, brownfieldVerification('complete')); + await runJson(['lifecycle-transition', 'verify', '--plan', changePath, '--artifact', verificationPath, '--authority', 'workflow', '--json']); + next = await runJson(['next', '--json']); + assert.strictEqual(next.state, 'verify', 'prior gaps must not mask a new verification pass'); + assert.strictEqual(next.route_kind, 'brownfield_change_verification'); + assert.strictEqual(next.continuity.posture.recorded_plan_approval?.value, 'recorded'); + + const savedState = fs.readFileSync(statePath); + const invalidRef = JSON.parse(savedState); + invalidRef.workflow.approval_ref = 'auto'; + fs.writeFileSync(statePath, `${JSON.stringify(invalidRef, null, 2)}\n`); + next = await runJson(['next', '--json']); + assert.strictEqual(next.continuity.posture.recorded_plan_approval, undefined, + 'a synthetic reference must not be presented as a recorded owner approval'); + + const mismatchedPlan = JSON.parse(savedState); + mismatchedPlan.workflow.plan.identity = '.work/brownfield-change/OTHER.md'; + fs.writeFileSync(statePath, `${JSON.stringify(mismatchedPlan, null, 2)}\n`); + next = await runJson(['next', '--json']); + assert.strictEqual(next.continuity.posture.recorded_plan_approval, undefined, + 'historical approval provenance requires a consistent recorded plan identity'); + + const missingIdentity = JSON.parse(savedState); + missingIdentity.workflow.plan.identity = null; + fs.writeFileSync(statePath, `${JSON.stringify(missingIdentity, null, 2)}\n`); + next = await runJson(['next', '--json']); + assert.strictEqual(next.continuity.posture.recorded_plan_approval, undefined, + 'historical approval provenance requires both recorded plan pointers'); + fs.writeFileSync(statePath, savedState); + }); + test('next does not advertise execution after approved brownfield bytes change', async () => { await initWork(); const changePath = '.work/brownfield-change/CHANGE.md';