Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ tmp/
distilled.zip
test-gsdd/
.worktrees

# Worktree coordination registry (local-only, never committed)
# .tmp files are per-PID (registry.json.<pid>.tmp) to avoid concurrent-write truncation
.planning/.local/registry.json
.planning/.local/registry.json.*.tmp
.planning/.local/registry.json.broken-*
.planning/.local/registry.json.tmp
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,25 @@ Use Workspine when a feature takes more than one session, or when you need to sw
## CLI

```bash
npx -y gsdd-cli init # guided install wizard
npx -y gsdd-cli health # workspace integrity check
npx -y gsdd-cli update # regenerate stale runtime surfaces
npx -y gsdd-cli models profile quality # maximize review rigor
npx -y gsdd-cli models profile budget # minimize cost
npx -y gsdd-cli control-map # repo and planning state at a glance
npx -y gsdd-cli closeout-report # read-only phase closeout replay
npx -y gsdd-cli phase-status 5 done # mark a phase status in ROADMAP.md
npx -y gsdd-cli find-phase 5 # show phase info as JSON
npx -y gsdd-cli verify 5 # run artifact checks for a phase
npx -y gsdd-cli scaffold phase 5 name # create a new phase plan file
npx -y gsdd-cli file-op copy ... # deterministic workspace file ops
npx -y gsdd-cli session-fingerprint write # rebaseline planning-state drift
npx -y gsdd-cli ui-proof validate path # validate UI proof metadata
npx -y gsdd-cli registry-list # list worktree coordination leases
npx -y gsdd-cli registry-show 5 # show lease for a specific phase
npx -y gsdd-cli registry-clear 5 # remove a lease record
npx -y gsdd-cli registry-crash 5 ... # mark a lease crashed (P66 placeholder)
npx -y gsdd-cli help # show all commands
```

Full reference: [User Guide](docs/USER-GUIDE.md) · [Runtime Support](docs/RUNTIME-SUPPORT.md) · [Verification Discipline](docs/VERIFICATION-DISCIPLINE.md)
Expand Down
5 changes: 5 additions & 0 deletions bin/gsdd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
import { cmdUiProof } from './lib/ui-proof.mjs';
import { cmdControlMap } from './lib/control-map.mjs';
import { createCmdCloseoutReport } from './lib/closeout-report.mjs';
import { cmdRegistryClear, cmdRegistryCrash, cmdRegistryList, cmdRegistryShow } from './lib/registry-commands.mjs';
import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -112,6 +113,10 @@ const COMMANDS = {
'closeout-report': cmdCloseoutReport,
'find-phase': cmdFindPhase,
'phase-status': cmdPhaseStatus,
'registry-clear': cmdRegistryClear,
'registry-crash': cmdRegistryCrash,
'registry-list': cmdRegistryList,
'registry-show': cmdRegistryShow,
verify: cmdVerify,
scaffold: cmdScaffold,
help: cmdHelp,
Expand Down
52 changes: 52 additions & 0 deletions bin/lib/closeout-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ async function buildHealthReportSafe(ctx, args) {
}
}

async function buildRegistrySectionSafe(workspaceRoot, closingPhaseId) {
try {
const { listLeases, registryExists } = await import('./registry.mjs');
if (!registryExists(workspaceRoot)) return null;
const leases = listLeases(workspaceRoot);
const active = leases.filter((l) => l.lease_state === 'open');
const closingId = closingPhaseId != null ? String(closingPhaseId) : null;
// An open lease only blocks closeout if it belongs to a phase OTHER than
// the one being closed. The own-phase active lease is expected during
// normal closeout (the phase is being verified). Parallel phases (P70+)
// will have multiple concurrent opens; we surface only the foreign ones
// as [BLOCK].
const blocking = closingId
? active.filter((l) => String(l.phase_id) !== closingId)
: active;
const ownPhase = closingId
? active.filter((l) => String(l.phase_id) === closingId)
: [];
return {
active_leases: active,
blocking_leases: blocking,
own_phase_leases: ownPhase,
stale_leases: leases.filter((l) => l.lease_state === 'crashed'),
closed_leases: leases.filter((l) => l.lease_state === 'closed'),
};
} catch {
return null;
}
}

