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
19 changes: 19 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Normalize line endings to LF in the repository.
# Working tree line endings still follow the user's git config (core.autocrlf).
# This file prevents Windows checkouts from generating phantom CRLF diffs.
* text=auto eol=lf

# Explicit binary types — never normalize.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,11 @@ _bmad/
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
653 changes: 70 additions & 583 deletions README.md

Large diffs are not rendered by default.

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
90 changes: 84 additions & 6 deletions bin/lib/closeout-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function notice(source, severity, entry) {
severity,
code: entry.code || entry.id || 'unknown',
message: entry.message,
fix: entry.fix || entry.fix_hint || null,
fix: entry.fix_hint || entry.fix || null,
path: entry.path || null,
};
}
Expand Down 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 @@ -160,18 +190,38 @@ function nextSafeAction({ blockers, warnings, phaseNumber }) {
if (blockers.length > 0) {
return {
command: `gsdd verify ${phaseNumber}`,
reason: 'Repair blockers before treating closeout as replayed.',
reason: 'Fix blockers first, then re-run closeout replay.',
};
}
const hasWarn = warnings.some((entry) => entry.severity === 'warn');
if (warnings.length > 0 && hasWarn) {
const sources = new Set(warnings.filter((entry) => entry.severity === 'warn').map((entry) => entry.source));
if (sources.has('health')) {
return {
command: 'gsdd health --json',
reason: 'Resolve workspace health warnings before claiming the environment is clean.',
};
}
if (sources.has('ui_proof') || sources.has('phase_verification')) {
return {
command: `gsdd verify ${phaseNumber}`,
reason: 'Resolve phase verification warnings before claiming closeout is replay-clean.',
};
}
return {
command: 'gsdd control-map --json',
reason: 'Resolve local state warnings before claiming the environment is clean.',
};
}
if (warnings.length > 0) {
return {
command: 'gsdd control-map --json',
reason: 'Review warnings before claiming the local environment is clean.',
reason: 'Review the informational notices before claiming the local environment is clean.',
};
}
return {
command: `gsdd verify ${phaseNumber}`,
reason: 'Phase implementation is replay-clean; run formal verification for closure if it has not already been recorded.',
reason: 'Closeout replay is clean; run formal verification for closure if it has not already been recorded.',
};
}

Expand Down Expand Up @@ -217,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 @@ -256,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 @@ -266,11 +318,37 @@ function printHuman(report) {
console.log(`Status: ${report.status}`);
if (report.blockers.length > 0) {
console.log('\nBlockers:');
for (const blocker of report.blockers) console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`);
for (const blocker of report.blockers) {
console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`);
if (blocker.fix) console.log(` Fix: ${blocker.fix}`);
}
}
if (report.warnings.length > 0) {
console.log('\nWarnings:');
for (const warning of report.warnings) console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`);
for (const warning of report.warnings) {
console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`);
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
113 changes: 94 additions & 19 deletions bin/lib/control-map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -982,29 +982,73 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
const writeSetOverlaps = findWriteSetOverlaps(writeEntries);
const dirtyWriteSetOverlaps = findDirtyWriteSetOverlaps(writeEntries, dirtyEntries);

function fixHintForRisk(risk) {
const code = risk.code;
switch (code) {
case 'canonical_git_invalid':
case 'worktree_git_invalid': {
const targetPath = risk.worktree_id || canonical.path;
return `Run \`git config --global --add safe.directory ${targetPath}\`, then re-run \`gsdd control-map --json\`.`;
}
case 'canonical_dirty':
return 'Commit, stash, or checkpoint the canonical changes before planning, cleanup, merge, or broad execution.';
case 'canonical_dirty_behind_upstream':
return 'Commit/stash the canonical changes or sync the branch; do not mutate a dirty checkout that is behind upstream.';
case 'canonical_branch_behind_upstream':
case 'canonical_branch_diverged_upstream':
case 'worktree_branch_behind_upstream':
case 'worktree_branch_diverged_upstream':
return 'Review upstream divergence (fetch/merge/rebase) before treating this branch state as an execution surface.';
case 'detached_candidate_worktree':
return 'Classify the detached worktree intent (active vs abandoned) before using it for execution or cleanup decisions.';
case 'sibling_worktree_dirty':
case 'unannotated_candidate_worktree':
return 'Review sibling worktree ownership and write set before starting overlapping implementation.';
case 'write_set_overlap':
return 'Resolve overlapping local annotation write sets before starting another owned-write workflow.';
case 'dirty_path_write_set_overlap':
return 'Checkpoint or classify dirty paths that overlap annotated write sets before owned-write transitions.';
case 'planning_state_drift':
return 'Review drift and rebaseline with session-fingerprint only after confirming the planning changes are intentional.';
default:
return null;
}
}

