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

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 (149) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-code-fixer.md +3 -2
  3. package/agents/gsd-debug-session-manager.md +2 -1
  4. package/agents/gsd-debugger.md +4 -3
  5. package/agents/gsd-executor.md +16 -15
  6. package/agents/gsd-intel-updater.md +38 -41
  7. package/agents/gsd-phase-researcher.md +8 -8
  8. package/agents/gsd-plan-checker.md +12 -11
  9. package/agents/gsd-planner.md +21 -181
  10. package/agents/gsd-project-researcher.md +5 -4
  11. package/agents/gsd-research-synthesizer.md +2 -1
  12. package/agents/gsd-ui-researcher.md +2 -1
  13. package/agents/gsd-verifier.md +9 -8
  14. package/bin/install.js +205 -1402
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd_run +20 -0
  17. package/gsd-core/bin/lib/capability-registry.cjs +1820 -3
  18. package/gsd-core/bin/lib/check-command-router.cjs +133 -2
  19. package/gsd-core/bin/lib/core.cjs +51 -1
  20. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  21. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  22. package/gsd-core/bin/lib/intel.cjs +3 -3
  23. package/gsd-core/bin/lib/io.cjs +61 -6
  24. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  25. package/gsd-core/bin/lib/phase.cjs +17 -0
  26. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +40 -0
  28. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +51 -141
  29. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +67 -29
  30. package/gsd-core/bin/lib/runtime-homes.cjs +137 -101
  31. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  32. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
  33. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  34. package/gsd-core/bin/lib/state-document.cjs +8 -0
  35. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  36. package/gsd-core/bin/lib/update-context.cjs +4 -1
  37. package/gsd-core/bin/lib/verify.cjs +103 -0
  38. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  39. package/gsd-core/bin/shared/model-catalog.json +11 -6
  40. package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
  41. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  42. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  43. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  44. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  45. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  46. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  47. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  48. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  49. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  50. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  51. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  52. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  53. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  54. package/gsd-core/references/edge-probe.md +261 -0
  55. package/gsd-core/references/planner-antipatterns.md +41 -0
  56. package/gsd-core/references/planner-guidance.md +186 -0
  57. package/gsd-core/templates/spec.md +12 -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 +26 -37
  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 +1 -1
  71. package/gsd-core/workflows/code-review.md +51 -16
  72. package/gsd-core/workflows/complete-milestone.md +1 -1
  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 +1 -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 +1 -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 +1 -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/import.md +1 -1
  94. package/gsd-core/workflows/ingest-docs.md +1 -1
  95. package/gsd-core/workflows/insert-phase.md +1 -1
  96. package/gsd-core/workflows/list-workspaces.md +1 -1
  97. package/gsd-core/workflows/manager.md +1 -1
  98. package/gsd-core/workflows/map-codebase.md +1 -1
  99. package/gsd-core/workflows/milestone-summary.md +1 -1
  100. package/gsd-core/workflows/mvp-phase.md +1 -1
  101. package/gsd-core/workflows/new-milestone.md +9 -1
  102. package/gsd-core/workflows/new-project.md +9 -1
  103. package/gsd-core/workflows/new-workspace.md +1 -1
  104. package/gsd-core/workflows/next.md +1 -1
  105. package/gsd-core/workflows/pause-work.md +1 -1
  106. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  107. package/gsd-core/workflows/plan-phase.md +39 -27
  108. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  109. package/gsd-core/workflows/plant-seed.md +1 -1
  110. package/gsd-core/workflows/profile-user.md +1 -1
  111. package/gsd-core/workflows/progress.md +1 -1
  112. package/gsd-core/workflows/quick.md +1 -1
  113. package/gsd-core/workflows/remove-phase.md +1 -1
  114. package/gsd-core/workflows/remove-workspace.md +1 -1
  115. package/gsd-core/workflows/resume-project.md +1 -1
  116. package/gsd-core/workflows/review.md +1 -1
  117. package/gsd-core/workflows/scan.md +1 -1
  118. package/gsd-core/workflows/secure-phase.md +1 -1
  119. package/gsd-core/workflows/settings-advanced.md +18 -14
  120. package/gsd-core/workflows/settings-integrations.md +1 -1
  121. package/gsd-core/workflows/settings.md +2 -2
  122. package/gsd-core/workflows/ship.md +1 -1
  123. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  124. package/gsd-core/workflows/sketch.md +1 -1
  125. package/gsd-core/workflows/spec-phase.md +130 -1
  126. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  127. package/gsd-core/workflows/spike.md +1 -1
  128. package/gsd-core/workflows/stats.md +1 -1
  129. package/gsd-core/workflows/thread.md +1 -1
  130. package/gsd-core/workflows/transition.md +1 -1
  131. package/gsd-core/workflows/ui-phase.md +1 -1
  132. package/gsd-core/workflows/ui-review.md +1 -1
  133. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  134. package/gsd-core/workflows/update.md +2 -2
  135. package/gsd-core/workflows/validate-phase.md +1 -1
  136. package/gsd-core/workflows/verify-phase.md +1 -1
  137. package/gsd-core/workflows/verify-work.md +1 -1
  138. package/package.json +6 -3
  139. package/scripts/gen-capability-registry.cjs +498 -13
  140. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  141. package/scripts/lint-test-file-count.allowlist.json +15 -2
  142. package/scripts/lint-windows-test-portability.cjs +178 -0
  143. package/scripts/research-profiles.cjs +10 -10
  144. package/scripts/run-tests.cjs +54 -13
  145. package/scripts/sync-next-version.cjs +133 -0
  146. package/scripts/sync-runtime-launcher.cjs +21 -5
  147. package/scripts/update-size-baseline.cjs +68 -0
  148. package/scripts/workflow-policy.cjs +42 -9
  149. package/scripts/workflow-size.cjs +90 -0
