From fd4b560acf925b1766aefc29877dee1570faf870 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 17:07:04 +0200 Subject: [PATCH 1/4] fix: reconcile multi-plan phase lifecycle and fresh verification Agent-Harness: chat-on-steroids Agent-Session: factory-038-fix1 --- bin/lib/init-runtime.mjs | 4 +- bin/lib/lifecycle-preflight.mjs | 18 +- bin/lib/work-context.mjs | 82 ++++++++- distilled/workflows/plan.md | 2 +- distilled/workflows/verify.md | 1 + docs/USER-GUIDE.md | 12 ++ tests/gsdd.lifecycle-reconciliation.test.cjs | 182 +++++++++++++++++++ 7 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 tests/gsdd.lifecycle-reconciliation.test.cjs diff --git a/bin/lib/init-runtime.mjs b/bin/lib/init-runtime.mjs index 7d78f64..75f86f6 100644 --- a/bin/lib/init-runtime.mjs +++ b/bin/lib/init-runtime.mjs @@ -283,7 +283,7 @@ export const COMMAND_FLAGS = { 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, '--amend': false, '--json': false, '--workspace-root': true }, + 'lifecycle-transition': { '--plan': true, '--artifact': true, '--authority': true, '--approval-ref': true, '--reason': true, '--question': true, '--approved': true, '--amend': false, '--start-new': 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 }, @@ -411,7 +411,7 @@ function commandSpecificFlagError(command, positionals, commandArgs) { allowed = allow('--plan', '--artifact', '--authority', '--reason', '--json', '--workspace-root'); } else { allowed = target === 'plan' - ? allow('--plan', '--authority', '--reason', '--amend', '--json', '--workspace-root') + ? allow('--plan', '--authority', '--reason', '--amend', '--start-new', '--json', '--workspace-root') : allow('--plan', '--authority', '--reason', '--json', '--workspace-root'); } } else if (command === 'decisions') { diff --git a/bin/lib/lifecycle-preflight.mjs b/bin/lib/lifecycle-preflight.mjs index 52d2601..99ea0f4 100644 --- a/bin/lib/lifecycle-preflight.mjs +++ b/bin/lib/lifecycle-preflight.mjs @@ -1306,6 +1306,17 @@ 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]); } + // Carry the validated report result to persistence, and check its bytes + // again on both writes and replays. A path alone is not fresh evidence. + if (['audit', 'next', 'fix_gaps'].includes(target)) { + const preceding = preWriteGuard; + preWriteGuard = () => { + preceding?.(); + if (readFileSync(artifact.path, 'utf8') !== artifactMeta.content) { + throw transitionErrorForCli('stale_artifact', 'Verification report changed while the transition was being validated.', [artifact.relative]); + } + }; + } const warnings = []; if (brownfieldChain && target === 'execute') { const contractGuard = preWriteGuard; @@ -1334,6 +1345,8 @@ function validateTransitionArtifact({ planningDir, target, planArg, artifactArg, authority: authority || planMeta?.metadata?.authority || null, approvalRef: authority === 'owner' ? normalizedApprovalRef || null : null, approvalRequested, + verificationStatus: ['audit', 'next'].includes(target) ? 'passed' + : target === 'fix_gaps' ? artifactStatus : null, preWriteGuard, warnings, stateLabel, @@ -1363,7 +1376,7 @@ export function cmdLifecycleTransition(...args) { try { assertStateAuthority(state); if (normalizedArgs.includes('--help') || normalizedArgs[0] === 'help') { - console.log('Usage: lifecycle-transition --plan [--artifact ] --authority [--reason ] [--amend (owner plan only)] [--question ] [--json]'); + console.log('Usage: lifecycle-transition --plan [--artifact ] --authority [--reason ] [--amend (owner plan only) | --start-new (distinct phase plan; requires --reason)] [--question ] [--json]'); return; } const target = String(normalizedArgs.find((arg) => !arg.startsWith('--')) || '').toLowerCase(); @@ -1379,6 +1392,7 @@ export function cmdLifecycleTransition(...args) { const approvalRefProvided = normalizedArgs.includes('--approval-ref'); const reason = flag('--reason'); const amend = normalizedArgs.includes('--amend'); + const startNew = normalizedArgs.includes('--start-new'); const question = flag('--question'); const approved = String(flag('--approved') || '').toLowerCase() === 'true' || target === 'approve'; const approvalRequested = target === 'approve' || String(flag('--approved') || '').toLowerCase() === 'true'; @@ -1393,6 +1407,8 @@ export function cmdLifecycleTransition(...args) { approvalRef: validated.approvalRef, reason, amend, + startNew, + verificationStatus: validated.verificationStatus, question, approved, preWriteGuard: validated.preWriteGuard, diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 99b3021..2bdadca 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -364,6 +364,8 @@ export function transitionWorkflowState(workDir, { question = null, approved = null, amend = false, + startNew = false, + verificationStatus = null, preWriteGuard = null, now = new Date(), } = {}) { @@ -387,6 +389,9 @@ 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 (startNew && (normalizedTarget !== 'plan' || amend || !String(reason || '').trim() || approvalRef || approved === true)) { + throw transitionError('new_plan_not_allowed', 'Starting a distinct phase plan requires plan --start-new --reason and separate approval; it cannot amend or approve work.', ['--start-new', '--reason']); + } if (amend && normalizedTarget !== 'plan') { throw transitionError('invalid_amendment_target', 'Only the plan lifecycle transition accepts --amend.', ['--amend']); } @@ -412,6 +417,9 @@ export function transitionWorkflowState(workDir, { assertLifecyclePlanIdentity(workDir, normalizedTarget, expectedPlan); if (planIdentity) assertLifecyclePlanIdentity(workDir, normalizedTarget, planIdentity); const recordedPlan = workflow.plan.path || workflow.plan.identity || null; + if (recordedPlan && workflow.plan.identity && workflow.plan.path && workflow.plan.path !== workflow.plan.identity) { + throw transitionError(startNew ? 'new_plan_not_allowed' : 'stale_state', 'Recorded plan path and identity disagree.', [workflow.plan.path, workflow.plan.identity]); + } // Recover the historical phase-path brownfield approval through the existing // plan transition. Execution/evidence cannot be transferred to another chain. const stateRoot = normalizeSlashes(relative(resolve(workDir, '..'), workDir)); @@ -431,7 +439,46 @@ export function transitionWorkflowState(workDir, { && [workflow.execution, workflow.verification, workflow.audit, workflow.dogfood] .every(stage => stage.status === 'not_started' && !stage.artifact && !stage.identity) && !oldChainArtifacts; - if (recordedPlan && expectedPlan && recordedPlan !== expectedPlan && !repairBrownfieldApproval) { + // This is an explicit handoff, never an approval/execute identity repair. + // Keep completed execution and unresolved verification in the same state + // document; new approval must bind only the freshly selected plan. + const assertNewPlan = () => { + if (!startNew) return; + const lifecycle = evaluateLifecycleState({ planningDir: workDir }); + const findPlan = identity => lifecycle.phaseArtifacts.find(item => item.kind === 'plan' + && normalizeSlashes(relative(resolve(workDir, '..'), item.path)) === identity); + const previous = findPlan(recordedPlan); + const selected = findPlan(expectedPlan); + const phase = selected && lifecycle.phases.find(item => item.number === selected.phaseToken); + const history = Array.isArray(workflow.plan_history) ? workflow.plan_history : []; + const previousExecution = workflow.execution.artifact; + const matchingSummary = lifecycle.phaseArtifacts.find(item => item.kind === 'summary' + && item.chainKey === previous?.chainKey + && normalizeSlashes(relative(resolve(workDir, '..'), item.path)) === previousExecution); + if (!previous || !selected || previous.chainKey === selected.chainKey + || previous.dir !== selected.dir || !phase || phase.status === 'done' || phase.parked + || workflow.plan.path !== recordedPlan || workflow.plan.identity !== recordedPlan + || !['verify', 'audit', 'fix_gaps'].includes(workflow.current_state || state.current_state) + || (state.current_state && state.current_state !== workflow.current_state) + || state.status === 'blocked' || state.status === 'paused' + || state.status === 'complete' || state.status === 'completed' + || workflow.status === 'blocked' || workflow.status === 'paused' + || workflow.status === 'complete' || workflow.status === 'completed' + || workflow.completion_approved === true + || (workflow.human_gate && workflow.human_gate.approved !== true) + || workflow.execution.status !== 'complete' || !matchingSummary + || workflow.execution.identity !== previousExecution + || !['complete', 'completed', 'done', 'passed', 'pass'].includes(readPlanStatus(readFileSync(matchingSummary.path, 'utf8'))) + || history.some(entry => entry.workflow?.plan?.path === expectedPlan) + || lifecycle.phaseArtifacts.some(item => item.chainKey === selected.chainKey && ['summary', 'verification'].includes(item.kind)) + || !planArtifactSha256(workDir, expectedPlan)) { + throw transitionError('new_plan_not_allowed', + 'Start-new requires a distinct unexecuted current plan in the same open phase, consistent completed execution proof, and no blocked or pending owner gate. Retained plans cannot be reopened.', + [String(recordedPlan || ''), String(expectedPlan || ''), '.work/state.json']); + } + }; + assertNewPlan(); + if (recordedPlan && expectedPlan && recordedPlan !== expectedPlan && !repairBrownfieldApproval && !startNew) { 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 @@ -443,16 +490,15 @@ export function transitionWorkflowState(workDir, { [String(recordedPlan || '.work/state.json')]); } if (artifactPath && artifactIdentity) { - const recordedArtifact = normalizedTarget === 'verify' - ? workflow.execution.artifact || workflow.execution.identity - : workflow.verification.artifact || workflow.verification.identity; - if (recordedArtifact && recordedArtifact !== artifactPath && recordedArtifact !== artifactIdentity) { + const stage = normalizedTarget === 'verify' ? workflow.execution : workflow.verification; + const recordedArtifact = stage.artifact || stage.identity; + if ([stage.artifact, stage.identity].some(value => value && value !== artifactPath && value !== artifactIdentity)) { throw transitionError('stale_state', 'Recorded lifecycle artifact does not match the supplied artifact.', [String(recordedArtifact), String(artifactPath)]); } } const next = JSON.parse(JSON.stringify(state)); - next.workflow = workflow; + next.workflow = workflowStateWithDefaults(workflow); next.workflow.plan = { ...workflow.plan }; next.workflow.execution = { ...workflow.execution }; next.workflow.verification = { ...workflow.verification }; @@ -522,11 +568,11 @@ export function transitionWorkflowState(workDir, { const effectiveTarget = normalizedTarget === 'next' ? 'audit' : normalizedTarget; const allowedPredecessors = { - plan: amend ? interruptedExecutionStates : ['plan', 'fix_gaps', 'blocked', 'ask_user'], + plan: startNew ? ['verify', 'audit', 'fix_gaps'] : amend ? interruptedExecutionStates : ['plan', 'fix_gaps', 'blocked', 'ask_user'], approve: ['plan'], execute: ['plan', 'execute'], verify: ['execute', 'verify'], - audit: ['verify', 'audit'], + audit: ['verify', 'audit', 'fix_gaps'], fix_gaps: ['verify', 'audit', 'fix_gaps'], blocked: ['plan', 'execute', 'verify', 'audit', 'fix_gaps', 'blocked', 'ask_user'], ask_user: ['plan', 'execute', 'verify', 'audit', 'fix_gaps', 'ask_user'], @@ -534,6 +580,9 @@ export function transitionWorkflowState(workDir, { if (allowedPredecessors[effectiveTarget] && !allowedPredecessors[effectiveTarget].includes(currentState)) { throw transitionError('out_of_order', `Cannot transition from ${currentState} to ${effectiveTarget}; resolve the recorded lifecycle posture first.`, ['.work/state.json']); } + if (effectiveTarget === 'audit' && currentState === 'fix_gaps' && workflow.execution.status !== 'complete') { + throw transitionError('execution_incomplete', 'Fresh verification can resolve gaps only for completed execution.', [String(normalizedPlan || '')]); + } if (effectiveTarget === 'approve') { next.workflow.plan.approved = true; next.workflow.plan.approved_sha256 = approvedPlanSha256; @@ -546,6 +595,17 @@ export function transitionWorkflowState(workDir, { ]; } } else if (effectiveTarget === 'plan') { + if (startNew) { + const { plan_history: history = [], ...retained } = workflow; + next.workflow.plan_history = [...history, { workflow: retained, reason: String(reason).trim(), retained_at: now.toISOString() }]; + next.workflow.plan = { path: normalizedPlan, identity: normalizedPlan, approved: false }; + for (const stage of ['execution', 'verification', 'audit', 'dogfood']) { + next.workflow[stage] = { ...WORKFLOW_DEFAULTS[stage] }; + } + delete next.workflow.amendments; + delete next.workflow.human_gate; + delete next.workflow.reason; + } if (amend) { next.workflow.amendments = [ ...(Array.isArray(workflow.amendments) ? workflow.amendments : []), @@ -598,7 +658,10 @@ export function transitionWorkflowState(workDir, { } next.workflow.current_state = 'audit'; } else if (effectiveTarget === 'fix_gaps') { - next.workflow.verification.status = 'gaps_found'; + if (verificationStatus !== null && !['gaps_found', 'human_needed', 'blocked', 'failed', 'fail'].includes(verificationStatus)) { + throw transitionError('gaps_not_recorded', 'Verification reconciliation requires a validated gap or human-gate status.', [String(normalizedArtifact || '')]); + } + next.workflow.verification.status = verificationStatus || 'gaps_found'; if (normalizedArtifact) { next.workflow.verification.artifact = artifactPath || workflow.verification.artifact || null; next.workflow.verification.identity = artifactIdentity || workflow.verification.identity || normalizedArtifact; @@ -636,6 +699,7 @@ 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(); + assertNewPlan(); // 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); diff --git a/distilled/workflows/plan.md b/distilled/workflows/plan.md index 441c17c..5b2ed64 100644 --- a/distilled/workflows/plan.md +++ b/distilled/workflows/plan.md @@ -38,7 +38,7 @@ Before presenting a brownfield plan, complete its draft and checker result and r 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`. +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. When another phase PLAN is already recorded, first follow the distinct-plan handoff contract in `docs/USER-GUIDE.md#phase-plan-handoffs`. 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. diff --git a/distilled/workflows/verify.md b/distilled/workflows/verify.md index 142979c..819409a 100644 --- a/distilled/workflows/verify.md +++ b/distilled/workflows/verify.md @@ -398,6 +398,7 @@ For phase authority only, after the verification artifact is durable, record lif `node .work/bin/gsdd.mjs lifecycle-transition audit --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --artifact .work/phases/{phase_dir}/{plan_id}-VERIFICATION.md --authority workflow --json`. For phase authority with `gaps_found` or `human_needed`, use `lifecycle-transition fix_gaps` with the same artifact and preserve the human gate; a missing, stale, or mismatched artifact must fail closed without changing state. +Re-verification uses those same commands: `fix_gaps` reconciles the recorded status to the current report (including `human_needed`); once the report passes, use `audit` from the recorded `fix_gaps` posture. Reconciliation preserves plan approval and any explicit owner gate; it never grants human approval. An unchanged report/status replays without writing state. For phase authority, `gaps_found`/`human_needed` keeps ROADMAP open; if currently `[x]`, reopen with `phase-status ... in_progress`. Brownfield uses `fix_gaps` and leaves CHANGE open. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 57bf355..353f495 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -49,6 +49,18 @@ That is the first-use path. Everything below is reference material for deeper li --- +## Phase Plan Handoffs + +The lifecycle records one selected plan at a time. To start a distinct unexecuted PLAN in the same open phase after the recorded plan has a complete matching SUMMARY, explicitly hand off: + +```sh +node .work/bin/gsdd.mjs lifecycle-transition plan --plan .work/phases/{phase_dir}/{plan_id}-PLAN.md --authority workflow --start-new --reason "Start the independently scoped next plan; retain previous proof" --json +``` + +This retains the previous workflow and approval receipt in `workflow.plan_history`, including unresolved verification, and selects the new plan without approval. Obtain separate owner approval of its exact bytes and reference before execution. Existing artifacts remain evidence; the handoff does not resolve earlier gaps, human proof debt, or close the phase. It refuses in-progress execution, blocked or pending owner gates, completed phases, and previously executed plans. Resolve refusals through review; preserve state and artifacts rather than switching identities by hand. Ordinary `plan`, `approve`, and `execute` still reject a mismatched recorded plan. + +After updating the selected plan's VERIFICATION report, replay `lifecycle-transition fix_gaps` with the same `--plan`, `--artifact`, and `--authority workflow` to record its fresh gap or `human_needed` status. When the report passes, use `lifecycle-transition audit` with those paths, including from `fix_gaps`. A passing report does not approve an explicit owner gate; an unchanged reconciliation replays without writing state. + ## First Change, End to End Suppose you need to change behavior in an existing application and there is at least one product decision the agent diff --git a/tests/gsdd.lifecycle-reconciliation.test.cjs b/tests/gsdd.lifecycle-reconciliation.test.cjs new file mode 100644 index 0000000..4a46845 --- /dev/null +++ b/tests/gsdd.lifecycle-reconciliation.test.cjs @@ -0,0 +1,182 @@ +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 { cleanup, createTempProject, runCliAsMain, readJson } = require('./gsdd.helpers.cjs'); + +let root; +const phase = '.work/phases/01-parallel-issues'; +const plan = id => `${phase}/${id}-PLAN.md`; +const summary = id => `${phase}/${id}-SUMMARY.md`; +const report = id => `${phase}/${id}-VERIFICATION.md`; +const write = (name, content) => { + const target = path.join(root, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +}; +const bytes = name => fs.readFileSync(path.join(root, name)); +const state = () => readJson(path.join(root, '.work/state.json')); +const transition = (target, id, flags = []) => runCliAsMain(root, [ + 'lifecycle-transition', target, '--plan', plan(id), '--authority', 'workflow', + ...flags, '--json', '--no-update-notice', +]); +async function ok(target, id, flags = []) { + const result = await transition(target, id, flags); + assert.equal(result.exitCode, 0, result.output); + return JSON.parse(result.output); +} +async function approve(id) { + const result = await runCliAsMain(root, [ + 'lifecycle-transition', 'approve', '--plan', plan(id), '--authority', 'owner', + '--approval-ref', `fixture-owner-review-${id}`, '--json', '--no-update-notice', + ]); + assert.equal(result.exitCode, 0, result.output); +} +async function refused(target, id, flags, code) { + const before = bytes('.work/state.json'); + const result = await transition(target, id, flags); + assert.equal(result.exitCode, 1, result.output); + if (code) assert.equal(JSON.parse(result.output).error_code, code, result.output); + assert.deepEqual(bytes('.work/state.json'), before); +} +async function executed(id = '01') { + await ok('plan', id); + await approve(id); + await ok('execute', id); + write(summary(id), '---\nstatus: complete\n---\n# Execution evidence\n'); + await ok('verify', id, ['--artifact', summary(id)]); +} +async function verified(status, id = '01') { + await executed(id); + write(report(id), `---\nstatus: ${status}\n---\n# Verification evidence\n`); + await ok(status === 'passed' ? 'audit' : 'fix_gaps', id, ['--artifact', report(id)]); +} +const start = ['--start-new', '--reason', 'Start the independently scoped second plan; retain first-plan proof']; + +beforeEach(async () => { + root = createTempProject(); + const { ensureWorkStructure } = await import(pathToFileURL(path.join(__dirname, '../bin/lib/work-context.mjs')).href); + ensureWorkStructure(root); + write('.work/config.json', '{}\n'); + write('.work/SPEC.md', '# Parallel issues fixture\n'); + write('.work/ROADMAP.md', '# Roadmap\n- [-] **Phase 1: Parallel issues**\n'); + for (const id of ['01', '02']) write(plan(id), `---\nstatus: pending\nphase: 01-parallel-issues\nplan: ${id}\n---\n# Independent plan ${id}\n`); +}); +afterEach(() => cleanup(root)); + +for (const status of ['human_needed', 'passed']) { + test(`explicit handoff to a separate plan retains ${status} proof and requires fresh exact approval`, async () => { + await verified(status); + const previous = state().workflow; + const retained = [plan('01'), summary('01'), report('01')].map(bytes); + await refused('plan', '02', [], 'stale_state'); + const beforeWrongApproval = bytes('.work/state.json'); + const wrongApproval = await runCliAsMain(root, [ + 'lifecycle-transition', 'approve', '--plan', plan('02'), '--authority', 'owner', + '--approval-ref', 'fixture-second-review', '--json', '--no-update-notice', + ]); + assert.equal(wrongApproval.exitCode, 1, wrongApproval.output); + assert.equal(JSON.parse(wrongApproval.output).error_code, 'stale_state'); + assert.deepEqual(bytes('.work/state.json'), beforeWrongApproval); + + const result = await ok('plan', '02', start); + assert.equal(result.changed, true); + const current = state().workflow; + assert.equal(current.plan.path, plan('02')); + assert.equal(current.plan.identity, plan('02')); + assert.equal(current.plan.approved, false); + assert.equal(current.plan.approved_sha256, undefined); + assert.equal(current.approval_ref, null); + for (const stage of ['execution', 'verification', 'audit', 'dogfood']) assert.deepEqual(current[stage], { status: 'not_started' }); + assert.deepEqual(current.plan_history.at(-1).workflow, previous); + assert.deepEqual([plan('01'), summary('01'), report('01')].map(bytes), retained); + await refused('execute', '02', [], 'not_approved'); + await approve('02'); + write(plan('02'), bytes(plan('02')).toString() + '\nChanged after approval\n'); + await refused('execute', '02', [], 'not_approved'); + await approve('02'); + await ok('execute', '02'); + await refused('execute', '01', [], 'not_approved'); + write(summary('02'), '---\nstatus: complete\n---\n# Second execution\n'); + await ok('verify', '02', ['--artifact', summary('02')]); + write(report('02'), '---\nstatus: passed\n---\n# Second verification\n'); + await ok('audit', '02', ['--artifact', report('02')]); + await refused('plan', '01', start, 'new_plan_not_allowed'); + assert.deepEqual([plan('01'), summary('01'), report('01')].map(bytes), retained); + const routed = await runCliAsMain(root, ['next', '--json', '--no-update-notice']); + assert.equal(routed.exitCode, 0, routed.output); + assert.equal(JSON.parse(routed.output).continuity.posture.verification.value, 'passed'); + }); +} + +test('fresh verification reconciles gaps to human-needed and then passed without approving a human gate', async () => { + await verified('gaps_found'); + const originalPlan = state().workflow.plan; + write(report('01'), '---\nstatus: human_needed\n---\n# Browser proof still needs a human\n'); + const reconciled = await ok('fix_gaps', '01', ['--artifact', report('01')]); + assert.equal(reconciled.changed, true); + assert.equal(reconciled.state.workflow.verification.status, 'human_needed'); + assert.deepEqual(reconciled.state.workflow.plan, originalPlan); + const unchanged = bytes('.work/state.json'); + assert.equal((await ok('fix_gaps', '01', ['--artifact', report('01')])).changed, false); + assert.deepEqual(bytes('.work/state.json'), unchanged); + write(report('01'), '---\nstatus: passed\n---\n# Fresh passing evidence\n'); + await refused('fix_gaps', '01', ['--artifact', report('01')], 'gaps_not_recorded'); + const passed = await ok('audit', '01', ['--artifact', report('01')]); + assert.equal(passed.changed, true); + assert.equal(passed.state.workflow.verification.status, 'passed'); + assert.equal(passed.state.workflow.current_state, 'audit'); + assert.equal(passed.state.workflow.human_gate, undefined); + assert.deepEqual(passed.state.workflow.plan, originalPlan); +}); + +test('a passing report cannot approve an explicit owner gate or reconcile unfinished execution', async () => { + await verified('gaps_found'); + write(report('01'), '---\nstatus: passed\n---\n# Fresh report\n'); + const unfinished = state(); + unfinished.workflow.execution.status = 'in_progress'; + write('.work/state.json', JSON.stringify(unfinished)); + await refused('audit', '01', ['--artifact', report('01')], 'execution_incomplete'); + unfinished.workflow.execution.status = 'complete'; + const gate = { approved: false, question: 'Owner signoff is still required' }; + unfinished.workflow.human_gate = gate; + write('.work/state.json', JSON.stringify(unfinished)); + const result = await ok('audit', '01', ['--artifact', report('01')]); + assert.deepEqual(result.state.workflow.human_gate, gate); +}); + +test('new plan cannot discard in-progress execution, blocked work, mismatched identity or existing target evidence', async () => { + await ok('plan', '01'); + await approve('01'); + await ok('execute', '01'); + await refused('plan', '02', start, 'new_plan_not_allowed'); + write(summary('01'), '---\nstatus: complete\n---\n# Execution\n'); + await ok('verify', '01', ['--artifact', summary('01')]); + await refused('plan', '02', ['--start-new'], 'new_plan_not_allowed'); + const valid = state(); + for (const change of [ + s => { s.workflow.plan.identity = plan('02'); }, + s => { s.workflow.current_state = s.current_state = 'complete'; }, + s => { s.workflow.status = s.status = 'blocked'; }, + s => { s.workflow.human_gate = { approved: false, question: 'Owner decision pending' }; }, + ]) { + const changed = structuredClone(valid); + change(changed); + write('.work/state.json', JSON.stringify(changed)); + await refused('plan', '02', start, 'new_plan_not_allowed'); + } + write('.work/state.json', JSON.stringify(valid)); + write(summary('02'), '---\nstatus: complete\n---\n# Retained completed work\n'); + await refused('plan', '02', start, 'new_plan_not_allowed'); +}); + +test('verification reconciliation rejects another chain and conflicting artifact identity', async () => { + await verified('gaps_found'); + write(report('02'), '---\nstatus: human_needed\n---\n# Wrong chain\n'); + await refused('fix_gaps', '01', ['--artifact', report('02')], 'wrong_artifact_identity'); + const changed = state(); + changed.workflow.verification.identity = report('02'); + write('.work/state.json', JSON.stringify(changed)); + await refused('fix_gaps', '01', ['--artifact', report('01')], 'stale_state'); +}); From c9387337052b11fde091837601c4d6a26d3dfe2b Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 18:00:10 +0200 Subject: [PATCH 2/4] fix: route exact multi-plan verification and guard handoff history Agent-Harness: codex-cli Agent-Session: factory-038-fix1-revision --- bin/lib/lifecycle-state.mjs | 54 ++++++++------ bin/lib/next.mjs | 40 ++++++---- bin/lib/work-context.mjs | 12 ++- tests/gsdd.lifecycle-reconciliation.test.cjs | 77 ++++++++++++++++++++ tests/gsdd.next.test.cjs | 40 ++++++++++ 5 files changed, 184 insertions(+), 39 deletions(-) diff --git a/bin/lib/lifecycle-state.mjs b/bin/lib/lifecycle-state.mjs index 56b14f5..641203d 100644 --- a/bin/lib/lifecycle-state.mjs +++ b/bin/lib/lifecycle-state.mjs @@ -778,34 +778,44 @@ export function evaluateStandardPlanClosure({ lifecycle, selection, readFile = r if (!lifecycle || !selection?.candidate) throw new Error('selected phase identity is required'); const plans = selection.candidate.plans || []; const artifacts = lifecycle.phaseArtifacts || []; - const chains = plans.map((plan) => { - const matching = artifacts.filter((artifact) => artifact.chainKey === plan.chainKey); - const summary = matching.find((artifact) => artifact.kind === 'summary') || null; - const verification = matching.find((artifact) => artifact.kind === 'verification') || null; - let verificationStatus = null; - let verificationError = null; - if (verification) { - try { - verificationStatus = readPlanStatus(readFile(verification.path, 'utf-8')); - } catch (error) { - verificationError = error.message; - } - } - return { - plan, - summary, - verification, - verificationStatus, - verificationError, - complete: Boolean(summary && verification && verificationStatus === 'passed'), - }; - }); + const chains = plans.map((plan) => evaluateStandardVerificationChain(plan, artifacts, readFile)); return { complete: plans.length > 0 && chains.every((chain) => chain.complete), chains, }; } +/** Include legacy orphan summaries, using the same exact proof as phase closure. */ +export function evaluateStandardSummaryChains({ lifecycle, readFile = readFileSync } = {}) { + const artifacts = lifecycle?.phaseArtifacts || []; + return artifacts.filter((artifact) => artifact.kind === 'summary') + .map((summary) => evaluateStandardVerificationChain(summary, artifacts, readFile)); +} + +function evaluateStandardVerificationChain(anchor, artifacts, readFile) { + const matching = artifacts.filter((artifact) => artifact.chainKey === anchor.chainKey); + const plan = matching.find((artifact) => artifact.kind === 'plan') || null; + const summary = matching.find((artifact) => artifact.kind === 'summary') || null; + const verification = matching.find((artifact) => artifact.kind === 'verification') || null; + let verificationStatus = null; + let verificationError = null; + if (verification) { + try { + verificationStatus = readPlanStatus(readFile(verification.path, 'utf-8')); + } catch (error) { + verificationError = error.message; + } + } + return { + plan, + summary, + verification, + verificationStatus, + verificationError, + complete: Boolean(summary && verification && verificationStatus === 'passed'), + }; +} + export function evaluateNativePlanClosure({ selection, readFile = readFileSync } = {}) { if (!selection?.candidate) throw new Error('selected native phase identity is required'); const chains = (selection.candidate.plans || []).map((plan) => { diff --git a/bin/lib/next.mjs b/bin/lib/next.mjs index 376b258..68cb096 100644 --- a/bin/lib/next.mjs +++ b/bin/lib/next.mjs @@ -5,7 +5,7 @@ import { buildControlMap } from './control-map.mjs'; import { resolveStateDir, stateAuthorityGate } from './state-dir.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; import { workflowId } from './workflows.mjs'; -import { evaluateLifecycleState, collectNativePhaseArtifacts, partitionPlanChains } from './lifecycle-state.mjs'; +import { evaluateLifecycleState, evaluateStandardSummaryChains, collectNativePhaseArtifacts, partitionPlanChains } from './lifecycle-state.mjs'; import { NEXT_STATES, addOpenQuestion, @@ -791,8 +791,9 @@ function routeNext(ctx, observedContext = null) { }); } - 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 }); + const phaseVerificationRoute = routeFromPhaseVerification(context, lifecycleTruth.lifecycle); + if (phaseVerificationRoute) { + return enrichRoute(phaseVerificationRoute, { context, controlMap, constraints, privacyNotes, inputsConsidered, inputsSkipped, traceRefs }); } const workMilestoneRoute = routeFromWorkMilestone(context, manifest); @@ -1173,20 +1174,29 @@ function defaultWriteArtifacts(state) { return []; } -function hasUnverifiedSummaries(phases, lifecycle) { +function routeFromPhaseVerification(context, 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); + const unresolved = evaluateStandardSummaryChains({ lifecycle }).filter((chain) => { + const roadmapPhase = byToken.get(chain.summary.phaseToken); + // Completed/owner-parked phases and superseded chains remain historical. 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); - }); + }).filter((chain) => !chain.complete); + if (unresolved.length === 0) return null; + const gapStatuses = new Set(['gaps_found', 'human_needed', 'blocked', 'failed', 'fail']); + // Finish missing proof before revisiting other retained chains' findings. + const needsVerification = unresolved.filter((chain) => !gapStatuses.has(chain.verificationStatus)); + const chains = needsVerification.length > 0 ? needsVerification : unresolved; + const artifactPath = (artifact) => normalizeSlashes(relative(context.paths.root, artifact.path)); + return { + state: needsVerification.length > 0 ? 'verify' : 'fix_gaps', + reason: needsVerification.length > 0 + ? `${statePath(context)} phase summaries exist without matching verification reports proving status: passed.` + : `Phase verification has unresolved findings: ${chains.map((chain) => `${artifactPath(chain.verification)} (${chain.verificationStatus})`).join(', ')}.`, + artifacts_to_read: chains.flatMap((chain) => [chain.plan, chain.summary, chain.verification].filter(Boolean).map(artifactPath)), + artifacts_to_write: chains.map((chain) => chain.verification + ? artifactPath(chain.verification) + : artifactPath(chain.summary).replace(/-SUMMARY\.md$/i, '-VERIFICATION.md')), + }; } function findTrustGate(manifest) { diff --git a/bin/lib/work-context.mjs b/bin/lib/work-context.mjs index 2bdadca..89f6b75 100644 --- a/bin/lib/work-context.mjs +++ b/bin/lib/work-context.mjs @@ -249,6 +249,10 @@ export function hasDurableWorkflowApproval(workDir, normalizedPlan, stateValue = : { exists: true, ok: true, value: stateValue }; if (!current.exists || !current.ok || !current.value || typeof current.value !== 'object' || Array.isArray(current.value)) return false; const workflow = workflowStateWithDefaults(current.value.workflow); + return workflow.authority === 'owner' && hasWorkflowPlanApprovalProof(workDir, normalizedPlan, workflow); +} + +function hasWorkflowPlanApprovalProof(workDir, normalizedPlan, workflow) { const recordedPlan = workflow.plan; const expected = normalizeSlashes(String(normalizedPlan || '')).replace(/^\.\//, ''); const recordedPath = normalizeSlashes(String(recordedPlan.path || '')).replace(/^\.\//, ''); @@ -259,7 +263,6 @@ export function hasDurableWorkflowApproval(workDir, normalizedPlan, stateValue = && recordedIdentity === expected && typeof recordedPlan.approved_sha256 === 'string' && recordedPlan.approved_sha256 === currentPlanSha256 - && workflow.authority === 'owner' && isValidApprovalReference(workflow.approval_ref); } @@ -458,6 +461,11 @@ export function transitionWorkflowState(workDir, { if (!previous || !selected || previous.chainKey === selected.chainKey || previous.dir !== selected.dir || !phase || phase.status === 'done' || phase.parked || workflow.plan.path !== recordedPlan || workflow.plan.identity !== recordedPlan + // Phase verify/audit transitions legitimately record workflow authority. + // Retaining their approval proof still requires the exact owner-approved + // bytes and reference; this does not authorize another execution. + || !['owner', 'workflow'].includes(workflow.authority) + || !hasWorkflowPlanApprovalProof(workDir, recordedPlan, workflow) || !['verify', 'audit', 'fix_gaps'].includes(workflow.current_state || state.current_state) || (state.current_state && state.current_state !== workflow.current_state) || state.status === 'blocked' || state.status === 'paused' @@ -473,7 +481,7 @@ export function transitionWorkflowState(workDir, { || lifecycle.phaseArtifacts.some(item => item.chainKey === selected.chainKey && ['summary', 'verification'].includes(item.kind)) || !planArtifactSha256(workDir, expectedPlan)) { throw transitionError('new_plan_not_allowed', - 'Start-new requires a distinct unexecuted current plan in the same open phase, consistent completed execution proof, and no blocked or pending owner gate. Retained plans cannot be reopened.', + 'Start-new requires a distinct unexecuted current plan in the same open phase, intact prior approval and completed execution proof, and no blocked or pending owner gate. Retained plans cannot be reopened.', [String(recordedPlan || ''), String(expectedPlan || ''), '.work/state.json']); } }; diff --git a/tests/gsdd.lifecycle-reconciliation.test.cjs b/tests/gsdd.lifecycle-reconciliation.test.cjs index 4a46845..162b77b 100644 --- a/tests/gsdd.lifecycle-reconciliation.test.cjs +++ b/tests/gsdd.lifecycle-reconciliation.test.cjs @@ -53,6 +53,17 @@ async function verified(status, id = '01') { await ok(status === 'passed' ? 'audit' : 'fix_gaps', id, ['--artifact', report(id)]); } const start = ['--start-new', '--reason', 'Start the independently scoped second plan; retain first-plan proof']; +async function nextRoute() { + const result = await runCliAsMain(root, ['next', '--json', '--no-update-notice']); + assert.equal(result.exitCode, 0, result.output); + return JSON.parse(result.output); +} +async function closePhase(allowed) { + const before = bytes('.work/ROADMAP.md'); + const result = await runCliAsMain(root, ['phase-status', 'phases/01-parallel-issues', 'done']); + assert.equal(result.exitCode, allowed ? 0 : 1, result.output); + if (!allowed) assert.deepEqual(bytes('.work/ROADMAP.md'), before); +} beforeEach(async () => { root = createTempProject(); @@ -180,3 +191,69 @@ test('verification reconciliation rejects another chain and conflicting artifact write('.work/state.json', JSON.stringify(changed)); await refused('fix_gaps', '01', ['--artifact', report('01')], 'stale_state'); }); + +test('next verifies the exact second summary before phase closeout despite a passed first report', async () => { + await verified('passed'); + await ok('plan', '02', start); + await approve('02'); + await ok('execute', '02'); + write(summary('02'), '---\nstatus: complete\n---\n# Second execution\n'); + await ok('verify', '02', ['--artifact', summary('02')]); + const routed = await nextRoute(); + assert.equal(routed.state, 'verify', JSON.stringify(routed)); + assert.ok(routed.artifacts_to_read.includes(summary('02'))); + assert.ok(routed.artifacts_to_write.includes(report('02'))); + await closePhase(false); + write(report('02'), '---\nstatus: passed\n---\n# Second verification\n'); + await ok('audit', '02', ['--artifact', report('02')]); + assert.notEqual((await nextRoute()).state, 'verify'); + await closePhase(true); +}); + +test('next retains old human-needed debt after the later plan passes and rereads the old report', async () => { + await verified('human_needed'); + const retained = bytes(report('01')); + await ok('plan', '02', start); + await approve('02'); + await ok('execute', '02'); + write(summary('02'), '---\nstatus: complete\n---\n# Second execution\n'); + await ok('verify', '02', ['--artifact', summary('02')]); + write(report('02'), '---\nstatus: passed\n---\n# Second verification\n'); + await ok('audit', '02', ['--artifact', report('02')]); + let routed = await nextRoute(); + assert.equal(routed.state, 'fix_gaps', JSON.stringify(routed)); + assert.ok(routed.artifacts_to_read.includes(report('01'))); + assert.match(routed.reason, /human_needed/); + assert.deepEqual(bytes(report('01')), retained); + await closePhase(false); + write(report('01'), '---\nstatus: passed\n---\n# Fresh first-plan verification\n'); + routed = await nextRoute(); + assert.notEqual(routed.state, 'fix_gaps'); + assert.equal(state().workflow.plan_history[0].workflow.verification.status, 'human_needed'); + await closePhase(true); +}); + +test('start-new refuses tampered prior approval proof without changing state or artifacts', async () => { + await verified('passed'); + const validState = bytes('.work/state.json'); + const original = bytes(plan('01')); + const retained = [summary('01'), report('01'), plan('02')].map(bytes); + write(plan('01'), original.toString() + '\nChanged after approved execution\n'); + await refused('plan', '02', start, 'new_plan_not_allowed'); + assert.deepEqual(bytes(plan('01')), Buffer.from(original.toString() + '\nChanged after approved execution\n')); + write(plan('01'), original); + for (const change of [ + s => { s.workflow.plan.approved_sha256 = '0'.repeat(64); }, + s => { s.workflow.plan.approved = false; }, + s => { s.workflow.approval_ref = null; }, + s => { s.workflow.authority = 'unknown'; }, + ]) { + const changed = JSON.parse(validState); + change(changed); + write('.work/state.json', JSON.stringify(changed)); + await refused('plan', '02', start, 'new_plan_not_allowed'); + } + write('.work/state.json', validState); + await ok('plan', '02', start); + assert.deepEqual([summary('01'), report('01'), plan('02')].map(bytes), retained); +}); diff --git a/tests/gsdd.next.test.cjs b/tests/gsdd.next.test.cjs index b2f144a..88e3b2f 100644 --- a/tests/gsdd.next.test.cjs +++ b/tests/gsdd.next.test.cjs @@ -2569,6 +2569,46 @@ describe('next command routing', () => { assert.match(result.reason, /summaries exist without matching verification/); }); + test('standard verification routing joins exact orphan chains and rereads report status', async () => { + await initWork(); + writeFile('.work/SPEC.md', '# Spec\n'); + writeFile('.work/ROADMAP.md', '# Roadmap\n- [-] **Phase 1: Verify**\n'); + writeFile('.work/MILESTONES.md', '# Milestones\n'); + writeFile('.work/phases/01-verify/01-SUMMARY.md', '# First orphan summary\n'); + writeFile('.work/phases/01-verify/01-VERIFICATION.md', '---\nstatus: passed\n---\n'); + writeFile('.work/phases/01-verify/02-SUMMARY.md', '# Second orphan summary\n'); + let routed = await runJson(['next', '--json']); + assert.strictEqual(routed.state, 'verify'); + assert.ok(routed.artifacts_to_write.includes('.work/phases/01-verify/02-VERIFICATION.md')); + for (const status of ['gaps_found', 'human_needed', 'failed', 'blocked', null, 'passed']) { + writeFile('.work/phases/01-verify/02-VERIFICATION.md', status ? `---\nstatus: ${status}\n---\n` : '# Unproven report\n'); + routed = await runJson(['next', '--json']); + assert.strictEqual(routed.state, status === 'passed' ? 'plan' : status ? 'fix_gaps' : 'verify', status); + } + }); + + test('multi-plan verification debt respects done, owner-parked and superseded phase semantics', async () => { + await initWork(); + writeFile('.work/SPEC.md', '# Spec\n'); + writeFile('.work/MILESTONES.md', '# Milestones\n'); + for (const id of ['01', '02']) { + writeFile(`.work/phases/01-verify/${id}-PLAN.md`, '# Plan\n'); + writeFile(`.work/phases/01-verify/${id}-SUMMARY.md`, '# Summary\n'); + } + writeFile('.work/phases/01-verify/01-VERIFICATION.md', '---\nstatus: human_needed\n---\n'); + for (const roadmap of [ + '# Roadmap\n- [x] **Phase 1: Verify**\n', + '# Roadmap\n- [ ] **Phase 1: Verify** - Parked by owner until review.\n', + ]) { + writeFile('.work/ROADMAP.md', roadmap); + const routed = await runJson(['next', '--json']); + assert.strictEqual(routed.state, 'plan', JSON.stringify(routed)); + } + writeFile('.work/ROADMAP.md', '# Roadmap\n- [-] **Phase 1: Verify**\n'); + for (const id of ['01', '02']) writeFile(`.work/phases/01-verify/${id}-PLAN.md`, '---\nstatus: superseded\n---\n'); + assert.strictEqual((await runJson(['next', '--json'])).state, 'plan'); + }); + test('historical-only standard plan chain keeps every companion historical and routes to plan', async () => { await initWork(); writeFile('.work/SPEC.md', '# Spec\n'); From 7ad153765ff89c80f21116dc083e25db23128d7f Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 18:28:46 +0200 Subject: [PATCH 3/4] fix: expose installed Workspine workflows in root AGENTS Agent-Harness: codex-cli Agent-Session: factory-038-fix6 --- README.md | 6 +- bin/lib/init-flow.mjs | 2 + bin/lib/init-runtime.mjs | 7 +- bin/lib/manifest.mjs | 21 ++- bin/lib/rendering.mjs | 40 ++--- bin/lib/setup.mjs | 36 ++--- distilled/DESIGN.md | 4 +- distilled/README.md | 6 +- distilled/SKILL.md | 4 +- distilled/templates/agents.block.md | 8 +- docs/RUNTIME-SUPPORT.md | 2 +- docs/USER-GUIDE.md | 14 +- tests/gsdd.setup.test.cjs | 239 +++++++++++++++++++++++++++- 13 files changed, 319 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index ee5a6f4..5042778 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ npx -y workspine setup That command is interactive when a terminal is available. In CI or another headless/non-TTY environment, use `npx -y workspine setup --yes` so Workspine has explicit permission to perform the bounded setup writes. -Setup adds Workspine to the current repository: durable project state in `.work/` and workflow skills in -`.agents/skills/work-*`. Runtime-specific adapters are optional layers on top of that shared skill surface. +Setup adds Workspine to the current repository: durable project state in `.work/`, workflow skills in +`.agents/skills/work-*`, and a concise root `AGENTS.md` guide pointing to those skills. Existing consumer +governance is preserved; `setup --no-agents` skips the root guide. Runtime-specific adapters are optional +layers on top of the shared skill surface. For a self-contained task with clear decisions and checks, your agent's normal workflow may be enough. Use Workspine when you want the plan, decisions you approve, verification, and next-session handoff kept diff --git a/bin/lib/init-flow.mjs b/bin/lib/init-flow.mjs index fefd2ae..10cba3e 100644 --- a/bin/lib/init-flow.mjs +++ b/bin/lib/init-flow.mjs @@ -368,6 +368,7 @@ export function createCmdInit(ctx) { targets: migrationAdapterTargets, manifest: readManifest(state.legacyDir), stateDirName: initCtx.stateDirName, + allowUnmarkedRootAgents: initCtx.allowUnmarkedRootAgents === true, }); migrateLegacyState(initCtx.cwd); } catch (error) { @@ -428,6 +429,7 @@ export function createCmdInit(ctx) { targets: initAdapterTargets, manifest: readManifest(planningDir), stateDirName, + allowUnmarkedRootAgents: initCtx.allowUnmarkedRootAgents === true, }); preflightPlanningCliHelpersReadOnly(initCtx); applyAdapterRecovery(adapterPlan); diff --git a/bin/lib/init-runtime.mjs b/bin/lib/init-runtime.mjs index 75f86f6..a61900b 100644 --- a/bin/lib/init-runtime.mjs +++ b/bin/lib/init-runtime.mjs @@ -276,7 +276,7 @@ export function getPostInitRoutingLines(selectedRuntimes) { // `install` lists --local/--verify-runtime/--live-runtime because it recognises them only to // 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 }, + setup: { '--global': false, '-g': false, '--agent': true, '--all': false, '--no-agents': false, '-y': false, '--yes': false, '--dry-run': false, '--dry': false, '--workspace-root': true, '--migrate': false }, 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 }, @@ -451,7 +451,7 @@ export function validateCommandShape(command, commandArgs = []) { if (commandArgs.includes('--help') || commandArgs.includes('-h')) return null; const positionals = commandPositionals(command, commandArgs); const usage = { - setup: 'Usage: workspine setup [-g|--global] [--agent ] [--all] [-y|--yes] [--dry-run] [--workspace-root ] [--migrate]', + setup: 'Usage: workspine setup [-g|--global] [--agent ] [--all] [--no-agents] [-y|--yes] [--dry-run] [--workspace-root ] [--migrate]', init: 'Usage: workspine init [flags]', update: 'Usage: workspine update [--dry-run] [-g|--global]', health: 'Usage: workspine health [--json] [-g|--global] [--workspace-root ]', @@ -562,7 +562,8 @@ First use: work-quick is the lighter shortcut; work-new-project is for fuzzy or broader scope Everyday commands: - setup [-y|--yes] [--dry-run] Set up portable skills in this repo + setup [-y|--yes] [--dry-run] Set up portable skills and root AGENTS.md guidance + --no-agents Skip root guidance (project setup only) health [--json] [-g|--global] Check generated files without changing them update [--dry-run] [-g|--global] Repair or refresh generated files next [--json] Show the next file-backed action diff --git a/bin/lib/manifest.mjs b/bin/lib/manifest.mjs index 039cfd5..738ff9b 100644 --- a/bin/lib/manifest.mjs +++ b/bin/lib/manifest.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'; import { writeFileAtomic } from './atomic-write.mjs'; import { ADAPTER_SOURCE_FILES } from '../adapters/index.mjs'; import { + findBoundedBlock, renderAgentsBoundedBlock, renderSkillContent, upsertBoundedBlock, @@ -459,7 +460,7 @@ export function applyHistoricalAdapterPruning(plan, { isDry = false } = {}) { * Read-only preflight for all local generated adapter/shared-skill targets. * No directory, recovery, manifest, or target bytes are written here. */ -export function planAdapterGeneration({ cwd, planningDir, targets, manifest, stateDirName = '.work', requireManifest = false, requireExistingNativeTargets = false, replacementHashes = null }) { +export function planAdapterGeneration({ cwd, planningDir, targets, manifest, stateDirName = '.work', requireManifest = false, requireExistingNativeTargets = false, replacementHashes = null, allowUnmarkedRootAgents = false }) { const manifestPath = join(planningDir, MANIFEST_FILENAME); const hasManifest = existsSync(manifestPath); const ownership = adapterOwnershipEntries(manifest); @@ -498,15 +499,21 @@ export function planAdapterGeneration({ cwd, planningDir, targets, manifest, sta continue; } + // Validate root markers even for manifest-owned files, before recovery or + // any generated writes. Rendering uses this same parser for replacement. + const rootBlock = relativePath === 'AGENTS.md' + ? findBoundedBlock(readFileSync(absolutePath, 'utf-8')) : null; const owned = ownership?.get(relativePath); if (!owned) { - // A pre-existing AGENTS.md with the bounded marker is the one - // compatibility case where init may extend a consumer file before the - // first local manifest exists. The adapter's bounded upsert preserves - // everything outside that marker; an unmarked file remains a collision. + // Init may refresh an existing bounded AGENTS.md before the first local + // manifest exists. Setup can also request a first bounded insertion; + // the adapter preserves everything outside the managed block. if (!hasManifest && target.adapter === 'agents') { - const existing = readFileSync(absolutePath, 'utf-8'); - if (existing.includes('') && existing.includes('')) continue; + if (rootBlock) continue; + // First-time setup may append a bounded guide to this one consumer + // file. Keep init/update collision rules and filesystem checks intact; + // partial markers must be resolved rather than silently duplicated. + if (allowUnmarkedRootAgents && !requireManifest && relativePath === 'AGENTS.md') continue; } const reason = hasManifest ? 'is not generation-manifest-owned' diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index 12aa5be..6a83b93 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -358,8 +358,21 @@ function renderAgentsFileContent(options = {}) { return `# AGENTS.md - GSDD Governance\n\n\n${block}\n\n`; } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function findBoundedBlock(existing) { + const begins = [...existing.matchAll(//g)]; + const ends = [...existing.matchAll(//g)]; + if (begins.length === 0 && ends.length === 0) return null; + + const isStandalone = (match) => { + const after = match.index + match[0].length; + return (match.index === 0 || existing[match.index - 1] === '\n') + && (after === existing.length || existing[after] === '\n' || existing.startsWith('\r\n', after)); + }; + if (begins.length !== 1 || ends.length !== 1 + || begins[0].index >= ends[0].index || !isStandalone(begins[0]) || !isStandalone(ends[0])) { + throw new Error('Refusing adapter update: AGENTS.md has malformed GSDD markers; require exactly one standalone BEGIN/END pair in that order. Preserve the consumer policy and repair the markers before retrying.'); + } + return { start: begins[0].index, end: ends[0].index + ends[0][0].length }; } function upsertBoundedBlock(existing, blockContent) { @@ -367,29 +380,18 @@ function upsertBoundedBlock(existing, blockContent) { const end = ''; const bounded = `${begin}\n${blockContent.trimEnd()}\n${end}`; - const re = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}`, 'm'); - if (re.test(existing)) return existing.replace(re, bounded); - - const lines = existing.split(/\r?\n/); - const h1Idx = lines.findIndex((line) => /^#\s+/.test(line)); - if (h1Idx !== -1) { - const insertAt = h1Idx + 1; - const out = [ - ...lines.slice(0, insertAt), - '', - bounded, - '', - ...lines.slice(insertAt), - ]; - return out.join('\n').replace(/\n{3,}/g, '\n\n'); - } + const range = findBoundedBlock(existing); + if (range) return existing.slice(0, range.start) + bounded + existing.slice(range.end); - return `${bounded}\n\n${existing}`.replace(/\n{3,}/g, '\n\n'); + // Append without normalizing consumer whitespace or line endings. Only the + // bounded block is replaced on later runs; all surrounding bytes stay intact. + return `${existing}${existing.endsWith('\n') ? '\n' : '\n\n'}${bounded}\n`; } export { buildPlanningCliHelperEntries, buildPortableSkillEntries, + findBoundedBlock, getDelegateContent, getWorkflowContent, localizeStateDirReferences, diff --git a/bin/lib/setup.mjs b/bin/lib/setup.mjs index ea788e7..e3ce1d6 100644 --- a/bin/lib/setup.mjs +++ b/bin/lib/setup.mjs @@ -7,7 +7,6 @@ import { promptConfirm, promptMultiSelect } from './init-prompts.mjs'; import { parseFlagValue } from './cli-utils.mjs'; import { GLOBAL_AGENT_OPTIONS, validateCommandShape } from './init-runtime.mjs'; import { resolveStateDir, stateAuthorityGate } from './state-dir.mjs'; -import { migrateLegacyState } from './state-migration.mjs'; import { buildDefaultConfig } from './config.mjs'; const PROJECT_AGENTS = new Set(['claude', 'opencode', 'codex', 'generic']); @@ -16,7 +15,7 @@ const PROJECT_ALL = ['claude', 'opencode', 'codex', 'agents']; const GLOBAL_ALL = ['claude', 'opencode', 'codex', 'copilot']; function setupUsage() { - return 'Usage: workspine setup [-g|--global] [--agent ] [--all] [-y|--yes] [--dry-run] [--workspace-root ] [--migrate]'; + return 'Usage: workspine setup [-g|--global] [--agent ] [--all] [--no-agents] [-y|--yes] [--dry-run] [--workspace-root ] [--migrate]'; } function gitTopLevel(cwd) { @@ -51,12 +50,12 @@ function setupRoot(rawArgs, cwd) { return { root: gitTopLevel(cwd) ?? resolve(cwd) }; } -function selectedProjectTools(agent, all) { - if (all) return [...PROJECT_ALL]; - if (agent === 'generic') return ['agents']; - // `portable` is an intentionally internal init-flow selector: it satisfies the retained - // init --auto grammar while leaving optional root AGENTS.md governance opt-in. - return agent ? [agent] : ['portable']; +function selectedProjectTools(agent, all, noAgents) { + const tools = all ? [...PROJECT_ALL] : (agent && agent !== 'generic' ? [agent, 'agents'] : ['agents']); + const selected = noAgents ? tools.filter((tool) => tool !== 'agents') : tools; + // The internal portable selector bypasses init's runtime wizard without + // selecting any native adapter. Shared skills are always generated. + return selected.length ? selected : ['portable']; } function detectGlobalTargets(roots) { @@ -115,6 +114,7 @@ export function createCmdSetup(ctx) { const globalScope = setupArgs.includes('-g') || setupArgs.includes('--global'); const all = setupArgs.includes('--all'); + const noAgents = setupArgs.includes('--no-agents'); const yes = setupArgs.includes('-y') || setupArgs.includes('--yes'); const dryRun = setupArgs.includes('--dry-run') || setupArgs.includes('--dry'); let migrate = setupArgs.includes('--migrate'); @@ -127,6 +127,7 @@ export function createCmdSetup(ctx) { } if (all && agent) return fail('--all and --agent cannot be combined. Choose one target selection.'); if (migrate && globalScope) return fail('--migrate applies only to project setup.'); + if (noAgents && globalScope) return fail('--no-agents applies only to project setup.'); const scope = globalScope ? 'global' : 'project'; const interactive = Boolean(process.stdin.isTTY && !yes && !dryRun); @@ -182,12 +183,13 @@ export function createCmdSetup(ctx) { } } - const tools = selectedProjectTools(agent, all); + const tools = selectedProjectTools(agent, all, noAgents); if (!agent && !all && detectGlobalTargets(resolveGlobalInstallRoots(ctx.globalInstallRootOptions)).includes('claude')) { console.log('Claude home detected. For Claude skills in a fresh repo: npx -y workspine setup --agent claude --yes. After portable setup, add them with: npx -y workspine init --tools claude'); } if (dryRun) return printProjectDryRun(root, tools); - if (!yes && !(await confirmSetup({ promptApi: ctx.setupPromptApi, details: [`Bounded write set: .work/, .agents/skills/work-*, and ${tools.join(', ')} selected project surfaces.`] }))) { + const rootGuidance = tools.includes('agents') ? ' Root AGENTS.md gets a bounded workflow guide; existing consumer content is preserved. Use --no-agents to skip it.' : ''; + if (!yes && !(await confirmSetup({ promptApi: ctx.setupPromptApi, details: [`Bounded write set: .work/, .agents/skills/work-*, and ${tools.join(', ')} selected project surfaces.${rootGuidance}`] }))) { return fail('Setup cancelled; no files were written.'); } @@ -198,17 +200,13 @@ export function createCmdSetup(ctx) { // contextAtWorkspaceRoot then rebuilds cwd-bound native adapters when the // selected workspace differs. Pre-setting cwd=root would make that rebind // look unnecessary while retaining adapter closures for the invocation cwd. - const initCtx = { ...ctx, initPromptApi }; - if (migrate) { - try { - migrateLegacyState(root); - } catch (error) { - return fail(`Legacy state migration failed: ${error.message}`); - } + const initCtx = { ...ctx, initPromptApi, allowUnmarkedRootAgents: !noAgents }; + if (migrate && state.status !== 'legacy_migratable') { + return fail(`Legacy state migration failed: Legacy state is not migratable (${state.status}${state.reason ? `: ${state.reason}` : ''}).`); } // Setup owns consent and explicit scope/target flags; init-flow owns the established filesystem - // preflight and generation transaction. Supplying targets avoids reopening its runtime wizard. - return createCmdInit(initCtx)('--tools', tools.join(','), '--workspace-root', root); + // preflight and migration transaction. Supplying targets avoids reopening its runtime wizard. + return createCmdInit(initCtx)('--tools', tools.join(','), '--workspace-root', root, ...(migrate ? ['--migrate'] : [])); }; } diff --git a/distilled/DESIGN.md b/distilled/DESIGN.md index 185e3a6..2066c4c 100644 --- a/distilled/DESIGN.md +++ b/distilled/DESIGN.md @@ -1647,9 +1647,11 @@ That conflation was survivable while Cursor and Copilot were incorrectly treated **Key architectural rule:** runtime selection and adapter generation are separate concerns. - In the wizard, choosing Cursor/Copilot/Gemini affects post-init routing and user-facing install intent, but does **not** silently write root `AGENTS.md`. -- Root `AGENTS.md` is generated only when the user explicitly opts into governance (wizard) or explicitly requests a governance-writing flag path (`--tools agents`, `--tools all`, or legacy runtime aliases). +- Lower-level `init` generates root `AGENTS.md` only when the user explicitly opts into governance (wizard) or explicitly requests a governance-writing flag path (`--tools agents`, `--tools all`, or legacy runtime aliases). - This keeps D35's capability split honest instead of re-conflating skills-native runtime choice with governance-file generation. +**Normal setup refinement (FACTORY-038 #6):** Project `setup` now includes the same concise root routing guide by default, with `--no-agents` as an explicit opt-out. This makes installed workflows visible to an agent reading root instructions without replacing `.agents/skills/work-*/SKILL.md`. First-time setup may append a bounded guide to an unmarked consumer `AGENTS.md`, preserving its existing bytes; partial markers and unsafe filesystem targets still fail closed. Lower-level `init` selection and collision rules remain unchanged, and existing setup reruns remain zero-write. Updates retain manifest ownership/recovery and replace only the bounded guide. + **Phase 17 refinement:** - The guided config path was reduced from 13 visible prompts to 5. diff --git a/distilled/README.md b/distilled/README.md index 42430d6..74e9672 100644 --- a/distilled/README.md +++ b/distilled/README.md @@ -29,7 +29,7 @@ Workspine is a small set of workflow sources plus the `workspine` CLI that: - scaffolds a project planning workspace (`.work/`) - generates compact open-standard workflow entrypoints as skills (`.agents/skills/work-*/SKILL.md`) - generates an internal repo-local helper runtime at `.work/bin/gsdd.mjs` for deterministic workflow commands run from the repo root -- optionally generates tool-specific adapters for runtimes that need extra native surfaces (root `AGENTS.md`, Claude skills + plan-command alias + native agents, OpenCode commands + native agents, Codex CLI checker agent) +- normal project `setup` also adds a bounded root `AGENTS.md` routing guide (`--no-agents` skips it); native adapters remain optional (Claude skills + plan-command alias + native agents, OpenCode commands + native agents, Codex CLI checker agent) It gives serious AI-assisted work one durable, repo-native workflow for planning, checking, execution, verification, and handoff — plain files, no hosted service. @@ -81,12 +81,12 @@ Notes: - `--tools claude` also generates native agents at `.claude/agents/work-*.md` and a compatibility plan command alias at `.claude/commands/work-plan.md`. - `--tools opencode` also generates native agents at `.opencode/agents/work-*.md`. - `--tools codex` generates `.codex/agents/work-plan-checker.toml` and `.codex/agents/work-approach-explorer.toml`; the portable `.agents/skills/work-plan/` surface remains the Codex entry path and internal helper commands route through `.work/bin/gsdd.mjs`. -- Root `AGENTS.md` is only written when explicitly requested (`--tools agents`, `--tools all`, legacy runtime aliases, or the wizard governance opt-in). Governance and native adapter surfaces are optional ergonomics; the compact `.agents/skills/` files remain the baseline agent entrypoints. +- Project `setup` adds concise root `AGENTS.md` guidance by default, preserving existing consumer content; `setup --no-agents` skips it. Lower-level `init` retains explicit selection (`--tools agents`, `--tools all`, legacy runtime aliases, or the wizard governance opt-in). The root guide routes to workflows; compact `.agents/skills/` files remain the baseline agent entrypoints. ## The Workflow ``` -npx -y workspine setup -> bootstrap (create .work/, copy templates, generate portable skills) +npx -y workspine setup -> bootstrap (create .work/, copy templates, generate portable skills and root guidance) /work-new-project -> .work/SPEC.md + .work/ROADMAP.md (questioning + codebase audit + research) /work-plan N -> phases/N/PLAN.md (task breakdown + research) /work-execute N -> code changes (plan execution with quality gates) diff --git a/distilled/SKILL.md b/distilled/SKILL.md index e6a5ee2..5c60ca2 100644 --- a/distilled/SKILL.md +++ b/distilled/SKILL.md @@ -69,9 +69,9 @@ npx -y workspine init --tools agents Behavior: - Always: generates open-standard skills at `.agents/skills/work-*/SKILL.md` by embedding `distilled/workflows/*.md`, plus repo-local deterministic helpers at `.work/bin/gsdd.mjs` (or `.planning/bin/gsdd.mjs` in legacy workspaces). -- Optional: generates tool adapters (root `AGENTS.md`, Claude `.claude/skills` + `.claude/commands` alias + `.claude/agents`, OpenCode `.opencode/commands` + `.opencode/agents`, Codex CLI `.codex/agents/work-plan-checker.toml`). +- Project `setup` adds bounded root `AGENTS.md` guidance by default (`--no-agents` skips it). Native tool adapters remain optional (Claude `.claude/skills` + `.claude/commands` alias + `.claude/agents`, OpenCode `.opencode/commands` + `.opencode/agents`, Codex CLI `.codex/agents/work-plan-checker.toml`). - Codex CLI: uses the portable skill entry surface and the generated `.codex/agents/` checker/approach-explorer agents; it does not use `.codex/AGENTS.md` as the primary integration path. -- Root `AGENTS.md` is only written when explicitly requested (so we do not pollute existing user governance). +- Setup preserves existing user-authored root governance and routes to portable skills without replacing them. Lower-level `init` keeps its explicit governance opt-in and `--tools` behavior. diff --git a/distilled/templates/agents.block.md b/distilled/templates/agents.block.md index 7c6863f..c655be5 100644 --- a/distilled/templates/agents.block.md +++ b/distilled/templates/agents.block.md @@ -5,10 +5,10 @@ Managed by `gsdd`; edit the framework template, not this block. Lifecycle: `new-project -> plan -> execute -> verify -> audit-milestone`. Core skills: `work-new-project`, `work-plan`, `work-execute`, `work-verify`, `work-progress`. -Planning state: `.work/` (legacy `.planning/` workspaces are still read). Portable workflows: `.agents/skills/work-*/SKILL.md`. -Install/repair: `npx -y workspine init` creates repo-local skills and planning state; `npx -y workspine health` verifies repo-local generated surfaces; `npx -y workspine update` repairs repo-local drift. `npx -y workspine install --global` is for fresh personal-agent installation; inspect an existing global install with `npx -y workspine health --global` and follow its safe-update or manual-resolution guidance. +Planning state: `.work/` (supported legacy `.planning/` state requires explicit migration). Portable workflows: `.agents/skills/work-*/SKILL.md`. +Install/repair: `npx -y workspine setup` creates repo-local skills, planning state and this routing guide (`--no-agents` skips it); `npx -y workspine init` remains the advanced entrypoint. `npx -y workspine health` checks generated surfaces; `npx -y workspine update` repairs drift. For an existing global install, use `npx -y workspine health --global` and follow its safe-update or manual-resolution guidance. This guide points to portable skills; it does not replace them or install native adapters. -Invoke: `/work-plan` (Claude, OpenCode; Cursor/Copilot/Gemini when skill discovery is available) · `$work-plan` (Codex CLI, plan-only until `$work-execute`) · open SKILL.md directly elsewhere. +Invoke: `/work-plan` (Claude, OpenCode; Cursor/Copilot/Gemini when skill discovery is available) · `$work-plan` (Codex CLI, plan-only until explicit owner approval and `$work-execute`) · open SKILL.md directly elsewhere. Native adapters: `setup --agent ` in a fresh repo; `init --tools ` in an existing workspace. Rules: 1. Read before writing roadmap work: `.work/SPEC.md`, `.work/ROADMAP.md`, `.work/config.json`, and the relevant phase plan when one exists; use matching `.planning/` paths only in legacy workspaces. @@ -18,4 +18,4 @@ Rules: 5. Do not pollute core workflows with vendor-specific syntax; workflow entry lives in `.agents/skills/`, helpers in `.work/bin/`, and native adapters in their tool-specific directories. 6. Git guidance in `.work/config.json` -> `gitProtocol` is advisory; follow the repo's own conventions first. -If `.work/` is missing, run `npx -y workspine init` then `work-new-project`; bare `gsdd init` is equivalent only when globally installed. +If `.work/` is missing, run `npx -y workspine setup`; then use `work-plan` for a bounded change or `work-new-project` for broader scope. Bare `gsdd` commands require a global install. diff --git a/docs/RUNTIME-SUPPORT.md b/docs/RUNTIME-SUPPORT.md index 04909ed..08c68b3 100644 --- a/docs/RUNTIME-SUPPORT.md +++ b/docs/RUNTIME-SUPPORT.md @@ -8,7 +8,7 @@ The package runtime floor is Node >=22. Update awareness is limited to the suppo Human repo setup and repair commands in this document use `npx -y workspine ...` because that works without a global install. If you installed `workspine` globally, the equivalent bare `gsdd ...` command is fine. For fresh cross-repo setup, run `npx -y workspine install --global` interactively or pass `--tools `. For an existing Workspine-owned global install, run `npx -y workspine health --global` first; it emits `update --global` only when the whole discovered set is safe to reconcile automatically. `--auto` detects existing agent homes for install/setup and is not the generic repair path. -Normal first use starts with `npx -y workspine setup`. The lower-level `npx -y workspine init` command remains available for compatibility and scripted advanced setup. +Normal first use starts with `npx -y workspine setup`. Project setup also creates a bounded root `AGENTS.md` guide pointing to the installed portable skills and native invocation paths, preserving existing consumer content. `setup --no-agents` skips root guidance. This routing guide does not replace skill discovery. The lower-level `npx -y workspine init` command retains its explicit governance selection for compatibility and scripted advanced setup. The install contract is deliberately skills-first: `npx -y workspine init` always creates `.agents/skills/work-*` and `.work/bin/gsdd*`; runtime-specific adapters are optional discovery or orchestration helpers layered on top. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 353f495..0169eff 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -13,7 +13,8 @@ Use Workspine when you want the plan, owner decisions, verification, and continu 1. **Set up the repo.** From its root, run `npx -y workspine setup` with Node `>=22` in an interactive terminal. In CI or another headless/non-TTY environment, use `npx -y workspine setup --yes`. Setup creates `.work/` - and the shared `.agents/skills/work-*` workflow entry surface; runtime-specific adapters are optional. + and the shared `.agents/skills/work-*` workflow entry surface, plus a root `AGENTS.md` routing guide; + runtime-specific adapters are optional. Use `setup --no-agents` to skip the root guide. 2. **Describe one change in chat.** Ask your agent to use `work-plan` and state the outcome and constraints. Enter through the discovered skill, commonly `/work-plan`, `$work-plan`, or a skill reference. If discovery is unavailable, ask the agent to read and follow `.agents/skills/work-plan/SKILL.md`. @@ -267,7 +268,14 @@ In CI or another headless/non-TTY environment, setup requires explicit write con npx -y workspine setup --yes ``` -Setup defaults to recommended portable files in the current repo. Use `setup --global` for personal agent homes, +Setup defaults to portable files and a bounded root `AGENTS.md` guide in the current repo. It appends the guide +to a pre-existing unmarked file without changing its existing bytes; later refreshes replace only the managed +`BEGIN GSDD` / `END GSDD` block. `setup --no-agents` leaves root governance untouched (and still installs +portable skills and any requested native adapters). The flag applies only to project setup and overrides +`--agent generic` or `--all` for root guidance. Repeat setup writes nothing when `.work/` already exists; +use `init --tools agents` to add root guidance to an older workspace through the existing ownership checks. +The lower-level `init` wizard governance choice and explicit `--tools` behavior remain unchanged. +Use `setup --global` for personal agent homes, `setup --agent ` for one native target, `setup --all` for every supported project target, or `setup --migrate` to approve a detected legacy-state move explicitly. `-y`/`--yes` accepts the bounded write without prompts; `--dry-run` previews it. For an explicit legacy migration in CI/headless use @@ -693,7 +701,7 @@ Platform-specific adapters (generated by `npx -y workspine init`, or `gsdd init` .codex/agents/ # Codex CLI agent TOML files -AGENTS.md # Optional governance block (useful for agents that consume AGENTS.md) +AGENTS.md # Root workflow routing guide (setup default; --no-agents skips it) ``` `.agents/skills/` is the workflow entry surface. `.work/bin/` is the internal helper runtime used by those workflows. Native adapters and governance files are optional ergonomics, not required prompt bulk. diff --git a/tests/gsdd.setup.test.cjs b/tests/gsdd.setup.test.cjs index 5f4ad30..c223af1 100644 --- a/tests/gsdd.setup.test.cjs +++ b/tests/gsdd.setup.test.cjs @@ -34,6 +34,47 @@ function runInstalled(entryPath, cwd, args, env = {}) { }); } +function assertRootGuidance(root) { + const agentsPath = path.join(root, 'AGENTS.md'); + assert.ok(fs.existsSync(agentsPath), 'normal setup must expose Workspine in root AGENTS.md'); + const guidance = fs.readFileSync(agentsPath, 'utf8'); + assert.match(guidance, /\.agents\/skills\/work-\*\/SKILL\.md/); + assert.match(guidance, /\$work-plan/); + assert.match(guidance, /\/work-plan/); + assert.match(guidance, /owner approval/); + for (const workflow of ['work-plan', 'work-execute', 'work-verify', 'work-progress']) { + assert.ok(guidance.includes(workflow), `root guidance must route to ${workflow}`); + assert.ok(fs.existsSync(path.join(root, '.agents', 'skills', workflow, 'SKILL.md'))); + } + assert.strictEqual((guidance.match(//g) || []).length, 1); + return guidance; +} + +const BEGIN = ''; +const END = ''; +const MALFORMED_ROOT_BLOCKS = [ + ['duplicate begin', `${BEGIN}\nOwner policy\n${BEGIN}\nOld guide\n${END}\n`], + ['duplicate end', `${BEGIN}\nOld guide\n${END}\nOwner policy\n${END}\n`], + ['nested pair', `${BEGIN}\nOwner policy\n${BEGIN}\nOld guide\n${END}\n${END}\n`], + ['two pairs', `${BEGIN}\nFirst\n${END}\nOwner policy\n${BEGIN}\nSecond\n${END}\n`], + ['reversed pair', `${END}\nOwner policy\n${BEGIN}\n`], + ['inline begin', `Owner policy ${BEGIN}\nOld guide\n${END}\n`], + ['inline end', `${BEGIN}\nOld guide\n${END} Owner policy\n`], + ['indented marker', ` ${BEGIN}\nOld guide\n${END}\n`], + ['same line pair', `${BEGIN} Owner policy ${END}\n`], +]; + +async function createLegacyPortableFixture(root, run = (args) => runCliAsMain(root, args)) { + const seeded = await run(['setup', '--no-agents', '-y']); + assert.strictEqual(seeded.exitCode ?? seeded.status, 0, seeded.output || seeded.stderr); + fs.renameSync(path.join(root, '.work'), path.join(root, '.planning')); + const manifest = JSON.parse(fs.readFileSync(path.join(root, '.planning', 'generation-manifest.json'), 'utf8')); + assert.ok(manifest.adapterFiles['.agents/skills/work-plan/SKILL.md']); + assert.ok(!Object.hasOwn(manifest.adapterFiles, 'AGENTS.md')); + writeFile(path.join(root, '.planning', 'consumer.bin'), Buffer.from([0, 255, 13, 10, 128])); + writeFile(path.join(root, 'AGENTS.md'), '# Owner rules\r\n\r\nKeep all bytes. \r\n'); +} + function installPackedEntry() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsdd-setup-packed-')); const pack = path.join(root, 'pack'); @@ -42,6 +83,11 @@ function installPackedEntry() { fs.mkdirSync(install, { recursive: true }); const packed = JSON.parse(execFileSync(NPM, [...NPM_PREFIX, 'pack', '--ignore-scripts', '--offline', '--json', '--pack-destination', pack], { cwd: ROOT, encoding: 'utf8' })); assert.strictEqual(packed.length, 1); + const shipped = new Set(packed[0].files.map((file) => file.path)); + for (const required of ['bin/lib/setup.mjs', 'bin/lib/manifest.mjs', 'bin/lib/rendering.mjs', 'bin/adapters/agents.mjs', 'distilled/templates/agents.block.md', 'distilled/workflows/plan.md']) { + assert.ok(shipped.has(required), `package missing ${required}`); + } + assert.ok(![...shipped].some((file) => file.startsWith('.factory-038-smoke/')), 'local evidence must not ship'); const tarball = path.join(pack, packed[0].filename); writeFile(path.join(install, 'package.json'), '{"name":"packed-setup-consumer","private":true}\n'); execFileSync(NPM, [...NPM_PREFIX, 'install', '--ignore-scripts', '--offline', '--no-audit', '--no-fund', tarball], { cwd: install, stdio: 'pipe' }); @@ -114,12 +160,153 @@ describe('gsdd setup facade', () => { assert.strictEqual(first.exitCode, 0, first.output); assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'config.json'))); assert.ok(fs.existsSync(path.join(tmpDir, '.agents', 'skills', 'work-quick', 'SKILL.md'))); - assert.ok(!fs.existsSync(path.join(tmpDir, 'AGENTS.md')), 'generic governance is opt-in'); - const before = fs.readFileSync(path.join(tmpDir, '.work', 'generation-manifest.json')); + assertRootGuidance(tmpDir); + const before = snapshotTree(tmpDir); const second = await runCliAsMain(tmpDir, ['setup', '-y']); assert.strictEqual(second.exitCode, 0, second.output); assert.match(second.output, /state already exists/); - assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'generation-manifest.json')), before); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + }); + + test('setup appends guidance without changing consumer governance bytes and update preserves it', async () => { + const original = '# Team rules\r\n\r\n\r\nKeep owner approval. \r\nUse our build command.'; + const agentsPath = path.join(tmpDir, 'AGENTS.md'); + fs.writeFileSync(agentsPath, original); + const first = await runCliAsMain(tmpDir, ['setup', '--agent', 'codex', '-y']); + assert.strictEqual(first.exitCode, 0, first.output); + const guidance = assertRootGuidance(tmpDir); + assert.ok(guidance.startsWith(original), 'consumer content must remain byte-for-byte at the start'); + fs.appendFileSync(agentsPath, '\nTeam addition after setup.\r\n'); + const beforeUpdate = fs.readFileSync(agentsPath); + const update = await runCliAsMain(tmpDir, ['update']); + assert.strictEqual(update.exitCode, 0, update.output); + assert.deepStrictEqual(fs.readFileSync(agentsPath), beforeUpdate); + assertRootGuidance(tmpDir); + }); + + test('project --no-agents preserves root governance while still installing portable and requested native skills', async () => { + for (const [index, flags] of [[], ['--agent', 'codex'], ['--all'], ['--agent', 'generic']].entries()) { + const root = path.join(tmpDir, `opt-out-${index}`); + fs.mkdirSync(root); + const original = '# Consumer-only rules\r\nKeep exactly.\r\n'; + if (index % 2) fs.writeFileSync(path.join(root, 'AGENTS.md'), original); + const result = await runCliAsMain(root, ['setup', '-y', '--no-agents', ...flags]); + assert.strictEqual(result.exitCode, 0, result.output); + assert.ok(fs.existsSync(path.join(root, '.agents/skills/work-plan/SKILL.md'))); + if (index % 2) assert.strictEqual(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), original); + else assert.ok(!fs.existsSync(path.join(root, 'AGENTS.md'))); + if (index === 1 || index === 2) assert.ok(fs.existsSync(path.join(root, '.codex/agents'))); + } + const before = snapshotTree(tmpDir); + const global = await runCliAsMain(tmpDir, ['setup', '--global', '--agent', 'codex', '--no-agents', '-y']); + assert.strictEqual(global.exitCode, 1); + assert.match(global.output, /--no-agents applies only to project setup/); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + }); + + test('setup refuses partial root markers and non-file targets before writing generated surfaces', async () => { + for (const [index, content] of ['\nunfinished', '\norphan', null].entries()) { + const root = path.join(tmpDir, `unsafe-root-${index}`); + fs.mkdirSync(root); + const agentsPath = path.join(root, 'AGENTS.md'); + if (content === null) fs.mkdirSync(agentsPath); + else fs.writeFileSync(agentsPath, content); + const before = snapshotTree(root); + const result = await runCliAsMain(root, ['setup', '-y']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /Refusing adapter update/); + assert.deepStrictEqual(snapshotTree(root), before); + } + }); + + for (const [label, malformed] of MALFORMED_ROOT_BLOCKS) { + test(`malformed root ${label} refuses fresh setup, init and owned update without writes`, async () => { + for (const mode of ['setup', 'init', 'update']) { + const root = path.join(tmpDir, mode); + fs.mkdirSync(root); + if (mode === 'update') { + const seeded = await runCliAsMain(root, ['setup', '-y']); + assert.strictEqual(seeded.exitCode, 0, seeded.output); + } + const agentsPath = path.join(root, 'AGENTS.md'); + const original = Buffer.from(`# Owner rules\r\n\r\n${malformed}Keep this policy. \r\n`); + fs.writeFileSync(agentsPath, original); + writeFile(path.join(root, 'consumer.bin'), Buffer.from([0, 255, 128])); + const before = snapshotTree(root); + const args = mode === 'setup' ? ['setup', '-y'] + : mode === 'init' ? ['init', '--auto', '--tools', 'agents'] : ['update']; + const result = await runCliAsMain(root, args); + // Assert byte preservation first: a nonzero exit after writes is also unsafe. + assert.deepStrictEqual(fs.readFileSync(agentsPath), original, `${mode}: ${label} changed owner policy`); + assert.deepStrictEqual(snapshotTree(root), before, `${mode}: ${label} wrote repository bytes`); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /Refusing adapter update/); + } + }); + } + + test('a single standalone root pair preserves surrounding CRLF bytes through setup and owned update', async () => { + const prefix = '# Owner rules\r\nKeep before. \r\n'; + const suffix = '\r\n\r\nKeep after. \r\n'; + const agentsPath = path.join(tmpDir, 'AGENTS.md'); + fs.writeFileSync(agentsPath, `${prefix}${BEGIN}\r\nOld guide\r\n${END}${suffix}`); + const setup = await runCliAsMain(tmpDir, ['setup', '-y']); + assert.strictEqual(setup.exitCode, 0, setup.output); + let guidance = assertRootGuidance(tmpDir); + assert.ok(guidance.startsWith(prefix)); + assert.ok(guidance.endsWith(suffix)); + fs.writeFileSync(agentsPath, guidance.replace('## GSDD Governance (Generated)', '## Outdated managed guide')); + const update = await runCliAsMain(tmpDir, ['update']); + assert.strictEqual(update.exitCode, 0, update.output); + guidance = assertRootGuidance(tmpDir); + assert.ok(guidance.startsWith(prefix)); + assert.ok(guidance.endsWith(suffix)); + assert.match(guidance, /## GSDD Governance \(Generated\)/); + }); + + test('owned legacy root adapter aliases refuse malformed markers before update writes', async () => { + for (const runtime of ['cursor', 'copilot', 'gemini']) { + const root = path.join(tmpDir, runtime); + fs.mkdirSync(root); + const seeded = await runCliAsMain(root, ['init', '--auto', '--tools', runtime]); + assert.strictEqual(seeded.exitCode, 0, seeded.output); + writeFile(path.join(root, 'AGENTS.md'), `${BEGIN}\nOwner policy\n${BEGIN}\nOld guide\n${END}\n`); + const before = snapshotTree(root); + const refused = await runCliAsMain(root, ['update']); + assert.strictEqual(refused.exitCode, 1, refused.output); + assert.deepStrictEqual(snapshotTree(root), before, `${runtime} update wrote before refusing malformed root markers`); + } + }); + + test('setup migration refuses unowned root guidance before moving a valid portable generation manifest', async () => { + await createLegacyPortableFixture(tmpDir); + const before = snapshotTree(tmpDir); + const result = await runCliAsMain(tmpDir, ['setup', '--migrate', '-y']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /AGENTS\.md is not generation-manifest-owned/); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'refusal must preserve .planning, root policy and every repository byte'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.work'))); + const optedOut = await runCliAsMain(tmpDir, ['setup', '--migrate', '--no-agents', '-y']); + assert.strictEqual(optedOut.exitCode, 0, optedOut.output); + assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'migration-receipt.json'))); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning'))); + assert.strictEqual(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf8'), '# Owner rules\r\n\r\nKeep all bytes. \r\n'); + }); + + test('setup migration checks all selected portable and native collisions before renaming legacy state', async () => { + for (const [index, relativePath] of ['.agents/skills/work-plan/SKILL.md', '.codex/agents/work-plan-checker.toml'].entries()) { + const root = path.join(tmpDir, `legacy-collision-${index}`); + writeFile(path.join(root, '.planning', 'config.json'), '{"initVersion":"v1.1"}\n'); + writeFile(path.join(root, '.planning', 'consumer.bin'), Buffer.from([0, 255, 128])); + writeFile(path.join(root, 'AGENTS.md'), '# Consumer policy\r\nKeep exactly.\r\n'); + writeFile(path.join(root, relativePath), 'Consumer-owned target\r\n'); + const before = snapshotTree(root); + const result = await runCliAsMain(root, ['setup', '--migrate', '--agent', 'codex', '-y']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /Refusing adapter update/); + assert.deepStrictEqual(snapshotTree(root), before); + assert.ok(!fs.existsSync(path.join(root, '.work'))); + } }); test('dry run is zero-write and hidden --dry remains compatible', async () => { @@ -127,6 +314,7 @@ describe('gsdd setup facade', () => { const result = await runCliAsMain(tmpDir, ['setup', '--dry-run']); assert.strictEqual(result.exitCode, 0, result.output); assert.match(result.output, /dry run/); + assert.match(result.output, /AGENTS\.md/); assert.deepStrictEqual(fs.readdirSync(tmpDir), before); const hidden = await runCliAsMain(tmpDir, ['setup', '--dry']); assert.strictEqual(hidden.exitCode, 0, hidden.output); @@ -185,7 +373,7 @@ describe('gsdd setup facade', () => { confirmSetup: async ({ details }) => { consentPrompts += 1; assert.match(details.join(' '), /Bounded write set: \.work\//); - assert.match(details.join(' '), /portable selected project surfaces/); + assert.match(details.join(' '), /AGENTS\.md/); return true; }, }); @@ -194,7 +382,7 @@ describe('gsdd setup facade', () => { assert.strictEqual(consentPrompts, 1); assert.ok(fs.existsSync(path.join(tmpDir, '.agents', 'skills', 'work-plan', 'SKILL.md'))); assert.ok(!fs.existsSync(path.join(tmpDir, '.codex')), 'native targets stay explicit'); - assert.ok(!fs.existsSync(path.join(tmpDir, 'AGENTS.md')), 'root governance stays explicit'); + assertRootGuidance(tmpDir); const config = JSON.parse(fs.readFileSync(path.join(tmpDir, '.work', 'config.json'), 'utf8')); assert.strictEqual(config.rigorProfile, 'high'); assert.strictEqual(config.researchDepth, 'deep'); @@ -348,7 +536,7 @@ describe('gsdd setup facade', () => { } }); - test('offline packed installed entry covers the setup matrix in one install', () => { + test('offline packed installed entry covers the setup matrix in one install', async () => { const packed = installPackedEntry(); try { const repo = path.join(packed.root, 'repo'); @@ -356,6 +544,7 @@ describe('gsdd setup facade', () => { const first = runInstalled(packed.entry, repo, ['setup', '-y']); assert.strictEqual(first.status, 0, first.stderr || first.stdout); assert.ok(fs.existsSync(path.join(repo, '.agents', 'skills', 'work-quick', 'SKILL.md'))); + assertRootGuidance(repo); const repoBeforeRerun = snapshotTree(repo); const second = runInstalled(packed.entry, repo, ['setup', '--yes']); assert.strictEqual(second.status, 0, second.stderr || second.stdout); @@ -367,6 +556,44 @@ describe('gsdd setup facade', () => { const codex = runInstalled(packed.entry, projectCodex, ['setup', '--agent', 'codex', '--yes']); assert.strictEqual(codex.status, 0, codex.stderr || codex.stdout); assert.ok(fs.existsSync(path.join(projectCodex, '.codex', 'agents'))); + assertRootGuidance(projectCodex); + + const projectExisting = path.join(packed.root, 'project-existing'); + fs.mkdirSync(projectExisting); + const original = '# Consumer governance\r\n\r\nKeep this exactly.\r\n'; + writeFile(path.join(projectExisting, 'AGENTS.md'), original); + const existing = runInstalled(packed.entry, projectExisting, ['setup', '--yes']); + assert.strictEqual(existing.status, 0, existing.stderr || existing.stdout); + assert.ok(assertRootGuidance(projectExisting).startsWith(original)); + + for (const [index, [label, content]] of MALFORMED_ROOT_BLOCKS.entries()) { + const malformedRoot = path.join(packed.root, `malformed-${index}`); + writeFile(path.join(malformedRoot, 'AGENTS.md'), `# Owner rules\r\n${content}Keep this.\r\n`); + const before = snapshotTree(malformedRoot); + const refused = runInstalled(packed.entry, malformedRoot, ['setup', '--yes']); + assert.notStrictEqual(refused.status, 0, label); + assert.match(refused.stdout + refused.stderr, /Refusing adapter update/); + assert.deepStrictEqual(snapshotTree(malformedRoot), before, `installed ${label} changed consumer bytes`); + } + + const ownedLegacy = path.join(packed.root, 'owned-legacy'); + fs.mkdirSync(ownedLegacy); + const runLegacy = (args) => runInstalled(packed.entry, ownedLegacy, args); + await createLegacyPortableFixture(ownedLegacy, runLegacy); + const ownedLegacyBefore = snapshotTree(ownedLegacy); + const refusedLegacy = runLegacy(['setup', '--migrate', '-y']); + assert.notStrictEqual(refusedLegacy.status, 0); + assert.match(refusedLegacy.stdout + refusedLegacy.stderr, /AGENTS\.md is not generation-manifest-owned/); + assert.deepStrictEqual(snapshotTree(ownedLegacy), ownedLegacyBefore); + assert.ok(!fs.existsSync(path.join(ownedLegacy, '.work'))); + + const projectOptOut = path.join(packed.root, 'project-opt-out'); + fs.mkdirSync(projectOptOut); + writeFile(path.join(projectOptOut, 'AGENTS.md'), original); + const optOut = runInstalled(packed.entry, projectOptOut, ['setup', '--no-agents', '--yes']); + assert.strictEqual(optOut.status, 0, optOut.stderr || optOut.stdout); + assert.strictEqual(fs.readFileSync(path.join(projectOptOut, 'AGENTS.md'), 'utf8'), original); + assert.ok(fs.existsSync(path.join(projectOptOut, '.agents/skills/work-plan/SKILL.md'))); const projectAll = path.join(packed.root, 'project-all'); fs.mkdirSync(projectAll); From 9963bf9d814c9e845f02df3caa29559f363170ea Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 20:52:13 +0200 Subject: [PATCH 4/4] fix: preflight legacy helpers and protect fenced AGENTS examples Agent-Harness: codex-cli Agent-Session: factory-038-fix6-hardening --- bin/lib/init-flow.mjs | 1 + bin/lib/rendering.mjs | 29 +++++++++++++++++++-- tests/gsdd.init.test.cjs | 24 +++++++++++++++++ tests/gsdd.setup.test.cjs | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/bin/lib/init-flow.mjs b/bin/lib/init-flow.mjs index 10cba3e..6950173 100644 --- a/bin/lib/init-flow.mjs +++ b/bin/lib/init-flow.mjs @@ -370,6 +370,7 @@ export function createCmdInit(ctx) { stateDirName: initCtx.stateDirName, allowUnmarkedRootAgents: initCtx.allowUnmarkedRootAgents === true, }); + preflightPlanningCliHelpersReadOnly({ ...initCtx, planningDir: state.legacyDir }); migrateLegacyState(initCtx.cwd); } catch (error) { console.error(`ERROR: Legacy state migration failed: ${error.message}`); diff --git a/bin/lib/rendering.mjs b/bin/lib/rendering.mjs index 6a83b93..f9a675f 100644 --- a/bin/lib/rendering.mjs +++ b/bin/lib/rendering.mjs @@ -359,8 +359,33 @@ function renderAgentsFileContent(options = {}) { } function findBoundedBlock(existing) { - const begins = [...existing.matchAll(//g)]; - const ends = [...existing.matchAll(//g)]; + const begins = []; + const ends = []; + let fence = null; + // Only track fenced-code boundaries, not Markdown structure. Keep original + // offsets so replacement never normalizes consumer bytes or line endings. + for (const line of existing.matchAll(/[^\n]*(?:\n|$)/g)) { + const content = line[0].replace(/\r?\n$/, ''); + const delimiter = content.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (fence) { + if (delimiter && delimiter[1][0] === fence.character + && delimiter[1].length >= fence.length && /^[ \t]*$/.test(delimiter[2])) { + fence = null; + } + continue; + } + if (delimiter && (delimiter[1][0] !== '`' || !delimiter[2].includes('`'))) { + fence = { character: delimiter[1][0], length: delimiter[1].length }; + continue; + } + for (const marker of content.matchAll(//g)) { + const match = { 0: marker[0], index: line.index + marker.index }; + (marker[1] === 'BEGIN' ? begins : ends).push(match); + } + } + if (fence) { + throw new Error('Refusing adapter update: AGENTS.md has an unclosed Markdown fence; close the example before retrying.'); + } if (begins.length === 0 && ends.length === 0) return null; const isStandalone = (match) => { diff --git a/tests/gsdd.init.test.cjs b/tests/gsdd.init.test.cjs index 5c004c5..f2a85ad 100644 --- a/tests/gsdd.init.test.cjs +++ b/tests/gsdd.init.test.cjs @@ -3006,6 +3006,30 @@ describe('gsdd init and update', () => { }); } + for (const collision of ['bin', 'bin/gsdd.mjs']) { + test(`legacy migration refuses helper collision at ${collision} before changing any bytes`, async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + fs.writeFileSync(path.join(legacy, 'consumer.bin'), Buffer.from([0, 255, 13, 10, 128])); + if (collision === 'bin') fs.writeFileSync(path.join(legacy, 'bin'), 'Consumer-owned file\r\n'); + else fs.mkdirSync(path.join(legacy, 'bin', 'gsdd.mjs'), { recursive: true }); + const before = snapshotTree(tmpDir); + + for (const args of [ + ['init', '--migrate', '--auto', '--tools', 'agents'], + ['setup', '--migrate', '-y'], + ]) { + const result = runPublicCli(tmpDir, args); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'helper refusal must preserve every path and byte'); + assert.strictEqual(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout + result.stderr, /Refusing to write generated runtime helper/); + assert.ok(fs.existsSync(legacy), 'helper refusal must precede the rename'); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work')), false); + } + }); + } + for (const ownership of ['missing', 'corrupt', 'incomplete', 'unowned']) { test(`legacy migration preflight preserves bytes for ${ownership} template ownership`, async () => { const legacy = path.join(tmpDir, '.planning'); diff --git a/tests/gsdd.setup.test.cjs b/tests/gsdd.setup.test.cjs index c223af1..2efefed 100644 --- a/tests/gsdd.setup.test.cjs +++ b/tests/gsdd.setup.test.cjs @@ -184,6 +184,61 @@ describe('gsdd setup facade', () => { assertRootGuidance(tmpDir); }); + for (const [label, open, close, example] of [ + ['backticks', '```markdown', '```', `${BEGIN}\nTeam-authored example\n${END}`], + ['tildes', '~~~markdown', '~~~', `${BEGIN}\nTeam-authored example\n${END}`], + ['long indented fence', ' ````markdown', ' `````', `${BEGIN}\n~~~\n\x60\x60\x60\nTeam-authored example\n${END}`], + ['partial example', '~~~', '~~~', `Example ${BEGIN}\n${END}`], + ]) { + test(`fenced AGENTS ${label} example remains byte-identical through setup and update`, async () => { + const original = Buffer.from(`# Team rules\r\n\r\n${open}\r\n${example.replace(/\n/g, '\r\n')}\r\n${close}\r\nKeep this line. \r\n`); + const agentsPath = path.join(tmpDir, 'AGENTS.md'); + fs.writeFileSync(agentsPath, original); + const result = await runCliAsMain(tmpDir, ['setup', '-y']); + const after = fs.readFileSync(agentsPath); + assert.deepStrictEqual(after.subarray(0, original.length), original, 'setup must preserve every preexisting AGENTS byte'); + assert.strictEqual(result.exitCode, 0, result.output); + const { findBoundedBlock } = await import(pathToFileURL(path.join(ROOT, 'bin/lib/rendering.mjs')).href); + const range = findBoundedBlock(after.toString('utf8')); + assert.ok(range.start >= original.length, 'the genuine managed block must be outside the example'); + assert.match(after.subarray(original.length).toString('utf8'), /## GSDD Governance \(Generated\)/); + const update = await runCliAsMain(tmpDir, ['update']); + assert.strictEqual(update.exitCode, 0, update.output); + assert.deepStrictEqual(fs.readFileSync(agentsPath), after); + }); + } + + test('bounded marker detection keeps exact ranges and refuses malformed markers outside examples', async () => { + const { findBoundedBlock, upsertBoundedBlock } = await import(pathToFileURL(path.join(ROOT, 'bin/lib/rendering.mjs')).href); + const prefix = `\x60\x60\x60markdown\n${BEGIN}\nExample\n${END}\n\x60\x60\x60\n`; + const managed = `${BEGIN}\r\nOld guide\r\n${END}`; + const suffix = '\r\nKeep after. \r\n'; + assert.strictEqual(findBoundedBlock(prefix), null); + assert.deepStrictEqual(findBoundedBlock(prefix + managed + suffix), { start: prefix.length, end: prefix.length + managed.length }); + assert.strictEqual(upsertBoundedBlock(prefix + managed + suffix, 'New guide'), `${prefix}${BEGIN}\nNew guide\n${END}${suffix}`); + const inline = 'Inline ```example``` and ~~~example~~~.\n```inline code```\n'; + assert.deepStrictEqual(findBoundedBlock(inline + managed), { start: inline.length, end: inline.length + managed.length }); + for (const [, malformed] of MALFORMED_ROOT_BLOCKS) { + assert.throws(() => findBoundedBlock(prefix + malformed), /malformed GSDD markers/); + } + }); + + test('unclosed AGENTS fences refuse setup before any consumer bytes change', async () => { + for (const [index, open, falseClose] of [ + [0, '```markdown', '~~~'], + [1, '~~~~markdown', '~~~'], + [2, '```markdown', '``` trailing text'], + ]) { + const root = path.join(tmpDir, `unclosed-${index}`); + writeFile(path.join(root, 'AGENTS.md'), `# Team rules\r\n${open}\r\n${falseClose}\r\n${BEGIN}\r\nKeep this example.\r\n${END}\r\n`); + const before = snapshotTree(root); + const result = await runCliAsMain(root, ['setup', '-y']); + assert.deepStrictEqual(snapshotTree(root), before); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /unclosed Markdown fence/); + } + }); + test('project --no-agents preserves root governance while still installing portable and requested native skills', async () => { for (const [index, flags] of [[], ['--agent', 'codex'], ['--all'], ['--agent', 'generic']].entries()) { const root = path.join(tmpDir, `opt-out-${index}`);