Refactor SDK-first architecture seams (#3316)
* refactor: tighten sdk-first architecture seams Refs #3312 * refactor: finish state document seam cleanup Refs #3312 * test: harden minimal install cleanup assertion * ci: support sdk-scoped package lock * fix(3316): restore root package-lock.json and align changeset pr ref Reverts dec57a83 ("ci: support sdk-scoped package lock") and restores the root package-lock.json that c249d34d deleted. The deletion was the wrong direction: - The root package.json declares its own runtime and dev deps (@anthropic-ai/claude-agent-sdk, ws, c8). Without a root lockfile, `npm install --no-package-lock` resolves whatever satisfies semver at install time — CI today and CI in six months can install different transitive trees, defeating reproducibility. - The lockfile has been part of every release on this repo (long history on main); removing it loses the npm audit / Dependabot target without compensating benefit. - The CI workaround pattern (cache-dependency-path: sdk/package-lock.json + `npm install --no-package-lock`) papered over the symptom rather than fix the cause. Also fix the changeset pr: from 3312 (issue) to 3316 (PR). CONTEXT.md flags this exact failure mode as a recurring CodeRabbit finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address coderabbit review findings * fix: close remaining coderabbit threads --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,44 +22,20 @@ import {
|
||||
} from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
import { toPosixPath, stateExtractField } from './helpers.js';
|
||||
import { toPosixPath } from './helpers.js';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { validateWorkstreamName, toWorkstreamSlug } from '../workstream-name-policy.js';
|
||||
import { readActiveWorkstream, writeActiveWorkstream } from './active-workstream-store.js';
|
||||
import {
|
||||
inspectWorkstream,
|
||||
listWorkstreamInventories,
|
||||
planningRoot,
|
||||
workstreamsRoot,
|
||||
} from './workstream-inventory.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const planningRoot = (projectDir: string) =>
|
||||
join(projectDir, '.planning');
|
||||
|
||||
const workstreamsDir = (projectDir: string) =>
|
||||
join(planningRoot(projectDir), 'workstreams');
|
||||
|
||||
function wsPlanningPaths(projectDir: string, name: string) {
|
||||
const base = join(planningRoot(projectDir), 'workstreams', name);
|
||||
return {
|
||||
planning: base,
|
||||
state: join(base, 'STATE.md'),
|
||||
roadmap: join(base, 'ROADMAP.md'),
|
||||
phases: join(base, 'phases'),
|
||||
requirements: join(base, 'REQUIREMENTS.md'),
|
||||
};
|
||||
}
|
||||
|
||||
function readSubdirectories(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
||||
}
|
||||
|
||||
function filterPlanFiles(files: string[]): string[] {
|
||||
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
}
|
||||
|
||||
function filterSummaryFiles(files: string[]): string[] {
|
||||
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -69,7 +45,7 @@ function filterSummaryFiles(files: string[]): string[] {
|
||||
*/
|
||||
export const workstreamGet: QueryHandler = async (_args, projectDir) => {
|
||||
const active = readActiveWorkstream(projectDir);
|
||||
const wsRoot = workstreamsDir(projectDir);
|
||||
const wsRoot = workstreamsRoot(projectDir);
|
||||
return {
|
||||
data: {
|
||||
active,
|
||||
@@ -79,15 +55,26 @@ export const workstreamGet: QueryHandler = async (_args, projectDir) => {
|
||||
};
|
||||
|
||||
export const workstreamList: QueryHandler = async (_args, projectDir) => {
|
||||
const dir = workstreamsDir(projectDir);
|
||||
if (!existsSync(dir)) return { data: { mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' } };
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const workstreams = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
return { data: { mode: 'workstream', workstreams, count: workstreams.length } };
|
||||
} catch {
|
||||
return { data: { mode: 'flat', workstreams: [], count: 0 } };
|
||||
const inventory = listWorkstreamInventories(projectDir);
|
||||
if (inventory.mode === 'flat') {
|
||||
return { data: { mode: 'flat', workstreams: [], message: inventory.message } };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
mode: 'workstream',
|
||||
workstreams: inventory.workstreams.map(ws => ({
|
||||
name: ws.name,
|
||||
path: ws.path,
|
||||
has_roadmap: ws.files.roadmap,
|
||||
has_state: ws.files.state,
|
||||
status: ws.status,
|
||||
current_phase: ws.current_phase,
|
||||
phase_count: ws.phase_count,
|
||||
completed_phases: ws.completed_phases,
|
||||
})),
|
||||
count: inventory.count,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const workstreamCreate: QueryHandler = async (args, projectDir) => {
|
||||
@@ -105,7 +92,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
|
||||
return { data: { created: false, reason: '.planning/ directory not found — run /gsd-new-project first' } };
|
||||
}
|
||||
|
||||
const wsRoot = workstreamsDir(projectDir);
|
||||
const wsRoot = workstreamsRoot(projectDir);
|
||||
const wsDir = join(wsRoot, slug);
|
||||
|
||||
if (existsSync(wsDir) && existsSync(join(wsDir, 'STATE.md'))) {
|
||||
@@ -170,7 +157,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
|
||||
* so frontmatter fields and body stay in lockstep with the source.
|
||||
*/
|
||||
function syncRootStateMirror(projectDir: string, name: string): void {
|
||||
const wsStatePath = join(workstreamsDir(projectDir), name, 'STATE.md');
|
||||
const wsStatePath = join(workstreamsRoot(projectDir), name, 'STATE.md');
|
||||
const rootStatePath = join(planningRoot(projectDir), 'STATE.md');
|
||||
if (!existsSync(wsStatePath)) return;
|
||||
try {
|
||||
@@ -195,7 +182,7 @@ export const workstreamSet: QueryHandler = async (args, projectDir) => {
|
||||
return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, underscores, or dots only' } };
|
||||
}
|
||||
|
||||
const wsDir = join(workstreamsDir(projectDir), name);
|
||||
const wsDir = join(workstreamsRoot(projectDir), name);
|
||||
if (!existsSync(wsDir)) {
|
||||
return { data: { active: null, error: 'not_found', workstream: name } };
|
||||
}
|
||||
@@ -214,60 +201,26 @@ export const workstreamStatus: QueryHandler = async (args, projectDir) => {
|
||||
throw new GSDError('Invalid workstream name', ErrorClassification.Validation);
|
||||
}
|
||||
|
||||
const wsDir = join(workstreamsDir(projectDir), name);
|
||||
const wsDir = join(workstreamsRoot(projectDir), name);
|
||||
if (!existsSync(wsDir)) {
|
||||
return { data: { found: false, workstream: name } };
|
||||
}
|
||||
|
||||
const p = wsPlanningPaths(projectDir, name);
|
||||
const relPath = toPosixPath(relative(projectDir, wsDir));
|
||||
|
||||
const files = {
|
||||
roadmap: existsSync(p.roadmap),
|
||||
state: existsSync(p.state),
|
||||
requirements: existsSync(p.requirements),
|
||||
};
|
||||
|
||||
const phases: Array<{ directory: string; status: string; plan_count: number; summary_count: number }> = [];
|
||||
for (const dir of readSubdirectories(p.phases).sort()) {
|
||||
try {
|
||||
const phaseFiles = readdirSync(join(p.phases, dir));
|
||||
const plans = filterPlanFiles(phaseFiles);
|
||||
const summaries = filterSummaryFiles(phaseFiles);
|
||||
phases.push({
|
||||
directory: dir,
|
||||
status:
|
||||
summaries.length >= plans.length && plans.length > 0
|
||||
? 'complete'
|
||||
: plans.length > 0
|
||||
? 'in_progress'
|
||||
: 'pending',
|
||||
plan_count: plans.length,
|
||||
summary_count: summaries.length,
|
||||
});
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
let stateInfo: Record<string, string | null> = {};
|
||||
try {
|
||||
const stateContent = readFileSync(p.state, 'utf-8');
|
||||
stateInfo = {
|
||||
status: stateExtractField(stateContent, 'Status') || 'unknown',
|
||||
current_phase: stateExtractField(stateContent, 'Current Phase'),
|
||||
last_activity: stateExtractField(stateContent, 'Last Activity'),
|
||||
};
|
||||
} catch { /* skip */ }
|
||||
const inventory = inspectWorkstream(projectDir, name);
|
||||
if (!inventory) return { data: { found: false, workstream: name } };
|
||||
|
||||
return {
|
||||
data: {
|
||||
found: true,
|
||||
workstream: name,
|
||||
path: relPath,
|
||||
files,
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
completed_phases: phases.filter(ph => ph.status === 'complete').length,
|
||||
...stateInfo,
|
||||
path: inventory.path,
|
||||
files: inventory.files,
|
||||
phases: inventory.phases,
|
||||
phase_count: inventory.phase_count,
|
||||
completed_phases: inventory.completed_phases,
|
||||
status: inventory.status,
|
||||
current_phase: inventory.current_phase,
|
||||
last_activity: inventory.last_activity,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -280,7 +233,7 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
|
||||
}
|
||||
|
||||
const root = planningRoot(projectDir);
|
||||
const wsRoot = workstreamsDir(projectDir);
|
||||
const wsRoot = workstreamsRoot(projectDir);
|
||||
const wsDir = join(wsRoot, name);
|
||||
|
||||
if (!existsSync(wsDir)) {
|
||||
@@ -341,85 +294,31 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
|
||||
* (Not the same as roadmap `progress` / `progressBar`.)
|
||||
*/
|
||||
export const workstreamProgress: QueryHandler = async (_args, projectDir) => {
|
||||
const wsRoot = workstreamsDir(projectDir);
|
||||
|
||||
if (!existsSync(wsRoot)) {
|
||||
const inventory = listWorkstreamInventories(projectDir);
|
||||
if (inventory.mode === 'flat') {
|
||||
return {
|
||||
data: {
|
||||
mode: 'flat',
|
||||
workstreams: [],
|
||||
message: 'No workstreams — operating in flat mode',
|
||||
message: inventory.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const active = readActiveWorkstream(projectDir);
|
||||
const entries = readdirSync(wsRoot, { withFileTypes: true });
|
||||
const workstreams: Array<{
|
||||
name: string;
|
||||
active: boolean;
|
||||
status: string;
|
||||
current_phase: string | null;
|
||||
phases: string;
|
||||
plans: string;
|
||||
progress_percent: number;
|
||||
}> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const wsDir = join(wsRoot, entry.name);
|
||||
const phasesDir = join(wsDir, 'phases');
|
||||
|
||||
const phaseDirsProgress = readSubdirectories(phasesDir);
|
||||
const phaseCount = phaseDirsProgress.length;
|
||||
let completedCount = 0;
|
||||
let totalPlans = 0;
|
||||
let completedPlans = 0;
|
||||
for (const d of phaseDirsProgress) {
|
||||
try {
|
||||
const phaseFiles = readdirSync(join(phasesDir, d));
|
||||
const plans = filterPlanFiles(phaseFiles);
|
||||
const summaries = filterSummaryFiles(phaseFiles);
|
||||
totalPlans += plans.length;
|
||||
completedPlans += Math.min(summaries.length, plans.length);
|
||||
if (plans.length > 0 && summaries.length >= plans.length) completedCount++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
let roadmapPhaseCount = phaseCount;
|
||||
try {
|
||||
const roadmapContent = readFileSync(join(wsDir, 'ROADMAP.md'), 'utf-8');
|
||||
const phaseMatches = roadmapContent.match(/^###?\s+Phase\s+\d/gm);
|
||||
if (phaseMatches) roadmapPhaseCount = phaseMatches.length;
|
||||
} catch { /* no roadmap */ }
|
||||
|
||||
let status = 'unknown';
|
||||
let currentPhase: string | null = null;
|
||||
try {
|
||||
const stateContent = readFileSync(join(wsDir, 'STATE.md'), 'utf-8');
|
||||
status = stateExtractField(stateContent, 'Status') || 'unknown';
|
||||
currentPhase = stateExtractField(stateContent, 'Current Phase');
|
||||
} catch { /* skip */ }
|
||||
|
||||
workstreams.push({
|
||||
name: entry.name,
|
||||
active: entry.name === active,
|
||||
status,
|
||||
current_phase: currentPhase,
|
||||
phases: `${completedCount}/${roadmapPhaseCount}`,
|
||||
plans: `${completedPlans}/${totalPlans}`,
|
||||
progress_percent:
|
||||
roadmapPhaseCount > 0 ? Math.round((completedCount / roadmapPhaseCount) * 100) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
mode: 'workstream',
|
||||
active,
|
||||
workstreams,
|
||||
count: workstreams.length,
|
||||
active: inventory.active,
|
||||
workstreams: inventory.workstreams.map(ws => ({
|
||||
name: ws.name,
|
||||
active: ws.active,
|
||||
status: ws.status,
|
||||
current_phase: ws.current_phase,
|
||||
phases: `${ws.completed_phases}/${ws.roadmap_phase_count}`,
|
||||
plans: `${ws.completed_plans}/${ws.total_plans}`,
|
||||
progress_percent: ws.progress_percent,
|
||||
})),
|
||||
count: inventory.count,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user