Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
587ebb6
fix: bind brownfield approval to the active plan
PatrickSys Sep 27, 2026
cc092e1
fix: reconcile brownfield approval and native workflow recovery
PatrickSys Oct 2, 2026
465e5b9
fix: preserve brownfield continuation and require branch agreement
PatrickSys Oct 3, 2026
d878ae9
fix(resume): qualify continuity claims before blocked reports
PatrickSys Oct 3, 2026
9abf087
fix(lifecycle): honor only top-level artifact status
PatrickSys Oct 8, 2026
d7d4afd
feat(models): record opt-in executor delegate model
PatrickSys Oct 8, 2026
edf1056
fix(lifecycle): permit reviewed amendments and safe legacy adoption
PatrickSys Oct 9, 2026
9789738
fix(models): expose executor reads in installed workflow helper
PatrickSys Oct 9, 2026
060103b
fix(lifecycle): preserve paused approval and seal adoption receipt
PatrickSys Oct 9, 2026
5a28488
fix(lifecycle): reject amendment after current terminal proof
PatrickSys Oct 9, 2026
b5a2ccb
fix(lifecycle): refuse ambiguous current verification status
PatrickSys Oct 9, 2026
0823b7a
fix(lifecycle): honor owner-parked phase dispatch boundaries
PatrickSys Oct 9, 2026
800a1ef
fix(lifecycle): exclude examples and archives from phase authority
PatrickSys Oct 9, 2026
da9e2a1
Preserve phase discovery through native Windows path aliases
PatrickSys Oct 9, 2026
958eebe
Reuse one fresh lifecycle observation per next command
PatrickSys Oct 9, 2026
a0f71ff
fix: preserve explicit authority across parallel milestones
PatrickSys Oct 9, 2026
c4c81b5
test: count physical workflow lines at budget boundaries
PatrickSys Oct 15, 2026
7467d33
fix: keep pending brownfield verification undated
PatrickSys Oct 15, 2026
a8a91b4
fix: accept generated work-path PLAN selectors
PatrickSys Oct 15, 2026
d93dd81
fix: preserve brownfield remediation and approval readback
PatrickSys Oct 15, 2026
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
14 changes: 7 additions & 7 deletions bin/adapters/claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,23 @@ Native Claude adapter rule:

