fix(workstream): normalize migration workstream names (#3269)

* fix(workstream): normalize migrate-name to valid slug

* docs(context): record workstream migrate-name slug invariant

* fix(catalog-cjs): balanced fallback for unknown profile (CR finding A)

profiles[profile] could return undefined for any profile key absent from
the catalog entry, causing downstream callers like formatAgentToModelMapAsTable
to crash on .length. Add ?? profiles.balanced fallback to match the SDK adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): anchor path resolution on import.meta.url not cwd (CR finding B)

resolve(process.cwd(), '..') breaks when Vitest is invoked from the repo root
because cwd is already the repo root and '..' goes one level above. Replace
with a file-relative path using fileURLToPath(new URL('../../../', import.meta.url))
anchored at the test file's location (sdk/src/query/).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: derive Group B runtime list from catalog (CR finding C)

Hardcoded ['kilo', 'cline', ...] throws TypeError if a runtime name is
removed from the catalog. Derive group B dynamically via
Object.keys(catalog.runtimeTierDefaults).filter(r => !r.opus) so the
test never goes stale and auto-covers future Group B additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(workflow): add hermes to Step B runtime options (CR finding D)

hermes appears in the Group A built-in defaults table but was missing from
the AskUserQuestion options in Step B, forcing users to manually type it via
'Other (Group B or custom)'. Add explicit hermes entry for UI consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(config): refresh dynamic_routing tier table; fix stale L671 (findings E+F)

Finding E: tier table was missing 6 heavy-tier agents and 15 standard/light
agents added by this PR. Updated all three rows to match catalog routingTier
assignments (33 agents total).

Finding F: removed stale '18 of 31' claim and agent enumeration; replaced
with accurate note that all 33 agents have explicit catalog entries. Updated
authoritative source pointers to model-catalog.cjs / model-catalog.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(core): add profile-fallback unit tests for quality and budget (CR nitpick G)

The PR introduced quality→opus and budget→haiku unknown-agent fallbacks but
only balanced→sonnet and inherit→inherit were tested. Add two tests covering
the remaining two branches to complete coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* adr: define planning workspace and worktree seam

* refactor(worktree): extract worktree safety policy module

* refactor(workstream): extract active workstream pointer store seam

* test(worktree): cover policy branch paths and persist seam guardrails

* refactor(worktree): centralize health inventory seam for W017

* fix(workspace): align SDK project path policy with CJS planningDir

* refactor(query): unify SDK planning path projection seam

* refactor(init): route workspace projection through planningPaths seam

* docs(adr): add SDK architecture and planning path ADRs

* refactor(worktree): deepen name, pointer, inventory, and config seams

* docs(config): harmonize claude-opus-4-6 to 4-7 in resolve_model_ids example (CR finding 2)

* fix(sdk): return undefined for model_profile='inherit' sentinel (CR finding 3)

* docs(adr): renumber conflicting 0003-sdk-package-seam-module to 0007, update seam-map reference (CR finding 4)

* fix(workstream): align CJS and SDK name validation to accept dots, guard path traversal via includes('..') (CR finding 5)

* fix(sdk): guard writeActiveWorkstream against non-existent workstream directory, k014/k031 parity (CR finding 6)

* chore(changeset): add #3269 changeset (CR finding 1 — proper changeset for this PR)

* docs(inventory): register 3 new CLI modules in INVENTORY.md/MANIFEST (active-workstream-store, workstream-name-policy, worktree-safety)

* fix(sdk): use relPlanningPath(workstream) in planningPaths, fix setActiveWorkstream/getActiveWorkstream name errors in workstream.ts