@@ -140,6 +140,26 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
140
140
  phase.cmdPhaseComplete(cwd, args[2], raw);
141
141
  return { ok: true, data: null };
142
142
  },
143
+ 'uat-passed': (_ctx) => {
144
+ let requireVerification = false;
145
+ const positional = [];
146
+ for (const token of args.slice(2)) {
147
+ if (token === '--require-verification') {
148
+ requireVerification = true;
149
+ }
150
+ else if (token === '--raw') {
151
+ // --raw is handled by the outer CLI layer; accepted here silently
152
+ }
153
+ else if (token.startsWith('--')) {
154
+ return makeInvalidArgs(token, `phase uat-passed does not support ${token}`);
155
+ }
156
+ else {
157
+ positional.push(token);
158
+ }
159
+ }
160
+ phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
161
+ return { ok: true, data: null };
162
+ },
143
163
  },
144
164
  };
145
165
  // ── Build manifest (available subcommands for UnknownCommand detection) ─────
@@ -32,6 +32,9 @@ const stateMod = require("./state.cjs");
32
32
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
33
33
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
34
34
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
36
+ const uatPredicate = require("./uat-predicate.cjs");
37
+ const { evaluateUatPassed } = uatPredicate;
35
38
  const { escapeRegex, loadConfig, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, ERROR_REASON, } = core;
36
39
  const { planningDir, withPlanningLock } = planningWorkspace;
37
40
  const { extractFrontmatter } = frontmatterMod;
@@ -1292,6 +1295,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1292
1295
  };
1293
1296
  output(result, raw);
1294
1297
  }
