@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-ui-checker.md +2 -0
  4. package/agents/gsd-ui-researcher.md +1 -0
  5. package/bin/install.js +970 -192
  6. package/gsd-core/bin/lib/capability-registry.cjs +501 -85
  7. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  8. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  9. package/gsd-core/bin/lib/init.cjs +33 -40
  10. package/gsd-core/bin/lib/install-engine.cjs +90 -20
  11. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  12. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  13. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  14. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +235 -38
  15. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -13
  16. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  17. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  18. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +501 -12
  19. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  20. package/gsd-core/bin/lib/security.cjs +6 -36
  21. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  22. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  23. package/gsd-core/bin/lib/surface.cjs +10 -6
  24. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  25. package/gsd-core/bin/shared/model-catalog.json +8 -3
  26. package/gsd-core/references/ui-consideration-probe.md +73 -0
  27. package/gsd-core/templates/UI-SPEC.md +25 -0
  28. package/gsd-core/templates/VALIDATION.md +2 -0
  29. package/gsd-core/workflows/audit-milestone.md +7 -4
  30. package/gsd-core/workflows/plan-phase.md +6 -0
  31. package/gsd-core/workflows/settings-advanced.md +7 -4
  32. package/gsd-core/workflows/ui-phase.md +146 -1
  33. package/gsd-core/workflows/validate-phase.md +2 -2
  34. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  35. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  36. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  37. package/hooks/gsd-windsurf-pre-command.js +275 -0
  38. package/hooks/gsd-windsurf-pre-write.js +132 -0
  39. package/hooks/managed-hooks-registry.cjs +2 -0
  40. package/package.json +8 -4
  41. package/pi/gsd.cjs +354 -0
  42. package/scripts/build-hooks.js +3 -0
  43. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  44. package/scripts/gen-registry.cjs +128 -0
  45. package/scripts/lint-test-file-count.allowlist.json +2 -1
  46. package/scripts/registry-schema.cjs +565 -0
  47. package/scripts/validate-registry.cjs +117 -0
  48. package/vscode/browser.js +197 -0
  49. package/vscode/extension.js +383 -0
  50. package/vscode/host-binding.js +113 -0
  51. package/vscode/package.json +96 -0
@@ -697,26 +697,35 @@ const VALID_CONVERTER_NAMES = new Set([
697
697
  'convertClaudeAgentToCodebuddyAgent',
698
698
  'convertClaudeAgentToClineAgent',
699
699
  'convertClaudeAgentToCodexAgent',
700
+ // ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection.
701
+ 'convertClaudeAgentToQwenAgent',
700
702
  ]);
701
703
 
702
704
  // C3: Validate role:runtime body
703
705
  const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
704
- const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
706
+ // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with
707
+ // no file-projected config directory at all.
708
+ const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']);
705
709
  const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
706
- const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
710
+ const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']);
707
711
  const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
708
712
  // extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
709
713
  // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
710
714
  // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
711
715
  // host exposes no extension surface (engine owns the bus, e.g. VS Code).
712
- const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
716
+ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'none']);
713
717
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
714
718
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
715
719
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
716
720
  const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
717
- const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
718
- const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
719
- const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
721
+ // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that
722
+ // are never CLI-installed (no allRuntimes membership, no install flag).
723
+ const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']);
724
+ // 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer.
725
+ const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']);
726
+ // SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see
727
+ // capabilities/qwen/capability.json's extendedHookEvents).
728
+ const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']);
720
729
 
721
730
  // ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts)
722
731
  const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']);
@@ -736,13 +745,18 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
736
745
  ['copilot-instructions', new Set(['copilot-inline'])],
737
746
  ['cline-rules', new Set(['cline-rules'])],
738
747
  ['cursor-hooks-json', new Set(['cursor-hooks-json'])],
739
- ['profile-marker-only', new Set(['none'])],
748
+ ['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])],
749
+ // 'none' added #2103 — VS Code has no CLI install surface at all; its only
750
+ // valid hooksSurface pairing is the other 'none' (engine owns the hook bus).
751
+ ['none', new Set(['none'])],
740
752
  ]);
741
753
 
742
754
  // GATE B: extended hook event families → required hookEvents value
