Files
msd-core/sdk/src/query/helpers.ts
Tom Boucher 26dcdb1ad0 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>
2026-05-09 14:21:42 -04:00

647 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared query helpers — cross-cutting utility functions used across query modules.
*
* Ported from get-shit-done/bin/lib/core.cjs and state.cjs.
* Provides phase name normalization, path handling, regex escaping,
* and STATE.md field extraction.
*
* @example
* ```typescript
* import { normalizePhaseName, planningPaths } from './helpers.js';
*
* normalizePhaseName('9'); // '09'
* normalizePhaseName('CK-01'); // '01'
*
* const paths = planningPaths('/project');
* // { planning: '/project/.planning', state: '/project/.planning/STATE.md', ... }
* ```
*/
import { join, dirname, relative, resolve, isAbsolute, normalize, parse as parsePath, sep as pathSep } from 'node:path';
import { realpath } from 'node:fs/promises';
import { existsSync, statSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.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';
export { stateExtractField } from './state-document.js';
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
// ─── Runtime-aware agents directory resolution ─────────────────────────────
function expandTilde(p: string): string {
return p.startsWith('~/') || p === '~' ? join(homedir(), p.slice(1)) : p;
}
/**
* Resolve the per-runtime config directory, mirroring
* `bin/install.js:getGlobalDir()`. Agents live at `<configDir>/agents`.
*/
export function getRuntimeConfigDir(runtime: Runtime): string {
switch (runtime) {
case 'claude':
return process.env.CLAUDE_CONFIG_DIR
? expandTilde(process.env.CLAUDE_CONFIG_DIR)
: join(homedir(), '.claude');
case 'opencode':
if (process.env.OPENCODE_CONFIG_DIR) return expandTilde(process.env.OPENCODE_CONFIG_DIR);
if (process.env.OPENCODE_CONFIG) return dirname(expandTilde(process.env.OPENCODE_CONFIG));
if (process.env.XDG_CONFIG_HOME) return join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
return join(homedir(), '.config', 'opencode');
case 'kilo':
if (process.env.KILO_CONFIG_DIR) return expandTilde(process.env.KILO_CONFIG_DIR);
if (process.env.KILO_CONFIG) return dirname(expandTilde(process.env.KILO_CONFIG));
if (process.env.XDG_CONFIG_HOME) return join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
return join(homedir(), '.config', 'kilo');
case 'gemini':
return process.env.GEMINI_CONFIG_DIR ? expandTilde(process.env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini');
case 'codex':
return process.env.CODEX_HOME ? expandTilde(process.env.CODEX_HOME) : join(homedir(), '.codex');
case 'copilot':
return process.env.COPILOT_CONFIG_DIR ? expandTilde(process.env.COPILOT_CONFIG_DIR) : join(homedir(), '.copilot');
case 'antigravity':
return process.env.ANTIGRAVITY_CONFIG_DIR ? expandTilde(process.env.ANTIGRAVITY_CONFIG_DIR) : join(homedir(), '.gemini', 'antigravity');
case 'cursor':
return process.env.CURSOR_CONFIG_DIR ? expandTilde(process.env.CURSOR_CONFIG_DIR) : join(homedir(), '.cursor');
case 'windsurf':
return process.env.WINDSURF_CONFIG_DIR ? expandTilde(process.env.WINDSURF_CONFIG_DIR) : join(homedir(), '.codeium', 'windsurf');
case 'augment':
return process.env.AUGMENT_CONFIG_DIR ? expandTilde(process.env.AUGMENT_CONFIG_DIR) : join(homedir(), '.augment');
case 'trae':
return process.env.TRAE_CONFIG_DIR ? expandTilde(process.env.TRAE_CONFIG_DIR) : join(homedir(), '.trae');
case 'qwen':
return process.env.QWEN_CONFIG_DIR ? expandTilde(process.env.QWEN_CONFIG_DIR) : join(homedir(), '.qwen');
case 'codebuddy':
return process.env.CODEBUDDY_CONFIG_DIR ? expandTilde(process.env.CODEBUDDY_CONFIG_DIR) : join(homedir(), '.codebuddy');
case 'cline':
return process.env.CLINE_CONFIG_DIR ? expandTilde(process.env.CLINE_CONFIG_DIR) : join(homedir(), '.cline');
case 'hermes':
return process.env.HERMES_HOME ? expandTilde(process.env.HERMES_HOME) : join(homedir(), '.hermes');
default:
throw new Error(`Unknown runtime: ${String(runtime)}`);
}
}
/**
* Detect the invoking runtime using issue #2402 precedence:
* 1. `GSD_RUNTIME` env var
* 2. `config.runtime` field (from `.planning/config.json` when loaded)
* 3. Fallback to `'claude'`
*
* Unknown values fall through to the next tier rather than throwing, so
* stale env values don't hard-block workflows.
*/
export function detectRuntime(config?: { runtime?: unknown }): Runtime {
const envValue = process.env.GSD_RUNTIME;
if (envValue && (SUPPORTED_RUNTIMES as readonly string[]).includes(envValue)) {
return envValue as Runtime;
}
const configValue = config?.runtime;
if (typeof configValue === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(configValue)) {
return configValue as Runtime;
}
return 'claude';
}
/**
* Resolve the GSD agents directory for a given runtime.
*
* Precedence:
* 1. `GSD_AGENTS_DIR` — explicit SDK override (wins over runtime selection)
* 2. `<getRuntimeConfigDir(runtime)>/agents` — installer-parity default
*
* Defaults to Claude when no runtime is passed, matching prior behavior
* (see `init-runner.ts`, which is Claude-only by design).
*/
export function resolveAgentsDir(runtime: Runtime = 'claude'): string {
if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR;
return join(getRuntimeConfigDir(runtime), 'agents');
}
/**
* Resolve the runtime-global skills base directory.
*
* Most runtimes store global skills under `<configDir>/skills`.
* `cline` is rules-based and has no global skills directory.
*/
export function resolveGlobalSkillsBase(runtime: Runtime): string | null {
if (runtime === 'cline') return null;
return join(getRuntimeConfigDir(runtime), 'skills');
}
/**
* Render a human-readable runtime-global skills base path.
* Uses `~` when the path lives under the current home dir.
* Returns a displayable string for unsupported runtimes (never null).
*/
export function renderGlobalSkillsBaseDisplayPath(runtime: Runtime): string {
const base = resolveGlobalSkillsBase(runtime);
if (!base) return `(${runtime} does not use a skills directory)`;
const home = homedir();
const homeWithSep = home.endsWith(pathSep) ? home : `${home}${pathSep}`;
return (base === home || base.startsWith(homeWithSep)) ? `~${base.slice(home.length)}` : base;
}
/** Resolve one runtime-global skill directory, or `null` when unsupported. */
export function resolveGlobalSkillDir(runtime: Runtime, skillName: string): string | null {
const base = resolveGlobalSkillsBase(runtime);
if (base === null) return null;
const candidate = resolve(base, skillName);
const rel = relative(base, candidate);
if (!skillName || rel.startsWith('..') || isAbsolute(rel)) return null;
return candidate;
}
/** Resolve the canonical SKILL.md path for one runtime-global skill. */
export function resolveGlobalSkillMarkdownPath(runtime: Runtime, skillName: string): string | null {
const dir = resolveGlobalSkillDir(runtime, skillName);
if (dir === null) return null;
return join(dir, 'SKILL.md');
}
/**
* Render a human-readable global skill path for warnings.
* Uses `~` when the path lives under the current home dir.
*/
export function renderGlobalSkillDisplayPath(runtime: Runtime, skillName: string): string {
const dir = resolveGlobalSkillDir(runtime, skillName);
if (!dir) return `(${runtime} does not use a skills directory)`;
const home = homedir();
const homeWithSep = home.endsWith(pathSep) ? home : `${home}${pathSep}`;
return (dir === home || dir.startsWith(homeWithSep)) ? `~${dir.slice(home.length)}` : dir;
}
// ─── Types ──────────────────────────────────────────────────────────────────
/** Paths to common .planning files. */
export type { PlanningPaths } from './workspace.js';
// ─── escapeRegex ────────────────────────────────────────────────────────────
/**
* Escape regex special characters in a string.
*
* @param value - String to escape
* @returns String with regex special characters escaped
*/
export function escapeRegex(value: string): string {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ─── normalizePhaseName ─────────────────────────────────────────────────────
/**
* Normalize a phase identifier to a canonical form.
*
* Strips optional project code prefix (e.g., 'CK-01' -> '01'),
* pads numeric part to 2 digits, preserves letter suffix and decimal parts.
*
* @param phase - Phase identifier string
* @returns Normalized phase name
*/
export function normalizePhaseName(phase: string): string {
const str = String(phase);
// Strip optional project_code prefix (e.g., 'CK-01' -> '01')
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
// Standard numeric phases: 1, 01, 12A, 12.1
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (match) {
const padded = match[1].padStart(2, '0');
const letter = match[2] ? match[2].toUpperCase() : '';
const decimal = match[3] || '';
return padded + letter + decimal;
}
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
return str;
}
// ─── comparePhaseNum ────────────────────────────────────────────────────────
/**
* Compare two phase directory names for sorting.
*
* Handles numeric, letter-suffixed, and decimal phases.
* Falls back to string comparison for custom IDs.
*
* @param a - First phase directory name
* @param b - Second phase directory name
* @returns Negative if a < b, positive if a > b, 0 if equal
*/
export function comparePhaseNum(a: string, b: string): number {
// Strip optional project_code prefix before comparing
const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
// If either is non-numeric (custom ID), fall back to string comparison
if (!pa || !pb) return String(a).localeCompare(String(b));
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
if (intDiff !== 0) return intDiff;
// No letter sorts before letter: 12 < 12A < 12B
const la = (pa[2] || '').toUpperCase();
const lb = (pb[2] || '').toUpperCase();
if (la !== lb) {
if (!la) return -1;
if (!lb) return 1;
return la < lb ? -1 : 1;
}
// Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const maxLen = Math.max(aDecParts.length, bDecParts.length);
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
for (let i = 0; i < maxLen; i++) {
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
if (av !== bv) return av - bv;
}
return 0;
}
// ─── extractPhaseToken ──────────────────────────────────────────────────────
/**
* Extract the phase token from a directory name.
*
* Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
*
* @param dirName - Directory name to extract token from
* @returns The token portion (e.g. '01', '1009A', '999.6', 'PROJ-42')
*/
export function extractPhaseToken(dirName: string): string {
// Try project-code-prefixed numeric: CK-01-name -> CK-01
const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
if (codePrefixed) return codePrefixed[1];
// Try plain numeric: 01-name, 1009A-name, 999.6-name
const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
if (numeric) return numeric[1];
// Custom IDs: PROJ-42-name -> everything before the last segment that looks like a name
const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
if (custom) return custom[1];
return dirName;
}
// ─── phaseTokenMatches ──────────────────────────────────────────────────────
/**
* Check if a directory name's phase token matches the normalized phase exactly.
*
* Case-insensitive comparison for the token portion.
*
* @param dirName - Directory name to check
* @param normalized - Normalized phase name to match against
* @returns True if the directory matches the phase
*/
export function phaseTokenMatches(dirName: string, normalized: string): boolean {
const token = extractPhaseToken(dirName);
if (token.toUpperCase() === normalized.toUpperCase()) return true;
// Strip optional project_code prefix from dir and retry
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
if (stripped !== dirName) {
const strippedToken = extractPhaseToken(stripped);
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
}
return false;
}
// ─── toPosixPath ────────────────────────────────────────────────────────────
/**
* Convert a path to POSIX format (forward slashes).
*
* @param p - Path to convert
* @returns Path with all separators as forward slashes
*/
export function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
// ─── normalizeMd ───────────────────────────────────────────────────────────
/**
* Normalize markdown content for consistent formatting.
*
* Port of `normalizeMd` from core.cjs lines 434-529.
* Applies: CRLF normalization, blank lines around headings/fences/lists,
* blank line collapsing (3+ to 2), terminal newline.
*
* @param content - Markdown content to normalize
* @returns Normalized markdown string
*/
export function normalizeMd(content: string): string {
if (!content || typeof content !== 'string') return content;
// Normalize line endings to LF
let text = content.replace(/\r\n/g, '\n');
const lines = text.split('\n');
const result: string[] = [];
// Pre-compute fence state in a single O(n) pass
const fenceRegex = /^```/;
const insideFence = new Array<boolean>(lines.length);
let fenceOpen = false;
for (let i = 0; i < lines.length; i++) {
if (fenceRegex.test(lines[i].trimEnd())) {
if (fenceOpen) {
insideFence[i] = false;
fenceOpen = false;
} else {
insideFence[i] = false;
fenceOpen = true;
}
} else {
insideFence[i] = fenceOpen;
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const prev = i > 0 ? lines[i - 1] : '';
const prevTrimmed = prev.trimEnd();
const trimmed = line.trimEnd();
const isFenceLine = fenceRegex.test(trimmed);
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
result.push('');
}
// MD031: Blank line before fenced code blocks (opening fences only)
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
if (i === 0 || !insideFence[i - 1]) {
result.push('');
}
}
// MD032: Blank line before lists
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
prevTrimmed !== '---') {
result.push('');
}
result.push(line);
// MD022: Blank line after headings
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') {
result.push('');
}
}
// MD031: Blank line after closing fenced code blocks
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') {
result.push('');
}
}
// MD032: Blank line after last list item in a block
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '' &&
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
!/^\s/.test(next)) {
result.push('');
}
}
}
text = result.join('\n');
// MD012: Collapse 3+ consecutive blank lines to 2
text = text.replace(/\n{3,}/g, '\n\n');
// MD047: Ensure file ends with exactly one newline
text = text.replace(/\n*$/, '\n');
return text;
}
// ─── planningPaths ──────────────────────────────────────────────────────────
/**
* Get common .planning file paths for a project directory.
*
* When `workstream` is provided, all paths are rooted under
* `.planning/workstreams/<workstream>` instead of `.planning`.
* All paths returned in POSIX format.
*
* @param projectDir - Root project directory
* @param workstream - Optional workstream name
* @returns Object with paths to common .planning files
*/
export function planningPaths(projectDir: string, workstream?: string): PlanningPaths {
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')),
roadmap: toPosixPath(join(base, 'ROADMAP.md')),
project: toPosixPath(join(base, 'PROJECT.md')),
config: toPosixPath(join(base, 'config.json')),
phases: toPosixPath(join(base, 'phases')),
requirements: toPosixPath(join(base, 'REQUIREMENTS.md')),
};
}
// ─── findProjectRoot (multi-repo .planning resolution) ─────────────────────
/**
* Maximum number of parent directories to walk when searching for a
* multi-repo `.planning/` root. Bounded to avoid scanning to the filesystem
* root in pathological cases.
*/
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
/**
* Walk up from `startDir` to find the project root that owns `.planning/`.
*
* Ported from `get-shit-done/bin/lib/core.cjs:findProjectRoot` so that
* `gsd-sdk query` resolves the same parent `.planning/` root as the legacy
* `gsd-tools.cjs` CLI when invoked inside a `sub_repos`-listed child repo.
*
* Detection strategy (checked in order for each ancestor, up to
* `FIND_PROJECT_ROOT_MAX_DEPTH` levels):
* 1. `startDir` itself has `.planning/` — return it unchanged (#1362).
* 2. Parent has `.planning/config.json` with `sub_repos` listing the
* immediate child segment of the starting directory.
* 3. Parent has `.planning/config.json` with `multiRepo: true` (legacy).
* 4. Parent has `.planning/` AND an ancestor of `startDir` (up to the
* candidate parent) contains `.git` — heuristic fallback.
*
* Returns `startDir` unchanged when no ancestor `.planning/` is found
* (first-run or single-repo projects). Never walks above the user's home
* directory.
*
* All filesystem errors are swallowed — a missing or unparseable
* `config.json` falls back to the `.git` heuristic, and unreadable
* directories terminate the walk at that level.
*/
export function findProjectRoot(startDir: string): string {
let resolvedStart: string;
try {
resolvedStart = resolve(startDir);
} catch {
return startDir;
}
const fsRoot = parsePath(resolvedStart).root;
const home = homedir();
// If startDir already contains .planning/, it IS the project root.
try {
const ownPlanning = join(resolvedStart, '.planning');
if (existsSync(ownPlanning) && statSync(ownPlanning).isDirectory()) {
return startDir;
}
} catch {
// fall through
}
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
function isInsideGitRepo(candidateParent: string): boolean {
let d = resolvedStart;
while (d !== fsRoot) {
try {
if (existsSync(join(d, '.git'))) return true;
} catch {
// ignore
}
if (d === candidateParent) break;
const next = dirname(d);
if (next === d) break;
d = next;
}
return false;
}
let dir = resolvedStart;
let depth = 0;
while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
const parent = dirname(dir);
if (parent === dir) break;
if (parent === home) break;
const parentPlanning = join(parent, '.planning');
let parentPlanningIsDir = false;
try {
parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory();
} catch {
parentPlanningIsDir = false;
}
if (parentPlanningIsDir) {
const configPath = join(parentPlanning, 'config.json');
let matched = false;
try {
const raw = readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw) as {
sub_repos?: unknown;
planning?: { sub_repos?: unknown };
multiRepo?: unknown;
};
const subReposValue =
(config.sub_repos as unknown) ?? (config.planning && config.planning.sub_repos);
const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : [];
if (subRepos.length > 0) {
const relPath = relative(parent, resolvedStart);
const topSegment = relPath.split(pathSep)[0];
if (subRepos.includes(topSegment)) {
return parent;
}
}
if (config.multiRepo === true && isInsideGitRepo(parent)) {
matched = true;
}
} catch {
// config.json missing or unparseable — fall through to .git heuristic.
}
if (matched) return parent;
// Heuristic: parent has .planning/ and we're inside a git repo.
if (isInsideGitRepo(parent)) {
return parent;
}
}
dir = parent;
depth += 1;
}
return startDir;
}
// ─── resolvePathUnderProject ───────────────────────────────────────────────
/**
* Resolve a user-supplied path against the project and ensure it cannot escape
* the real project root (prefix checks are insufficient; symlinks are handled
* via realpath).
*
* @param projectDir - Project root directory
* @param userPath - Relative or absolute path from user input
* @returns Canonical resolved path within the project
*/
export async function resolvePathUnderProject(projectDir: string, userPath: string): Promise<string> {
const projectReal = await realpath(projectDir);
const candidate = isAbsolute(userPath) ? normalize(userPath) : resolve(projectReal, userPath);
let realCandidate: string;
try {
realCandidate = await realpath(candidate);
} catch {
realCandidate = candidate;
}
const rel = relative(projectReal, realCandidate);
if (rel.startsWith('..') || (isAbsolute(rel) && rel.length > 0)) {
throw new GSDError('path escapes project directory', ErrorClassification.Validation);
}
return realCandidate;
}
// ─── sanitizeForDisplay (security.cjs) ───────────────────────────────────────
/** Port of `sanitizeForPrompt` from `security.cjs`. */
export function sanitizeForPrompt(text: string): string {
let sanitized = text;
sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/g, '');
sanitized = sanitized.replace(
/<(\/?)(?:system|assistant|human)>/gi,
(_, slash: string) => `<${slash || ''}system-text>`,
);
sanitized = sanitized.replace(/\[(SYSTEM|INST)\]/gi, '[$1-TEXT]');
sanitized = sanitized.replace(/<<\s*SYS\s*>>/gi, '«SYS-TEXT»');
return sanitized;
}
/** Port of `sanitizeForDisplay` from `security.cjs` (matches CLI JSON). */
export function sanitizeForDisplay(text: string): string {
let sanitized = sanitizeForPrompt(text);
const protocolLeakPatterns = [
/^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
/^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
];
sanitized = sanitized
.split('\n')
.filter(line => !protocolLeakPatterns.some(pattern => pattern.test(line)))
.join('\n');
return sanitized;
}