@opengsd/gsd-core 1.4.2 → 1.5.0-rc.1

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 (153) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-executor.md +1 -1
  4. package/agents/gsd-plan-checker.md +11 -2
  5. package/agents/gsd-planner.md +11 -7
  6. package/agents/gsd-verifier.md +3 -3
  7. package/bin/install.js +947 -149
  8. package/commands/gsd/autonomous.md +5 -1
  9. package/commands/gsd/ns-manage.md +8 -1
  10. package/commands/gsd/ns-project.md +5 -0
  11. package/commands/gsd/ns-review.md +4 -1
  12. package/commands/gsd/ns-workflow.md +7 -1
  13. package/commands/gsd/plan-review-convergence.md +6 -5
  14. package/commands/gsd/surface.md +12 -5
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +198 -101
  17. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +417 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  20. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  21. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  22. package/gsd-core/bin/lib/configuration.cjs +4 -34
  23. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  24. package/gsd-core/bin/lib/core.cjs +57 -1817
  25. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  26. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  27. package/gsd-core/bin/lib/init.cjs +58 -12
  28. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  29. package/gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +123 -0
  30. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  31. package/gsd-core/bin/lib/io.cjs +167 -0
  32. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  34. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  35. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  36. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  37. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  39. package/gsd-core/bin/lib/roadmap.cjs +4 -1
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +96 -9
  41. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +2 -1
  42. package/gsd-core/bin/lib/runtime-homes.cjs +40 -0
  43. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -0
  44. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  45. package/gsd-core/bin/lib/state.cjs +114 -2
  46. package/gsd-core/bin/lib/surface.cjs +66 -14
  47. package/gsd-core/bin/lib/verify.cjs +1 -1
  48. package/gsd-core/bin/shared/model-catalog.json +5 -0
  49. package/gsd-core/bin/shared/runtime-aliases.manifest.json +3 -0
  50. package/gsd-core/bin/verify-reapply-patches.cjs +57 -7
  51. package/gsd-core/references/planner-reviews.md +5 -2
  52. package/gsd-core/templates/phase-prompt.md +7 -7
  53. package/gsd-core/templates/project.md +19 -2
  54. package/gsd-core/templates/summary-complex.md +1 -0
  55. package/gsd-core/templates/summary-minimal.md +1 -0
  56. package/gsd-core/templates/summary-standard.md +1 -0
  57. package/gsd-core/templates/summary.md +1 -0
  58. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  59. package/gsd-core/workflows/add-backlog.md +1 -1
  60. package/gsd-core/workflows/add-phase.md +1 -1
  61. package/gsd-core/workflows/add-tests.md +1 -1
  62. package/gsd-core/workflows/add-todo.md +1 -1
  63. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  64. package/gsd-core/workflows/audit-fix.md +1 -1
  65. package/gsd-core/workflows/audit-milestone.md +1 -1
  66. package/gsd-core/workflows/audit-uat.md +1 -1
  67. package/gsd-core/workflows/autonomous.md +86 -15
  68. package/gsd-core/workflows/check-todos.md +1 -1
  69. package/gsd-core/workflows/cleanup.md +1 -1
  70. package/gsd-core/workflows/code-review-fix.md +6 -4
  71. package/gsd-core/workflows/code-review.md +3 -2
  72. package/gsd-core/workflows/complete-milestone.md +11 -5
  73. package/gsd-core/workflows/debug.md +1 -1
  74. package/gsd-core/workflows/diagnose-issues.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  76. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  77. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  78. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  79. package/gsd-core/workflows/discuss-phase.md +8 -1
  80. package/gsd-core/workflows/do.md +1 -1
  81. package/gsd-core/workflows/docs-update.md +1 -1
  82. package/gsd-core/workflows/edit-phase.md +1 -1
  83. package/gsd-core/workflows/eval-review.md +4 -1
  84. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  85. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  86. package/gsd-core/workflows/execute-phase.md +8 -1
  87. package/gsd-core/workflows/execute-plan.md +1 -1
  88. package/gsd-core/workflows/explore.md +1 -1
  89. package/gsd-core/workflows/extract-learnings.md +1 -1
  90. package/gsd-core/workflows/forensics.md +1 -1
  91. package/gsd-core/workflows/graduation.md +1 -1
  92. package/gsd-core/workflows/health.md +1 -1
  93. package/gsd-core/workflows/help/modes/full.md +1 -1
  94. package/gsd-core/workflows/import.md +1 -1
  95. package/gsd-core/workflows/ingest-docs.md +1 -1
  96. package/gsd-core/workflows/insert-phase.md +1 -1
  97. package/gsd-core/workflows/list-workspaces.md +1 -1
  98. package/gsd-core/workflows/manager.md +1 -1
  99. package/gsd-core/workflows/map-codebase.md +1 -1
  100. package/gsd-core/workflows/milestone-summary.md +1 -1
  101. package/gsd-core/workflows/mvp-phase.md +1 -1
  102. package/gsd-core/workflows/new-milestone.md +1 -1
  103. package/gsd-core/workflows/new-project.md +1 -1
  104. package/gsd-core/workflows/new-workspace.md +1 -1
  105. package/gsd-core/workflows/next.md +1 -1
  106. package/gsd-core/workflows/pause-work.md +1 -1
  107. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  108. package/gsd-core/workflows/plan-phase.md +27 -2
  109. package/gsd-core/workflows/plan-review-convergence.md +76 -63
  110. package/gsd-core/workflows/plant-seed.md +1 -1
  111. package/gsd-core/workflows/profile-user.md +1 -1
  112. package/gsd-core/workflows/progress.md +1 -1
  113. package/gsd-core/workflows/quick.md +2 -2
  114. package/gsd-core/workflows/reapply-patches.md +18 -1
  115. package/gsd-core/workflows/remove-phase.md +1 -1
  116. package/gsd-core/workflows/remove-workspace.md +1 -1
  117. package/gsd-core/workflows/resume-project.md +1 -1
  118. package/gsd-core/workflows/review.md +1 -1
  119. package/gsd-core/workflows/scan.md +1 -1
  120. package/gsd-core/workflows/secure-phase.md +1 -1
  121. package/gsd-core/workflows/settings-advanced.md +1 -1
  122. package/gsd-core/workflows/settings-integrations.md +1 -1
  123. package/gsd-core/workflows/settings.md +1 -1
  124. package/gsd-core/workflows/ship.md +8 -1
  125. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  126. package/gsd-core/workflows/sketch.md +1 -1
  127. package/gsd-core/workflows/spec-phase.md +1 -1
  128. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  129. package/gsd-core/workflows/spike.md +1 -1
  130. package/gsd-core/workflows/stats.md +1 -1
  131. package/gsd-core/workflows/thread.md +1 -1
  132. package/gsd-core/workflows/transition.md +1 -1
  133. package/gsd-core/workflows/ui-phase.md +1 -1
  134. package/gsd-core/workflows/ui-review.md +1 -1
  135. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  136. package/gsd-core/workflows/update.md +20 -15
  137. package/gsd-core/workflows/validate-phase.md +1 -1
  138. package/gsd-core/workflows/verify-phase.md +1 -1
  139. package/gsd-core/workflows/verify-work.md +8 -1
  140. package/package.json +7 -2
  141. package/scripts/base64-scan.sh +1 -1
  142. package/scripts/changeset/cli.cjs +9 -2
  143. package/scripts/changeset/lint.cjs +38 -2
  144. package/scripts/ci-test-scope.cjs +21 -10
  145. package/scripts/gen-capability-registry.cjs +1808 -0
  146. package/scripts/gen-loop-host-contract.cjs +471 -0
  147. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  148. package/scripts/lint-regression-test-names.cjs +117 -0
  149. package/scripts/lint-test-file-count.allowlist.json +12 -4
  150. package/scripts/prompt-injection-scan.sh +4 -4
  151. package/scripts/run-tests.cjs +79 -16
  152. package/scripts/secret-scan.sh +3 -3
  153. package/scripts/run-cross-platform-tests.cjs +0 -67
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ /**
3
+ * Federated Config — Defensive merge of capability-declared config keys
4
+ *
5
+ * ADR-857 phase 3b: wires the Capability Registry's configSchema into
6
+ * loadConfig as a provably-empty no-op channel until capability keys are
7
+ * migrated out of the central config-schema.
8
+ *
9
+ * Exported function:
10
+ * mergeFederatedConfig({ configSchema, isCentralKey, userConfig })
11
+ * → { values, validKeys, warnings }
12
+ *
13
+ * Design:
14
+ * - For each key in configSchema:
15
+ * If isCentralKey(key) → SKIP; push a pending-migration warning.
16
+ * Else if slice is malformed → SKIP; push a warning. Never throw.
17
+ * Else (valid federated key absent from central):
18
+ * resolvedValue = nested userConfig lookup if present & type-matches; else slice.default.
19
+ * Add key→resolvedValue to values; add key to validKeys.
20
+ * - Guard all object writes with inline literal __proto__/constructor/prototype checks.
21
+ * - Zero external dependencies; no ajv; hand-rolled type checks only.
22
+ *
23
+ * ADR-857 no-op guarantee:
24
+ * With the current registry, every UI key is still present in the central
25
+ * config-schema, so isCentralKey() returns true for all of them and values
26
+ * is always empty. The channel is live but carries no traffic until a key
27
+ * is atomically removed from the central schema (the cutover step).
28
+ *
29
+ * Dependencies: none (zero-dep module).
30
+ */
31
+ // ─── Allowed slice types (mirrors gen-capability-registry.cjs VALID_CONFIG_SLICE_TYPES) ──
32
+ const VALID_SLICE_TYPES = new Set(['boolean', 'string', 'number', 'enum']);
33
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
34
+ /**
35
+ * Returns true if `slice` has a non-empty type, a `default` property, and a
36
+ * non-empty string description. Does NOT throw.
37
+ */
38
+ function _isWellFormedSlice(slice) {
39
+ if (typeof slice !== 'object' || slice === null || Array.isArray(slice))
40
+ return false;
41
+ const s = slice;
42
+ if (typeof s['type'] !== 'string' || s['type'].length === 0)
43
+ return false;
44
+ if (!VALID_SLICE_TYPES.has(s['type']))
45
+ return false;
46
+ if (!Object.prototype.hasOwnProperty.call(s, 'default'))
47
+ return false;
48
+ return true;
49
+ }
50
+ /**
51
+ * Returns true if `value` matches the declared type in the slice.
52
+ * For enum, also validates against slice.values if present.
53
+ */
54
+ function _typeMatches(value, slice) {
55
+ switch (slice.type) {
56
+ case 'boolean': return typeof value === 'boolean';
57
+ case 'string': return typeof value === 'string';
58
+ case 'number': return typeof value === 'number';
59
+ case 'enum':
60
+ // Must be a string AND, if values list is present, must be in it
61
+ if (typeof value !== 'string')
62
+ return false;
63
+ if (Array.isArray(slice.values) && slice.values.length > 0) {
64
+ return slice.values.includes(value);
65
+ }
66
+ return true;
67
+ default: return false;
68
+ }
69
+ }
70
+ /**
71
+ * Traverse a dotted key path through a nested config object.
72
+ * E.g. key="workflow.ui_phase", obj={workflow:{ui_phase:false}} → {found:true, value:false}
73
+ * Returns {found:false} if any segment is missing or not an own property.
74
+ * Handles 1, 2, or N segments generically.
75
+ */
76
+ function _getNestedValue(obj, key) {
77
+ const segments = key.split('.');
78
+ let current = obj;
79
+ for (let i = 0; i < segments.length; i++) {
80
+ const seg = segments[i];
81
+ // Inline literal prototype-pollution guard
82
+ if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
83
+ return { found: false, value: undefined };
84
+ }
85
+ if (typeof current !== 'object' || current === null) {
86
+ return { found: false, value: undefined };
87
+ }
88
+ const cur = current;
89
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
90
+ return { found: false, value: undefined };
91
+ }
92
+ current = cur[seg];
93
+ }
94
+ return { found: true, value: current };
95
+ }
96
+ // ─── Public API ────────────────────────────────────────────────────────────────
97
+ /**
98
+ * Defensive merge of capability-declared config slices into the loadConfig
99
+ * return value.
100
+ *
101
+ * DEFENSIVE contract (never throws, even on bad capability data):
102
+ * - Null/undefined/non-object input → returns empty result.
103
+ * - Null/undefined/non-object userConfig → treated as {} (no overrides).
104
+ * - Central keys are skipped with a pending-migration warning.
105
+ * - Malformed slices are skipped with a warning.
106
+ * - User-supplied values with wrong types (or out-of-enum values) fall back
107
+ * to the slice default (a type-mismatch warning is pushed but the key is
108
+ * still federated with its default; this is best-effort degraded operation).
109
+ */
110
+ function mergeFederatedConfig(input) {
111
+ // FIX 4: Guard null/undefined/non-object input
112
+ if (input === null || input === undefined || typeof input !== 'object') {
113
+ return { values: Object.create(null), validKeys: [], warnings: [] };
114
+ }
115
+ const { configSchema, isCentralKey } = input;
116
+ // FIX 4: Guard null/undefined/non-object userConfig — treat as {}
117
+ const userConfig = (input.userConfig !== null && input.userConfig !== undefined && typeof input.userConfig === 'object' && !Array.isArray(input.userConfig))
118
+ ? input.userConfig
119
+ : {};
120
+ // FIX 6b: Use null-prototype object for all return paths
121
+ const values = Object.create(null);
122
+ const validKeys = [];
123
+ const warnings = [];
124
+ if (typeof configSchema !== 'object' || configSchema === null) {
125
+ return { values: Object.create(null), validKeys: [], warnings: [] };
126
+ }
127
+ for (const key of Object.keys(configSchema)) {
128
+ // S2: inline literal prototype-pollution guard (CodeQL barrier)
129
+ // Guard both the full key AND all dotted-path segments
130
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
131
+ continue;
132
+ const _keySegments = key.split('.');
133
+ if (_keySegments.some((s) => s === '__proto__' || s === 'constructor' || s === 'prototype'))
134
+ continue;
135
+ const slice = configSchema[key];
136
+ // If this key is still in the central schema → pending migration, skip
137
+ try {
138
+ if (isCentralKey(key)) {
139
+ warnings.push('federated-config: key "' + key + '" is still in the central config-schema (pending-migration); ' +
140
+ 'skipping federated resolution until the central schema entry is removed');
141
+ continue;
142
+ }
143
+ }
144
+ catch {
145
+ // isCentralKey threw — treat as unknown, skip defensively
146
+ warnings.push('federated-config: isCentralKey("' + key + '") threw; skipping key');
147
+ continue;
148
+ }
149
+ // Validate slice shape — skip malformed entries
150
+ if (!_isWellFormedSlice(slice)) {
151
+ warnings.push('federated-config: config slice for key "' + key + '" is malformed (missing or invalid type/default); skipping');
152
+ continue;
153
+ }
154
+ const sliceEntry = slice;
155
+ // FIX 1: Resolve value using NESTED dotted-path lookup through userConfig
156
+ let resolvedValue = sliceEntry.default;
157
+ const { found: userHasKey, value: userValue } = _getNestedValue(userConfig, key);
158
+ if (userHasKey && userValue !== undefined) {
159
+ // FIX 5b: For enum, validate against slice.values if present; otherwise check type
160
+ if (_typeMatches(userValue, sliceEntry)) {
161
+ resolvedValue = userValue;
162
+ }
163
+ else {
164
+ const typeDesc = sliceEntry.type === 'enum' && Array.isArray(sliceEntry.values)
165
+ ? 'enum(' + sliceEntry.values.join('|') + ')'
166
+ : sliceEntry.type;
167
+ warnings.push('federated-config: user-supplied value for "' + key + '" has wrong type or invalid enum value ' +
168
+ '(expected ' + typeDesc + ', got ' + typeof userValue +
169
+ (typeof userValue === 'string' ? ' "' + String(userValue) + '"' : '') +
170
+ '); falling back to slice default');
171
+ // resolvedValue stays as slice default
172
+ }
173
+ }
174
+ // S2: inline literal guard before writing to values
175
+ if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
176
+ values[key] = resolvedValue;
177
+ validKeys.push(key);
178
+ }
179
+ }
180
+ return { values, validKeys, warnings };
181
+ }
182
+ module.exports = { mergeFederatedConfig };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ /**
3
+ * Graphify command router — CLI subcommand dispatcher for `gsd-tools graphify`.
4
+ *
5
+ * ADR-959 (phase 4d-impl-2) pilot: first real capability command cutover.
6
+ * Extracted from the hardcoded `case 'graphify':` arm in gsd-tools.cjs.
7
+ * Behaviour is preserved byte-for-behaviour from the prior inline case;
8
+ * the dispatch path now flows: default → dispatchCapabilityCommand →
9
+ * require(graphify-command-router.cjs) → routeGraphifyCommand.
10
+ *
11
+ * Router signature: { args, cwd, raw, error } — identical to the 12 existing
12
+ * host routers. No new handler/arg convention; the capability registry
13
+ * discovers this router by name.
14
+ *
15
+ * Arg indexing (preserved exactly from the original case):
16
+ * args[0] = 'graphify' (family — matched by dispatchCapabilityCommand)
17
+ * args[1] = subcommand (query | status | diff | build)
18
+ * args[2] = term (query) | 'snapshot' (build snapshot)
19
+ * args.indexOf('--budget') + 1 = budget value
20
+ *
21
+ * Test seam: pass `_graphify` in the options object to inject a recording mock
22
+ * instead of the real graphify module. The `_`-prefix follows the repo's
23
+ * established seam convention (see other routers). Production callers omit it.
24
+ */
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const graphify = require("./graphify.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ const core = require("./core.cjs");
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const io = require("./io.cjs");
31
+ const { ERROR_REASON } = io;
32
+ // ─── Implementation ───────────────────────────────────────────────────────────
33
+ function routeGraphifyCommand({ args, cwd, raw, error, _graphify }) {
34
+ const subcommand = args[1];
35
+ const g = _graphify ?? graphify;
36
+ if (subcommand === 'query') {
37
+ const term = args[2];
38
+ if (!term) {
39
+ error('Usage: gsd-tools graphify query <term>', ERROR_REASON.USAGE);
40
+ return;
41
+ }
42
+ const budgetIdx = args.indexOf('--budget');
43
+ let budget = null;
44
+ if (budgetIdx !== -1) {
45
+ const rawBudget = args[budgetIdx + 1];
46
+ if (rawBudget === undefined || Number.isNaN(parseInt(rawBudget, 10))) {
47
+ error('Usage: gsd-tools graphify query <term> [--budget <N>]', ERROR_REASON.USAGE);
48
+ return;
49
+ }
50
+ budget = parseInt(rawBudget, 10);
51
+ }
52
+ core.output(g.graphifyQuery(cwd, term, { budget }), raw);
53
+ }
54
+ else if (subcommand === 'status') {
55
+ core.output(g.graphifyStatus(cwd), raw);
56
+ }
57
+ else if (subcommand === 'diff') {
58
+ core.output(g.graphifyDiff(cwd), raw);
59
+ }
60
+ else if (subcommand === 'build') {
61
+ if (args[2] === 'snapshot') {
62
+ core.output(g.writeSnapshot(cwd), raw);
63
+ }
64
+ else {
65
+ core.output(g.graphifyBuild(cwd), raw);
66
+ }
67
+ }
68
+ else {
69
+ error('Unknown graphify subcommand. Available: build, query, status, diff', ERROR_REASON.SDK_UNKNOWN_COMMAND);
70
+ }
71
+ }
72
+ module.exports = {
73
+ routeGraphifyCommand,
74
+ };
@@ -1718,16 +1718,22 @@ function buildSkillManifest(cwd, skillsDir = null) {
1718
1718
  roots.push(rootSummary);
1719
1719
  continue;
1720
1720
  }
1721
- let skillCount = 0;
1722
- for (const entry of entries) {
1723
- if (!entry.isDirectory())
1724
- continue;
1725
- const skillMdPath = node_path_1.default.join(rootPath, entry.name, 'SKILL.md');
1726
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(skillMdPath);
1727
- if (content === null)
1728
- continue;
1721
+ // Track skill names seen within this root to deduplicate dual-routed concretes
1722
+ // (e.g. spec-phase nested under both gsd-ns-workflow and gsd-ns-manage).
1723
+ const seenNamesInRoot = new Set();
1724
+ function pushSkillEntry(
1725
+ // relPath must use forward slashes on all platforms (manifest paths are
1726
+ // posix-style for cross-platform stability; flat entries use template
1727
+ // literals that always produce '/'; nested entries are joined below
1728
+ // with explicit '/' separators rather than path.join).
1729
+ relPath, content) {
1729
1730
  const frontmatter = extractFrontmatter(content);
1730
- const name = frontmatter['name'] || entry.name;
1731
+ const dirPart = relPath.replace(/\/SKILL\.md$/, '');
1732
+ const stem = dirPart.includes('/') ? dirPart.split('/').pop() : dirPart;
1733
+ const name = frontmatter['name'] || stem;
1734
+ if (seenNamesInRoot.has(name))
1735
+ return false; // dedupe dual-routed concretes
1736
+ seenNamesInRoot.add(name);
1731
1737
  const description = frontmatter['description'] || '';
1732
1738
  const triggers = [];
1733
1739
  const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
@@ -1746,14 +1752,54 @@ function buildSkillManifest(cwd, skillsDir = null) {
1746
1752
  name,
1747
1753
  description,
1748
1754
  triggers,
1749
- path: entry.name,
1750
- file_path: `${entry.name}/SKILL.md`,
1755
+ path: dirPart,
1756
+ file_path: relPath,
1751
1757
  root: rootInfo.root,
1752
1758
  scope: rootInfo.scope,
1753
1759
  installed: rootInfo.scope !== 'import-only',
1754
1760
  deprecated: !!rootInfo.deprecated,
1755
1761
  });
1756
- skillCount++;
1762
+ return true;
1763
+ }
1764
+ let skillCount = 0;
1765
+ for (const entry of entries) {
1766
+ if (!entry.isDirectory())
1767
+ continue;
1768
+ const skillMdPath = node_path_1.default.join(rootPath, entry.name, 'SKILL.md');
1769
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(skillMdPath);
1770
+ if (content !== null) {
1771
+ if (pushSkillEntry(`${entry.name}/SKILL.md`, content))
1772
+ skillCount++;
1773
+ }
1774
+ // Nested layout: <entry>/skills/<stem>/SKILL.md
1775
+ // Used by cline, qwen, hermes, augment, trae, antigravity (#69 nested=true).
1776
+ // Descend exactly one level into <entry>/skills/ — no deeper recursion.
1777
+ // Scope to gsd-ns-* routers only: never vacuum up an unrelated user skill
1778
+ // that happens to have its own `skills/` subdirectory.
1779
+ if (!entry.name.startsWith('gsd-ns-'))
1780
+ continue;
1781
+ const nestedSkillsDir = node_path_1.default.join(rootPath, entry.name, 'skills');
1782
+ let nestedEntries = [];
1783
+ try {
1784
+ nestedEntries = node_fs_1.default.readdirSync(nestedSkillsDir, { withFileTypes: true });
1785
+ }
1786
+ catch {
1787
+ // No skills/ subdir — flat layout or unreadable; nothing to do.
1788
+ nestedEntries = [];
1789
+ }
1790
+ for (const nested of nestedEntries) {
1791
+ if (!nested.isDirectory())
1792
+ continue;
1793
+ const nestedSkillMd = node_path_1.default.join(nestedSkillsDir, nested.name, 'SKILL.md');
1794
+ const nestedContent = (0, shell_command_projection_cjs_1.platformReadSync)(nestedSkillMd);
1795
+ if (nestedContent === null)
1796
+ continue;
1797
+ // Use forward-slash separator explicitly so manifest paths are posix-style
1798
+ // on all platforms, matching the flat-layout behaviour above.
1799
+ const relPath = `${entry.name}/skills/${nested.name}/SKILL.md`;
1800
+ if (pushSkillEntry(relPath, nestedContent))
1801
+ skillCount++;
1802
+ }
1757
1803
  }
1758
1804
  rootSummary.skill_count = skillCount;
1759
1805
  roots.push(rootSummary);
@@ -157,10 +157,44 @@ function computeClosure(base, manifest) {
157
157
  }
158
158
  return closed;
159
159
  }