743
755
  // Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'.
744
756
  const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']);
745
- const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']);
757
+ // SubagentStart added #2092 Phase B Upgrade 2 — Claude hook-event dialect
758
+ // counterpart of SubagentStop (qwen-only today).
759
+ const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'SubagentStart']);
746
760
 
747
761
  /**
748
762
  * Validate a runtime.configHome object per ADR-1016 Decision 1.
@@ -771,9 +785,18 @@ function validateConfigHome(capId, ch) {
771
785
  );
772
786
  }
773
787
 
774
- // name — required string
775
- if (typeof ch.name !== 'string' || ch.name.length === 0) {
776
- errors.push(ctx + '.name must be a non-empty string');
788
+ // name — required string, except when kind === 'none': the runtime has no
789
+ // file-projected config directory at all, so a descriptive name is
790
+ // optional (a carve-out mirroring the dot-home-nested⇒parent conditional
791
+ // below, not a new validation mechanism). If present it must still be a
792
+ // non-empty string (e.g. vscode's configHome.name stays a descriptive
793
+ // "vscode" string even though it is never used to build a path).
794
+ if (ch.kind !== 'none') {
795
+ if (typeof ch.name !== 'string' || ch.name.length === 0) {
796
+ errors.push(ctx + '.name must be a non-empty string');
797
+ }
798
+ } else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) {
799
+ errors.push(ctx + '.name must be a non-empty string if present when kind is "none"');
777
800
  }
778
801
 
779
802
  // parent — required when kind == dot-home-nested
@@ -973,7 +996,7 @@ function validateRuntimeBody(cap) {
973
996
  );
974
997
  }
975
998
 
976
- // hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
999
+ // hooksSurface — closed 7-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
977
1000
  if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
978
1001
  errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
979
1002
  } else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {
@@ -1054,15 +1077,27 @@ function validateRuntimeBody(cap) {
1054
1077
  // localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
1055
1078
  // Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
1056
1079
  // generator catches any descriptor missing the field before regenerating.
1057
- if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
1080
+ //
1081
+ // #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX
1082
+ // host with no file-projected config directory (e.g. VS Code) has no
1083
+ // local dir to name; localConfigDir may be null/absent for such runtimes.
1084
+ const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined;
1085
+ if (configHomeKind !== 'none') {
1086
+ if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
1087
+ errors.push(
1088
+ 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
1089
+ 'got: ' + JSON.stringify(r.localConfigDir),
1090
+ );
1091
+ } else if (!r.localConfigDir.startsWith('.')) {
1092
+ errors.push(
1093
+ 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
1094
+ );
1095
+ }
1096
+ } else if (r.localConfigDir !== null && r.localConfigDir !== undefined) {
1058
1097
  errors.push(
1059
- 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
1098
+ 'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' +
1060
1099
  'got: ' + JSON.stringify(r.localConfigDir),
1061
1100
  );
1062
- } else if (!r.localConfigDir.startsWith('.')) {
1063
- errors.push(
1064
- 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
1065
- );
1066
1101
  }
1067
1102
 
1068
1103
  // extendedHookEvents — required array; every element must be in closed enum
@@ -2168,6 +2203,9 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
2168
2203
  ['cline-rules', 'markdown-dir'],
2169
2204
  ['cursor-hooks-json', 'none'],
2170
2205
  ['profile-marker-only', 'none'],
2206
+ // 'none' added #2103 — a runtime with NO CLI install surface at all (e.g.
2207
+ // VS Code) has no config-file format to write either.
2208
+ ['none', 'none'],
2171
2209
  ]);
2172
2210
 
2173
2211
  /**
@@ -430,14 +430,25 @@ function hookEventSurfaceFor(hookEvents) {
430
430
  // events (session/tool/file/permission); pi ~30 fine-grained extension events;
431
431
  // 'none' = the host exposes no extension surface and the engine owns the bus
432
432
  // (VS Code). Declarative hosts (no plugin API) do not set `extensionEvents`.
433
+ // OpenCode's plugin event surface (ADR-1239 §research; ~25 documented events,
434
+ // GSD binds this subset). Hoisted to a named const — rather than duplicated
435
+ // object literals — so the `kilo` dialect below (#2093) can reuse the IDENTICAL
436
+ // array instead of a copy-pasted one that could silently drift out of sync.
437
+ const OPENCODE_EXTENSION_EVENTS = Object.freeze([
438
+ 'session.created', 'session.idle', 'experimental.session.compacting',
439
+ 'tool.execute.before', 'tool.execute.after', 'file.edited',
440
+ // #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
441
+ // permission decisions + session error surface.
442
+ 'permission.asked', 'permission.replied', 'session.error',
443
+ ]);
433
444
  const EXTENSION_EVENT_SURFACES = Object.freeze({
434
- opencode: Object.freeze([
435
- 'session.created', 'session.idle', 'experimental.session.compacting',
436
- 'tool.execute.before', 'tool.execute.after', 'file.edited',
437
- // #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
438
- // permission decisions + session error surface.
439
- 'permission.asked', 'permission.replied', 'session.error',
440
- ]),
445
+ opencode: OPENCODE_EXTENSION_EVENTS,
446
+ // #2093 — Kilo Code is an OpenCode fork sharing the same plugin/extension
447
+ // event bus (host hook bus, UPGRADE 1): reuses OPENCODE_EXTENSION_EVENTS
448
+ // verbatim (not a re-derivation), so the two dialects stay pinned together
449
+ // by construction. See .kilo/plugins/gsd-core.js (copied verbatim from
450
+ // .opencode/plugins/gsd-core.js).
451
+ kilo: OPENCODE_EXTENSION_EVENTS,
441
452
  // #2091 — Hermes Agent real plugin hook vocabulary (13 events).
442
453
  // Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
443
454
  // Replaces the borrowed `hookEvents: "claude"` 6-event surface that silently
@@ -451,7 +462,21 @@ const EXTENSION_EVENT_SURFACES = Object.freeze({
451
462
  'pre_gateway_dispatch', 'pre_approval_request',
452
463
  'transform_tool_result',
453
464
  ]),
454
- pi: Object.freeze(['tool_call']),
465
+ // #2102 Stage 2 — pi's real ExtensionAPI event vocabulary (~30 fine-grained
466
+ // extension events; documentation-sourced, ADR-1239 §research). Replaces the
467
+ // placeholder single-event ['tool_call'] surface — the Stage 1 value only
468
+ // covered the one event pi/gsd.cjs happened to bind at the time, not the
469
+ // full declared surface.
470
+ pi: Object.freeze([
471
+ 'session_start', 'project_trust', 'resources_discover', 'input',
472
+ 'before_agent_start', 'agent_start', 'message_start', 'message_update',
473
+ 'message_end', 'turn_start', 'context', 'before_provider_request',
474
+ 'after_provider_response', 'tool_execution_start', 'tool_execution_update',
475
+ 'tool_execution_end', 'tool_call', 'tool_result', 'turn_end', 'agent_end',
476
+ 'session_before_switch', 'session_shutdown', 'session_before_fork',
477
+ 'session_info_changed', 'session_before_compact', 'session_compact',
478
+ 'session_before_tree', 'session_tree', 'thinking_level_select', 'model_select',
479
+ ]),
455
480
  none: Object.freeze([]),
456
481
  });
457
482
  /**
@@ -63,7 +63,7 @@ const { resolveModelInternal, resolveGranularityInternal, assertValidGranularity
63
63
  const { findPhaseInternal } = phaseLocator;
64
64
  const { getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
65
65
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
66
- const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
66
+ const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery } = phaseId;
67
67
  const { pruneOrphanedWorktrees } = worktreeSafety;
68
68
  const { planningPaths, planningDir, planningRoot, listAvailableWorkstreams, getActiveWorkstream, findContextMdIn, } = planningWorkspace;
69
69
  const { determinePhaseStatus } = commandsMod;
@@ -74,25 +74,10 @@ const { evaluateUatPassed } = uatPredicateMod;
74
74
  void stripShippedMilestones;
75
75
  // Accept all bold/colon variants of the Requirements header (#2769)
76
76
  const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m;
77
- // #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
78
- // code (e.g. 'MEM-01' → '01'), so a foreign-prefixed workstream/task id would
79
- // collapse to its numeric suffix and resolve to an unrelated numeric phase via
80
- // the dir/roadmap fallback. init plan-phase must require EXACT prefixed
81
- // evidence — a phase dir whose token literally IS the prefixed query, or a
82
- // roadmap entry literally headed with it — before accepting such a match. The
83
- // configured project_code's own prefix (e.g. LKML-01 under project_code: LKML)
84
- // is never foreign and always passes through.
85
- function parsePhasePrefix(phase) {
86
- const match = String(phase).match(/^([A-Z][A-Z0-9_]*)-(?=\d)/i);
87
- return match ? match[1] : null;
88
- }
89
- function isForeignPrefixedPhaseQuery(phase, projectCode) {
90
- const prefix = parsePhasePrefix(phase);
91
- if (!prefix)
92
- return false;
93
- const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
94
- return !configured || prefix.toUpperCase() !== configured.toUpperCase();
95
- }
77
+ // #2056/#2104: isForeignPrefixedPhaseQuery is imported from phase-id.cts
78
+ // (the canonical predicate). parsePhasePrefix is no longer needed locally.
79
+ // phaseInfoMatchesExactPrefix and roadmapPhaseMatchesExactPrefix are local
80
+ // helpers that post-filter the lookup results for foreign-prefix queries.
96
81
  function phaseInfoMatchesExactPrefix(phaseInfo, phase) {
97
82
  const num = phaseInfo?.['phase_number'];
98
83
  const numStr = typeof num === 'string' ? num : (typeof num === 'number' ? String(num) : '');
@@ -103,6 +88,23 @@ function roadmapPhaseMatchesExactPrefix(roadmapPhase, phase) {
103
88
  const section = typeof sectionRaw === 'string' ? sectionRaw : '';
104
89
  return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section);
105
90
  }
91
+ // #2104: shared helpers that wrap findPhaseInternal / getRoadmapPhaseInternal
92
+ // with the #2056 foreign-prefix guard, so every init command gets the same
93
+ // protection without duplicating the guard logic at each call site.
94
+ function guardedFindPhase(cwd, phase, projectCode) {
95
+ let phaseInfo = findPhaseInternal(cwd, phase);
96
+ if (isForeignPrefixedPhaseQuery(phase, projectCode) && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) {
97
+ phaseInfo = null;
98
+ }
99
+ return phaseInfo;
100
+ }
101
+ function guardedGetRoadmapPhase(cwd, phase, projectCode) {
102
+ let roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
103
+ if (isForeignPrefixedPhaseQuery(phase, projectCode) && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) {
104
+ roadmapPhase = null;
105
+ }
106
+ return roadmapPhase;
107
+ }
106
108
  function listPhaseSummaryFiles(phaseDir) {
107
109
  return scanPhasePlans(phaseDir)['summaryFiles'];
108
110
  }
@@ -258,9 +260,9 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
258
260
  error('phase required for init execute-phase');
259
261
  }
260
262
  const config = loadConfig(cwd);
261
- let phaseInfo = findPhaseInternal(cwd, phase);
263
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
262
264
  const milestone = getMilestoneInfo(cwd);
263
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
265
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
264
266
  if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
265
267
  phaseInfo = null;
266
268
  }
@@ -361,18 +363,9 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
361
363
  error('phase required for init plan-phase');
362
364
  }
363
365
  const config = loadConfig(cwd);
364
- const foreignPrefixedPhase = isForeignPrefixedPhaseQuery(phase, config.project_code);
365
- let phaseInfo = findPhaseInternal(cwd, phase);
366
- // #2056: a foreign-prefixed query must only match a phase whose token literally
367
- // IS the prefixed query (e.g. a real 'MEM-01-*' dir); otherwise the numeric
368
- // fallback ('MEM-01' → '01') would resolve to the unrelated numeric phase.
369
- if (foreignPrefixedPhase && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) {
370
- phaseInfo = null;
371
- }
372
- let roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
373
- if (foreignPrefixedPhase && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) {
374
- roadmapPhase = null;
375
- }
366
+ // #2056/#2104: foreign-prefixed queries must not collapse to numeric phases.
367
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
368
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
376
369
  if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
377
370
  phaseInfo = null;
378
371
  }
@@ -679,15 +672,15 @@ function cmdInitVerifyWork(cwd, phase, raw) {
679
672
  }
680
673
  const config = loadConfig(cwd);
681
674
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
682
- let phaseInfo = findPhaseInternal(cwd, phase);
675
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
683
676
  if (phaseInfo?.['archived']) {
684
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
677
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
685
678
  if (roadmapPhase?.['found']) {
686
679
  phaseInfo = null;
687
680
  }
688
681
  }
689
682
  if (!phaseInfo) {
690
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
683
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
691
684
  if (roadmapPhase?.['found']) {
692
685
  const phaseName = roadmapPhase['phase_name'];
693
686
  phaseInfo = {
@@ -736,9 +729,9 @@ function cmdInitVerifyWork(cwd, phase, raw) {
736
729
  }
737
730
  function cmdInitPhaseOp(cwd, phase, raw) {
738
731
  const config = loadConfig(cwd);
739
- let phaseInfo = findPhaseInternal(cwd, phase);
732
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
740
733
  if (phaseInfo?.['archived']) {
741
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
734
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
742
735
  if (roadmapPhase?.['found']) {
743
736
  const phaseName = roadmapPhase['phase_name'];
744
737
  phaseInfo = {
@@ -759,7 +752,7 @@ function cmdInitPhaseOp(cwd, phase, raw) {
759
752
  }
760
753
  }
761
754
  if (!phaseInfo) {
762
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
755
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
763
756
  if (roadmapPhase?.['found']) {
764
757
  const phaseName = roadmapPhase['phase_name'];
765
758
  phaseInfo = {
@@ -102,6 +102,17 @@ function convertClaudeCommandToOpencodeSkill(content, skillName) {
102
102
  function convertClaudeCommandToKiloSkill(content, skillName) {
103
103
  return runtimeArtifactConversion.convertClaudeCommandToKiloSkill(content, skillName);
104
104
  }
105
+ /**
106
+ * Converter-name registry for the OpenCode-family combined skills installer
107
+ * (ADR-1239 / #2093). Maps the `converter` string declared on each runtime's
108
+ * artifactLayout skills-kind descriptor (capabilities/<runtime>/capability.json)
109
+ * to the actual conversion function, so `installOpencodeFamilySkills` dispatches
110
+ * off the descriptor instead of a `frontmatterDialect === 'kilo'` runtime check.
111
+ */
112
+ const SKILLS_CONVERTER_REGISTRY = {
113
+ convertClaudeCommandToOpencodeSkill,
114
+ convertClaudeCommandToKiloSkill,
115
+ };
105
116
  // ---------------------------------------------------------------------------