1298
+ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
1299
+ if (!phaseNum) {
1300
+ error('phase number required for phase uat-passed');
1301
+ }
1302
+ const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
1303
+ if (!phaseInfoRaw) {
1304
+ error(`Phase ${phaseNum} not found`);
1305
+ }
1306
+ const phaseInfo = phaseInfoRaw;
1307
+ const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1308
+ const report = evaluateUatPassed(phaseFullDir, { policy: opts.policy });
1309
+ output({ phase: phaseNum, ...report }, raw);
1310
+ }
1295
1311
  module.exports = {
1296
1312
  cmdPhasesList,
1297
1313
  cmdPhaseNextDecimal,
@@ -1303,5 +1319,6 @@ module.exports = {
1303
1319
  cmdPhaseInsert,
1304
1320
  cmdPhaseRemove,
1305
1321
  cmdPhaseComplete,
1322
+ cmdPhaseUatPassed,
1306
1323
  computeDependencyLevels,
1307
1324
  };
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ /**
3
+ * probe-core — generic spec-phase probe resolution model (ADR-550 Decision 7).
4
+ *
5
+ * Extracted from the edge-probe (the first adapter) once the prohibition probe (#644)
6
+ * proved it the *second* adapter of the same model: one adapter is a hypothetical seam,
7
+ * two is a real one. This module owns everything generic — the resolution lifecycle,
8
+ * the status×verification re-cut, `validateResolution`/`validateRequirement`, the
9
+ * `analyzeCoverage(items, resolutions?, validators)` merge/rollup/orphan-reject engine,
10
+ * the `byVerification` rollup, and the `runProbeCli` I/O scaffold. Each probe is a thin
11
+ * adapter: it supplies the proposal logic (deterministic for edge, LLM-recall for
12
+ * prohibition) and its closed vocabularies via injected validators.
13
+ *
14
+ * Authored as strict TypeScript (`src/probe-core.cts`) and compiled by
15
+ * `tsc -p tsconfig.build.json` to the gitignored runtime artifact
16
+ * `gsd-core/bin/lib/probe-core.cjs`. Do NOT hand-write the `.cjs`; it is emitted.
17
+ *
18
+ * Two orthogonal axes (the re-cut):
19
+ * - status: resolved | dismissed | unresolved — the resolution LIFECYCLE (shared)
20
+ * - verification: <probe-defined> | null — HOW a resolved item is verified
21
+ * The edge adapter declares `verification: explicit | backstop`; the prohibition adapter
22
+ * (#644) will declare `test | judgment`. Splitting the axes keeps the lifecycle enum free
23
+ * of a verification fact and lets a sibling probe add its own tiers without a parallel enum.
24
+ *
25
+ * Typing is hybrid (ADR-550 #5): generic type params for adapter DX, but enforcement runs
26
+ * through injected runtime validators, because the CLI executes over JSON where TS types are
27
+ * erased. The contract test pins the validators, not the types.
28
+ */
29
+ var __importDefault = (this && this.__importDefault) || function (mod) {
30
+ return (mod && mod.__esModule) ? mod : { "default": mod };
31
+ };
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.VALID_STATUS = void 0;
34
+ exports.validateRequirement = validateRequirement;
35
+ exports.validateResolution = validateResolution;
36
+ exports.analyzeCoverage = analyzeCoverage;
37
+ exports.runProbeCli = runProbeCli;
38
+ const node_fs_1 = __importDefault(require("node:fs"));
39
+ /** The LOCKED set of valid lifecycle statuses (the re-cut: no covered/backstop). */
40
+ exports.VALID_STATUS = ['resolved', 'dismissed', 'unresolved'];
41
+ function errMessage(e) {
42
+ return e instanceof Error ? e.message : String(e);
43
+ }
44
+ /**
45
+ * Structural guard for the report an adapter's `analyze` returns. The scaffold types `analyze`
46
+ * loosely (it runs over JSON-parsed input the adapter `as`-casts), so a future adapter (#644)
47
+ * that forgets to validate inside its closure could hand back a malformed object. Rather than
48
+ * stringify garbage as green output, `runProbeCli` checks the report shape and fails closed.
49
+ */
50
+ function isValidReport(report) {
51
+ if (report == null || typeof report !== 'object')
52
+ return false;
53
+ const r = report;
54
+ if (!Array.isArray(r.items))
55
+ return false;
56
+ const c = r.coverage;
57
+ if (c == null || typeof c !== 'object')
58
+ return false;
59
+ if (typeof c.applicable !== 'number' || typeof c.resolved !== 'number' || typeof c.unresolved !== 'number') {
60
+ return false;
61
+ }
62
+ if (c.byVerification == null || typeof c.byVerification !== 'object')
63
+ return false;
64
+ return true;
65
+ }
66
+ /**
67
+ * Validate a requirement's generic structural fields — fail closed on malformed input rather
68
+ * than coercing it. Probe-specific fields (e.g. the edge adapter's `shapes`) are validated by
69
+ * the adapter. Typed loosely because the CLI casts arbitrary parsed JSON to `Requirement`.
70
+ */
71
+ function validateRequirement(requirement) {
72
+ const r = requirement;
73
+ if (typeof r.id !== 'string' || !r.id.trim()) {
74
+ throw new Error(`requirement id must be a non-empty string (got ${JSON.stringify(r.id)})`);
75
+ }
76
+ if (r.text != null && typeof r.text !== 'string') {
77
+ throw new Error(`requirement ${r.id} text must be a string when present`);
78
+ }
79
+ }
80
+ /**
81
+ * Validate a resolution against the probe's injected validators. Rejects an unknown status,
82
+ * a dismissal without a non-empty reason, a `resolved` item with a missing/unknown
83
+ * verification tier, and a `resolved` item missing any field its tier requires (per
84
+ * `requiredFieldsByVerification`). Returns true on success.
85
+ */
86
+ function validateResolution(r, validators) {
87
+ const key = `${r.requirement_id}::${r.category}`;
88
+ if (!exports.VALID_STATUS.includes(r.status)) {
89
+ throw new Error(`invalid status "${r.status}" for ${key}`);
90
+ }
91
+ // Invariant (this module's header): `verification` is null unless `status` is `resolved`.
92
+ // Enforce it for EVERY status — a dismissed/unresolved resolution carrying a verification
93
+ // tier would otherwise merge verbatim (`analyzeCoverage` below) and silently break the
94
+ // model for the second adapter (#644) that inherits this seam. Fail closed across the full
95
+ // status×verification space, not just `resolved`.
96
+ if (r.status !== 'resolved' && r.verification != null) {
97
+ throw new Error(`verification must be null unless status is "resolved" (got "${r.verification}") for ${key}`);
98
+ }
99
+ // An `unresolved` resolution is an UNACTED item: it must carry no resolution/reason payload.
100
+ // A populated payload is an authoring mistake (the author meant resolved/dismissed) that
101
+ // would otherwise be silently dropped into the unresolved count with no error pointing at
102
+ // it. Reject it so the mistake surfaces.
103
+ if (r.status === 'unresolved') {
104
+ if (r.resolution != null && String(r.resolution).trim()) {
105
+ throw new Error(`unresolved must not carry a resolution (${key})`);
106
+ }
107
+ if (r.reason != null && String(r.reason).trim()) {
108
+ throw new Error(`unresolved must not carry a reason (${key})`);
109
+ }
110
+ }
111
+ if (r.status === 'dismissed' && !(r.reason && String(r.reason).trim())) {
112
+ throw new Error(`dismissed requires a reason (${key})`);
113
+ }
114
+ if (r.status === 'resolved') {
115
+ const tier = r.verification;
116
+ if (tier == null) {
117
+ throw new Error(`resolved requires a verification tier (one of: ${validators.verification.join(', ')}) for ${key}`);
118
+ }
119
+ if (!validators.verification.includes(tier)) {
120
+ throw new Error(`invalid verification "${tier}" for ${key} — must be one of: ${validators.verification.join(', ')}`);
121
+ }
122
+ const required = validators.requiredFieldsByVerification[tier] ?? [];
123
+ for (const field of required) {
124
+ // field is 'resolution' | 'reason'; both are `string | null | undefined` on Resolution,
125
+ // so the indexed access is string-typed (no unknown-to-string coercion).
126
+ const value = r[field];
127
+ if (!(value != null && String(value).trim())) {
128
+ throw new Error(`${tier} requires a ${field} (${key})`);
129
+ }
130
+ }
131
+ }
132
+ return true;
133
+ }
134
+ /**
135
+ * Merge author resolutions onto ALREADY-PROPOSED items and roll up coverage counts.
136
+ *
137
+ * Core operates on `items[]`, never a `proposeFn`: probes have different deterministic
138
+ * surfaces (edge = deterministic propose + LLM resolve; prohibition = LLM propose + deterministic
139
+ * validate/merge), so proposal stays in each adapter and core must not assume it is deterministic.
140
+ *
141
+ * `coverage.resolved` is the COUNT of CLOSED items (`resolved` + `dismissed` status) =
142
+ * `applicable - unresolved` — the pre-re-cut "covered + dismissed + backstop" set,
143
+ * count-preserved. `byVerification` breaks the `resolved`-status items down by tier (each tier
144
+ * initialized to 0). Throws on any invalid resolution, a duplicate, an orphan (a resolution
145
+ * matching no proposed item), or a proposed item whose category is outside `validators.categories`.
146
+ */
147
+ function analyzeCoverage(items, resolutions = [], validators) {
148
+ if (!Array.isArray(items)) {
149
+ throw new Error('items must be an array');
150
+ }
151
+ const key = (r) => `${r.requirement_id}::${r.category}`;
152
+ const resMap = new Map();
153
+ for (const r of resolutions) {
154
+ validateResolution(r, validators);
155
+ if (resMap.has(key(r))) {
156
+ throw new Error(`duplicate resolution for ${key(r)}`);
157
+ }
158
+ resMap.set(key(r), r);
159
+ }
160
+ const validCategories = new Set(validators.categories);
161
+ const merged = [];
162
+ const itemKeys = new Set();
163
+ for (const item of items) {
164
+ if (!validCategories.has(item.category)) {
165
+ throw new Error(`item ${key(item)} has unknown category "${item.category}" — not one of: ${validators.categories.join(', ')}`);
166
+ }
167
+ itemKeys.add(key(item));
168
+ const o = resMap.get(key(item));
169
+ if (o) {
170
+ merged.push({ ...item, status: o.status, verification: o.verification ?? null, resolution: o.resolution ?? null, reason: o.reason ?? null });
171
+ }
172
+ else {
173
+ // No author resolution: the item is rolled up VERBATIM, so its own status/fields must be
174
+ // valid too. The edge adapter only proposes `unresolved` items, but the prohibition adapter
175
+ // (#644) proposes LLM-generated items that arrive already populated — one carrying an
176
+ // out-of-enum status (e.g. the dropped "covered") or `dismissed` with no reason would
177
+ // otherwise be counted closed without validation. An Item is structurally a superset of a
178
+ // Resolution, so the same fail-closed check guards both. (ADR-550 Decision 5 hardens this
179
+ // shared seam for the second adapter; m1.)
180
+ validateResolution(item, validators);
181
+ merged.push(item);
182
+ }
183
+ }
184
+ // Reject orphan resolutions — a resolution whose (requirement_id, category) matches no
185
+ // proposed item (typo'd category or a non-applicable one) would otherwise be silently
186
+ // dropped, leaving the author believing an item is resolved while the report shows it
187
+ // unresolved (adversarial-review HIGH; preserved from the edge-probe's original engine).
188
+ for (const k of resMap.keys()) {
189
+ if (!itemKeys.has(k)) {
190
+ throw new Error(`unknown resolution for ${k} — no matching proposed item (typo'd category or non-applicable shape?)`);
191
+ }
192
+ }
193
+ const unresolved = merged.filter((i) => i.status === 'unresolved').length;
194
+ const applicable = merged.length;
195
+ const resolved = applicable - unresolved; // closed set: resolved-status + dismissed
196
+ const byVerification = {};
197
+ for (const tier of validators.verification)
198
+ byVerification[tier] = 0;
199
+ for (const i of merged) {
200
+ if (i.status === 'resolved' && i.verification != null) {
201
+ byVerification[i.verification] = (byVerification[i.verification] ?? 0) + 1;
202
+ }
203
+ }
204
+ return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
205
+ }
206
+ /**
207
+ * Read the requirements file (and optional resolutions file), run the adapter's `analyze`,
208
+ * and write the report as pretty JSON + newline. With no requirements path, writes the usage
209
+ * line to stderr and exits 2. A JSON-parse failure or any `analyze` throw is a handled error:
210
+ * stderr + exit 2, never an uncaught stack trace — so the engine's fail-closed validation
211
+ * surfaces at the workflow boundary rather than failing open.
212
+ */
213
+ function runProbeCli(analyze, options) {
214
+ const argv = options.argv ?? process.argv;
215
+ const readFile = options.readFile ?? ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
216
+ const write = options.write ?? ((s) => { process.stdout.write(s); });
217
+ const writeErr = options.writeErr ?? ((s) => { process.stderr.write(s); });
218
+ const exit = options.exit ?? ((code) => { process.exit(code); });
219
+ const reqPath = argv[2];
220
+ const resPath = argv[3];
221
+ if (!reqPath) {
222
+ writeErr(`usage: ${options.usage}\n`);
223
+ exit(2);
224
+ return;
225
+ }
226
+ let requirements;
227
+ try {
228
+ requirements = JSON.parse(readFile(reqPath));
229
+ }
230
+ catch (e) {
231
+ writeErr(`error: cannot parse JSON from ${reqPath}: ${errMessage(e)}\n`);
232
+ exit(2);
233
+ return;
234
+ }
235
+ let resolutions = [];
236
+ if (resPath) {
237
+ try {
238
+ resolutions = JSON.parse(readFile(resPath));
239
+ }
240
+ catch (e) {
241
+ writeErr(`error: cannot parse JSON from ${resPath}: ${errMessage(e)}\n`);
242
+ exit(2);
243
+ return;
244
+ }
245
+ }
246
+ try {
247
+ const report = analyze(requirements, resolutions);
248
+ if (!isValidReport(report)) {
249
+ throw new Error('adapter returned a structurally-invalid coverage report (expected { items[], coverage{ applicable, resolved, unresolved, byVerification } })');
250
+ }
251
+ write(`${JSON.stringify(report, null, 2)}\n`);
252
+ }
253
+ catch (e) {
254
+ writeErr(`error: ${errMessage(e)}\n`);
255
+ exit(2);
256
+ }
257
+ }
@@ -135,6 +135,45 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
135
135
  section,
136
136
  };
