Files
msd-core/sdk/src/query/validate.ts
Tom Boucher 5fdc950eb7 feat(#2792): namespace meta-skills + keyword-tag descriptions + context utilization guard (#2825)
* feat(#2792): namespace meta-skills retargeted at the post-#2790 surface

This branch is now based on #2790's HEAD (the consolidation PR) instead
of main, and every routing table targets the consolidated surface so a
user routed by a namespace meta-skill never lands at a deleted /
folded sub-skill.

Cross-PR inconsistencies the original PR #2825 carried (vs #2790):

  - ns-ideate routed to gsd-note / gsd-add-todo / gsd-add-backlog /
    gsd-plant-seed → all folded into gsd-capture by #2790. Now routes
    to gsd-capture (the parent picks the mode from the user's intent).
  - ns-context routed to gsd-scan and gsd-intel → folded into
    gsd-map-codebase --fast / --query by #2790. Now routes to those
    flag forms.
  - ns-manage routed all workspace intent to gsd-list-workspaces (a
    list-only entry) → CR also flagged the over-narrow target. #2790
    folds into gsd-workspace; routing now points there.
  - ns-workflow routed to gsd-research-phase → deleted outright by
    #2790. Removed.
  - ns-project routed to gsd-plan-milestone-gaps → deleted outright by
    #2790. Removed.
  - None of the namespaces previously surfaced #2790's new consolidated
    skills (gsd-capture, gsd-phase, gsd-config, gsd-workspace,
    gsd-progress). All five are now reachable through the routers.
  - extract_learnings → extract-learnings (canonicalized by #2858).

Defect fixes within the namespace skills:

  - Hyphen-form `name:` (gsd-workflow, …) per the canonical naming
    contract — the colon-form addressed CR's drift complaint.
  - `Skill` added to allowed-tools on every router. The body instructs
    "Invoke the matched skill directly using the Skill tool" — without
    Skill in the permission list the meta-skill cannot route at all.

New regression guard in tests/enh-2792-namespace-skills.test.cjs: every
gsd-* token in any namespace router's table column resolves to a
surviving commands/gsd/*.md file (or to a known consolidated parent for
flag-form targets like gsd-map-codebase --fast). This single test would
have caught every dead-end route the original PR shipped with.

Skill-count cap in tests/enh-2790-skill-consolidation.test.cjs now
filters out ns-*.md from its <= 63 cap. Namespace routers are
descriptor-only entries, not part of the consolidation surface that cap
is policing — they have their own contract in
tests/enh-2792-namespace-skills.test.cjs.

INVENTORY.md gains a "Namespace Meta-Skills" section with the 6 router
rows; INVENTORY-MANIFEST.json gains 6 entries; the headline count moves
59 → 65 to match.

Out of scope for this rebase: the gsd-health --context flag (PR #2825
advertised the contract but didn't implement it). That's a separate
feature concern and is left untouched here.

5908/5908 on `npm test`.

* feat(#2792): implement gsd-health --context utilization guard

The original PR #2825 advertised a `--context` flag on gsd-health with a
60%/70% utilization threshold table but never implemented the workflow
logic — CR caught it as a contract leak, the rebase deferred it. This
commit closes the gap with TDD red/green/refactor.

Math layer (pure):
  - get-shit-done/bin/lib/context-utilization.cjs
    classifyContextUtilization(tokensUsed, contextWindow) →
      { percent, state }
    State boundaries use the exact ratio:
      < 60% healthy / 60–70% warning / ≥ 70% critical (fracture point)
    Display percent rounded for humans. Throws TypeError on non-integer
    or out-of-range inputs.
  - STATES = Object.freeze({ HEALTHY, WARNING, CRITICAL }) exported
    so callers reference the names by symbol, not by literal string.

SDK CLI integration:
  - get-shit-done/bin/gsd-tools.cjs
    `validate context --tokens-used N --context-window M [--json]`
    routes to the classifier, owns the recommendation copy (the
    classifier intentionally does not — keeps the renderer free to
    evolve without touching the math layer or its tests), and uses
    core.output's rawValue path for the sync-flush guarantee.
  - sdk/src/query/validate.ts + sdk/src/query/index.ts
    TypeScript validateContext handler registered at 'validate.context'
    and 'validate context'. Mirrors the CJS classifier inline (15 lines
    of arithmetic; not worth a shared cross-language module).

User-facing wiring:
  - commands/gsd/health.md frontmatter advertises --context, body
    documents the three-state threshold table.
  - get-shit-done/workflows/health.md adds a `context_check` step
    that's reached only when --context is set. Step calls
    `gsd-sdk query validate.context` with self-reported tokensUsed and
    contextWindow, prints the SDK output verbatim, and ends. Includes
    a TEXT_MODE plain-text fallback for non-Claude runtimes per #2012.

Tests:
  - tests/context-utilization.test.cjs (17 tests) — pure-function
    contract: state thresholds at every boundary, percent rounding,
    input validation, return-shape (no recommendation field — that's
    the renderer's job).
  - tests/validate-context.test.cjs (9 tests) — SDK CLI plumbing:
    arg parsing errors, JSON vs human rendering, recommendation copy
    pinned per state.
  - tests/enh-2792-namespace-skills.test.cjs (4 new tests) — markdown
    contract: --context advertised in argument-hint, threshold table
    in command body, context_check step exists in workflow, step
    invokes gsd-sdk query validate.context with both flags.

Inventory bookkeeping:
  - docs/INVENTORY.md "CLI Modules" 31 → 32; new row for
    context-utilization.cjs.
  - docs/INVENTORY-MANIFEST.json mirror.

5939/5939 on `npm test`.
2026-04-30 01:04:41 -04:00

897 lines
35 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.
/**
* Validation query handlers — key-link verification and consistency checking.
*
* Ported from get-shit-done/bin/lib/verify.cjs.
* Provides key-link integration point verification and cross-file consistency
* detection as native TypeScript query handlers registered in the SDK query registry.
*
* @example
* ```typescript
* import { verifyKeyLinks, validateConsistency } from './validate.js';
*
* const result = await verifyKeyLinks(['path/to/plan.md'], '/project');
* // { data: { all_verified: true, verified: 1, total: 1, links: [...] } }
* ```
*/
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { MODEL_PROFILES } from './config-query.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js';
import { escapeRegex, normalizePhaseName, planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
/** Max length for key_links regex patterns (ReDoS mitigation). */
const MAX_KEY_LINK_PATTERN_LEN = 512;
/**
* Build a RegExp for must_haves key_links pattern matching.
* Long or nested-quantifier patterns fall back to a literal match via escapeRegex.
*/
export function regexForKeyLinkPattern(pattern: string): RegExp {
if (typeof pattern !== 'string' || pattern.length === 0) {
return /$^/;
}
if (pattern.length > MAX_KEY_LINK_PATTERN_LEN) {
return new RegExp(escapeRegex(pattern.slice(0, MAX_KEY_LINK_PATTERN_LEN)));
}
// Mitigate catastrophic backtracking on nested quantifier forms
if (/\([^)]*[\+\*][^)]*\)[\+\*]/.test(pattern)) {
return new RegExp(escapeRegex(pattern));
}
try {
return new RegExp(pattern);
} catch {
return new RegExp(escapeRegex(pattern));
}
}
// ─── verifyKeyLinks ───────────────────────────────────────────────────────
/**
* Verify key-link integration points from must_haves.key_links.
*
* Port of `cmdVerifyKeyLinks` from `verify.cjs` lines 338-396.
* Reads must_haves.key_links from plan frontmatter, checks source/target
* files for pattern matching or target reference presence.
*
* @param args - args[0]: plan file path (required)
* @param projectDir - Project root directory
* @returns QueryResult with { all_verified, verified, total, links }
* @throws GSDError with Validation classification if file path missing
*/
export const verifyKeyLinks: QueryHandler = async (args, projectDir) => {
const planFilePath = args[0];
if (!planFilePath) {
throw new GSDError('plan file path required', ErrorClassification.Validation);
}
// T-12-07: Null byte check on plan file path
if (planFilePath.includes('\0')) {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
}
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, planFilePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: planFilePath } };
}
throw err;
}
let content: string;
try {
content = await readFile(fullPath, 'utf-8');
} catch {
return { data: { error: 'File not found', path: planFilePath } };
}
const { items: keyLinks } = parseMustHavesBlock(content, 'key_links');
if (keyLinks.length === 0) {
return { data: { error: 'No must_haves.key_links found in frontmatter', path: planFilePath } };
}
const results: Array<{ from: string; to: string; via: string; verified: boolean; detail: string }> = [];
for (const link of keyLinks) {
if (typeof link === 'string') continue;
const linkObj = link as Record<string, unknown>;
const check = {
from: (linkObj.from as string) || '',
to: (linkObj.to as string) || '',
via: (linkObj.via as string) || '',
verified: false,
detail: '',
};
let sourceContent: string | null = null;
if (check.from) {
try {
const sourcePath = await resolvePathUnderProject(projectDir, check.from);
sourceContent = await readFile(sourcePath, 'utf-8');
} catch {
// Source file not found or path escapes project
}
}
if (!sourceContent) {
check.detail = 'Source file not found';
} else if (linkObj.pattern) {
try {
const regex = new RegExp(linkObj.pattern as string);
if (regex.test(sourceContent)) {
check.verified = true;
check.detail = 'Pattern found in source';
} else {
let targetContent: string | null = null;
if (check.to) {
try {
const targetPath = await resolvePathUnderProject(projectDir, check.to);
targetContent = await readFile(targetPath, 'utf-8');
} catch {
// Target file not found or path escapes project
}
}
if (targetContent && regex.test(targetContent)) {
check.verified = true;
check.detail = 'Pattern found in target';
} else {
check.detail = `Pattern "${linkObj.pattern}" not found in source or target`;
}
}
} catch {
check.detail = `Invalid regex pattern: ${linkObj.pattern}`;
}
} else {
// No pattern: check if target path is referenced in source content
if (sourceContent.includes(check.to)) {
check.verified = true;
check.detail = 'Target referenced in source';
} else {
check.detail = 'Target not referenced in source';
}
}
results.push(check);
}
const verified = results.filter(r => r.verified).length;
return {
data: {
all_verified: verified === results.length,
verified,
total: results.length,
links: results,
},
};
};
// ─── validateConsistency ─────────────────────────────────────────────────
/**
* Validate consistency between ROADMAP.md, disk phases, and plan frontmatter.
*
* Port of `cmdValidateConsistency` from `verify.cjs` lines 398-519.
* Checks ROADMAP/disk phase sync, sequential numbering, plan numbering gaps,
* summary/plan orphans, and frontmatter completeness.
*
* @param _args - No required args (operates on projectDir)
* @param projectDir - Project root directory
* @returns QueryResult with { passed, errors, warnings, warning_count }
*/
export const validateConsistency: QueryHandler = async (_args, projectDir, workstream) => {
const paths = planningPaths(projectDir, workstream);
const errors: string[] = [];
const warnings: string[] = [];
// Read ROADMAP.md
let roadmapContent: string;
try {
roadmapContent = await readFile(paths.roadmap, 'utf-8');
} catch {
return { data: { passed: false, errors: ['ROADMAP.md not found'], warnings: [], warning_count: 0 } };
}
// Strip shipped milestone <details> blocks
const activeContent = roadmapContent.replace(/<details>[\s\S]*?<\/details>/gi, '');
// Extract phase numbers from ROADMAP headings
const roadmapPhases = new Set<string>();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(activeContent)) !== null) {
roadmapPhases.add(m[1]);
}
// Get phases on disk
const diskPhases = new Set<string>();
let diskDirs: string[] = [];
try {
const entries = await readdir(paths.phases, { withFileTypes: true });
diskDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
for (const dir of diskDirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
} catch {
// phases directory doesn't exist
}
// Check: phases in ROADMAP but not on disk
for (const p of roadmapPhases) {
if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
}
}
// Check: phases on disk but not in ROADMAP
for (const p of diskPhases) {
const unpadded = String(parseInt(p, 10));
if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
}
}
// Check sequential phase numbering (skip in custom naming mode)
let config: Record<string, unknown> = {};
try {
const configContent = await readFile(paths.config, 'utf-8');
config = JSON.parse(configContent) as Record<string, unknown>;
} catch {
// config not found or invalid — proceed with defaults
}
if (config.phase_naming !== 'custom') {
const integerPhases = [...diskPhases]
.filter(p => !p.includes('.'))
.map(p => parseInt(p, 10))
.sort((a, b) => a - b);
for (let i = 1; i < integerPhases.length; i++) {
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} \u2192 ${integerPhases[i]}`);
}
}
}
// Check plan numbering and summaries within each phase
for (const dir of diskDirs) {
let phaseFiles: string[];
try {
phaseFiles = await readdir(join(paths.phases, dir));
} catch {
continue;
}
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
// Extract plan numbers and check for gaps
const planNums = plans.map(p => {
const pm = p.match(/-(\d{2})-PLAN\.md$/);
return pm ? parseInt(pm[1], 10) : null;
}).filter((n): n is number => n !== null);
for (let i = 1; i < planNums.length; i++) {
if (planNums[i] !== planNums[i - 1] + 1) {
warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`);
}
}
// Check: summaries without matching plans
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
for (const sid of summaryIds) {
if (!planIds.has(sid)) {
warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`);
}
}
}
// Check frontmatter completeness in plans
for (const dir of diskDirs) {
let phaseFiles: string[];
try {
phaseFiles = await readdir(join(paths.phases, dir));
} catch {
continue;
}
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
for (const plan of plans) {
try {
const content = await readFile(join(paths.phases, dir, plan), 'utf-8');
const fm = extractFrontmatter(content);
if (!fm.wave) {
warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
}
} catch {
// Cannot read plan file
}
}
}
const passed = errors.length === 0;
return {
data: {
passed,
errors,
warnings,
warning_count: warnings.length,
},
};
};
// ─── validateHealth ─────────────────────────────────────────────────────────
/**
* Health check with optional repair mode.
*
* Port of `cmdValidateHealth` from `verify.cjs` lines 522-921.
* Performs 10+ checks on .planning/ directory structure, config, state,
* and cross-file consistency. With `--repair` flag, can fix missing
* config.json, STATE.md, and nyquist key.
*
* @param args - Optional: '--repair' to perform repairs
* @param projectDir - Project root directory
* @returns QueryResult with { status, errors, warnings, info, repairable_count, repairs_performed? }
*/
export const validateHealth: QueryHandler = async (args, projectDir, workstream) => {
const doRepair = args.includes('--repair');
// T-12-09: Home directory guard
const resolved = resolve(projectDir);
if (resolved === homedir()) {
return {
data: {
status: 'error',
errors: [{
code: 'E010',
message: `CWD is home directory (${resolved}) — health check would read the wrong .planning/ directory. Run from your project root instead.`,
fix: 'cd into your project directory and retry',
}],
warnings: [],
info: [{ code: 'I010', message: `Resolved CWD: ${resolved}` }],
repairable_count: 0,
},
};
}
const paths = planningPaths(projectDir, workstream);
const planBase = paths.planning;
const projectPath = join(planBase, 'PROJECT.md');
const roadmapPath = paths.roadmap;
const statePath = paths.state;
const configPath = paths.config;
const phasesDir = paths.phases;
interface Issue {
code: string;
message: string;
fix: string;
repairable: boolean;
}
const errors: Issue[] = [];
const warnings: Issue[] = [];
const info: Issue[] = [];
const repairs: string[] = [];
const addIssue = (severity: 'error' | 'warning' | 'info', code: string, message: string, fix: string, repairable = false) => {
const issue: Issue = { code, message, fix, repairable };
if (severity === 'error') errors.push(issue);
else if (severity === 'warning') warnings.push(issue);
else info.push(issue);
};
// ─── Check 1: .planning/ exists ───────────────────────────────────────────
if (!existsSync(planBase)) {
addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd-new-project to initialize');
return {
data: {
status: 'broken',
errors,
warnings,
info,
repairable_count: 0,
},
};
}
// ─── Check 2: PROJECT.md exists and has required sections ─────────────────
if (!existsSync(projectPath)) {
addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd-new-project to create');
} else {
try {
const content = await readFile(projectPath, 'utf-8');
const requiredSections = ['## What This Is', '## Core Value', '## Requirements'];
for (const section of requiredSections) {
if (!content.includes(section)) {
addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually');
}
}
} catch { /* intentionally empty */ }
}
// ─── Check 3: ROADMAP.md exists ───────────────────────────────────────────
if (!existsSync(roadmapPath)) {
addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd-new-milestone to create roadmap');
}
// ─── Check 4: STATE.md exists and references valid phases ─────────────────
if (!existsSync(statePath)) {
addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd-health --repair to regenerate', true);
repairs.push('regenerateState');
} else {
try {
const stateContent = await readFile(statePath, 'utf-8');
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(m => m[1]);
// Bug #2633 — ROADMAP.md is the authority for which phases are valid.
// STATE.md may legitimately reference current-milestone future phases
// (not yet materialized on disk) and shipped-milestone history phases
// (archived / cleared off disk). Matching only against on-disk dirs
// produces false W002 warnings in both cases.
const validPhases = new Set<string>();
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
if (m) validPhases.add(m[1]);
}
}
} catch { /* intentionally empty */ }
// Union in every phase declared anywhere in ROADMAP.md — current milestone,
// shipped milestones (inside <details> / ✅ SHIPPED sections), and any
// preamble/Backlog. We deliberately do NOT filter by current milestone.
try {
const roadmapRaw = await readFile(roadmapPath, 'utf-8');
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
for (const m of all) validPhases.add(m[1]);
} catch { /* intentionally empty */ }
// Compare canonical full phase tokens. Also accept a leading-zero
// variant on the integer prefix only (e.g. "03" → "3", "03.1" → "3.1")
// so historic STATE.md formatting still validates. Suffix tokens like
// "3A" must match exactly — never collapsed to "3".
const normalizedValid = new Set<string>();
for (const p of validPhases) {
normalizedValid.add(p);
const dotIdx = p.indexOf('.');
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
if (/^\d+$/.test(head)) {
normalizedValid.add(head.padStart(2, '0') + tail);
}
}
for (const ref of phaseRefs) {
const dotIdx = ref.indexOf('.');
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
if (normalizedValid.size > 0) {
addIssue('warning', 'W002',
`STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`,
'Review STATE.md manually');
}
}
}
} catch { /* intentionally empty */ }
}
// ─── Check 5: config.json valid JSON + valid schema ───────────────────────
if (!existsSync(configPath)) {
addIssue('warning', 'W003', 'config.json not found', 'Run /gsd-health --repair to create with defaults', true);
repairs.push('createConfig');
} else {
try {
const raw = await readFile(configPath, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const validProfiles = ['quality', 'balanced', 'budget', 'inherit'];
if (parsed.model_profile && !validProfiles.includes(parsed.model_profile as string)) {
addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addIssue('error', 'E005', `config.json: JSON parse error - ${msg}`, 'Run /gsd-health --repair to reset to defaults', true);
repairs.push('resetConfig');
}
}
// ─── Check 5b: Nyquist validation key presence ──────────────────────────
if (existsSync(configPath)) {
try {
const configRaw = await readFile(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
const workflow = configParsed.workflow as Record<string, unknown> | undefined;
if (workflow && workflow.nyquist_validation === undefined) {
addIssue('warning', 'W008', 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', 'Run /gsd-health --repair to add key', true);
if (!repairs.includes('addNyquistKey')) repairs.push('addNyquistKey');
}
} catch { /* intentionally empty */ }
}
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) {
addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
}
}
} catch { /* intentionally empty */ }
// ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const phaseFiles = await readdir(join(phasesDir, e.name));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
for (const plan of plans) {
const planBase2 = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
if (!summaryBases.has(planBase2)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
}
}
} catch { /* intentionally empty */ }
// ─── Check 7b: Nyquist VALIDATION.md consistency ────────────────────────
try {
const phaseEntries = await readdir(phasesDir, { withFileTypes: true });
for (const e of phaseEntries) {
if (!e.isDirectory()) continue;
const phaseFiles = await readdir(join(phasesDir, e.name));
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md'));
const hasValidation = phaseFiles.some(f => f.endsWith('-VALIDATION.md'));
if (hasResearch && !hasValidation) {
const researchFile = phaseFiles.find(f => f.endsWith('-RESEARCH.md'));
if (researchFile) {
try {
const researchContent = await readFile(join(phasesDir, e.name, researchFile), 'utf-8');
if (researchContent.includes('## Validation Architecture')) {
addIssue('warning', 'W009', `Phase ${e.name}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, 'Re-run /gsd-plan-phase with --research to regenerate');
}
} catch { /* intentionally empty */ }
}
}
}
} catch { /* intentionally empty */ }
// ─── Check 8: ROADMAP/disk phase sync ─────────────────────────────────────
if (existsSync(roadmapPath)) {
try {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
const roadmapPhases = new Set<string>();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
}
const diskPhases = new Set<string>();
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
}
} catch { /* intentionally empty */ }
for (const p of roadmapPhases) {
const padded = String(parseInt(p, 10)).padStart(2, '0');
if (!diskPhases.has(p) && !diskPhases.has(padded)) {
addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap');
}
}
for (const p of diskPhases) {
const unpadded = String(parseInt(p, 10));
if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory');
}
}
} catch { /* intentionally empty */ }
}
// ─── Check 9: STATE.md / ROADMAP.md cross-validation ─────────────────────
if (existsSync(statePath) && existsSync(roadmapPath)) {
try {
const stateContent = await readFile(statePath, 'utf-8');
const roadmapContentFull = await readFile(roadmapPath, 'utf-8');
const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) ||
stateContent.match(/Current Phase:\s*(\S+)/i);
if (currentPhaseMatch) {
const statePhase = currentPhaseMatch[1].replace(/^0+/, '');
const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i');
if (phaseCheckboxRe.test(roadmapContentFull)) {
const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i);
const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : '';
if (statusVal !== 'complete' && statusVal !== 'done') {
addIssue('warning', 'W011',
`STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`,
'Run /gsd-progress to re-derive current position, or manually update STATE.md');
}
}
}
} catch { /* intentionally empty */ }
}
// ─── Check 10: Config field validation ────────────────────────────────────
if (existsSync(configPath)) {
try {
const configRaw = await readFile(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
const validStrategies = ['none', 'phase', 'milestone'];
const bs = configParsed.branching_strategy as string | undefined;
if (bs && !validStrategies.includes(bs)) {
addIssue('warning', 'W012',
`config.json: invalid branching_strategy "${bs}"`,
`Valid values: ${validStrategies.join(', ')}`);
}
if (configParsed.context_window !== undefined) {
const cw = configParsed.context_window;
if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) {
addIssue('warning', 'W013',
`config.json: context_window should be a positive integer, got "${cw}"`,
'Set to 200000 (default) or 1000000 (for 1M models)');
}
}
const pbt = configParsed.phase_branch_template as string | undefined;
if (pbt && !pbt.includes('{phase}')) {
addIssue('warning', 'W014',
'config.json: phase_branch_template missing {phase} placeholder',
'Template must include {phase} for phase number substitution');
}
const mbt = configParsed.milestone_branch_template as string | undefined;
if (mbt && !mbt.includes('{milestone}')) {
addIssue('warning', 'W015',
'config.json: milestone_branch_template missing {milestone} placeholder',
'Template must include {milestone} for version substitution');
}
} catch { /* parse error already caught in Check 5 */ }
}
// ─── Perform repairs if requested ─────────────────────────────────────────
const repairActions: Array<{ action: string; success: boolean; path?: string; error?: string }> = [];
if (doRepair && repairs.length > 0) {
for (const repair of repairs) {
try {
switch (repair) {
case 'createConfig':
case 'resetConfig': {
// T-12-11: Write known-safe defaults only
const defaults = {
model_profile: 'balanced',
commit_docs: false,
search_gitignored: false,
branching_strategy: 'none',
phase_branch_template: 'feat/phase-{phase}',
milestone_branch_template: 'feat/{milestone}',
quick_branch_template: 'fix/{slug}',
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: true,
},
parallelization: 1,
brave_search: false,
};
await writeFile(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
repairActions.push({ action: repair, success: true, path: 'config.json' });
break;
}
case 'regenerateState': {
// Generate minimal STATE.md from ROADMAP.md structure
let milestoneName = 'Unknown';
let milestoneVersion = 'v1.0';
try {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
const milestoneMatch = roadmapContent.match(/##\s+(?:Current\s+)?Milestone[:\s]+(\S+)\s*[-—]\s*(.+)/i);
if (milestoneMatch) {
milestoneVersion = milestoneMatch[1];
milestoneName = milestoneMatch[2].trim();
}
} catch { /* intentionally empty */ }
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: .planning/PROJECT.md\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestoneVersion} ${milestoneName}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd-health --repair\n`;
await writeFile(statePath, stateContent, 'utf-8');
repairActions.push({ action: repair, success: true, path: 'STATE.md' });
break;
}
case 'addNyquistKey': {
if (existsSync(configPath)) {
try {
const configRaw = await readFile(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (!configParsed.workflow) configParsed.workflow = {};
const wf = configParsed.workflow as Record<string, unknown>;
if (wf.nyquist_validation === undefined) {
wf.nyquist_validation = true;
await writeFile(configPath, JSON.stringify(configParsed, null, 2), 'utf-8');
}
repairActions.push({ action: repair, success: true, path: 'config.json' });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
repairActions.push({ action: repair, success: false, error: msg });
}
}
break;
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
repairActions.push({ action: repair, success: false, error: msg });
}
}
}
// ─── Determine overall status ─────────────────────────────────────────────
let status: string;
if (errors.length > 0) {
status = 'broken';
} else if (warnings.length > 0) {
status = 'degraded';
} else {
status = 'healthy';
}
const repairableCount = errors.filter(e => e.repairable).length +
warnings.filter(w => w.repairable).length;
return {
data: {
status,
errors,
warnings,
info,
repairable_count: repairableCount,
repairs_performed: repairActions.length > 0 ? repairActions : undefined,
},
};
};
// ─── validateAgents ────────────────────────────────────────────────────────
/**
* Default agents directory — mirrors `getAgentsDir` in `get-shit-done/bin/lib/core.cjs`:
* `GSD_AGENTS_DIR`, else `../../../agents` relative to this module (`sdk/dist/query` → monorepo
* root), matching `core.cjs` (`get-shit-done/bin/lib` → same repo `agents/`).
*/
function getAgentsDirForValidateAgents(): string {
if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR;
const here = dirname(fileURLToPath(import.meta.url));
return resolve(here, '..', '..', '..', 'agents');
}
/**
* Validate GSD agent file installation under the managed agents directory.
*
* Port of `cmdValidateAgents` from `verify.cjs` lines 997–1009 (uses `checkAgentsInstalled` from core).
*/
export const validateAgents: QueryHandler = async (_args, _projectDir) => {
const agentsDir = getAgentsDirForValidateAgents();
const expected = Object.keys(MODEL_PROFILES);
const installed: string[] = [];
const missing: string[] = [];
if (!existsSync(agentsDir)) {
return {
data: {
agents_dir: agentsDir,
agents_found: false,
installed: [] as string[],
missing: expected,
expected,
},
};
}
for (const agent of expected) {
const agentFile = join(agentsDir, `${agent}.md`);
const agentFileCopilot = join(agentsDir, `${agent}.agent.md`);
if (existsSync(agentFile) || existsSync(agentFileCopilot)) {
installed.push(agent);
} else {
missing.push(agent);
}
}
const agentsInstalled = installed.length > 0 && missing.length === 0;
return {
data: {
agents_dir: agentsDir,
agents_found: agentsInstalled,
installed,
missing,
expected,
},
};
};
/**
* Classify the running session's context utilization against the
* thresholds documented in #2792:
* < 60% healthy
* 60–70% warning → recommend /gsd-thread
* ≥ 70% critical → reasoning quality may degrade ("fracture point")
*
* Args: --tokens-used <int> --context-window <int>
*
* The model self-reports both numbers — the SDK has no privileged access
* to either. Recommendation copy is owned by this handler (the renderer)
* so it can change without touching the math layer.
*
* Mirror of get-shit-done/bin/lib/context-utilization.cjs (the legacy
* gsd-tools.cjs path uses the CJS module). Keep both in sync.
*/
function parseFlagInt(args: string[], flag: string): number | null {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length) return null;
const v = Number(args[idx + 1]);
return Number.isInteger(v) ? v : null;
}
const CONTEXT_RECOMMENDATIONS: Record<string, string | null> = {
healthy: null,
warning: 'Context is approaching the fracture zone — consider /gsd-thread to continue in a fresh window.',
critical: 'Reasoning quality may degrade past 70% utilization (fracture point). Run /gsd-thread now to preserve output quality.',
};
export const validateContext: QueryHandler = async (args, _projectDir) => {
const tokensUsed = parseFlagInt(args, '--tokens-used');
const contextWindow = parseFlagInt(args, '--context-window');
if (tokensUsed === null || tokensUsed < 0) {
throw new GSDError(
'--tokens-used <non-negative integer> is required for `validate.context`',
ErrorClassification.Validation,
);
}
if (contextWindow === null || contextWindow <= 0) {
throw new GSDError(
'--context-window <positive integer> is required for `validate.context`',
ErrorClassification.Validation,
);
}
const ratio = Math.min(tokensUsed / contextWindow, 1);
const percent = Math.min(Math.round(ratio * 100), 100);
const state = ratio < 0.60 ? 'healthy' : ratio < 0.70 ? 'warning' : 'critical';
return {
data: {
percent,
state,
recommendation: CONTEXT_RECOMMENDATIONS[state],
},
};
};