106
117
  // User-artifact preservation helpers
107
118
  // ---------------------------------------------------------------------------
@@ -302,9 +313,13 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
302
313
  let destName;
303
314
  if (kind.kind === 'agents') {
304
315
  // Agent files already carry the gsd- prefix in the source dir.
305
- // #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
306
- destName = runtime === 'copilot'
307
- ? entry.name.replace(/\.md$/, '.agent.md')
316
+ // #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
317
+ // hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
318
+ // every other runtime's descriptor leaves this unset, so destName falls
319
+ // back to entry.name unchanged (byte-parity, #1575 origin comment).
320
+ const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
321
+ destName = _agentExt
322
+ ? entry.name.replace(/\.md$/, _agentExt)
308
323
  : entry.name;
309
324
  }
310
325
  else if (namespacedByDir) {
@@ -440,7 +455,7 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
440
455
  // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
441
456
  // created skills/gsd-dev-preferences/ skill dir.
442
457
  let savedLegacyArtifacts = null;
443
- if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
458
+ if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
444
459
  if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
445
460
  savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
446
461
  node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
@@ -497,7 +512,8 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
497
512
  // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
498
513
  // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
499
514
  // (which would redirect to skills/) conflicts with the test contract for local installs.
500
- const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
515
+ const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
516
+ const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && scope === 'global');
501
517
  if (isLegacyCommandsGsd) {
502
518
  const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
503
519
  if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
@@ -651,6 +667,18 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
651
667
  const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
652
668
  _removeHermesBareStemDirs(nestedGsdDirForCleanup);
653
669
  }
