Merge remote-tracking branch 'origin/next' into kimi-runtime-support
# Conflicts: # docs/ARCHITECTURE.md
This commit is contained in:
154
src/core.cts
154
src/core.cts
@@ -56,8 +56,10 @@ const {
|
||||
getPhaseFileStats,
|
||||
readSubdirectories,
|
||||
timeAgo,
|
||||
extractCanonicalPlanId,
|
||||
} = coreUtilsModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLocatorModule = require('./phase-locator.cjs');
|
||||
const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
|
||||
import { findProjectRoot } from './project-root.cjs';
|
||||
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
||||
|
||||
@@ -520,150 +522,14 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
|
||||
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
|
||||
|
||||
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
||||
// The destructured binding above (from coreUtilsModule) makes it available to
|
||||
// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export =
|
||||
// block (it was never public).
|
||||
// It is consumed exclusively by phase-locator.cjs, which imports it from
|
||||
// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
|
||||
// in core.cjs's public export = block (it was never public).
|
||||
|
||||
interface PhaseSearchResult {
|
||||
found: boolean;
|
||||
directory: string;
|
||||
phase_number: string;
|
||||
phase_name: string | null;
|
||||
phase_slug: string | null;
|
||||
plans: string[];
|
||||
summaries: string[];
|
||||
incomplete_plans: string[];
|
||||
has_research: boolean;
|
||||
has_context: boolean;
|
||||
has_verification: boolean;
|
||||
has_reviews: boolean;
|
||||
archived?: string;
|
||||
}
|
||||
|
||||
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
|
||||
try {
|
||||
const dirs = readSubdirectories(baseDir, true);
|
||||
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
||||
if (!match) return null;
|
||||
|
||||
const phaseToken = extractPhaseToken(match);
|
||||
const phaseNumber = phaseToken || normalized;
|
||||
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
||||
const phaseName = afterToken || null;
|
||||
const phaseDir = path.join(baseDir, match);
|
||||
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
|
||||
const plans = unsortedPlans.sort();
|
||||
const summaries = unsortedSummaries.sort();
|
||||
|
||||
const completedPlanIds = new Set(
|
||||
summaries.flatMap(s => {
|
||||
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
const canonical = extractCanonicalPlanId(s);
|
||||
return canonical === exact ? [exact] : [exact, canonical];
|
||||
})
|
||||
);
|
||||
const incompletePlans = plans.filter(p => {
|
||||
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
const canonical = extractCanonicalPlanId(p);
|
||||
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
|
||||
});
|
||||
|
||||
return {
|
||||
found: true,
|
||||
directory: toPosixPath(path.join(relBase, match)),
|
||||
phase_number: phaseNumber,
|
||||
phase_name: phaseName,
|
||||
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
||||
plans,
|
||||
summaries,
|
||||
incomplete_plans: incompletePlans,
|
||||
has_research: hasResearch,
|
||||
has_context: hasContext,
|
||||
has_verification: hasVerification,
|
||||
has_reviews: hasReviews,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
|
||||
if (!phase) return null;
|
||||
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalized = normalizePhaseName(phase);
|
||||
|
||||
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
||||
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
||||
if (current) return current;
|
||||
|
||||
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
||||
if (!fs.existsSync(milestonesDir)) return null;
|
||||
|
||||
try {
|
||||
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
||||
const archiveDirs = milestoneEntries
|
||||
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const archiveName of archiveDirs) {
|
||||
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
||||
const version = versionMatch![1];
|
||||
const archivePath = path.join(milestonesDir, archiveName);
|
||||
const relBase = '.planning/milestones/' + archiveName;
|
||||
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
||||
if (result) {
|
||||
result.archived = version;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ArchivedPhaseDir {
|
||||
name: string;
|
||||
milestone: string;
|
||||
basePath: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
|
||||
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
||||
const results: ArchivedPhaseDir[] = [];
|
||||
|
||||
if (!fs.existsSync(milestonesDir)) return results;
|
||||
|
||||
try {
|
||||
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
||||
const phaseDirs = milestoneEntries
|
||||
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const archiveName of phaseDirs) {
|
||||
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
||||
const version = versionMatch![1];
|
||||
const archivePath = path.join(milestonesDir, archiveName);
|
||||
const dirs = readSubdirectories(archivePath, true);
|
||||
|
||||
for (const dir of dirs) {
|
||||
results.push({
|
||||
name: dir,
|
||||
milestone: version,
|
||||
basePath: path.join('.planning', 'milestones', archiveName),
|
||||
fullPath: path.join(archivePath, dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
return results;
|
||||
}
|
||||
// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
|
||||
// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
|
||||
// make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
|
||||
// getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
|
||||
|
||||
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
|
||||
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
|
||||
|
||||
@@ -331,14 +331,114 @@ function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedPr
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace-router → concrete sub-skill mapping for nested install layouts (#69).
|
||||
*/
|
||||
interface NamespaceBundleMap {
|
||||
routerStems: Set<string>;
|
||||
routerChildren: Map<string, string[]>;
|
||||
childToRouters: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the namespace router → concrete sub-skill mapping (#69). The
|
||||
* authoritative source is each `ns-*.md` router file's `requires:` frontmatter
|
||||
* list. A concrete skill may be routed by more than one router (e.g. spec-phase
|
||||
* is shared by ns-workflow and ns-ideate); it is nested — and physically
|
||||
* duplicated — under every owning router.
|
||||
*/
|
||||
function buildNamespaceBundleMap(srcCommandsDir: string): NamespaceBundleMap {
|
||||
const routerStems = new Set<string>();
|
||||
const routerChildren = new Map<string, string[]>();
|
||||
const childToRouters = new Map<string, string[]>();
|
||||
if (!fs.existsSync(srcCommandsDir)) {
|
||||
return { routerStems, routerChildren, childToRouters };
|
||||
}
|
||||
for (const entry of fs.readdirSync(srcCommandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('ns-')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
let children: string[] = [];
|
||||
try {
|
||||
children = parseRequires(fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'));
|
||||
} catch { children = []; }
|
||||
routerStems.add(stem);
|
||||
routerChildren.set(stem, children);
|
||||
for (const child of children) {
|
||||
const owners = childToRouters.get(child) || [];
|
||||
owners.push(stem);
|
||||
childToRouters.set(child, owners);
|
||||
}
|
||||
}
|
||||
return { routerStems, routerChildren, childToRouters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a converted namespace-router SKILL.md so its routing table points at
|
||||
* nested sub-skill files instead of bare Skill-tool names (#69). Each table row
|
||||
* whose final cell carries a `gsd-<stem>` token (optionally with `--flag`
|
||||
* suffixes) is rewritten to `Read \`skills/<stem>/SKILL.md\`` (flags preserved
|
||||
* as a note), the `Invoke` column header becomes `Read`, and the
|
||||
* "Invoke … using the Skill tool" trailer becomes a file-read instruction.
|
||||
* Only lines beginning with a table pipe are touched, so the `|` inside the
|
||||
* `description:` frontmatter field is never matched.
|
||||
*/
|
||||
function transformRouterBodyToNested(converted: string): string {
|
||||
const lines = converted.split('\n');
|
||||
const out = lines.map((line) => {
|
||||
if (/Invoke the matched skill directly using the Skill tool\./.test(line)) {
|
||||
return line.replace(
|
||||
/Invoke the matched skill directly using the Skill tool\./,
|
||||
"Read the matched sub-skill's SKILL.md and follow its instructions. The `skills/<name>/SKILL.md` paths in the right column are relative to this skill's own directory.",
|
||||
);
|
||||
}
|
||||
if (!/^\s*\|/.test(line)) return line;
|
||||
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return line;
|
||||
if (/\|\s*Invoke\s*\|/.test(line)) {
|
||||
return line.replace(/\|\s*Invoke\s*\|/, '| Read |');
|
||||
}
|
||||
const cells = line.split('|');
|
||||
const lastIdx = cells.length - 2;
|
||||
if (lastIdx < 1) return line;
|
||||
const cell = cells[lastIdx];
|
||||
const m = cell.match(/gsd-([a-z0-9-]+)((?:\s+--[a-z0-9-]+)*)/i);
|
||||
if (!m) return line;
|
||||
const stem = m[1];
|
||||
const flags = m[2].trim();
|
||||
cells[lastIdx] = flags
|
||||
? ` Read \`skills/${stem}/SKILL.md\` (${flags}) `
|
||||
: ` Read \`skills/${stem}/SKILL.md\` `;
|
||||
return cells.join('|');
|
||||
});
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function stageSkillsForRuntimeAsSkills(
|
||||
srcCommandsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string, skillName: string) => string,
|
||||
prefix: string,
|
||||
nested = false,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
// Nesting applies to the `full` install AND to any surface whose skill set
|
||||
// still contains every namespace router (a full/reset surface). It must NOT
|
||||
// depend on the `'*'` sentinel alone: applySurface() materializes `full` into
|
||||
// a concrete Set, so a sentinel-only gate would re-flatten the layout on every
|
||||
// surface apply/reset (#69 adversarial-review finding). A partial surface that
|
||||
// drops a whole router cluster falls back to flat automatically.
|
||||
const bundles = nested ? buildNamespaceBundleMap(srcCommandsDir) : null;
|
||||
let doNest = false;
|
||||
if (nested && bundles && bundles.routerStems.size > 0) {
|
||||
if (resolvedProfile.skills === '*') {
|
||||
doNest = true;
|
||||
} else {
|
||||
const present = resolvedProfile.skills;
|
||||
doNest = [...bundles.routerStems].every((r) => present.has(r));
|
||||
}
|
||||
}
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
@@ -350,6 +450,30 @@ function stageSkillsForRuntimeAsSkills(
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const skillName = `${prefix}${stem}`;
|
||||
const converted = converter(content, skillName);
|
||||
|
||||
if (doNest && bundles!.routerStems.has(stem)) {
|
||||
// Router skill: rewrite its routing table to the nested Read pattern and
|
||||
// emit it as the single top-level bundle entry.
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doNest && bundles!.childToRouters.has(stem)) {
|
||||
// Concrete skill routed by one or more namespace routers: nest a copy
|
||||
// under each owning router's skills/ subdir so it drops out of the
|
||||
// top-level eager listing while staying readable by file path (#69).
|
||||
for (const routerStem of bundles!.childToRouters.get(stem)!) {
|
||||
const destDir = path.join(stageDir, `${prefix}${routerStem}`, 'skills', stem);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flat top-level skill (default behaviour; also the unrouted fallback when
|
||||
// nesting is active).
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
|
||||
183
src/phase-locator.cts
Normal file
183
src/phase-locator.cts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Phase Locator — Phase-directory search and location
|
||||
*
|
||||
* ADR-857 rollout phase 2d: extracted from core.cts (issue #881).
|
||||
* Owns active-phase discovery against the `.planning/phases/` tree
|
||||
* (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
|
||||
* enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
|
||||
* the filesystem. Behaviour is preserved byte-for-behaviour from the prior
|
||||
* location; only the module boundary moved. core.cjs re-exports
|
||||
* `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat.
|
||||
*
|
||||
* New imports should pull phase-locator helpers from phase-locator.cjs
|
||||
* directly.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs, no loadConfig):
|
||||
* - node:fs / node:path (stdlib)
|
||||
* - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
|
||||
* - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
|
||||
* - ./planning-workspace.cjs (planningDir)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdModule = require('./phase-id.cjs');
|
||||
const { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtilsModule = require('./core-utils.cjs');
|
||||
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
|
||||
// ─── Phase search types ───────────────────────────────────────────────────────
|
||||
|
||||
interface PhaseSearchResult {
|
||||
found: boolean;
|
||||
directory: string;
|
||||
phase_number: string;
|
||||
phase_name: string | null;
|
||||
phase_slug: string | null;
|
||||
plans: string[];
|
||||
summaries: string[];
|
||||
incomplete_plans: string[];
|
||||
has_research: boolean;
|
||||
has_context: boolean;
|
||||
has_verification: boolean;
|
||||
has_reviews: boolean;
|
||||
archived?: string;
|
||||
}
|
||||
|
||||
interface ArchivedPhaseDir {
|
||||
name: string;
|
||||
milestone: string;
|
||||
basePath: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
// ─── Phase search helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
|
||||
try {
|
||||
const dirs = readSubdirectories(baseDir, true);
|
||||
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
||||
if (!match) return null;
|
||||
|
||||
const phaseToken = extractPhaseToken(match);
|
||||
const phaseNumber = phaseToken || normalized;
|
||||
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
||||
const phaseName = afterToken || null;
|
||||
const phaseDir = path.join(baseDir, match);
|
||||
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
|
||||
const plans = unsortedPlans.sort();
|
||||
const summaries = unsortedSummaries.sort();
|
||||
|
||||
const completedPlanIds = new Set(
|
||||
summaries.flatMap(s => {
|
||||
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
const canonical = extractCanonicalPlanId(s);
|
||||
return canonical === exact ? [exact] : [exact, canonical];
|
||||
})
|
||||
);
|
||||
const incompletePlans = plans.filter(p => {
|
||||
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
const canonical = extractCanonicalPlanId(p);
|
||||
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
|
||||
});
|
||||
|
||||
return {
|
||||
found: true,
|
||||
directory: toPosixPath(path.join(relBase, match)),
|
||||
phase_number: phaseNumber,
|
||||
phase_name: phaseName,
|
||||
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
||||
plans,
|
||||
summaries,
|
||||
incomplete_plans: incompletePlans,
|
||||
has_research: hasResearch,
|
||||
has_context: hasContext,
|
||||
has_verification: hasVerification,
|
||||
has_reviews: hasReviews,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
|
||||
if (!phase) return null;
|
||||
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalized = normalizePhaseName(phase);
|
||||
|
||||
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
||||
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
||||
if (current) return current;
|
||||
|
||||
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
||||
if (!fs.existsSync(milestonesDir)) return null;
|
||||
|
||||
try {
|
||||
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
||||
const archiveDirs = milestoneEntries
|
||||
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const archiveName of archiveDirs) {
|
||||
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
||||
const version = versionMatch![1];
|
||||
const archivePath = path.join(milestonesDir, archiveName);
|
||||
const relBase = '.planning/milestones/' + archiveName;
|
||||
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
||||
if (result) {
|
||||
result.archived = version;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
|
||||
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
||||
const results: ArchivedPhaseDir[] = [];
|
||||
|
||||
if (!fs.existsSync(milestonesDir)) return results;
|
||||
|
||||
try {
|
||||
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
||||
const phaseDirs = milestoneEntries
|
||||
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
||||
.map(e => e.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const archiveName of phaseDirs) {
|
||||
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
||||
const version = versionMatch![1];
|
||||
const archivePath = path.join(milestonesDir, archiveName);
|
||||
const dirs = readSubdirectories(archivePath, true);
|
||||
|
||||
for (const dir of dirs) {
|
||||
results.push({
|
||||
name: dir,
|
||||
milestone: version,
|
||||
basePath: path.join('.planning', 'milestones', archiveName),
|
||||
fullPath: path.join(archivePath, dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export = {
|
||||
searchPhaseInDir,
|
||||
findPhaseInternal,
|
||||
getArchivedPhaseDirs,
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import os from 'node:os';
|
||||
import readline from 'node:readline';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioModule = require('./io.cjs');
|
||||
const { output, error, reapStaleTempFiles } = ioModule;
|
||||
const { output, error, reapStaleTempFiles, ensureGsdTempDir, GSD_TEMP_DIR } = ioModule;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -413,7 +413,8 @@ async function cmdExtractMessages(projectArg: string, options: { sessionId?: str
|
||||
}
|
||||
|
||||
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
|
||||
ensureGsdTempDir();
|
||||
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-pipeline-'));
|
||||
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
|
||||
|
||||
let sessionsProcessed = 0;
|
||||
@@ -601,7 +602,8 @@ async function cmdProfileSample(overridePath: string | null | undefined, options
|
||||
}
|
||||
|
||||
reapStaleTempFiles('gsd-profile-', { dirsOnly: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
|
||||
ensureGsdTempDir();
|
||||
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-profile-'));
|
||||
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
|
||||
for (const msg of allMessages) {
|
||||
fs.appendFileSync(outputPath, JSON.stringify(msg) + '\n');
|
||||
|
||||
@@ -327,6 +327,46 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fence-aware text helper ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return a copy of `text` with every line that lies inside a fenced code block
|
||||
* replaced by an empty string, using the same fence semantics as
|
||||
* `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle;
|
||||
* an unclosed fence treats remaining content as fenced).
|
||||
*/
|
||||
function stripFencedLines(text: string): string {
|
||||
let fenceChar: string | null = null;
|
||||
let fenceLen = 0;
|
||||
const lines = text.split('\n');
|
||||
const result: string[] = [];
|
||||
for (const line of lines) {
|
||||
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fm) {
|
||||
const ch = fm[1][0];
|
||||
const ln = fm[1].length;
|
||||
const trailing = fm[2] || '';
|
||||
if (!fenceChar) {
|
||||
fenceChar = ch;
|
||||
fenceLen = ln;
|
||||
// The fence-open line itself is not a content line — blank it.
|
||||
result.push('');
|
||||
} else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) {
|
||||
fenceChar = null;
|
||||
fenceLen = 0;
|
||||
// The fence-close line — blank it.
|
||||
result.push('');
|
||||
} else {
|
||||
// A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content.
|
||||
result.push(fenceChar ? '' : line);
|
||||
}
|
||||
} else {
|
||||
result.push(fenceChar ? '' : line);
|
||||
}
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
// ─── Milestone phase filter ───────────────────────────────────────────────────
|
||||
|
||||
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
|
||||
@@ -416,8 +456,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null):
|
||||
}
|
||||
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
const roadmapUnfenced = stripFencedLines(roadmap);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
||||
while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
|
||||
milestonePhaseNums.add(m[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
@@ -250,6 +250,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
|
||||
* @param converterName name of converter function in bin/install.js exports
|
||||
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
|
||||
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
||||
* @param nested if true, nest concrete skills under their ns-* routers (#69)
|
||||
*/
|
||||
function skillsKind(
|
||||
destSubpath: string,
|
||||
@@ -257,6 +258,7 @@ function skillsKind(
|
||||
converterName: string,
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
nested = false,
|
||||
): ArtifactKind {
|
||||
return {
|
||||
kind: 'skills',
|
||||
@@ -270,7 +272,7 @@ function skillsKind(
|
||||
const cmdNames = installExports.readGsdCommandNames();
|
||||
const wrappedConverter = (content: string, skillName: string): string =>
|
||||
realConverter(content, skillName, runtime, cmdNames);
|
||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix);
|
||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -313,6 +315,39 @@ function convertedCommandsKind(
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nested skill-bundle support matrix (#69)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// When a runtime's skill loader scans only one level deep (non-recursive), a
|
||||
// concrete skill nested at `<router>/skills/<name>/SKILL.md` drops out of the
|
||||
// eager top-level listing yet stays readable by file path — which is exactly
|
||||
// what namespace routing needs. Recursive loaders surface every nested SKILL.md
|
||||
// as a peer (zero token saving), so they stay flat. Unconfirmed loaders stay
|
||||
// flat conservatively. Verified June 2026:
|
||||
//
|
||||
// NEST (confirmed non-recursive / one-level scan):
|
||||
// claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266
|
||||
// (scans one level under ~/.claude/skills; nested skills not auto-listed)
|
||||
// cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir
|
||||
// qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough")
|
||||
// hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills
|
||||
// (single-level subdir probe of the tap path)
|
||||
// augment — https://docs.augmentcode.com/cli/skills (flat single-level)
|
||||
// trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors)
|
||||
// antigravity— discuss.ai.google.dev/t/more-antigravity-issues/145875 ("will not recursive scan")
|
||||
//
|
||||
// FLAT (recursive loader → nesting gives no saving):
|
||||
// cursor — https://cursor.com/docs/skills (walks skills root recursively)
|
||||
// opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md"
|
||||
// kilo — Kilo-Org/kilocode (opencode fork, same ** glob)
|
||||
//
|
||||
// FLAT (nested-scan behaviour unconfirmed → conservative):
|
||||
// codex — developers.openai.com/codex/skills/
|
||||
// copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills
|
||||
// windsurf — docs.devin.ai/desktop/cascade/skills
|
||||
// codebuddy — codebuddy.ai/docs/cli/skills
|
||||
|
||||
/**
|
||||
* Resolve the artifact layout for a given runtime and config directory.
|
||||
*/
|
||||
@@ -336,7 +371,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
agentsKind('agents', 'gsd-', configDir),
|
||||
];
|
||||
} else {
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir, true /* #69 nested: non-recursive scan, see matrix above */)];
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -364,7 +399,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'antigravity':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'windsurf':
|
||||
@@ -374,20 +409,20 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
case 'augment':
|
||||
kinds = [
|
||||
commandsKind('commands', 'gsd-', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir, true /* #69 nested */),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'trae':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'qwen':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'hermes':
|
||||
kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)];
|
||||
kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'codebuddy':
|
||||
@@ -405,7 +440,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'cline':
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : [];
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : [];
|
||||
break;
|
||||
|
||||
case 'kimi':
|
||||
|
||||
Reference in New Issue
Block a user