* fix(sdk): validate GSD_WORKSTREAM in planningPaths before use (#3269 regression)

planningPaths() called resolveWorkspaceContext() which returned GSD_WORKSTREAM
raw (no validation). An invalid value like '../evil' was used as effectiveWorkstream,
constructing a bad path; roadmapAnalyze() caught the ENOENT and returned a
no-phase_count error object instead of the root ROADMAP result.

Fix: validate envCtx.workstream with validateWorkstreamName() in planningPaths()
before accepting it as effectiveWorkstream. Invalid env → null → root .planning/
fallback, preserving the bug-2791 contract: invalid GSD_WORKSTREAM is silently
ignored and falls back to the root context (phase_count: 0 for empty root ROADMAP).

The bug-2791 regression test now passes. No other call sites read GSD_WORKSTREAM
without validation: query-runtime-context.ts already validates; cli.ts already
validates; context-engine.ts takes a caller-validated workstream parameter.

Closes #3268 (regression introduced by #3269 workstream-name-policy work).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 00:15:04 -04:00
committed by GitHub
parent 65abc4fc90
commit 8bc255c266
37 changed files with 1267 additions and 263 deletions

View File

@@ -0,0 +1,50 @@
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { validateWorkstreamName } from '../workstream-utils.js';
function pointerPath(projectDir: string): string {
return join(projectDir, '.planning', 'active-workstream');
}
function workstreamDir(projectDir: string, name: string): string {
return join(projectDir, '.planning', 'workstreams', name);
}
/**
* Read active workstream pointer from `.planning/active-workstream`.
* Invalid or stale pointers are self-healed by clearing the file.
*/
export function readActiveWorkstream(projectDir: string): string | null {
const filePath = pointerPath(projectDir);
try {
const name = readFileSync(filePath, 'utf-8').trim();
if (!name || !validateWorkstreamName(name)) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
if (!existsSync(workstreamDir(projectDir, name))) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
return name;
} catch {
return null;
}
}
export function writeActiveWorkstream(projectDir: string, name: string | null): void {
const filePath = pointerPath(projectDir);
if (!name) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return;
}
if (!validateWorkstreamName(name)) {
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
}
const wsDir = workstreamDir(projectDir, name);
if (!existsSync(wsDir)) {
throw new Error(`Workstream directory does not exist: ${name}`);
}
writeFileSync(filePath, name + '\n', 'utf-8');
}

View File