670
+ // Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
671
+ // outside the OpenCode/Kilo combined-family install (e.g. pi, whose
672
+ // artifactLayout is empty and which never sets combinedFamilyInstall) still
673
+ // need their declared hostBehaviors.nativePlugin file copied into configDir.
674
+ // findInstallSourceRoot resolves the repo/package root independent of
675
+ // configDir contents (marker check, then a walk-up from __dirname), so this
676
+ // is safe even when configDir has no .gsd-source marker (artifactLayout: []).
677
+ if (behaviors.nativePlugin) {
678
+ const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
679
+ const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
680
+ _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
681
+ }
654
682
  }
655
683
  // ---------------------------------------------------------------------------
656
684
  // installOpencodeFamilySkills
@@ -679,9 +707,16 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
679
707
  const rawDir = rawCommandsDir;
680
708
  if (!rawDir || !node_fs_1.default.existsSync(rawDir))
681
709
  return 0;
682
- const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
683
- ? convertClaudeCommandToKiloSkill
684
- : convertClaudeCommandToOpencodeSkill;
710
+ // #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
711
+ // string (capabilities/<runtime>/capability.json artifactLayout) via the
712
+ // SKILLS_CONVERTER_REGISTRY, instead of a `frontmatterDialect === 'kilo'`
713
+ // runtime check. Fail loud if the descriptor names an unregistered converter
714
+ // (mirrors the converter=null throw in runtime-artifact-layout.cts).
715
+ const converterName = skillsKindEntry.converter;
716
+ const converter = converterName ? SKILLS_CONVERTER_REGISTRY[converterName] : undefined;
717
+ if (!converter) {
718
+ throw new TypeError(`installOpencodeFamilySkills: unknown skills converter '${String(converterName)}' for runtime '${runtime}'`);
719
+ }
685
720
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
686
721
  // Symlink-escape guard: reject if any path component between targetDir and
