Merge branch 'next' into fix/2056-plan-phase-foreign-prefix

This commit is contained in:
Tom Boucher
2026-07-09 19:08:08 -04:00
committed by GitHub
86 changed files with 4340 additions and 538 deletions

View File

@@ -0,0 +1,155 @@
/**
* Imperative hook-bus adapter — descriptor-driven hooks.json binding
* (ADR-1239 Phase D / #2089).
*
* Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
* into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
* axis + the host's documented hook-event list (from
* `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
* `sessionStart`/`postToolUse` pair.
*
* This module is PURE (no I/O): it resolves the event→script mapping and builds
* the hooks.json entry manifest. The actual file I/O (copying scripts, writing
* hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
* functions exported here. This separation makes the binding testable without a
* filesystem.
*
* Cursor hook-event universe (closed vocabulary per ADR-1239,
* https://cursor.com/docs/hooks):
* sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
* subagentStop, beforeShellExecution, afterShellExecution,
* afterMCPExecution, afterFileEdit, preCompact, stop,
* beforeTabFileRead, afterTabFileEdit, workspaceOpen
*
* GSD registers for the 6 events in the portable floor + subagent lifecycle
* (AC4a upgrade, #2089):
* sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
*/
'use strict';
/**
* The full set of Cursor hook events GSD can register for.
* Frozen closed vocabulary — adding an event requires updating both this set
* and the event→script mapping below.
*/
export const CURSOR_HOOK_EVENTS = Object.freeze([
'sessionStart',
'postToolUse',
'preToolUse',
'stop',
'subagentStart',
'subagentStop',
] as const);
export type CursorHookEvent = (typeof CURSOR_HOOK_EVENTS)[number];
/**
* Event → hook-script mapping. Each event maps to a standalone `.js` script
* under `hooks/` that Cursor invokes via `hooks.json`.
*
* Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
* `hooks/` and copied to `<configDir>/hooks/` during install by
* `runtime-hooks-surface.cts`.
*/
export const CURSOR_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
sessionStart: 'gsd-cursor-session-start.js',
postToolUse: 'gsd-cursor-post-tool.js',
preToolUse: 'gsd-cursor-pre-tool.js',
stop: 'gsd-cursor-stop.js',
subagentStart: 'gsd-cursor-subagent-start.js',
subagentStop: 'gsd-cursor-subagent-stop.js',
});
/**
* The GSD-managed marker written into each hooks.json entry so the
* reconcile pass can distinguish GSD-owned entries from user-owned ones.
*/
export const GSD_HOOK_MARKER = 'gsd-managed';
/**
* Resolve the managed hook events from a runtime descriptor's
* `hostBehaviors.managedHookEvents` list. Falls back to the full
* `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
* (backward-compat for descriptors predating #2089).
*
* Pure: no I/O, never throws. Unknown event names are silently filtered
* (fail-closed — an unrecognized event is never registered). Falls back to
* the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries
* are unrecognized (ensures the portable-event floor is always covered).
*
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
* @returns a deduplicated, validated array of event names
*/
export function resolveManagedHookEvents(
managedHookEvents: readonly string[] | null | undefined,
): readonly string[] {
if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
return CURSOR_HOOK_EVENTS;
}
const valid = new Set<string>(CURSOR_HOOK_EVENTS);
const seen = new Set<string>();
const result: string[] = [];
for (const ev of managedHookEvents) {
if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
seen.add(ev);
result.push(ev);
}
}
return result.length > 0 ? result : CURSOR_HOOK_EVENTS;
}
/**
* Build the list of hook script files that need to be copied for the given
* managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
*
* Pure: returns a deduplicated array of script filenames.
*
* @param events - the managed event names (validated by `resolveManagedHookEvents`)
* @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
*/
export function resolveHookScripts(
events: readonly string[],
): readonly string[] {
const scripts: string[] = [];
const seen = new Set<string>();
for (const ev of events) {
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
if (script && !seen.has(script)) {
seen.add(script);
scripts.push(script);
}
}
return scripts;
}
/**
* Build the hooks.json managed-entry manifest for the given events.
* Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
*
* The `command` string is built by the caller (it requires platform-specific
* node-runner resolution from `runtime-hooks-surface.cts`). This function
* receives a pre-built `event → command` map and attaches the marker.
*
* Pure: no I/O.
*
* @param events - the managed event names
* @param commands - a map of event → command string (built by the caller)
* @returns a map of event → managed entry, ready for hooks.json reconciliation
*/
export function buildHookBusEntries(
events: readonly string[],
commands: Readonly<Record<string, string | null>>,
): Record<string, { type: string; command: string; [key: string]: unknown }> {
const entries: Record<string, { type: string; command: string; [key: string]: unknown }> = {};
for (const ev of events) {
const cmd = commands[ev];
if (cmd) {
entries[ev] = {
type: 'command',
command: cmd,
[GSD_HOOK_MARKER]: true,
};
}
}
return entries;
}

