@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
@@ -33,6 +33,7 @@
33
33
  const { execFileSync } = require('child_process');
34
34
  const fs = require('fs');
35
35
  const path = require('path');
36
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
36
37
 
37
38
  // Constructed with split to avoid self-match when this script is scanned.
38
39
  const FORBIDDEN = 'get-shit' + '-done';
@@ -90,67 +91,70 @@ function isBinary(fullPath) {
90
91
  }
91
92
  }
92
93
 
93
- // Enumerate tracked files via git ls-files so only committed/staged source is checked.
94
- let trackedFiles;
95
- try {
96
- trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
97
- .split('\n')
98
- .map((f) => f.trim())
99
- .filter(Boolean);
100
- } catch (err) {
101
- process.stderr.write('ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message + '\n');
102
- process.exit(1);
103
- }
94
+ function main() {
95
+ // Enumerate tracked files via git ls-files so only committed/staged source is checked.
96
+ let trackedFiles;
97
+ try {
98
+ trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
99
+ .split('\n')
100
+ .map((f) => f.trim())
101
+ .filter(Boolean);
102
+ } catch (err) {
103
+ throw new ExitError(1, 'ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message);
104
+ }
104
105
 
105
- const violations = [];
106
+ const violations = [];
106
107
 
107
- for (const relPath of trackedFiles) {
108
- if (isAllowlisted(relPath)) continue;
108
+ for (const relPath of trackedFiles) {
109
+ if (isAllowlisted(relPath)) continue;
109
110
 
110
- const fullPath = path.join(REPO_ROOT, relPath);
111
+ const fullPath = path.join(REPO_ROOT, relPath);
111
112
 
112
- // Skip this guard script itself.
113
- if (path.resolve(fullPath) === SELF_PATH) continue;
113
+ // Skip this guard script itself.
114
+ if (path.resolve(fullPath) === SELF_PATH) continue;
114
115
 
115
- if (isBinary(fullPath)) continue;
116
+ if (isBinary(fullPath)) continue;
116
117
 
117
- let content;
118
- try {
119
- content = fs.readFileSync(fullPath, 'utf8');
120
- } catch {
121
- // Unreadable files (permissions, etc.) — skip silently.
122
- continue;
123
- }
118
+ let content;
119
+ try {
120
+ content = fs.readFileSync(fullPath, 'utf8');
121
+ } catch {
122
+ // Unreadable files (permissions, etc.) — skip silently.
123
+ continue;
124
+ }
124
125
 
125
- const lines = content.split('\n');
126
- for (let i = 0; i < lines.length; i++) {
127
- const line = lines[i];
128
- // Skip lines that carry the explicit allow marker.
129
- if (line.includes(ALLOW_MARKER)) continue;
130
-
131
- FORBIDDEN_RE.lastIndex = 0;
132
- let match;
133
- while ((match = FORBIDDEN_RE.exec(line)) !== null) {
134
- violations.push({
135
- file: relPath,
136
- line: i + 1,
137
- col: match.index + 1,
138
- text: match[0],
139
- });
126
+ const lines = content.split('\n');
127
+ for (let i = 0; i < lines.length; i++) {
128
+ const line = lines[i];
129
+ // Skip lines that carry the explicit allow marker.
130
+ if (line.includes(ALLOW_MARKER)) continue;
131
+
132
+ FORBIDDEN_RE.lastIndex = 0;
133
+ let match;
134
+ while ((match = FORBIDDEN_RE.exec(line)) !== null) {
135
+ violations.push({
136
+ file: relPath,
137
+ line: i + 1,
138
+ col: match.index + 1,
139
+ text: match[0],
140
+ });
141
+ }
140
142
  }
141
143
  }
142
- }
143
144
 
144
- if (violations.length === 0) {
145
- process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n');
146
- process.exit(0);
147
- }
145
+ if (violations.length === 0) {
146
+ process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n');
147
+ return 0;
148
+ }
148
149
 
149
- process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n');
150
- for (const v of violations) {
151
- process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n');
150
+ process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n');
151
+ for (const v of violations) {
152
+ process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n');
153
+ }
154
+ process.stderr.write('\n');
155
+ process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n');
156
+ process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n');
157
+ return 1;
152
158
  }
153
- process.stderr.write('\n');
154
- process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n');
155
- process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n');
156
- process.exit(1);
159
+
160
+ runMain(main);
@@ -4,6 +4,8 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
 
7
+ const { runMain } = require('./lib/cli-exit.cjs');
8
+
7
9
  const ROOT = path.join(__dirname, '..');
8
10
 
9
11
  const DEFAULT_RELATIVE_FILES = [
@@ -84,7 +86,7 @@ function main(argv = process.argv.slice(2)) {
84
86
  }
85
87
 
86
88
  if (require.main === module) {
87
- process.exit(main());
89
+ runMain(main);
88
90
  }
89
91
 
90
92
  module.exports = {
@@ -13,19 +13,9 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
16
17
 
17
18
  const ROOT = path.resolve(__dirname, '..');
18
- const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js');
19
- const target = path.resolve(targetArg);
20
- const rel = path.relative(ROOT, target);
21
-
22
- let content;
23
- try {
24
- content = fs.readFileSync(target, 'utf8');
25
- } catch (error) {
26
- process.stderr.write(`lint-shell-command-projection-drift: failed to read ${target}: ${error.message}\n`);
27
- process.exit(1);
28
- }
29
19
 
30
20
  const forbidden = [
31
21
  {
@@ -42,16 +32,31 @@ const forbidden = [
42
32
  },
43
33
  ];
44
34
 
45
- const matches = forbidden.filter((rule) => rule.pattern.test(content));
46
- if (matches.length === 0) {
47
- process.stdout.write(`ok shell-projection-drift: ${rel}\n`);
48
- process.exit(0);
49
- }
35
+ function main() {
36
+ const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js');
37
+ const target = path.resolve(targetArg);
38
+ const rel = path.relative(ROOT, target);
39
+
40
+ let content;
41
+ try {
42
+ content = fs.readFileSync(target, 'utf8');
43
+ } catch (error) {
44
+ throw new ExitError(1, `lint-shell-command-projection-drift: failed to read ${target}: ${error.message}`);
45
+ }
50
46
 
51
- process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`);
52
- for (const match of matches) {
53
- process.stderr.write(` - ${match.label}\n`);
47
+ const matches = forbidden.filter((rule) => rule.pattern.test(content));
48
+ if (matches.length === 0) {
49
+ process.stdout.write(`ok shell-projection-drift: ${rel}\n`);
50
+ return 0;
51
+ }
52
+
53
+ process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`);
54
+ for (const match of matches) {
55
+ process.stderr.write(` - ${match.label}\n`);
56
+ }
57
+ process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n');
58
+ process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n');
59
+ return 1;
54
60
  }
55
- process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n');
56
- process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n');
57
- process.exit(1);
61
+
62
+ runMain(main);
@@ -25,6 +25,7 @@
25
25
 
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
+ const { runMain } = require('./lib/cli-exit.cjs');
28
29
 
29
30
  const PROFILES_MODULE = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'install-profiles.cjs');
30
31
  const { PROFILES, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE);
@@ -142,39 +143,43 @@ function checkProfileClosure(manifest) {
142
143
  // Main
143
144
  // ---------------------------------------------------------------------------
144
145
 
145
- const manifest = loadSkillsManifest(commandsDir);
146
- const allStems = new Set(
147
- [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_'))
148
- );
146
+ function main() {
147
+ const manifest = loadSkillsManifest(commandsDir);
148
+ const allStems = new Set(
149
+ [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_'))
150
+ );
149
151
 
150
- const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems);
151
- const closureViolations = checkProfileClosure(manifest);
152
+ const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems);
153
+ const closureViolations = checkProfileClosure(manifest);
152
154
 
153
- const totalViolations = consistencyViolations.length + closureViolations.length;
155
+ const totalViolations = consistencyViolations.length + closureViolations.length;
154
156
 
155
- if (totalViolations === 0) {
156
- const checked = manifest.size;
157
- process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n');
158
- process.exit(0);
159
- }
157
+ if (totalViolations === 0) {
158
+ const checked = manifest.size;
159
+ process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n');
160
+ return 0;
161
+ }
160
162
 
161
- process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n');
163
+ process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n');
162
164
 
163
- if (consistencyViolations.length > 0) {
164
- process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n');
165
- for (const v of consistencyViolations) {
166
- process.stderr.write(' ' + v.filePath + '\n');
167
- process.stderr.write(' ' + v.message + '\n\n');
165
+ if (consistencyViolations.length > 0) {
166
+ process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n');
167
+ for (const v of consistencyViolations) {
168
+ process.stderr.write(' ' + v.filePath + '\n');
169
+ process.stderr.write(' ' + v.message + '\n\n');
170
+ }
171
+ process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n');
168
172
  }
169
- process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n');
170
- }
171
173
 
172
- if (closureViolations.length > 0) {
173
- process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n');
174
- for (const v of closureViolations) {
175
- process.stderr.write(' ' + v.message + '\n');
174
+ if (closureViolations.length > 0) {
175
+ process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n');
176
+ for (const v of closureViolations) {
177
+ process.stderr.write(' ' + v.message + '\n');
178
+ }
179
+ process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n');
176
180
  }
177
- process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n');
181
+
182
+ return 1;
178
183
  }
179
184
 
180
- process.exit(1);
185
+ runMain(main);
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "milestone": {
45
45
  "files": [
46
+ "bug-730-milestone-phase-details-scope.test.cjs",
46
47
  "milestone-archive.test.cjs",
47
48
  "milestone-helper.test.cjs",
48
49
  "milestone-prefixed-convention.test.cjs",
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs');
20
+ const { runMain } = require('./lib/cli-exit.cjs');
20
21
 
21
22
  const ROOT = path.join(__dirname, '..');
22
23
  const PROD_DIRS = [
@@ -201,14 +202,14 @@ function run() {
201
202
 
202
203
  if (jsonMode) {
203
204
  console.log(JSON.stringify({ ok: failures.length === 0, results, failures, hints: [] }, null, 2));
204
- process.exit(failures.length > 0 ? 1 : 0);
205
+ return failures.length > 0 ? 1 : 0;
205
206
  }
206
207
 
207
208
  if (failures.length === 0) {
208
209
  const inAllowlist = results.filter(r => r.verdict === Verdict.OK_IN_ALLOWLIST).length;
209
210
  console.log(`ok lint-test-file-count: ${results.length} module(s) checked, 0 failures` +
210
211
  (inAllowlist > 0 ? `, ${inAllowlist} allowlisted` : ''));
211
- process.exit(0);
212
+ return 0;
212
213
  }
213
214
 
214
215
  process.stderr.write(`\nERROR lint-test-file-count: ${failures.length} module(s) exceed the test-file limit\n\n`);
@@ -231,7 +232,7 @@ function run() {
231
232
  }
232
233
  process.stderr.write('\nFix: consolidate test files per module (one primary + one integration).\n');
233
234
  process.stderr.write('Or update scripts/lint-test-file-count.allowlist.json with PR justification.\n\n');
234
- process.exit(1);
235
+ return 1;
235
236
  }
236
237
 
237
238
  module.exports = {
@@ -242,4 +243,4 @@ module.exports = {
242
243
  _loadAllowlist: loadAllowlist,
243
244
  };
244
245
 
245
- if (require.main === module) run();
246
+ if (require.main === module) runMain(run);
@@ -32,6 +32,8 @@
32
32
  const { execFileSync } = require('child_process');
33
33
  const { readFileSync } = require('fs');
34
34
 
35
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
36
+
35
37
  // ── Single source of truth: covered modules ───────────────────────────────────
36
38
  // Each entry: { cjs: '<built artifact>', tests: ['tests/...', ...] }
37
39
  // A module is "covered" iff its tests are wired into the Stryker command runner
@@ -121,7 +123,7 @@ function parseArgs(argv) {
121
123
  ' --base <ref> Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})',
122
124
  ' --print Human-readable output instead of JSON',
123
125
  ].join('\n'));
124
- process.exit(0);
126
+ throw new ExitError(0);
125
127
  } else {
126
128
  throw new Error(`unknown argument: ${arg}`);
127
129
  }
@@ -211,9 +213,10 @@ function main() {
211
213
  console.log(JSON.stringify(result, null, 2));
212
214
  }
213
215
  } catch (err) {
216
+ if (err instanceof ExitError) throw err;
214
217
  console.error(`mutation-matrix: ${err.message}`);
215
- process.exit(2);
218
+ throw new ExitError(2);
216
219
  }
217
220
  }
218
221
 
219
- main();
222
+ runMain(main);
@@ -83,7 +83,7 @@ ALLOWLIST=(
83
83
  # These files contain intentional injection examples / security-model prose
84
84
  # and are not attack vectors — they explain/demonstrate injection patterns.
85
85
  'TEST-EXAMPLES.md'
86
- 'docs/explanation/security-model.md'
86
+ 'explanation/security-model.md'
87
87
  )
88
88
 
89
89
  is_allowlisted() {
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const fs = require('fs');
6
6
  const { execFileSync } = require('child_process');
7
+ const { runMain, ExitError } = require('../lib/cli-exit.cjs');
7
8
 
8
9
  /**
9
10
  * Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'.
@@ -155,7 +156,7 @@ function formatReleaseNotes({ generatedBody, version, prerelease, packageName })
155
156
  }
156
157
 
157
158
  // CLI entry point
158
- if (require.main === module) {
159
+ function main() {
159
160
  try {
160
161
  const argv = process.argv.slice(2);
161
162
 
@@ -248,9 +249,13 @@ if (require.main === module) {
248
249
  process.stdout.write(formatted + '\n');
249
250
  }
250
251
  } catch (err) {
251
- process.stderr.write((err.message || String(err)) + '\n');
252
- process.exit(1);
252
+ if (err instanceof ExitError) throw err;
253
+ throw new ExitError(1, err && err.message ? err.message : String(err));
253
254
  }
254
255
  }
255
256
 
257
+ if (require.main === module) {
258
+ runMain(main);
259
+ }
260
+
256
261
  module.exports = { formatReleaseNotes, classifyTitle };
@@ -46,6 +46,7 @@ const fs = require('fs');
46
46
  const os = require('os');
47
47
  const path = require('path');
48
48
  const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
49
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
49
50
  // 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
50
51
  // `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
51
52
  // spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
@@ -587,23 +588,24 @@ function cliMain() {
587
588
  };
588
589
  if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
589
590
  cleanup(packDir, installPrefix, fixtureDir);
590
- process.exit(1);
591
+ throw new ExitError(1);
591
592
  }
592
593
  }
593
594
  } catch (err) {
595
+ if (err instanceof ExitError) throw err;
594
596
  const result = {
595
597
  code: SMOKE.PACK_FAILED,
596
598
  details: { error: err.message, stderr: err.stderr },
597
599
  };
598
600
  if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
599
601
  cleanup(packDir, installPrefix, fixtureDir);
600
- process.exit(1);
602
+ throw new ExitError(1);
601
603
  }
602
604
 
603
605
  const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir });
604
606
  if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
605
607
  cleanup(packDir, installPrefix, fixtureDir);
606
- process.exit(result.code === SMOKE.OK ? 0 : 1);
608
+ return result.code === SMOKE.OK ? 0 : 1;
607
609
  }
608
610
 
609
611
  function cleanup(...dirs) {
@@ -623,5 +625,5 @@ function cleanup(...dirs) {
623
625
  module.exports = { SMOKE, runSmoke, binInvocation };
624
626
 
625
627
  if (require.main === module) {
626
- cliMain();
628
+ runMain(cliMain);
627
629
  }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * research-profiles.cjs — hand-authored profile table for the 7 researcher agents.
6
+ *
7
+ * Each profile field is derived verbatim from the agent's current committed state so
8
+ * that the initial --check in gen-research-agents.cjs is green by construction.
9
+ *
10
+ * Fields:
11
+ * name — verbatim frontmatter `name:` value
12
+ * description — verbatim frontmatter `description:` value
13
+ * color — verbatim frontmatter `color:` value
14
+ * tools — verbatim frontmatter `tools:` value (single string, comma-separated)
15
+ * requiredIncludes — @~/.claude/gsd-core/references/<file>.md strings the body MUST contain
16
+ * requiredSeamCalls — `gsd-tools query <cmd>` strings the body MUST contain
17
+ * outputContract — strings the body MUST contain (output path, return marker, etc.)
18
+ */
19
+
20
+ const PROFILES = [
21
+ {
22
+ name: 'gsd-project-researcher',
23
+ description:
24
+ 'Researches domain ecosystem before roadmap creation. Produces files in .planning/research/ consumed during roadmap creation. Spawned by /gsd:new-project or /gsd:new-milestone orchestrators.',
25
+ color: 'cyan',
26
+ tools:
27
+ 'Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
28
+ requiredIncludes: [
29
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
30
+ '@~/.claude/gsd-core/references/research-philosophy.md',
31
+ '@~/.claude/gsd-core/references/research-verification-protocol.md',
32
+ ],
33
+ requiredSeamCalls: [
34
+ 'gsd-tools query research-plan',
35
+ 'gsd-tools query research-store put',
36
+ 'gsd-tools query classify-confidence',
37
+ ],
38
+ outputContract: [
39
+ '.planning/research/',
40
+ '## RESEARCH COMPLETE',
41
+ ],
42
+ },
43
+ {
44
+ name: 'gsd-phase-researcher',
45
+ description:
46
+ 'Researches how to implement a phase before planning. Produces RESEARCH.md consumed by gsd-planner. Spawned by /gsd:plan-phase orchestrator.',
47
+ color: 'cyan',
48
+ tools:
49
+ 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
50
+ requiredIncludes: [
51
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
52
+ '@~/.claude/gsd-core/references/research-philosophy.md',
53
+ '@~/.claude/gsd-core/references/research-verification-protocol.md',
54
+ ],
55
+ requiredSeamCalls: [
56
+ 'gsd-tools query research-plan',
57
+ 'gsd-tools query research-store put',
58
+ 'gsd-tools query classify-confidence',
59
+ 'gsd-tools query package-legitimacy check',
60
+ ],
61
+ outputContract: [
62
+ '.planning/phases/XX-name/{phase_num}-RESEARCH.md',
63
+ '## RESEARCH COMPLETE',
64
+ ],
65
+ },
66
+ {
67
+ name: 'gsd-advisor-researcher',
68
+ description:
69
+ 'Researches a single gray area decision and returns a structured comparison table with rationale. Spawned by discuss-phase advisor mode.',
70
+ color: 'cyan',
71
+ tools: 'Read, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*',
72
+ requiredIncludes: [
73
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
74
+ ],
75
+ requiredSeamCalls: [],
76
+ outputContract: [
77
+ '| Option | Pros | Cons | Complexity | Recommendation |',
78
+ '**Rationale:**',
79
+ ],
80
+ },
81
+ {
82
+ name: 'gsd-ai-researcher',
83
+ description:
84
+ 'Researches a chosen AI framework\'s official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.',
85
+ color: '"#34D399"',
86
+ tools:
87
+ 'Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*',
88
+ requiredIncludes: [
89
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
90
+ ],
91
+ requiredSeamCalls: [],
92
+ outputContract: [
93
+ 'AI-SPEC.md',
94
+ 'Section 3',
95
+ 'Section 4',
96
+ ],
97
+ },
98
+ {
99
+ name: 'gsd-domain-researcher',
100
+ description:
101
+ 'Researches the business domain and real-world application context of the AI system being built. Surfaces domain expert evaluation criteria, industry-specific failure modes, regulatory context, and what "good" looks like for practitioners in this field — before the eval-planner turns it into measurable rubrics. Spawned by /gsd:ai-integration-phase orchestrator.',
102
+ color: '"#A78BFA"',
103
+ tools:
104
+ 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*',
105
+ requiredIncludes: [
106
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
107
+ ],
108
+ requiredSeamCalls: [],
109
+ outputContract: [
110
+ 'AI-SPEC.md',
111
+ 'Section 1b',
112
+ ],
113
+ },
114
+ {
115
+ name: 'gsd-ui-researcher',
116
+ description:
117
+ 'Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator.',
118
+ color: '"#E879F9"',
119
+ tools:
120
+ 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
121
+ requiredIncludes: [
122
+ '@~/.claude/gsd-core/references/research-documentation-lookup.md',
123
+ ],
124
+ requiredSeamCalls: [
125
+ 'gsd-tools query commit',
126
+ ],
127
+ outputContract: [
128
+ 'UI-SPEC.md',
129
+ '## UI-SPEC COMPLETE',
130
+ ],
131
+ },
132
+ {
133
+ name: 'gsd-research-synthesizer',
134
+ description:
135
+ 'Synthesizes research outputs from parallel researcher agents into SUMMARY.md. Spawned by /gsd:new-project after 4 researcher agents complete.',
136
+ color: 'purple',
137
+ tools: 'Read, Write, Bash',
138
+ requiredIncludes: [],
139
+ requiredSeamCalls: [
140
+ 'gsd-tools query commit',
141
+ ],
142
+ outputContract: [
143
+ '.planning/research/SUMMARY.md',
144
+ '## SYNTHESIS COMPLETE',
145
+ ],
146
+ },
147
+ ];
148
+
149
+ module.exports = { PROFILES };
@@ -2,5 +2,6 @@
2
2
  'use strict';
3
3
 
4
4
  const { runAffectedTests } = require('./affected-tests-lib.cjs');
5
+ const { runMain } = require('./lib/cli-exit.cjs');
5
6
 
6
- runAffectedTests();
7
+ runMain(runAffectedTests);
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawnSync } = require('child_process');
4
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
4
5
 
5
6
  const CROSS_PLATFORM_TEST_REASON = Object.freeze({
6
7
  PASS: 'pass',
@@ -50,14 +51,17 @@ function runCrossPlatformTests(options = {}, deps = {}) {
50
51
  }
51
52
 
52
53
  if (require.main === module) {
53
- const result = runCrossPlatformTests();
54
- const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`;
55
- if (result.ok) {
56
- process.stdout.write(`${line}\n`);
57
- process.exit(0);
54
+ function main() {
55
+ const result = runCrossPlatformTests();
56
+ const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`;
57
+ if (result.ok) {
58
+ process.stdout.write(`${line}\n`);
59
+ return 0;
60
+ }
61
+ process.stderr.write(`${line}\n`);
62
+ throw new ExitError(result.exitCode);
58
63
  }
59
- process.stderr.write(`${line}\n`);
60
- process.exit(result.exitCode);
64
+ runMain(main);
61
65
  }
62
66
 
63
67
  module.exports = { CROSS_PLATFORM_TEST_REASON, runCrossPlatformTests };