687
722
  // dest is a symlink that would redirect writes outside the config root.
@@ -769,6 +804,16 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
769
804
  let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
770
805
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
771
806
  content = processAttribution(content, resolveAttribution(runtime));
807
+ // #2093: this commands-kind entry's descriptor `converter` field is
808
+ // intentionally `null` (see capabilities/{kilo,opencode}/capability.json —
809
+ // the flattened-command writer above applies its own path/attribution
810
+ // rewrites and has no per-file converter slot to key on), so there is no
811
+ // descriptor string to dispatch through here. `frontmatterDialect` is the
812
+ // documented, intentional dispatch key for frontmatter-shape selection —
813
+ // it is itself descriptor-driven (not a `runtime === 'kilo'` check), so it
814
+ // already satisfies the fold-to-descriptor requirement. Only the SKILLS
815
+ // converter site above (installOpencodeFamilySkills) has a real
816
+ // `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
772
817
  content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
773
818
  ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
774
819
  : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
@@ -777,12 +822,44 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
777
822
  }
778
823
  }
779
824
  // ---------------------------------------------------------------------------
825
+ // _installNativePluginIfDeclared
826
+ // ---------------------------------------------------------------------------
827
+ /**
828
+ * Copy a runtime's declared native-extension/plugin file (hostBehaviors.nativePlugin)
829
+ * into its resolved config dir, when the runtime descriptor declares one.
830
+ *
831
+ * Extracted (ADR-1239 / #2102 Stage 1) from the body previously inlined in
832
+ * installOpencodeFamilyArtifacts so a runtime that is NOT part of the
833
+ * OpenCode/Kilo combined-family install (e.g. pi, whose artifactLayout is
834
+ * empty and which never sets combinedFamilyInstall) can still get its
835
+ * nativePlugin file staged via the generic installRuntimeArtifacts branch.
836
+ * Behavior for opencode/kilo is unchanged — same source resolution, same
837
+ * mkdir + copyFileSync call, same silent no-op when the source is missing.
838
+ *
839
+ * @param runtime - canonical runtime id (only used for the assertDestWithinConfigHome guard)
840
+ * @param configDir - resolved runtime config directory
841
+ * @param behaviors - the runtime's hostBehaviors descriptor
842
+ * @param src - repo/package root (two levels up from the commands/gsd source dir)
843
+ */
844
+ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
845
+ const np = behaviors.nativePlugin;
846
+ if (np && np.source) {
847
+ const pluginSrc = node_path_1.default.join(src, np.source);
848
+ if (node_fs_1.default.existsSync(pluginSrc)) {
849
+ const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
850
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
851
+ node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
852
+ }
853
+ }
854
+ }
855
+ // ---------------------------------------------------------------------------
780
856
  // installOpencodeFamilyArtifacts