137
137
  }
138
+ // ─── getRoadmapPhaseWithFallback ──────────────────────────────────────────────
139
+ /**
140
+ * Two-pass phase lookup that mirrors cmdRoadmapGetPhase's resolution strategy.
141
+ *
142
+ * Pass 1: current-milestone slice (extractCurrentMilestone).
143
+ * Pass 2: full roadmap content (stripShippedMilestones) — covers cross-milestone
144
+ * and older frontend phases that are no longer in the current milestone slice.
145
+ *
146
+ * Returns the phase section string if found, null if ROADMAP.md is missing,
147
+ * or throws if ROADMAP.md read fails.
148
+ *
149
+ * Used by check-command-router (computeUiPlanGate) so ui-plan-gate uses the SAME
150
+ * phase resolution as `roadmap.get-phase` — not a milestone-only subset.
151
+ */
152
+ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
153
+ const roadmapPath = planningPaths(cwd).roadmap;
154
+ if (!node_fs_1.default.existsSync(roadmapPath))
155
+ return null;
156
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
157
+ const milestoneContent = extractCurrentMilestone(rawContent, cwd);
158
+ const fullContent = stripShippedMilestones(rawContent);
159
+ const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
160
+ if (exactSource) {
161
+ const exactMilestone = searchPhaseInContent(milestoneContent, exactSource, phaseNum);
162
+ if (exactMilestone && !exactMilestone.error)
163
+ return exactMilestone.section ?? null;
164
+ const exactFull = searchPhaseInContent(fullContent, exactSource, phaseNum);
165
+ if (exactFull && !exactFull.error)
166
+ return exactFull.section ?? null;
167
+ }
168
+ const escapedPhase = phaseMarkdownRegexSource(phaseNum);
169
+ const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
170
+ const result = (milestoneResult && !milestoneResult.error)
171
+ ? milestoneResult
172
+ : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
173
+ if (!result || result.error)
174
+ return null;
175
+ return result.section ?? null;
176
+ }
138
177
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
139
178
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
140
179
  const roadmapPath = planningPaths(cwd).roadmap;
