@opengsd/gsd-core 1.9.1 → 1.10.0

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 (219) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +453 -289
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +579 -66
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +75 -47
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -28
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  62. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  63. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  64. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  67. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  68. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  69. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  70. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  71. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  72. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  73. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  74. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  75. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  76. package/gsd-core/bin/lib/state.cjs +151 -62
  77. package/gsd-core/bin/lib/surface.cjs +12 -1
  78. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  79. package/gsd-core/bin/lib/uat.cjs +320 -21
  80. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  81. package/gsd-core/bin/lib/verification.cjs +29 -12
  82. package/gsd-core/bin/lib/verify.cjs +10 -2
  83. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  84. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  85. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  86. package/gsd-core/bin/lib/workstream.cjs +6 -0
  87. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  88. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  89. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  90. package/gsd-core/references/artifact-types.md +10 -3
  91. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  92. package/gsd-core/references/debugger-techniques.md +255 -0
  93. package/gsd-core/references/research-documentation-lookup.md +5 -3
  94. package/gsd-core/references/specless-probe-fallback.md +7 -6
  95. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  96. package/gsd-core/references/worktree-branch-check.md +2 -2
  97. package/gsd-core/templates/summary-complex.md +2 -0
  98. package/gsd-core/templates/summary-minimal.md +2 -0
  99. package/gsd-core/templates/summary-standard.md +2 -0
  100. package/gsd-core/templates/summary.md +2 -0
  101. package/gsd-core/workflows/audit-milestone.md +3 -0
  102. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  107. package/gsd-core/workflows/autonomous.md +32 -69
  108. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  109. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  110. package/gsd-core/workflows/code-review.md +42 -160
  111. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  112. package/gsd-core/workflows/complete-milestone.md +23 -81
  113. package/gsd-core/workflows/debug.md +9 -12
  114. package/gsd-core/workflows/diagnose-issues.md +22 -0
  115. package/gsd-core/workflows/discovery-phase.md +4 -4
  116. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  117. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  118. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  119. package/gsd-core/workflows/docs-update.md +8 -51
  120. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  121. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  122. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  124. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  126. package/gsd-core/workflows/execute-phase.md +65 -137
  127. package/gsd-core/workflows/execute-plan.md +1 -1
  128. package/gsd-core/workflows/help/modes/full.md +6 -1
  129. package/gsd-core/workflows/ingest-docs.md +2 -1
  130. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  131. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  132. package/gsd-core/workflows/new-milestone.md +21 -38
  133. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  134. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  135. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  136. package/gsd-core/workflows/new-project.md +13 -226
  137. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  138. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  139. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  140. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  142. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  143. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  144. package/gsd-core/workflows/plan-phase.md +49 -193
  145. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  146. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  147. package/gsd-core/workflows/progress.md +11 -153
  148. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  149. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  150. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  151. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  152. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  153. package/gsd-core/workflows/quick.md +20 -390
  154. package/gsd-core/workflows/resume-project.md +3 -0
  155. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  157. package/gsd-core/workflows/review.md +15 -8
  158. package/gsd-core/workflows/section-manifest.json +219 -0
  159. package/gsd-core/workflows/sketch.md +1 -1
  160. package/gsd-core/workflows/spec-phase.md +17 -14
  161. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  162. package/gsd-core/workflows/spike.md +50 -16
  163. package/gsd-core/workflows/sync-skills.md +49 -11
  164. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  165. package/gsd-core/workflows/transition.md +8 -21
  166. package/gsd-core/workflows/ui-phase.md +8 -7
  167. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  168. package/gsd-core/workflows/update.md +18 -7
  169. package/gsd-core/workflows/verify-phase.md +4 -7
  170. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  171. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  172. package/gsd-core/workflows/verify-work.md +8 -58
  173. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  174. package/hooks/dist/gsd-check-update-worker.js +14 -5
  175. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  176. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  177. package/hooks/dist/gsd-statusline.js +72 -6
  178. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  179. package/hooks/dist/gsd-write-guard.js +359 -0
  180. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  181. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  182. package/hooks/gsd-agent-isolation-guard.js +428 -0
  183. package/hooks/gsd-check-update-worker.js +14 -5
  184. package/hooks/gsd-cursor-subagent-start.js +532 -26
  185. package/hooks/gsd-read-injection-scanner.js +7 -0
  186. package/hooks/gsd-statusline.js +72 -6
  187. package/hooks/gsd-worktree-path-guard.js +2 -1
  188. package/hooks/gsd-write-guard.js +359 -0
  189. package/hooks/hooks.json +12 -0
  190. package/hooks/lib/isolation-sentinel.js +268 -0
  191. package/hooks/managed-hooks-registry.cjs +2 -0
  192. package/package.json +14 -5
  193. package/pi/gsd.cjs +57 -12
  194. package/scripts/build-hooks.js +9 -0
  195. package/scripts/changeset/lint.cjs +9 -2
  196. package/scripts/changeset/serialize.cjs +5 -1
  197. package/scripts/gen-capability-matrix.cjs +1 -1
  198. package/scripts/gen-context-index.cjs +448 -0
  199. package/scripts/gen-inventory-manifest.cjs +101 -1
  200. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  201. package/scripts/gen-section-manifest.cjs +638 -0
  202. package/scripts/generate-package-identity.cjs +4 -2
  203. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  204. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  205. package/scripts/lint-docs-command-form.cjs +195 -0
  206. package/scripts/lint-docs-required.cjs +9 -1
  207. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  208. package/scripts/lint-example-parser-parity.cjs +395 -0
  209. package/scripts/lint-test-file-count.allowlist.json +27 -1
  210. package/scripts/mutation-matrix.cjs +13 -0
  211. package/scripts/prompt-injection-scan.sh +27 -6
  212. package/scripts/run-tests.cjs +3 -2
  213. package/skills/gsd-autonomous/SKILL.md +1 -1
  214. package/skills/gsd-execute-phase/SKILL.md +1 -1
  215. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  216. package/skills/gsd-new-milestone/SKILL.md +1 -1
  217. package/skills/gsd-plan-phase/SKILL.md +2 -2
  218. package/vscode/package.json +1 -1
  219. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -0,0 +1,518 @@