160
+ /**
161
+ * Compute the capability skills to add for a given profile mode from the registry.
162
+ * Returns an array of skill stems contributed by capabilities whose profileMembership
163
+ * includes the given mode. Guards against prototype pollution and malformed registry.
164
+ */
165
+ function _capabilitySkillsForMode(mode, registry) {
166
+ const BANNED = ['__proto__', 'constructor', 'prototype'];
167
+ const clusters = registry.capabilityClusters;
168
+ const membership = registry.profileMembership;
169
+ if (!clusters || typeof clusters !== 'object' || !membership || typeof membership !== 'object') {
170
+ return [];
171
+ }
172
+ const result = [];
173
+ for (const capId of Object.keys(clusters)) {
174
+ if (BANNED.includes(capId))
175
+ continue;
176
+ const mem = membership[capId];
177
+ if (!mem || typeof mem !== 'object')
178
+ continue;
179
+ const profiles = mem.profiles;
180
+ if (!Array.isArray(profiles))
181
+ continue;
182
+ if (!profiles.includes(mode))
183
+ continue;
184
+ const skills = clusters[capId];
185
+ if (!Array.isArray(skills))
186
+ continue;
187
+ for (const s of skills) {
188
+ if (typeof s === 'string' && s.length > 0)
189
+ result.push(s);
190
+ }
191
+ }
192
+ return result;
193
+ }
160
194
  /**
161
195
  * Resolve a profile (or composed profiles) to a typed result object.
162
196
  */
