Skip to content
Merged
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
8 changes: 6 additions & 2 deletions hooks/dev-rules-reminder.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ try {
resolveNamingPattern,
resolvePlanPath,
getGitBranch,
resolveSkillsVenv
resolveSkillsVenv,
resolveFeatureRoot
} = require('./lib/paths.cjs');
const { readSessionState } = require('./lib/state.cjs');

Expand All @@ -42,7 +43,7 @@ try {
const gitBranch = getGitBranch(baseDir);
const namePattern = resolveNamingPattern(config.plan, gitBranch);

const resolved = resolvePlanPath(sessionId, config, readSessionState);
const resolved = resolvePlanPath(sessionId, config, readSessionState, baseDir);
const reportsPath = getReportsPath(resolved.path, resolved.resolvedBy, config.plan, config.paths, baseDir, config);
const plansPath = getPlansPath(baseDir, config);
const docsPath = getDocsPath(baseDir, config);
Expand All @@ -51,6 +52,8 @@ try {
const visualsPath = umbrellaVal ? getVisualsPath(baseDir, config) : null;
const journalsPath = umbrellaVal ? getJournalsPath(baseDir, config) : null;
const statePath = umbrellaVal ? getStatePath(baseDir, config) : null;
const featureFirst = !!umbrellaVal && (config.paths?.layout === 'feature-first');
const featureRoot = featureFirst ? resolveFeatureRoot(config, baseDir) : null;

const skillsVenv = resolveSkillsVenv(baseDir);

Expand All @@ -62,6 +65,7 @@ try {
} else {
lines.push(`Reports: ${reportsPath}/ | Plans: ${plansPath}/ | Docs: ${docsPath}/`);
}
if (featureFirst) lines.push(`Feature: ${featureRoot}/`);
lines.push('');

lines.push('## Naming');
Expand Down
36 changes: 32 additions & 4 deletions hooks/lib/config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const { getMainWorktreeRoot } = require('./paths.cjs');

const DEFAULT_CONFIG = {
schemaVersion: 2,
plan: {
namingFormat: '{date}-{issue}-{slug}',
dateFormat: 'YYMMDD-HHmm',
issuePrefix: null,
ticketPrefixes: ['ELT', 'GH', 'PROJ'],
reportsDir: 'reports',
resolution: {
order: ['session', 'branch'],
Expand All @@ -36,6 +39,9 @@ const DEFAULT_CONFIG = {
// Umbrella: null = legacy CWD-anchored layout.
// Set to a relative name (e.g. ".workbench") in <git-root>/.vd.json to opt in.
umbrella: null,
// Layout: 'type-first' (flat type siblings) | 'feature-first' (per-feature folders).
// Default 'type-first' → byte-identical to legacy; opt in per-repo via .vd.json.
layout: 'type-first',
visuals: 'visuals',
journals: 'journals',
state: 'state'
Expand Down Expand Up @@ -146,7 +152,28 @@ function assertMigrated(vdPath, ckPath) {
}

/**
* Load config: DEFAULT ← global (~/.claude/.vd.json) ← project (<git-root>/.vd.json).
* Read the MAIN worktree's .vd.json (or null). Layout-determining keys (umbrella,
* layout) come from here so linked worktrees can't disagree about the artifact layout.
*/
function getMainWorktreeConfig(cwd) {
const mainRoot = getMainWorktreeRoot(cwd);
if (!mainRoot) return null;
return readJson(path.join(mainRoot, '.vd.json'));
}

/** Overlay the repo-wide layout keys (umbrella, layout) from the main worktree config. */
function applyMainWorktreeLayout(merged, mainCfg) {
if (!mainCfg || !mainCfg.paths) return merged;
const out = Object.assign({}, merged);
out.paths = Object.assign({}, merged.paths);
if (typeof mainCfg.paths.umbrella === 'string') out.paths.umbrella = mainCfg.paths.umbrella;
if (typeof mainCfg.paths.layout === 'string') out.paths.layout = mainCfg.paths.layout;
return out;
}

/**
* Load config: DEFAULT ← global (~/.claude/.vd.json) ← project (<git-root>/.vd.json),
* then overlay layout+umbrella from the MAIN worktree (repo-wide artifact-layout keys).
* No .ck.json fallback — a lingering legacy file raises a migration error.
* Falls back to defaults on any error.
*/
Expand All @@ -162,12 +189,11 @@ function loadConfig() {
const globalCfg = readJson(globalPath);
const localCfg = localPath ? readJson(localPath) : null;

if (!globalCfg && !localCfg) return buildResult(layerConfigs({}, DEFAULT_CONFIG), gitRoot);

try {
let merged = layerConfigs({}, DEFAULT_CONFIG);
if (globalCfg) merged = layerConfigs(merged, globalCfg);
if (localCfg) merged = layerConfigs(merged, localCfg);
merged = applyMainWorktreeLayout(merged, getMainWorktreeConfig(process.cwd()));
return buildResult(merged, gitRoot);
} catch {
return buildResult(layerConfigs({}, DEFAULT_CONFIG), gitRoot);
Expand All @@ -180,11 +206,13 @@ function buildResult(merged, gitRoot) {
const umbrella = sanitizeUmbrella(rawPaths.umbrella, gitRoot || null);

return {
schemaVersion: merged.schemaVersion ?? DEFAULT_CONFIG.schemaVersion,
plan: merged.plan || DEFAULT_CONFIG.plan,
paths: {
docs: rawPaths.docs || DEFAULT_CONFIG.paths.docs,
plans: rawPaths.plans || DEFAULT_CONFIG.paths.plans,
umbrella,
layout: rawPaths.layout === 'feature-first' ? 'feature-first' : 'type-first',
visuals: rawPaths.visuals || DEFAULT_CONFIG.paths.visuals,
journals: rawPaths.journals || DEFAULT_CONFIG.paths.journals,
state: rawPaths.state || DEFAULT_CONFIG.paths.state
Expand All @@ -201,4 +229,4 @@ function buildResult(merged, gitRoot) {
};
}

module.exports = { DEFAULT_CONFIG, layerConfigs, loadConfig, getGitRoot, sanitizeUmbrella, assertMigrated };
module.exports = { DEFAULT_CONFIG, layerConfigs, loadConfig, getGitRoot, sanitizeUmbrella, assertMigrated, getMainWorktreeConfig, applyMainWorktreeLayout };
203 changes: 177 additions & 26 deletions hooks/lib/paths.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ function resolveUmbrellaRoot(config, baseDir) {
* Umbrella-off: <baseDir>/plans (legacy, byte-identical)
*/
function getPlansPath(baseDir, config) {
const umbrellaRoot = resolveUmbrellaRoot(config, baseDir);
if (umbrellaRoot) {
return path.join(umbrellaRoot, stripTrailing(config.paths?.plans) || 'plans');
const featureRoot = resolveFeatureRoot(config, baseDir); // == umbrellaRoot unless feature-first
if (featureRoot) {
return path.join(featureRoot, stripTrailing(config.paths?.plans) || 'plans');
}
// Legacy: second arg was pathsConfig in P2 — accept both shapes
const pathsConfig = config?.paths ? config.paths : config;
Expand All @@ -119,26 +119,26 @@ function getDocsPath(baseDir, config) {
}

function getVisualsPath(baseDir, config) {
const umbrellaRoot = resolveUmbrellaRoot(config, baseDir);
const featureRoot = resolveFeatureRoot(config, baseDir);
const name = stripTrailing(config?.paths?.visuals) || 'visuals';
return umbrellaRoot
? path.join(umbrellaRoot, name)
return featureRoot
? path.join(featureRoot, name)
: path.join(baseDir, 'plans', name); // legacy fallback mirrors plans/visuals
}

function getJournalsPath(baseDir, config) {
const umbrellaRoot = resolveUmbrellaRoot(config, baseDir);
const featureRoot = resolveFeatureRoot(config, baseDir);
const name = stripTrailing(config?.paths?.journals) || 'journals';
return umbrellaRoot
? path.join(umbrellaRoot, name)
return featureRoot
? path.join(featureRoot, name)
: path.join(baseDir, 'plans', name); // legacy fallback: plans/journals
}

function getStatePath(baseDir, config) {
const umbrellaRoot = resolveUmbrellaRoot(config, baseDir);
const featureRoot = resolveFeatureRoot(config, baseDir);
const name = stripTrailing(config?.paths?.state) || 'state';
return umbrellaRoot
? path.join(umbrellaRoot, name)
return featureRoot
? path.join(featureRoot, name)
: path.join(baseDir, 'plans', 'goals'); // legacy fallback: plans/goals
}

Expand All @@ -157,6 +157,15 @@ function getStatePath(baseDir, config) {
function getReportsPath(planPath, resolvedBy, planConfig, pathsConfig, anchor, config) {
const subdir = stripTrailing(planConfig?.reportsDir) || 'reports';

// Feature-first: reports nest in the FEATURE dir (not the plan subdir) — kills the split-brain.
if (config && config.paths?.layout === 'feature-first') {
const featureRoot = resolveFeatureRoot(config, anchor || process.cwd());
if (featureRoot) {
if (!anchor) return `${featureRoot}/${subdir}/`;
return path.join(featureRoot, subdir);
}
}

// Session-active plan overrides everything
const activePlan = (planPath && resolvedBy === 'session') ? stripTrailing(planPath) : null;

Expand Down Expand Up @@ -288,8 +297,13 @@ function slugFromBranch(branch, pattern) {
* `readState` is injected to avoid circular deps and enable testing.
* Returns { path, resolvedBy } where resolvedBy is 'session'|'branch'|null.
*/
function resolvePlanPath(sessionId, config, readState) {
const plansDir = stripTrailing(config?.paths?.plans) || 'plans';
/** Strip a leading `YYYYMMDD-HHMM-` or `YYMMDD-HHMM-` date prefix from a plan dir name. */
function planDirSlug(name) {
return name.replace(/^\d{6,8}-\d{4}-/, '');
}

function resolvePlanPath(sessionId, config, readState, baseDir) {
baseDir = baseDir || process.cwd();
const resolution = config?.plan?.resolution || {};
const order = resolution.order || ['session', 'branch'];

Expand All @@ -305,18 +319,24 @@ function resolvePlanPath(sessionId, config, readState) {
}
} else if (step === 'branch') {
try {
const branch = getGitBranch();
const branch = getGitBranch(baseDir);
const slug = slugFromBranch(branch, resolution.branchPattern);
if (slug && fs.existsSync(plansDir)) {
const matches = fs.readdirSync(plansDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.includes(slug));
if (matches.length > 0) {
return {
path: path.join(plansDir, matches[matches.length - 1].name),
resolvedBy: 'branch'
};
}
}
if (!slug) continue;
// Anchor to the umbrella/main-worktree plans dir — the old cwd-relative
// `plansDir` silently no-op'd inside linked worktrees.
const plansDir = getPlansPath(baseDir, config);
if (!fs.existsSync(plansDir)) continue;
const dirs = fs.readdirSync(plansDir, { withFileTypes: true })
.filter(e => e.isDirectory());
// Prefer an EXACT slug match; fall back to substring only when unambiguous.
// On >1 candidate, REFUSE — the old `matches[last]` silently mis-converged.
const exact = dirs.filter(e => planDirSlug(e.name) === slug);
const substr = dirs.filter(e => e.name.includes(slug));
const pick = exact.length === 1 ? exact[0]
: exact.length > 1 ? null
: substr.length === 1 ? substr[0]
: null;
if (pick) return { path: path.join(plansDir, pick.name), resolvedBy: 'branch' };
} catch { /* ignore fs errors */ }
}
}
Expand All @@ -329,6 +349,130 @@ function extractTaskListId(resolved) {
return path.basename(resolved.path);
}

// ── feature-first resolution (gated on config.paths.layout === 'feature-first') ──

/** Prefix-preserving ticket extractor: `feat/ELT-3316-x` → `ELT-3316`; `gh3251` → `GH-3251`. */
function extractTicketFromBranch(branch, prefixes) {
if (!branch) return null;
const list = (Array.isArray(prefixes) && prefixes.length) ? prefixes : ['ELT', 'GH', 'PROJ'];
const re = new RegExp(`\\b(${list.join('|')})-?(\\d+)\\b`, 'i');
const m = branch.match(re);
return m ? `${m[1].toUpperCase()}-${m[2]}` : null;
}

/** Feature id from `{ticket}-{slug}` or `{slug}` (lowercased, cleaned).
* Strips a leading duplicate ticket from slug so `feat/ELT-3316-manual-upload`
* → `elt-3316-manual-upload`, not `elt-3316-elt-3316-manual-upload`. */
function computeFeatureId(ticket, slug) {
if (ticket) {
const [pre, num] = ticket.split('-');
const desc = slug ? slug.replace(new RegExp(`^${pre}-?${num}-?`, 'i'), '') : '';
return cleanSlug((desc ? `${ticket}-${desc}` : ticket).toLowerCase());
}
if (slug) return cleanSlug(slug.toLowerCase()); // lowercase for parity with the ticket branch
return null;
}

/** Scan features/<id>/feature.json for field===value; return the id, or null (refuse on >1). */
function findFeatureBy(featuresDir, field, value) {
let dirs;
try { dirs = fs.readdirSync(featuresDir, { withFileTypes: true }).filter(e => e.isDirectory()); }
catch { return null; }
const hits = [];
for (const d of dirs) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(featuresDir, d.name, 'feature.json'), 'utf8'));
if (meta && meta[field] === value) hits.push(d.name);
} catch { /* missing/invalid feature.json — skip */ }
}
return hits.length === 1 ? hits[0] : null;
}

/** Create features/<id>/feature.json if absent. Idempotent, atomic (rename), best-effort. */
function ensureFeatureMeta(featuresDir, id, meta) {
const dir = path.join(featuresDir, id);
const metaPath = path.join(dir, 'feature.json');
if (fs.existsSync(metaPath)) return;
try {
fs.mkdirSync(dir, { recursive: true });
const tmp = `${metaPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(meta, null, 2));
fs.renameSync(tmp, metaPath); // atomic commit; first rename wins (created/branches may differ on a race)
} catch { /* never block resolution on a write failure */ }
}

const _featureIdCache = new Map();

/**
* Resolve the feature id for the current context. Pure read except a one-time idempotent
* feature.json create on first strong-signal resolution. First hit wins; no call-time date,
* no LLM slug. Returns null on no signal (caller routes to _global/scratch).
*/
function resolveFeatureId(config, baseDir, sessionId, readState) {
baseDir = baseDir || process.cwd();
const umbrellaRoot = resolveUmbrellaRoot(config, baseDir);
if (!umbrellaRoot) return null;
const cacheKey = `${umbrellaRoot}|${sessionId || ''}`;
if (_featureIdCache.has(cacheKey)) return _featureIdCache.get(cacheKey);

const featuresDir = path.join(umbrellaRoot, 'features');
const remember = (id) => { _featureIdCache.set(cacheKey, id); return id; };

// 1. explicit per-session override (workbench switch)
const state = readState ? readState(sessionId) : null;
if (state && typeof state.featureId === 'string' && state.featureId) return remember(state.featureId);

// branch signals
const branch = getGitBranch(baseDir);
const ticket = extractTicketFromBranch(branch, config?.plan?.ticketPrefixes);
const slug = slugFromBranch(branch, config?.plan?.resolution?.branchPattern);

// 2-3. match an EXISTING feature (survives slug drift / relabel)
if (ticket) { const m = findFeatureBy(featuresDir, 'ticket', ticket); if (m) return remember(m); }
if (slug) { const m = findFeatureBy(featuresDir, 'slug', slug); if (m) return remember(m); }

// 4. strong branch signal, no existing match → compute id + create the anchor (idempotent)
const computed = computeFeatureId(ticket, slug);
if (computed) {
ensureFeatureMeta(featuresDir, computed, {
id: computed, ticket: ticket || null, slug: slug || null, label: slug || computed,
status: 'active', created: new Date().toISOString(), parentId: null,
supersededBy: null, relatedDocs: [], branches: branch ? [branch] : []
});
return remember(computed);
}

// 5. session-active plan → its parent feature (plan path stored absolute in state)
if (state && state.activePlan) {
let p = state.activePlan;
if (!path.isAbsolute(p) && state.sessionOrigin) p = path.join(state.sessionOrigin, p);
const seg = path.relative(featuresDir, p).split(path.sep);
if (seg[0] && !seg[0].startsWith('..')) return remember(seg[0]);
}

// 6. no signal
return remember(null);
}

/** Feature root: umbrella root verbatim when not feature-first (byte-identical); else features/<id> or _global/scratch. */
function resolveFeatureRoot(config, baseDir, sessionId, readState) {
baseDir = baseDir || process.cwd();
const u = resolveUmbrellaRoot(config, baseDir);
if (!u || config?.paths?.layout !== 'feature-first') return u;
const id = resolveFeatureId(config, baseDir, sessionId, readState);
return id ? path.join(u, 'features', id) : path.join(u, '_global', 'scratch');
}

/** Root-level cross-feature zones (umbrella only). */
function getGlobalPath(baseDir, config) {
const u = resolveUmbrellaRoot(config, baseDir);
return u ? path.join(u, '_global') : null;
}
function getArchivePath(baseDir, config) {
const u = resolveUmbrellaRoot(config, baseDir);
return u ? path.join(u, '_archive') : null;
}

// ── venv resolution ───────────────────────────────────────────────────────

/** Check project-local then global ~/.claude for a skills venv python binary. */
Expand Down Expand Up @@ -360,5 +504,12 @@ module.exports = {
getMainWorktreeRoot,
slugFromBranch,
extractIssueFromBranch,
resolveUmbrellaRoot
resolveUmbrellaRoot,
cleanSlug,
extractTicketFromBranch,
computeFeatureId,
resolveFeatureId,
resolveFeatureRoot,
getGlobalPath,
getArchivePath
};
Loading