function summarizeControlMap(map) {
return {
status: map.risks.some((risk) => risk.severity === 'block')
Expand Down Expand Up @@ -237,6 +267,7 @@ export async function buildCloseoutReport(ctx = {}, args = []) {
planningDir: context.planningDir,
});
const health = await buildHealthReportSafe(ctx, ['--workspace-root', context.workspaceRoot]);
const registrySection = await buildRegistrySectionSafe(context.workspaceRoot, selectedPhase);
const preflight = evaluateLifecyclePreflight({
planningDir: context.planningDir,
surface: 'verify',
Expand Down Expand Up @@ -276,6 +307,7 @@ export async function buildCloseoutReport(ctx = {}, args = []) {
preflight: summarizePreflight(preflight),
phase_verification: summarizePhaseVerification(phaseReport),
ui_proof: phaseReport.ok ? phaseReport.result.ui_proof : null,
...(registrySection !== null ? { registry: registrySection } : {}),
},
};
}
Expand All @@ -298,6 +330,26 @@ function printHuman(report) {
if (warning.fix) console.log(` Fix: ${warning.fix}`);
}
}
if (report.registry) {
const {
blocking_leases = [],
own_phase_leases = [],
stale_leases = [],
closed_leases = [],
} = report.registry;
const hasAny =
blocking_leases.length > 0 ||
own_phase_leases.length > 0 ||
stale_leases.length > 0 ||
closed_leases.length > 0;
if (hasAny) {
console.log('\nRegistry:');
for (const l of blocking_leases) console.log(` [BLOCK] ${l.phase_id} ${l.branch_name} open ${l.granted_at}`);
for (const l of own_phase_leases) console.log(` [INFO] ${l.phase_id} ${l.branch_name} open ${l.granted_at} (closing phase)`);
for (const l of stale_leases) console.log(` [WARN] ${l.phase_id} ${l.branch_name} crashed ${l.granted_at}`);
for (const l of closed_leases) console.log(` [INFO] ${l.phase_id} ${l.branch_name} closed ${l.granted_at}`);
}
}
console.log(`\nNext safe action: ${report.next_safe_action.command}`);
console.log(`Reason: ${report.next_safe_action.reason}`);
}
Expand Down
7 changes: 7 additions & 0 deletions bin/lib/init-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ Commands:
Maintain optional local intent annotations under .planning/.local/
closeout-report [--json] [--phase <N>]
Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals
registry-list [--json] List all worktree coordination leases (phase, branch, state, granted_at)
registry-show <phase> [--json]
Show the lease record for a specific phase
registry-clear <phase> [--force]
Remove a lease record (--force required if lease is open)
registry-crash <phase> --reason <text>
Mark a lease as crashed (P66; placeholder in P65)
help Show this summary

Platforms (for --tools):
Expand Down
120 changes: 115 additions & 5 deletions bin/lib/phase.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// IMPORTANT: No module-scope process.cwd() — ESM caching means sub-modules
// evaluate once, so CWD must be computed inside function bodies.

import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
import { dirname, join, relative } from 'path';
import { output } from './cli-utils.mjs';
import { writeFingerprint } from './session-fingerprint.mjs';
Expand Down Expand Up @@ -560,18 +560,101 @@ export function updateRoadmapPhaseStatus(roadmap, phaseNumber, status) {
return updatedLines.join('\n');
}

// AGENTS.md §1.17 — phase-closure artifact gate. A phase cannot transition to
// `done` unless NN-PLAN-CHECK.md and NN-VERIFICATION.md exist in the phase
// folder, and .internal-research/lessons-learned.md has been touched within
// the staleness window (default 7 days). `--force` overrides the gate but
// requires `--reason <text>` which is auto-appended as an LL-* entry.
const PHASE_CLOSURE_LESSONS_STALENESS_DAYS = 7;

function findPhaseFolder(planningDir, phaseNumber) {
const phasesDir = join(planningDir, 'phases');
if (!existsSync(phasesDir)) return null;
const padded = padPhase(phaseNumber);
for (const entry of readdirSync(phasesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(`${padded}-`)) {
return { dir: join(phasesDir, entry.name), padded };
}
}
return null;
}

function checkPhaseClosureGate(workspaceRoot, planningDir, phaseNumber) {
const folder = findPhaseFolder(planningDir, phaseNumber);
if (!folder) {
// No phase folder exists for this phase number. §1.17 enforces artifacts
// for *real* phase closures; a roadmap-only mutation (no plan/summary
// structure under .planning/phases/) is out of scope. Skip the gate.
return { ok: true, missing: [], gate_skipped: 'no phase folder' };
}
// Also skip the gate if .internal-research/ does not exist — consumer
// projects do not have this directory; §1.17 is internal-GSDD governance.
const internalResearchDir = join(workspaceRoot, '.internal-research');
if (!existsSync(internalResearchDir)) {
return { ok: true, missing: [], gate_skipped: 'no .internal-research/ directory (consumer project)' };
}
const missing = [];
const planCheck = join(folder.dir, `${folder.padded}-PLAN-CHECK.md`);
const verification = join(folder.dir, `${folder.padded}-VERIFICATION.md`);
if (!existsSync(planCheck)) missing.push(planCheck);
if (!existsSync(verification)) missing.push(verification);
const lessons = join(internalResearchDir, 'lessons-learned.md');
if (!existsSync(lessons)) {
missing.push(`${lessons} (file not found; §6 doc-sync evidence required)`);
} else {
const ageDays = (Date.now() - statSync(lessons).mtimeMs) / 86_400_000;
if (ageDays > PHASE_CLOSURE_LESSONS_STALENESS_DAYS) {
missing.push(
`${lessons} (last touched ${ageDays.toFixed(1)} days ago; must be within ${PHASE_CLOSURE_LESSONS_STALENESS_DAYS} days per §1.17)`,
);
}
}
return { ok: missing.length === 0, missing };
}