163
- function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
197
+ function resolveProfile({ modes, manifest, _profilesOverride, registry } = {}) {
164
198
  const profiles = _profilesOverride || PROFILES;
165
199
  const activeModes = (modes && modes.length > 0) ? modes : ['full'];
166
200
  const normalizedModes = activeModes
@@ -185,7 +219,11 @@ function resolveProfile({ modes, manifest, _profilesOverride } = {}) {
185
219
  // This profile is full — sentinel short-circuit
186
220
  return { name: 'full', skills: '*', agents: new Set() };
187
221
  }
188
- const closure = computeClosure(base, man);
222
+ // ADR-857 phase 4c: union capability skills for this mode BEFORE closure so
223
+ // their requires: chains expand too.
224
+ const capSkills = registry ? _capabilitySkillsForMode(mode, registry) : [];
225
+ const baseWithCap = [...base, ...capSkills];
226
+ const closure = computeClosure(baseWithCap, man);
189
227
  for (const s of closure)
190
228
  unionSkills.add(s);
191
229
  }
@@ -318,9 +356,103 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) {
318
356
  ensureExitCleanup();
319
357
  return stageDir;
320
358
  }
321
- function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) {
359
+ /**
360
+ * Build the namespace router → concrete sub-skill mapping (#69). The
361
+ * authoritative source is each `ns-*.md` router file's `requires:` frontmatter
362
+ * list. A concrete skill may be routed by more than one router (e.g. spec-phase
363
+ * is shared by ns-workflow and ns-ideate); it is nested — and physically
364
+ * duplicated — under every owning router.
365
+ */
366
+ function buildNamespaceBundleMap(srcCommandsDir) {
367
+ const routerStems = new Set();
368
+ const routerChildren = new Map();
369
+ const childToRouters = new Map();
370
+ if (!node_fs_1.default.existsSync(srcCommandsDir)) {
371
+ return { routerStems, routerChildren, childToRouters };
372
+ }
373
+ for (const entry of node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true })) {
374
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
375
+ continue;
376
+ if (!entry.name.startsWith('ns-'))
377
+ continue;
378
+ const stem = entry.name.slice(0, -3);
379
+ let children = [];
380
+ try {
381
+ children = parseRequires(node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8'));
382
+ }
383
+ catch {
384
+ children = [];
385
+ }
386
+ routerStems.add(stem);
387
+ routerChildren.set(stem, children);
388
+ for (const child of children) {
389
+ const owners = childToRouters.get(child) || [];
390
+ owners.push(stem);
391
+ childToRouters.set(child, owners);
392
+ }
393
+ }
394
+ return { routerStems, routerChildren, childToRouters };
395
+ }
396
+ /**
397
+ * Rewrite a converted namespace-router SKILL.md so its routing table points at
398
+ * nested sub-skill files instead of bare Skill-tool names (#69). Each table row
399
+ * whose final cell carries a `gsd-<stem>` token (optionally with `--flag`
400
+ * suffixes) is rewritten to `Read \`skills/<stem>/SKILL.md\`` (flags preserved
401
+ * as a note), the `Invoke` column header becomes `Read`, and the
402
+ * "Invoke … using the Skill tool" trailer becomes a file-read instruction.
403
+ * Only lines beginning with a table pipe are touched, so the `|` inside the
404
+ * `description:` frontmatter field is never matched.
405
+ */
406
+ function transformRouterBodyToNested(converted) {
407
+ const lines = converted.split('\n');
408
+ const out = lines.map((line) => {
409
+ if (/Invoke the matched skill directly using the Skill tool\./.test(line)) {
410
+ return line.replace(/Invoke the matched skill directly using the Skill tool\./, "Read the matched sub-skill's SKILL.md and follow its instructions. The `skills/<name>/SKILL.md` paths in the right column are relative to this skill's own directory.");
411
+ }
412
+ if (!/^\s*\|/.test(line))
413
+ return line;
414
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line))
415
+ return line;
416
+ if (/\|\s*Invoke\s*\|/.test(line)) {
417
+ return line.replace(/\|\s*Invoke\s*\|/, '| Read |');
418
+ }
419
+ const cells = line.split('|');
420
+ const lastIdx = cells.length - 2;
421
+ if (lastIdx < 1)
422
+ return line;
423
+ const cell = cells[lastIdx];
424
+ const m = cell.match(/gsd-([a-z0-9-]+)((?:\s+--[a-z0-9-]+)*)/i);
425
+ if (!m)
426
+ return line;
427
+ const stem = m[1];
428
+ const flags = m[2].trim();
429
+ cells[lastIdx] = flags
430
+ ? ` Read \`skills/${stem}/SKILL.md\` (${flags}) `
431
+ : ` Read \`skills/${stem}/SKILL.md\` `;
432
+ return cells.join('|');
433
+ });
434
+ return out.join('\n');
435
+ }
436
+ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix, nested = false) {
322
437
  if (!node_fs_1.default.existsSync(srcCommandsDir))
323
438
  return srcCommandsDir;
439
+ // Nesting applies to the `full` install AND to any surface whose skill set
440
+ // still contains every namespace router (a full/reset surface). It must NOT
441
+ // depend on the `'*'` sentinel alone: applySurface() materializes `full` into
442
+ // a concrete Set, so a sentinel-only gate would re-flatten the layout on every
443
+ // surface apply/reset (#69 adversarial-review finding). A partial surface that
444
+ // drops a whole router cluster falls back to flat automatically.
445
+ const bundles = nested ? buildNamespaceBundleMap(srcCommandsDir) : null;
446
+ let doNest = false;
447
+ if (nested && bundles && bundles.routerStems.size > 0) {
448
+ if (resolvedProfile.skills === '*') {
449
+ doNest = true;
450
+ }
451
+ else {
452
+ const present = resolvedProfile.skills;
453
+ doNest = [...bundles.routerStems].every((r) => present.has(r));
454
+ }
455
+ }
324
456
  const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-skills-'));
325
457
  try {
326
458
  const entries = node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true });
