@opengsd/gsd-core 1.3.1 → 1.4.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 (97) hide show
  1. package/agents/gsd-advisor-researcher.md +1 -20
  2. package/agents/gsd-ai-researcher.md +1 -20
  3. package/agents/gsd-domain-researcher.md +1 -20
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-phase-researcher.md +92 -166
  6. package/agents/gsd-planner.md +9 -36
  7. package/agents/gsd-project-researcher.md +62 -141
  8. package/agents/gsd-ui-researcher.md +2 -21
  9. package/agents/gsd-verifier.md +8 -2
  10. package/bin/install.js +85 -4
  11. package/commands/gsd/graphify.md +11 -6
  12. package/commands/gsd/import.md +6 -2
  13. package/commands/gsd/plan-phase.md +2 -2
  14. package/gsd-core/bin/check-latest-version.cjs +3 -2
  15. package/gsd-core/bin/gsd-tools.cjs +238 -32
  16. package/gsd-core/bin/lib/check-command-router.cjs +1 -0
  17. package/gsd-core/bin/lib/cli-exit.cjs +42 -0
  18. package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
  19. package/gsd-core/bin/lib/commands.cjs +5 -4
  20. package/gsd-core/bin/lib/config.cjs +28 -4
  21. package/gsd-core/bin/lib/core.cjs +72 -28
  22. package/gsd-core/bin/lib/graphify.cjs +2 -2
  23. package/gsd-core/bin/lib/init-command-router.cjs +2 -2
  24. package/gsd-core/bin/lib/init.cjs +19 -3
  25. package/gsd-core/bin/lib/intel.cjs +3 -20
  26. package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
  27. package/gsd-core/bin/lib/phase.cjs +3 -3
  28. package/gsd-core/bin/lib/research-provider.cjs +137 -0
  29. package/gsd-core/bin/lib/research-store.cjs +167 -0
  30. package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
  31. package/gsd-core/bin/lib/security.cjs +73 -0
  32. package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
  33. package/gsd-core/bin/lib/validate.cjs +2 -2
  34. package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
  35. package/gsd-core/bin/lib/verification.cjs +193 -0
  36. package/gsd-core/bin/lib/verify.cjs +2 -2
  37. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
  38. package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
  39. package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
  40. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  41. package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
  42. package/gsd-core/references/planner-load-graph-context.md +36 -0
  43. package/gsd-core/references/planning-config.md +3 -1
  44. package/gsd-core/references/research-documentation-lookup.md +29 -0
  45. package/gsd-core/references/research-philosophy.md +29 -0
  46. package/gsd-core/references/research-verification-protocol.md +27 -0
  47. package/gsd-core/workflows/execute-phase.md +19 -8
  48. package/gsd-core/workflows/help/modes/full.md +2 -2
  49. package/gsd-core/workflows/ingest-docs.md +3 -2
  50. package/gsd-core/workflows/plan-phase.md +14 -10
  51. package/gsd-core/workflows/plan-review-convergence.md +3 -3
  52. package/gsd-core/workflows/review.md +22 -5
  53. package/gsd-core/workflows/ship.md +5 -8
  54. package/gsd-core/workflows/spec-phase.md +2 -1
  55. package/gsd-core/workflows/update.md +2 -1
  56. package/hooks/dist/gsd-context-monitor.js +1 -1
  57. package/hooks/dist/gsd-workflow-guard.js +1 -0
  58. package/hooks/dist/gsd-worktree-path-guard.js +1 -1
  59. package/hooks/gsd-context-monitor.js +1 -1
  60. package/hooks/gsd-workflow-guard.js +1 -0
  61. package/hooks/gsd-worktree-path-guard.js +1 -1
  62. package/package.json +4 -1
  63. package/scripts/affected-tests-lib.cjs +3 -2
  64. package/scripts/changeset/cli.cjs +183 -28
  65. package/scripts/changeset/lint.cjs +5 -4
  66. package/scripts/changeset/new.cjs +4 -4
  67. package/scripts/check-alias-drift.cjs +77 -71
  68. package/scripts/check-env.cjs +185 -179
  69. package/scripts/check-npm-integrity.cjs +115 -109
  70. package/scripts/ci-guard-runner.cjs +11 -5
  71. package/scripts/ci-prepare-test-scope.cjs +27 -22
  72. package/scripts/ci-rebase-check.cjs +46 -45
  73. package/scripts/ci-test-scope.cjs +6 -4
  74. package/scripts/diff-touches-shipped-paths.cjs +52 -44
  75. package/scripts/gen-inventory-manifest.cjs +38 -32
  76. package/scripts/gen-research-agents.cjs +276 -0
  77. package/scripts/lib/cli-exit.cjs +56 -0
  78. package/scripts/lint-command-contract.cjs +28 -22
  79. package/scripts/lint-descriptions.cjs +32 -28
  80. package/scripts/lint-docs-required.cjs +4 -4
  81. package/scripts/lint-legacy-dir-name.cjs +56 -52
  82. package/scripts/lint-pr-check-project-dir.cjs +3 -1
  83. package/scripts/lint-shell-command-projection-drift.cjs +27 -22
  84. package/scripts/lint-skill-deps.cjs +31 -26
  85. package/scripts/lint-test-file-count.allowlist.json +1 -0
  86. package/scripts/lint-test-file-count.cjs +5 -4
  87. package/scripts/mutation-matrix.cjs +6 -3
  88. package/scripts/prompt-injection-scan.sh +1 -1
  89. package/scripts/release-notes/format-github-release-notes.cjs +8 -3
  90. package/scripts/release-tarball-smoke.cjs +6 -4
  91. package/scripts/research-profiles.cjs +149 -0
  92. package/scripts/run-affected-tests.cjs +2 -1
  93. package/scripts/run-cross-platform-tests.cjs +11 -7
  94. package/scripts/run-tests.cjs +8 -7
  95. package/scripts/strip-prose-atrefs.cjs +1 -1
  96. package/scripts/sync-runtime-launcher.cjs +0 -3
  97. package/scripts/verify-npm-publish.cjs +14 -26