1
+ /**
2
+ * MCP served catalog — Phase B, issue #3072 (ADR-1671 epic #1671).
3
+ * Design: `.gsd/phase/feat-3072-mcp-served-catalog/40-design.md`.
4
+ *
5
+ * Serves GSD's content tree as MCP **resources** (`gsd-core/workflows/*.md`,
6
+ * `gsd-core/references/*.md`) and the 71 `commands/gsd/*.md` files as MCP
7
+ * **prompts**, through the SAME composition rule the installer applies
8
+ * (`bin/install.js`'s `copyWithPathReplacement`) — additive to the file-copy
9
+ * floor, which stays untouched (ADR-1671 Decision 6).
10
+ *
11
+ * ## The two findings that shape this module's shape (40-design.md)
12
+ *
13
+ * **F1 — composition is scoped to `gsd-core/workflows/`, and that scoping is
14
+ * load-bearing.** `bin/install.js` runs `composeWorkflow` (from
15
+ * `workflow-fragments.cts`) only when the normalized source path matches
16
+ * `(?:^|\/)gsd-core\/workflows\//`. A reference/command doc that merely
17
+ * *documents* `<!-- gsd:section -->` marker syntax with an unfenced example
18
+ * would otherwise be mis-parsed as a real marker and have that line lossily
19
+ * dropped. {@link shouldCompose} is the ONE exported predicate both this
20
+ * module and `bin/install.js` call — never a second, hand-duplicated regex
21
+ * (ADR-1671:309, `DEFECT.GENERATIVE-FIX`).
22
+ *
23
+ * **F2 — parity cannot mean byte-equality with an emitted runtime tree.**
24
+ * Install applies per-runtime path rewrites AFTER composition. The served
25
+ * catalog is host-agnostic and rewrites for no runtime, so served bytes
26
+ * equal the post-composition, pre-rewrite stage — the parity assertion is
27
+ * that the catalog and the installer apply the SAME composition rule to the
28
+ * SAME source, not that final bytes match an emitted tree.
29
+ *
30
+ * ## Shape
31
+ *
32
+ * ```
33
+ * buildCatalog({root?, readFile?, readDir?}) -> Catalog {resources, prompts}
34
+ * readResource(catalog, uri) -> {uri, mimeType, text} | throws CatalogError
35
+ * getPrompt(catalog, name, args?) -> {description, messages} | throws CatalogError
36
+ * shouldCompose(relPath) -> boolean // THE shared F1 predicate
37
+ * listResources(catalog, {cursor?, pageSize?}) -> {resources, nextCursor?} | throws CatalogError
38
+ * ```
39
+ *
40
+ * `listResources` is a delegation surface beyond the four primitives named in
41
+ * the design's "Shape" block: `src/mcp-server.cts`'s `handleMessage` is
42
+ * documented PURE and must stay thin (design "Shape" section), so cursor
43
+ * pagination over the resource index is catalog-module responsibility, not
44
+ * protocol-handler responsibility, mirroring how `shouldCompose` centralizes
45
+ * the F1 predicate rather than letting it leak into the protocol layer.
46
+ *
47
+ * `buildCatalog`'s `root` is optional: omitted, the real implementation must
48
+ * resolve the package root from THIS MODULE's own location (`__dirname`),
49
+ * never from `ctx.cwd` (design row 16 / test-matrix rows 46-47) — `ctx.cwd`
50
+ * is the user's *project* (state IO), the catalog lives in the *package*.
51
+ *
52
+ * Pure over injected `readFile`/`readDir` seams so tests inject IO faults by
53
+ * monkeypatching the seam (never `chmod 0o000` — root bypasses mode bits and
54
+ * the test would silently pass with zero coverage in CI).
55
+ *
56
+ * Every throw carries a stable {@link REASON} code via a typed `CatalogError`
57
+ * (mirrors `workflow-fragments.cts`'s `REASON`/`fail()` idiom) so tests assert
58
+ * `err.reason === REASON.X` rather than regex-/substring-matching the
59
+ * human-readable message (CONTRIBUTING.md "Prohibited: Raw Text Matching on
60
+ * Test Outputs").
61
+ *
62
+ * `src/mcp-server.cts` gains `resources/*` + `prompts/*` `handleMessage`
63
+ * cases delegating to this module. The catalog is built once per process,
64
+ * lazily, and is immutable for the process's lifetime (Gall's Law — no
65
+ * watching, no invalidation, no subscriptions; see design "Known limits").
66
+ *
67
+ * ADR-457 build-at-publish: compiled by tsc to
68
+ * gsd-core/bin/lib/mcp-catalog.cjs (gitignored).
69
+ *
70
+ * ## Directory-walk fail-open/fail-closed split (buildCatalog)
71
+ *
72
+ * The three catalog roots (`gsd-core/workflows/`, `gsd-core/references/`,
73
+ * `commands/gsd/`) are each reached through exactly ONE "speculative" probe —
74
+ * `readDir(root/gsd-core)` and `readDir(root/commands)` — whose failure is
75
+ * TOLERATED as "this segment has zero entries" (never fatal, never a crash;
76
+ * design row 45 / row 16 "absent root does not fall back to cwd"). Every
77
+ * directory reached AFTER that, because a parent's successful listing already
78
+ * reported it as a real subdirectory entry, is CONFIRMED to exist; a read
79
+ * failure for a confirmed directory is fatal (`REASON.READ_FAILED`, design
80
+ * row 43 "a directory read failure fails closed" — "no partial catalog
81
+ * silently served"). This is what lets an absent `commands/` tree (row 45 /
82
+ * many unit fixtures that only populate `gsd-core/workflows/`) build an empty
83
+ * segment silently, while an INJECTED failure on an already-listed
84
+ * `gsd-core/workflows/` directory (row 43) still fails the whole build.
85
+ *
86
+ * `readFile` is never called at build time — only `readDir`, for discovery.
87
+ * A single resource's content is read (and, for a workflow, composed) lazily
88
+ * inside {@link readResource}/{@link getPrompt}, so a bad `readFile` for ONE
89
+ * entry never prevents that entry from being *listed*, only from being
90
+ * *read* (design row 14 / test-matrix row 42).
91
+ */
92
+ 'use strict';
93
+ var __importDefault = (this && this.__importDefault) || function (mod) {
94
+ return (mod && mod.__esModule) ? mod : { "default": mod };
95
+ };
96
+ Object.defineProperty(exports, "__esModule", { value: true });
97
+ exports.DEFAULT_PAGE_SIZE = exports.REASON = void 0;
98
+ exports.shouldCompose = shouldCompose;
99
+ exports.buildCatalog = buildCatalog;
100
+ exports.readResource = readResource;
101
+ exports.listResources = listResources;
102
+ exports.getPrompt = getPrompt;
103
+ const node_fs_1 = __importDefault(require("node:fs"));
104
+ const node_path_1 = __importDefault(require("node:path"));
105
+ const security_cjs_1 = require("./security.cjs");
106
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
107
+ const workflowFragments = require("./workflow-fragments.cjs");
108
+ const { composeWorkflow } = workflowFragments;
109
+ /**
110
+ * Frozen, stable reason codes for every typed throw this module's real
111
+ * implementation will produce. Tests assert `err.reason === REASON.X`
112
+ * (CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs") — shape
113
+ * copied from `workflow-fragments.cts`'s own `REASON` enum.
114
+ *
115
+ * - UNKNOWN_RESOURCE — uri (or a real-but-unindexed sibling path) is not a
116
+ * key in the prebuilt resource index; index membership is the sole
117
+ * authority (design "Hostile inputs" gate 1 / negative-space bullets).
118
+ * - UNKNOWN_PROMPT — prompt name is not a key in the prebuilt prompt index.
119
+ * - INVALID_URI — uri is syntactically malformed but not string-typed
120
+ * traversal shape: empty string, wrong scheme (e.g. `file://`).
121
+ * - TRAVERSAL_REFUSED — uri is shaped like a path-traversal or absolute-path
122
+ * escape attempt (`../`, `..\\`, percent/double-encoded, absolute posix/
123
+ * windows paths, null byte, a symlink caught by the second `validatePath`
124
+ * gate) — refused by defense-in-depth, index-first (design "Hostile
125
+ * inputs").
126
+ * - UNKNOWN_CURSOR — an unrecognized/malformed pagination cursor.
127
+ * - READ_FAILED — the injected `readFile`/`readDir` seam threw for one
128
+ * resource (or a directory) at build or read time; the failure is
129
+ * contained to that one entry (design row 14).
130
+ * - UNKNOWN_ROOT — uri's scheme is `gsd://` but its root segment names
131
+ * neither `workflows` nor `references`.
132
+ * - INVALID_PARAMS — a required parameter is missing or wrong-typed (e.g. a
133
+ * non-string uri/name passed to `readResource`/`getPrompt`).
134
+ *
135
+ * Adding a new reason requires updating this map AND the test that locks
136
+ * `Object.keys(REASON).sort()` as a coordinated change.
137
+ */
138
+ exports.REASON = Object.freeze({
139
+ UNKNOWN_RESOURCE: 'unknown_resource',
140
+ UNKNOWN_PROMPT: 'unknown_prompt',
141
+ INVALID_URI: 'invalid_uri',
142
+ TRAVERSAL_REFUSED: 'traversal_refused',
143
+ UNKNOWN_CURSOR: 'unknown_cursor',
144
+ READ_FAILED: 'read_failed',
145
+ UNKNOWN_ROOT: 'unknown_root',
146
+ INVALID_PARAMS: 'invalid_params',
147
+ });
148
+ /**
149
+ * Default page size for {@link listResources}. ~326 real entries is past the
150
+ * point where a single unpaginated response is polite (design row 3).
151
+ */
152
+ exports.DEFAULT_PAGE_SIZE = 50;
153
+ /**
154
+ * Throws a `TypeError` carrying `reason` (one of {@link REASON}) as a typed
155
+ * property, mirroring `workflow-fragments.cts`'s own `fail()` idiom so
156
+ * callers/tests never pattern-match message prose. `cause`, when given, is
157
+ * attached via the standard ES2022 `Error` cause chain (never string-baked
158
+ * into the message) so the original injected-seam/compose failure stays
159
+ * inspectable without shape-locking this module's own message text.
160
+ */
161
+ function fail(reason, message, cause) {
162
+ const options = cause === undefined ? undefined : { cause };
163
+ const err = new TypeError(`mcp-catalog: ${message}`, options);
164
+ err.reason = reason;
165
+ throw err;
166
+ }
167
+ // ─── shouldCompose — the F1 predicate ───────────────────────────────────────
168
+ // Mirrors bin/install.js's pre-refactor inline regex EXACTLY (see that file's
169
+ // comment block, preserved, for the two-reviewer rationale). `bin/install.js`
170
+ // now imports THIS function rather than re-declaring the regex (task D).
171
+ const WORKFLOWS_SCOPE_RE = /(?:^|\/)gsd-core\/workflows\//;
172
+ /**
173
+ * THE shared F1 predicate: does `relPath` (POSIX-normalized, relative to the
174
+ * catalog root) fall under `gsd-core/workflows/`? Only such paths are ever
175
+ * run through `composeWorkflow` — everything else (references, commands) is
176
+ * served verbatim. `bin/install.js` imports and calls this SAME function
177
+ * (replacing its inline regex) so the catalog and the installer can never
178
+ * independently drift on what gets composed (ADR-1671:309,
179
+ * `DEFECT.GENERATIVE-FIX`; the parity gate in
180
+ * `tests/mcp-catalog-parity.test.cjs` asserts exactly this).
181
+ */
182
+ function shouldCompose(relPath) {
183
+ if (typeof relPath !== 'string')
184
+ return false;
185
+ // Path is normalized UNCONDITIONALLY (backslash paths arrive on Linux too —
186
+ // CONTEXT.md path-separator rule), matching bin/install.js's own note.
187
+ const normalized = relPath.replace(/\\/g, '/');
188
+ return WORKFLOWS_SCOPE_RE.test(normalized);
189
+ }
190
+ // ─── buildCatalog — directory discovery ─────────────────────────────────────
191
+ /** Resolve the package root from THIS MODULE's own compiled location (`gsd-core/bin/lib/mcp-catalog.cjs`), never `ctx.cwd` (design row 16). */
192
+ function defaultPackageRoot() {
193
+ return node_path_1.default.resolve(__dirname, '..', '..', '..');
194
+ }
195
+ function defaultReadFile(absPath) {
196
+ return node_fs_1.default.readFileSync(absPath, 'utf8');
197
+ }
198
+ function defaultReadDir(absPath) {
199
+ return node_fs_1.default.readdirSync(absPath, { withFileTypes: true });
200
+ }
201
+ /** Speculative directory probe: failure (missing, unreadable) is tolerated as "zero entries", never fatal — nothing has confirmed this path exists yet. */
202
+ function tryReadDir(readDir, absPath) {
203
+ try {
204
+ return readDir(absPath);
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ /**
211
+ * Recursively walk `dirAbs` for `.md` files, calling `onFile(relPathFromDirAbs, absPath)`
212
+ * for each in listing order (callers re-sort as needed — see `listResources`).
213
+ * `dirAbs` is assumed CONFIRMED to exist (a parent's successful listing
214
+ * already reported it as a directory entry), so any `readDir` failure here —
215
+ * at this level or deeper — is fail-closed (`REASON.READ_FAILED`, design row
216
+ * 43): "a directory listing failure must surface as a typed error, never a
217
+ * silently partial catalog." Directory symlinks are inert here by
218
+ * construction: {@link DirEntryLike} exposes only `isDirectory()`, and a
219
+ * symlink's dirent type is never reported as a directory, so a symlinked
220
+ * subtree is neither recursed into nor silently skipped-with-a-lie — it is
221
+ * simply not `.md`-matched either, and falls out of the walk (the ONE
222
+ * client-controlled path surface, `resources/read`, still gets the
223
+ * `validatePath` second gate for a symlinked *file*; see `readIndexedResource`).
224
+ */
225
+ function walkMarkdownFilesFatal(dirAbs, readDir, onFile, relPrefix = '') {
226
+ let entries;
227
+ try {
228
+ entries = readDir(dirAbs);
229
+ }
230
+ catch (err) {
231
+ fail(exports.REASON.READ_FAILED, `directory listing failed: ${relPrefix || dirAbs}`, err);
232
+ }
233
+ for (const entry of entries) {
234
+ const childRel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
235
+ const childAbs = node_path_1.default.join(dirAbs, entry.name);
236
+ if (entry.isDirectory()) {
237
+ walkMarkdownFilesFatal(childAbs, readDir, onFile, childRel);
238
+ }
239
+ else if (entry.name.endsWith('.md')) {
240
+ onFile(childRel, childAbs);
241
+ }
242
+ }
243
+ }
244
+ /** Basename of a POSIX-joined relative path (never touches the OS path separator — every rel path this module builds is joined with literal `/`). */
245
+ function posixBaseName(relPath) {
246
+ const idx = relPath.lastIndexOf('/');
247
+ return idx === -1 ? relPath : relPath.slice(idx + 1);
248
+ }
249
+ /** `plan-phase` / `docs-update` -> `Plan Phase` / `Docs Update`; purely cosmetic, not asserted by any test. */
250
+ function titleFromBaseName(baseName) {
251
+ return baseName.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
252
+ }
253
+ /**
254
+ * Every segment served as an MCP **resource**. `commands` sits alongside
255
+ * `workflows`/`references` here because a host may legitimately want to READ
256
+ * a command's markdown without invoking it as a prompt: `resources/read`
257
+ * returns raw text while `prompts/get` returns wrapped messages — two MCP
258
+ * *shapes* over the SAME one file read through the SAME one read path, not a
259
+ * second source of truth. Drives both the resource listing walk and
260
+ * unindexed-uri classification (`UNKNOWN_ROOT` vs `UNKNOWN_RESOURCE`).
261
+ */
262
+ const RESOURCE_SEGMENTS = new Set(['workflows', 'references', 'commands']);
263
+ /** The subset of {@link RESOURCE_SEGMENTS} that lives under `gsd-core/` (as opposed to `commands/gsd/`, which is rooted elsewhere). */
264
+ const GSD_CORE_RESOURCE_SEGMENTS = new Set(['workflows', 'references']);
265
+ function indexResourceSegments(root, readDir, out) {
266
+ const gsdCoreAbs = node_path_1.default.join(root, 'gsd-core');
267
+ const gsdCoreEntries = tryReadDir(readDir, gsdCoreAbs);
268
+ if (gsdCoreEntries === null)
269
+ return;
270
+ for (const segmentEntry of gsdCoreEntries) {
271
+ if (!segmentEntry.isDirectory() || !GSD_CORE_RESOURCE_SEGMENTS.has(segmentEntry.name))
272
+ continue;
273
+ const segment = segmentEntry.name;
274
+ const segmentAbs = node_path_1.default.join(gsdCoreAbs, segment);
275
+ walkMarkdownFilesFatal(segmentAbs, readDir, (withinSegmentRelPath, absPath) => {
276
+ const relPath = `gsd-core/${segment}/${withinSegmentRelPath}`;
277
+ const uri = `gsd://${segment}/${withinSegmentRelPath}`;
278
+ const name = withinSegmentRelPath.replace(/\.md$/, '');
279
+ out.set(uri, {
280
+ uri,
281
+ name,
282
+ title: titleFromBaseName(posixBaseName(name)),
283
+ description: `GSD ${segment} resource: ${relPath}`,
284
+ mimeType: 'text/markdown',
285
+ relPath,
286
+ absPath,
287
+ });
288
+ });
289
+ }
290
+ }
291
+ function indexPromptSegment(root, readDir, out, resourcesOut) {
292
+ const commandsAbs = node_path_1.default.join(root, 'commands');
293
+ const commandsEntries = tryReadDir(readDir, commandsAbs);
294
+ if (commandsEntries === null)
295
+ return;
296
+ for (const entry of commandsEntries) {
297
+ if (!entry.isDirectory() || entry.name !== 'gsd')
298
+ continue;
299
+ const gsdAbs = node_path_1.default.join(commandsAbs, 'gsd');
300
+ walkMarkdownFilesFatal(gsdAbs, readDir, (withinRelPath, absPath) => {
301
+ const relPath = `commands/gsd/${withinRelPath}`;
302
+ // Bare command name — never a path (design row 8 / test-matrix row 31).
303
+ const name = posixBaseName(withinRelPath).replace(/\.md$/, '');
304
+ out.set(name, {
305
+ name,
306
+ title: titleFromBaseName(name),
307
+ description: `GSD command: ${name}`,
308
+ relPath,
309
+ absPath,
310
+ });
311
+ // Same file, second MCP shape: also indexed as a resource (see
312
+ // RESOURCE_SEGMENTS doc comment) so a host can `resources/read` a
313
+ // command's raw markdown without invoking it as a prompt.
314
+ const resourceUri = `gsd://commands/${withinRelPath}`;
315
+ const resourceName = withinRelPath.replace(/\.md$/, '');
316
+ resourcesOut.set(resourceUri, {
317
+ uri: resourceUri,
318
+ name: resourceName,
319
+ title: titleFromBaseName(posixBaseName(resourceName)),
320
+ description: `GSD commands resource: ${relPath}`,
321
+ mimeType: 'text/markdown',
322
+ relPath,
323
+ absPath,
324
+ });
325
+ });
326
+ }
327
+ }
328
+ /**
329
+ * Build the immutable catalog index over `root` (or, if omitted, this
330
+ * module's own package location — never `ctx.cwd`; design row 16).
331
+ * Pure over the injected `readFile`/`readDir` seams so IO faults are tested
332
+ * by monkeypatching them, never `chmod 0o000`. Only `readDir` is called here
333
+ * (discovery) — `readFile` is never invoked at build time; see the module
334
+ * doc comment's "Directory-walk fail-open/fail-closed split" section.
335
+ */
336
+ function buildCatalog(opts = {}) {
337
+ const root = opts.root !== undefined ? opts.root : defaultPackageRoot();
338
+ const readFile = opts.readFile || defaultReadFile;
339
+ const readDir = opts.readDir || defaultReadDir;
340
+ const resources = new Map();
341
+ const prompts = new Map();
342
+ indexResourceSegments(root, readDir, resources);
343
+ indexPromptSegment(root, readDir, prompts, resources);
344
+ return { resources, prompts, root, readFile };
345
+ }
346
+ // ─── readResource — the one client-controlled path surface ─────────────────
347
+ /** Percent-decode up to a few passes (double-encoding, design "Hostile inputs"), stopping early once decoding is a no-op or throws on malformed escapes. */
348
+ function decodePassesFor(value) {
349
+ const normalized = value.replace(/\\/g, '/');
350
+ const passes = [normalized];
351
+ let current = normalized;
352
+ for (let i = 0; i < 3; i++) {
353
+ let decoded;
354
+ try {
355
+ decoded = decodeURIComponent(current);
356
+ }
357
+ catch {
358
+ break;
359
+ }
360
+ if (decoded === current)
361
+ break;
362
+ passes.push(decoded);
363
+ current = decoded;
364
+ }
365
+ return passes;
366
+ }
367
+ /** Does any decode pass of `value` contain a literal `..` path segment? */
368
+ function hasTraversalSegment(value) {
369
+ return decodePassesFor(value).some((pass) => pass.split('/').some((seg) => seg === '..'));
370
+ }
371
+ const GSD_URI_RE = /^gsd:\/\/([^/]*)\/(.*)$/;
372
+ const WINDOWS_ABS_RE = /^[A-Za-z]:\//;
373
+ /**
374
+ * Classify a uri NOT present in the index, to pick the right {@link REASON}
375
+ * for the refusal. This is diagnostic only — the actual access decision is
376
+ * the prebuilt index-membership check in {@link readResource}, which already
377
+ * rejects every one of these shapes by construction (design "Hostile inputs"
378
+ * gate 1: "a traversal uri is simply not a key").
379
+ */
380
+ function classifyUnindexedUri(uri) {
381
+ if (uri.includes('\0'))
382
+ return exports.REASON.TRAVERSAL_REFUSED;
383
+ if (uri.startsWith('file://'))
384
+ return exports.REASON.INVALID_URI;
385
+ const normalized = uri.replace(/\\/g, '/');
386
+ if (!normalized.startsWith('gsd://')) {
387
+ if (normalized.startsWith('/'))
388
+ return exports.REASON.TRAVERSAL_REFUSED;
389
+ if (WINDOWS_ABS_RE.test(normalized))
390
+ return exports.REASON.TRAVERSAL_REFUSED;
391
+ return exports.REASON.INVALID_URI;
392
+ }
393
+ const match = GSD_URI_RE.exec(normalized);
394
+ if (!match)
395
+ return exports.REASON.INVALID_URI;
396
+ const [, rootSegment, rest] = match;
397
+ if (!RESOURCE_SEGMENTS.has(rootSegment))
398
+ return exports.REASON.UNKNOWN_ROOT;
399
+ if (hasTraversalSegment(rest))
400
+ return exports.REASON.TRAVERSAL_REFUSED;
401
+ return exports.REASON.UNKNOWN_RESOURCE;
402
+ }
403
+ /** Second, independent gate (design "Hostile inputs" gate 2): re-validate an INDEXED entry's relPath against the catalog root, catching a symlink planted after the index was built. */
404
+ function readIndexedResource(catalog, entry) {
405
+ const check = (0, security_cjs_1.validatePath)(entry.relPath, catalog.root);
406
+ if (!check.safe) {
407
+ fail(exports.REASON.TRAVERSAL_REFUSED, `indexed resource escapes catalog root: ${entry.relPath}`);
408
+ }
409
+ let raw;
410
+ try {
411
+ raw = catalog.readFile(entry.absPath);
412
+ }
413
+ catch (err) {
414
+ fail(exports.REASON.READ_FAILED, `failed to read resource: ${entry.relPath}`, err);
415
+ }
416
+ let text = raw;
417
+ if (shouldCompose(entry.relPath)) {
418
+ try {
419
+ text = composeWorkflow(raw, { sourcePath: entry.relPath });
420
+ }
421
+ catch (err) {
422
+ fail(exports.REASON.READ_FAILED, `failed to compose resource: ${entry.relPath}`, err);
423
+ }
424
+ }
425
+ return { uri: entry.uri, mimeType: entry.mimeType, text };
426
+ }
427
+ /**
428
+ * Read one resource by uri. Index-membership-then-validate (design "Hostile
429
+ * inputs"): the uri must be an exact key in `catalog.resources`; a workflow
430
+ * entry is served through `shouldCompose`-gated composition (F1), a
431
+ * reference entry is served verbatim. Throws a {@link CatalogError} for any
432
+ * uri not present in the index — never an empty success.
433
+ */
434
+ function readResource(catalog, uri) {
435
+ if (typeof uri !== 'string') {
436
+ fail(exports.REASON.INVALID_PARAMS, `uri must be a string, got ${typeof uri}`);
437
+ }
438
+ if (uri.length === 0) {
439
+ fail(exports.REASON.INVALID_URI, 'uri must not be empty');
440
+ }
441
+ // Gate 1 — index membership, exact-match, never a path join.
442
+ const entry = catalog.resources.get(uri);
443
+ if (entry) {
444
+ return readIndexedResource(catalog, entry);
445
+ }
446
+ fail(classifyUnindexedUri(uri), `unknown or refused resource uri: ${uri}`);
447
+ }
448
+ function encodeCursor(index) {
449
+ return Buffer.from(JSON.stringify({ i: index }), 'utf8').toString('base64url');
450
+ }
451
+ function decodeCursor(cursor) {
452
+ try {
453
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
454
+ const parsed = JSON.parse(json);
455
+ if (parsed &&
456
+ typeof parsed === 'object' &&
457
+ Number.isInteger(parsed.i) &&
458
+ parsed.i >= 0) {
459
+ return { index: parsed.i };
460
+ }
461
+ return null;
462
+ }
463
+ catch {
464
+ return null;
465
+ }
466
+ }
467
+ /**
468
+ * List resources, sorted deterministically by uri, optionally paginated.
469
+ * Throws a {@link CatalogError} (`REASON.UNKNOWN_CURSOR`) for a malformed or
470
+ * unrecognized cursor rather than silently resetting to page 1.
471
+ */
472
+ function listResources(catalog, opts = {}) {
473
+ const pageSize = opts.pageSize !== undefined && Number.isFinite(opts.pageSize) && opts.pageSize > 0 ? Math.floor(opts.pageSize) : exports.DEFAULT_PAGE_SIZE;
474
+ const sorted = [...catalog.resources.values()].sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
475
+ let startIndex = 0;
476
+ if (opts.cursor !== undefined) {
477
+ const decoded = decodeCursor(opts.cursor);
478
+ if (decoded === null) {
479
+ fail(exports.REASON.UNKNOWN_CURSOR, `unrecognized pagination cursor: ${opts.cursor}`);
480
+ }
481
+ startIndex = decoded.index;
482
+ }
483
+ const page = sorted.slice(startIndex, startIndex + pageSize);
484
+ const nextIndex = startIndex + page.length;
485
+ const result = { resources: page };
486
+ if (nextIndex < sorted.length) {
487
+ result.nextCursor = encodeCursor(nextIndex);
488
+ }
489
+ return result;
490
+ }
491
+ // ─── getPrompt ───────────────────────────────────────────────────────────────
492
+ /**
493
+ * Get one prompt by its bare command name (never a path). `args`, if
494
+ * supplied, is accepted and ignored (design row 11 — no command template
495
+ * takes injected arguments today). Throws a {@link CatalogError} for any
496
+ * name not present in the index.
497
+ */
498
+ function getPrompt(catalog, name, args) {
499
+ void args;
500
+ if (typeof name !== 'string' || name.length === 0) {
501
+ fail(exports.REASON.INVALID_PARAMS, `prompt name must be a non-empty string, got ${typeof name}`);
502
+ }
503
+ const entry = catalog.prompts.get(name);
504
+ if (!entry) {
505
+ fail(exports.REASON.UNKNOWN_PROMPT, `unknown prompt: ${name}`);
506
+ }
507
+ let raw;
508
+ try {
509
+ raw = catalog.readFile(entry.absPath);
510
+ }
511
+ catch (err) {
512
+ fail(exports.REASON.READ_FAILED, `failed to read prompt: ${entry.relPath}`, err);
513
+ }
514
+ return {
515
+ description: entry.description,
516
+ messages: [{ role: 'user', content: { type: 'text', text: raw } }],
517
+ };
518
+ }