@@ -335,6 +467,27 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
335
467
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8');
336
468
  const skillName = `${prefix}${stem}`;
337
469
  const converted = converter(content, skillName);
470
+ if (doNest && bundles.routerStems.has(stem)) {
471
+ // Router skill: rewrite its routing table to the nested Read pattern and
472
+ // emit it as the single top-level bundle entry.
473
+ const destDir = node_path_1.default.join(stageDir, skillName);
474
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
475
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted));
476
+ continue;
477
+ }
478
+ if (doNest && bundles.childToRouters.has(stem)) {
479
+ // Concrete skill routed by one or more namespace routers: nest a copy
480
+ // under each owning router's skills/ subdir so it drops out of the
481
+ // top-level eager listing while staying readable by file path (#69).
482
+ for (const routerStem of bundles.childToRouters.get(stem)) {
483
+ const destDir = node_path_1.default.join(stageDir, `${prefix}${routerStem}`, 'skills', stem);
484
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
485
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), converted);
486
+ }
487
+ continue;
488
+ }
489
+ // Flat top-level skill (default behaviour; also the unrouted fallback when
490
+ // nesting is active).
338
491
  const destDir = node_path_1.default.join(stageDir, skillName);
339
492
  node_fs_1.default.mkdirSync(destDir, { recursive: true });