View File

@@ -555,6 +555,9 @@ const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Ob
opencode: Object.freeze([
'session.created', 'session.idle', 'experimental.session.compacting',
'tool.execute.before', 'tool.execute.after', 'file.edited',
// #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
// permission decisions + session error surface.
'permission.asked', 'permission.replied', 'session.error',
]),
pi: Object.freeze(['tool_call']),
none: Object.freeze([]),

View File

@@ -37,7 +37,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs';
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase, getGlobalConfigDir } from './runtime-homes.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
@@ -2420,11 +2420,24 @@ function buildSkillManifest(cwd: string, skillsDir: string | null = null): Skill
kind: 'skills',
},
{
root: '~/.codex/skills',
// ADR-1239 upgrade 3 (#2088): Codex's canonical skill root is
// $HOME/.agents/skills (per codex core-skills loader.rs), resolved via
// the skills-kind `home` override in getGlobalSkillsBase.
root: '~/.agents/skills',
path: getGlobalSkillsBase('codex') as string,
scope: 'global',
kind: 'skills',
},
{
// Codex's deprecated fallback skill root ($CODEX_HOME/skills). Kept as a
// discovery-only legacy root so pre-move installs remain inventoried;
// GSD no longer installs here (#2088).
root: '~/.codex/skills',
path: path.join(getGlobalConfigDir('codex'), 'skills'),
scope: 'global',
kind: 'skills',
deprecated: true,
},
{
root: '.claude/gsd-core/skills',
path: path.join(os.homedir(), '.claude', 'gsd-core', 'skills'),

View File

@@ -26,6 +26,7 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
import installProfiles = require('./install-profiles.cjs');
const { processAttribution } = runtimeArtifactConversion;
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
@@ -65,6 +66,26 @@ type ResolveAttribution = (runtime: string) => any;
*/
const USER_OWNED_ARTIFACTS: string[] = ['USER-PROFILE.md'];
// ---------------------------------------------------------------------------
// Host-behavior helpers
// ---------------------------------------------------------------------------
/**
* Host-specific install behaviors declared on the runtime descriptor
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
* Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
* {} for runtimes that declare none or if the registry fails to load, so
* every behavior branch degrades to the generic path by default.
*/
function _hostBehaviors(runtime: string): any {
try {
const reg = require('./capability-registry.cjs');
return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Conversion helpers
// ---------------------------------------------------------------------------
@@ -255,15 +276,21 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
'_copyStaged: configDir (install root) is required to confine writes — refusing to write',
);
}
// The install root is normally configDir, but a kind may declare an alternate
// `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
// that case this defense-in-depth check must confine against the resolved
// alternate root instead, matching the upstream gate's own root selection in
// createRuntimeArtifactInstallPlan.
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
// Strict-subpath + NUL containment via the canonical gate (shared with the
// layout-driven install plan); throws if destDir escapes the install root.
// destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
// Symlink-escape guard: reject if any path component between configDir and
// destDir is a symlink that would redirect writes outside configDir.
if (hasExistingSymlinkBetween(path.resolve(configDir), resolvedDest)) {
// destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
// Symlink-escape guard: reject if any path component between the install root and
// destDir is a symlink that would redirect writes outside the install root.
if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest)) {
throw new Error(
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`,
);
}
// Use the validated absolute path for the actual writes below.
@@ -563,6 +590,16 @@ function installRuntimeArtifacts(
resolvedProfile: any,
resolveAttribution: ResolveAttribution = () => undefined,
): void {
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
// the dedicated combined commands+skills+plugin orchestrator instead of the
// generic layout-driven loop below, mirroring the bespoke install path that
// previously lived inline in bin/install.js.
const behaviors = _hostBehaviors(runtime);
if (behaviors.combinedFamilyInstall) {
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
return;
}
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
@@ -589,11 +626,17 @@ function installRuntimeArtifacts(
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
const dest = item.destDir;
// Symlink-escape guard: reject before mkdir if dest (or any component
// between configDir and dest) is a symlink pointing outside configDir.
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) {
// between the install root and dest) is a symlink pointing outside that
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
// call. The install root is normally configDir, but a kind may declare
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
// $HOME/.agents) — in that case the guard must check against the
// resolved alternate root instead, matching assertDestWithinConfigHome's
// own root selection in createRuntimeArtifactInstallPlan.
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest)) {
throw new Error(
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`,
);
}
fs.mkdirSync(dest, { recursive: true });
@@ -690,7 +733,7 @@ function installOpencodeFamilySkills(
const rawDir = rawCommandsDir;
if (!rawDir || !fs.existsSync(rawDir)) return 0;
const converter = runtime === 'kilo'
const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? convertClaudeCommandToKiloSkill
: convertClaudeCommandToOpencodeSkill;
@@ -743,6 +786,121 @@ function installOpencodeFamilySkills(
return count;
}
// ---------------------------------------------------------------------------
// installOpencodeFamilyCommands
// ---------------------------------------------------------------------------
/**
* Install the flattened commands surface for an OpenCode-family runtime
* (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
*
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
* #2087), except attribution is resolved via the injected
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
*
* @param runtime - 'opencode' or 'kilo'
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
* @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
* @param pathPrefix - computed config-path prefix for body rewrites
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
*/
function installOpencodeFamilyCommands(
runtime: string,
destDir: string,
srcDir: string,
pathPrefix: string,
resolveAttribution: ResolveAttribution = () => undefined,
prefix: string = 'gsd',
): void {
if (!fs.existsSync(srcDir)) return;
// Remove old gsd-*.md files before copying new ones
if (fs.existsSync(destDir)) {
for (const file of fs.readdirSync(destDir)) {
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file));
}
} else {
fs.mkdirSync(destDir, { recursive: true });
}
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
if (entry.isDirectory()) {
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
} else if (entry.name.endsWith('.md')) {
const baseName = entry.name.replace('.md', '');
const destName = `${prefix}-${baseName}.md`;
let content = fs.readFileSync(srcPath, 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, resolveAttribution(runtime));
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(path.join(destDir, destName), content);
}
}
}
// ---------------------------------------------------------------------------
// installOpencodeFamilyArtifacts
// ---------------------------------------------------------------------------
/**
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
* Stages the flattened commands surface + skills surface + (OpenCode only)
* native plugin adapter, mirroring the bespoke `else if (isOpencode ||
* isKilo)` block previously inlined in bin/install.js.
*
* @param runtime - 'opencode' or 'kilo'
* @param configDir - resolved runtime config directory
* @param scope - install scope ('global' | 'local')
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
*/
function installOpencodeFamilyArtifacts(
runtime: string,
configDir: string,
scope: string,
resolvedProfile: any,
resolveAttribution: ResolveAttribution = () => undefined,
behaviors: any = {},
): void {
const isGlobal = scope === 'global';
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
// (via the .gsd-source marker or a walk-up from __dirname) — every other
// call site in runtime-artifact-layout.cts feeds its return value straight
// into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
// root (needed below for the native plugin source) is two levels up.
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
const src = path.dirname(path.dirname(commandsGsdDir));
const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
const pathPrefix = (runtimeArtifactConversion as any)._computePathPrefix({
isGlobal,
isOpencode: behaviors.skipHomePrefixSubstitution === true,
isWindowsHost: process.platform === 'win32',
resolvedTarget: path.resolve(configDir).replace(/\\/g, '/'),
homeDir: os.homedir().replace(/\\/g, '/'),
});
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
const np = behaviors.nativePlugin;
if (np && np.source) {
const pluginSrc = path.join(src, np.source);
if (fs.existsSync(pluginSrc)) {
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(pluginSrc, path.join(destDir, np.file));
}
}
}
// ---------------------------------------------------------------------------
// uninstallRuntimeArtifacts
// ---------------------------------------------------------------------------
@@ -807,6 +965,9 @@ export = {
installRuntimeArtifacts,
uninstallRuntimeArtifacts,
installOpencodeFamilySkills,
installOpencodeFamilyCommands,
installOpencodeFamilyArtifacts,
_hostBehaviors,
_copyStaged,
hasExistingSymlinkBetween,
preserveUserArtifacts,

View File

@@ -32,7 +32,11 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/gsd-config-reload.js',
'hooks/gsd-context-monitor.js',
'hooks/gsd-cursor-post-tool.js',
'hooks/gsd-cursor-pre-tool.js',
'hooks/gsd-cursor-session-start.js',
'hooks/gsd-cursor-stop.js',
'hooks/gsd-cursor-subagent-start.js',
'hooks/gsd-cursor-subagent-stop.js',
'hooks/gsd-ensure-canonical-path.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',

View File

@@ -255,6 +255,97 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean {
return false;
}
// ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
/**
* Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text
* after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`,
* or `"Milestone v0.5 complete"`).
*
* The token is anchored to the START of the value (after an optional literal
* `Phase ` label and an optional project-code prefix) so a phase is only
* returned when the value actually begins with one. This is the #2111 fix: the
* prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral
* anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version
* digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield
* `{ phase: null }` because they do not begin with a phase token. The name
* extraction (parenthetical or em-dash tail, minus status words) is unchanged.
*/
function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } {
if (!value) return { phase: null, name: null };
// Coerce defensively so a non-string caller cannot throw on this canonical
// surface (mirrors the sibling #2121 functions' String(...) handling).
const str = String(value);
const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
// The name-extraction quantifiers are length-bounded so a crafted long
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
// cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
// name is far shorter than the cap.
const parenName = str.match(/\(([^)]{1,200})\)/);
const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
? rawName.trim()
: null;
return {
phase: phaseMatch ? phaseMatch[1] : null,
name,
};
}
/**
* Config-AWARE project-code prefix strip. Unlike the config-blind
* `stripProjectCodePrefix` (which strips ANY `<CODE>-` shape), this strips the
* leading `<CODE>-` ONLY when `<CODE>` case-insensitively equals the configured
* `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`)
* or an absent/empty `projectCode` is preserved verbatim — this is the #2104
* fix: a foreign-prefixed id must not collapse to a bare numeric phase and
* collide with a real one.
*/
function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string {
const input = String(value);
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
if (!configured) return input;
const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
if (!m) return input;
if (m[1].toUpperCase() !== configured.toUpperCase()) return input;
return m[2];
}
/**
* True when `phase` carries a project-code prefix that is NOT the configured
* `projectCode` (or when no `projectCode` is configured). The canonical
* predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates
* to, so every call site shares one foreign-prefix rule.
*/
function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean {
const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
if (!m) return false;
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
return !configured || m[1].toUpperCase() !== configured.toUpperCase();
}
/**
* Canonical ROADMAP heading lookup-source list (moved here from
* roadmap-parser.cts so phase-id.cts is the single owner of the ordering).
* Sources are tried in a fixed, deduplicated order: exact (only when the query
* itself is project-code-prefixed) → bare numeric / padding-tolerant →
* prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant
* form so a canonical heading (`### Phase 117:`) is preferred over a drifted
* prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
*/
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
const sources: string[] = [];
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) sources.push(exactSource);
const numericSource = phaseMarkdownRegexSource(phaseNum);
sources.push(numericSource);
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
return [...new Set(sources)];
}
export = {
escapeRegex,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
@@ -268,4 +359,8 @@ export = {
comparePhaseNum,
extractPhaseToken,
phaseTokenMatches,
parsePhaseFromProse,
stripConfiguredProjectCodePrefix,
isForeignPrefixedPhaseQuery,
roadmapPhaseLookupSources,
};

View File

@@ -22,10 +22,11 @@ import phaseIdModule = require('./phase-id.cjs');
const {
escapeRegex,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
stripProjectCodePrefix,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
// #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
// the lookup-source ordering); imported here rather than defined locally.
roadmapPhaseLookupSources,
} = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
@@ -242,23 +243,6 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour
};
}
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
const sources: string[] = [];
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) sources.push(exactSource);
const numericSource = phaseMarkdownRegexSource(phaseNum);
// Source order matters: the bare numeric source is tried before the
// prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
// preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
// both exist in the same ROADMAP. The prefix-tolerant form is the fallback
// that handles the drifted-only case.
sources.push(numericSource);
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
return [...new Set(sources)];
}
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
if (!phaseNum) return null;
const normalizedPhase = stripProjectCodePrefix(phaseNum);

