Merge branch 'next' into fix/2056-plan-phase-foreign-prefix
This commit is contained in:
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal file
155
src/host-integration-adapters/imperative-hook-bus.cts
Normal 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;
|
||||
}
|
||||
@@ -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([]),
|
||||
|
||||
17
src/init.cts
17
src/init.cts
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user