340
493
  node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), converted);
@@ -575,6 +728,7 @@ module.exports = {
575
728
  readActiveProfile,
576
729
  writeActiveProfile,
577
730
  // Shared internals
731
+ parseRequires,
578
732
  cleanupStagedSkills,
579
733
  // Back-compat / deprecated
580
734
  MINIMAL_SKILL_ALLOWLIST,
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * Installer migration 004: remove stale gsd-pristine/get-shit-done/ snapshot // gsd-allow-legacy-name
4
+ * files after the get-shit-done → gsd-core rename (#604, #934). // gsd-allow-legacy-name
5
+ *
6
+ * Background: migration 003 removed legacy runtime files from
7
+ * get-shit-done/ but did not touch gsd-pristine/get-shit-done/, the // gsd-allow-legacy-name
8
+ * parallel directory that holds pristine snapshots captured before the rename.
9
+ * These snapshot files are GSD-managed (written by the installer, never by the
10
+ * user) and reference stale get-shit-done/... key paths that no longer exist // gsd-allow-legacy-name
11
+ * in the active layout. When verify-reapply-patches.cjs looks up a backup entry
12
+ * keyed under gsd-core/... it finds no matching gsd-pristine/ snapshot, falls
13
+ * to over-broad mode, and reports false FAIL_INSTALLED_MISSING / // gsd-allow-legacy-name
14
+ * FAIL_USER_LINES_MISSING for every backed-up pre-rename file (#934).
15
+ *
16
+ * Fix: walk gsd-pristine/get-shit-done/ and emit remove-managed for each file. // gsd-allow-legacy-name
17
+ * These files are always GSD-written snapshots — users never place their own
18
+ * files inside gsd-pristine/ — so the classification override
19
+ * (managed-pristine) is safe: there is no user content to protect.
20
+ *
21
+ * Checksum safety: migration 003's body is left untouched. Adding this
22
+ * separate migration avoids modifying 003's checksum, which would break
23
+ * upgrade state for any user who already applied 003 (root cause of #670).
24
+ *
25
+ * Per-file approach: the migration framework has no recursive directory-removal
26
+ * primitive — all actions operate on individual files. Empty directory shells
27
+ * left after removal can be cleaned up manually; this is the intentional ADR-0008
28
+ * limitation.
29
+ */
30
+ var __importDefault = (this && this.__importDefault) || function (mod) {
31
+ return (mod && mod.__esModule) ? mod : { "default": mod };
32
+ };
33
+ const node_fs_1 = __importDefault(require("node:fs"));
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ function walkPristineFiles(root, relDir, baseResolved, results) {
36
+ const dir = node_path_1.default.join(root, relDir);
37
+ let entries;
38
+ try {
39
+ entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
40
+ }
41
+ catch {
42
+ return; // directory absent or unreadable — nothing to do
43
+ }
44
+ for (const entry of entries) {
45
+ // Do not follow symlinks — skip to avoid out-of-tree traversal.
46
+ if (entry.isSymbolicLink())
47
+ continue;
48
+ const relPath = node_path_1.default.posix.join(relDir, entry.name);
49
+ // Bounds check: ensure the resolved path stays under configDir.
50
+ const resolved = node_path_1.default.resolve(root, relPath);
51
+ if (resolved !== baseResolved && !resolved.startsWith(baseResolved + node_path_1.default.sep))
52
+ continue;
53
+ if (entry.isDirectory()) {
54
+ walkPristineFiles(root, relPath, baseResolved, results);
55
+ }
56
+ else if (entry.isFile()) {
57
+ results.push(relPath);
58
+ }
59
+ }
60
+ }
61
+ const REASON = 'stale pristine snapshot from legacy get-shit-done/ dir, orphaned by rename migration 003 (#604, #934)'; // gsd-allow-legacy-name
62
+ const migration = {
63
+ id: '2026-06-09-prune-stale-pristine-get-shit-done', // gsd-allow-legacy-name
64
+ title: 'Remove stale gsd-pristine/get-shit-done/ snapshot files (#934)', // gsd-allow-legacy-name
65
+ description: 'Migration 003 removed runtime files from get-shit-done/ but left the matching pristine snapshot ' + // gsd-allow-legacy-name
66
+ 'directory gsd-pristine/get-shit-done/ intact. Those snapshots reference stale key paths and cause ' + // gsd-allow-legacy-name
67
+ 'verify-reapply-patches false positives (#934). Remove all files under gsd-pristine/get-shit-done/ ' + // gsd-allow-legacy-name
68
+ 'as they are GSD-managed snapshots, never user content.',
69
+ introducedIn: '1.4.3',
70
+ scopes: ['global', 'local'],
71
+ destructive: true,
72
+ plan(ctx) {
73
+ const pristineGsdRoot = node_path_1.default.join(ctx.configDir, 'gsd-pristine', 'get-shit-done'); // gsd-allow-legacy-name
74
+ // Idempotency: if the stale pristine subdir doesn't exist, nothing to do.
75
+ if (!node_fs_1.default.existsSync(pristineGsdRoot))
76
+ return [];
77
+ // Safety: reject symlinks in ANY ancestor component of the path we will walk
78
+ // to prevent following a symlink out of configDir. Check both gsd-pristine/
79
+ // and gsd-pristine/get-shit-done/ — either being a symlink could redirect // gsd-allow-legacy-name
80
+ // the walk to an out-of-tree location.
81
+ const pristineParent = node_path_1.default.join(ctx.configDir, 'gsd-pristine');
82
+ try {
83
+ if (node_fs_1.default.lstatSync(pristineParent).isSymbolicLink())
84
+ return [];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ try {
90
+ if (node_fs_1.default.lstatSync(pristineGsdRoot).isSymbolicLink())
91
+ return []; // gsd-allow-legacy-name
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ const baseResolved = node_path_1.default.resolve(ctx.configDir);
97
+ const relPaths = [];
98
+ walkPristineFiles(ctx.configDir, node_path_1.default.posix.join('gsd-pristine', 'get-shit-done'), baseResolved, relPaths); // gsd-allow-legacy-name
99
+ const actions = [];
100
+ for (const relPath of relPaths) {
101
+ // Bounds-check each relPath before emitting any action.
102
+ const resolved = node_path_1.default.resolve(ctx.configDir, relPath);
103
+ if (resolved !== baseResolved && !resolved.startsWith(baseResolved + node_path_1.default.sep))
104
+ continue;
105
+ // These files are GSD-managed pristine snapshots — the installer writes
106
+ // them during install/upgrade; users never place personal files inside
107
+ // gsd-pristine/. Pass classification: 'managed-pristine' explicitly so
108
+ // the framework does not downgrade remove-managed to preserve-user when
109
+ // the manifest has no entry (these paths were never in the manifest since
110
+ // they live under gsd-pristine/, not the tracked runtime dir).
111
+ actions.push({
112
+ type: 'remove-managed',
113
+ relPath,
114
+ reason: REASON,
115
+ ownershipEvidence: 'GSD-written pristine snapshot under gsd-pristine/get-shit-done/; ' + // gsd-allow-legacy-name
116
+ 'installer is the sole author of gsd-pristine/ contents; no user content lives here',
117
+ classification: 'managed-pristine',
118
+ });
119
+ }
120
+ return actions;
121
+ },
122
+ };
123
+ module.exports = migration;