@@ -597,6 +636,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
597
636
  }
598
637
  module.exports = {
599
638
  cmdRoadmapGetPhase,
639
+ getRoadmapPhaseWithFallback,
600
640
  cmdRoadmapAnalyze,
601
641
  cmdRoadmapUpdatePlanProgress,
602
642
  cmdRoadmapAnnotateDependencies,
@@ -197,8 +197,12 @@ function kimiAgentsKind(destSubpath, prefix, configDir) {
197
197
  * @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
198
198
  * @param configDir runtime config dir (for .gsd-source marker resolution)
199
199
  * @param nested if true, nest concrete skills under their ns-* routers (#69)
200
+ * @param scope install scope; converted to isGlobal and passed as 5th positional
201
+ * arg so scope-aware converters (antigravity, copilot) can choose
202
+ * between global home paths and workspace-relative paths without
203
+ * colliding with the `runtime` string at position 3.
200
204
  */
201
- function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false) {
205
+ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nested = false, scope = 'global') {
202
206
  return {
203
207
  kind: 'skills',
204
208
  destSubpath,
@@ -207,9 +211,14 @@ function skillsKind(destSubpath, prefix, converterName, runtime, configDir, nest
207
211
  const installExports = getInstallExports();
208
212
  const realConverter = installExports[converterName];
209
213
  // Compute cmdNames once per stage call for performance (#3583).
210
- // Extra args are ignored by converters that don't need runtime/cmdNames.
214
+ // Extra trailing args are ignored by converters that don't need them. The
215
+ // isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is
216
+ // `runtime` for the claude/kimi/cline converters, so the scope-aware
217
+ // converters (antigravity, copilot) read isGlobal from position 5 to avoid
218
+ // colliding with `runtime` and always taking the global branch.
211
219
  const cmdNames = installExports.readGsdCommandNames();
212
- const wrappedConverter = (content, skillName) => realConverter(content, skillName, runtime, cmdNames);
220
+ const isGlobal = scope === 'global';
221
+ const wrappedConverter = (content, skillName) => realConverter(content, skillName, runtime, cmdNames, isGlobal);
213
222
  return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
214
223
  },
215
224
  };
@@ -242,47 +251,41 @@ function convertedCommandsKind(destSubpath, prefix, converterName, configDir) {
242
251
  },
243
252
  };
244
253
  }
245
- // ---------------------------------------------------------------------------
246
- // Public API
247
- // ---------------------------------------------------------------------------
248
- // ---------------------------------------------------------------------------
249
- // Nested skill-bundle support matrix (#69)
250
- // ---------------------------------------------------------------------------
251
- //
252
- // When a runtime's skill loader scans only one level deep (non-recursive), a
253
- // concrete skill nested at `<router>/skills/<name>/SKILL.md` drops out of the
254
- // eager top-level listing yet stays readable by file path — which is exactly
255
- // what namespace routing needs. Recursive loaders surface every nested SKILL.md
256
- // as a peer (zero token saving), so they stay flat. Unconfirmed loaders stay
257
- // flat conservatively. Verified June 2026:
258
- //
259
- // NEST (confirmed non-recursive / one-level scan):
260
- // cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir
261
- // qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough")
262
- // hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills
263
- // (single-level subdir probe of the tap path)
264
- // augment — https://docs.augmentcode.com/cli/skills (flat single-level)
265
- // trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors)
266
- // antigravity— discuss.ai.google.dev/t/more-antigravity-issues/145875 ("will not recursive scan")
267
- //
268
- // FLAT (recursive loader → nesting gives no saving):
269
- // cursor — https://cursor.com/docs/skills (walks skills root recursively)
270
- // opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md"
271
- // kilo — Kilo-Org/kilocode (opencode fork, same ** glob)
272
- //
273
- // FLAT (reverted from nested — nested skills not discoverable by Skill tool, #924):
274
- // claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266
275
- // (one-level scan under ~/.claude/skills — but Skill-tool errors on unknown
276
- // names rather than re-routing via the router; concrete skills must be
277
- // at the top level so Skill(skill="gsd-plan-phase") succeeds)
278
- //
279
- // FLAT (nested-scan behaviour unconfirmed → conservative):
280
- // codex — developers.openai.com/codex/skills/
281
- // copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills
282
- // windsurf — docs.devin.ai/desktop/cascade/skills
283
- // codebuddy — codebuddy.ai/docs/cli/skills
254
+ /** Lazy registry accessor — mirrors pattern from 5b/5c (runtime-homes.cts). */
255
+ function getRegistry() {
256
+ return _require('./capability-registry.cjs');
257
+ }
258
+ /**
259
+ * Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
260
+ * matching builder function. Mirrors the hand-built calls in the old switch.
261
+ */
262
+ function dispatchKindEntry(entry, runtime, configDir, scope) {
263
+ const { kind, destSubpath, prefix, nesting, converter } = entry;
264
+ const nested = nesting === 'nested';
265
+ switch (kind) {
266
+ case 'commands':
267
+ if (converter == null) {
268
+ return commandsKind(destSubpath, prefix, configDir);
269
+ }
270
+ return convertedCommandsKind(destSubpath, prefix, converter, configDir);
271
+ case 'agents':
272
+ return agentsKind(destSubpath, prefix, configDir);
273
+ case 'skills':
274
+ if (converter == null) {
275
+ throw new TypeError(`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`);
276
+ }
277
+ return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
278
+ case 'kimi-agents':
279
+ return kimiAgentsKind(destSubpath, prefix, configDir);
280
+ default:
281
+ throw new TypeError(`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`);
282
+ }
283
+ }
284
284
  /**
285
285
  * Resolve the artifact layout for a given runtime and config directory.
286
+ *
287
+ * ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
288
+ * instead of a hardcoded switch statement.
286
289
  */
287
290
  function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') {
288
291
  if (typeof configDir !== 'string' || configDir === '') {
@@ -294,106 +297,13 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') {
294
297
  if (!ALLOWED_RUNTIMES.has(runtime)) {
295
298
  throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
296
299
  }
297
- let kinds;
298
- switch (runtime) {
299
- case 'claude':
300
- if (scope === 'local') {
301
- kinds = [
302
- commandsKind('commands/gsd', 'gsd-', configDir),
303
- agentsKind('agents', 'gsd-', configDir),
304
- ];
305
- }
306
- else {
307
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)];
308
- }
309
- break;
310
- case 'cursor':
311
- // Cursor 1.6+ supports two artifact surfaces:
312
- // 1. skills/gsd-<name>/SKILL.md — rich skills with frontmatter + adapter header
313
- // 2. commands/gsd-<name>.md — plain markdown slash commands (no frontmatter)
314
- // accessed via '/' in the Agent input (#785)
315
- kinds = [
316
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir),
317
- convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir),
318
- ];
319
- break;
320
- case 'gemini':
321
- kinds = [commandsKind('commands/gsd', 'gsd-', configDir)];
322
- break;
323
- case 'codex':
324
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', 'codex', configDir)];
325
- break;
326
- case 'copilot':
327
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', 'copilot', configDir)];
328
- break;
329
- case 'antigravity':
330
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir, true /* #69 nested */)];
331
- break;
332
- case 'windsurf':
333
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', 'windsurf', configDir)];
334
- break;
335
- case 'augment':
336
- kinds = [
337
- commandsKind('commands', 'gsd-', configDir),
338
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir, true /* #69 nested */),
339
- ];
340
- break;
341
- case 'trae':
342
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir, true /* #69 nested */)];
343
- break;
344
- case 'qwen':
345
- kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir, true /* #69 nested */)];
346
- break;
347
- case 'hermes':
348
- // #947: restore canonical gsd- prefix — skills land at skills/gsd/gsd-<stem>/SKILL.md
349
- // and dispatch as /gsd-<stem>, consistent with every other runtime.
350
- // The skills/gsd/ category bucket (introduced by #2841) is retained.
351
- // Prior bare-stem layout (prefix='') used by #3664 is reversed here.
352
- kinds = [skillsKind('skills/gsd', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir, true /* #69 nested */)];
353
- break;
354
- case 'codebuddy':
355
- // CodeBuddy (Tencent) reads two user-level surfaces (codebuddy.ai/docs/cli):
356
- // 1. commands/gsd-<name>.md — slash commands shown in the '/' menu (#789)
357
- // 2. skills/gsd-<name>/SKILL.md — model-invocable skills, emitted with
358
- // user-invocable:false so they stay OUT of '/' (the commands surface is
359
- // the sole '/' entry point) — avoids a duplicated /gsd-* per workflow.
360
- // Subagents (~/.codebuddy/agents/) are already emitted by the generic agents
361
- // block in bin/install.js; MCP is excluded (gsd ships no MCP server).
362
- kinds = [
363
- convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCodebuddyCommand', configDir),
364
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir),
365
- ];
366
- break;
367
- case 'cline':
368
- kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : [];
369
- break;
370
- case 'kimi':
371
- kinds = scope === 'global'
372
- ? [
373
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToKimiSkill', 'kimi', configDir),
374
- kimiAgentsKind('agents', 'gsd', configDir),
375
- ]
376
- : [];
377
- break;
378
- case 'opencode':
379
- // OpenCode reads flat slash commands from command/ and on-demand skills
380
- // from skills/<name>/SKILL.md (https://opencode.ai/docs/skills). Emit both.
381
- kinds = [
382
- commandsKind('command', 'gsd-', configDir),
383
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToOpencodeSkill', 'opencode', configDir),
384
- ];
385
- break;
386
- case 'kilo':
387
- // Kilo derives from OpenCode and shares the skills/<name>/SKILL.md layout
388
- // (https://kilo.ai/docs/customize/skills). Emit flat commands + skills.
389
- kinds = [
390
- commandsKind('command', 'gsd-', configDir),
391
- skillsKind('skills', 'gsd-', 'convertClaudeCommandToKiloSkill', 'kilo', configDir),
392
- ];
393
- break;
394
- default:
395
- throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
300
+ const desc = getRegistry().runtimes[runtime]?.runtime?.artifactLayout;
301
+ if (!desc) {
302
+ // Runtime is in ALLOWED_RUNTIMES but has no descriptor — reproduce old default: throw.
303
+ throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
396
304
  }
305
+ const entries = desc[scope] ?? [];
306
+ const kinds = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope));
397
307
  return { runtime, configDir, scope, kinds };
398
308
  }
399
309
  module.exports = { resolveRuntimeArtifactLayout, findInstallSourceRoot, getInstallExports };