@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ci-next-health.cjs — #4422 base-branch health gate.
5
+ //
6
+ // Risk asymmetry drives this design, same shape as scripts/ci-pr-mergeability.cjs
7
+ // but pointed the other direction: on 2026-09-06 three unrelated PRs merged on
8
+ // top of an already-broken `next` before anyone noticed, because nothing checked
9
+ // the base branch's OWN health before letting a PR land on it. A false positive
10
+ // here (a healthy `next` wrongly reported RED) blocks every PR merge in the repo
11
+ // until a human notices and applies the `fix-next` bypass label — annoying, but
12
+ // loud and immediately actionable. A false negative (a broken `next` reported
13
+ // healthy) reproduces the #4422 incident exactly. So, unlike the mergeability
14
+ // preflight, this gate does NOT fail open on a definite red signal — it fails
15
+ // open only when the signal itself is unavailable or inapplicable (wrong event,
16
+ // no resolvable base ref, an API read that throws). Once GitHub actually answers
17
+ // with a non-success conclusion for the base branch's last push-triggered Tests
18
+ // run, that is treated as authoritative and blocks — with one explicit, visible,
19
+ // human-operated escape hatch (the `fix-next` label) for the PR that is itself
20
+ // the fix-forward.
21
+ //
22
+ // Every non-success conclusion (`failure`, `cancelled`, `timed_out`,
23
+ // `action_required`, ...) is treated as RED, not just `failure` — a cancelled or
24
+ // timed-out run on the base branch is not evidence the branch is healthy, it is
25
+ // evidence nobody knows yet.
26
+
27
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
28
+
29
+ const VERDICT = Object.freeze({
30
+ CLEAN: 'CLEAN',
31
+ RED: 'RED',
32
+ BYPASSED: 'BYPASSED',
33
+ SKIPPED_NOT_APPLICABLE: 'SKIPPED_NOT_APPLICABLE',
34
+ INDETERMINATE: 'INDETERMINATE',
35
+ });
36
+
37
+ const APPLICABLE_EVENTS = new Set(['pull_request', 'merge_group']);
38
+ const BYPASS_LABEL = 'fix-next';
39
+ const TESTS_WORKFLOW_FILE = 'test.yml';
40
+
41
+ /**
42
+ * Pure, total classifier: the GitHub "list workflow runs" response payload ->
43
+ * CLEAN | RED | INDETERMINATE. Never throws.
44
+ *
45
+ * - Not an object, or `workflow_runs` isn't an array -> INDETERMINATE: the
46
+ * shape we depend on is not present, so nothing can be concluded.
47
+ * - No completed push runs found yet (e.g. a brand-new `release/**`/`hotfix/**`
48
+ * branch with no Tests history) -> CLEAN: absence of evidence of red is not
49
+ * evidence of red, and a brand-new branch must not be permanently unmergeable.
50
+ * - The most recent run's `conclusion === 'success'` -> CLEAN.
51
+ * - Anything else (failure, cancelled, timed_out, action_required, ...) -> RED.
52
+ */
53
+ function classifyRunConclusion(payload) {
54
+ if (payload === null || typeof payload !== 'object') return VERDICT.INDETERMINATE;
55
+ if (!Array.isArray(payload.workflow_runs)) return VERDICT.INDETERMINATE;
56
+ if (payload.workflow_runs.length === 0) return VERDICT.CLEAN;
57
+ const [latest] = payload.workflow_runs;
58
+ if (latest && latest.conclusion === 'success') return VERDICT.CLEAN;
59
+ return VERDICT.RED;
60
+ }
61
+
62
+ /**
63
+ * Dependency-injected orchestrator. `fetchLatestRun` is an async function
64
+ * taking the resolved base branch name and returning the parsed API payload
65
+ * (or throwing) — injected so tests never touch the network.
66
+ *
67
+ * @returns {Promise<{verdict:string, payload:*, reason:string}>}
68
+ */
69
+ async function resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels } = {}) {
70
+ if (!APPLICABLE_EVENTS.has(eventName)) {
71
+ return { verdict: VERDICT.SKIPPED_NOT_APPLICABLE, payload: null, reason: 'not-applicable-event' };
72
+ }
73
+
74
+ if (typeof baseRef !== 'string' || baseRef.trim() === '') {
75
+ return { verdict: VERDICT.INDETERMINATE, payload: null, reason: 'no-base-ref' };
76
+ }
77
+
78
+ let payload = null;
79
+ try {
80
+ payload = await fetchLatestRun(baseRef);
81
+ } catch (err) {
82
+ return { verdict: VERDICT.INDETERMINATE, payload: null, reason: `fetch-failed: ${err.message}` };
83
+ }
84
+
85
+ const classified = classifyRunConclusion(payload);
86
+ if (classified !== VERDICT.RED) {
87
+ return { verdict: classified, payload, reason: 'resolved' };
88
+ }
89
+
90
+ // Escape hatch. `labels` is empty/absent for merge_group — that event has no
91
+ // label surface today, which means a queued merge-group commit cannot use
92
+ // this bypass. Known gap, not solved here: a maintainer must land the
93
+ // fix-forward as a direct pull_request merge (where the label IS readable)
94
+ // rather than through the merge queue, until GitHub exposes an equivalent
95
+ // signal for merge_group.
96
+ const labelList = Array.isArray(labels) ? labels : [];
97
+ if (labelList.includes(BYPASS_LABEL)) {
98
+ return { verdict: VERDICT.BYPASSED, payload, reason: 'bypass-label' };
99
+ }
100
+
101
+ return { verdict: VERDICT.RED, payload, reason: 'red' };
102
+ }
103
+
104
+ function usage() {
105
+ return [
106
+ 'Usage:',
107
+ ' node scripts/ci-next-health.cjs',
108
+ '',
109
+ 'CI-only gate: checks whether the PR/merge-group\'s base branch\'s own last',
110
+ 'push-triggered Tests run is red, and fails the job (exit 1) when it is —',
111
+ 'unless the PR carries the "fix-next" bypass label. Every other case',
112
+ '(wrong event, no resolvable base ref, an unreadable API read) fails open',
113
+ '(exit 0).',
114
+ '',
115
+ 'Environment variables read:',
116
+ ' GITHUB_EVENT_NAME workflow trigger event; only "pull_request" and',
117
+ ' "merge_group" are checked',
118
+ ' GITHUB_REPOSITORY owner/repo',
119
+ ' GITHUB_TOKEN optional bearer token for the API read',
120
+ ' GITHUB_BASE_REF PR base branch name (pull_request events; set',
121
+ ' automatically by GitHub Actions)',
122
+ ' MERGE_GROUP_BASE_REF merge-group base ref (merge_group events; the',
123
+ ' caller workflow must populate this from',
124
+ ' github.event.merge_group.base_ref — a leading',
125
+ ' "refs/heads/" prefix is stripped if present)',
126
+ ' GITHUB_API_URL GitHub API base URL (default: https://api.github.com)',
127
+ ' PR_LABELS comma-separated PR label names (pull_request events;',
128
+ ' the caller workflow must populate this from',
129
+ ' github.event.pull_request.labels.*.name); checked',
130
+ ' for the literal "fix-next" bypass label',
131
+ ' GITHUB_OUTPUT path to append verdict= step output to',
132
+ ' GITHUB_STEP_SUMMARY path to append a human-readable summary to',
133
+ ].join('\n');
134
+ }
135
+
136
+ function parseArgs(argv) {
137
+ for (const arg of argv) {
138
+ if (arg === '--help' || arg === '-h') {
139
+ process.stdout.write(`${usage()}\n`);
140
+ throw new ExitError(0);
141
+ }
142
+ throw new Error(`unknown argument: ${arg}`);
143
+ }
144
+ }
145
+
146
+ function writeOutput(lines) {
147
+ const outputPath = process.env.GITHUB_OUTPUT;
148
+ if (typeof outputPath !== 'string' || outputPath === '') return;
149
+ try {
150
+ const fs = require('node:fs');
151
+ fs.appendFileSync(outputPath, `${lines.join('\n')}\n`);
152
+ } catch (err) {
153
+ // A failure while REPORTING the verdict must never invert the gate.
154
+ process.stderr.write(`::warning::failed to write GITHUB_OUTPUT: ${err.message}\n`);
155
+ }
156
+ }
157
+
158
+ function writeSummary(text) {
159
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
160
+ if (typeof summaryPath !== 'string' || summaryPath === '') return;
161
+ try {
162
+ const fs = require('node:fs');
163
+ fs.appendFileSync(summaryPath, `${text}\n`);
164
+ } catch (err) {
165
+ process.stderr.write(`::warning::failed to write GITHUB_STEP_SUMMARY: ${err.message}\n`);
166
+ }
167
+ }
168
+
169
+ function resolveBaseRef(eventName) {
170
+ if (eventName === 'pull_request') {
171
+ return process.env.GITHUB_BASE_REF || '';
172
+ }
173
+ if (eventName === 'merge_group') {
174
+ const raw = process.env.MERGE_GROUP_BASE_REF || '';
175
+ return raw.startsWith('refs/heads/') ? raw.slice('refs/heads/'.length) : raw;
176
+ }
177
+ return '';
178
+ }
179
+
180
+ function parseLabels(raw) {
181
+ if (typeof raw !== 'string' || raw.trim() === '') return [];
182
+ return raw.split(',').map((s) => s.trim()).filter((s) => s !== '');
183
+ }
184
+
185
+ function runUrlOf(payload) {
186
+ const run = payload && Array.isArray(payload.workflow_runs) ? payload.workflow_runs[0] : undefined;
187
+ return run && typeof run.html_url === 'string' ? run.html_url : '(unknown run URL)';
188
+ }
189
+
190
+ // `argv` defaults to real CLI argv but is a parameter so tests can call
191
+ // main() in-process (e.g. main([])) without inheriting the test runner's own
192
+ // argv, which would otherwise trip parseArgs's "unknown argument" branch.
193
+ async function main(argv = process.argv.slice(2)) {
194
+ parseArgs(argv);
195
+
196
+ const eventName = process.env.GITHUB_EVENT_NAME;
197
+ const repo = process.env.GITHUB_REPOSITORY || '';
198
+ const token = process.env.GITHUB_TOKEN || '';
199
+ const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com';
200
+ const baseRef = resolveBaseRef(eventName);
201
+ const labels = parseLabels(process.env.PR_LABELS);
202
+
203
+ const fetchLatestRun = async (branch) => {
204
+ const url = `${apiBase}/repos/${repo}/actions/workflows/${TESTS_WORKFLOW_FILE}/runs`
205
+ + `?branch=${encodeURIComponent(branch)}&event=push&status=completed&per_page=1`;
206
+ const headers = {
207
+ accept: 'application/vnd.github+json',
208
+ 'x-github-api-version': '2022-11-28',
209
+ 'user-agent': 'gsd-core-ci-next-health',
210
+ };
211
+ if (token) headers.authorization = `Bearer ${token}`;
212
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
213
+ if (!response.ok) {
214
+ throw new Error(`GitHub API responded ${response.status}`);
215
+ }
216
+ const text = await response.text();
217
+ try {
218
+ return JSON.parse(text);
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
224
+ const result = await resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels });
225
+
226
+ writeOutput([`verdict=${result.verdict}`]);
227
+ writeSummary(`Base branch health: ${result.verdict}`);
228
+
229
+ if (result.verdict === VERDICT.RED) {
230
+ const runUrl = runUrlOf(result.payload);
231
+ process.stderr.write(
232
+ `::error::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
233
+ + 'wait for a fix-forward merge, or if THIS pull request is the fix, ask a '
234
+ + `maintainer to apply the "${BYPASS_LABEL}" label to explicitly override this gate.\n`,
235
+ );
236
+ return 1;
237
+ }
238
+
239
+ if (result.verdict === VERDICT.BYPASSED) {
240
+ const runUrl = runUrlOf(result.payload);
241
+ process.stderr.write(
242
+ `::warning::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
243
+ + `a human applied the "${BYPASS_LABEL}" label to explicitly override this gate.\n`,
244
+ );
245
+ return 0;
246
+ }
247
+
248
+ if (result.verdict === VERDICT.INDETERMINATE) {
249
+ process.stderr.write(
250
+ `::warning::could not determine "${baseRef || '(no base ref)'}"'s Tests health (${result.reason}); `
251
+ + 'proceeding (fail-open).\n',
252
+ );
253
+ return 0;
254
+ }
255
+
256
+ process.stdout.write(`Base branch health: ${result.verdict}\n`);
257
+ return 0;
258
+ }
259
+
260
+ if (require.main === module) {
261
+ runMain(main);
262
+ }
263
+
264
+ module.exports = {
265
+ VERDICT,
266
+ BYPASS_LABEL,
267
+ TESTS_WORKFLOW_FILE,
268
+ classifyRunConclusion,
269
+ resolveNextHealth,
270
+ main,
271
+ };
@@ -4,9 +4,9 @@
4
4
  // Shell-agnostic: invoked as `node scripts/ci-prepare-test-scope.cjs` from any shell.
5
5
  //
6
6
  // Required environment variables (set by the workflow step's `env:` block):
7
- // TEST_SCOPE — "windows" | "targeted"
7
+ // TEST_SCOPE — "targeted" (the `windows` scope was retired by #4641:
8
+ // test-conformance is now the sole Windows selector)
8
9
  // TARGETED_TESTS — space-separated test file list (from ci-test-scope.cjs output)
9
- // WINDOWS_TESTS — space-separated test file list for the windows lane
10
10
  //
11
11
  // Writes: .ci-selected-tests.txt (one file per line, no blanks)
12
12
  // Exit 0 = success; exit 1 = unknown scope.
@@ -46,13 +46,14 @@ function isResolvable(entry, root) {
46
46
 
47
47
  // Resolve the scoped test selection for the lane. Pure (no I/O beyond the
48
48
  // existence probe under `root`) so it can be unit-tested directly.
49
- function resolveSelection({ scope, targeted, windows, root }) {
49
+ function resolveSelection({ scope, targeted, root }) {
50
50
  let selected;
51
- if (scope === 'windows') {
52
- selected = windows;
53
- } else if (scope === 'targeted') {
51
+ if (scope === 'targeted') {
54
52
  selected = targeted;
55
53
  } else {
54
+ // #4641: the `windows` scope was retired (test-conformance is now the
55
+ // sole Windows selector) — an unknown/retired scope must fail loudly
56
+ // rather than silently selecting nothing.
56
57
  throw new ExitError(1, `::error::Unknown test scope: ${scope}`);
57
58
  }
58
59
 
@@ -75,7 +76,6 @@ function main() {
75
76
  const lines = resolveSelection({
76
77
  scope: process.env.TEST_SCOPE || '',
77
78
  targeted: process.env.TARGETED_TESTS || '',
78
- windows: process.env.WINDOWS_TESTS || '',
79
79
  root,
80
80
  });
81
81
 
@@ -3,9 +3,10 @@
3
3
 
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
- const { existsSync, readdirSync, appendFileSync } = require('fs');
6
+ const { existsSync, readdirSync, appendFileSync, readFileSync } = require('fs');
7
7
 
8
8
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
9
+ const { classifyContent, NOISY_FOR_SOURCE_REACHABILITY } = require('./gen-platform-conformance-tier.cjs');
9
10
 
10
11
  // Workflow files that are purely administrative / policy bots. Changes to these
11
12
  // files do NOT require the cross-platform test matrix — only a lightweight
@@ -484,12 +485,68 @@ function addAll(set, values) {
484
485
  // fullMatrix=true, so the full Windows lane already runs when those paths
485
486
  // change. The old six-hint list pulled 102 of ~633 test files into the scoped
486
487
  // windows lane, turning it into a ~10-minute job on every PR.
488
+ // #4641: the scoped windows lane itself is gone. These hints now drive
489
+ // full_matrix instead — a matched RULE whose tests[] includes a
490
+ // windows-hint filename AND that hinted file is itself in the conformance
491
+ // tier (per reachesConformanceTierOrSeam) escalates straight to full_matrix
492
+ // (routed to test-conformance, the sole Windows selector) rather than
493
+ // feeding a side lane. Tier-backed on purpose: full_matrix only ever runs
494
+ // CONFORMANCE_TIER_FILES, so a hint on a non-tier file would cost 4 CI jobs
495
+ // with zero Windows coverage of that file. See
496
+ // docs/adr/4641-windows-selector-consolidation.md.
487
497
  const WINDOWS_HINTS = ['windows', 'win32', 'shell', 'path'];
488
498
  const isWindowsHint = s => WINDOWS_HINTS.some(k => s.toLowerCase().includes(k));
489
499
 
490
- function classify(files) {
500
+ // A change to the classification mechanism itself cannot be presumed safe by
501
+ // the very mechanism being changed (#4592).
502
+ const CLASSIFIER_DEFINITION_FILES = new Set([
503
+ 'scripts/gen-platform-conformance-tier.cjs',
504
+ 'scripts/lib/platform-conformance-tier.generated.cjs',
505
+ 'scripts/lib/suite-detection.cjs',
506
+ ]);
507
+
508
+ /**
509
+ * Does `file`'s blast radius reach (a) Phase 2's conformance-tier test-file
510
+ * list or (b) a live platform-conditional signal in src/? Fail-safe: any
511
+ * thrown error (a require failure, a readFileSync failure, a malformed
512
+ * generated module, etc.) is treated as uncertainty and returns true — per
513
+ * #4592's explicit "fail-safe to full_matrix=true on any reachability-
514
+ * computation error or uncertainty" requirement.
515
+ * `loadConformanceTier` is injectable (defaults to the real generated module)
516
+ * solely so tests can simulate a load failure without touching the real,
517
+ * committed generated file.
518
+ * @param {string} file
519
+ * @param {{loadConformanceTier?: () => {CONFORMANCE_TIER_FILES: string[]}}} [deps]
520
+ * @returns {boolean}
521
+ */
522
+ function reachesConformanceTierOrSeam(file, deps = {}) {
523
+ const loadConformanceTier =
524
+ deps.loadConformanceTier || (() => require('./lib/platform-conformance-tier.generated.cjs'));
525
+ try {
526
+ if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
527
+ const { CONFORMANCE_TIER_FILES } = loadConformanceTier();
528
+ return CONFORMANCE_TIER_FILES.includes(file);
529
+ }
530
+
531
+ if (file.startsWith('src/')) {
532
+ const content = readFileSync(file, 'utf8');
533
+ const { signals } = classifyContent(content);
534
+ const narrowSignals = signals.filter(signal => !NOISY_FOR_SOURCE_REACHABILITY.has(signal));
535
+ return narrowSignals.length > 0;
536
+ }
537
+
538
+ return false;
539
+ } catch {
540
+ return true;
541
+ }
542
+ }
543
+
544
+ // `reachabilityDeps` is injectable (defaults to {}, which makes
545
+ // reachesConformanceTierOrSeam use the real generated module) solely so
546
+ // tests can simulate a reachability-computation failure without touching
547
+ // the real, committed generated file.
548
+ function classify(files, reachabilityDeps = {}) {
491
549
  const targeted = new Set();
492
- const windows = new Set();
493
550
  const reasons = [];
494
551
  let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
495
552
  let inertCiChanged = false; // inert workflow files
@@ -526,15 +583,35 @@ function classify(files) {
526
583
 
527
584
  if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
528
585
  targeted.add(file);
529
- // #494 invariant, narrowed: a changed test must still be exercised on
530
- // the divergent OS before merge, but at per-file cost — it ALWAYS joins
531
- // the scoped windows lane instead of triggering the three full parity
532
- // lanes. (full_matrix fired on 15/15 sampled PRs because test-driven
533
- // PRs always touch tests/, costing ~25 runner-minutes each.) Changed
534
- // tests already run on the two ubuntu-24 lanes via targeted_tests; the
535
- // residual macOS / windows cross-product is covered by the full
536
- // matrix on every push to next.
537
- windows.add(file);
586
+ // #494 originally narrowed this to skip full_matrix for changed test
587
+ // files, on the theory that ubuntu targeted_tests + the scoped windows
588
+ // lane already covered them. Rescinded per #4421: PR #4384 landed a
589
+ // macOS-only regression on 2026-09-06 that stayed invisible pre-merge
590
+ // precisely because this carve-out suppressed the only macOS signal.
591
+ // #4592: the blanket rule is replaced with a reachability check — only
592
+ // a changed test file that actually reaches Phase 2's conformance-tier
593
+ // list (or is the classification mechanism itself) forces full_matrix.
594
+ if (reachesConformanceTierOrSeam(file, reachabilityDeps)) {
595
+ fullMatrix = true;
596
+ reasons.push(`${file}: conformance-tier reachability`);
597
+ }
598
+ }
599
+
600
+ // #4592: a src/-only diff (no test file touched) must still be able to
601
+ // set full_matrix=true when it carries a live platform-conditional
602
+ // signal — this is independent of the tests/ branch above.
603
+ if (file.startsWith('src/') && reachesConformanceTierOrSeam(file, reachabilityDeps)) {
604
+ fullMatrix = true;
605
+ reasons.push(`${file}: platform seam reachability`);
606
+ }
607
+
608
+ // #4592: a changed file that IS the classification mechanism itself
609
+ // (neither under tests/ nor src/, so neither branch above reaches it)
610
+ // must also force full_matrix — a change to the mechanism cannot be
611
+ // presumed safe by the very mechanism being changed.
612
+ if (CLASSIFIER_DEFINITION_FILES.has(file)) {
613
+ fullMatrix = true;
614
+ reasons.push(`${file}: reachability classifier definition changed`);
538
615
  }
539
616
 
540
617
  for (const rule of RULES) {
@@ -542,6 +619,32 @@ function classify(files) {
542
619
  addAll(targeted, rule.tests);
543
620
  reasons.push(`${file}: ${rule.name}`);
544
621
  if (rule.fullMatrix) fullMatrix = true;
622
+ // #4641: a rule that pulls in a test file matching a Windows-sensitive
623
+ // filename hint is the one non-redundant residue of the deleted
624
+ // scoped windows lane — escalate to full_matrix (test-conformance)
625
+ // instead of feeding a side lane. TIER-BACKED (measured): full_matrix
626
+ // routes to test-conformance, which runs ONLY CONFORMANCE_TIER_FILES —
627
+ // escalating on the filename hint alone can fire on a hinted test that
628
+ // isn't in that tier, costing 4 CI jobs while never actually running it
629
+ // on Windows. The predicate below requires BOTH the hint AND tier
630
+ // membership (via reachesConformanceTierOrSeam, the same fail-safe
631
+ // reachability helper used elsewhere in this file, so error/uncertainty
632
+ // behavior stays identical). Measured: over the 16 RULES entries this
633
+ // narrowed form fires on the same rules as the un-narrowed form today
634
+ // (no behavior change now, correct-by-construction going forward). The
635
+ // broader alternative — escalate on ANY tier member a rule pulls in,
636
+ // ignoring the filename hint — was measured and rejected: it fires on
637
+ // 14 of 16 rules, newly escalating most ordinary product-code PRs
638
+ // (src/, agents/, commands/, hooks/, skills/, config paths). Only one
639
+ // rule's escalation is live today: 'portability lint rules (ADR-1703)'.
640
+ // Four others already had fullMatrix: true (no-op here), and 'inert
641
+ // CI''s escalation is overridden downstream by the inert-CI reset.
642
+ // Distinct, greppable reason so the conformance-lane coverage for it
643
+ // is traceable per rule.
644
+ if (rule.tests.some(t => isWindowsHint(t) && reachesConformanceTierOrSeam(t, reachabilityDeps))) {
645
+ fullMatrix = true;
646
+ reasons.push(`${file}: ${rule.name} (windows-hint rule test, #4641)`);
647
+ }
545
648
  }
546
649
  }
547
650
  }
@@ -555,7 +658,7 @@ function classify(files) {
555
658
  // covered by .github/workflows/install-smoke.yml
556
659
  'tests/release-tarball-smoke.install.test.cjs',
557
660
  ]);
558
- for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); windows.delete(f); }
661
+ for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); }
559
662
 
560
663
  // code_changed: true when product/pipeline OR inert CI changed.
561
664
  // Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
@@ -569,8 +672,6 @@ function classify(files) {
569
672
  targetedTests.push('unit');
570
673
  }
571
674
 
572
- const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort());
573
-
574
675
  // Inert-CI-only: full_matrix must be false (override any RULES that fired).
575
676
  if (inertCiChanged && !productOrPipelineChanged) {
576
677
  fullMatrix = false;
@@ -579,12 +680,11 @@ function classify(files) {
579
680
  // Normalize: when code_changed is false, the output must be self-consistent.
580
681
  // A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md
581
682
  // matches the installer rule via path.includes('install')), leaving full_matrix=true and
582
- // non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed)
683
+ // non-empty targeted_tests. The workflow skips correctly (gated on code_changed)
583
684
  // but the output object would be self-contradictory. Force a clean "nothing to run" result.
584
685
  if (!codeChanged) {
585
686
  fullMatrix = false;
586
687
  targetedTests.length = 0;
587
- windowsTests.length = 0;
588
688
  }
589
689
 
590
690
  return {
@@ -592,7 +692,6 @@ function classify(files) {
592
692
  product_changed: productOrPipelineChanged,
593
693
  full_matrix: fullMatrix,
594
694
  targeted_tests: targetedTests,
595
- windows_tests: windowsTests,
596
695
  reasons: [...new Set(reasons)].sort(),
597
696
  };
598
697
  }
@@ -604,7 +703,6 @@ function writeOutputs(result) {
604
703
  `product_changed=${result.product_changed}`,
605
704
  `full_matrix=${result.full_matrix}`,
606
705
  `targeted_tests=${result.targeted_tests.join(' ')}`,
607
- `windows_tests=${result.windows_tests.join(' ')}`,
608
706
  ];
609
707
  appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
610
708
  }
@@ -630,4 +728,12 @@ if (require.main === module) {
630
728
  runMain(main);
631
729
  }
632
730
 
633
- module.exports = { RULES, missingRuleTestFiles, PROTECTED_WORKFLOWS, INERT_WORKFLOWS, missingProtectedWorkflows };
731
+ module.exports = {
732
+ RULES,
733
+ missingRuleTestFiles,
734
+ PROTECTED_WORKFLOWS,
735
+ INERT_WORKFLOWS,
736
+ missingProtectedWorkflows,
737
+ classify,
738
+ reachesConformanceTierOrSeam,
739
+ };
@@ -33,8 +33,8 @@ const WORKFLOWS_DIR = path.join(__dirname, '..', '.github', 'workflows');
33
33
  const JOB_RULES = [
34
34
  { workflowFile: 'test.yml', jobKey: 'test-inert', test: (name) => name === 'test (inert CI)' },
35
35
  { workflowFile: 'test.yml', jobKey: 'test', test: (name) => name.startsWith('test (') && name !== 'test (inert CI)' },
36
- { workflowFile: 'test.yml', jobKey: 'test-full', test: (name) => name.startsWith('full test (') },
37
36
  { workflowFile: 'test.yml', jobKey: 'coverage-gate', test: (name) => name === 'Coverage gate (merged shards)' },
37
+ { workflowFile: 'test.yml', jobKey: 'test-conformance', test: (name) => name.startsWith('conformance test (') },
38
38
  { workflowFile: 'install-smoke.yml', jobKey: 'smoke', test: (name) => name.startsWith('smoke (') },
39
39
  ];
40
40
 
@@ -72,7 +72,7 @@ function isShipped(diffPath, shipPrefixes) {
72
72
  // forward slashes, but a developer running this locally on a different
73
73
  // tool's output shouldn't get a false negative).
74
74
  const p = diffPath.replace(/\\/g, '/');
75
- return shipPrefixes.some((s) => p === s || p.startsWith(s + '/'));
75
+ return shipPrefixes.some((s) => p === s || p.startsWith(s + '/')); // allow-handrolled-containment: shipped-path filtering for a CI check, not a safety decision
76
76
  }
77
77
 
78
78
  // #2980: commits that touch `.github/workflows/*` cannot be cherry-picked
@@ -9,8 +9,12 @@
9
9
  *
10
10
  * A prior version of this PR added a `docs guards` RULE to `RULES` in
11
11
  * scripts/ci-test-scope.cjs, on the theory that classify()'s `!codeChanged`
12
- * normalization (which zeroes fullMatrix/targeted_tests/windows_tests for
13
- * docs-only diffs) made the RULE inert to classify()'s scope decision.
12
+ * normalization (which at the time zeroed fullMatrix/targeted_tests/
13
+ * windows_tests for docs-only diffs) made the RULE inert to classify()'s
14
+ * scope decision. (#4641 later removed `windows_tests` from classify()'s
15
+ * output entirely; the normalization today only zeroes fullMatrix and
16
+ * targeted_tests. This paragraph is historical narration of a rejected
17
+ * design, not a description of current behaviour.)
14
18
  *
15
19
  * That is true for docs-ONLY diffs and FALSE for MIXED docs+code diffs:
16
20
  * `codeChanged` is true whenever ANY changed file is product/pipeline code,
@@ -193,6 +197,7 @@ const DOCS_GUARD_TESTS = {
193
197
  // (commit-files-pathspec.test.cjs:1618) — cannot be resolved to specific
194
198
  // files without re-deriving the scan's own file-discovery logic.
195
199
  'tests/commit-files-pathspec.test.cjs': ['*'],
200
+ 'tests/compact-content-4139.test.cjs': ['docs/CONFIGURATION.md'],
196
201
  'tests/config-field-docs.test.cjs': ['docs/CONFIGURATION.md'],
197
202
  'tests/config.test.cjs': ['docs/CONFIGURATION.md'],
198
203
  'tests/context-index-sync.test.cjs': ['docs/CONTEXT-INDEX.json'],
@@ -31,6 +31,7 @@ const path = require('node:path');
31
31
 
32
32
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
33
33
  const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
34
+ const { isContainedIn } = require('../gsd-core/bin/lib/security.cjs');
34
35
 
35
36
  const ROOT = path.resolve(__dirname, '..');
36
37
  const ADR_DIR = path.join(ROOT, 'docs', 'adr');
@@ -176,10 +177,15 @@ const ADR_FILENAME_RE = /^[0-9]+-[a-z0-9-]+\.md$/;
176
177
  * segment merely BEGINS with two dots (`..hidden.md`), and a false "escapes
177
178
  * the repository" on a valid path is a worse failure than a miss — hence the
178
179
  * whole-segment `rel === '..' || rel.startsWith('..' + sep)` form.
180
+ *
181
+ * Delegates to the shared `isContainedIn` (ADR-4650): every call site here
182
+ * already resolved both operands itself (either lexically, before any
183
+ * filesystem call, or via realpathSync after a symlink) and needs only this
184
+ * comparison step — the exact already-resolved-caller case `isContainedIn`
185
+ * documents.
179
186
  */
180
187
  function escapesRoot(abs, root) {
181
- const rel = path.relative(root, abs);
182
- return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
188
+ return !isContainedIn(abs, root);
183
189
  }
184
190
 
185
191
  /**
@@ -91,6 +91,18 @@ const NESTED_FAMILIES = [
91
91
  subdir: 'steps',
92
92
  filter: (f) => f.endsWith('.md'),
93
93
  },
94
+ {
95
+ name: 'workflow_detail',
96
+ root: path.join(ROOT, 'gsd-core', 'workflows'),
97
+ subdir: 'detail',
98
+ filter: (f) => f.endsWith('.md'),
99
+ },
100
+ {
101
+ name: 'workflow_templates',
102
+ root: path.join(ROOT, 'gsd-core', 'workflows'),
103
+ subdir: 'templates',
104
+ filter: (f) => f.endsWith('.md'),
105
+ },
94
106
  ];
95
107
 
96
108
  /**