Execution flow:
1. Read \`.work/SPEC.md\` and \`.work/ROADMAP.md\` when present, \`.work/config.json\`, relevant phase research, and any existing phase plan files.
2. Classify the target through the portable planning contract: a bounded multi-step change without a roadmap selects brownfield-change; a tiny self-contained change selects work-quick; an existing roadmap keeps phase selection. For brownfield use CHANGE.md as identity and plan body, HANDOFF.md as context-only, and the portable brownfield lifecycle commands in place of phase paths throughout this flow. Read SPEC/ROADMAP only when present.
3. **Approach exploration** (before planning):
2. Classify the target through the portable planning contract: a bounded multi-step change without a roadmap selects brownfield-change; a tiny self-contained change selects work-quick; an existing roadmap keeps phase selection unless an active bounded change owns the request or the owner selects a standalone change. For brownfield use CHANGE.md as identity and plan body, HANDOFF.md as context-only, and the portable brownfield lifecycle commands in place of phase paths throughout this flow. Read SPEC/ROADMAP only when present.
3. **Approach exploration** (before planning): for brownfield, use owner-confirmed decisions in CHANGE.md, stop for unresolved material choices, and skip the phase APPROACH procedure a-f below. Do not create or require a phase APPROACH.md for brownfield.
a. Check \`.work/config.json\` for \`workflow.discuss\`. If \`false\` or missing, skip to step 4 and report \`reduced_alignment\` in the summary.
b. Check if \`{phase_dir}/{padded_phase}-APPROACH.md\` exists. If it does, offer the user: "Use existing" / "Update it" / "View it". If "Use existing", load decisions, then validate the alignment proof before step 4; proofless or invalid existing APPROACH.md must be updated, not silently trusted.
c. If no APPROACH.md exists (or user chose "Update"): invoke the native \`${SUBAGENT_IDS.approachExplorer}\` subagent with the phase goal, requirement IDs, project config from \`.work/config.json\` (especially \`workflow.discuss\`), SPEC locked decisions, phase research, and relevant codebase files.
d. The explorer runs a GSD-style interactive conversation with the user (gray areas, research, deep-dive questions, assumptions) and writes APPROACH.md.
e. Before planning, confirm APPROACH.md records all canonical proof fields: \`alignment_status\`, \`alignment_method\`, \`user_confirmed_at\`, \`explicit_skip_approved\`, \`skip_scope\`, \`skip_rationale\`, and \`confirmed_decisions\`. For \`alignment_status: user_confirmed\`, \`confirmed_decisions\` must name the locked decisions and skip fields may be \`false\`/\`N/A\`; for \`alignment_status: approved_skip\`, \`explicit_skip_approved: true\`, \`skip_scope\`, and \`skip_rationale\` must be substantive. Agent-only "No questions needed" is not valid proof under \`workflow.discuss: true\`.
f. Load APPROACH.md decisions as locked constraints alongside SPEC.md decisions.
4. Produce the initial phase plan according to ${planningContract}. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
5. If \`.work/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the step 3 alignment-proof gate when \`workflow.discuss: true\`.
4. Produce the selected plan according to ${planningContract}: brownfield writes CHANGE.md#Implementation Plan with its owner-confirmed decisions; phases write PLAN.md with APPROACH.md decisions (if any) as locked constraints.
5. If \`.work/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the phase alignment-proof gate when \`workflow.discuss: true\`. Brownfield still requires owner-confirmed CHANGE.md decisions.
6. If \`workflow.planCheck: true\`, invoke the native \`${SUBAGENT_IDS.planChecker}\` subagent with fresh context.
7. Pass only explicit inputs to the checker:
- target phase goal and requirement IDs
- relevant locked decisions / deferred items from \`.work/SPEC.md\`
- project config from \`.work/config.json\`, especially \`workflow.discuss\` and \`workflow.planCheck\`
- approach decisions from \`.work/phases/*-APPROACH.md\` (if exists)
- relevant phase research file(s)
- project config from \`.work/config.json\`, especially \`workflow.discuss\` and \`workflow.planCheck\`; discuss controls phase APPROACH exploration
- for phases: approach decisions from \`.work/phases/*-APPROACH.md\` (if exists) and relevant phase research
- for brownfield: owner-confirmed decisions in CHANGE.md; HANDOFF.md is judgment-only context
- produced \`.work/brownfield-change/CHANGE.md\` for brownfield (Goal, scope, Done When, and Implementation Plan replace phase inputs), or \`.work/phases/*-PLAN.md\` file(s) for roadmap phases
- the exact persisted \`lastDecisionsDigest\` snapshot from the successful plan preflight, if present, including an explicitly persisted empty snapshot
8. Require the checker to return a single JSON object with this shape:
Expand Down
20 changes: 14 additions & 6 deletions bin/lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { parseFlagValue, output } from './cli-utils.mjs';
import { validateCommandShape } from './init-runtime.mjs';
import { assertStateAuthority, resolveStateDir } from './state-dir.mjs';
import { resolveWorkspaceContext } from './workspace-root.mjs';
import { getRuntimeExecutorModelState } from './executor-model-view.mjs';

export const DEFAULT_GIT_PROTOCOL = {
branch: 'Follow the existing repo or team branching convention. Use a feature branch for significant changes when no convention exists.',
Expand Down Expand Up @@ -87,6 +88,9 @@ export function effectiveRigorLevel(config, step) {

export const VALID_MODEL_PROFILES = ['quality', 'balanced', 'budget'];
export const PORTABLE_AGENT_IDS = ['plan-checker', 'approach-explorer'];
// Executor selection is runtime-specific: unlike the portable checker profiles,
// no model map can switch the already-running primary execution session.
export const RUNTIME_OVERRIDABLE_AGENT_IDS = [...PORTABLE_AGENT_IDS, 'executor'];
export const MODEL_RUNTIME_IDS = ['claude', 'opencode', 'codex'];
export const MODEL_ID_PATTERN = /^[a-zA-Z0-9._\/:@-]+$/;

Expand Down Expand Up @@ -276,6 +280,7 @@ function cmdModelsShow(cwd) {
runtimeModelOverrides: config.runtimeModelOverrides || {},
effective: {
claude: {
executor: getRuntimeExecutorModelState(config, 'claude'),
'plan-checker': getRuntimeAgentModelState({
config,
runtime: 'claude',
Expand All @@ -290,6 +295,7 @@ function cmdModelsShow(cwd) {
}),
},
opencode: {
executor: getRuntimeExecutorModelState(config, 'opencode'),
'plan-checker': {
mode: ocCheckerOverride ? 'override' : 'inherit',
model: ocCheckerOverride,
Expand All @@ -302,6 +308,7 @@ function cmdModelsShow(cwd) {
},
},
codex: {
executor: getRuntimeExecutorModelState(config, 'codex'),
'plan-checker': {
mode: codexCheckerOverride ? 'override' : 'inherit',
model: codexCheckerOverride,
Expand Down Expand Up @@ -424,8 +431,8 @@ function cmdModelsSetRuntimeOverride(args, cwd) {
process.exitCode = 1;
return;
}
if (!PORTABLE_AGENT_IDS.includes(agent)) {
console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${PORTABLE_AGENT_IDS.join(', ')}`);
if (!RUNTIME_OVERRIDABLE_AGENT_IDS.includes(agent)) {
console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${RUNTIME_OVERRIDABLE_AGENT_IDS.join(', ')}`);
process.exitCode = 1;
return;
}
Expand Down Expand Up @@ -458,7 +465,8 @@ function cmdModelsSetRuntimeOverride(args, cwd) {
result.config.runtimeModelOverrides[runtime][agent] = model.trim();
writeProjectConfig(result.config, cwd);
console.log(` - set ${runtime} runtime override for ${agent}`);
console.log(' Run gsdd update to regenerate adapter files.');
if (agent === 'executor') console.log(' Applies to newly delegated executors when the runtime supports model selection; cannot change the current session model.');
else console.log(' Run gsdd update to regenerate adapter files.');
}

function cmdModelsClearRuntimeOverride(args, cwd) {
Expand All @@ -470,8 +478,8 @@ function cmdModelsClearRuntimeOverride(args, cwd) {
process.exitCode = 1;
return;
}
if (!PORTABLE_AGENT_IDS.includes(agent)) {
console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${PORTABLE_AGENT_IDS.join(', ')}`);
if (!RUNTIME_OVERRIDABLE_AGENT_IDS.includes(agent)) {
console.error(`ERROR: Invalid agent "${agent}". Valid agents: ${RUNTIME_OVERRIDABLE_AGENT_IDS.join(', ')}`);
process.exitCode = 1;
return;
}
Expand Down Expand Up @@ -500,7 +508,7 @@ function cmdModelsClearRuntimeOverride(args, cwd) {
}
writeProjectConfig(result.config, cwd);
console.log(` - cleared ${runtime} runtime override for ${agent}`);
console.log(' Run gsdd update to regenerate adapter files.');
if (agent !== 'executor') console.log(' Run gsdd update to regenerate adapter files.');
}

