Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions bin/lib/init-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,9 @@ export function createCmdInit(ctx) {
targets: migrationAdapterTargets,
manifest: readManifest(state.legacyDir),
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}`);
Expand Down Expand Up @@ -428,6 +430,7 @@ export function createCmdInit(ctx) {
targets: initAdapterTargets,
manifest: readManifest(planningDir),
stateDirName,
allowUnmarkedRootAgents: initCtx.allowUnmarkedRootAgents === true,
});
preflightPlanningCliHelpersReadOnly(initCtx);
applyAdapterRecovery(adapterPlan);
Expand Down
11 changes: 6 additions & 5 deletions bin/lib/init-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,14 @@ 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 },
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 },
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 <target>] [--all] [-y|--yes] [--dry-run] [--workspace-root <path>] [--migrate]',
setup: 'Usage: workspine setup [-g|--global] [--agent <target>] [--all] [--no-agents] [-y|--yes] [--dry-run] [--workspace-root <path>] [--migrate]',
init: 'Usage: workspine init [flags]',
update: 'Usage: workspine update [--dry-run] [-g|--global]',
health: 'Usage: workspine health [--json] [-g|--global] [--workspace-root <path>]',
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion bin/lib/lifecycle-preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1363,7 +1376,7 @@ export function cmdLifecycleTransition(...args) {
try {
assertStateAuthority(state);
if (normalizedArgs.includes('--help') || normalizedArgs[0] === 'help') {
console.log('Usage: lifecycle-transition <plan|approve|execute|verify|audit|next|fix_gaps|blocked|ask_user> --plan <path> [--artifact <path>] --authority <value> [--reason <text>] [--amend (owner plan only)] [--question <text>] [--json]');
console.log('Usage: lifecycle-transition <plan|approve|execute|verify|audit|next|fix_gaps|blocked|ask_user> --plan <path> [--artifact <path>] --authority <value> [--reason <text>] [--amend (owner plan only) | --start-new (distinct phase plan; requires --reason)] [--question <text>] [--json]');
return;
}
const target = String(normalizedArgs.find((arg) => !arg.startsWith('--')) || '').toLowerCase();
Expand All @@ -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';
Expand All @@ -1393,6 +1407,8 @@ export function cmdLifecycleTransition(...args) {
approvalRef: validated.approvalRef,
reason,
amend,
startNew,
verificationStatus: validated.verificationStatus,
question,
approved,
preWriteGuard: validated.preWriteGuard,
Expand Down
54 changes: 32 additions & 22 deletions bin/lib/lifecycle-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
21 changes: 14 additions & 7 deletions bin/lib/manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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('<!-- BEGIN GSDD -->') && existing.includes('<!-- END GSDD -->')) 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'
Expand Down
40 changes: 25 additions & 15 deletions bin/lib/next.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading