@opengsd/gsd-core 1.7.0-rc.4 → 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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -0,0 +1,565 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/registry-schema.cjs — pure schema/vocab constants + validation +
5
+ * markdown-generation logic for the two third-party discoverability catalogs
6
+ * (issue #2182):
7
+ *
8
+ * - `docs/registries/capabilities.json` → "GSD Community Capability Registry"
9
+ * - `docs/registries/eos.json` → "GSD EoS Registry" (PR2)
10
+ *
11
+ * The vocabulary constants below are ADDITIVE CONTRACTS that track the
12
+ * runtime/ADR closed vocabularies they describe — they are a documentation-
13
+ * registry-scoped mirror, not the runtime source of truth:
14
+ *
15
+ * - `LOOP_POINTS` mirrors ADR-857 "Loop Extension Points (the 12)"
16
+ * (docs/adr/857-capability-system.md §"Loop Extension Points (the 12)").
17
+ * The canonical runtime set lives in `src/loop-resolver.cts`
18
+ * (`CANONICAL_POINTS` / `CANONICAL_POINTS_FALLBACK`, derived from
19
+ * `loop-host-contract.cjs`) — changing that set requires updating this
20
+ * list too, since a registry entry's `loopExtensionPoints` describes
21
+ * which of those 12 points a third-party capability extends.
22
+ * - `HOOK_KINDS` mirrors ADR-857 Decision 4 "three hook kinds": `step`
23
+ * (runs as its own sequenced unit), `contribution` (injects into the
24
+ * core step's prompt/context), `gate` (checks and optionally blocks).
25
+ * - `INTERFACE_POINTS` mirrors ADR-1239 "The six interface points" (the
26
+ * Host-Integration Interface integration surface): command/workflow
27
+ * invocation, agent dispatch, model invocation, lifecycle hooks,
28
+ * state+config IO, artifact surface.
29
+ * - `PROFILES` mirrors ADR-1239 "Host-capability profiles (negotiation
30
+ * baselines)": `programmatic-cli`, `declarative-cli`, `ide`.
31
+ * - `AXES` mirrors ADR-1239 "the eight negotiated axes" (the negotiated
32
+ * capability schema exchanged at `initialize`): `embeddingMode`,
33
+ * `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`,
34
+ * `transport`, `runtime`. Seven of the eight are closed enums here;
35
+ * `dispatch` is ADR-1239's structured negotiated object
36
+ * (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
37
+ * this registry accepts a free-form human summary string instead, so it
38
+ * carries the `AXES_FREE_STRING` sentinel rather than an enum array.
39
+ * - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
40
+ * fields for each entry type, including `enginesGsd` (ADR-1244 D1
41
+ * "Versioned capability manifest" — the `engines.gsd` semver-range gate,
42
+ * modelled on VS Code's `engines.vscode`).
43
+ *
44
+ * This module is pure — no `fs`/`process`/child-process access — so tests
45
+ * can `require()` it directly and assert on structured return values.
46
+ * `scripts/validate-registry.cjs` and `scripts/gen-registry.cjs` are the thin
47
+ * CLI wrappers that perform I/O around these functions.
48
+ */
49
+
50
+ // ─── ADR-857 "Loop Extension Points (the 12)" ────────────────────────────────
51
+ const LOOP_POINTS = Object.freeze([
52
+ 'discuss:pre',
53
+ 'discuss:post',
54
+ 'plan:pre',
55
+ 'plan:post',
56
+ 'execute:pre',
57
+ 'execute:wave:pre',
58
+ 'execute:wave:post',
59
+ 'execute:post',
60
+ 'verify:pre',
61
+ 'verify:post',
62
+ 'ship:pre',
63
+ 'ship:post',
64
+ ]);
65
+
66
+ // ─── ADR-857 Decision 4 — three hook kinds ───────────────────────────────────
67
+ const HOOK_KINDS = Object.freeze(['step', 'contribution', 'gate']);
68
+
69
+ // ─── ADR-1239 "The six interface points" ─────────────────────────────────────
70
+ const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
71
+
72
+ // ─── ADR-1239 "Host-capability profiles (negotiation baselines)" ────────────
73
+ const PROFILES = Object.freeze(['programmatic-cli', 'declarative-cli', 'ide']);
74
+
75
+ // Sentinel marking an AXES entry as a free-form descriptive string rather than
76
+ // a closed enum array. `Array.isArray(AXES_FREE_STRING)` is false, so callers
77
+ // can branch on `Array.isArray(AXES[key])` vs `AXES[key] === AXES_FREE_STRING`
78
+ // without risking confusion with a real enum value.
79
+ const AXES_FREE_STRING = Symbol('registry-schema.AXES_FREE_STRING');
80
+
81
+ // ─── ADR-1239 "the eight negotiated axes" ────────────────────────────────────
82
+ const AXES = Object.freeze({
83
+ embeddingMode: Object.freeze(['imperative', 'declarative']),
84
+ commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
85
+ dispatch: AXES_FREE_STRING,
86
+ modelMode: Object.freeze(['active', 'passive']),
87
+ hookBus: Object.freeze(['host', 'engine', 'none']),
88
+ stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
89
+ transport: Object.freeze(['mcp', 'native-extension']),
90
+ runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
91
+ });
92
+
93
+ // ─── Required top-level fields ───────────────────────────────────────────────
94
+ const CAPABILITY_REQUIRED = Object.freeze([
95
+ 'id',
96
+ 'name',
97
+ 'type',
98
+ 'repo',
99
+ 'description',
100
+ 'author',
101
+ 'license',
102
+ 'enginesGsd',
103
+ 'install',
104
+ 'uninstall',
105
+ 'interactions',
106
+ 'discussion',
107
+ ]);
108
+
109
+ const EOS_REQUIRED = Object.freeze([
110
+ 'id',
111
+ 'name',
112
+ 'type',
113
+ 'repo',
114
+ 'description',
115
+ 'author',
116
+ 'license',
117
+ 'enginesGsd',
118
+ 'install',
119
+ 'uninstall',
120
+ 'interactions',
121
+ 'discussion',
122
+ 'protocolVersion',
123
+ ]);
124
+
125
+ // Escape Markdown inline metacharacters in UNTRUSTED free text so a registry
126
+ // entry cannot inject links/tables/code-spans into the generated catalog.
127
+ // Neutralizes: link hijack ([ ] ( )), table breakout (|), code span (`),
128
+ // and backslash. Newlines are collapsed to a single space (inline contexts).
129
+ function mdInline(value) {
130
+ return String(value).replace(/[\\`*_[\]()|~<>]/g, '\\$&').replace(/[\r\n]+/g, ' ');
131
+ }
132
+ // A fenced-code fence guaranteed longer than any backtick run in `value`, so a
133
+ // value containing ``` cannot escape the block (CommonMark rule). Min length 3.
134
+ function fenceFor(value) {
135
+ const runs = String(value).match(/`+/g) || [];
136
+ const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
137
+ return '`'.repeat(Math.max(3, longest + 1));
138
+ }
139
+
140
+ // A single `engines.gsd` range clause: optional comparison operator, optional
141
+ // leading `v`, exactly three dot-separated numeric segments, optional
142
+ // prerelease (`-...`) and build (`+...`) suffixes. Operator alternation order
143
+ // matters — `>=`/`<=` must be tried before `>`/`<` or the longer operator
144
+ // would never match.
145
+ const GSD_RANGE_CLAUSE_RE = /^(>=|<=|>|<|=|\^|~)?v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
146
+
147
+ /**
148
+ * Validate the SHAPE of an `engines.gsd`-style semver range string (ADR-1244
149
+ * D1). Self-contained — no `semver` dependency, modelled on the constraint
150
+ * parsing in `scripts/check-env.cjs` (`satisfiesConstraint`), but this
151
+ * function validates that the range is well-formed rather than comparing it
152
+ * against a concrete version.
153
+ *
154
+ * @param {string} range
155
+ * @returns {boolean}
156
+ */
157
+ function isValidGsdRange(range) {
158
+ if (typeof range !== 'string') return false;
159
+ const trimmed = range.trim();
160
+ if (trimmed === '') return false;
161
+ if (trimmed === '*') return true;
162
+ const clauses = trimmed.split(/\s+/);
163
+ return clauses.length > 0 && clauses.every((clause) => clause !== '' && GSD_RANGE_CLAUSE_RE.test(clause));
164
+ }
165
+
166
+ /**
167
+ * Validate the `interactions` sub-object for a capability entry.
168
+ *
169
+ * @param {object} interactions
170
+ * @param {(field: string, reason: string) => void} addError
171
+ * @returns {void}
172
+ */
173
+ function validateCapabilityInteractions(interactions, addError) {
174
+ const allowedKeys = new Set([
175
+ 'loopExtensionPoints',
176
+ 'hookKinds',
177
+ 'configKeys',
178
+ 'requires',
179
+ 'runtimeCompat',
180
+ 'produces',
181
+ 'consumes',
182
+ ]);
183
+ for (const key of Object.keys(interactions)) {
184
+ if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
185
+ }
186
+
187
+ for (const field of ['loopExtensionPoints', 'hookKinds']) {
188
+ if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
189
+ }
190
+
191
+ if (interactions.loopExtensionPoints !== undefined) {
192
+ const v = interactions.loopExtensionPoints;
193
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => LOOP_POINTS.includes(x))) {
194
+ addError('interactions.loopExtensionPoints', 'must be a non-empty array of valid loop extension points');
195
+ }
196
+ }
197
+
198
+ if (interactions.hookKinds !== undefined) {
199
+ const v = interactions.hookKinds;
200
+ if (!Array.isArray(v) || !v.every((x) => HOOK_KINDS.includes(x))) {
201
+ addError('interactions.hookKinds', 'must be an array of valid hook kinds');
202
+ }
203
+ }
204
+
205
+ for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
206
+ if (interactions[field] === undefined) continue;
207
+ const v = interactions[field];
208
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
209
+ addError(`interactions.${field}`, 'must be an array of strings');
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validate the `interactions` sub-object for an eos entry.
216
+ *
217
+ * @param {object} interactions
218
+ * @param {(field: string, reason: string) => void} addError
219
+ * @returns {void}
220
+ */
221
+ function validateEosInteractions(interactions, addError) {
222
+ const allowedKeys = new Set(['interfacePoints', 'profile', 'axes']);
223
+ for (const key of Object.keys(interactions)) {
224
+ if (!allowedKeys.has(key)) addError(`interactions.${key}`, 'unknown field');
225
+ }
226
+
227
+ for (const field of ['interfacePoints', 'profile', 'axes']) {
228
+ if (interactions[field] === undefined) addError(`interactions.${field}`, 'missing required field');
229
+ }
230
+
231
+ if (interactions.interfacePoints !== undefined) {
232
+ const v = interactions.interfacePoints;
233
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => INTERFACE_POINTS.includes(x))) {
234
+ addError('interactions.interfacePoints', 'must be a non-empty array of valid interface points');
235
+ }
236
+ }
237
+
238
+ if (interactions.profile !== undefined) {
239
+ if (typeof interactions.profile !== 'string' || !PROFILES.includes(interactions.profile)) {
240
+ addError('interactions.profile', 'must be one of the valid negotiation profiles');
241
+ }
242
+ }
243
+
244
+ if (interactions.axes !== undefined) {
245
+ const axes = interactions.axes;
246
+ if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
247
+ addError('interactions.axes', 'axes must be an object');
248
+ } else {
249
+ const expectedKeys = Object.keys(AXES);
250
+ const actualKeys = Object.keys(axes);
251
+ const actualKeySet = new Set(actualKeys);
252
+ const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
253
+ if (!keysMatch) {
254
+ addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
255
+ } else {
256
+ for (const key of expectedKeys) {
257
+ const allowedValues = AXES[key];
258
+ const v = axes[key];
259
+ if (allowedValues === AXES_FREE_STRING) {
260
+ if (typeof v !== 'string' || v.trim() === '') {
261
+ addError(`interactions.axes.${key}`, 'must be a non-empty string');
262
+ } else if (v.length > 300) {
263
+ addError(`interactions.axes.${key}`, 'exceeds max length 300');
264
+ }
265
+ } else if (typeof v !== 'string' || !allowedValues.includes(v)) {
266
+ addError(`interactions.axes.${key}`, `must be one of the allowed values for ${key}`);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Validate an array of registry entries against the closed schema for
276
+ * `opts.type` ('capability' | 'eos').
277
+ *
278
+ * @param {object[]} entries
279
+ * @param {{type: 'capability'|'eos'}} opts
280
+ * @returns {{ok: boolean, errors: Array<{index: number, id?: string, field: string, reason: string}>}}
281
+ */
282
+ function validateEntries(entries, opts) {
283
+ if (!Array.isArray(entries)) {
284
+ return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'entries must be an array' }] };
285
+ }
286
+
287
+ // Entry-count cap: a pathologically large array (e.g. from an automated or
288
+ // malicious PR) is rejected wholesale rather than validated entry-by-entry.
289
+ if (entries.length > 2000) {
290
+ return { ok: false, errors: [{ index: -1, field: '(root)', reason: 'too many entries (max 2000)' }] };
291
+ }
292
+
293
+ const required = opts.type === 'eos' ? EOS_REQUIRED : CAPABILITY_REQUIRED;
294
+ const requiredSet = new Set(required);
295
+ const seenIds = new Set();
296
+ const errors = [];
297
+
298
+ entries.forEach((entry, index) => {
299
+ const addError = (field, reason) => {
300
+ const err = { index, field, reason };
301
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string') err.id = entry.id;
302
+ errors.push(err);
303
+ };
304
+
305
+ // Null/non-object element guard — a malformed array element (null,
306
+ // undefined-via-hole, a primitive, or an array) cannot be destructured by
307
+ // the field checks below, so reject it outright rather than throwing.
308
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
309
+ addError('(entry)', 'entry must be a JSON object');
310
+ return;
311
+ }
312
+
313
+ for (const key of Object.keys(entry)) {
314
+ if (!requiredSet.has(key)) addError(key, 'unknown field');
315
+ }
316
+
317
+ const missing = new Set();
318
+ for (const field of required) {
319
+ if (entry[field] === undefined) {
320
+ addError(field, 'missing required field');
321
+ missing.add(field);
322
+ }
323
+ }
324
+
325
+ // Control-character rejection (defense in depth): `allowTabNewline` widens
326
+ // the reject-set exception for the two shell-snippet fields (install/
327
+ // uninstall), which legitimately contain tabs/newlines; every other free
328
+ // text field disallows ALL C0 control characters plus DEL (incl. \n/\t).
329
+ // Checked via char codes (not a literal control-char regex range) — same
330
+ // approach as capability-validator.cjs's hooks[].matcher check, which
331
+ // avoids tripping ESLint's no-control-regex rule.
332
+ const hasDisallowedControlChar = (v, allowTabNewline) => {
333
+ for (let c = 0; c < v.length; c += 1) {
334
+ const code = v.charCodeAt(c);
335
+ if (allowTabNewline && (code === 0x09 || code === 0x0a)) continue;
336
+ if (code < 0x20 || code === 0x7f) return true;
337
+ }
338
+ return false;
339
+ };
340
+ const checkNoControlChars = (field, allowTabNewline) => {
341
+ if (missing.has(field)) return;
342
+ const v = entry[field];
343
+ if (typeof v !== 'string') return;
344
+ if (hasDisallowedControlChar(v, allowTabNewline)) addError(field, 'must not contain control characters');
345
+ };
346
+ // Length cap: reject oversized fields (untrusted third-party input feeding
347
+ // a committed Markdown catalog should not be allowed to blow up the doc).
348
+ const checkMaxLength = (field, max) => {
349
+ if (missing.has(field)) return;
350
+ const v = entry[field];
351
+ if (typeof v === 'string' && v.length > max) addError(field, `exceeds max length ${max}`);
352
+ };
353
+
354
+ if (!missing.has('id')) {
355
+ const id = entry.id;
356
+ if (typeof id !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
357
+ addError('id', 'id must be kebab-case');
358
+ }
359
+ if (seenIds.has(id)) {
360
+ addError('id', `duplicate id: ${id}`);
361
+ } else {
362
+ seenIds.add(id);
363
+ }
364
+ }
365
+ checkMaxLength('id', 100);
366
+
367
+ for (const field of ['name', 'description', 'author']) {
368
+ if (missing.has(field)) continue;
369
+ const v = entry[field];
370
+ if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
371
+ checkNoControlChars(field, false);
372
+ }
373
+ checkMaxLength('name', 120);
374
+ checkMaxLength('author', 120);
375
+ checkMaxLength('description', 1000);
376
+
377
+ if (!missing.has('type') && entry.type !== opts.type) {
378
+ addError('type', `type must be "${opts.type}"`);
379
+ }
380
+
381
+ if (!missing.has('repo')) {
382
+ if (typeof entry.repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) {
383
+ addError('repo', 'repo must be in "owner/repo" form');
384
+ }
385
+ }
386
+ checkMaxLength('repo', 100);
387
+
388
+ if (!missing.has('license')) {
389
+ const v = entry.license;
390
+ if (typeof v !== 'string' || v.trim() === '' || !/^[A-Za-z0-9.+()\- ]+$/.test(v)) {
391
+ addError('license', 'license must be a non-empty SPDX-like string');
392
+ }
393
+ }
394
+ checkMaxLength('license', 120);
395
+
396
+ if (!missing.has('enginesGsd') && !isValidGsdRange(entry.enginesGsd)) {
397
+ addError('enginesGsd', 'enginesGsd must be a valid semver range');
398
+ }
399
+ checkMaxLength('enginesGsd', 100);
400
+
401
+ for (const field of ['install', 'uninstall']) {
402
+ if (missing.has(field)) continue;
403
+ const v = entry[field];
404
+ if (typeof v !== 'string' || v.trim() === '') addError(field, 'must be a non-empty string');
405
+ checkNoControlChars(field, true);
406
+ }
407
+ checkMaxLength('install', 2000);
408
+ checkMaxLength('uninstall', 2000);
409
+
410
+ if (!missing.has('discussion')) {
411
+ const v = entry.discussion;
412
+ if (typeof v !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/discussions\/\d+$/.test(v)) {
413
+ addError('discussion', 'discussion must be a GitHub discussions URL');
414
+ }
415
+ }
416
+ checkMaxLength('discussion', 300);
417
+
418
+ if (!missing.has('interactions')) {
419
+ const interactions = entry.interactions;
420
+ if (typeof interactions !== 'object' || interactions === null || Array.isArray(interactions)) {
421
+ addError('interactions', 'interactions must be an object');
422
+ } else if (opts.type === 'eos') {
423
+ validateEosInteractions(interactions, addError);
424
+ } else {
425
+ validateCapabilityInteractions(interactions, addError);
426
+ }
427
+ }
428
+
429
+ if (opts.type === 'eos' && !missing.has('protocolVersion')) {
430
+ if (!Number.isInteger(entry.protocolVersion) || entry.protocolVersion < 1) {
431
+ addError('protocolVersion', 'protocolVersion must be an integer >= 1');
432
+ }
433
+ }
434
+ });
435
+
436
+ return { ok: errors.length === 0, errors };
437
+ }
438
+
439
+ /**
440
+ * Render the deterministic Markdown document for a registry.
441
+ *
442
+ * @param {object[]} entries
443
+ * @param {{type: 'capability'|'eos', sourceFile?: string}} opts
444
+ * @returns {string}
445
+ */
446
+ function renderMarkdown(entries, opts) {
447
+ const sorted = [...entries].sort((a, b) => {
448
+ if (a.id < b.id) return -1;
449
+ if (a.id > b.id) return 1;
450
+ return 0;
451
+ });
452
+ const isEos = opts.type === 'eos';
453
+ const lines = [];
454
+
455
+ lines.push(
456
+ `<!-- GENERATED by scripts/gen-registry.cjs from docs/registries/${opts.sourceFile} — do not edit by hand; run \`npm run gen:registry\` -->`,
457
+ );
458
+ lines.push('');
459
+ lines.push(isEos ? '# GSD EoS Registry' : '# GSD Community Capability Registry');
460
+ lines.push('');
461
+ lines.push(
462
+ "> **Not an endorsement.** Inclusion means only that a maintainer merged a PR linking the author's repository — GSD has not reviewed, tested, or verified any listing. See the [registry README](./README.md).",
463
+ );
464
+ lines.push('');
465
+ lines.push(`_To add your ${isEos ? 'integration' : 'capability'}, see the [registry README](./README.md)._`);
466
+ lines.push('');
467
+
468
+ if (sorted.length === 0) {
469
+ lines.push('_No entries yet — be the first: see [README](./README.md)._');
470
+ return `${lines.join('\n')}\n`;
471
+ }
472
+
473
+ lines.push('| Name | What it is | Latest release | GSD compat | Discussion |');
474
+ lines.push('|---|---|---|---|---|');
475
+ for (const entry of sorted) {
476
+ // entry.repo/enginesGsd/discussion are regex-constrained (validateEntries)
477
+ // and used as link DESTINATIONS / badge URLs here — never mdInline those,
478
+ // it would corrupt the URL. entry.name/description are untrusted free-text
479
+ // link TEXT / body copy and MUST be escaped.
480
+ lines.push(
481
+ `| [${mdInline(entry.name)}](https://github.com/${entry.repo}) | ${mdInline(entry.description)} | ` +
482
+ `![release](https://img.shields.io/github/v/release/${entry.repo}?sort=semver&include_prereleases) | ` +
483
+ `\`${entry.enginesGsd}\` | [discuss](${entry.discussion}) |`,
484
+ );
485
+ }
486
+ lines.push('');
487
+
488
+ sorted.forEach((entry, i) => {
489
+ const interactions = entry.interactions || {};
490
+
491
+ lines.push(`## ${mdInline(entry.name)}`);
492
+ lines.push(
493
+ `- **Repository:** https://github.com/${entry.repo} — [latest release](https://github.com/${entry.repo}/releases/latest)`,
494
+ );
495
+ lines.push(`- **What it is:** ${mdInline(entry.description)}`);
496
+ lines.push(`- **Author:** ${mdInline(entry.author)}`);
497
+
498
+ if (isEos) {
499
+ const axesSummary = Object.keys(AXES)
500
+ .map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
501
+ .join(', ');
502
+ const summary =
503
+ `Interface points: ${(interactions.interfacePoints || []).join(', ')}; ` +
504
+ `profile: ${interactions.profile}; protocol v${entry.protocolVersion}; axes: ${axesSummary}`;
505
+ // Single mdInline pass over the fully-assembled summary: none of the
506
+ // literal separator text above contains Markdown metacharacters, so
507
+ // this equally neutralizes every embedded free-text/vocab value
508
+ // (notably interactions.axes.dispatch, a free-form untrusted string).
509
+ lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
510
+ } else {
511
+ let summary =
512
+ `Loop Extension Points: ${(interactions.loopExtensionPoints || []).join(', ')}; ` +
513
+ `hook kinds: ${(interactions.hookKinds || []).join(', ')}`;
514
+ for (const field of ['configKeys', 'requires', 'runtimeCompat', 'produces', 'consumes']) {
515
+ const v = interactions[field];
516
+ if (Array.isArray(v) && v.length > 0) summary += `; ${field}: ${v.join(', ')}`;
517
+ }
518
+ // configKeys/requires/runtimeCompat/produces/consumes are untrusted
519
+ // free-form strings (schema only requires "array of strings") — same
520
+ // single-pass mdInline rationale as the eos branch above.
521
+ lines.push(`- **Every interaction with GSD:** ${mdInline(summary)}`);
522
+ }
523
+
524
+ // Code-span content (install/uninstall) is NOT mdInline-escaped — it is a
525
+ // verbatim shell snippet, not inline prose. Instead each block picks a
526
+ // fence strictly longer than any backtick run inside its own content, so
527
+ // an embedded ``` cannot prematurely close the fence (CommonMark rule).
528
+ const installFence = fenceFor(entry.install);
529
+ lines.push('- **Install:**');
530
+ lines.push(`${installFence}sh`);
531
+ lines.push(entry.install);
532
+ lines.push(installFence);
533
+ const uninstallFence = fenceFor(entry.uninstall);
534
+ lines.push('- **Uninstall:**');
535
+ lines.push(`${uninstallFence}sh`);
536
+ lines.push(entry.uninstall);
537
+ lines.push(uninstallFence);
538
+
539
+ lines.push(
540
+ isEos
541
+ ? `- **GSD compatibility:** \`${entry.enginesGsd}\`, protocol v${entry.protocolVersion}`
542
+ : `- **GSD compatibility:** \`${entry.enginesGsd}\``,
543
+ );
544
+ lines.push(`- **License:** ${mdInline(entry.license)}`);
545
+ lines.push(`- **Discussion / ranking:** ${entry.discussion}`);
546
+
547
+ if (i < sorted.length - 1) lines.push('');
548
+ });
549
+
550
+ return `${lines.join('\n')}\n`;
551
+ }
552
+
553
+ module.exports = {
554
+ LOOP_POINTS,
555
+ HOOK_KINDS,
556
+ INTERFACE_POINTS,
557
+ PROFILES,
558
+ AXES,
559
+ AXES_FREE_STRING,
560
+ CAPABILITY_REQUIRED,
561
+ EOS_REQUIRED,
562
+ isValidGsdRange,
563
+ validateEntries,
564
+ renderMarkdown,
565
+ };
@@ -577,6 +577,23 @@ function main() {
577
577
  const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
578
578
  ? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
579
579
  : 60;
580
+ // #2088: file COUNT is a poor proxy for a chunk's wall-clock — install-heavy
581
+ // files (real installs; install-minimal-hooks.test.cjs alone runs ~250 cases
582
+ // doing dozens of installs) are ~10× a unit file. When several land in the SAME
583
+ // chunk — e.g. a PR touching the whole install surface, whose *targeted* lane is
584
+ // unsharded (13 install-heavy files → one chunk) — that chunk blows the 600s
585
+ // backstop while unit-only chunks finish in seconds. WEIGHT install-heavy files
586
+ // so they fill a chunk's budget faster and therefore SPREAD across chunks
587
+ // instead of clustering. Light files keep weight 1, so pure-unit chunking (and
588
+ // its harness tests) is byte-for-byte unchanged. `MAX_FILES_PER_CHUNK` is now a
589
+ // per-chunk WEIGHT budget (backwards-compatible: it equals the file count when
590
+ // every file is light). Tune via RUN_TESTS_HEAVY_FILE_WEIGHT; classify via the
591
+ // basename prefix (install*/installer*/codex-* are the real install-heavy suites).
592
+ const HEAVY_TEST_RE = /^(?:install|codex-)/;
593
+ const HEAVY_FILE_WEIGHT = process.env.RUN_TESTS_HEAVY_FILE_WEIGHT
594
+ ? Number(process.env.RUN_TESTS_HEAVY_FILE_WEIGHT)
595
+ : 12;
596
+ const fileWeight = (f) => (HEAVY_TEST_RE.test(basename(f)) ? HEAVY_FILE_WEIGHT : 1);
580
597
 
581
598
  // node:test does not exit until the event loop drains. A unit test that leaks
582
599
  // an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
@@ -595,18 +612,21 @@ function main() {
595
612
  const chunks = [];
596
613
  let current = [];
597
614
  let currentLen = FIXED_OVERHEAD;
615
+ let currentWeight = 0;
598
616
  for (const file of selected) {
599
617
  const add = file.length + 1; // +1 for the inter-arg separator
600
618
  if (
601
619
  current.length > 0 &&
602
- (currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK)
620
+ (currentLen + add > MAX_CMDLINE_CHARS || currentWeight >= MAX_FILES_PER_CHUNK)
603
621
  ) {
604
622
  chunks.push(current);
605
623
  current = [];
606
624
  currentLen = FIXED_OVERHEAD;
625
+ currentWeight = 0;
607
626
  }
608
627
  current.push(file);
609
628
  currentLen += add;
629
+ currentWeight += fileWeight(file); // heavy install files count for more
610
630
  }
611
631
  if (current.length > 0) chunks.push(current);
612
632