// --- The rigor knob ---------------------------------------------------------
Expand Down
36 changes: 33 additions & 3 deletions bin/lib/control-map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ function runGit(cwd, args) {
}
}

/** Read a literal branch declaration, allowing only Markdown/display decoration. */
export function resolveGitBranchDeclaration({ workspaceRoot, surface } = {}) {
const declaration = typeof surface === 'string' ? surface.trim() : '';
if (!declaration || /^(?:unknown|unverified|tbd)$/i.test(declaration)) return null;
const unwrap = (value) => value.replace(/^`([^`]+)`$/, '$1');
const candidates = [
unwrap(declaration),
unwrap(declaration.replace(/\s*\([^()]*\)\s*$/, '').trim()),
];
for (const candidate of new Set(candidates)) {
if (!candidate) continue;
const branch = candidate.replace(/^refs\/heads\//, '');
const checked = runGit(workspaceRoot, ['check-ref-format', '--branch', branch]);
// Git owns ref-name syntax. Reject revision expansions such as @{-1}.
if (checked.ok && checked.stdout === branch) return branch;
}
return null;
}

function runIgnoredCount(cwd) {
const result = runGit(cwd, ['ls-files', '-o', '-i', '--exclude-standard']);
if (!result.ok) {
Expand Down Expand Up @@ -539,8 +558,18 @@ function reconcileAnnotations(worktrees, annotations) {
};
}

function classifyWorkflowState(planningDir) {
const lifecycle = evaluateLifecycleState({ planningDir });
function classifyWorkflowState(planningDir, { lifecycleObservation = null, expectedWorkspaceRoot = resolve(planningDir, '..') } = {}) {
// A transient observation belongs to exactly one workspace and state root.
// An explicitly supplied mismatched observation is an error, never a hint
// to reuse another checkout's authority or skip its phase evidence.
if (lifecycleObservation && (
lifecycleObservation.workspaceRoot !== resolve(expectedWorkspaceRoot)
|| lifecycleObservation.planningDir !== resolve(planningDir)
|| !lifecycleObservation.lifecycle
)) {
throw new Error('Lifecycle observation workspace root or planning directory mismatch');
}
const lifecycle = lifecycleObservation?.lifecycle || evaluateLifecycleState({ planningDir });
const checkpointPath = join(planningDir, '.continue-here.md');
const workspaceRoot = resolve(planningDir, '..');
const milestoneVersion = lifecycle.currentMilestone?.version || null;
Expand Down Expand Up @@ -779,6 +808,7 @@ export function buildControlMap({
planningDir,
annotationPath = null,
includeIgnoredPaths = false,
lifecycleObservation = null,
now = new Date(),
} = {}) {
const root = resolve(workspaceRoot || process.cwd());
Expand All @@ -791,7 +821,7 @@ export function buildControlMap({
const canonical = reconciled.worktrees.find((entry) => resolve(entry.path) === root)
|| reconciled.worktrees[0]
|| readGitWorktree(root, root, { includeIgnoredCount: true });
const workflowState = classifyWorkflowState(planning);
const workflowState = classifyWorkflowState(planning, { lifecycleObservation, expectedWorkspaceRoot: root });
const runtimeDirs = discoverRuntimeDirs(root);
const annotationReport = {
path: annotations.path,
Expand Down
44 changes: 44 additions & 0 deletions bin/lib/executor-model-view.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { lstatSync, readFileSync } from 'fs';
import { join } from 'path';

const RUNTIMES = Object.freeze(['claude', 'opencode', 'codex']);

// The public models CLI and the generated, read-only workflow helper must
// resolve the same executor override without importing an entire native
// adapter runtime into .work/bin/lib/.
export function getRuntimeExecutorModelState(config, runtime) {
const value = config?.runtimeModelOverrides?.[runtime]?.executor;
const model = typeof value === 'string' && value.trim() ? value.trim() : null;
return { mode: model ? 'override' : 'inherit', model };
}

export function executorModelSnapshot(config) {
return Object.fromEntries(RUNTIMES.map((runtime) => [
runtime, { executor: getRuntimeExecutorModelState(config, runtime) },
]));
}

// This reader is deliberately scoped to the generated helper's sole new
// command: models show. Refuse an absent/malformed/linked config rather than
// claiming inheritance when the user's persisted selection is unreadable.
export function readExecutorModelsForHelper(workspaceRoot) {
const configPath = join(workspaceRoot, '.work', 'config.json');
let config;
try {
const stat = lstatSync(configPath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular configuration file');
config = JSON.parse(readFileSync(configPath, 'utf8'));
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not a JSON object');
} catch {
const error = new Error('Unable to read a valid .work/config.json; executor model selection is unknown. Repair the config before delegating execution.');
error.code = 'executor_model_config_unreadable';
throw error;
}

return {
schema_version: 1,
operation: 'models show',
scope: 'executor',
effective: executorModelSnapshot(config),
};
}
58 changes: 56 additions & 2 deletions bin/lib/init-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from './init-runtime.mjs';
import { createInitPromptApi } from './init-prompts.mjs';
import { createAdapterRegistry } from '../adapters/index.mjs';
import { migrateLegacyState } from './state-migration.mjs';
import { adoptLegacyState, migrateLegacyState } from './state-migration.mjs';
import { resolveStateDir, stateAuthorityGate, MIGRATION_COMMAND } from './state-dir.mjs';
import { resolveWorkspaceContext } from './workspace-root.mjs';
import { ensureWorkStructure } from './work-context.mjs';
Expand Down Expand Up @@ -243,6 +243,12 @@ export function createCmdInit(ctx) {

const isAuto = parseAutoFlag(normalizedArgs);
const wantsMigration = normalizedArgs.includes('--migrate');
const wantsAdoption = normalizedArgs.includes('--adopt-legacy');
if (wantsMigration && wantsAdoption) {
console.error('ERROR: Select either supported --migrate or historical --adopt-legacy, not both.');
process.exitCode = 1;
return;
}
const toolsFlag = parseFlagValue(normalizedArgs, '--tools');
const briefFlag = parseFlagValue(normalizedArgs, '--brief');
let briefSource = null;
Expand Down Expand Up @@ -279,7 +285,55 @@ export function createCmdInit(ctx) {
const promptApi = ctx.initPromptApi || createInitPromptApi();
let interactiveSession;

if (state.status === 'legacy_migratable') {
if (wantsAdoption && state.status !== 'legacy_adoptable') {
console.error('ERROR: --adopt-legacy requires a recognized historical roadmap-style .planning/ without a Workspine initVersion. No state changed.');
process.exitCode = 1;
return;
}
if (state.status === 'legacy_adoptable') {
if (!wantsAdoption) {
console.error(`ERROR: ${stateAuthorityGate(state).message}`);
process.exitCode = 1;
return;
}
interactiveSession = await resolveInteractiveInitSession({
ctx: initCtx, promptApi, parsedTools, isAuto,
});
try {
// Resolve the generated-file and Git ownership constraints while all
// legacy bytes are still in .planning. A conflicting adapter target
// must never force the owner to recover an already moved history.
preflightInitState(initCtx, {
isAuto,
preselectedConfig: interactiveSession.config,
});
planAdapterGeneration({
cwd: initCtx.cwd,
planningDir: initCtx.planningDir,
targets: getLocalAdapterTargets(
initCtx.adapters,
initCtx.workflows,
interactiveSession.adapterTargets,
),
manifest: readManifest(initCtx.planningDir),
stateDirName: initCtx.stateDirName,
});
preflightPlanningCliHelpersReadOnly(initCtx);
adoptLegacyState(initCtx.cwd);
} catch (error) {
console.error(`ERROR: Historical state adoption failed: ${error.message}`);
process.exitCode = 1;
return;
}
console.log(' - preserved historical .planning/ bytes in ignored .work/.local/legacy-planning/; the new .work/ has independent authority');
initCtx = contextAtWorkspaceRoot(initCtx, initCtx.cwd);
state = resolveStateDir(initCtx.cwd);
if (state.status !== 'current') {
console.error('ERROR: Adoption failed to establish a single active .work/ root.');
process.exitCode = 1;
return;
}
} else if (state.status === 'legacy_migratable') {
let approved = wantsMigration;
if (!approved && !isAuto && process.stdin.isTTY) {
approved = await promptApi.confirmLegacyMigration({ command: MIGRATION_COMMAND });
Expand Down
Loading
Loading