@@ -5,6 +5,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm, readdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js';
@@ -217,7 +218,8 @@ describe('resolveModel', () => {
describe('MODEL_PROFILES', () => {
it('contains every shipped gsd agent file on disk (#3229)', async () => {
const { MODEL_PROFILES } = await import('./config-query.js');
const repoRoot = resolve(process.cwd(), '..');
// config-query.test.ts lives at sdk/src/query/ — three levels from repo root
const repoRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url)));
const agentFiles = (await readdir(join(repoRoot, 'agents')))
.filter((f) => /^gsd-.*\.md$/.test(f))
.map((f) => f.replace(/\.md$/, ''))

View File

@@ -193,6 +193,11 @@ describe('stateExtractField', () => {
// ─── planningPaths ──────────────────────────────────────────────────────────
describe('planningPaths', () => {
afterEach(() => {
delete process.env['GSD_WORKSTREAM'];
delete process.env['GSD_PROJECT'];
});
it('returns all expected keys', () => {
const paths = planningPaths('/proj');
expect(paths).toHaveProperty('planning');
@@ -209,6 +214,19 @@ describe('planningPaths', () => {
expect(paths.state).toContain('.planning/STATE.md');
expect(paths.config).toContain('.planning/config.json');
});
it('uses GSD_PROJECT env when no explicit workstream is provided', () => {
process.env['GSD_PROJECT'] = 'proj-scope';
const paths = planningPaths('/proj');
expect(paths.planning).toContain('/proj/.planning/proj-scope');
});
it('explicit workstream overrides GSD_PROJECT env', () => {
process.env['GSD_PROJECT'] = 'proj-scope';
const paths = planningPaths('/proj', 'ws-a');
expect(paths.planning).toContain('/proj/.planning/workstreams/ws-a');
expect(paths.planning).not.toContain('proj-scope');
});
});
// ─── normalizeMd ───────────────────────────────────────────────────────────

View File

@@ -22,9 +22,10 @@ import { realpath } from 'node:fs/promises';
import { existsSync, statSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { relPlanningPath } from '../workstream-utils.js';
export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js';
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
// ─── Runtime-aware agents directory resolution ─────────────────────────────
@@ -173,15 +174,7 @@ export function renderGlobalSkillDisplayPath(runtime: Runtime, skillName: string
// ─── Types ──────────────────────────────────────────────────────────────────
/** Paths to common .planning files. */
export interface PlanningPaths {
planning: string;
state: string;
roadmap: string;
project: string;
config: string;
phases: string;
requirements: string;
}
export type { PlanningPaths } from './workspace.js';
// ─── escapeRegex ────────────────────────────────────────────────────────────
@@ -462,11 +455,23 @@ export function normalizeMd(content: string): string {
* All paths returned in POSIX format.
*
* @param projectDir - Root project directory
* @param workstream - Optional workstream name (see relPlanningPath)
* @param workstream - Optional workstream name
* @returns Object with paths to common .planning files
*/
export function planningPaths(projectDir: string, workstream?: string): PlanningPaths {
const base = join(projectDir, relPlanningPath(workstream));
const envCtx = resolveWorkspaceContext();
// Validate env workstream before use: invalid GSD_WORKSTREAM falls back to
// root .planning/ (bug-2791 contract — invalid env must not crash or route
// to a bad path; silent fallback to root preserves pre-#3269 behaviour).
const validEnvWorkstream =
envCtx.workstream && validateWorkstreamName(envCtx.workstream) ? envCtx.workstream : null;
const effectiveWorkstream = workstream ?? validEnvWorkstream;
// Use relPlanningPath(workstream) to scope the base path per workstream policy.
const base = join(projectDir, relPlanningPath(effectiveWorkstream ?? undefined));
// For env-sourced project scoping (no explicit workstream), delegate to workspace.
if (!effectiveWorkstream && envCtx.project) {
return workspacePlanningPaths(projectDir, { workstream: null, project: envCtx.project });
}
return {
planning: toPosixPath(base),
state: toPosixPath(join(base, 'STATE.md')),

View File

@@ -29,7 +29,6 @@ import { maskIfSecret } from './secrets.js';
import { findPhase } from './phase.js';
import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js';
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
import { relPlanningPath } from '../workstream-utils.js';
import type { QueryHandler } from './utils.js';
// ─── Internal helpers ──────────────────────────────────────────────────────
@@ -279,7 +278,8 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea
}
const config = await loadConfig(projectDir);
const planningDir = join(projectDir, relPlanningPath(workstream));
const paths = planningPaths(projectDir, workstream);
const planningDir = paths.planning;
const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream);
const phase_req_ids = extractReqIds(roadmapPhase);
@@ -361,7 +361,8 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream)
}
const config = await loadConfig(projectDir);
const planningDir = join(projectDir, relPlanningPath(workstream));
const paths = planningPaths(projectDir, workstream);
const planningDir = paths.planning;
const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream);
const phase_req_ids = extractReqIds(roadmapPhase);
@@ -630,7 +631,8 @@ export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) =>
}
const config = await loadConfig(projectDir);
const planningDir = join(projectDir, relPlanningPath(workstream));
const paths = planningPaths(projectDir, workstream);
const planningDir = paths.planning;
// findPhase with archived override: if only match is archived, prefer ROADMAP
const phaseResult = await findPhase([phase], projectDir, workstream);
@@ -796,7 +798,8 @@ export const initTodos: QueryHandler = async (args, projectDir) => {
*/
export const initMilestoneOp: QueryHandler = async (_args, projectDir, workstream) => {
const config = await loadConfig(projectDir);
const planningDir = join(projectDir, relPlanningPath(workstream));
const paths = planningPaths(projectDir, workstream);
const planningDir = paths.planning;
const milestone = await getMilestoneInfo(projectDir, workstream);
const phasesDir = join(planningDir, 'phases');

View File

@@ -1,7 +1,6 @@
import { join } from 'node:path';
import { readFileSync, existsSync } from 'node:fs';
import { findProjectRoot } from './helpers.js';
import { validateWorkstreamName } from '../workstream-utils.js';
import { readActiveWorkstream } from './active-workstream-store.js';
export interface QueryRuntimeContextInput {
projectDir: string;
@@ -13,26 +12,6 @@ export interface QueryRuntimeContext {
ws?: string;
}
/**
* Read the active workstream from `.planning/active-workstream` file.
*
* Mirrors the logic in workstream.ts:getActiveWorkstream — returns null
* when the file is missing, empty, contains invalid characters, or names
* a workstream directory that doesn't exist on disk.
*/
function readActiveWorkstreamFile(projectDir: string): string | null {
const filePath = join(projectDir, '.planning', 'active-workstream');
try {
const name = readFileSync(filePath, 'utf-8').trim();
if (!name || !validateWorkstreamName(name)) return null;
const wsDir = join(projectDir, '.planning', 'workstreams', name);
if (!existsSync(wsDir)) return null;
return name;
} catch {
return null;
}
}
/**
* Resolve the runtime context for a query invocation.
*
@@ -57,7 +36,7 @@ export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): Que
return { projectDir, ws: envWs };
}
const fileWs = readActiveWorkstreamFile(projectDir);
const fileWs = readActiveWorkstream(projectDir);
return {
projectDir,
ws: fileWs ?? undefined,

View File

@@ -69,10 +69,11 @@ describe('workspacePlanningPaths', () => {
expect(paths.phases).toContain('workstreams/backend/phases');
});
it('scopes to .planning/projects/<project> when project set', () => {
it('scopes to .planning/<project> when project set (CJS parity)', () => {
const paths = workspacePlanningPaths(projectDir, { workstream: null, project: 'api-server' });
expect(paths.planning).toContain('projects/api-server');
expect(paths.state).toContain('projects/api-server/STATE.md');
expect(paths.planning).toContain('.planning/api-server');
expect(paths.planning).not.toContain('projects/');
expect(paths.state).toContain('.planning/api-server/STATE.md');
});
it('workstream takes precedence over project when both set', () => {

View File

@@ -21,8 +21,20 @@
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { toPosixPath } from './helpers.js';
import type { PlanningPaths } from './helpers.js';
export interface PlanningPaths {
planning: string;
state: string;
roadmap: string;
project: string;
config: string;
phases: string;
requirements: string;
}
function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -93,7 +105,7 @@ export function resolveWorkspaceContext(): WorkspaceContext {
* Return PlanningPaths scoped to the active workspace or project.
*
* When context has a workstream set: base = .planning/workstreams/<ws>/
* When context has a project set: base = .planning/projects/<project>/
* When context has a project set: base = .planning/<project>/
* When context is null or empty: base = .planning/ (default)
*
* Workspace and project names are validated before path construction.
@@ -114,7 +126,9 @@ export function workspacePlanningPaths(
base = join(projectDir, '.planning', 'workstreams', context.workstream);
} else if (context?.project != null) {
validateWorkspaceName(context.project, 'project');
base = join(projectDir, '.planning', 'projects', context.project);
// Match CJS planningDir() policy: project scopes under `.planning/<project>/`
// (not `.planning/projects/<project>/`).
base = join(projectDir, '.planning', context.project);
} else {
base = join(projectDir, '.planning');
}

View File

@@ -24,6 +24,8 @@ import { join, relative } from 'node:path';
import { toPosixPath, stateExtractField } 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 type { QueryHandler } from './utils.js';
// ─── Internal helpers ─────────────────────────────────────────────────────
@@ -58,37 +60,6 @@ function filterSummaryFiles(files: string[]): string[] {
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
}
function getActiveWorkstream(projectDir: string): string | null {
const filePath = join(planningRoot(projectDir), 'active-workstream');
try {
const name = readFileSync(filePath, 'utf-8').trim();
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
const wsDir = join(workstreamsDir(projectDir), name);
if (!existsSync(wsDir)) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
return name;
} catch {
return null;
}
}
function setActiveWorkstream(projectDir: string, name: string | null): void {
const filePath = join(planningRoot(projectDir), 'active-workstream');
if (!name) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
}
writeFileSync(filePath, name + '\n', 'utf-8');
}
// ─── Handlers ─────────────────────────────────────────────────────────────
/**
@@ -97,7 +68,7 @@ function setActiveWorkstream(projectDir: string, name: string | null): void {
* Port of `cmdWorkstreamGet` from `workstream.cjs` lines 367–371.
*/
export const workstreamGet: QueryHandler = async (_args, projectDir) => {
const active = getActiveWorkstream(projectDir);
const active = readActiveWorkstream(projectDir);
const wsRoot = workstreamsDir(projectDir);
return {
data: {
@@ -126,7 +97,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
return { data: { created: false, reason: 'invalid workstream name — path separators not allowed' } };
}
const slug = rawName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const slug = toWorkstreamSlug(rawName);
if (!slug) return { data: { created: false, reason: 'invalid workstream name — must contain at least one alphanumeric character' } };
const baseDir = planningRoot(projectDir);
@@ -174,7 +145,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
writeFileSync(statePath, stateContent, 'utf-8');
}
setActiveWorkstream(projectDir, slug);
writeActiveWorkstream(projectDir, slug);
const relPath = toPosixPath(relative(projectDir, wsDir));
return {
@@ -215,13 +186,13 @@ export const workstreamSet: QueryHandler = async (args, projectDir) => {
if (name !== '--clear') {
return { data: { set: false, reason: 'name required. Usage: workstream set <name> (or workstream set --clear to unset)' } };
}
const previous = getActiveWorkstream(projectDir);
setActiveWorkstream(projectDir, null);
const previous = readActiveWorkstream(projectDir);
writeActiveWorkstream(projectDir, null);
return { data: { active: null, cleared: true, previous: previous || null } };
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, and underscores only' } };
if (!validateWorkstreamName(name)) {
return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, underscores, or dots only' } };
}
const wsDir = join(workstreamsDir(projectDir), name);
@@ -229,7 +200,7 @@ export const workstreamSet: QueryHandler = async (args, projectDir) => {
return { data: { active: null, error: 'not_found', workstream: name } };
}
setActiveWorkstream(projectDir, name);
writeActiveWorkstream(projectDir, name);
syncRootStateMirror(projectDir, name);
return { data: { active: name, set: true, mirror_synced: existsSync(join(wsDir, 'STATE.md')) } };
};
@@ -316,8 +287,8 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
return { data: { completed: false, error: 'not_found', workstream: name } };
}
const active = getActiveWorkstream(projectDir);
if (active === name) setActiveWorkstream(projectDir, null);
const active = readActiveWorkstream(projectDir);
if (active === name) writeActiveWorkstream(projectDir, null);
const archiveDir = join(root, 'milestones');
const today = new Date().toISOString().split('T')[0];
@@ -341,7 +312,7 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
try { renameSync(join(archivePath, fname), join(wsDir, fname)); } catch { /* rollback */ }
}
try { rmdirSync(archivePath); } catch { /* cleanup */ }
if (active === name) setActiveWorkstream(projectDir, name);
if (active === name) writeActiveWorkstream(projectDir, name);
return { data: { completed: false, error: 'archive_failed', message: String(err), workstream: name } };
}
@@ -382,7 +353,7 @@ export const workstreamProgress: QueryHandler = async (_args, projectDir) => {
};
}
const active = getActiveWorkstream(projectDir);
const active = readActiveWorkstream(projectDir);
const entries = readdirSync(wsRoot, { withFileTypes: true });
const workstreams: Array<{
name: string;

View File

@@ -52,6 +52,7 @@ function resolveModel(options?: SessionOptions, config?: GSDConfig): string | un
if (config?.model_profile) {
const profile = String(config.model_profile).toLowerCase();
if (profile === 'inherit') return undefined;
const tier = profile === 'quality' ? 'opus'
: (profile === 'budget' || profile === 'speed') ? 'haiku'
: (profile === 'balanced' || profile === 'adaptive') ? 'sonnet'

View File

@@ -0,0 +1,24 @@
/**
* Workstream Name Policy Module
*
* Owns SDK-side workstream validation and slug normalization.
*/
/**
* Validate a workstream name.
* Allowed: alphanumeric, hyphens, underscores, dots.
* Disallowed: empty, spaces, slashes, special chars, path traversal.
*/
export function validateWorkstreamName(name: string): boolean {
if (!name || name.length === 0) return false;
if (name.includes('..')) return false;
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
export function toWorkstreamSlug(name: string): string {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

View File

@@ -6,19 +6,7 @@
*/
import { posix } from 'node:path';
/**
* Validate a workstream name.
* Allowed: alphanumeric, hyphens, underscores, dots.
* Disallowed: empty, spaces, slashes, special chars, path traversal.
*/
export function validateWorkstreamName(name: string): boolean {
if (!name || name.length === 0) return false;
// Only allow alphanumeric, hyphens, underscores, dots
// Must not be ".." or start with ".." (path traversal)
if (name === '..' || name.startsWith('../')) return false;
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
export { validateWorkstreamName, toWorkstreamSlug } from './workstream-name-policy.js';
/**
* Return the relative planning directory path.