Skip to content

Commit 2915854

Browse files
authored
feat: add brownfield change continuity flow
Integrate the bounded brownfield change lane with resume/progress continuity, shipped templates, lifecycle preflight, and generated helper/runtime hardening while preserving current release automation.
1 parent e3b3578 commit 2915854

34 files changed

Lines changed: 2123 additions & 59 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Milestone: **v1.4.0 — Launch Surface Coherence**
3030
* .mailmap for contributor identity consolidation
3131
* FUNDING.yml and SECURITY.md references updated
3232
* FRAMEWORK_VERSION bumped to v1.4
33-
* framework state: 52 design decisions, 14 workflows, 1,381 tests across 13 suites
33+
* framework state: 52 design decisions, 14 workflows, 1,381 tests across 13 test files
3434
* npm tarball now ships public docs/proof surfaces plus `distilled/EVIDENCE-INDEX.md` and `distilled/SKILL.md`
3535
* `gsdd help` now reflects the full workflow surface and helper commands more cleanly
3636
* launch docs now align on Node 20+, qualified-support caveats, and milestone-continuation surfaces

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ This creates:
9595
3. `.planning/bin/gsdd.mjs` — repo-local helper runtime for deterministic workflow commands inside generated skills (run helper commands from the repo root)
9696
4. Optional tool-specific adapters you choose in the install wizard (Claude skills/commands/agents, OpenCode commands/agents, Codex CLI agents, optional governance)
9797

98-
Then run the new-project workflow to produce `.planning/SPEC.md` and `.planning/ROADMAP.md`.
98+
Then pick the first workflow lane that matches your situation:
99+
100+
- `gsdd-new-project` for greenfield work, fuzzy brownfield work, or milestone-shaped work
101+
- `gsdd-quick` for a concrete bounded brownfield change
102+
- `gsdd-map-codebase` first when the repo is unfamiliar, risky, or needs a deeper baseline before choosing a lane
99103

100104
In a terminal, `npx -y gsdd-cli init` opens a guided install wizard. If you installed the package globally, `gsdd init` is the equivalent shorthand:
101105

@@ -123,7 +127,7 @@ Start with the public proof pack:
123127

124128
Runtime floor: Node 20+.
125129

126-
Your tool determines how you invoke workflows:
130+
Your tool determines how you invoke workflows after `npx gsdd-cli init`:
127131

128132
- **Claude Code / OpenCode:** Use native slash commands directly — `/gsdd-new-project`, `/gsdd-plan`, etc.
129133
- **Codex CLI:** Use skill references — `$gsdd-new-project`, `$gsdd-plan`, etc. `$gsdd-plan` writes the plan and stops; start a separate `$gsdd-execute` run when you want implementation to begin.
@@ -172,14 +176,16 @@ npx -y gsdd-cli init --tools all # All of the above
172176
| **Cursor / Copilot / Gemini** | Qualified support | Uses `.agents/skills/` when skill/slash discovery is available; optional root `AGENTS.md` block adds behavioral governance, and the generated skill surface is freshness-checked locally |
173177
| **Other AI tools** | Fallback only | Open `.agents/skills/gsdd-*/SKILL.md` directly |
174178

175-
### Updating
179+
### Updating And Repair
176180

177181
```bash
178182
npx -y gsdd-cli update # Regenerate adapters from latest sources
179183
npx -y gsdd-cli update --tools claude # Update specific platform only
180184
npx -y gsdd-cli update --templates # Refresh .planning/templates/ and role contracts from framework source
181185
```
182186

187+
Use `gsdd health` first when you want a status check. Use `npx gsdd-cli update` when the generated runtime-facing surfaces are missing, drifted, or you want the latest generated output. If a runtime is only in the qualified-support tier, `health` and `update` still cover generated-surface drift; they do not imply parity-level runtime proof.
188+
183189
### Non-Interactive Mode (CI / Automation)
184190

185191
For non-interactive environments:

bin/gsdd.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
#!/usr/bin/env node
22