781
857
  // ---------------------------------------------------------------------------
782
858
  /**
783
- * Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
784
- * Stages the flattened commands surface + skills surface + (OpenCode only)
785
- * native plugin adapter, mirroring the bespoke `else if (isOpencode ||
859
+ * Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087,
860
+ * #2093). Stages the flattened commands surface + skills surface + (any
861
+ * runtime whose hostBehaviors declares `nativePlugin` — OpenCode and, since
862
+ * #2093, Kilo) native plugin adapter, mirroring the bespoke `else if (isOpencode ||
786
863
  * isKilo)` block previously inlined in bin/install.js.
787
864
  *
788
865
  * @param runtime - 'opencode' or 'kilo'
@@ -812,15 +889,7 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
812
889
  const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
813
890
  installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
814
891
  installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
815
- const np = behaviors.nativePlugin;
816
- if (np && np.source) {
817
- const pluginSrc = node_path_1.default.join(src, np.source);
818
- if (node_fs_1.default.existsSync(pluginSrc)) {
819
- const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
820
- node_fs_1.default.mkdirSync(destDir, { recursive: true });
821
- node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
822
- }
823
- }
892
+ _installNativePluginIfDeclared(runtime, configDir, behaviors, src);
824
893
  }
825
894
  // ---------------------------------------------------------------------------
826
895
  // uninstallRuntimeArtifacts
@@ -879,6 +948,7 @@ module.exports = {
879
948
  installOpencodeFamilySkills,
880
949
  installOpencodeFamilyCommands,
881
950
  installOpencodeFamilyArtifacts,
951
+ _installNativePluginIfDeclared,
882
952
  _hostBehaviors,
883
953
  _copyStaged,
884
954
  hasExistingSymlinkBetween,
@@ -42,6 +42,9 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
42
42
  'hooks/gsd-cursor-stop.js',
43
43
  'hooks/gsd-cursor-subagent-start.js',
44
44
  'hooks/gsd-cursor-subagent-stop.js',
45
+ // Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
46
+ 'hooks/gsd-windsurf-pre-write.js',
47
+ 'hooks/gsd-windsurf-pre-command.js',
45
48
  'hooks/gsd-ensure-canonical-path.js',
46
49
  'hooks/gsd-graphify-update.sh',
47
50
  'hooks/gsd-phase-boundary.sh',
@@ -5,8 +5,16 @@
5
5
  * so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
6
6
  * Hermes) can drive GSD with NO bespoke plugin:
7
7
  *
8
- * - point 1 (command): tool `gsd_invoke_command` → the command-routing hub
9
- * (`createHub`/`dispatch`, src/command-routing-hub.cts).
8
+ * - point 1 (command): tool `gsd_invoke_command` → `dispatchGsdCommand`
9
+ * (src/shell-command-projection.cts), a bounded subprocess-shim to
10
+ * gsd-tools.cjs. #2102 Stage 2: `commandRoutingHub.createHub()` called
11
+ * with no args here always hit `if(!_cjsRegistry) return
12
+ * makeUnknownCommand()` — every dispatch was UnknownCommand. No
13
+ * fully-populated hub factory exists anywhere in gsd-core (every
14
+ * createHub() caller builds a single-family hub for its own narrow
15
+ * purpose), so the fix routes through the SAME shared dispatch helper
16
+ * the pi extension uses (pi/gsd.cjs), mirroring the SUBPROCESS-REUSE
17
+ * precedent already established for the OpenCode/Kilo hook bridge.
10
18
  * - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the
11
19
  * Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
12
20
  *
@@ -24,9 +32,10 @@ exports.SERVER_NAME = exports.PROTOCOL_VERSION = void 0;
24
32
  exports.handleMessage = handleMessage;
25
33
  exports.runServer = runServer;
26
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const commandRoutingHub = require("./command-routing-hub.cjs");
28
- // eslint-disable-next-line @typescript-eslint/no-require-imports
29
35
  const stateIo = require("./state-io.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const shellCommandProjection = require("./shell-command-projection.cjs");
38
+ const { dispatchGsdCommand } = shellCommandProjection;
30
39
  exports.PROTOCOL_VERSION = '2024-11-05';
31
40
  exports.SERVER_NAME = 'gsd-core';
32
41
  const SERVER_VERSION = '1.7.0';
@@ -94,9 +103,11 @@ function callTool(name, args, ctx) {
94
103
  if (!family || !subcommand) {
95
104
  return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
96
105
  }
97
- const hub = commandRoutingHub.createHub();
98
- const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined });
99
- return { content: [{ type: 'text', text: JSON.stringify(res) }] };
106
+ const res = dispatchGsdCommand({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd });
107
+ if (!res.ok) {
108
+ return { isError: true, content: [{ type: 'text', text: res.stderr || res.stdout || `dispatch failed (exit ${res.code})` }] };
109
+ }
110
+ return { content: [{ type: 'text', text: res.stdout }] };
100
111
  }
101
112
  if (name === 'gsd_read_state') {
102
113
  const p = asString(a.path);
@@ -15,25 +15,42 @@
15
15
  * per-instance CLI flags). An instance is available iff its base `cli` is
16
16
  * detected. The instance→cli mapping lives HERE (single source; see the parity
17
17
  * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX).
18
+ *
19
+ * KNOWN_REVIEWER_SLUGS (post-review #2092): registry-derived, not a flat
20
+ * hand-maintained array. Each capability-runtime descriptor that is a valid
21
+ * reviewer CLI declares `runtime.hostBehaviors.reviewerCli: true`
22
+ * (capabilities/<id>/capability.json); this module reads that flag off the
23
+ * generated capability-registry.cjs at require-time. A handful of reviewer
24
+ * CLIs are NOT install-time runtimes at all (no capabilities/<id>/ descriptor
25
+ * exists) — those stay a small hardcoded tail:
26
+ * - `gemini` — hook-event dialect name only (see runtime-hooks-surface.cts);
27
+ * the Gemini CLI reviewer is not an installable runtime (#1928 folded
28
+ * gemini into antigravity's descriptor).
29
+ * - `coderabbit` / `ollama` / `lm_studio` / `llama_cpp` — third-party
30
+ * review/model CLIs with no GSD install surface at all.
18
31
  */