@@ -550,7 +550,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
550
550
  let _newPhaseId;
551
551
  let _dirName;
552
552
  if (customId || config.phase_naming === 'custom') {
553
- _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
553
+ _newPhaseId = customId || slug.toUpperCase();
554
554
  if (!_newPhaseId)
555
555
  error('--id required when phase_naming is "custom"');
556
556
  _dirName = `${prefix}${_newPhaseId}-${slug}`;
@@ -658,7 +658,7 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
658
658
  let newPhaseId;
659
659
  let dirName;
660
660
  if (config.phase_naming === 'custom') {
661
- newPhaseId = slug.toUpperCase().replace(/-/g, '-');
661
+ newPhaseId = slug.toUpperCase();
662
662
  dirName = `${prefix}${newPhaseId}-${slug}`;
663
663
  }
664
664
  else {
@@ -913,7 +913,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
913
913
  content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
914
914
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
915
915
  content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
916
- content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
916
+ content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
917
917
  content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
918
918
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
919
919
  }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /**
3
+ * Research Provider Module
4
+ *
5
+ * Encodes the Balanced-set provider decision: PROVIDER_WATERFALL constant,
6
+ * classifyConfidence, providerAvailability, and planResearch (with injectable
7
+ * store for testability).
8
+ *
9
+ * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
10
+ */
11
+ // ---------------------------------------------------------------------------
12
+ // Cycle 1 / Cycle 6: PROVIDER_WATERFALL (Balanced-set decision)
13
+ // firecrawl appears ONLY in scrape — demoted to known-URL scrape, NOT in docs/web
14
+ // ---------------------------------------------------------------------------
15
+ const PROVIDER_WATERFALL = {
16
+ docs: ['context7', 'ref', 'jina', 'websearch'],
17
+ web: ['exa', 'tavily', 'perplexity', 'brave', 'websearch'],
18
+ scrape: ['firecrawl', 'jina'],
19
+ };
20
+ function authorityOf(provider) {
21
+ switch (provider) {
22
+ case 'context7':
23
+ case 'ref':
24
+ return 'official';
25
+ case 'jina':
26
+ case 'firecrawl':
27
+ return 'scrape';
28
+ case 'exa':
29
+ case 'tavily':
30
+ case 'perplexity':
31
+ case 'brave':
32
+ case 'websearch':
33
+ return 'web';
34
+ default:
35
+ return 'none';
36
+ }
37
+ }
38
+ function normalizeLegitimacyVerdict(raw) {
39
+ if (typeof raw !== 'string')
40
+ return null;
41
+ const upper = raw.toUpperCase();
42
+ if (upper === 'OK' || upper === 'SUS' || upper === 'SLOP')
43
+ return upper;
44
+ return null;
45
+ }
46
+ function classifyConfidence(input) {
47
+ try {
48
+ const { provider, verifiedAgainstOfficial, legitimacyVerdict } = input;
49
+ const authority = authorityOf(provider);
50
+ const verdict = normalizeLegitimacyVerdict(legitimacyVerdict);
51
+ const groundTruth = verdict === 'OK';
52
+ // SLOP caps everything — checked first
53
+ if (verdict === 'SLOP')
54
+ return 'LOW';
55
+ // Ground-truth corroboration + known authority → HIGH
56
+ if (groundTruth && authority !== 'none')
57
+ return 'HIGH';
58
+ // Official or scrape provider (authority alone) → MEDIUM
59
+ if (authority === 'official' || authority === 'scrape')
60
+ return 'MEDIUM';
61
+ // Ground-truth but unknown provider → MEDIUM
62
+ if (groundTruth)
63
+ return 'MEDIUM';
64
+ // Web provider with self-reported verification → MEDIUM
65
+ if (authority === 'web' && verifiedAgainstOfficial === true)
66
+ return 'MEDIUM';
67
+ return 'LOW';
68
+ }
69
+ catch {
70
+ return 'LOW';
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Cycle 5: providerAvailability
75
+ // ---------------------------------------------------------------------------
76
+ function providerAvailability(config) {
77
+ const cfg = config ?? {};
78
+ return {
79
+ context7: true,
80
+ jina: cfg.jina !== undefined ? Boolean(cfg.jina) : true,
81
+ websearch: true,
82
+ exa: Boolean(cfg.exa_search),
83
+ tavily: Boolean(cfg.tavily_search),
84
+ brave: Boolean(cfg.brave_search),
85
+ firecrawl: Boolean(cfg.firecrawl),
86
+ ref: Boolean(cfg.ref_search),
87
+ perplexity: Boolean(cfg.perplexity),
88
+ };
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Lazy-load default store (avoids circular require at module eval time)
92
+ // ---------------------------------------------------------------------------
93
+ let _defaultStore;
94
+ function getDefaultStore() {
95
+ if (!_defaultStore) {
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy default store; tests inject their own
97
+ _defaultStore = require('./research-store.cjs');
98
+ }
99
+ return _defaultStore;
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Cycle 1–3, 5, 7: planResearch
103
+ // ---------------------------------------------------------------------------
104
+ function planResearch(options) {
105
+ const { questions, ecosystem = '', cwd, config, clock = Date, homeDir, store = getDefaultStore(), } = options;
106
+ const availability = providerAvailability(config);
107
+ const items = questions.flatMap((q) => {
108
+ const { text, kind, library, version } = q;
109
+ // Skip questions without a non-empty string text — emitting an item with
110
+ // question:undefined / fetch.query:undefined would produce corrupt output.
111
+ if (typeof text !== 'string' || text.length === 0) {
112
+ return [];
113
+ }
114
+ const key = store.researchKey({ ecosystem, library, version, query: text, kind });
115
+ const res = store.getResearch(cwd, key, { clock, homeDir, kind });
116
+ // Fresh cache hit — no fetch needed
117
+ if (res.hit && !res.stale) {
118
+ return { question: text, key, cache: { hit: true, stale: false } };
119
+ }
120
+ // Determine which waterfall to use
121
+ const waterfall = PROVIDER_WATERFALL[kind] ?? PROVIDER_WATERFALL.web;
122
+ // Pick first available provider
123
+ const provider = waterfall.find((p) => availability[p] === true) ?? 'websearch';
124
+ const item = {
125
+ question: text,
126
+ key,
127
+ fetch: { provider, query: text },
128
+ };
129
+ // Stale hit: include cache info
130
+ if (res.hit) {
131
+ item.cache = { hit: true, stale: true };
132
+ }
133
+ return item;
134
+ });
135
+ return { items };
136
+ }
137
+ module.exports = { PROVIDER_WATERFALL, classifyConfidence, providerAvailability, planResearch };
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ /**
3
+ * Research Store Module
4
+ *
5
+ * Provides deterministic cache key generation, TTL policy, path resolution,
6
+ * and JSON-backed put/get operations for research entries.
7
+ *
8
+ * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ const node_fs_1 = __importDefault(require("node:fs"));
14
+ const node_os_1 = __importDefault(require("node:os"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const node_crypto_1 = __importDefault(require("node:crypto"));
17
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ const DAY_MS = 86_400_000;
22
+ // ---------------------------------------------------------------------------
23
+ // researchKey
24
+ // ---------------------------------------------------------------------------
25
+ function normalize(x) {
26
+ if (x === null || x === undefined)
27
+ return '';
28
+ if (typeof x === 'object')
29
+ return JSON.stringify(x).trim().toLowerCase();
30
+ // After excluding null, undefined, and object, x can only be a primitive —
31
+ // cast through number | string | boolean to avoid no-base-to-string on unknown.
32
+ return `${x}`.trim().toLowerCase();
33
+ }
34
+ function researchKey(input) {
35
+ const parts = {
36
+ ecosystem: normalize(input.ecosystem),
37
+ library: normalize(input.library),
38
+ version: normalize(input.version),
39
+ query: normalize(input.query),
40
+ kind: normalize(input.kind),
41
+ };
42
+ const serialized = JSON.stringify(parts);
43
+ return node_crypto_1.default.createHash('sha256').update(serialized).digest('hex');
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // ttlForSource
47
+ // ---------------------------------------------------------------------------
48
+ function ttlForSource(source, confidence) {
49
+ if (source === 'curated' && confidence === 'HIGH')
50
+ return 30 * DAY_MS;
51
+ if (source === 'curated' && confidence === 'MEDIUM')
52
+ return 7 * DAY_MS;
53
+ return DAY_MS;
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // tierForSource / resolveStorePath
57
+ // ---------------------------------------------------------------------------
58
+ const CURATED_SOURCES = new Set(['curated']);
59
+ function tierForSource(source) {
60
+ return CURATED_SOURCES.has(source) ? 'user' : 'project';
61
+ }
62
+ function resolveStorePath(cwd, source, { homeDir = node_os_1.default.homedir() } = {}) {
63
+ if (tierForSource(source) === 'user') {
64
+ return node_path_1.default.join(homeDir, '.gsd', 'research-cache');
65
+ }
66
+ return node_path_1.default.join(cwd, '.planning', 'research', '.cache');
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // isValidResearchKey
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * Returns true iff key is a valid 64-character lowercase hexadecimal SHA-256
73
+ * string (the exact shape produced by researchKey). Any other shape —
74
+ * including path-traversal sequences — is rejected.
75
+ */
76
+ function isValidResearchKey(key) {
77
+ return typeof key === 'string' && /^[0-9a-f]{64}$/.test(key);
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // putResearch
81
+ // ---------------------------------------------------------------------------
82
+ function putResearch(cwd, key, payload, { clock = Date, homeDir = node_os_1.default.homedir() } = {}) {
83
+ // Defense-in-depth: reject any key that is not a 64-char sha256 hex string.
84
+ if (!isValidResearchKey(key)) {
85
+ throw new Error('invalid research key');
86
+ }
87
+ const { content, source, provider, confidence, kind, version } = payload;
88
+ let ttl = ttlForSource(source, confidence);
89
+ // Cap TTL when version is blank/missing — a versionless curated entry must not
90
+ // get the long 30-day window since we can't know if it's still current.
91
+ if (!version) {
92
+ ttl = Math.min(ttl, DAY_MS);
93
+ }
94
+ const fetched_at = new Date(clock.now()).toISOString();
95
+ const entry = { content, source, provider, confidence, fetched_at, ttl, kind };
96
+ const dir = resolveStorePath(cwd, source, { homeDir });
97
+ // Belt-and-suspenders: ensure the resolved file path stays inside the store dir.
98
+ const resolvedDir = node_path_1.default.resolve(dir);
99
+ const filePath = node_path_1.default.join(dir, `${key}.json`);
100
+ const resolvedFile = node_path_1.default.resolve(filePath);
101
+ if (!resolvedFile.startsWith(resolvedDir + node_path_1.default.sep)) {
102
+ throw new Error('invalid research key');
103
+ }
104
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
105
+ (0, shell_command_projection_cjs_1.platformWriteSync)(filePath, JSON.stringify(entry));
106
+ return entry;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // getResearch
110
+ // ---------------------------------------------------------------------------
111
+ function getResearch(cwd, key, { clock = Date, homeDir = node_os_1.default.homedir() } = {}) {
112
+ // Defense-in-depth: reject any key that is not a 64-char sha256 hex string.
113
+ if (!isValidResearchKey(key)) {
114
+ return { hit: false, stale: false, entry: null };
115
+ }
116
+ try {
117
+ // Search both physical tiers: user (curated) and project (web/etc.)
118
+ const userDir = node_path_1.default.join(homeDir, '.gsd', 'research-cache');
119
+ const projectDir = node_path_1.default.join(cwd, '.planning', 'research', '.cache');
120
+ const tierDirs = [userDir, projectDir];
121
+ const candidates = [];
122
+ for (const dir of tierDirs) {
123
+ const resolvedDir = node_path_1.default.resolve(dir);
124
+ const filePath = node_path_1.default.join(dir, `${key}.json`);
125
+ // Belt-and-suspenders: ensure path stays inside tier dir
126
+ if (!node_path_1.default.resolve(filePath).startsWith(resolvedDir + node_path_1.default.sep))
127
+ continue;
128
+ if (!node_fs_1.default.existsSync(filePath))
129
+ continue;
130
+ let entry;
131
+ try {
132
+ entry = JSON.parse(node_fs_1.default.readFileSync(filePath, 'utf8'));
133
+ }
134
+ catch {
135
+ // Corrupt file in this tier — skip it
136
+ continue;
137
+ }
138
+ // Finding 3: validate entry metadata shape before accepting as a candidate.
139
+ // An entry with missing/invalid fetched_at or ttl must be treated as a miss.
140
+ const parsedFetchedAt = Date.parse(entry.fetched_at);
141
+ if (!Number.isFinite(parsedFetchedAt))
142
+ continue;
143
+ if (typeof entry.ttl !== 'number' ||
144
+ !Number.isFinite(entry.ttl) ||
145
+ entry.ttl <= 0)
146
+ continue;
147
+ const age = clock.now() - parsedFetchedAt;
148
+ const stale = age > entry.ttl;
149
+ candidates.push({ entry, stale, age });
150
+ }
151
+ if (candidates.length === 0) {
152
+ return { hit: false, stale: false, entry: null };
153
+ }
154
+ // Prefer: non-stale over stale; among same-staleness, lowest age (most recent)
155
+ candidates.sort((a, b) => {
156
+ if (a.stale !== b.stale)
157
+ return a.stale ? 1 : -1; // non-stale first
158
+ return a.age - b.age; // lower age (more recent) first
159
+ });
160
+ const best = candidates[0];
161
+ return { hit: true, stale: best.stale, entry: best.entry };
162
+ }
163
+ catch {
164
+ return { hit: false, stale: false, entry: null };
165
+ }
166
+ }
167
+ module.exports = { isValidResearchKey, researchKey, ttlForSource, tierForSource, resolveStorePath, putResearch, getResearch };
@@ -25,8 +25,6 @@ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}
25
25
  // Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
26
26
  // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
27
27
  const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
28
- // Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal)
29
- const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
30
28
  // ─── Pure computation helpers ─────────────────────────────────────────────────
31
29
  /**
32
30
  * Parse the ROADMAP.md content and build a list of phase entries with their
@@ -126,19 +124,6 @@ function buildNewDirName(oldDirName, newId, projectCode) {
126
124
  const newBase = slug ? `${paddedMilestone}-${subStr}-${slug}` : `${paddedMilestone}-${subStr}`;
127
125
  return projectCode ? `${projectCode}-${newBase}` : newBase;
128
126
  }
129
- /**
130
- * Read project_code from config.json if present.
131
- */
132
- function readProjectCode(configPath) {
133
- try {
134
- const raw = node_fs_1.default.readFileSync(configPath, 'utf8');
135
- const parsed = JSON.parse(raw);
136
- return typeof parsed['project_code'] === 'string' ? parsed['project_code'] : null;
137
- }
138
- catch {
139
- return null;
140
- }
141
- }
142
127
  // ─── computeMigrationPlan ─────────────────────────────────────────────────────
143
128
  /**
144
129
  * Compute a migration plan without touching the filesystem.
@@ -390,7 +375,7 @@ function applyMigration(cwd, plan, options = {}) {
390
375
  // ── Real run: verify clean working tree ───────────────────────────────────
391
376
  let gitStatus;
392
377
  try {
393
- gitStatus = (0, node_child_process_1.execSync)('git status --porcelain', { cwd, encoding: 'utf8' });
378
+ gitStatus = (0, node_child_process_1.execSync)('git status --porcelain', { cwd, encoding: 'utf8', windowsHide: true });
394
379
  }
395
380
  catch (err) {
396
381
  throw new Error(`git status failed: ${err.message}`);
@@ -401,7 +386,7 @@ function applyMigration(cwd, plan, options = {}) {
401
386
  // Capture HEAD sha for rollback
402
387
  let headSha;
403
388
  try {
404
- headSha = (0, node_child_process_1.execSync)('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
389
+ headSha = (0, node_child_process_1.execSync)('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim();
405
390
  }
406
391
  catch (err) {
407
392
  throw new Error(`git rev-parse HEAD failed: ${err.message}`);
@@ -475,8 +460,8 @@ function applyMigration(cwd, plan, options = {}) {
475
460
  catch (err) {
476
461
  // Rollback via git reset --hard + git clean
477
462
  try {
478
- (0, node_child_process_1.execSync)(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' });
479
- (0, node_child_process_1.execSync)('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' });
463
+ (0, node_child_process_1.execSync)(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true });
464
+ (0, node_child_process_1.execSync)('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true });
480
465
  }
481
466
  catch {
482
467
  // Swallow rollback errors — surface original error
@@ -24,6 +24,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.MARKDOWN_LINK_PATTERNS = exports.INJECTION_PATTERNS = void 0;
26
26
  exports.validatePath = validatePath;
27
+ exports.loadTrustedGlobalRoots = loadTrustedGlobalRoots;
27
28
  exports.requireSafePath = requireSafePath;
28
29
  exports.scanForInjection = scanForInjection;
29
30
  exports.sanitizeForPrompt = sanitizeForPrompt;
@@ -35,6 +36,7 @@ exports.validateFieldName = validateFieldName;
35
36
  exports.validatePromptStructure = validatePromptStructure;
36
37
  exports.scanEntropyAnomalies = scanEntropyAnomalies;
37
38
  const node_fs_1 = __importDefault(require("node:fs"));
39
+ const node_os_1 = __importDefault(require("node:os"));
38
40
  const node_path_1 = __importDefault(require("node:path"));
39
41
  // ─── Path Traversal Prevention ──────────────────────────────────────────────
40
42
  /**
@@ -92,6 +94,77 @@ function validatePath(filePath, baseDir, opts = {}) {
92
94
  }
93
95
  return { safe: true, resolved: resolvedPath };
94
96
  }
97
+ /**
98
+ * Load the opt-in trusted global roots allowlist from config.
99
+ *
100
+ * Reads `config.agent_skills_security.trusted_global_roots` (an array of
101
+ * path strings). Each entry is canonicalized via realpathSync: non-strings
102
+ * are dropped, leading `~/` is expanded to `os.homedir()`, entries that are
103
+ * not absolute after expansion are dropped (project-relative paths are
104
+ * rejected as a security boundary), and entries that do not exist on disk are
105
+ * dropped (a non-existent root is not trustworthy). The canonical realpath is
106
+ * used for all subsequent checks and as the stored value — this closes the
107
+ * case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`)
108
+ * and ensures trust doesn't drift across re-invocations if a root is
109
+ * re-created at a different target. Results are de-duplicated by canonical path.
110
+ */
111
+ function loadTrustedGlobalRoots(config) {
112
+ const roots = config?.['agent_skills_security'];
113
+ const raw = roots?.['trusted_global_roots'];
114
+ if (!Array.isArray(raw))
115
+ return [];
116
+ // Compute canonical homedir once for case-insensitive-safe comparison.
117
+ let realHome;
118
+ try {
119
+ realHome = node_fs_1.default.realpathSync(node_os_1.default.homedir());
120
+ }
121
+ catch {
122
+ realHome = node_os_1.default.homedir();
123
+ }
124
+ const seen = new Set();
125
+ const result = [];
126
+ for (const entry of raw) {
127
+ if (typeof entry !== 'string')
128
+ continue;
129
+ let expanded;
130
+ if (entry === '~') {
131
+ expanded = node_os_1.default.homedir();
132
+ }
133
+ else if (entry.startsWith('~/')) {
134
+ expanded = node_path_1.default.join(node_os_1.default.homedir(), entry.slice(2));
135
+ }
136
+ else {
137
+ expanded = entry;
138
+ }
139
+ if (!node_path_1.default.isAbsolute(expanded))
140
+ continue; // reject project-relative
141
+ // Canonicalize: resolve symlinks and normalise case. If the path doesn't
142
+ // exist or can't be read, skip it — a non-existent root is not trustworthy.
143
+ let real;
144
+ try {
145
+ real = node_fs_1.default.realpathSync(expanded);
146
+ }
147
+ catch {
148
+ continue; // non-existent or unreadable — skip
149
+ }
150
+ // Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share').
151
+ // Normalize both sides by stripping trailing path separators before comparing so that
152
+ // Windows UNC shares (where path.parse().root includes a trailing separator) are caught.
153
+ const stripTrailingSep = (p) => p.replace(/[\\/]+$/, '');
154
+ if (stripTrailingSep(node_path_1.default.parse(real).root) === stripTrailingSep(real))
155
+ continue;
156
+ // Reject homedir itself (canonical compare closes case-insensitive bypass).
157
+ // Apply stripTrailingSep for robustness on platforms where realpathSync may
158
+ // or may not include a trailing separator on the homedir path.
159
+ if (stripTrailingSep(real) === stripTrailingSep(realHome))
160
+ continue;
161
+ if (seen.has(real))
162
+ continue;
163
+ seen.add(real);
164
+ result.push(real);
165
+ }
166
+ return result;
167
+ }
95
168
  /**
96
169
  * Validate a file path and throw on traversal attempt.
97
170
  * Convenience wrapper around validatePath for use in CLI commands.
@@ -381,6 +381,7 @@ function execGit(args, opts = {}) {
381
381
  encoding: 'utf-8',
382
382
  stdio: 'pipe',
383
383
  timeout: opts.timeout ?? 10_000,
384
+ windowsHide: true,
384
385
  });
385
386
  return _spawnResult(result, 'git');
386
387
  }
@@ -391,6 +392,7 @@ function execNpm(args, opts = {}) {
391
392
  encoding: 'utf-8',
392
393
  stdio: ['ignore', 'pipe', 'pipe'],
393
394
  timeout: opts.timeout ?? 15_000,
395
+ windowsHide: true,
394
396
  });
395
397
  return _spawnResult(result, 'npm');
396
398
  }
@@ -401,6 +403,7 @@ function execTool(program, args, opts = {}) {
401
403
  encoding: 'utf-8',
402
404
  stdio: 'pipe',
403
405
  timeout: opts.timeout ?? 30_000,
406
+ windowsHide: true,
404
407
  });
405
408
  return _spawnResult(result, program);
406
409
  }
@@ -90,7 +90,7 @@ function buildRoadmapPhaseVariants(roadmapContent) {
90
90
  const roadmapPhaseVariants = new Set();
91
91
  // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
92
92
  // and bracket-prefixed (### [GSD] Phase 2-01:) headings.
93
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
93
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
94
94
  let m;
95
95
  while ((m = phasePattern.exec(roadmapContent)) !== null) {
96
96
  roadmapPhases.add(m[1]);
@@ -102,7 +102,7 @@ function buildRoadmapPhaseVariants(roadmapContent) {
102
102
  function buildNotStartedPhaseVariants(roadmapContent) {
103
103
  const notStartedPhases = new Set();
104
104
  // Also matches milestone-prefixed and bracket-prefixed checklist items.
105
- const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi;
105
+ const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi;
106
106
  let um;
107
107
  while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
108
108
  for (const variant of phaseVariants(um[1]))
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * Verification-status subcommand router.
4
+ * Routes `verification.status <phaseDir>` to verification.cmdVerificationStatus.
5
+ *
6
+ * Note: `verification` (reads verifier-emitted status) is distinct from `verify`
7
+ * (runs verification checks like plan-structure/artifacts). Keep them separate.
8
+ *
9
+ * ADR-457 build-at-publish: source in src/verification-command-router.cts,
10
+ * compiled to gsd-core/bin/lib/verification-command-router.cjs (gitignored).
11
+ */
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
14
+ const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
15
+ // ─── Implementation ───────────────────────────────────────────────────────────
16
+ const VERIFICATION_SUBCOMMANDS = ['status'];
17
+ function routeVerificationCommand({ verification, args, cwd, raw, error, }) {
18
+ routeCjsCommandFamily({
19
+ args,
20
+ subcommands: VERIFICATION_SUBCOMMANDS,
21
+ unsupported: {},
22
+ error,
23
+ unknownMessage: (_subcommand, available) => `Unknown verification subcommand. Available: ${available.join(', ')}`,
24
+ handlers: {
25
+ status: () => verification.cmdVerificationStatus(cwd, args[2], raw),
26
+ },
27
+ });
28
+ }
29
+ module.exports = {
30
+ routeVerificationCommand,
31
+ };