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

# Conflicts:
#	src/init.cts
This commit is contained in:
Tom Boucher
2026-07-10 12:01:32 -04:00
53 changed files with 2090 additions and 7035 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2132
---
**Cline is now driven through the public Host-Integration Interface, with two capability upgrades (ADR-1239 / EoS).** Cline previously installed via hardcoded `runtime === 'cline'`/`isCline` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded cline branch is folded into descriptor-driven `runtime.hostBehaviors` (reapplyCommand, frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot, clineRulesSurface, localCommandsViaRules). Install/uninstall output is **byte-identical** (golden parity asserted for cline + claude/cursor/codex/opencode). Two Context7-verified upgrades land: (1) **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` that cancels write-class calls targeting `.planning/` (same fail-open semantics), driven by a new descriptor-driven adapter module (`src/host-integration-adapters/cline-sdk-binding.cts`); cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx. (2) **`createAgentModel` model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's per-subagent `model_overrides`/`model_profile_overrides` resolution applies to Cline subagents (`modelMode: active`); cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx. Cline's dispatch deliberately stays **degraded/flat** (`maxDepth: 1`, read-only, no nested spawning) per the documented host restriction — never silently upgraded to full nested/background. (#2090)

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2134
---
**Hermes Agent is now driven through the public Host-Integration Interface, with three capability upgrades (ADR-1239 / EoS).** Hermes previously installed via hardcoded `runtime === 'hermes'`/`isHermes` branches in `bin/install.js`; its install/uninstall now runs through the imperative adapter, and every hardcoded hermes branch is folded into descriptor-driven `runtime.hostBehaviors`. Three upgrades land: (1) **real plugin hook vocabulary** — GSD registers a new `extensionEvents: "hermes"` dialect carrying the 13 documented Hermes plugin events (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `pre_approval_request`, `transform_tool_result`), replacing the borrowed `hookEvents: "claude"` 6-event surface that silently never fired; cite https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md. (2) **dispatch posture** — Hermes' `dispatch.nested: true` with `maxDepth: 1` is correctly negotiated (not silently flattened). (3) **branding/category metadata** — `DESCRIPTION.md` category descriptions, `version:` frontmatter, and branding rewrites are now descriptor-driven rather than hardcoded. Install/uninstall output is byte-identical (golden parity asserted for all runtimes). (#2091)

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 2141
---
**Hardened phase/roadmap/plan markdown parsing against quadratic-time (ReDoS) CPU exhaustion** — a crafted `ROADMAP.md`, `STATE.md`, or `PLAN.md` with large runs of unclosed `(`, `[`, `<tag>`, `<!--`, or `<details>` could drive the phase-header, Plans-count, `files_modified`, and `<tag>`-block parsers into O(n²) scans (tens of seconds on a ~1.5 MB file). Every affected regex is now linear: header tag/bracket clauses are length-bounded, the Plans-count scan is section-local, and all `<tag>…</tag>` extraction routes through a single ReDoS-safe seam. (#2128)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2139
---
**`roadmap get-phase` resolves project-code-prefixed headings by bare number** — a bare-number query (e.g. `29`) now resolves a drifted `### Phase AB-29:` heading, matching the internal resolver used by `init.phase-op`; previously the CLI returned empty. A bare sibling (`### Phase 29:`) still takes precedence. A project-code-prefixed heading present only as a summary/checklist line (no matching detail section) now reports a `malformed_roadmap` diagnostic — for both prefixed and bare-number queries — instead of a silent empty result. (#2114)

1
.gitignore vendored
View File

@@ -70,6 +70,7 @@ build/
/gsd-core/bin/lib/host-integration.cjs
/gsd-core/bin/lib/host-integration-sdk.cjs
/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs
/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs
/gsd-core/bin/lib/handshake-serialized.cjs
/gsd-core/bin/lib/install-effort-resolver.cjs
/gsd-core/bin/lib/install-engine.cjs

View File

@@ -1851,7 +1851,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
// Hermes' SKILL.md spec lists `version` as a required frontmatter field.
// Track GSD's package version so Hermes' skill_view() reports a stable
// identifier per install.
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`;
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
@@ -6774,9 +6774,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// Get the target directory based on runtime and install type. Cline local
// installs write to the project root (.clinerules/ lives at the root, not in
// a .cline/ subdir), mirroring the install() path resolution (#787).
// Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
// project root (.clinerules/ lives at the root, not in a .cline/ subdir),
// mirroring the install() path resolution (#787). Folded from a hardcoded
// `runtime === 'cline'` branch into hostBehaviors.localTargetIsProjectRoot.
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: runtime === 'cline'
: _hostBehaviors(runtime).localTargetIsProjectRoot
? process.cwd()
: path.join(process.cwd(), dirName);
@@ -6941,7 +6945,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// 1b-cline. Non-layout Cline side-effects (issue #787): remove the
// directory-form rules + PreToolUse hook, and strip the GSD block from the
// global cross-tool ~/.agents/AGENTS.md target.
if (runtime === 'cline') {
// Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'`
// into hostBehaviors.clineRulesSurface.
if (_hostBehaviors(runtime).clineRulesSurface) {
const clinerulesDir = path.join(targetDir, '.clinerules');
for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
const p = path.join(clinerulesDir, rel);
@@ -7061,7 +7067,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// removes the directory; we must preserve/restore user artifacts before that path.
// This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
// was already removed and skip if so (idempotent).
if (isQwen || isHermes) {
if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
// dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
// that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
// attempt migration. In practice _runLegacyUninstallCleanup removes it first,
@@ -7815,7 +7821,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
// re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/';
const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
const agentsDir = path.join(configDir, 'agents');
const manifest = {
version: pkg.version,
@@ -7865,8 +7871,8 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash;
}
}
// For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift.
if (isHermes) {
// Descriptor-driven (#2090): hash the category DESCRIPTION.md so reinstall detects drift.
if (_hostBehaviors(runtime).trackCategoryDescription) {
const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md');
if (fs.existsSync(descPath)) {
manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath);
@@ -7892,7 +7898,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// Track Cline directory-form artifacts in the manifest (issue #787): the
// rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
// marker block, not the per-configDir manifest, since it lives outside it.)
if (isCline) {
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
// hostBehaviors.clineRulesSurface.
if (_hostBehaviors(runtime).clineRulesSurface) {
for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
const dest = path.join(configDir, rel);
if (fs.existsSync(dest)) {
@@ -7903,7 +7911,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// Track hook files so saveLocalPatches() can detect user modifications
// Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
if (!isCodex && !isCopilot && !isCline && !isKimi) {
// Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) {
const hooksDir = path.join(configDir, 'hooks');
if (fs.existsSync(hooksDir)) {
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -8348,15 +8358,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
};
// Get the target directory based on runtime and install type.
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
// Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
// project root (like Claude Code) — .clinerules lives at the root, not inside
// a .cline/ subdirectory. Folded from `isCline` into
// hostBehaviors.localTargetIsProjectRoot.
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
// but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: isCline
: _hostBehaviors(runtime).localTargetIsProjectRoot
? process.cwd()
: path.join(process.cwd(), dirName);
@@ -8810,13 +8822,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
if (isHermes) {
// Descriptor-driven (#2090): write DESCRIPTION.md for the gsd/ category after layout install
if (_hostBehaviors(runtime).writeCategoryDescription) {
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
}
// Verify installed artifacts and report
if (isHermes) {
if (_hostBehaviors(runtime).reportSkillsCount) {
const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
if (fs.existsSync(hermesSkillsDir)) {
// Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
@@ -8929,10 +8941,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
}
}
} else if (isCline) {
} else if (_hostBehaviors(runtime).localCommandsViaRules) {
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
// No skills/commands directory needed for local installs.
// Global installs are handled above by _isSkillsRuntime (#782).
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
// hostBehaviors.localCommandsViaRules.
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
} else {
// Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
@@ -9212,13 +9226,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
content = convertClaudeAgentToTraeAgent(content);
} else if (isCodebuddy) {
content = convertClaudeAgentToCodebuddyAgent(content);
} else if (isCline) {
} else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
// hostBehaviors.frontmatterDialect === 'cline'.
content = convertClaudeAgentToClineAgent(content);
} else if (isQwen) {
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
content = content.replace(/\.claude\//g, '.qwen/');
} else if (isHermes) {
} else if (_hostBehaviors(runtime).brandingRewrites) {
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
content = content.replace(/\.claude\//g, '.hermes/');
@@ -9286,7 +9302,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// them and the CommonJS package.json marker written below.
// #2089: Cursor's exclusion is now descriptor-driven via
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode) {
// #2090: Cline's exclusion is likewise descriptor-driven (cline declares
// skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) {
// Write package.json to force CommonJS mode for GSD scripts
// Prevents "require is not defined" errors when project has "type": "module"
// Node.js walks up looking for package.json - this stops inheritance from project
@@ -9315,7 +9333,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
}
if (isHermes) {
if (_hostBehaviors(runtime).brandingRewrites) {
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
}
@@ -9378,15 +9396,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
// (Cursor uses standalone .js hook scripts registered via hooks.json — gated
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Codex uses
// hooks.json directly; the others skip hooks entirely); Kilo and ZCode also skip
// hooks entirely (hooksSurface:'none' with no plugin surface — #1821). OpenCode
// is NOT excluded: its #1914 plugin adapter spawns the staged hooks and requires
// hooks/lib/ helpers. None of the excluded runtimes must receive the hooks/lib/
// helpers — otherwise the Codex comment downstream ("we deliberately do *not*
// copy hooks/lib/ for Codex") is contradicted in practice.
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
// #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and
// ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821).
// OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and
// requires hooks/lib/ helpers. None of the excluded runtimes must receive the
// hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do
// *not* copy hooks/lib/ for Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isCline && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);

View File

@@ -56,6 +56,14 @@
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"frontmatterDialect": "cline",
"skipSharedHooksInstall": true,
"localTargetIsProjectRoot": true,
"clineRulesSurface": true,
"localCommandsViaRules": true
}
}
}

View File

@@ -50,6 +50,21 @@
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"extensionEvents": "hermes",
"hostBehaviors": {
"skillFrontmatterVersion": true,
"skillsManifestPrefix": "skills/gsd/",
"trackCategoryDescription": true,
"writeCategoryDescription": true,
"reportSkillsCount": true,
"legacyCommandsGsdCleanup": true,
"brandingRewrites": {
"CLAUDE.md": "HERMES.md",
"Claude Code": "Hermes Agent",
".claude/": ".hermes/"
},
"reapplyCommand": "gsd-update --reapply (mention the skill name)"
},
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",

View File

@@ -222,6 +222,12 @@ Sources consulted:
- https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
- /cline/cline (Context7)
**GSD integration status — Phase D dogfood complete (#2090, ADR-1239).** Cline installs through the `imperative` embedding adapter (`createImperativeAdapter` → `installRuntimeArtifacts`); the hardcoded `runtime === 'cline'` / `isCline` projection is folded into descriptor-driven `runtime.hostBehaviors`, and install/uninstall output is byte-parity-gated (`tests/fixtures/golden-install-parity/cline.json`). Two capability upgrades land, each with a test driving the user-reachable surface:
- **`AgentPlugin.hooks.beforeTool` planning guard** — the `.clinerules/hooks/PreToolUse` file-convention hook (#787) is re-implemented as a real Cline SDK `AgentPlugin` registered through the negotiated `hookBus: host` interface point. Guard semantics are preserved exactly (fail-open, cancels write-class calls targeting `.planning/`); the SDK maps the file hook's `{cancel, errorMessage}` to `{skip, reason}`. The binding lives in `src/host-integration-adapters/cline-sdk-binding.cts` (cite https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx).
- **`createAgentModel` per-subagent model overrides** — `DefaultGateway.createAgentModel({providerId, modelId})` is wired so GSD's `model_overrides` / `model_profile_overrides` resolution (already used for OpenCode/Codex passive hosts) applies to cline subagents (`modelMode: active`), instead of leaving model selection untouched (cite https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx).
- **Dispatch stays degraded/flat (deliberate)** — unlike cursor's dispatch upgrade, cline's `dispatch` is `maxDepth: 1`, `nested: false`, `subagentToolkit: 'read-only'`, `backgroundDispatch: false`. `shouldFlattenDispatch(cline)` returns `true` and `degradationFor('dispatch', cline)` returns `{level:'degraded', fallback:'flat dispatch — waves run inline'}`. This is NOT upgraded: cline's own docs restrict subagents to a single level with a read-only toolkit and no nested spawning, so claiming full dispatch would misrepresent the host and violate the fail-closed negotiation contract (cite https://github.com/cline/cline/blob/main/docs/features/subagents.mdx).
---
## hermes
@@ -252,6 +258,8 @@ Sources consulted:
- https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.19
- /nousresearch/hermes-agent (Context7)
**EoS migration status (#2091):** Migrated onto the imperative adapter. All `runtime === 'hermes'` branches in `bin/install.js` folded into descriptor-driven `runtime.hostBehaviors`. New `extensionEvents: "hermes"` dialect registered (13 real plugin hook events, replacing the borrowed `hookEvents: "claude"` 6-event surface). Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
Documentation gaps:
- runtime — Hermes plugins and agent core run in Python, but this was confirmed by code inspection rather than explicit docs statement.
- dispatch.namedDispatch — docs explicitly confirm no named-agent dispatch in delegate_task; Kanban has named profiles but that is a separate board system not a dispatch mechanism.

View File

@@ -399,4 +399,17 @@ export default tseslint.config(
languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } },
rules: { 'local/no-source-grep': 'error' },
},
// ── #2126 lint-rule CLEAN fixture ───────────────────────────────────────────
// `tests/_ff_lint_clean.cjs` is the KNOWN-CLEAN companion to the violation fixture: the
// prohibition-enforcement real-runner tests lint it as their non-vacuous "clean target" instead of
// a type-aware `src/**/*.cts` file, so each eslint spawn is ~0.8s (non-type-aware) not ~2s
// (whole-tsconfig-program load) — removing the CPU starvation that blew the 60s bound under
// --test-concurrency. Rule enabled (as error) so the pass is non-vacuous; the file is clean so it
// greens. PLAIN `.cjs`, kept OFF the `*.test.cjs` runner glob. (#2126)
{
files: ['tests/_ff_lint_clean.cjs'],
plugins: { local: localPlugin },
languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } },
rules: { 'local/no-source-grep': 'error' },
},
);

View File

@@ -618,6 +618,14 @@ const capabilities = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"frontmatterDialect": "cline",
"skipSharedHooksInstall": true,
"localTargetIsProjectRoot": true,
"clineRulesSurface": true,
"localCommandsViaRules": true
}
}
},
@@ -1374,6 +1382,21 @@ const capabilities = {
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"extensionEvents": "hermes",
"hostBehaviors": {
"skillFrontmatterVersion": true,
"skillsManifestPrefix": "skills/gsd/",
"trackCategoryDescription": true,
"writeCategoryDescription": true,
"reportSkillsCount": true,
"legacyCommandsGsdCleanup": true,
"brandingRewrites": {
"CLAUDE.md": "HERMES.md",
"Claude Code": "Hermes Agent",
".claude/": ".hermes/"
},
"reapplyCommand": "gsd-update --reapply (mention the skill name)"
},
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",
@@ -4029,6 +4052,14 @@ const runtimes = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "node"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"frontmatterDialect": "cline",
"skipSharedHooksInstall": true,
"localTargetIsProjectRoot": true,
"clineRulesSurface": true,
"localCommandsViaRules": true
}
}
},
@@ -4481,6 +4512,21 @@ const runtimes = {
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"extensionEvents": "hermes",
"hostBehaviors": {
"skillFrontmatterVersion": true,
"skillsManifestPrefix": "skills/gsd/",
"trackCategoryDescription": true,
"writeCategoryDescription": true,
"reportSkillsCount": true,
"legacyCommandsGsdCleanup": true,
"brandingRewrites": {
"CLAUDE.md": "HERMES.md",
"Claude Code": "Hermes Agent",
".claude/": ".hermes/"
},
"reapplyCommand": "gsd-update --reapply (mention the skill name)"
},
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "slash-programmatic",

View File

@@ -709,7 +709,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
// plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
// host exposes no extension surface (engine owns the bus, e.g. VS Code).
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);

View File

@@ -1442,7 +1442,7 @@ function reconcileByPhaseTable(content, deps, timestamp, log) {
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(content, timestamp, log) {
// Scan body `**Field:** value` lines; when value matches the placeholder
// shape, replace with `(pending)`. We deliberately do NOT touch fields that

View File

@@ -78,6 +78,7 @@
"check:env": "node scripts/check-env.cjs",
"check:alias-drift": "node scripts/check-alias-drift.cjs",
"check:identity-drift": "node scripts/lint-package-identity-drift.cjs",
"check:phase-id-drift": "node scripts/lint-phase-id-drift.cjs",
"check:integrity": "node scripts/check-npm-integrity.cjs",
"build": "npm run generate:identity && npm run build:lib && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks",
"build:hooks": "node scripts/build-hooks.js",

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
'use strict';
/**
* Anti-divergence drift guard for the phase-identifier parsing seam
* (epic #2121, Phase 4 / issue #2128, locked by ADR-2121 Decision 7).
*
* `src/phase-id.cts` is the SINGLE canonical owner of phase-ID parsing. Its
* `PHASE_NUMBER_TOKEN_SOURCE` (and `phaseMarkdownRegexSource` for a known number)
* is the one place the phase-number-token grammar `\d+[A-Z]?(?:\.\d+)*` is
* defined. Every other module that scans/enumerates phase headings must build
* its regex from that source rather than re-deriving the grammar as a literal —
* otherwise the trio drifts again (the #2111 / #2114 / #2104 recurrence loop this
* epic closes).
*
* This lint makes the invariant machine-enforced: it FAILS the moment a literal
* re-derivation of the canonical token grammar is introduced anywhere in
* `src/**` outside `phase-id.cts`, unless the site is deliberately sanctioned
* with a `// phase-id-owner: <reason>` comment (on the same line or the line
* directly above). Sites that build their regex from `PHASE_NUMBER_TOKEN_SOURCE`
* carry no literal grammar and pass automatically.
*
* Detection is intentionally NARROW: only the contiguous canonical token
* (`\d+[A-Z]?(?:\.\d+)*`, its `[A-Za-z]` and `[.-]` near-variants, in both
* regex-literal `\d` and `new RegExp` template `\\d` escaping) is drift. Bare
* `\d+` probes, `[\w][\w.-]*` ids, digits-only captures, status-message text
* (`Phase\s+\d`), and pipe-table structures are NOT phase-token re-derivations
* and are not flagged.
*/
const fs = require('node:fs');
const path = require('node:path');
// The canonical phase-number token as it appears in SOURCE TEXT:
// \d+[A-Z]?(?:\.\d+)* in a regex literal -> one backslash before d/.
// \\d+[A-Z]?(?:\\.\\d+)* in a template string -> two backslashes
// Tolerated near-variants so a trivial rewrite does not silently evade the guard:
// digit class \d \\d or [0-9]
// letter class [A-Z] or [A-Za-z]
// sub-phase sep \. \\. or [.-] (dot-or-dash)
// KNOWN, ACCEPTED limits of a per-line textual scan (covered instead by the
// identity guard + code review, not by this regex): a re-derivation split
// across lines via string concatenation, a capturing `(\.\d+)*` in place of the
// non-capturing group, or a semantically-equivalent restructuring. This guard
// targets the common case — an accidental copy of the exact grammar — not an
// adversary deliberately obfuscating a re-derivation.
const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])(?:\\{1,2}d|\[0-9\])\+\)\*/;
// A `phase-id-owner:` sanction must be a DEDICATED `//` comment line (the marker
// as the line's leading token). A `//` or the phrase embedded in a string
// literal or trailing a code line is NOT a comment and must never suppress a real
// flag — so sanctions live on their own line directly above the regex.
const OWNER_RE = /^\s*\/\/.*phase-id-owner:/;
const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE';
/**
* Pure: find every literal re-derivation of the canonical phase-number token in
* `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding
* NON-BLANK line is a dedicated `// phase-id-owner:` comment (blank lines between
* the comment and the regex are tolerated, so an auto-formatter cannot reactivate
* the flag), or when the regex line references `PHASE_NUMBER_TOKEN_SOURCE` (built
* from the canonical source, not a literal). A `//`/phrase inside a string or
* trailing a code line does NOT count — put the sanction on its own line above.
* Returns [{ line, found }].
*/
function findPhaseIdRegexDrift(text) {
const out = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = TOKEN_DRIFT_RE.exec(line);
if (!m) continue;
if (line.includes(CANON_REF)) continue;
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
if (j >= 0 && OWNER_RE.test(lines[j])) continue;
out.push({ line: i + 1, found: m[0] });
}
return out;
}
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
const SCAN_DIRS = ['src'];
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
// The canonical owner defines the grammar; it is exempt by construction.
const EXEMPT = new Set([path.join('src', 'phase-id.cts')]);
function walk(dir, acc) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
walk(full, acc);
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
acc.push(full);
}
}
return acc;
}
/**
* Scan the authored source tree and return every unsanctioned phase-token
* re-derivation, each annotated with the repo-relative file path.
*/
function scanRepo(root) {
const violations = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(root, dir), [])) {
const rel = path.relative(root, file);
if (EXEMPT.has(rel)) continue;
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch {
continue;
}
for (const d of findPhaseIdRegexDrift(text)) {
violations.push({ file: rel, ...d });
}
}
}
return violations;
}
function main() {
const root = path.join(__dirname, '..');
const violations = scanRepo(root);
if (violations.length === 0) {
process.stdout.write('ok phase-id-drift: no unsanctioned phase-token re-derivations outside phase-id.cts\n');
return;
}
process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n');
process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n');
process.stderr.write('known number), or sanction the site with a dedicated `// phase-id-owner: <reason>`\n');
process.stderr.write('comment on the line directly above the regex:\n');
for (const d of violations) {
process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`);
}
process.exitCode = 1;
}
if (require.main === module) main();
module.exports = { findPhaseIdRegexDrift, scanRepo, TOKEN_DRIFT_RE };

View File

@@ -20,6 +20,9 @@ const { planningDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -482,7 +485,7 @@ function scanUatGaps(planDir: string): UatGapItem[] {
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];
@@ -552,7 +555,7 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] {
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];
@@ -614,7 +617,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] {
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];

View File

@@ -139,11 +139,14 @@ function loadPlanContents(phaseDir: string): string[] {
}
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
function stripCommentsAndFences(text: string): string {
// HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
// Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
// so downstream tags are preserved (unlike a `(?:-->|$)` fallback, which would
// wipe to EOF and fail-close the decision-coverage gate).
const htmlStripped = text.replace(/<!--(?:(?!<!--)[\s\S])*?-->/g, ' ');
// Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
// replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
return stripFencedCode(htmlStripped).text;

View File

@@ -1334,7 +1334,7 @@ function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean)
for (const pf of planFiles) {
const planContent = platformReadSync(path.join(phaseDir, pf));
if (planContent === null) continue;
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
if (fmFiles) {
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
}
@@ -1516,8 +1516,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
// Also tolerates optional [bracket-token] scope prefix on phase headings.
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
let match: RegExpExecArray | null;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
const key = normalizePhaseName(match[1]);

View File

@@ -0,0 +1,256 @@
/**
* Cline SDK binding — AgentPlugin + createAgentModel adapters
* (ADR-1239 Phase D / #2090).
*
* Two Context7-verified UPGRADES the file-convention projection ignored, now
* delivered through the negotiated `hookBus: host` + `modelMode: active`
* interface points:
*
* UPGRADE 1 — `AgentPlugin.hooks.beforeTool` planning-artifact guard.
* Re-implements the `.clinerules/hooks/PreToolUse` file-convention hook
* (issue #787) as a real Cline SDK AgentPlugin. Guard semantics are
* preserved EXACTLY: fail-open, cancel (skip) write-class calls targeting
* `.planning/`, pass through everything else. The SDK maps the file hook's
* `{cancel:true, errorMessage}` to `{skip:true, reason}` (beforeTool
* contract).
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
*
* UPGRADE 2 — `DefaultGateway.createAgentModel({providerId, modelId})`.
* Resolves GSD's per-subagent `model_overrides` / `model_profile_overrides`
* (already used for OpenCode/Codex passive hosts) into the createAgentModel
* call params for cline's active model mode. The host gateway owns the
* actual model instantiation; this binding resolves WHICH model an
* overridden subagent should use.
* Cite: https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
*
* This module is PURE (no I/O, no SDK import): the real `@cline/sdk` is a
* fast-moving package set not linked at build/test time, so the binding exposes
* the decision functions a host plugin/gateway would call. Tests drive payloads
* through them directly (same mock-the-SDK pattern as the VS Code reference
* binding, tests/fixtures/vscode-host-binding.cjs).
*/
'use strict';
// ---------------------------------------------------------------------------
// UPGRADE 1 — beforeTool planning-artifact guard
// ---------------------------------------------------------------------------
/**
* Write-class tool-verb detector. Matches the SAME regex as the #787
* PreToolUse file-convention hook so the guard behaves identically.
* Case-insensitive (the SDK delivers tool names in varying case).
*/
export const WRITE_TOOL_PATTERN = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
/**
* `.planning/` path detector. Matches `.planning` preceded by start-of-string
* or a path separator (posix `/` or windows `\`) and followed by a separator or
* end-of-string — so `.planning-readme.txt` is NOT falsely matched. Mirrors the
* PreToolUse hook's `(^|[\\/])\.planning([\\/]|$)` exactly.
*/
export const PLANNING_PATH_PATTERN = /(^|[\\/])\.planning([\\/]|$)/;
/**
* The user-visible reason returned when a `.planning/` write is blocked.
* Preserves the PreToolUse hook's errorMessage text so the guard behaves
* identically to the user (cancel→skip, errorMessage→reason).
*/
export const PLANNING_GUARD_REASON: string = Object.freeze(
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
);
/**
* Path-bearing field-name detector. Only PATH-keyed field values are inspected,
* so a document that merely mentions ".planning/" in its body content is never
* falsely blocked. Mirrors the PreToolUse hook's PATH_KEY regex exactly.
*/
const PATH_KEY_PATTERN = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
type BeforeToolPayload = {
tool?: { name?: unknown } | string | null | undefined;
input?: unknown;
};
type BeforeToolDecision = { decision: 'skip'; reason: string } | { decision: 'allow'; reason?: undefined };
/**
* Collect PATH-bearing string field values from an object tree, mirroring the
* PreToolUse hook's bounded walk. Pure; never throws.
*/
function collectPathValues(root: unknown): string[] {
const paths: string[] = [];
const walk = (v: unknown, depth: number): void => {
if (depth > 5 || paths.length > 64) return;
if (Array.isArray(v)) {
for (const x of v) walk(x, depth + 1);
return;
}
if (v && typeof v === 'object') {
const obj = v as Record<string, unknown>;
for (const k of Object.keys(obj)) {
const val = obj[k];
if (typeof val === 'string' && PATH_KEY_PATTERN.test(k)) {
paths.push(val);
} else {
walk(val, depth + 1);
}
}
}
};
walk(root, 0);
return paths;
}
/**
* Resolve a tool name from a beforeTool payload's `tool` field, which may be a
* string or an object with a `name` property. Returns '' when absent (treated
* as non-write-class → allow, fail-open).
*/
function resolveToolName(tool: BeforeToolPayload['tool']): string {
if (!tool) return '';
if (typeof tool === 'string') return tool;
const name = tool.name;
return typeof name === 'string' ? name : '';
}
/**
* The pure guard decision: given a beforeTool payload, decide skip (cancel) or
* allow. Fail-OPEN — any malformed input, missing tool, or thrown error returns
* 'allow' (the guard never blocks on a defect, mirroring the PreToolUse hook).
*
* @returns `{decision:'skip', reason}` for a write-class call targeting
* `.planning/`; `{decision:'allow'}` for everything else.
*/
export function evaluateBeforeTool(payload: BeforeToolPayload | null | undefined): BeforeToolDecision {
try {
if (!payload) return { decision: 'allow' };
const toolName = resolveToolName(payload.tool);
if (!toolName) return { decision: 'allow' };
const isWrite = WRITE_TOOL_PATTERN.test(toolName);
if (!isWrite) return { decision: 'allow' };
const paths = collectPathValues(payload.input);
const isPlanningPath = (s: string): boolean => PLANNING_PATH_PATTERN.test(s);
if (paths.some(isPlanningPath)) {
return { decision: 'skip', reason: PLANNING_GUARD_REASON };
}
return { decision: 'allow' };
} catch {
// Fail-open: a defect in the guard never blocks the user's operation.
return { decision: 'allow' };
}
}
/**
* The Cline `AgentPlugin` shape (Context7 /cline/cline). A plugin implements
* `setup({agentId})` returning `{hooks, tools}`. The `beforeTool` hook returns
* `{skip:true, reason}` to block or `undefined` to allow.
*
* This object is the portable plugin a host loads from `~/.cline/plugins/`
* (analogous to `.opencode/plugins/gsd-core.js`). Its `beforeTool` delegates to
* the pure `evaluateBeforeTool` so the decision logic is testable without the
* SDK linked.
*/
export const clineGsdPlugin: {
name: string;
setup: (ctx: { agentId?: string }) => {
hooks: {
beforeTool: (payload: BeforeToolPayload) => { skip: true; reason: string } | undefined;
};
};
} = Object.freeze({
name: 'gsd-planning-guard',
setup(_ctx: { agentId?: string }) {
return {
hooks: {
beforeTool(payload: BeforeToolPayload): { skip: true; reason: string } | undefined {
const decision = evaluateBeforeTool(payload);
return decision.decision === 'skip' ? { skip: true, reason: decision.reason } : undefined;
},
},
};
},
});
// ---------------------------------------------------------------------------
// UPGRADE 2 — createAgentModel model-override resolution
// ---------------------------------------------------------------------------
/**
* The fallback provider id when a model id does not match a known provider
* family. Anthropic is cline's most common default; the host gateway retains
* the final say over provider resolution.
*/
export const DEFAULT_CLINE_PROVIDER_ID: string = 'anthropic';
/**
* Infer a `providerId` (the createAgentModel first arg) from a model id by
* matching known provider families. Returns DEFAULT_CLINE_PROVIDER_ID for an
* unrecognized or empty id (fail-safe — the gateway applies its own default).
*
* Pure string-prefix classification; does not validate the id is a real model.
*/
export function inferProviderId(modelId: string): string {
if (typeof modelId !== 'string' || modelId.length === 0) return DEFAULT_CLINE_PROVIDER_ID;
const lower = modelId.toLowerCase();
if (lower.startsWith('claude')) return 'anthropic';
if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai';
if (lower.startsWith('gemini')) return 'google';
if (lower.startsWith('deepseek')) return 'deepseek';
return DEFAULT_CLINE_PROVIDER_ID;
}
type ModelOverrideMap = Record<string, string> | null | undefined;
type ProfileOverrideMap = Record<string, Record<string, string>> | null | undefined;
type AgentModelParams = { providerId: string; modelId: string };
/**
* Resolve the createAgentModel call params for a cline subagent from GSD's model
* override config. Mirrors the precedence OpenCode/Codex use (passive hosts
* embed the resolved model into agent frontmatter); for cline's active model
* mode the same resolution flows to `gateway.createAgentModel(params)`.
*
* Precedence (matches GSD's model_overrides > model_profile_overrides contract):
* 1. `modelOverrides[agentType]` — direct per-agent override
* 2. `modelProfileOverrides[profile][agentType]` — profile-scoped override
* 3. null — no override; the host gateway applies its own default
*
* Pure; never throws. Non-string / empty override values are ignored (fail-safe).
*
* @returns the `{providerId, modelId}` for createAgentModel, or null when no
* override is configured (the gateway default applies — GSD does NOT
* call createAgentModel in that case).
*/
export function resolveClineAgentModelParams(args: {
agentType: string;
modelOverrides?: ModelOverrideMap;
modelProfileOverrides?: ProfileOverrideMap;
profile?: string;
}): AgentModelParams | null {
const { agentType, modelOverrides, modelProfileOverrides, profile } = args;
if (!agentType || typeof agentType !== 'string') return null;
// 1. Direct per-agent override wins.
if (modelOverrides && typeof modelOverrides === 'object') {
const direct = modelOverrides[agentType];
if (typeof direct === 'string' && direct.length > 0) {
return { providerId: inferProviderId(direct), modelId: direct };
}
}
// 2. Profile-scoped override.
if (modelProfileOverrides && typeof modelProfileOverrides === 'object' && profile) {
const profileEntry = modelProfileOverrides[profile];
if (profileEntry && typeof profileEntry === 'object') {
const profileModel = profileEntry[agentType];
if (typeof profileModel === 'string' && profileModel.length > 0) {
return { providerId: inferProviderId(profileModel), modelId: profileModel };
}
}
}
// 3. No override — gateway default applies.
return null;
}

View File

@@ -559,6 +559,19 @@ const EXTENSION_EVENT_SURFACES: Readonly<Record<string, readonly string[]>> = Ob
// permission decisions + session error surface.
'permission.asked', 'permission.replied', 'session.error',
]),
// #2091 — Hermes Agent real plugin hook vocabulary (13 events).
// Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
// Replaces the borrowed `hookEvents: "claude"` 6-event surface that silently
// never fired on Hermes.
hermes: Object.freeze([
'pre_tool_call', 'post_tool_call',
'pre_llm_call', 'post_llm_call',
'on_session_start', 'on_session_end',
'on_session_finalize', 'on_session_reset',
'subagent_start', 'subagent_stop',
'pre_gateway_dispatch', 'pre_approval_request',
'transform_tool_result',
]),
pi: Object.freeze(['tool_call']),
none: Object.freeze([]),
});

View File

@@ -73,7 +73,7 @@ const {
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId;
const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
@@ -1209,8 +1209,8 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(currentSection)) !== null) {
if (/^999(?:\.|$)/.test(m[1])) continue;
@@ -1229,7 +1229,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/);
const m = stripProjectCodePrefix(e.name).match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`));
if (!m) continue;
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
}
@@ -1367,14 +1367,14 @@ function cmdInitManager(cwd: string, raw: boolean): void {
})();
const _checkboxStates = new Map<string, boolean>();
const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let _cbMatch: RegExpExecArray | null;
while ((_cbMatch = _cbPattern.exec(content)) !== null) {
_checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
}
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const phases: Record<string, unknown>[] = [];
let match: RegExpExecArray | null;
@@ -1513,7 +1513,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
);
const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p]));
const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let _allMatch: RegExpExecArray | null;
while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
const phaseNum = normalizePhaseNumber(_allMatch[1]);
@@ -1547,7 +1547,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
) {
phase['deps_satisfied'] = true;
} else {
const depNums = (phase['depends_on'] as string).match(/\d+[A-Z]?(?:\.\d+)*/gi) || [];
const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || [];
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
phase['dep_phases'] = depNums;
}
@@ -1736,14 +1736,14 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'),
cwd,
);
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
let hm: RegExpExecArray | null;
while ((hm = headingPattern.exec(roadmapContent)) !== null) {
roadmapPhaseNums.add(hm[1]);
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
}
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
let cbm: RegExpExecArray | null;
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
@@ -1762,14 +1762,14 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
.map((e) => e.name)
.filter(isDirInMilestone)
.sort((a, b) => {
const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
if (!pa || !pb) return a.localeCompare(b);
return parseInt(pa[1], 10) - parseInt(pb[1], 10);
});
for (const dir of dirs) {
const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
const phaseNumber = dirMatch ? dirMatch[1] : dir;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');

View File

@@ -520,25 +520,25 @@ export function iterateBullets(sectionText: string): BulletItem[] {
* fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
* block and should be excluded, the caller should apply `stripFencedCode` first.
*
* **Nested tags are NOT supported.** The underlying regex uses a non-greedy
* `[\s\S]*?` match, which means it closes at the FIRST `</tagName>` encountered.
* Given `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns
* `['<x>inner']` — the inner `<x>` is captured as literal text, and the second
* `</x>` is left unmatched (or matched as a second block with empty inner text
* if another `<x>` follows). Callers that need to handle nested tags must
* pre-process the input or use a proper XML/HTML parser.
* **Nested tags are NOT supported.** The body scan terminates at the NEXT
* opening of the same tag (the ReDoS-safe boundary, #2128). Given
* `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns `['inner']`
* — the well-formed inner block; the unterminated outer `<x>` is skipped.
* Callers that need true nesting must use a proper XML/HTML parser.
*
* `allowAttributes` (default `false`): when `true`, the opening tag may carry
* bounded attributes (`<tag foo="x">`) — needed for `<task type="…">` blocks.
* Leave `false` for tags that must match exactly (e.g. `<decisions>`), and never
* enable it for a tag where an attributed form is semantically distinct.
*
* Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
* so tier T1 can drop its own copy (tracked duplication until T1 lands).
*/
export function extractTaggedBlocks(content: string, tagName: string): string[] {
export function extractTaggedBlocks(content: string, tagName: string, allowAttributes = false): string[] {
if (typeof content !== 'string' || content.length === 0) return [];
if (typeof tagName !== 'string' || tagName.length === 0) return [];
// Escape the tag name for safe interpolation into a RegExp.
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
const results: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
@@ -547,6 +547,43 @@ export function extractTaggedBlocks(content: string, tagName: string): string[]
return results;
}
/**
* Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
* `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
*
* Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open)
* instead of lazily rescanning the whole remaining document for a `</tag>` that
* may never appear — so a document full of unclosed `<tag>` openings scans
* LINEARLY, not quadratically (#2128). Group 1 is the block body.
*
* `allowAttributes`: when `true`, the opener accepts bounded attributes
* (`<tag foo="x">`) and the body boundary is `<tag` followed by a space or `>`.
* When `false`, the opener is the EXACT `<tag>` and the boundary is exact `<tag>`,
* so an attributed `<tag foo>` is neither an opener nor a boundary — it is body
* content. That exact form is load-bearing for `<details>` stripping: `<details
* open>` marks the ACTIVE milestone and must be preserved, not stripped (#557).
*/
function taggedBlockPattern(tagName: string, flags: string, allowAttributes: boolean): RegExp {
const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`;
const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`;
return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)</${esc}>`, flags);
}
/**
* Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
* from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
* hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes`
* defaults to `false` so `<details open>` (active milestone) is preserved (#557);
* case-insensitive by default (matching the `<details>` strip call sites), pass
* `caseSensitive` to force exact-case matching.
*/
export function stripTaggedBlocks(content: string, tagName: string, allowAttributes = false, caseSensitive = false): string {
if (typeof content !== 'string' || content.length === 0) return '';
if (typeof tagName !== 'string' || tagName.length === 0) return content;
return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), '');
}
// ─── replaceSection ───────────────────────────────────────────────────────────
/**

View File

@@ -23,7 +23,7 @@ import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
@@ -176,8 +176,8 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
if (stateVersion && stateVersion === version) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const noDirectoryPhases: string[] = [];
let pm: RegExpExecArray | null;
const phaseDirEntries = ((): string[] => {

View File

@@ -37,9 +37,21 @@ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
// Enumeration/parse call sites that read phase headers from a regex *literal*
// (rather than a `new RegExp` built from an interpolated phase number) cannot
// reference this constant; they inline its literal-regex mirror instead —
// `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this
// `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this
// source. Both forms must change together; see the #1729 regression test.
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?';
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
// #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an
// optional single-letter variant suffix and optional dotted sub-phases
// (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to
// phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source
// for ONE KNOWN number; reference this constant when a call site must match ANY
// phase and capture its token. Enumeration/parse sites inline this into a
// `new RegExp(...)` instead of re-deriving the grammar as a literal, so every
// phase-token producer shares one owner. The anti-divergence guard
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
// introduced outside this module without a `// phase-id-owner:` justification.
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string {
const input = String(value);
@@ -350,6 +362,7 @@ export = {
escapeRegex,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
stripProjectCodePrefix,
normalizePhaseName,
getMilestoneFromPhaseId,

View File

@@ -37,6 +37,7 @@ const {
phaseTokenMatches,
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
OPTIONAL_PHASE_TAG_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
} = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
import phaseLocatorMod = require('./phase-locator.cjs');
@@ -374,8 +375,9 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
if (!match) continue;
const dirMatch =
match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
match.match(
new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')
) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
@@ -711,13 +713,13 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
// (section header, roadmap bullet, or on-disk directory) is counted:
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
// are counted and cannot collide with a freshly-added phase. (#1229)
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
const usedPhaseNums = new Set<number>();
let m: RegExpExecArray | null;
@@ -809,8 +811,8 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
const content = extractCurrentMilestone(rawContent, cwd);
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
@@ -1193,7 +1195,7 @@ function updateRoadmapAfterPhaseRemoval(
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
// is re-emitted verbatim — a tagged later phase still gets renumbered.
content = content.replace(
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi,
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi,
(_match, prefix: string, num: string, suffix: string) =>
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
);
@@ -1517,7 +1519,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
}
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i',
);
roadmapContent = roadmapContent.replace(
@@ -1672,7 +1674,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
if (dm) {
if (/^999(?:\.|$)/.test(dm[1])) continue;
if (comparePhaseNum(dm[1], phaseNum) > 0) {
@@ -1702,10 +1704,13 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// phase. Allow optional `**`/`__` emphasis after the marker and stop
// the name capture at emphasis so bold names slug cleanly; the number
// capture is unchanged.
// #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
const phasePattern = new RegExp(
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
'gi'
);
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -1741,8 +1746,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
if (isLastPhase && roadmapContent !== null) {
try {
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
const cbPattern =
/-\s*\[(x| )\]\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
const cbPattern = new RegExp(
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
'gi'
);
let cbm: RegExpExecArray | null;
let lowestOutstanding: { num: string; name: string } | null = null;
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {

View File

@@ -67,13 +67,14 @@ function checkW021(content: string): W021Warning[] {
// Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
// OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
// Capture the major integer.
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
// Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i;
// Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
// phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE.
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
let currentMilestoneMajor: number | null = null;
const lines = content.split('\n');

View File

@@ -32,7 +32,7 @@ const {
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
import { platformReadSync } from './shell-command-projection.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { tokenizeHeadings, stripTaggedBlocks } from './markdown-sectionizer.cjs';
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
@@ -40,7 +40,7 @@ import { tokenizeHeadings } from './markdown-sectionizer.cjs';
* Strip shipped milestone content wrapped in <details> blocks.
*/
function stripShippedMilestones(content: string): string {
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
return stripTaggedBlocks(content, 'details');
}
/**
@@ -96,10 +96,9 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
const preamble = content.slice(0, preambleCutoff)
.replace(/<details>[\s\S]*?<\/details>/gi, '')
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
const preamble = stripTaggedBlocks(content.slice(0, preambleCutoff), 'details')
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return preamble + content.slice(detailsOpenIdx, detailsEnd);
}
@@ -177,10 +176,9 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
);
}
const preamble = beforeMilestones
.replace(/<details>[\s\S]*?<\/details>/gi, '')
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
const preamble = stripTaggedBlocks(beforeMilestones, 'details')
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return detailsSection
@@ -215,7 +213,7 @@ interface RoadmapPhaseResult {
function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null {
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
const headingPattern = new RegExp(
`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
'i'
);
const headings = tokenizeHeadings(content);
@@ -370,7 +368,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
console.warn(
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
@@ -427,8 +425,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
// T4 seam migration: phase headings inside fences are excluded automatically.
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
for (const h of tokenizeHeadings(roadmap)) {
if (h.level < 2 || h.level > 4) continue;
const pm = phaseHeadingPattern.exec(h.text);
@@ -459,6 +457,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// the milestone as a bogus "46-6" id.
const numericRe = roadmapUsesHyphenedIds
? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
// phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
function isDirInMilestone(dirName: string): boolean {

View File

@@ -16,20 +16,23 @@ import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { planningDir } = planningWorkspace;
const { stripProjectCodePrefix } = phaseIdMod;
const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// ─── Regex helpers ────────────────────────────────────────────────────────────
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
const LEGACY_PHASE_HEADING_RE = new RegExp(
`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`,
'i'
);
// Matches already-migrated phase headings: ### Phase M-NN: Name
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -172,7 +175,7 @@ function extractPhaseNumFromDir(dirName: string): string | null {
const stripped = stripProjectCodePrefix(dirName);
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
const m = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-|$)`, 'i'));
return m ? m[1] : null;
}
@@ -188,7 +191,7 @@ function buildNewDirName(oldDirName: string, newId: string, projectCode: string
const stripped = stripProjectCodePrefix(oldDirName);
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i'));
const slug = slugMatch ? slugMatch[1] : stripped;
// Build M-NN prefix (zero-pad both parts)
@@ -341,7 +344,7 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
// Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
const oldLine = lines[entry.lineIndex];
const newLine = oldLine.replace(
/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i,
new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'),
`$1${mapping.newId}$2`
);
if (newLine !== oldLine) {
@@ -364,7 +367,9 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
if (roadmapEdits.some(e => e.lineIndex === i)) continue;
// Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
const checklistMatch = line.match(
new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i')
);
if (checklistMatch) {
const legacyNum = checklistMatch[2];
const cIntPart = parseInt(legacyNum, 10);
@@ -393,7 +398,7 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
if (newId) {
const newLine = line.replace(
/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i,
new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'),
`$1${newId}$2`
);
if (newLine !== line) {

View File

@@ -14,7 +14,7 @@ import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
@@ -126,7 +126,7 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null {
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
const headingPattern = new RegExp(
`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
'i'
);
const headings = tokenizeHeadings(content);
@@ -215,22 +215,17 @@ function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | nu
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
const fullContent = stripShippedMilestones(rawContent);
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) {
const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
if (exactMilestone && !exactMilestone.error) return exactMilestone.section ?? null;
const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
if (exactFull && !exactFull.error) return exactFull.section ?? null;
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a
// bare-number query resolves a drifted project-code-prefixed heading.
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
if (milestoneResult && !milestoneResult.error) return milestoneResult.section ?? null;
const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
if (fullResult && !fullResult.error) return fullResult.section ?? null;
}
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
const result = (milestoneResult && !milestoneResult.error)
? milestoneResult
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
if (!result || result.error) return null;
return result.section ?? null;
return null;
}
// ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
@@ -251,52 +246,37 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
// #3599 two-pass: when the caller passes a project-code-prefixed ID like
// `PROJ-42`, try the exact-prefixed heading first (`### Phase PROJ-42:`).
// If no match, fall back to the #3537 padding-tolerant numeric form so
// a `CK-01` query still resolves to `### Phase 1:`. Doing this at the
// call site (instead of inside phaseMarkdownRegexSource) avoids the
// alternation-order ambiguity where a bare `### Phase 42:` heading in
// the same document would intercept the match for a `PROJ-42` query.
const fullContent = stripShippedMilestones(rawContent);
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
if (exactSource) {
const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
if (exactMilestone && !exactMilestone.error) {
output(exactMilestone, raw, exactMilestone.section);
// #2121/#2114: iterate the shared lookup-source list (exact → numeric →
// prefix-tolerant) so all three roadmap resolvers share one contract and a
// bare-number query resolves a drifted `### Phase AB-29:` heading. This
// preserves the #3599 exact-prefix-first and #3537 padding-tolerant behavior
// (both now encoded in roadmapPhaseLookupSources' ordering). A clean match
// (milestone or full, any source) wins immediately; a malformed_roadmap
// (checklist-only) candidate is surfaced only if no source finds a real
// heading — so a milestone checklist never blocks a full-roadmap header.
let malformed: PhaseSearchResult | null = null;
for (const source of roadmapPhaseLookupSources(phaseNum)) {
const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum);
if (milestoneResult && !milestoneResult.error) {
output(milestoneResult, raw, milestoneResult.section);
return;
}
const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
if (exactFull && !exactFull.error) {
output(exactFull, raw, exactFull.section);
const fullResult = searchPhaseInContent(fullContent, source, phaseNum);
if (fullResult && !fullResult.error) {
output(fullResult, raw, fullResult.section);
return;
}
if (!malformed) malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
}
// #3537: padding-tolerant fragment so callers passing `02.7` still match
// un-padded ROADMAP prose (`### Phase 2.7:`).
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
// Search the current milestone slice first, then fall back to full roadmap.
// A malformed_roadmap result (checklist-only) from the milestone should not
// block finding a full header match in the wider roadmap content.
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
const result = (milestoneResult && !milestoneResult.error)
? milestoneResult
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
if (!result) {
output({ found: false, phase_number: phaseNum }, raw, '');
if (malformed) {
output(malformed, raw, '');
return;
}
if (result.error) {
output(result, raw, '');
return;
}
output(result, raw, result.section);
output({ found: false, phase_number: phaseNum }, raw, '');
} catch (e) {
error('Failed to read ROADMAP.md: ' + (e as Error).message);
}
@@ -317,8 +297,9 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const phasesDir = planningPaths(cwd).phases;
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
const phases: Array<{
number: string;
name: string;
@@ -363,7 +344,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const restOfContent = content.slice(sectionStart);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
const section = content.slice(sectionStart, sectionEnd);
@@ -457,6 +438,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
const checklistPhases = new Set<string>();
let checklistMatch: RegExpExecArray | null;
@@ -563,7 +545,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
// `Plans: N plans` — plain text header
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`,
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`,
'i'
);
const planCountText = isComplete
@@ -633,11 +615,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// Pattern A: anchor to bare `Plans:` header (preferred).
// Pattern B: fallback to bold summary when no bare header exists.
const insertRowsPatternA = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
'i'
);
const insertRowsPatternB = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
'i'
);

View File

@@ -2522,10 +2522,12 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
* attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace
* (single copy+rewrite owner).
*
* @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam
* API for command bodies. No production caller today (install rewrites commands via
* copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for
* API symmetry + test coverage.
* @internal — symmetric companion to rewriteStagedSkillBodies; the deep-seam API for
* command bodies. Production callers: applySurface (surface.cts) and the install path
* in createRuntimeArtifactInstallPlan (runtime-artifact-install-plan.cts) — both keep
* the returned temp dir alive until they have copied its contents out, then clean it up
* in their own finally. (A test that treats this as a throwaway shared-tmp path will
* race those live temp dirs under --test-concurrency; see #1575/#2090.)
*
* @returns {string} path to the temp dir (caller is responsible for cleanup)
*/

View File

@@ -1818,7 +1818,7 @@ function reconcileByPhaseTable(
* source to substitute. This is honest — better than silently leaving `[X]`
* which looks like a value.
*/
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/;
function stripTemplatePlaceholders(
content: string,

View File

@@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod;
const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
@@ -1406,6 +1406,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
// neither the denominator nor the numerator (mirrors the heading
// exclusion below). Project-code-aware via phaseKeyFromDir.
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue;
// phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local.
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
const key = m ? m[1].toLowerCase() : dir;
if (!seenPhaseNums.has(key)) {
@@ -1441,8 +1442,8 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
// truth for total_phases (#549).
let roadmapPhaseCount = 0;
if (roadmapScope !== null) {
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
// Only count tokens that contain at least one digit — excludes
@@ -2394,7 +2395,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
if (completed) diskCompletedPhases++;
// Track the highest phase with incomplete plans (or any plans)
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
if (phaseMatch && plans > 0) {
if (summaries < plans) {
// Incomplete phase — this is likely the current one
@@ -2418,8 +2419,8 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
try {
let roadmapPhaseCount = 0;
if (syncRoadmapScope !== null) {
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
// Only count tokens that contain at least one digit — excludes

View File

@@ -29,6 +29,9 @@ const { planningDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -82,7 +85,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
.sort();
for (const dir of dirs) {
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
const phaseDir = path.join(phasesDir, dir);
const files = fs.readdirSync(phaseDir);

View File

@@ -33,7 +33,7 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
@@ -62,7 +62,7 @@ export function canonicalPlanStem(stem: string): string {
// #2043: the plan component (after the phase number) must be zero-padded
// (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
// for a "46-6" phase/plan pair.
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i);
const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
return m ? m[1] : stem;
}
@@ -113,8 +113,8 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
const roadmapPhaseVariants = new Set<string>();
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);

View File

@@ -26,6 +26,7 @@ import { PACKAGE_NAME } from './package-identity.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
import { isCanonicalPlanningFile } from './artifacts.cjs';
import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheck = require('./agent-install-check.cjs');
const { checkAgentsInstalled } = agentInstallCheck;
@@ -37,7 +38,7 @@ import configLoaderMod = require('./config-loader.cjs');
const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
@@ -205,8 +206,13 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni
// while a prose echo on the same line is still caught.
const cmdSpanRe =
/grep(?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+\s+(?:'[^']*'|"[^"]*"|[^\s'"|>&;]+)[^\n]*?(?:==|-eq|=)\s*0\b/g;
// Security scan: must see the FULL text up to the first </action> — including a
// malformed inner <action> — so a grep-echo-0 trick cannot hide behind a
// deliberately-unclosed tag. Use a bounded to-first-close scan (ReDoS-safe via
// the {0,20000} cap, #2128), NOT the stop-at-next-open extractTaggedBlocks seam
// (which would drop the span before an unterminated inner <action>).
const actionZones: string[] = [];
const actionRe = /<action>([\s\S]*?)<\/action>/g;
const actionRe = /<action>([\s\S]{0,20000}?)<\/action>/g;
let acm: RegExpExecArray | null;
while ((acm = actionRe.exec(text)) !== null) actionZones.push(acm[1]);
const scannableActionText = actionZones.map((zone) => zone.replace(cmdSpanRe, ' ')).join('\n');
@@ -364,32 +370,21 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[]
gateText: string; // <verify>+<automated>+<acceptance_criteria> text
reqText: string; // <action>+<acceptance_criteria> text (requirement side)
}
const taskRe = /<task[^>]*>([\s\S]*?)<\/task>/g;
const tasks: TaskInfo[] = [];
let tm: RegExpExecArray | null;
while ((tm = taskRe.exec(text)) !== null) {
const tc = tm[1];
for (const tc of extractTaggedBlocks(text, 'task', true)) {
// Extract task name.
const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
const name = namem ? namem[1].trim() : 'unnamed';
const namem = extractTaggedBlocks(tc, 'name');
const name = namem.length ? namem[0].trim() : 'unnamed';
// Extract <files> entries.
const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
const filesText = filesm ? filesm[1] : '';
const filesArr = extractTaggedBlocks(tc, 'files');
const filesText = filesArr.length ? filesArr[0] : '';
const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
// Gate text: <verify>/<automated>/<acceptance_criteria>.
const gateFragments: string[] = [];
for (const tag of ['verify', 'automated', 'acceptance_criteria']) {
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
let mm: RegExpExecArray | null;
while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]);
}
for (const tag of ['verify', 'automated', 'acceptance_criteria']) gateFragments.push(...extractTaggedBlocks(tc, tag));
// Requirement text: <action>/<acceptance_criteria>.
const reqFragments: string[] = [];
for (const tag of ['action', 'acceptance_criteria']) {
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
let mm: RegExpExecArray | null;
while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]);
}
for (const tag of ['action', 'acceptance_criteria']) reqFragments.push(...extractTaggedBlocks(tc, tag));
// Strip XML tags from gate text so segments containing embedded
// XML closing tags (e.g. <automated>cmd</automated> nested inside <verify>)
// don't bleed into the file-path token extraction.
@@ -577,19 +572,16 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`);
}
const taskPattern = /<task[^>]*>([\s\S]*?)<\/task>/g;
const tasks: Record<string, unknown>[] = [];
let taskMatch: RegExpExecArray | null;
while ((taskMatch = taskPattern.exec(content)) !== null) {
const taskContent = taskMatch[1];
const nameMatch = taskContent.match(/<name>([\s\S]*?)<\/name>/);
const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
for (const taskContent of extractTaggedBlocks(content, 'task', true)) {
const nameArr = extractTaggedBlocks(taskContent, 'name');
const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed';
const hasFiles = /<files>/.test(taskContent);
const hasAction = /<action>/.test(taskContent);
const hasVerify = /<verify>/.test(taskContent);
const hasDone = /<done>/.test(taskContent);
if (!nameMatch) errors.push('Task missing <name> element');
if (nameArr.length === 0) errors.push('Task missing <name> element');
if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
@@ -1073,7 +1065,7 @@ function checkMilestonePrefixMismatches(
): MilestoneMismatch[] {
const mismatches: MilestoneMismatch[] = [];
const sections: { version: string; start: number; end: number }[] = [];
const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim;
const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim;
let m: RegExpExecArray | null;
while ((m = sectionRx.exec(roadmapContent)) !== null) {
if (sections.length > 0) sections[sections.length - 1].end = m.index;
@@ -1081,8 +1073,8 @@ function checkMilestonePrefixMismatches(
}
for (const section of sections) {
const content = roadmapContent.slice(section.start, section.end);
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
let pm: RegExpExecArray | null;
while ((pm = phaseRx.exec(content)) !== null) {
const phaseId = pm[1];
@@ -1302,14 +1294,18 @@ function cmdValidateHealth(
repairs.push('regenerateState');
} else {
const stateContent = fs.readFileSync(statePath, 'utf-8');
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(
const phaseRefs = [
...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')),
].map(
(m) => m[1],
);
const validPhases = collectDiskPhases(planBase);
try {
if (fs.existsSync(roadmapPath)) {
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
const all = [
...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')),
];
for (const m of all) validPhases.add(m[1]);
}
} catch {
@@ -1808,8 +1804,8 @@ function cmdValidateHealth(
if (isMarkedComplete) {
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const unstarted: string[] = [];
let pm: RegExpExecArray | null;
// Non-hoisted: load-order matters (circular dep guard)
@@ -2090,7 +2086,7 @@ function cmdVerifySchemaDrift(
const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
for (const pf of planFiles) {
const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
const fmMatch = content.match(/files_modified:\s*\[([^\]]*)\]/);
const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
if (fmMatch) {
const files = fmMatch[1].split(',').map((f) => f.trim()).filter(Boolean);
allFiles.push(...files);

19
tests/_ff_lint_clean.cjs Normal file
View File

@@ -0,0 +1,19 @@
// PERMANENT LOAD-BEARING FIXTURE for #1259 / #2126 — DO NOT delete or rename to `*.test.cjs`.
//
// A KNOWN-CLEAN, lint-scoped `.cjs` companion to `_ff_lint_violation.cjs`. It has NO
// `local/no-source-grep` violation, so the prohibition-enforcement real-runner tests can use it as
// the "clean target" for a NON-VACUOUS pass — the rule RUNS on it (enabled via the flat-config
// block below) and finds nothing.
//
// Why a `.cjs` and not `src/clock.cts`: linting a `src/**/*.cts` file is type-aware
// (`recommendedTypeChecked` + `parserOptions.project`), which loads the whole `tsconfig.build.json`
// program on every eslint spawn (~2s, CPU-heavy). The real-runner tests spawn eslint repeatedly and,
// under `--test-concurrency`, those full-program type-checks oversubscribe the bench CPU and blow the
// 60s subprocess bound (#2126). A plain `.cjs` is linted non-type-aware (~0.8s) — same coverage of
// the AST-only `no-source-grep` rule, no starvation.
//
// PLAIN `.cjs` (NOT `*.test.cjs`) on purpose — same reason as the violation fixture: keep it OFF the
// `node --test` runner glob so it is only ever linted, never executed.
'use strict';
module.exports = {};

View File

@@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)');
});
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/none — #1943)', () => {
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/none — #1943/#2091)', () => {
assert.ok(VALID_EXTENSION_EVENTS instanceof Set);
for (const v of ['opencode', 'pi', 'none']) {
for (const v of ['opencode', 'pi', 'hermes', 'none']) {
assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 3);
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 4);
});
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {

View File

@@ -0,0 +1,168 @@
'use strict';
/**
* cline beforeTool plugin UPGRADE — ADR-1239 / #2090 AC (upgrade 1).
*
* Proves the `.clinerules/hooks/PreToolUse` file-convention planning-artifact
* guard is re-implemented as a real Cline SDK `AgentPlugin.hooks.beforeTool`
* handler, delivered through the negotiated `hookBus: host` interface point.
* Guard semantics are preserved EXACTLY from the PreToolUse script: fail-open,
* cancel (skip) write-class calls targeting `.planning/`, pass through
* everything else.
*
* The adapter is exercised directly (mocked SDK payload shape) since the real
* `@cline/sdk` is a fast-moving package set not linked at test time — same
* pattern as the VS Code reference binding (tests/fixtures/vscode-host-binding.cjs).
*
* Cite:
* https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
* — "Lifecycle hooks ... include beforeRun, afterRun, beforeModel,
* afterModel, beforeTool, afterTool, and onEvent."
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
* — beforeTool({ tool, input }) => { skip: true, reason } | undefined
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
WRITE_TOOL_PATTERN,
PLANNING_PATH_PATTERN,
PLANNING_GUARD_REASON,
evaluateBeforeTool,
clineGsdPlugin,
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
// -- upgrade 1: write-class detection ---------------------------------------
test('WRITE_TOOL_PATTERN matches the write-class tool verbs (parity with PreToolUse)', () => {
const writeVerbs = ['write_to_file', 'edit_file', 'replace_in_file', 'create_file',
'delete_file', 'remove_file', 'append_to_file', 'apply_patch', 'insert_edit', 'mkdir'];
for (const v of writeVerbs) {
assert.ok(WRITE_TOOL_PATTERN.test(v), `write-class verb must match: ${v}`);
}
});
test('WRITE_TOOL_PATTERN does NOT match read-class tools', () => {
const readVerbs = ['read_file', 'list_files', 'search_files', 'execute_command', 'ask_user'];
for (const v of readVerbs) {
assert.ok(!WRITE_TOOL_PATTERN.test(v), `read-class verb must NOT match: ${v}`);
}
});
// -- upgrade 1: planning-path detection -------------------------------------
test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separators', () => {
const planningPaths = ['.planning/state.md', '.planning/phases/1/PLAN.md',
'/home/u/proj/.planning/config.json', 'proj\\.planning\\foo', './.planning/x'];
for (const p of planningPaths) {
assert.ok(PLANNING_PATH_PATTERN.test(p), `planning path must match: ${p}`);
}
});
test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => {
// .planning-readme.txt must not match (boundary after .planning required)
for (const p of ['src/planning-utils.ts', 'docs/plan.md']) {
assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`);
}
});
// -- upgrade 1: evaluateBeforeTool — the guard decision ----------------------
test('write-class tool targeting .planning/ is SKIPPED (cancel)', () => {
const result = evaluateBeforeTool({
tool: { name: 'write_to_file' },
input: { path: '.planning/phases/1/PLAN.md', content: '...' },
});
assert.equal(result.decision, 'skip');
assert.equal(result.reason, PLANNING_GUARD_REASON);
});
test('write-class tool targeting a NON-planning path is ALLOWED', () => {
const result = evaluateBeforeTool({
tool: { name: 'write_to_file' },
input: { path: 'src/index.ts', content: '...' },
});
assert.equal(result.decision, 'allow');
assert.equal(result.reason, undefined);
});
test('read-class tool targeting .planning/ is ALLOWED (reads are safe)', () => {
const result = evaluateBeforeTool({
tool: { name: 'read_file' },
input: { path: '.planning/state.md' },
});
assert.equal(result.decision, 'allow');
});
test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', () => {
// A doc that merely mentions ".planning/" in its body is never falsely blocked —
// only PATH-bearing field values are inspected (parity with PreToolUse).
const result = evaluateBeforeTool({
tool: { name: 'write_to_file' },
input: { path: 'docs/guide.md', content: 'see .planning/ for details' },
});
assert.equal(result.decision, 'allow');
});
test('write tool with .planning/ in a recognized nested path-key is SKIPPED', () => {
// The guard walks the input object tree collecting values from PATH-keyed
// fields (path|file|target|dir|...). A recognized key nested anywhere in the
// payload is caught — parity with the PreToolUse hook's bounded walk.
const result = evaluateBeforeTool({
tool: { name: 'apply_patch' },
input: { target: '.planning/config.json' },
});
assert.equal(result.decision, 'skip');
});
// -- upgrade 1: fail-open on malformed/missing input ------------------------
test('evaluateBeforeTool fails OPEN on null tool/input (never throws, never blocks)', () => {
assert.equal(evaluateBeforeTool({ tool: null, input: null }).decision, 'allow');
assert.equal(evaluateBeforeTool({}).decision, 'allow');
assert.equal(evaluateBeforeTool(null).decision, 'allow');
});
test('evaluateBeforeTool fails OPEN on a tool with no name', () => {
assert.equal(evaluateBeforeTool({ tool: {}, input: { path: '.planning/x' } }).decision, 'allow');
});
// -- upgrade 1: the AgentPlugin wrapper shape -------------------------------
test('clineGsdPlugin is an AgentPlugin with a beforeTool hook', () => {
assert.equal(typeof clineGsdPlugin, 'object');
assert.equal(clineGsdPlugin.name, 'gsd-planning-guard');
assert.equal(typeof clineGsdPlugin.setup, 'function');
});
test('clineGsdPlugin.setup returns hooks.beforeTool that maps skip→{skip,reason}', () => {
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
assert.equal(typeof hooks.beforeTool, 'function');
// planning write → { skip: true, reason }
const blocked = hooks.beforeTool({
tool: { name: 'write_to_file' },
input: { path: '.planning/state.md' },
});
assert.deepEqual(blocked, { skip: true, reason: PLANNING_GUARD_REASON });
});
test('clineGsdPlugin.beforeTool returns undefined for allowed calls (SDK contract)', () => {
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
const allowed = hooks.beforeTool({
tool: { name: 'write_to_file' },
input: { path: 'src/foo.ts' },
});
assert.equal(allowed, undefined, 'undefined = allow (Cline SDK beforeTool contract)');
});
// -- upgrade 1: parity with the existing PreToolUse script semantics --------
test('the guard reason matches the PreToolUse script errorMessage contract', () => {
// The file-convention hook wrote { cancel:true, errorMessage:'GSD: ...' }.
// The SDK plugin maps cancel→skip and errorMessage→reason. The user-visible
// message text is preserved so the guard behaves identically to the user.
assert.ok(typeof PLANNING_GUARD_REASON === 'string' && PLANNING_GUARD_REASON.length > 0);
assert.ok(PLANNING_GUARD_REASON.includes('.planning/'),
'reason must explain the .planning/ protection so the user can act on it');
});

View File

@@ -0,0 +1,101 @@
'use strict';
/**
* cline dispatch DEGRADATION — ADR-1239 / #2090.
*
* Proves cline's dispatch STAYS degraded/flat and is NEVER silently upgraded to
* the programmatic-cli profile baseline's full nested/background dispatch.
* Cline's own docs (docs/features/subagents.mdx) restrict subagents to a single
* level, read-only toolkit, no nested spawning — so claiming full dispatch would
* misrepresent a documented host restriction and violate the fail-closed
* negotiation contract. This is the cline counterpart to cursor's dispatch
* UPGRADE (#2089), asserting the OPPOSITE invariant: cline flattens.
*
* Cite:
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
* — "subagents are restricted from editing files, using the browser,
* accessing MCP servers, or creating nested subagents."
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
* — "They are explicitly prohibited from ... spawning other subagents."
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
shouldFlattenDispatch,
degradationFor,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const CLN_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
);
const CLN_DISPATCH = CLN_CAP.runtime.hostIntegration.dispatch;
// -- cline dispatch axes: documented restrictions ----------------------------
test('cline dispatch declares namedDispatch but NOT nested (single-level only)', () => {
assert.equal(CLN_DISPATCH.namedDispatch, true,
'cite agents-squad example — start_subagent(preset:..., task:...) named dispatch');
assert.equal(CLN_DISPATCH.nested, false,
'cite subagents.mdx — subagents cannot create nested subagents');
});
test('cline dispatch respects maxDepth: 1 (the documented hard limit)', () => {
assert.equal(CLN_DISPATCH.maxDepth, 1,
'cite subagents.mdx — "explicitly prohibited from ... spawning other subagents"');
});
test('cline subagentToolkit is read-only (no write/browser/mcp for subagents)', () => {
assert.equal(CLN_DISPATCH.subagentToolkit, 'read-only',
'cite subagents.mdx — "strictly limited to read-only operations"');
});
test('cline backgroundDispatch is false (background commands run, but no nested-dispatch)', () => {
assert.equal(CLN_DISPATCH.background, true,
'cite subagents.mdx — "Commands executed by subagents run in the background"');
assert.equal(CLN_DISPATCH.backgroundDispatch, false,
'cite subagents.mdx — cannot spawn nested subagents from a background context');
});
// -- shouldFlattenDispatch: cline MUST flatten (degraded) --------------------
test('shouldFlattenDispatch(cline) is true — waves run inline (the #853 rule)', () => {
assert.equal(shouldFlattenDispatch(CLN_DISPATCH), true,
'cline has backgroundDispatch:false → GSD must force-flatten (run inline)');
});
test('a hypothetical full-upgrade (backgroundDispatch:true) would NOT flatten — proving the discriminator', () => {
const hypothetical = { ...CLN_DISPATCH, backgroundDispatch: true, nested: true, maxDepth: 2, subagentToolkit: 'full' };
assert.equal(shouldFlattenDispatch(hypothetical), false,
'only background:true AND backgroundDispatch:true escapes flattening — cline lacks both');
});
// -- degradationFor: cline dispatch is 'degraded' / flat ---------------------
test('degradationFor("dispatch", cline) is degraded with the flat-dispatch fallback', () => {
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
assert.equal(result.level, 'degraded',
'maxDepth:1 (flat) is a degraded dispatch surface, never full');
assert.equal(result.fallback, 'flat dispatch — waves run inline');
assert.notEqual(result.level, 'full',
'cline dispatch must NEVER be classified as full — that would misrepresent the host');
});
test('cline dispatch is never silently upgraded to the programmatic-cli baseline', () => {
// The full baseline requires nested + maxDepth>=2 + subagentToolkit 'full'.
// Cline violates ALL three (nested:false, maxDepth:1, read-only) — so it must
// stay degraded regardless of any negotiation defaults.
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
assert.notEqual(result.level, 'full');
});
// -- boundary: maxDepth 1 vs 0 vs -1 ----------------------------------------
test('maxDepth 1 is flat (NOT absent, NOT unbounded)', () => {
assert.ok(CLN_DISPATCH.maxDepth > 0, 'maxDepth must be positive (not absent/single-agent)');
assert.notEqual(CLN_DISPATCH.maxDepth, -1, 'cline is NOT unbounded — depth-1 is the documented limit');
assert.notEqual(CLN_DISPATCH.maxDepth, 0, 'maxDepth:0 would mean no named dispatch — cline HAS named dispatch');
});

View File

@@ -0,0 +1,113 @@
// allow-test-rule: AC2 requires asserting no `runtime === 'cline'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2090)
'use strict';
/**
* cline imperative reference host — ADR-1239 Phase D / #2090 (EoS/cline).
*
* Proves cline is driven through the PUBLIC Host-Integration Interface (the
* imperative adapter), that its negotiated axes classify + negotiate correctly,
* that negotiation fails CLOSED on a corrupted descriptor, that the
* Context7-sourced dispatch classification STAYS degraded/flat (cline subagents
* are documented as single-level read-only — maxDepth:1 — and must never be
* silently upgraded to full nested/background dispatch), and that the migration
* retired the hardcoded `runtime === 'cline'` / `isCline` branches (folded into
* descriptor-driven `runtime.hostBehaviors`).
*
* Contrast with cursor (#2089): cursor's dispatch got an UPGRADE (background +
* nested). cline's dispatch is a deliberate DEGRADATION that must be preserved —
* upgrading it would misrepresent a documented host restriction and violate the
* fail-closed negotiation contract (see dispatch-degradation test).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const {
profileOf,
negotiateHostCapabilities,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const CLN_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
);
const CLN_AXES = CLN_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (imperative adapter) -----------
test('createImperativeAdapter classifies cline as imperative + composes the registry', () => {
const adapter = createImperativeAdapter({ runtime: 'cline' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'cline');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('cline axes classify as the programmatic-cli reference profile (imperative embedding)', () => {
assert.equal(profileOf(CLN_AXES), 'programmatic-cli');
});
// -- AC3: all axes populated + validated -------------------------------------
test('cline descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
assert.equal(CLN_AXES.embeddingMode, 'imperative');
assert.equal(CLN_AXES.commandSurface, 'slash-file');
assert.equal(CLN_AXES.modelMode, 'active');
assert.equal(CLN_AXES.hookBus, 'host');
assert.equal(CLN_AXES.stateIO, 'filesystem');
assert.equal(CLN_AXES.transport, 'mcp');
assert.equal(CLN_AXES.runtime, 'node');
const d = CLN_AXES.dispatch;
assert.equal(d.namedDispatch, true);
assert.equal(d.nested, false);
assert.equal(d.maxDepth, 1);
assert.equal(d.background, true);
assert.equal(d.subagentToolkit, 'read-only');
assert.equal(d.backgroundDispatch, false);
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for cline, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: 'future-unknown' }));
});
test('a partial/empty cline descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
assert.ok(result.warnings.length > 0);
});
// -- AC2: the hardcoded branches are retired ---------------------------------
test('cline descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = CLN_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
assert.equal(hb.frontmatterDialect, 'cline');
assert.equal(hb.skipSharedHooksInstall, true);
assert.equal(hb.localTargetIsProjectRoot, true);
assert.equal(hb.clineRulesSurface, true);
assert.equal(hb.localCommandsViaRules, true);
});
test('no `runtime === "cline"` string-equality branch remains in the install source (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) {
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cline'/g) || [];
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cline' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
}
});

View File

@@ -0,0 +1,161 @@
'use strict';
/**
* cline createAgentModel UPGRADE — ADR-1239 / #2090 AC (upgrade 2).
*
* Proves GSD's per-subagent `model_overrides` / `model_profile_overrides`
* resolution (already used for OpenCode/Codex) now applies to cline subagents
* via `DefaultGateway.createAgentModel({ providerId, modelId })`, instead of
* leaving model selection untouched. Cline's `modelMode: active` (the host
* exposes provider registration via createLlmsRuntime) is what makes this
* wiring possible — passive hosts can only inject a per-agent model field.
*
* The binding resolves the createAgentModel call parameters from GSD config;
* the real gateway call is the host's responsibility (mocked here, same pattern
* as tests/fixtures/vscode-host-binding.cjs).
*
* Cite:
* https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
* — createAgentModel({ providerId, modelId }) returns an AgentModel
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
* — createLlmsRuntime(...) provider registry (modelMode: active)
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
resolveClineAgentModelParams,
inferProviderId,
DEFAULT_CLINE_PROVIDER_ID,
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
// -- upgrade 2: provider inference from a model id --------------------------
test('inferProviderId maps anthropic model ids to "anthropic"', () => {
assert.equal(inferProviderId('claude-sonnet-4-5'), 'anthropic');
assert.equal(inferProviderId('claude-opus-4-1'), 'anthropic');
});
test('inferProviderId maps openai model ids to "openai"', () => {
assert.equal(inferProviderId('gpt-4o'), 'openai');
assert.equal(inferProviderId('o1-preview'), 'openai');
});
test('inferProviderId falls back to DEFAULT_CLINE_PROVIDER_ID for unknown ids', () => {
assert.equal(inferProviderId('some-custom-model'), DEFAULT_CLINE_PROVIDER_ID);
assert.equal(inferProviderId(''), DEFAULT_CLINE_PROVIDER_ID);
});
// -- upgrade 2: model_overrides resolution ----------------------------------
test('a per-agent model_overrides entry resolves to createAgentModel params', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { planner: 'claude-sonnet-4-5' },
modelProfileOverrides: null,
profile: 'balanced',
});
assert.deepEqual(result, { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
});
test('model_overrides takes precedence over model_profile_overrides', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { planner: 'claude-sonnet-4-5' },
modelProfileOverrides: { balanced: { planner: 'claude-opus-4-1' } },
profile: 'balanced',
});
assert.equal(result.modelId, 'claude-sonnet-4-5', 'direct model_overrides wins');
});
test('model_profile_overrides resolves when no direct model_overrides entry exists', () => {
const result = resolveClineAgentModelParams({
agentType: 'executor',
modelOverrides: null,
modelProfileOverrides: { balanced: { executor: 'gpt-4o' } },
profile: 'balanced',
});
assert.deepEqual(result, { providerId: 'openai', modelId: 'gpt-4o' });
});
// -- upgrade 2: no override → null (gateway default applies) ----------------
test('returns null when no override is configured (gateway picks the default model)', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: null,
modelProfileOverrides: null,
profile: 'balanced',
});
assert.equal(result, null, 'null = no override; host gateway default applies');
});
test('returns null when the agentType has no matching override', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { executor: 'claude-sonnet-4-5' },
modelProfileOverrides: null,
profile: 'balanced',
});
assert.equal(result, null);
});
// -- upgrade 2: the gateway binding (createAgentModel call shape) -----------
test('a resolved override drives DefaultGateway.createAgentModel with the right params', () => {
// Simulate the host gateway (mocked — the real @cline/sdk is not linked here).
const calls = [];
const fakeGateway = {
createAgentModel(selection) { calls.push(selection); return { providerId: selection.providerId, modelId: selection.modelId }; },
};
const params = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { planner: 'claude-sonnet-4-5' },
modelProfileOverrides: null,
profile: 'balanced',
});
assert.ok(params, 'override must resolve to non-null params');
const model = fakeGateway.createAgentModel(params);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
assert.equal(model.modelId, 'claude-sonnet-4-5');
});
test('no override → createAgentModel is NOT called (gateway default, not GSD override)', () => {
const calls = [];
const fakeGateway = {
createAgentModel(selection) { calls.push(selection); return {}; },
};
const params = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: null,
modelProfileOverrides: null,
profile: 'balanced',
});
if (params) fakeGateway.createAgentModel(params);
assert.equal(calls.length, 0, 'no override → gateway must use its own default, GSD does not call createAgentModel');
});
// -- upgrade 2: fail-safe / malformed config --------------------------------
test('malformed override values (non-string) are ignored (fail-safe, not crash)', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { planner: 42, executor: 'claude-sonnet-4-5' },
modelProfileOverrides: null,
profile: 'balanced',
});
// planner's non-string override is ignored; falls through to null (no executor match for agentType planner)
assert.equal(result, null);
});
test('empty-string override is treated as absent', () => {
const result = resolveClineAgentModelParams({
agentType: 'planner',
modelOverrides: { planner: '' },
modelProfileOverrides: null,
profile: 'balanced',
});
assert.equal(result, null);
});

View File

@@ -13,11 +13,95 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// In-process invocation, not execFileSync: cmdConfigGet is a pure CJS
// function reachable without spawning `node` as a child. The prior
// execFileSync(..., { timeout: 5000 }) raced a real subprocess's startup
// (full node boot + gsd-tools.cjs's large eager require graph — capability
// registry, phase/roadmap/agent/check/task routers, verify.cjs,
// cli-skew-check, findProjectRoot, etc.) against a fixed 5s wall clock, with
// no retry. Under Docker host contention that wall clock loses
// nondeterministically (ETIMEDOUT) — a test-harness race, not a product
// defect. bin/lib/config.cjs requires none of that dispatcher machinery, so
// calling cmdConfigGet directly removes the subprocess-spawn cost and the
// wall-clock race entirely: no timeout of any size can flake this.
const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config.cjs'));
// io.cjs owns error()/output() and the JSON-error-mode toggle. cmdConfigGet's `error`
// is bound to io.error at load, so we drive io directly to (a) get structured stderr
// we can assert a typed `reason` on, and (b) restore the mode after each error probe.
const io = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'));
/**
* cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) calls process.exit(1)
* directly (it predates the ExitError/runMain seam used by the CLI
* entrypoint's non-error paths). Intercepting process.exit with a throwable
* sentinel lets the error path be exercised in-process without killing the
* test worker.
*
* The sentinel carries the ORIGINAL error message (not a generic "process.exit(1)").
* That matters for cmdConfigGet's "no config.json" branch, whose `error()` sits inside
* a try/catch that reclassifies any throw NOT starting with "No config.json" as a parse
* failure (a guard that is dead in production, where process.exit terminates first, but
* becomes live once process.exit is a throwing seam). Carrying the real message makes
* that guard re-throw — modeling the single, faithful production termination instead of
* a spurious second error() call with the wrong reason.
*/
class _ExitSignal extends Error {
constructor(code, message) {
super(message ?? `process.exit(${code})`);
this.code = code;
}
}
/**
* bin/lib/io.cjs's output()/error() write directly to the raw fd (1 or 2)
* via fs.writeSync — they bypass console.log entirely, so
* tests/helpers.cjs's captureConsole() cannot observe them (see
* tests/io.test.cjs: "output() writes directly to fd 1"). Monkeypatch
* fs.writeSync itself — save the original, override, restore in a finally,
* the project's standard IO capture/fault-injection seam — to capture what
* would have hit the fd.
*/
function captureFdWrite(fd, fn) {
const orig = fs.writeSync;
let captured = Buffer.alloc(0);
fs.writeSync = (writeFd, ...rest) => {
if (writeFd !== fd) return orig.call(fs, writeFd, ...rest);
const [data, offset = 0, length] = rest;
const chunk = Buffer.isBuffer(data)
? data.subarray(offset, offset + (length ?? data.length - offset))
: Buffer.from(String(data), 'utf8');
captured = Buffer.concat([captured, chunk]);
return chunk.length;
};
try {
fn();
} finally {
fs.writeSync = orig;
}
return captured.toString('utf-8');
}
/**
* Parse a CLI-style config-get argv (mirrors gsd-core/bin/gsd-tools.cjs's
* 'config-get' case: key is args[1], optional --default <value>, optional
* --raw) into cmdConfigGet's positional params. Keeps the test bodies below
* expressed in the same CLI-args vocabulary they always were.
*/
function parseConfigGetArgs(args) {
const rest = args.slice(1); // drop the leading 'config-get'
let raw = false;
let defaultValue;
const positional = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] === '--raw') { raw = true; continue; }
if (rest[i] === '--default') { defaultValue = rest[i + 1] ?? ''; i++; continue; }
positional.push(rest[i]);
}
return { keyPath: positional[0], raw, defaultValue };
}
describe('config-get --default flag (#1893)', () => {
let tmpDir;
@@ -34,10 +118,11 @@ describe('config-get --default flag (#1893)', () => {
});
function run(...args) {
return execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], {
encoding: 'utf-8',
timeout: 5000,
}).trim();
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const out = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
});
return out.trim();
}
function runRaw(...args) {
@@ -45,22 +130,55 @@ describe('config-get --default flag (#1893)', () => {
}
function runExpectError(...args) {
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const origExit = process.exit;
const origWriteSync = fs.writeSync;
io.setJsonErrorMode(true); // structured stderr line lets the sentinel carry the message + assert reason
let exitCount = 0;
let exitCode;
let stderr = '';
fs.writeSync = (fd, ...rest) => {
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
const [data, offset = 0, length] = rest;
const chunk = Buffer.isBuffer(data)
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
: String(data);
stderr += chunk;
return Buffer.byteLength(chunk);
};
const lastError = () => {
const parts = stderr.split('\n').filter(Boolean);
try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; }
};
process.exit = (code) => {
exitCount++;
exitCode = code;
// Carry the just-emitted error message so cmdConfigGet's seam guard re-throws
// (single, faithful fire) instead of catching + reclassifying into a 2nd error().
throw new _ExitSignal(code, lastError().message);
};
try {
execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], {
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
assert.fail('Expected command to exit non-zero');
} catch (err) {
assert.ok(err.status !== 0, 'Expected non-zero exit code');
return err;
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
} catch (e) {
if (!(e instanceof _ExitSignal)) throw e;
} finally {
process.exit = origExit;
fs.writeSync = origWriteSync;
io.setJsonErrorMode(false);
}
assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code');
// Faithfulness guard: production process.exit terminates, so error() fires exactly
// once. A count of 2 means the throwing-exit seam was caught + reclassified (the bug
// this harness redesign fixes) — fail loudly rather than report a wrong reason.
assert.equal(exitCount, 1, 'error() must fire exactly once (production process.exit terminates)');
const payload = lastError();
return { status: exitCode, reason: payload.reason, message: payload.message, stderr };
}
test('absent key without --default errors', () => {
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
runExpectError('config-get', 'nonexistent.key', '--raw');
const { reason } = runExpectError('config-get', 'nonexistent.key', '--raw');
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'absent key must report CONFIG_KEY_NOT_FOUND');
});
test('absent key with --default returns default value', () => {
@@ -99,7 +217,8 @@ describe('config-get --default flag (#1893)', () => {
test('missing config.json without --default errors', () => {
// No config.json written
runExpectError('config-get', 'any.key', '--raw');
const { reason } = runExpectError('config-get', 'any.key', '--raw');
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'missing config.json must report CONFIG_NO_FILE');
});
test('--default works with JSON output (no --raw)', () => {

View File

@@ -0,0 +1,73 @@
'use strict';
/**
* hermes dispatch UPGRADE — ADR-1239 / #2091.
*
* Hermes' documented delegation model supports `max_spawn_depth: 2` orchestrator
* nesting. The descriptor carries dispatch axes that reflect this. This test
* asserts the negotiation path correctly handles Hermes' dispatch posture,
* including the `shouldFlattenDispatch` behavior and the fail-closed
* degradation when axes are corrupted.
*
* Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/guides/delegation-patterns.md
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
negotiateHostCapabilities,
shouldFlattenDispatch,
degradationFor,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const HERMES_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'),
);
const HERMES_AXES = HERMES_CAP.runtime.hostIntegration;
const HERMES_DISPATCH = HERMES_AXES.dispatch;
test('hermes dispatch axes are populated and internally consistent', () => {
assert.equal(HERMES_DISPATCH.nested, true, 'hermes supports nested dispatch');
assert.ok(HERMES_DISPATCH.maxDepth >= 1, 'maxDepth must be >= 1');
assert.ok(typeof HERMES_DISPATCH.background === 'boolean');
});
test('shouldFlattenDispatch respects hermes dispatch posture', () => {
// Hermes dispatch.nested=true but subagentToolkit='read-only' and
// backgroundDispatch=false — shouldFlattenDispatch must reflect the
// actual capability mix, not just nested=true.
const result = shouldFlattenDispatch(HERMES_DISPATCH);
assert.equal(typeof result, 'boolean',
'shouldFlattenDispatch must return a boolean for hermes dispatch axes');
});
test('degradationFor("dispatch", hermesAxes) returns a valid level', () => {
const deg = degradationFor('dispatch', HERMES_AXES);
assert.ok(deg, 'degradationFor must return a result for dispatch');
assert.ok(['full', 'degraded', 'absent'].includes(deg.level),
`dispatch level must be full/degraded/absent, got: ${deg.level}`);
assert.ok(typeof deg.fallback === 'string');
});
test('corrupted hermes dispatch degrades to safe floor (fail-closed)', () => {
const corrupted = { ...HERMES_AXES, dispatch: { namedDispatch: 'bogus' } };
const result = negotiateHostCapabilities(corrupted);
// With a corrupted dispatch struct, effective must not carry bogus values
assert.equal(typeof result.effective.dispatch.namedDispatch, 'boolean');
assert.equal(result.effective.dispatch.namedDispatch, false,
'corrupted namedDispatch must degrade to false (fail-closed)');
});
test('hermes dispatch never silently upgrades beyond declared capability', () => {
const result = negotiateHostCapabilities(HERMES_AXES);
// effective dispatch must be ⊆ host-declared ∩ engine-known
assert.ok(result.effective.dispatch.maxDepth <= HERMES_DISPATCH.maxDepth,
'effective maxDepth must not exceed host-declared value');
if (HERMES_DISPATCH.backgroundDispatch === false) {
assert.equal(result.effective.dispatch.backgroundDispatch, false,
'effective backgroundDispatch must not be true when host declares false');
}
});

View File

@@ -0,0 +1,132 @@
// allow-test-rule: AC2 requires asserting no `runtime === 'hermes'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2091)
'use strict';
/**
* hermes imperative reference host — ADR-1239 Phase D / #2091 (EoS/hermes).
*
* Proves hermes is driven through the PUBLIC Host-Integration Interface (the
* imperative adapter), that its negotiated axes classify + negotiate correctly,
* that negotiation fails CLOSED on a corrupted descriptor, that the
* extensionEvents UPGRADE (13 real plugin hook events replacing the borrowed
* "claude" 6-event surface) is registered, and that the migration retired the
* hardcoded `runtime === 'hermes'` / `isHermes` branches in bin/install.js
* (folded into descriptor-driven `runtime.hostBehaviors`).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const {
profileOf,
negotiateHostCapabilities,
extensionEventSurfaceFor,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const HERMES_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'),
);
const HERMES_AXES = HERMES_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (imperative adapter) -----------
test('createImperativeAdapter classifies hermes as imperative + composes the registry', () => {
const adapter = createImperativeAdapter({ runtime: 'hermes' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'hermes');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('hermes axes classify as the programmatic-cli reference profile', () => {
assert.equal(profileOf(HERMES_AXES), 'programmatic-cli');
});
// -- AC3: all axes populated + validated -------------------------------------
test('hermes descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
assert.equal(HERMES_AXES.embeddingMode, 'imperative');
assert.equal(HERMES_AXES.commandSurface, 'slash-programmatic');
assert.equal(HERMES_AXES.modelMode, 'active');
assert.equal(HERMES_AXES.hookBus, 'host');
assert.equal(HERMES_AXES.stateIO, 'filesystem');
assert.equal(HERMES_AXES.transport, 'mcp');
assert.equal(HERMES_AXES.runtime, 'python');
const d = HERMES_AXES.dispatch;
assert.equal(typeof d.namedDispatch, 'boolean');
assert.equal(typeof d.nested, 'boolean');
assert.equal(typeof d.maxDepth, 'number');
assert.equal(typeof d.background, 'boolean');
assert.ok(['full', 'read-only'].includes(d.subagentToolkit));
assert.equal(typeof d.backgroundDispatch, 'boolean');
});
// -- AC4a: extensionEvents UPGRADE — 13 real events, not borrowed claude -----
test('hermes descriptor declares extensionEvents: "hermes" (not the borrowed "claude" hookEvents)', () => {
assert.equal(HERMES_CAP.runtime.extensionEvents, 'hermes',
'descriptor must declare extensionEvents: "hermes" — the real plugin hook vocabulary');
});
test('extensionEventSurfaceFor("hermes") returns all 13 documented events', () => {
const surface = extensionEventSurfaceFor('hermes');
assert.ok(surface, 'hermes extensionEvents surface must be registered');
const expectedEvents = [
'pre_tool_call', 'post_tool_call',
'pre_llm_call', 'post_llm_call',
'on_session_start', 'on_session_end',
'on_session_finalize', 'on_session_reset',
'subagent_start', 'subagent_stop',
'pre_gateway_dispatch', 'pre_approval_request',
'transform_tool_result',
];
assert.equal(surface.length, 13, 'exactly 13 documented Hermes plugin events');
for (const ev of expectedEvents) {
assert.ok(surface.includes(ev), `surface must include ${ev}`);
}
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for hermes, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: 'future-unknown' }));
});
test('a partial/empty hermes descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
assert.ok(result.warnings.length > 0);
});
// -- AC2: the hardcoded branches are retired ---------------------------------
test('hermes descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = HERMES_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.skillFrontmatterVersion, true);
assert.equal(hb.skillsManifestPrefix, 'skills/gsd/');
assert.equal(hb.trackCategoryDescription, true);
assert.equal(hb.writeCategoryDescription, true);
assert.equal(hb.reportSkillsCount, true);
assert.equal(hb.legacyCommandsGsdCleanup, true);
assert.ok(hb.brandingRewrites && typeof hb.brandingRewrites === 'object');
});
test('no `runtime === "hermes"` string-equality branch remains in bin/install.js (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const offenders = strip(src).match(/runtime\s*[!=]==\s*'hermes'/g) || [];
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='hermes' branch may remain in bin/install.js; found: ${offenders.join(', ')}`);
});

View File

@@ -74,9 +74,9 @@ describe('extensionEventSurfaceFor (extension-system event dialect — #1943)',
assert.equal(extensionEventSurfaceFor('nope'), null);
assert.equal(extensionEventSurfaceFor(undefined), null);
});
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/none', () => {
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/none', () => {
assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true);
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['none', 'opencode', 'pi']);
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'none', 'opencode', 'pi']);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -98,6 +98,8 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
installContent,
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
);
}
});
}
test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
@@ -124,8 +126,6 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
}
});
});
}
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));

View File

@@ -33,6 +33,7 @@ const {
collectSection,
iterateBullets,
extractTaggedBlocks,
stripTaggedBlocks,
replaceSection,
} = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
@@ -1015,15 +1016,14 @@ describe('stripFencedCode and tokenizeHeadings: backtick info string with backti
// ─── FIX 6: extractTaggedBlocks — nested tag behavior ─────────────────────────
describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitation)', () => {
test('nested <x><x>…</x></x> closes at first </x> (non-greedy; nested tags not supported)', () => {
// Non-greedy match: <x>([\s\S]*?)</x> closes at the FIRST </x>.
// So <x><x>inner</x></x> → first block captures "<x>inner", second </x> is unmatched.
describe('extractTaggedBlocks: nested same-name tag behavior (#2128 stop-at-next-open)', () => {
test('nested <x><x>inner</x></x> extracts the well-formed inner block', () => {
// #2128: the ReDoS-safe body scan terminates at the NEXT opening <x>, so the
// unterminated outer <x> is skipped and the inner block is extracted.
const content = '<x><x>inner</x></x>';
const result = extractTaggedBlocks(content, 'x');
// The first match closes at the first </x>, capturing "<x>inner"
assert.equal(result.length, 1, 'non-greedy match produces exactly one result from nested input');
assert.equal(result[0], '<x>inner', 'inner capture is the content up to the first closing tag');
assert.equal(result.length, 1, 'exactly one result from nested input');
assert.equal(result[0], 'inner', 'the well-formed inner block is extracted; the unterminated outer is skipped');
});
test('back-to-back blocks (not nested) are both extracted', () => {
@@ -1033,6 +1033,24 @@ describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitat
assert.equal(result[0], 'first');
assert.equal(result[1], 'second');
});
test('#2128: a document full of unclosed <x> openings stays linear and yields no match', () => {
const content = '<x>a\n'.repeat(50) + 'no closing tag';
assert.deepEqual(extractTaggedBlocks(content, 'x'), [], 'no </x> anywhere -> no blocks');
});
test('#557 / #2128: attr-intolerant by default preserves <details open>; opt-in matches <task type=…>', () => {
// stripTaggedBlocks(details) must PRESERVE <details open> (the active-milestone
// marker) and strip only bare <details>; extractTaggedBlocks(task, true) must
// match attributed tasks, and must NOT when allowAttributes is left false.
assert.equal(
stripTaggedBlocks('X<details>shipped</details>Y<details open>active</details>Z', 'details'),
'XY<details open>active</details>Z',
'#557: <details open> preserved; bare <details> stripped',
);
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task', true), ['body'], 'attributed task matched with allowAttributes=true');
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task'), [], 'attributed task NOT matched with allowAttributes=false');
});
});
// Parity guard removed in T5 (ADR-1372): uat-predicate now imports stripFencedCode

View File

@@ -0,0 +1,172 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Anti-divergence guard for the phase-identifier parsing seam
* (epic #2121 Phase 4 / issue #2128, ADR-2121 Decision 7).
*
* `src/phase-id.cts` is the single canonical owner of phase-ID parsing. Two guards
* keep it that way:
* 1. DRIFT SCANNER (scripts/lint-phase-id-drift.cjs) — fails CI if any module
* outside phase-id.cts re-derives the canonical phase-number token as a
* literal without a `// phase-id-owner:` sanction.
* 2. IDENTITY guard — phase-id.cjs exports the complete locked surface, and no
* consumer re-exports a DIVERGENT copy of a canonical function (re-export,
* never re-implement).
*
* Behavioral throughout: assertions drive `findPhaseIdRegexDrift` / `scanRepo`
* and compare object identity — no `readFileSync().includes()` in a test body.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { findPhaseIdRegexDrift, scanRepo } = require(
path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'),
);
const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'));
// The locked canonical surface (ADR-2121 Decision 1/2; PHASE_NUMBER_TOKEN_SOURCE
// added in Phase 4). Every name is exported by phase-id.cjs; the identity guard
// forbids any other module from re-exporting a divergent copy of one.
const CANONICAL = [
'escapeRegex', 'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE',
'PHASE_NUMBER_TOKEN_SOURCE', 'stripProjectCodePrefix', 'normalizePhaseName',
'getMilestoneFromPhaseId', 'getPhaseDirFromPhaseId', 'phaseMarkdownRegexSource',
'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken',
'phaseTokenMatches', 'parsePhaseFromProse', 'stripConfiguredProjectCodePrefix',
'isForeignPrefixedPhaseQuery', 'roadmapPhaseLookupSources',
];
describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => {
test('a regex built from PHASE_NUMBER_TOKEN_SOURCE is NOT drift', () => {
assert.deepEqual(
findPhaseIdRegexDrift('const re = new RegExp(`Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`);'),
[],
);
});
test('a literal re-derivation of the canonical token IS flagged (fail-first)', () => {
const v = findPhaseIdRegexDrift('const re = /Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)/;');
assert.equal(v.length, 1);
assert.equal(v[0].found, '\\d+[A-Z]?(?:\\.\\d+)*');
});
test('a re-derivation inside a new RegExp template (\\\\d escaping) IS flagged', () => {
const v = findPhaseIdRegexDrift('new RegExp(`Phase\\\\s+(\\\\d+[A-Z]?(?:\\\\.\\\\d+)*)`)');
assert.equal(v.length, 1);
});
test('the [A-Za-z], [.-] and [0-9] near-variants ARE flagged (no trivial evasion)', () => {
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1, '[A-Za-z] letter class');
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1, '[.-] separator');
assert.equal(findPhaseIdRegexDrift('/([0-9]+[A-Z]?(?:\\.[0-9]+)*)/').length, 1, '[0-9] in place of \\d');
});
test('a dedicated preceding // phase-id-owner: comment line suppresses the flag', () => {
assert.deepEqual(
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
[],
);
});
test('a blank line between the // phase-id-owner: comment and the regex still suppresses', () => {
assert.deepEqual(
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
[],
);
});
test('a trailing same-line // phase-id-owner: is NOT a sanction (must be a dedicated line above)', () => {
// The marker must lead its own comment line; a trailing comment on a code
// line is not honored, so the regex is still flagged.
const v = findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: not honored here');
assert.equal(v.length, 1);
});
test('a // phase-id-owner: embedded in a STRING literal does NOT suppress (decoy)', () => {
// A `//` inside a string is not a comment — help/doc text that quotes the
// sanction syntax must not silently suppress a real re-derivation.
const decoyLine = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
assert.equal(decoyLine.length, 1);
const decoyPrev = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>";\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
assert.equal(decoyPrev.length, 1);
});
test('a bare "phase-id-owner:" substring with no // does NOT suppress', () => {
const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
assert.equal(v.length, 1);
});
test('non-token phase regexes are NOT flagged (no false positives)', () => {
assert.deepEqual(findPhaseIdRegexDrift('/^Executing Phase\\s+\\d+/'), [], 'status-message bare \\d+');
assert.deepEqual(findPhaseIdRegexDrift('/#{2,4}\\s*Phase\\s+(\\d+)[A-Z]?(?:\\.\\d+)*/'), [], 'digits-only capture is non-contiguous');
assert.deepEqual(findPhaseIdRegexDrift('/Phase\\s+([\\w][\\w.-]*)/'), [], '\\w id grammar is not the canonical token');
assert.deepEqual(findPhaseIdRegexDrift('/\\|\\s*Phase\\s*\\|\\s*Plans\\s*\\|/'), [], 'pipe-table structure');
});
test('reports 1-based line numbers', () => {
const v = findPhaseIdRegexDrift('line1\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;\nline3');
assert.equal(v[0].line, 2);
});
});
describe('#2128 phase-id drift scanner: the live repo is clean', () => {
test('scanRepo finds zero unsanctioned phase-token re-derivations', () => {
const violations = scanRepo(ROOT);
assert.deepEqual(
violations,
[],
'unsanctioned phase-token re-derivation(s) — build from PHASE_NUMBER_TOKEN_SOURCE or add // phase-id-owner:\n' +
violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'),
);
});
});
describe('#2128 phase-id single-owner identity guard', () => {
test('phase-id.cjs exports the complete locked canonical surface', () => {
for (const name of CANONICAL) {
assert.ok(name in phaseId, `phase-id.cjs must export the canonical member '${name}'`);
}
});
test('no consumer module re-exports a DIVERGENT copy of a canonical phase-id function', () => {
// Forward guard: if any built lib module re-exports a name that phase-id.cjs
// owns, it MUST be the identical reference — a re-export, never a local
// re-implementation. All consumers pass today (none re-export); the guard
// fails the moment a divergent copy ships.
const libDir = path.join(ROOT, 'gsd-core', 'bin', 'lib');
const consumers = fs.readdirSync(libDir).filter((f) => f.endsWith('.cjs') && f !== 'phase-id.cjs');
let checked = 0;
const requireFailures = [];
for (const f of consumers) {
let mod;
try {
mod = require(path.join(libDir, f));
} catch (e) {
// Surfaced, not silently skipped — a module that cannot be required
// would otherwise erode the guard's coverage without any signal.
requireFailures.push(`${f}: ${e.message}`);
continue;
}
if (!mod || typeof mod !== 'object') continue; // bare-function exports carry no named canonical member
checked++;
for (const name of CANONICAL) {
if (Object.prototype.hasOwnProperty.call(mod, name)) {
assert.strictEqual(
mod[name],
phaseId[name],
`${f} re-exports '${name}' but it is NOT the phase-id.cjs reference — re-export the canonical, do not re-implement`,
);
}
}
}
assert.deepEqual(requireFailures, [], `consumer module(s) failed to require (guard coverage would silently degrade):\n ${requireFailures.join('\n ')}`);
// Coverage floor: the vast majority of the ~150 built lib modules export an
// object and must actually be inspected — not a token "at least one".
assert.ok(checked > consumers.length * 0.75, `expected to inspect most of the ${consumers.length} consumer modules, only inspected ${checked}`);
});
});

View File

@@ -348,6 +348,23 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present');
});
test('#2128: the pre-colon tag is length-bounded so the tag clause cannot ReDoS', () => {
// The tag body `[^)\n]*` was unbounded, making the optional-group + /g scan
// quadratic on adversarial ROADMAP.md/STATE.md (a long run of `(` after a
// header). Bounding it to {0,200} keeps the match linear; a 200-char tag body
// still matches (real tags are a handful of chars), 201 does not.
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
// Boundary coverage (CLAUDE.md): limit-1, limit, limit+1.
assert.ok(re.test(`### Phase 26 (${'x'.repeat(199)}): T`), 'a 199-char tag body (limit-1) is within the bound');
assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body (limit) is within the bound');
assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body (limit+1) exceeds the bound');
// Linearity guard: the adversarial input that was ~18.8s unbounded resolves
// near-instantly now. Assert bounded work, not wall-clock (no clock seam):
// the bounded source contains an explicit upper repetition limit.
assert.match(phaseId.OPTIONAL_PHASE_TAG_SOURCE, /\{0,\d+\}/, 'tag body must carry an explicit upper bound');
});
test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => {
// The resolver (get-phase) and the capture-all enumeration regexes are
// separate code paths. Fixing only the resolver left `roadmap analyze`
@@ -410,11 +427,12 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => {
// Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites
// inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two
// header families silently diverge. Assert behavioral equivalence over a
// representative header corpus so the split cannot drift undetected.
// inline `(?:\s*\([^)\n]{0,200}\))?`. If one is edited without the other the
// two header families silently diverge (the body is bounded to {0,200} in
// both since #2128 — a ReDoS fix that MUST stay in lockstep). Assert
// behavioral equivalence over a representative header corpus.
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?';
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]{0,200}\\))?';
const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`);
for (const sample of [

View File

@@ -716,13 +716,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
const enforce = require(ENFORCEMENT_LIB);
// Migrated to the SHIPPING prover (#1279): the default real prover lints the committed
// `_ff_lint_violation.cjs` violationFixture (the rule fires -> fail-first proven) while the
// clean runCheck lints src/clock.cts (no violation -> non-vacuous pass). Both via real eslint.
// clean runCheck lints tests/_ff_lint_clean.cjs (no violation -> non-vacuous pass). Both via real eslint.
const result = enforce.runProhibitionEnforcement(
TEST_TIER,
{
kind: 'lint-rule',
rule: 'local/no-source-grep',
target: 'src/clock.cts',
target: 'tests/_ff_lint_clean.cjs',
failFirst: true,
violationFixture: path.join('tests', '_ff_lint_violation.cjs'),
},
@@ -759,13 +759,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
const enforce = require(ENFORCEMENT_LIB);
// No injected runCheck/proveFailFirst: the default prover lints the committed
// `_ff_lint_violation.cjs` (the rule fires -> fail-first proven) AND the default runner lints
// the clean `src/clock.cts` (no violation -> non-vacuous pass). BOTH directions via real eslint.
// the clean `tests/_ff_lint_clean.cjs` (no violation -> non-vacuous pass). BOTH directions via real eslint.
const result = enforce.runProhibitionEnforcement(
TEST_TIER,
{
kind: 'lint-rule',
rule: 'local/no-source-grep',
target: 'src/clock.cts',
target: 'tests/_ff_lint_clean.cjs',
violationFixture: path.join('tests', '_ff_lint_violation.cjs'),
},
{ cwd: process.cwd() },
@@ -780,7 +780,7 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
test('FULL producer (real): lint-rule hard-gates on a TOOTHLESS violationFixture (rule does not flag it) (FF-02 wrong-direction)', () => {
const enforce = require(ENFORCEMENT_LIB);
// The "violation fixture" is a CLEAN in-tree file (src/clock.cts) the rule does NOT flag, so the
// The "violation fixture" is a CLEAN in-tree file (tests/_ff_lint_clean.cjs) the rule does NOT flag, so the
// default prover cannot prove fail-first -> the producer must hard-gate (never green), even though
// the clean target itself would pass the runner. A toothless guard is not a guard.
const result = enforce.runProhibitionEnforcement(
@@ -788,8 +788,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
{
kind: 'lint-rule',
rule: 'local/no-source-grep',
target: 'src/clock.cts',
violationFixture: 'src/clock.cts',
target: 'tests/_ff_lint_clean.cjs',
violationFixture: 'tests/_ff_lint_clean.cjs',
},
{ cwd: process.cwd() },
);
@@ -807,8 +807,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
{
kind: 'lint-rule',
rule: 'local/no-source-grep',
target: 'src/clock.cts',
violationFixture: 'src/clock.cts',
target: 'tests/_ff_lint_clean.cjs',
violationFixture: 'tests/_ff_lint_clean.cjs',
},
{ cwd: process.cwd(), mode },
);
@@ -1009,11 +1009,11 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', ()
test('lint-rule: a CLEAN violationFixture (rule does not flag) is NOT proven (FF-02 toothless direction)', () => {
const enforce = require(ENFORCEMENT_LIB);
// src/clock.cts is a clean in-tree source with no no-source-grep violation. If a "violation
// tests/_ff_lint_clean.cjs is a clean in-tree source with no no-source-grep violation. If a "violation
// fixture" does not actually trigger the rule, the rule is toothless on it → not a guard → not
// proven → must hard-gate.
const proof = enforce.defaultProveFailFirst(
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts', violationFixture: 'src/clock.cts' },
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs', violationFixture: 'tests/_ff_lint_clean.cjs' },
process.cwd(),
);
assert.equal(proof.provenFailFirst, false,
@@ -1023,7 +1023,7 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', ()
test('lint-rule: no violationFixture -> not proven (FF-05 fail-closed)', () => {
const enforce = require(ENFORCEMENT_LIB);
const proof = enforce.defaultProveFailFirst(
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts' }, // no violationFixture
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs' }, // no violationFixture
process.cwd(),
);
assert.equal(proof.provenFailFirst, false, 'no violationFixture -> cannot prove -> hard-gate');

View File

@@ -77,6 +77,19 @@ describe('roadmap-parser: stripShippedMilestones', () => {
assert.ok(!result.includes('closed content'), 'content removed');
assert.ok(result.includes('after'), 'after content preserved');
});
test('#557: preserves an active <details open> block while stripping shipped bare <details>', () => {
// <details open> marks the ACTIVE milestone (roadmap.analyze must still see its
// phases); only closed/shipped bare <details> blocks are stripped. Regression for
// #557, which the #2128 shared-seam migration briefly reintroduced via the seam's
// attribute-tolerance — the details strip is now attr-INTOLERANT to keep #557 fixed.
const input = '<details>\nshipped phase\n</details>\n<details open>\n- [ ] **Phase 9: Active**\n</details>\nafter';
const result = stripShippedMilestones(input);
assert.ok(!result.includes('shipped phase'), 'shipped bare <details> stripped');
assert.ok(result.includes('<details open>'), 'active <details open> tag preserved');
assert.ok(result.includes('Phase 9: Active'), 'active-milestone phases preserved');
assert.ok(result.includes('after'), 'trailing content preserved');
});
});
// ─── extractCurrentMilestone ──────────────────────────────────────────────────

View File

@@ -1918,11 +1918,13 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
});
test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => {
// Counter-test: if the regex erroneously matches both forms in both
// directions, this catches it. `42` must only match `Phase 42:` — not
// `Phase PROJ-42:` — otherwise integer phase lookups silently steal
// matches from prefixed siblings.
test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => {
// #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a
// distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve
// the BARE one — the numeric source is tried before the prefix-tolerant
// fallback, so a bare query never steals a distinct prefixed sibling.
// (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed
// heading when no bare sibling exists — see the bug #2114 block below.)
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
@@ -1931,8 +1933,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Should not be returned for `42`',
'**Goal:** Counter-test',
'### Phase 42: Bare',
'**Goal:** Canonical bare heading',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Distinct prefixed sibling',
'',
].join('\n'),
);
@@ -1940,10 +1945,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(
payload.found,
false,
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
payload.phase_name,
'Bare',
`bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`,
);
});
@@ -2071,6 +2077,85 @@ function makePlanProject(files = {}) {
return dir;
}
describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('bug-2114-'); });
afterEach(() => { cleanup(tmpDir); });
test('bare-number query resolves a drifted project-code-prefixed heading', () => {
// Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI
// (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did
// — the #2114 divergence. Now all three resolvers share the 3-source list.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 30: Plain',
'**Goal:** Canonical bare heading',
'',
'### Phase AB-29: Prefixed',
'**Goal:** Drifted prefixed heading',
'',
].join('\n'),
);
const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir);
assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`);
const payloadAB29 = JSON.parse(resultAB29.output);
assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`);
assert.strictEqual(payloadAB29.phase_name, 'Prefixed');
assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading');
// The canonical bare heading still resolves.
const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir);
assert.ok(result30.success);
const payload30 = JSON.parse(result30.output);
assert.strictEqual(payload30.found, true);
assert.strictEqual(payload30.phase_name, 'Plain');
});
test('project-code-prefixed checklist-only entry surfaces malformed_roadmap for both query forms', () => {
// #2121/#2114 route all three resolvers through the shared 3-source lookup. A
// `**Phase PROJ-42:**` summary line with no matching `### Phase PROJ-42:` detail heading
// is a malformed ROADMAP. Before the consolidation this project-code-prefixed checklist
// was reported as a silent `{found:false}` for BOTH query forms — the prefixed pass
// discarded its malformed candidate, and the bare pass could not match the `PROJ-` prefix
// at all. The unified lookup newly surfaces the malformed_roadmap diagnostic for both, so
// this test fails on the prior silent-empty behavior for the prefixed AND the bare form.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap v1.0',
'',
'## Phases',
'',
'- [ ] **Phase PROJ-42: Checklist only, no header**',
'',
].join('\n'),
);
const prefixed = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
assert.ok(prefixed.success, `command failed: ${prefixed.error || prefixed.output}`);
const pPayload = JSON.parse(prefixed.output);
assert.strictEqual(pPayload.found, false, 'malformed roadmap: phase must not be found');
assert.strictEqual(pPayload.error, 'malformed_roadmap', 'prefixed query must surface malformed_roadmap');
assert.ok(pPayload.message.includes('missing'), 'message must explain the missing detail section');
// Parity: the bare numeric form yields the same diagnostic against the same fixture.
const bare = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(bare.success, `command failed: ${bare.error || bare.output}`);
assert.strictEqual(JSON.parse(bare.output).error, 'malformed_roadmap', 'bare query surfaces the same diagnostic');
});
});
describe('roadmap annotate-dependencies', () => {
let tmpDir;