for (const error of gitErrors) {
risks.push({ code: error.code, severity: 'warn', message: error.message });
}
if (!canonical.git_valid) {
risks.push({ code: 'canonical_git_invalid', severity: 'warn', message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}` });
const risk = {
code: 'canonical_git_invalid',
severity: 'warn',
path: normalizeSlashes(canonical.path),
message: `Canonical worktree git status failed: ${canonical.status_error || 'unknown error'}`,
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
addBranchStateRisks(risks, canonical, { canonical: true });
if (canonical.dirty.counts.tracked > 0 || canonical.dirty.counts.untracked > 0) {
risks.push({
const risk = {
code: 'canonical_dirty',
severity: 'warn',
message: `Canonical worktree has tracked/untracked changes (${canonical.dirty.counts.tracked} tracked, ${canonical.dirty.counts.untracked} untracked).`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
if (canonical.dirty.counts.tracked > 0 && (canonical.ahead_behind?.behind || 0) > 0) {
risks.push({
const risk = {
code: 'canonical_dirty_behind_upstream',
severity: 'block',
branch: canonical.branch,
ahead: canonical.ahead_behind?.ahead,
behind: canonical.ahead_behind?.behind,
message: `Canonical worktree has tracked changes while behind upstream by ${canonical.ahead_behind.behind} commit(s).`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
if (canonical.dirty.counts.ignored > 0) {
risks.push({
Expand All @@ -1015,51 +1059,68 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
}
for (const worktree of worktrees.filter((entry) => entry.path !== canonical.path)) {
if (!worktree.git_valid) {
risks.push({ code: 'worktree_git_invalid', severity: 'warn', worktree_id: worktree.id, message: `Worktree ${worktree.id} could not be inspected by git.` });
const risk = {
code: 'worktree_git_invalid',
severity: 'warn',
worktree_id: worktree.id,
message: `Worktree ${worktree.id} could not be inspected by git.`,
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
addBranchStateRisks(risks, worktree);
if (worktree.detached) {
risks.push({
const risk = {
code: 'detached_candidate_worktree',
severity: 'warn',
worktree_id: worktree.id,
message: `Worktree ${worktree.id} is detached; classify its intent before treating it as an execution surface.`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
if (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0) {
risks.push({
const risk = {
code: 'sibling_worktree_dirty',
severity: 'warn',
worktree_id: worktree.id,
message: `Sibling worktree ${worktree.id} has tracked/untracked changes.`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
if (!worktree.annotation && (worktree.dirty.counts.tracked > 0 || worktree.dirty.counts.untracked > 0 || worktree.detached)) {
risks.push({
const risk = {
code: 'unannotated_candidate_worktree',
severity: 'info',
worktree_id: worktree.id,
message: `Worktree ${worktree.id} has candidate-work signals but no local control-map annotation.`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
}
if (writeSetOverlaps.length > 0) {
risks.push({
const risk = {
code: 'write_set_overlap',
severity: 'block',
message: `Active control-map annotations have ${writeSetOverlaps.length} concrete write-set overlap(s).`,
overlaps: writeSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
omitted_count: Math.max(0, writeSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
if (dirtyWriteSetOverlaps.length > 0) {
risks.push({
const risk = {
code: 'dirty_path_write_set_overlap',
severity: 'block',
message: `Live dirty paths overlap annotated write sets (${dirtyWriteSetOverlaps.length} overlap(s)).`,
overlaps: dirtyWriteSetOverlaps.slice(0, MAX_DIRTY_BUCKET_ENTRIES),
omitted_count: Math.max(0, dirtyWriteSetOverlaps.length - MAX_DIRTY_BUCKET_ENTRIES),
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}
for (const warning of annotations.warnings || []) risks.push(warning);
for (const error of annotations.errors || []) {
Expand All @@ -1074,11 +1135,22 @@ function buildRisks({ canonical, worktrees, annotations, rawAnnotations, runtime
});
}
if (workflowState.planning_drift.drifted) {
risks.push({
const risk = {
code: 'planning_state_drift',
severity: 'warn',
message: `Planning state drifted since the last fingerprint: ${workflowState.planning_drift.details.join('; ')}`,
});
};
risk.fix_hint = fixHintForRisk(risk);
risks.push(risk);
}

// Ensure the common closure risks expose actionable fix guidance even when
// the originating helper (for example branch-state risks) didn't attach it.
for (const risk of risks) {
if (!risk.fix_hint) {
const hint = fixHintForRisk(risk);
if (hint) risk.fix_hint = hint;
}
}
return risks;
}
Expand Down Expand Up @@ -1175,7 +1247,10 @@ function printHuman(map) {
}
if (map.risks.length > 0) {
console.log('\nRisks:');
for (const risk of map.risks) console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`);
for (const risk of map.risks) {
console.log(` - [${risk.severity || 'info'}] ${risk.code}: ${risk.message}`);
if (risk.fix_hint) console.log(` Fix: ${risk.fix_hint}`);
}
}
console.log('\nInterventions:');
for (const intervention of map.interventions) console.log(` - ${intervention}`);
Expand Down
Loading