3-
// gsdd - Workspine CLI
4-
5-
import { realpathSync } from 'fs';
3+
import { realpathSync, readFileSync } from 'fs';
64
import { join, dirname } from 'path';
75
import { fileURLToPath } from 'url';
86
import { createAdapterRegistry } from './adapters/index.mjs';
@@ -26,7 +24,7 @@ const __filename = fileURLToPath(import.meta.url);
2624
const __dirname = dirname(__filename);
2725
const DISTILLED_DIR = join(__dirname, '..', 'distilled');
2826
const AGENTS_DIR = join(__dirname, '..', 'agents');
29-
const CWD = process.cwd();
27+
const PACKAGE_JSON = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
3028
const IS_MAIN = process.argv[1]
3129
? realpathSync(process.argv[1]) === realpathSync(__filename)
3230
: false;
@@ -67,6 +65,8 @@ function createCliContext(cwd = process.cwd()) {
6765
planningDir: join(cwd, '.planning'),
6866
distilledDir: DISTILLED_DIR,
6967
agentsDir: AGENTS_DIR,
68+
packageName: PACKAGE_JSON.name,
69+
packageVersion: PACKAGE_JSON.version,
7070
workflows: WORKFLOWS,
7171
frameworkVersion: FRAMEWORK_VERSION,
7272
adapters: createAdapterRegistry({
@@ -85,7 +85,7 @@ function createCliContext(cwd = process.cwd()) {
8585
};
8686
}
8787

88-
const INIT_CONTEXT = createCliContext(CWD);
88+
const INIT_CONTEXT = createCliContext(process.cwd());
8989

9090
const cmdInit = createCmdInit(INIT_CONTEXT);
9191
const cmdHealth = createCmdHealth(INIT_CONTEXT);

bin/lib/health-truth.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option
9494
warnings.push({
9595
id: 'W11',
9696
severity: 'WARN',
97-
message: `Installed generated runtime surfaces drift from current render output (${summarizeRuntimeFreshnessIssues(options.runtimeFreshnessReport)})`,
97+
message: `Installed generated runtime and workflow-helper surfaces drift from current render output (${summarizeRuntimeFreshnessIssues(options.runtimeFreshnessReport)})`,
9898
fix: getRuntimeFreshnessRepairGuidance(options.runtimeFreshnessReport),
9999
});
100100
}

bin/lib/health.mjs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function createCmdHealth(ctx) {
3131
}
3232
const cwd = workspaceRoot;
3333
const frameworkSourceMode = isFrameworkSourceRepo(cwd);
34-
const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3'];
34+
const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3'];
3535

3636
// Pre-init guard
3737
if (!existsSync(join(planningDir, 'config.json'))) {
@@ -128,6 +128,16 @@ export function createCmdHealth(ctx) {
128128
if (missingRoot.length > 0) {
129129
errors.push({ id: 'E8', severity: 'ERROR', message: `.planning/templates/ missing critical root files: ${missingRoot.join(', ')}`, fix: 'Run `npx -y gsdd-cli update --templates`' });
130130
}
131+
132+
const brownfieldChangeDir = join(templatesDir, 'brownfield-change');
133+
if (!existsSync(brownfieldChangeDir)) {
134+
errors.push({ id: 'E9', severity: 'ERROR', message: '.planning/templates/brownfield-change/ missing', fix: 'Run `npx -y gsdd-cli update --templates`' });
135+
} else {
136+
const missingBrownfield = ['CHANGE.md', 'HANDOFF.md', 'VERIFICATION.md'].filter((file) => !existsSync(join(brownfieldChangeDir, file)));
137+
if (missingBrownfield.length > 0) {
138+
errors.push({ id: 'E9', severity: 'ERROR', message: `.planning/templates/brownfield-change/ missing critical files: ${missingBrownfield.join(', ')}`, fix: 'Run `npx -y gsdd-cli update --templates`' });
139+
}
140+
}
131141
}
132142

133143
// --- WARNING checks ---
@@ -144,6 +154,7 @@ export function createCmdHealth(ctx) {
144154
{ name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null, fixCommand: 'npx -y gsdd-cli update --templates' },
145155
{ name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research, fixCommand: 'npx -y gsdd-cli update --templates' },
146156
{ name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase, fixCommand: 'npx -y gsdd-cli update --templates' },
157+
{ name: 'brownfield-change', dir: join(templatesDir, 'brownfield-change'), hashes: manifest.templates?.brownfieldChange, fixCommand: 'npx -y gsdd-cli update --templates' },
147158
{ name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root, fixCommand: 'npx -y gsdd-cli update --templates' },
148159
{ name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null, fixCommand: 'npx -y gsdd-cli update --templates' },
149160
{ name: 'runtime helpers', dir: planningDir, hashes: hasRuntimeHelpersDir ? manifest.runtimeHelpers : null, fixCommand: 'npx -y gsdd-cli update' },

bin/lib/init-runtime.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ Examples:
233233
234234
Workflows (run via skills/adapters generated by init, not direct CLI):
235235
gsdd-new-project Full initializer: questioning, brownfield audit, research, spec, roadmap
236-
gsdd-map-codebase Map or refresh brownfield codebase context
236+
gsdd-map-codebase Map or refresh brownfield codebase context before choosing or refreshing a work lane
237237
gsdd-plan Research, plan, and fresh-context plan check for a phase
238238
gsdd-execute Execute a phase plan and write phase summaries
239239
gsdd-verify Verify a completed phase with 3-level checks
@@ -246,5 +246,15 @@ Workflows (run via skills/adapters generated by init, not direct CLI):
246246
gsdd-pause Save session context to checkpoint
247247
gsdd-resume Restore context and route to the next action
248248
gsdd-progress Read-only status and routing surface
249+
250+
Starting lanes after init:
251+
gsdd-new-project Greenfield, fuzzy brownfield scope, or milestone-shaped work
252+
gsdd-quick Concrete bounded brownfield change
253+
gsdd-map-codebase Deeper brownfield orientation before choosing the lane above
254+
255+
Advanced/internal helpers (kept available, but not the primary first-run user story):
256+
lifecycle-preflight Inspect deterministic lifecycle gate results for a workflow surface
257+
phase-status Update ROADMAP.md phase status through the local helper surface
258+
file-op Deterministic workspace-confined file copy/delete/text mutation
249259
`;
250260
}

bin/lib/lifecycle-preflight.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ export function evaluateLifecyclePreflight({
120120
}
121121
}
122122

123-
if (surface === 'resume' && !existsSync(checkpointPath)) {
124-
blockers.push(blocker('missing_checkpoint', 'resume requires .planning/.continue-here.md to exist.', ['.planning/.continue-here.md']));
123+
if (surface === 'resume' && !existsSync(checkpointPath) && lifecycle.nonPhaseState !== 'active_brownfield_change') {
124+
blockers.push(blocker('missing_checkpoint', 'resume requires .planning/.continue-here.md unless an active .planning/brownfield-change/CHANGE.md continuity anchor exists.', ['.planning/.continue-here.md', '.planning/brownfield-change/CHANGE.md']));
125125
}
126126

127127
const warnings = [];

bin/lib/lifecycle-state.mjs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { existsSync, readFileSync, readdirSync } from 'fs';
22
import { join } from 'path';
33

4+
const BROWNFIELD_CHANGE_DIR = 'brownfield-change';
5+
46
const PHASE_LINE_RE = /^\s*[-*]\s*\[([ x-])\]\s*\*\*Phase\s+(\d+(?:\.\d+)*[a-z]?):\s*(.+?)\*\*(?:\s+\s+\[([^\]]+)])?/i;
57
const PHASE_DETAIL_HEADING_RE = /^(#{3,})\s+Phase\s+(\d+(?:\.\d+)*[a-z]?)(?::|\b)/i;
68
const PHASE_DETAIL_STATUS_RE = /^\s*\*\*Status\*\*:\s*\[([ x-])\]/i;
@@ -22,6 +24,13 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
2224
const spec = readTextIfExists(specPath);
2325
const roadmap = readTextIfExists(roadmapPath);
2426
const milestones = readTextIfExists(milestonesPath);
27+
const brownfieldChange = readBrownfieldChangeState(planningDir);
28+
const nonPhaseState = deriveNonPhaseState({
29+
planningDir,
30+
hasSpec: Boolean(spec.trim()),
31+
hasRoadmap: Boolean(roadmap.trim()),
32+
brownfieldChange,
33+
});
2534

2635
const phases = parseActiveRoadmapPhases(roadmap);
2736
const phaseStatusAlignment = evaluateRoadmapPhaseStatusAlignment(roadmap);
@@ -77,6 +86,8 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
7786
counts,
7887
phaseArtifacts,
7988
incompletePlans,
89+
brownfieldChange,
90+
nonPhaseState,
8091
phaseStatusAlignment,
8192
requirementAlignment: evaluateRequirementAlignment(spec, enrichedPhases, phaseStatusAlignment),
8293
provenance: provenance
@@ -174,6 +185,10 @@ function normalizeContent(content) {
174185
return String(content || '').replace(/\r\n/g, '\n');
175186
}
176187

188+
function escapeRegExp(value) {
189+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190+
}
191+
177192
export function normalizePhaseToken(value) {
178193
const raw = String(value || '').trim().toLowerCase();
179194
const match = raw.match(/^(\d+(?:\.\d+)*)([a-z]?)$/i);
@@ -223,6 +238,68 @@ function parseActiveRoadmapPhases(roadmap) {
223238
return phases;
224239
}
225240

241+
export function readBrownfieldChangeState(planningDir) {
242+
const dir = join(planningDir, BROWNFIELD_CHANGE_DIR);
243+
const changePath = join(dir, 'CHANGE.md');
244+
const handoffPath = join(dir, 'HANDOFF.md');
245+
246+
if (!existsSync(changePath)) {
247+
return {
248+
exists: false,
249+
dir,
250+
changePath,
251+
handoffPath,
252+
title: null,
253+
changeId: null,
254+
currentStatus: null,
255+
currentIntegrationSurface: null,
256+
currentOwnerRuntime: null,
257+
nextAction: null,
258+
declaredOwnedPaths: [],
259+
handoff: null,
260+
};
261+
}
262+
263+
const changeArtifact = readMarkdownArtifact(changePath);
264+
const handoffArtifact = existsSync(handoffPath) ? readMarkdownArtifact(handoffPath) : null;
265+
const currentStatusSection = extractMarkdownSection(changeArtifact.body, 'Current Status');
266+
const nextActionSection = extractMarkdownSection(changeArtifact.body, 'Next Action');
267+
const sliceSection = extractMarkdownSection(changeArtifact.body, 'PR Slice Ownership');
268+
269+
return {
270+
exists: true,
271+
dir,
272+
changePath,
273+
handoffPath,
274+
title: extractMarkdownHeading(changeArtifact.body),
275+
changeId: changeArtifact.frontmatter.change || null,
276+
currentStatus: changeArtifact.frontmatter.status || extractBulletLabel(currentStatusSection, 'Current posture'),
277+
currentIntegrationSurface: extractBulletLabel(currentStatusSection, 'Current branch / integration surface'),
278+
currentOwnerRuntime: extractBulletLabel(currentStatusSection, 'Current owner / runtime'),
279+
nextAction: collapseMarkdownSection(nextActionSection),
280+
declaredOwnedPaths: parseOwnedPathHints(sliceSection),
281+
handoff: handoffArtifact
282+
? {
283+
updated: handoffArtifact.frontmatter.updated || null,
284+
activeConstraints: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Active Constraints')),
285+
unresolvedUncertainty: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Unresolved Uncertainty')),
286+
decisionPosture: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Decision Posture')),
287+
antiRegression: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Anti-Regression')),
288+
nextActionContext: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Next Action')),
289+
}
290+
: null,
291+
};
292+
}
293+
294+
export function deriveNonPhaseState({ planningDir, hasSpec, hasRoadmap, brownfieldChange } = {}) {
295+
if (brownfieldChange?.exists) return 'active_brownfield_change';
296+
if (hasRoadmap) return null;
297+
if (hasSpec) return 'between_milestones';
298+
if (hasSubstantiveCodebaseMaps(planningDir)) return 'codebase_only';
299+
if (hasQuickLaneArtifacts(planningDir)) return 'quick_lane';
300+
return null;
301+
}
302+
226303
function splitRequirementList(rawRequirements = '') {
227304
return String(rawRequirements)
228305
.split(',')
@@ -375,3 +452,109 @@ function evaluateRequirementAlignment(spec, phases, phaseStatusAlignment = { mis
375452
mismatches,
376453
};
377454
}
455+
456+
function readMarkdownArtifact(filePath) {
457+
const raw = readTextIfExists(filePath);
458+
const normalized = normalizeContent(raw);
459+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
460+
if (!match) {
461+
return {
462+
raw: normalized,
463+
frontmatter: {},
464+
body: normalized,
465+
};
466+
}
467+
468+
return {
469+
raw: normalized,
470+
frontmatter: parseFrontmatter(match[1]),
471+
body: normalized.slice(match[0].length),
472+
};
473+
}
474+
475+
function parseFrontmatter(content) {
476+
const data = {};
477+
for (const line of normalizeContent(content).split('\n')) {
478+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
479+
if (!match) continue;
480+
data[match[1]] = match[2].trim();
481+
}
482+
return data;
483+
}
484+
485+
function extractMarkdownHeading(content) {
486+
return normalizeContent(content).match(/^#\s+(.+)$/m)?.[1]?.trim() || null;
487+
}
488+
489+
function extractMarkdownSection(content, heading) {
490+
const normalized = normalizeContent(content);
491+
const headingMatch = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm').exec(normalized);
492+
if (!headingMatch) return '';
493+
494+
const start = headingMatch.index + headingMatch[0].length;
495+
const remainder = normalized.slice(start).replace(/^\n/, '');
496+
const nextHeading = /\n##\s+/.exec(remainder);
497+
const end = nextHeading ? nextHeading.index : remainder.length;
498+
return remainder.slice(0, end).trim();
499+
}
500+
501+
function extractBulletLabel(section, label) {
502+
const match = normalizeContent(section).match(
503+
new RegExp(`^[-*]\\s+${escapeRegExp(label)}\\s*:\\s*(.+)$`, 'im')
504+
);
505+
return match ? match[1].trim() : null;
506+
}
507+
508+
function collapseMarkdownSection(section) {
509+
return normalizeContent(section)
510+
.split('\n')
511+
.map((line) => line.replace(/^[-*]\s+/, '').trim())
512+
.filter(Boolean)
513+
.join(' ');
514+
}
515+
516+
function parseOwnedPathHints(section) {
517+
const lines = normalizeContent(section)
518+
.split('\n')
519+
.map((line) => line.trim())
520+
.filter((line) => line.startsWith('|'));
521+
const paths = [];
522+
523+
for (const line of lines.slice(2)) {
524+
const columns = line.split('|').map((column) => column.trim());
525+
const owned = columns[3];
526+
if (!owned) continue;
527+
528+
for (const candidate of owned.split(',')) {
529+
const normalized = normalizeOwnedPathHint(candidate);
530+
if (normalized) paths.push(normalized);
531+
}
532+
}
533+
534+
return [...new Set(paths)];
535+
}
536+
537+
function normalizeOwnedPathHint(value) {
538+
const normalized = String(value || '')
539+
.replace(/[`[\]]/g, '')
540+
.trim()
541+
.replace(/\\/g, '/');
542+
if (!normalized) return null;
543+
if (/^(disjoint write set|owned files \/ modules|what this slice does|planned)$/i.test(normalized)) {
544+
return null;
545+
}
546+
if (!/[/.\\*]/.test(normalized)) return null;
547+
return normalized;
548+
}
549+
550+
function hasSubstantiveCodebaseMaps(planningDir) {
551+
const dir = join(planningDir, 'codebase');
552+
if (!existsSync(dir)) return false;
553+
return readdirSync(dir).some((entry) => entry.toLowerCase().endsWith('.md'));
554+
}
555+
556+
function hasQuickLaneArtifacts(planningDir) {
557+
const dir = join(planningDir, 'quick');
558+
if (!existsSync(dir)) return false;
559+
return readdirSync(dir).length > 0;
560+
}

0 commit comments

Comments
 (0)