@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0

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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Standalone golden-fixture generator for tests/golden-install-tree (#2267 Phase 2).
5
+ *
6
+ * This is a BUILD-TIME generation script — NOT a test run. It imports the
7
+ * canonical buildInstallTree builder from tests/helpers/install-shared.cjs,
8
+ * which in turn reuses buildParityManifest's exact exclusion set (issue
9
+ * #2266) so the file-set fixtures here and the golden-install-parity content
10
+ * fixtures never diverge on which files they cover. The authoritative test
11
+ * gate remains `gsd-test run`, never a local `node --test`.
12
+ *
13
+ * Usage: node scripts/gen-install-tree-fixtures.cjs [runtime ...]
14
+ */
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { execFileSync } = require('node:child_process');
18
+
19
+ const ROOT = path.resolve(__dirname, '..');
20
+ // buildInstallTree (and the exclusion constants it reuses from
21
+ // buildParityManifest) is the canonical single source of truth in
22
+ // tests/helpers/install-shared.cjs (issue #2266/#2267) — this generator does
23
+ // not keep its own inline copy of the walk/exclusion logic.
24
+ const { runMinimalInstall, RUNTIME_META, buildInstallTree, BUILD_SCRIPT } = require(path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs'));
25
+ const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'install-tree');
26
+
27
+ function cleanup(root) {
28
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
29
+ }
30
+
31
+ // Regenerate the fixture for every runtime in RUNTIME_META. Needed when a
32
+ // SHARED gsd-core payload file (e.g. model-catalog.json, capability-registry)
33
+ // changes content — its path appears in every runtime's manifest, so all
34
+ // fixtures must be recaptured together. Usage:
35
+ // node scripts/gen-install-tree-fixtures.cjs [runtime ...]
36
+ // With no args, regenerates ALL runtimes. With args, only the named runtimes.
37
+ const targets = process.argv.slice(2).length > 0 ? process.argv.slice(2) : Object.keys(RUNTIME_META);
38
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
39
+
40
+ // Build hooks/dist before capturing so fixtures are complete even in a clean
41
+ // checkout (hooks/dist is gitignored + built; DEFECT.HOOKS-DIST-SCOPED-CI). The
42
+ // test harness's before() hook does the same; without it a fresh checkout omits
43
+ // every hooks/* path and this generator silently writes short fixtures.
44
+ execFileSync(process.execPath, [BUILD_SCRIPT], { stdio: 'pipe' });
45
+
46
+ for (const runtime of targets) {
47
+ if (!Object.prototype.hasOwnProperty.call(RUNTIME_META, runtime)) {
48
+ process.stderr.write(`[gen] unknown runtime '${runtime}' (not in RUNTIME_META) — skipping\n`);
49
+ continue;
50
+ }
51
+ const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
52
+ let actual;
53
+ try {
54
+ actual = buildInstallTree(configDir, root);
55
+ } finally {
56
+ cleanup(root);
57
+ }
58
+ const fixturePath = path.join(FIXTURE_DIR, `${runtime}.json`);
59
+ fs.writeFileSync(fixturePath, JSON.stringify(actual, null, 2) + '\n', 'utf8');
60
+ process.stdout.write(`[gen] ${runtime}: wrote ${actual.length} paths -> ${fixturePath}\n`);
61
+ }
62
+
63
+ // Also regenerate the claude LOCAL legacy-layout fixture (claude-local.json).
64
+ // This layout is distinct from the global install (commands/gsd-*.md +
65
+ // agents/gsd-*.md) and has its own snapshot assertion in the test harness.
66
+ const { configDir: localConfigDir, root: localRoot } = runMinimalInstall({ runtime: 'claude', scope: 'local' });
67
+ let localActual;
68
+ try {
69
+ localActual = buildInstallTree(localConfigDir, localRoot);
70
+ } finally {
71
+ cleanup(localRoot);
72
+ }
73
+ const localFixturePath = path.join(FIXTURE_DIR, 'claude-local.json');
74
+ fs.writeFileSync(localFixturePath, JSON.stringify(localActual, null, 2) + '\n', 'utf8');
75
+ process.stdout.write(`[gen] claude-local: wrote ${localActual.length} paths -> ${localFixturePath}\n`);
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // Regenerate the per-file test timing table used to weight chunk packing in
3
+ // scripts/run-tests.cjs (issue #2456).
4
+ //
5
+ // The chunk packer needs to know what each test file actually COSTS. Before
6
+ // #2456 it guessed from the filename (`^(?:install|codex-)` scored 12x,
7
+ // everything else 1) and was wrong in both directions — installer-migration-
8
+ // authoring.test.cjs scored 12x but runs ~0.1s, while the two heaviest files in
9
+ // the suite (run-tests-harness.test.cjs and release-tarball-smoke.install
10
+ // .test.cjs) both scored 1. This script replaces the guess with measurement.
11
+ //
12
+ // Input is one or more node:test reporter event streams as emitted by
13
+ // `gsd-test` (`~/.local/state/gsd-test/runs/<run-id>/test-events-<os>-node<v>
14
+ // .jsonl`). Each stream carries one `test:summary` event per test FILE, whose
15
+ // `data.duration_ms` is that file's total wall-clock and whose `data.file` is
16
+ // its absolute in-container path.
17
+ //
18
+ // Usage:
19
+ // node scripts/gen-test-timings.cjs <events.jsonl> [<events.jsonl> ...]
20
+ // node scripts/gen-test-timings.cjs ~/.local/state/gsd-test/runs/*/test-events-*.jsonl
21
+ // node scripts/gen-test-timings.cjs events.jsonl --out tests/test-timings.json
22
+ //
23
+ // When several streams are supplied (multiple lanes, e.g. node22 + node24), a
24
+ // file's recorded time is the MAX across them, not the mean: the packer exists
25
+ // to keep the SLOWEST lane's slowest chunk away from the per-chunk timeout, so
26
+ // the conservative bound is the right one to balance against.
27
+ //
28
+ // The table is ADVISORY and deliberately un-gated — there is no `--check` mode
29
+ // and no CI lint that fails on staleness, because timing data legitimately
30
+ // varies run to run. A file missing from the table falls back to the table's
31
+ // median weight, so a stale table degrades chunk BALANCE gracefully instead of
32
+ // failing the build. Regenerate it when the suite's cost profile has visibly
33
+ // drifted, not on a schedule.
34
+ 'use strict';
35
+
36
+ const fs = require('fs');
37
+ const { basename, dirname, join } = require('path');
38
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
39
+
40
+ const DEFAULT_OUT = join(__dirname, '..', 'tests', 'test-timings.json');
41
+ const SCHEMA_VERSION = 1;
42
+
43
+ function parseArgs(argv) {
44
+ const inputs = [];
45
+ let out = DEFAULT_OUT;
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const arg = argv[i];
48
+ if (arg === '--out') {
49
+ const value = argv[++i];
50
+ if (!value) return { error: '--out requires a path' };
51
+ out = value;
52
+ } else if (arg.startsWith('--out=')) {
53
+ const value = arg.slice('--out='.length);
54
+ if (!value) return { error: '--out requires a path' };
55
+ out = value;
56
+ } else if (arg.startsWith('-')) {
57
+ return { error: `unknown flag "${arg}"` };
58
+ } else {
59
+ inputs.push(arg);
60
+ }
61
+ }
62
+ if (inputs.length === 0) {
63
+ return { error: 'usage: gen-test-timings.cjs <reporter-events.jsonl> [...] [--out <path>]' };
64
+ }
65
+ return { inputs, out };
66
+ }
67
+
68
+ // Fold one reporter event stream into `acc`, keeping the MAX duration seen for
69
+ // each test file. Returns per-stream counters plus any basename collisions
70
+ // found WITHIN this stream.
71
+ //
72
+ // Keying is by BASENAME, matching how run-tests.cjs weights a selected file:
73
+ // the reporter reports absolute in-container paths (/work/tests/foo.test.cjs)
74
+ // while the harness carries paths relative to its test dir, so the basename is
75
+ // the only stable join key between the two. A basename seen in two different
76
+ // directories would make the table ambiguous, so it must fail loudly.
77
+ //
78
+ // Collision detection is scoped to a SINGLE stream deliberately. Every lane
79
+ // writes its own stream under its own container root (`/work/tests` on Linux,
80
+ // `C:/work/tests` on Windows), so comparing directories ACROSS streams reports
81
+ // every shared basename as a collision — which is the script's own documented
82
+ // usage (globbing `test-events-*.jsonl` across lanes). Within one stream the
83
+ // root is constant, so a differing directory is a real collision.
84
+ function foldStream(text, acc) {
85
+ const dirsByBase = new Map();
86
+ let files = 0;
87
+ let malformed = 0;
88
+ for (const line of text.split('\n')) {
89
+ if (line.trim() === '') continue;
90
+ let event;
91
+ try {
92
+ event = JSON.parse(line);
93
+ } catch {
94
+ malformed++;
95
+ continue;
96
+ }
97
+ if (!event || event.type !== 'test:summary') continue;
98
+ const data = event.data;
99
+ if (!data || typeof data.file !== 'string') continue;
100
+ const ms = data.duration_ms;
101
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) continue;
102
+ const path = data.file.replace(/\\/g, '/');
103
+ const base = basename(path);
104
+ if (!dirsByBase.has(base)) dirsByBase.set(base, new Set());
105
+ dirsByBase.get(base).add(dirname(path));
106
+ const prev = acc.get(base);
107
+ if (prev === undefined || ms > prev) acc.set(base, ms);
108
+ files++;
109
+ }
110
+ const collisions = [...dirsByBase.entries()]
111
+ .filter(([, dirs]) => dirs.size > 1)
112
+ .map(([base, dirs]) => `${base} (${[...dirs].sort().join(', ')})`);
113
+ return { files, malformed, collisions };
114
+ }
115
+
116
+ function main() {
117
+ const parsed = parseArgs(process.argv.slice(2));
118
+ if (parsed.error) throw new ExitError(2, `gen-test-timings: ${parsed.error}`);
119
+
120
+ const acc = new Map();
121
+ const allCollisions = new Set();
122
+ const sources = [];
123
+ for (const input of parsed.inputs) {
124
+ let text;
125
+ try {
126
+ text = fs.readFileSync(input, 'utf8');
127
+ } catch (err) {
128
+ throw new ExitError(2, `gen-test-timings: cannot read "${input}": ${err.message}`);
129
+ }
130
+ const { files, malformed, collisions } = foldStream(text, acc);
131
+ for (const c of collisions) allCollisions.add(c);
132
+ sources.push(basename(input));
133
+ console.error(
134
+ `gen-test-timings: ${basename(input)} — ${files} file summaries` +
135
+ (malformed > 0 ? `, ${malformed} unparseable lines skipped` : ''),
136
+ );
137
+ }
138
+
139
+ if (acc.size === 0) {
140
+ throw new ExitError(
141
+ 2,
142
+ 'gen-test-timings: no `test:summary` events with a file and duration_ms were found. ' +
143
+ 'Check that the input is a node:test reporter event stream (test-events-<os>-node<v>.jsonl).',
144
+ );
145
+ }
146
+
147
+ // A basename that resolves to two different directories within one lane makes
148
+ // the table ambiguous: run-tests.cjs joins on basename alone, so one file's
149
+ // measured cost would silently be applied to the other. Fail rather than emit
150
+ // a table that lies.
151
+ if (allCollisions.size > 0) {
152
+ throw new ExitError(
153
+ 2,
154
+ `gen-test-timings: basename collision — the table cannot key on basename alone:\n ${[...allCollisions].sort().join('\n ')}`,
155
+ );
156
+ }
157
+
158
+ // Every key must be a plain test-file basename. This is a data-integrity
159
+ // check on a stream we do not control (the reporter emits whatever path the
160
+ // runner saw), and it structurally excludes a computed key like `__proto__`
161
+ // or `constructor` from being written into the table object below — the
162
+ // `js/prototype-polluting-assignment` shape, even though the value here is
163
+ // always a number and could not actually pollute.
164
+ const SAFE_BASENAME_RE = /^[A-Za-z0-9._-]+\.test\.cjs$/;
165
+ const rejected = [...acc.keys()].filter((base) => !SAFE_BASENAME_RE.test(base));
166
+ if (rejected.length > 0) {
167
+ throw new ExitError(
168
+ 2,
169
+ `gen-test-timings: refusing to emit non-test-file keys: ${rejected.sort().join(', ')}`,
170
+ );
171
+ }
172
+
173
+ // Sorted keys keep the checked-in diff reviewable: a regeneration shows only
174
+ // the files whose cost actually moved, not a reshuffled object.
175
+ const timings = Object.create(null);
176
+ for (const base of [...acc.keys()].sort()) {
177
+ timings[base] = Math.round(acc.get(base));
178
+ }
179
+
180
+ const payload = {
181
+ schema_version: SCHEMA_VERSION,
182
+ generated_by: 'scripts/gen-test-timings.cjs',
183
+ unit: 'ms',
184
+ sources: sources.sort(),
185
+ file_count: acc.size,
186
+ timings,
187
+ };
188
+
189
+ fs.writeFileSync(parsed.out, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
190
+ const totalMs = [...acc.values()].reduce((sum, ms) => sum + ms, 0);
191
+ console.error(
192
+ `gen-test-timings: wrote ${parsed.out} — ${acc.size} files, ${(totalMs / 1000).toFixed(1)}s total`,
193
+ );
194
+ return 0;
195
+ }
196
+
197
+ if (require.main === module) {
198
+ runMain(main);
199
+ }
200
+
201
+ module.exports = { parseArgs, foldStream };
@@ -99,7 +99,6 @@
99
99
  "tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product",
100
100
  "tests/milestone-summary.test.cjs :: source-text-is-the-product",
101
101
  "tests/milestone.test.cjs :: source-text-is-the-product",
102
- "tests/milestone.test.cjs :: structural-regression-guard",
103
102
  "tests/model-catalog-runtime-defaults.test.cjs :: source-text-is-the-product",
104
103
  "tests/next-safety-gates.test.cjs :: source-text-is-the-product",
105
104
  "tests/next-up-clear-order.test.cjs :: source-text-is-the-product",
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * lint-portable-timeout.cjs — ban hardcoded GNU-`timeout` in gsd
6
+ * workflow / agent / reference / command markdown (#2351).
7
+ *
8
+ * ## Why
9
+ *
10
+ * `timeout` and `gtimeout` are GNU coreutils. Stock macOS ships NEITHER
11
+ * (`brew install coreutils` only provides `gtimeout`, and only if installed).
12
+ * A hardcoded `timeout <n> <cmd>` inside an executable workflow snippet exits
13
+ * 127 ("command not found") on such a host, and the gate that runs it — which
14
+ * only distinguishes 0 (pass) / 124 (timeout) / other (fail) — misreports a
15
+ * perfectly good build or test command as a FAILURE (#2351).
16
+ *
17
+ * The portable, coreutils-independent replacement is the
18
+ * `gsd_run run-with-timeout <secs> [--] <cmd…>` verb (gsd-core/bin/gsd-tools.cjs):
19
+ * a Node-based wall-clock cap that keeps GNU `timeout`'s exit-code contract
20
+ * (124 on timeout) on every platform. The resolution lives there ONCE and is
21
+ * reused by every call site instead of a per-file `command -v timeout` probe.
22
+ *
23
+ * This ratchet fails the build if a NEW bare `timeout`/`gtimeout` execution
24
+ * slips into any of these surfaces.
25
+ *
26
+ * ## What PASSES
27
+ *
28
+ * - `gsd_run run-with-timeout 300 -- bash -c "$CMD"` — the approved verb.
29
+ * - `command -v timeout` / `command -v gtimeout` / `which timeout` capability
30
+ * PROBES — portable: they detect the binary, they do not unconditionally
31
+ * execute it (see gsd-core/workflows/review.md's `_AGY_KILLER` fallback).
32
+ * - Prose ("timed out after 5 minutes"), config keys
33
+ * (`workflow.test_gate_timeout`), CI `timeout-minutes:`, the agy
34
+ * `--print-timeout` flag, `$TIMEOUT`-style variable names — none of which is
35
+ * a bare timeout command invocation.
36
+ *
37
+ * ## What FAILS
38
+ *
39
+ * A bare `timeout <duration> …` / `gtimeout <duration> …` command invocation,
40
+ * where `<duration>` is a number, a `"$VAR"`, or a `${VAR}` (optionally with a
41
+ * leading `-k`/`-s` option).
42
+ */
43
+
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
47
+
48
+ const ROOT = path.join(__dirname, '..');
49
+
50
+ // Surfaces whose markdown carries agent-executed bash. Kept broad so the guard
51
+ // catches a regression anywhere a workflow snippet could bound a command.
52
+ const DEFAULT_ROOTS = ['gsd-core/workflows', 'gsd-core/references', 'agents', 'commands'];
53
+
54
+ // Capability probes to strip BEFORE testing for an invocation, so a portable
55
+ // `command -v timeout` on the same line is never mistaken for a bare execution.
56
+ const PROBE_RE = /command\s+-v\s+g?timeout|which\s+g?timeout/g;
57
+
58
+ // A `timeout`/`gtimeout` token INVOKED AS A COMMAND with a duration argument.
59
+ // Anchored to a command position — line start or right after `| & ; ( ` {` — so
60
+ // prose ("increase the timeout 30 seconds") and the `--print-timeout` flag / the
61
+ // approved `run-with-timeout` verb (no separator before "timeout") never match.
62
+ // Between the token and the duration, allow any number of leading options in
63
+ // short OR long form (`-k5`, `-k 5`, `--kill-after=5`, `--foreground`,
64
+ // `--signal=KILL`). The duration is a digit, `$((arith))`, a `$VAR`, or `${VAR}`.
65
+ const EXEC_RE = /(?:^|[|&;(`{])[ \t]*g?timeout[ \t]+(?:-{1,2}[\w-]+(?:=\S+)?[ \t]+)*["']?(?:\$\{?[A-Za-z_]|\$\(\(|\d)/;
66
+
67
+ /**
68
+ * Locate bare `timeout`/`gtimeout` invocations in a block of text.
69
+ *
70
+ * Pure (no I/O): callers pass the file contents; the caller reads files. Returns
71
+ * structured findings so tests assert on typed values, never on grepped text.
72
+ *
73
+ * @param {string} text file contents
74
+ * @returns {{ line: number, snippet: string }[]} findings (empty array = clean)
75
+ */
76
+ function findRawTimeoutInvocations(text) {
77
+ const findings = [];
78
+ const lines = String(text).split(/\r?\n/);
79
+ for (let i = 0; i < lines.length; i += 1) {
80
+ const stripped = lines[i].replace(PROBE_RE, '');
81
+ if (EXEC_RE.test(stripped)) findings.push({ line: i + 1, snippet: lines[i].trim() });
82
+ }
83
+ return findings;
84
+ }
85
+
86
+ function walkMarkdown(dir) {
87
+ const out = [];
88
+ let entries;
89
+ try {
90
+ entries = fs.readdirSync(dir, { withFileTypes: true });
91
+ } catch {
92
+ return out; // a missing root is not an error — some surfaces are optional
93
+ }
94
+ for (const entry of entries) {
95
+ const full = path.join(dir, entry.name);
96
+ if (entry.isDirectory()) out.push(...walkMarkdown(full));
97
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Scan the given roots (repo-relative) for bare timeout invocations.
104
+ * @param {string[]} roots
105
+ * @returns {{ file: string, line: number, snippet: string }[]}
106
+ */
107
+ function scan(roots = DEFAULT_ROOTS) {
108
+ const offenders = [];
109
+ for (const rel of roots) {
110
+ const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
111
+ for (const file of walkMarkdown(abs)) {
112
+ const findings = findRawTimeoutInvocations(fs.readFileSync(file, 'utf8'));
113
+ for (const f of findings) {
114
+ offenders.push({ file: path.relative(ROOT, file), line: f.line, snippet: f.snippet });
115
+ }
116
+ }
117
+ }
118
+ return offenders;
119
+ }
120
+
121
+ function main() {
122
+ const rootsEnv = process.env.GSD_LINT_PORTABLE_TIMEOUT_ROOTS;
123
+ const roots = rootsEnv ? rootsEnv.split(path.delimiter).filter(Boolean) : DEFAULT_ROOTS;
124
+ const offenders = scan(roots);
125
+ if (offenders.length > 0) {
126
+ const detail = offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n');
127
+ throw new ExitError(
128
+ 1,
129
+ 'lint-portable-timeout: hardcoded `timeout`/`gtimeout` is not portable — stock\n' +
130
+ 'macOS ships no coreutils, so these exit 127 and misreport a passing command as a\n' +
131
+ 'failure. Use `gsd_run run-with-timeout <secs> [--] <cmd…>` instead (#2351):\n' +
132
+ detail,
133
+ );
134
+ }
135
+ console.log(`ok lint-portable-timeout: no hardcoded timeout invocations in ${roots.length} root(s)`);
136
+ }
137
+
138
+ module.exports = { findRawTimeoutInvocations, scan, DEFAULT_ROOTS };
139
+
140
+ if (require.main === module) runMain(main);
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Table-schema drift lint (ADR-2143 §3 Generative-Fix-Divergence guard, §7
6
+ * "prohibition with teeth", epic #2143).
7
+ *
8
+ * `TABLE_SCHEMAS` (`src/markdown-table.cts`, compiled to
9
+ * `gsd-core/bin/lib/markdown-table.cjs`) is the single-source registry naming
10
+ * every canonical GSD pipe-table's column-header shape. Each registered
11
+ * variant's exact `| col | col |` header string MUST appear verbatim in the
12
+ * ONE template/workflow file that emits that table — otherwise the writer and
13
+ * the registry have silently drifted apart, the exact bug class this ADR
14
+ * closes (#2137 — reader hard-codes a shape the writer doesn't emit; #2133 —
15
+ * the `quick.md` writer and its shell guard disagree on column count; #2119 —
16
+ * dual SECURITY.md writers with conflicting shapes).
17
+ *
18
+ * Modeled on scripts/lint-phase-id-drift.cjs / scripts/lint-package-identity-drift.cjs:
19
+ * a standalone node script (not a node:test), wired into `lint:ci`, exit 0 clean /
20
+ * exit 1 + message on drift.
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ // Schema id -> the ONE canonical template/workflow file that must emit every
27
+ // variant's header verbatim (ADR-2143 §3).
28
+ const SCHEMA_SOURCE_FILES = {
29
+ RoadmapProgress: path.join('gsd-core', 'templates', 'roadmap.md'),
30
+ RequirementsTraceability: path.join('gsd-core', 'templates', 'requirements.md'),
31
+ QuickTasks: path.join('gsd-core', 'workflows', 'quick.md'),
32
+ Security: path.join('gsd-core', 'templates', 'SECURITY.md'),
33
+ };
34
+
35
+ /** Build the exact `| a | b | c |` header line for one schema variant. */
36
+ function buildHeader(variant) {
37
+ return `| ${variant.columns.join(' | ')} |`;
38
+ }
39
+
40
+ /** Normalize whitespace around pipes so template formatting quirks (extra
41
+ * spaces added by an editor, etc.) don't produce a false drift report. */
42
+ function normalize(line) {
43
+ return line.replace(/[ \t]*\|[ \t]*/g, '|').trim();
44
+ }
45
+
46
+ /**
47
+ * Pure: given `schemas` (a `TABLE_SCHEMAS`-shaped object) and a
48
+ * `readFile(relPath) -> string|null` accessor, find every variant whose exact
49
+ * header does not appear verbatim (pipe-whitespace normalized) in its
50
+ * schema's registered source file. Returns
51
+ * `[{ schemaId, label, header, file, reason }]`; empty when clean.
52
+ */
53
+ function findTableSchemaDrift(schemas, readFile, sourceFiles = SCHEMA_SOURCE_FILES) {
54
+ const violations = [];
55
+ for (const [schemaId, variants] of Object.entries(schemas)) {
56
+ const relPath = sourceFiles[schemaId];
57
+ if (!relPath) {
58
+ violations.push({
59
+ schemaId,
60
+ label: null,
61
+ header: null,
62
+ file: null,
63
+ reason: 'no canonical source file registered for this schema id in SCHEMA_SOURCE_FILES',
64
+ });
65
+ continue;
66
+ }
67
+
68
+ const content = readFile(relPath);
69
+ if (content == null) {
70
+ for (const variant of variants) {
71
+ violations.push({
72
+ schemaId,
73
+ label: variant.label,
74
+ header: buildHeader(variant),
75
+ file: relPath,
76
+ reason: 'source file not found or unreadable',
77
+ });
78
+ }
79
+ continue;
80
+ }
81
+
82
+ const normalizedLines = content.split(/\r?\n/).map(normalize);
83
+ for (const variant of variants) {
84
+ const expected = normalize(buildHeader(variant));
85
+ if (!normalizedLines.includes(expected)) {
86
+ violations.push({
87
+ schemaId,
88
+ label: variant.label,
89
+ header: buildHeader(variant),
90
+ file: relPath,
91
+ reason: 'header not found verbatim in source file',
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return violations;
97
+ }
98
+
99
+ /**
100
+ * Load the built seam and scan the real repo tree. Returns the same shape as
101
+ * `findTableSchemaDrift`. If the seam hasn't been built yet (`npm run
102
+ * build:lib`), reports a single actionable violation rather than throwing.
103
+ */
104
+ function scanRepo(root) {
105
+ const seamPath = path.join(root, 'gsd-core', 'bin', 'lib', 'markdown-table.cjs');
106
+ let seam;
107
+ try {
108
+ seam = require(seamPath);
109
+ } catch (e) {
110
+ return [{
111
+ schemaId: null,
112
+ label: null,
113
+ header: null,
114
+ file: null,
115
+ reason: `cannot load the markdown-table seam at ${path.relative(root, seamPath)} — run 'npm run build:lib' first (${e.message})`,
116
+ }];
117
+ }
118
+
119
+ const readFile = (relPath) => {
120
+ try {
121
+ return fs.readFileSync(path.join(root, relPath), 'utf8');
122
+ } catch {
123
+ return null;
124
+ }
125
+ };
126
+
127
+ return findTableSchemaDrift(seam.TABLE_SCHEMAS, readFile);
128
+ }
129
+
130
+ function main() {
131
+ const root = path.join(__dirname, '..');
132
+ const violations = scanRepo(root);
133
+ if (violations.length === 0) {
134
+ process.stdout.write(
135
+ 'ok table-schema-drift: every TABLE_SCHEMAS variant header appears verbatim in its canonical template/workflow\n',
136
+ );
137
+ return;
138
+ }
139
+ process.stderr.write(
140
+ 'table-schema-drift: TABLE_SCHEMAS variant(s) whose header is absent from their canonical source file (ADR-2143 §3).\n',
141
+ );
142
+ process.stderr.write(
143
+ 'Either update the template/workflow to emit the registered header verbatim, or update\n' +
144
+ 'TABLE_SCHEMAS in src/markdown-table.cts to match — the two must never drift.\n',
145
+ );
146
+ for (const v of violations) {
147
+ const id = v.label ? `${v.schemaId}.${v.label}` : (v.schemaId ?? '?');
148
+ const loc = v.file ? ` (${v.file})` : '';
149
+ const expected = v.header ? ` — expected ${JSON.stringify(v.header)}` : '';
150
+ process.stderr.write(` ${id}${loc}: ${v.reason}${expected}\n`);
151
+ }
152
+ process.exitCode = 1;
153
+ }
154
+
155
+ if (require.main === module) main();
156
+
157
+ module.exports = { findTableSchemaDrift, scanRepo, buildHeader, normalize, SCHEMA_SOURCE_FILES };
@@ -60,6 +60,7 @@
60
60
  "state": {
61
61
  "files": [
62
62
  "state-acquirestatelock-non-eexist.test.cjs",
63
+ "state-command-cutover.test.cjs",
63
64
  "state-prune.test.cjs",
64
65
  "state-rebuild-cli.test.cjs",
65
66
  "state-rebuild.test.cjs",
@@ -47,16 +47,23 @@ const os = require('os');
47
47
  const path = require('path');
48
48
  const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
49
49
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
50
- // 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
51
- // `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
52
- // spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
53
- // (Node docs: status is null when subprocess terminated due to a signal).
54
- // The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, so the
55
- // test saw empty stdout/stderr and a spurious INSTALL_FAILED. Windows runners
56
- // are slower than Linux/macOS for filesystem-heavy operations (
57
- // https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories
58
- // ). Raise to 600 s (the same ceiling the before() helper uses for pack+install).
59
- const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000;
50
+ // 120 s proved too tight for cold-cache `npm install -g` of a 1499-file tarball:
51
+ // spawnSync fires SIGTERM at the deadline and returns { status: null, stdout: '',
52
+ // stderr: '' } (Node docs: status is null when a subprocess is terminated by a
53
+ // signal). The INSTALL_FAILED branch checks `status !== 0`, which null satisfies,
54
+ // so the test sees empty stdout/stderr and a spurious INSTALL_FAILED (surfaced as
55
+ // `installError: spawnSync npm ETIMEDOUT`).
56
+ //
57
+ // First observed on Windows GitHub-hosted runners, which are slower for
58
+ // filesystem-heavy work
59
+ // (https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories),
60
+ // then again on a slow Linux bench (cartographer: cold disk, constrained CPU) —
61
+ // the same failure the before() helper's SLOW_HOST_TIMEOUT already guards for
62
+ // pack+install. The slow-host reality is not platform-specific, so the ceiling is
63
+ // now uniform 600 s across all platforms AND shared with before() via this
64
+ // exported constant, so the two surfaces cannot diverge again. 600 s stays well
65
+ // clear of a real cold install (3–6 min) without masking a genuine hang.
66
+ const CHILD_TIMEOUT_MS = 600_000;
60
67
  const QUIET_NPM_ENV = Object.freeze({
61
68
  npm_config_loglevel: 'error',
62
69
  npm_config_update_notifier: 'false',
@@ -628,7 +635,7 @@ function cleanup(...dirs) {
628
635
  // Exports
629
636
  // ---------------------------------------------------------------------------
630
637
 
631
- module.exports = { SMOKE, runSmoke, binInvocation };
638
+ module.exports = { SMOKE, runSmoke, binInvocation, CHILD_TIMEOUT_MS };
632
639
 
633
640
  if (require.main === module) {
634
641
  runMain(cliMain);