View File

@@ -2507,7 +2507,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const isGlobal = scope === 'global';
const isOpencode = runtime === 'opencode';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2543,7 +2543,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const homeDir = homedir().replace(/\\/g, '/');
const isGlobal = scope === 'global';
const isOpencode = runtime === 'opencode';
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
const isWindowsHost = platform === 'win32';
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
@@ -2672,6 +2672,11 @@ export = {
neutralizeAgentReferences,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
// #2087 — opencode/kilo command-frontmatter converters, exported so the
// layout-driven `convertedCommandsKind` can resolve them by name (routes the
// opencode/kilo command install through the engine instead of the bespoke path).
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
readGsdCommandNames,
transformContentToHyphen,
// #1383: version resolver (exported for regression test of the Codex

View File

@@ -32,6 +32,10 @@ interface ArtifactKind {
destSubpath: string;
prefix?: string;
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
/** Resolved absolute alternate install root for this kind, if the descriptor
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
* kind installs under the runtime's normal configDir. */
home?: string;
}
interface Layout {
@@ -216,7 +220,7 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
items.push({
kind: kind.kind,
sourceDir,
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
});
}
@@ -227,7 +231,7 @@ function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
return {
items: layout.kinds.map((kind) => ({
kind: kind.kind,
destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
})),
};
}