function appendForceOverrideLessonsEntry(workspaceRoot, phaseNumber, reason) {
const lessons = join(workspaceRoot, '.internal-research', 'lessons-learned.md');
if (!existsSync(lessons)) return;
const sanitizedReason = String(reason || '').trim();
const escapedPhase = String(phaseNumber).toUpperCase().replace(/[^A-Z0-9-]/g, '-');
const entry = [
'',
'---',
'',
`## LL-PHASE-STATUS-FORCE-OVERRIDE-${escapedPhase}-${new Date().toISOString().slice(0, 10)}`,
'',
`\`gsdd phase-status ${phaseNumber} done --force\` was invoked; the §1.17 phase-closure artifact gate was bypassed.`,
`**Why:** ${sanitizedReason}`,
`**Rule:** Force-overrides are appended here automatically so the gap is auditable. Future maintainers should treat the named phase as having an artifact gap that needs follow-up. The gate exists to prevent silent drift; \`--force\` is the explicit, auditable escape hatch, not a routine option.`,
'',
].join('\n');
const current = readFileSync(lessons, 'utf-8');
const trimmed = current.endsWith('\n') ? current : `${current}\n`;
writeFileSync(lessons, trimmed + entry);
}

export function cmdPhaseStatus(...args) {
const { args: normalizedArgs, planningDir, invalid, error } = resolveWorkspaceContext(args);
const { args: normalizedArgs, workspaceRoot, planningDir, invalid, error } = resolveWorkspaceContext(args);
if (invalid) {
console.error(error);
process.exitCode = 1;
return;
}
const force = normalizedArgs.includes('--force');
const reasonIdx = normalizedArgs.indexOf('--reason');
const reason = reasonIdx !== -1 ? normalizedArgs[reasonIdx + 1] : null;
const positional = normalizedArgs.filter((arg, idx) => {
if (arg === '--force') return false;
if (arg === '--reason') return false;
if (idx > 0 && normalizedArgs[idx - 1] === '--reason') return false;
return true;
});
const [phaseNumber, status] = positional;
const roadmapPath = join(planningDir, 'ROADMAP.md');
const [phaseNumber, status] = normalizedArgs;

if (!phaseNumber || !status) {
console.error('Usage: gsdd phase-status <phase-number> <not_started|todo|in_progress|done>');
console.error('Usage: gsdd phase-status <phase-number> <not_started|todo|in_progress|done> [--force --reason <text>]');
process.exitCode = 1;
return;
}
Expand All @@ -582,6 +665,33 @@ export function cmdPhaseStatus(...args) {
return;
}

// §1.17 phase-closure artifact gate — only fires when transitioning to `done`.
if (status === 'done') {
const gate = checkPhaseClosureGate(workspaceRoot, planningDir, phaseNumber);
if (!gate.ok && !force) {
console.error(`Refused: phase ${phaseNumber} cannot be marked done — §1.17 artifacts missing:`);
for (const item of gate.missing) console.error(` - ${item}`);
console.error('');
console.error('Resolve by creating the missing artifacts, or pass `--force --reason <text>` to override.');
console.error('Force-overrides are auto-recorded as LL-* entries in .internal-research/lessons-learned.md.');
process.exitCode = 1;
return;
}
if (force && (!reason || !String(reason).trim())) {
console.error('Refused: --force requires --reason <text> describing why the gate is being bypassed.');
console.error('The reason will be appended as an LL-* entry to .internal-research/lessons-learned.md.');
process.exitCode = 1;
return;
}
if (force && !gate.ok) {
try {
appendForceOverrideLessonsEntry(workspaceRoot, phaseNumber, reason);
} catch (err) {
console.error(`Warning: --force succeeded but failed to append LL entry (${err.message}).`);
}
}
}

try {
const roadmap = readFileSync(roadmapPath, 'utf-8');
const updated = updateRoadmapPhaseStatus(roadmap, phaseNumber, status);
Expand All @@ -590,7 +700,7 @@ export function cmdPhaseStatus(...args) {
writeFileSync(roadmapPath, updated);
try { writeFingerprint(planningDir); } catch { /* best-effort */ }
}
output({ phase: phaseNumber, status, roadmap: '.planning/ROADMAP.md', changed });
output({ phase: phaseNumber, status, roadmap: '.planning/ROADMAP.md', changed, gate_overridden: status === 'done' && force });
} catch (error) {
console.error(error.message);
process.exitCode = 1;
Expand Down
Loading