19
32
  Object.defineProperty(exports, "__esModule", { value: true });
20
33
  exports.INSTANCE_NAME_PATTERN = exports.KNOWN_REVIEWER_SLUGS = void 0;
21
34
  exports.normalizeConfiguredDefaultReviewers = normalizeConfiguredDefaultReviewers;
22
35
  exports.normalizeReviewerInstances = normalizeReviewerInstances;
23
36
  exports.resolveReviewerSelection = resolveReviewerSelection;
24
- exports.KNOWN_REVIEWER_SLUGS = [
37
+ const NON_RUNTIME_REVIEWER_SLUGS = [
25
38
  'gemini',
26
- 'claude',
27
- 'codex',
28
39
  'coderabbit',
29
- 'opencode',
30
- 'qwen',
31
- 'cursor',
32
- 'antigravity',
33
40
  'ollama',
34
41
  'lm_studio',
35
42
  'llama_cpp',
36
43
  ];
44
+ function deriveRuntimeReviewerSlugs() {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const registry = require('./capability-registry.cjs');
47
+ const runtimes = registry.runtimes || {};
48
+ return Object.keys(runtimes).filter((id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true);
49
+ }
50
+ exports.KNOWN_REVIEWER_SLUGS = [
51
+ ...deriveRuntimeReviewerSlugs(),
52
+ ...NON_RUNTIME_REVIEWER_SLUGS,
53
+ ];
37
54
  /** Instance names are lowercase slugs that must not shadow a built-in slug. */
38
55
  exports.INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
39
56
  function normalizeConfiguredDefaultReviewers(rawValue) {