View File

@@ -73,6 +73,10 @@ interface ArtifactKind {
/** For agents kind with a converter, accepts an optional AgentCtx as the second
* arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
/** Resolved absolute alternate install root for this kind, if the descriptor
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
* kind installs under the runtime's normal configDir. */
home?: string;
}
interface Layout {
@@ -419,6 +423,10 @@ interface ArtifactKindDescriptor {
nesting: 'flat' | 'nested';
recursive: boolean;
converter: string | null;
/** Optional alternate install home, relative to the user's home directory
* (e.g. ".agents" for codex skills → $HOME/.agents/skills). When absent,
* the kind installs under the runtime's normal configDir. */
home?: string;
}
interface ArtifactLayoutDescriptor {
@@ -445,18 +453,19 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
const { kind, destSubpath, prefix, nesting, converter } = entry;
const nested = nesting === 'nested';
let result: ArtifactKind;
switch (kind) {
case 'commands':
if (converter == null) {
return commandsKind(destSubpath, prefix, configDir);
}
return convertedCommandsKind(destSubpath, prefix, converter, configDir);
result = converter == null
? commandsKind(destSubpath, prefix, configDir)
: convertedCommandsKind(destSubpath, prefix, converter, configDir);
break;
case 'agents':
if (converter == null) {
return agentsKind(destSubpath, prefix, configDir);
}
return convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
result = converter == null
? agentsKind(destSubpath, prefix, configDir)
: convertedAgentsKind(destSubpath, prefix, converter, configDir, scope);
break;
case 'skills':
if (converter == null) {
@@ -464,16 +473,24 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
);
}
return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
break;
case 'kimi-agents':
return kimiAgentsKind(destSubpath, prefix, configDir);
result = kimiAgentsKind(destSubpath, prefix, configDir);
break;
default:
throw new TypeError(
`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`,
);
}
if (typeof entry.home === 'string' && entry.home !== '') {
result.home = path.join(os.homedir(), entry.home);
}
return result;
}
/**

View File

@@ -118,6 +118,9 @@ type ConfigHomeDescriptor =
interface RuntimeArtifactKindDescriptor {
kind: string;
destSubpath: string;
// ADR-1239 upgrade 3 (#2088): optional split-home override (relative to
// os.homedir()), e.g. Codex skills → ".agents". Absent for most runtimes.
home?: string;
}
interface RuntimeDescriptor {
@@ -416,6 +419,14 @@ export function getGlobalSkillsBase(runtime: string): string | null {
const runtimeEntry = getRegistry().runtimes[runtime];
const descriptor = runtimeEntry?.runtime;
const globalSkillsKind = descriptor?.artifactLayout?.global?.find((entry) => entry.kind === 'skills');
// ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
// → $HOME/.agents/skills, independent of $CODEX_HOME) so the reported skills
// root matches where the installer actually writes (the artifact layout /
// _resolveSkillsRootDir). Without this, `--skills-root` and the sync-skills
// workflow would look under configHome/skills while skills live under ~/.agents.
if (globalSkillsKind?.home && globalSkillsKind?.destSubpath) {
return path.join(os.homedir(), globalSkillsKind.home, globalSkillsKind.destSubpath);
}
if (descriptor?.configHome && globalSkillsKind?.destSubpath) {
return resolveSkillsBaseFromDescriptor(
descriptor.configHome,

View File

@@ -27,6 +27,13 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
CURSOR_HOOK_EVENTS,
CURSOR_EVENT_SCRIPT_MAP,
resolveManagedHookEvents,
resolveHookScripts,
buildHookBusEntries,
} from './host-integration-adapters/imperative-hook-bus.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCmdProjection = require('./shell-command-projection.cjs');
const {
@@ -80,8 +87,20 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
// ---------------------------------------------------------------------------
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
// The full set of Cursor hook events GSD manages — sourced from the adapter
// (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary
// stays closed and first-party. Used by reconcileCursorHooksJson (the
// reconciliation scope is always the full set). The install path
// (writeCursorHooksJson) resolves a descriptor-driven subset via
// resolveManagedHookEvents(opts.managedHookEvents).
const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS;
// ---------------------------------------------------------------------------
// Cline / AGENTS.md constants
// ---------------------------------------------------------------------------
@@ -976,9 +995,8 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) {
const eventKeys = ['sessionStart', 'postToolUse'];
const lifted: Record<string, unknown> = {};
for (const k of eventKeys) {
for (const k of CURSOR_MANAGED_EVENTS) {
if (Array.isArray(parsed[k])) {
lifted[k] = parsed[k];
delete parsed[k];
@@ -989,10 +1007,9 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
if (!parsed['version']) parsed['version'] = 1;
const hookTable = parsed['hooks'] as Record<string, unknown>;
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
const entries = managedEntries || {};
for (const event of MANAGED_EVENTS) {
for (const event of CURSOR_MANAGED_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
const newEntry = entries[event] || null;
@@ -1020,6 +1037,7 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
interface WriteCursorHooksJsonOpts {
absoluteRunner?: string | null;
platform?: string;
managedHookEvents?: readonly string[];
}
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
@@ -1027,7 +1045,11 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
// Descriptor-driven event resolution (#2089): the managed event set comes
// from the host descriptor's hostBehaviors.managedHookEvents via the pure
// adapter (resolveManagedHookEvents), NOT a hardcoded constant.
const events = resolveManagedHookEvents(opts.managedHookEvents);
const hookScripts = resolveHookScripts(events);
const srcHooksDir = path.join(src, 'hooks');
const installedScripts = new Set<string>();
for (const script of hookScripts) {
@@ -1043,28 +1065,16 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
}
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
: null;
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
: null;
const managedEntries: CursorManagedEntries = {};
if (sessionStartCmd) {
managedEntries['sessionStart'] = {
type: 'command',
command: sessionStartCmd,
[GSD_CURSOR_HOOK_MARKER]: true,
};
}
if (postToolCmd) {
managedEntries['postToolUse'] = {
type: 'command',
command: postToolCmd,
[GSD_CURSOR_HOOK_MARKER]: true,
};
const commands: Record<string, string | null> = {};
for (const ev of events) {
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
if (script && installedScripts.has(script)) {
commands[ev] = buildHookCommand(targetDir, script, hookOpts);
} else {
commands[ev] = null;
}
}
const managedEntries = buildHookBusEntries(events, commands) as CursorManagedEntries;
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
@@ -1729,6 +1739,10 @@ export = {
removeCursorHooksJson,
GSD_CURSOR_SESSION_HOOK_SCRIPT,
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
GSD_CURSOR_STOP_HOOK_SCRIPT,
GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
GSD_CURSOR_HOOK_MARKER,
// Copilot

View File

@@ -1118,8 +1118,12 @@ function matchSessionSection(body: string): RegExpMatchArray | null {
function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } {
if (!value) return { phase: null, name: null };
const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i);
const parenName = value.match(/\(([^)]+)\)/);
const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
// #2124 review: length-bound the name quantifiers so a crafted long
// unterminated `(` / `—` run in an untrusted STATE.md field cannot drive
// O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function
// by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.)
const parenName = value.match(/\(([^)]{1,200})\)/);
const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
? rawName.trim()