@opengsd/gsd-core 1.16.0 → 1.18.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 (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-advisor-researcher.compact.md +1 -1
  7. package/agents/gsd-advisor-researcher.md +1 -1
  8. package/agents/gsd-assumptions-analyzer.compact.md +1 -1
  9. package/agents/gsd-assumptions-analyzer.md +1 -1
  10. package/agents/gsd-code-fixer.compact.md +1 -1
  11. package/agents/gsd-code-fixer.md +1 -1
  12. package/agents/gsd-code-reviewer.compact.md +1 -1
  13. package/agents/gsd-code-reviewer.md +1 -1
  14. package/agents/gsd-codebase-mapper.compact.md +1 -1
  15. package/agents/gsd-codebase-mapper.md +1 -1
  16. package/agents/gsd-debug-session-manager.compact.md +6 -0
  17. package/agents/gsd-debug-session-manager.md +6 -0
  18. package/agents/gsd-debugger.md +1 -1
  19. package/agents/gsd-doc-verifier.compact.md +4 -4
  20. package/agents/gsd-doc-verifier.md +4 -3
  21. package/agents/gsd-doc-writer.compact.md +23 -5
  22. package/agents/gsd-doc-writer.md +19 -6
  23. package/agents/gsd-eval-auditor.compact.md +1 -1
  24. package/agents/gsd-eval-auditor.md +1 -1
  25. package/agents/gsd-executor.md +7 -3
  26. package/agents/gsd-integration-checker.compact.md +1 -1
  27. package/agents/gsd-integration-checker.md +1 -1
  28. package/agents/gsd-nyquist-auditor.compact.md +1 -1
  29. package/agents/gsd-nyquist-auditor.md +1 -1
  30. package/agents/gsd-phase-researcher.md +1 -1
  31. package/agents/gsd-plan-checker.md +2 -1
  32. package/agents/gsd-planner.md +5 -4
  33. package/agents/gsd-project-researcher.compact.md +1 -1
  34. package/agents/gsd-project-researcher.md +1 -1
  35. package/agents/gsd-research-synthesizer.compact.md +5 -5
  36. package/agents/gsd-research-synthesizer.md +5 -5
  37. package/agents/gsd-roadmapper.compact.md +1 -1
  38. package/agents/gsd-roadmapper.md +1 -1
  39. package/agents/gsd-security-auditor.compact.md +1 -1
  40. package/agents/gsd-security-auditor.md +1 -1
  41. package/agents/gsd-ui-auditor.compact.md +1 -1
  42. package/agents/gsd-ui-auditor.md +1 -1
  43. package/agents/gsd-ui-checker.compact.md +1 -1
  44. package/agents/gsd-ui-checker.md +1 -1
  45. package/agents/gsd-ui-researcher.compact.md +13 -1
  46. package/agents/gsd-ui-researcher.md +28 -1
  47. package/agents/gsd-verifier.md +1 -1
  48. package/bin/gsd-mcp-server.js +3 -1
  49. package/bin/install.js +398 -95
  50. package/commands/gsd/code-review.md +8 -0
  51. package/commands/gsd/pause-work.md +1 -1
  52. package/gsd-core/bin/gsd-tools.cjs +34 -12
  53. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  54. package/gsd-core/bin/lib/capability-ledger.cjs +25 -0
  55. package/gsd-core/bin/lib/capability-lock.cjs +29 -7
  56. package/gsd-core/bin/lib/capability-registry.cjs +138 -80
  57. package/gsd-core/bin/lib/capability-source.cjs +3 -1
  58. package/gsd-core/bin/lib/capability-state.cjs +6 -3
  59. package/gsd-core/bin/lib/capability-validator.cjs +8 -5
  60. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  61. package/gsd-core/bin/lib/commands.cjs +33 -9
  62. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  63. package/gsd-core/bin/lib/decision-coverage-support.cjs +19 -0
  64. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  65. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +15 -0
  66. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +13 -0
  67. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +23 -3
  68. package/gsd-core/bin/lib/gate-predicate.cjs +8 -12
  69. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +60 -5
  70. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  71. package/gsd-core/bin/lib/graphify.cjs +3 -2
  72. package/gsd-core/bin/lib/init.cjs +43 -16
  73. package/gsd-core/bin/lib/install-effort-resolver.cjs +2 -2
  74. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  75. package/gsd-core/bin/lib/install-model-override-resolver.cjs +4 -3
  76. package/gsd-core/bin/lib/install-profiles.cjs +5 -3
  77. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  78. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  79. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  80. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  81. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  82. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  83. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  84. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  85. package/gsd-core/bin/lib/phase.cjs +141 -11
  86. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  87. package/gsd-core/bin/lib/report-parser.cjs +57 -1
  88. package/gsd-core/bin/lib/review-lane-invocation.cjs +40 -0
  89. package/gsd-core/bin/lib/review-reviewer-selection.cjs +20 -1
  90. package/gsd-core/bin/lib/roadmap.cjs +56 -5
  91. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +142 -6
  92. package/gsd-core/bin/lib/runtime-homes.cjs +18 -0
  93. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  94. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  95. package/gsd-core/bin/lib/shell-command-projection.cjs +2 -0
  96. package/gsd-core/bin/lib/smart-entry.cjs +100 -53
  97. package/gsd-core/bin/lib/state-document.cjs +37 -3
  98. package/gsd-core/bin/lib/state-transition.cjs +98 -19
  99. package/gsd-core/bin/lib/state.cjs +544 -126
  100. package/gsd-core/bin/lib/task-command-router.cjs +2 -3
  101. package/gsd-core/bin/lib/tdd-red-evidence.cjs +5 -0
  102. package/gsd-core/bin/lib/tdd-test-files.cjs +42 -0
  103. package/gsd-core/bin/lib/update-context.cjs +18 -1
  104. package/gsd-core/bin/lib/verification.cjs +107 -17
  105. package/gsd-core/bin/lib/verify.cjs +36 -1
  106. package/gsd-core/references/agent-contracts.md +1 -1
  107. package/gsd-core/references/agent-skills-bootstrap.md +26 -7
  108. package/gsd-core/references/git-integration.md +3 -3
  109. package/gsd-core/references/planner-reversibility.md +18 -4
  110. package/gsd-core/references/planner-verify-command-grounding.md +18 -0
  111. package/gsd-core/references/tdd.md +4 -3
  112. package/gsd-core/references/verifier-phase-gates.md +8 -1
  113. package/gsd-core/workflows/add-backlog.md +10 -2
  114. package/gsd-core/workflows/add-phase.md +5 -1
  115. package/gsd-core/workflows/add-tests.md +5 -1
  116. package/gsd-core/workflows/add-todo.md +5 -1
  117. package/gsd-core/workflows/ai-integration-phase.md +5 -1
  118. package/gsd-core/workflows/audit-milestone.md +21 -9
  119. package/gsd-core/workflows/autonomous/templates/minimal-context.md +41 -0
  120. package/gsd-core/workflows/autonomous.md +52 -64
  121. package/gsd-core/workflows/check-todos.md +5 -1
  122. package/gsd-core/workflows/code-review-fix.md +5 -1
  123. package/gsd-core/workflows/code-review.md +21 -2
  124. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +5 -1
  125. package/gsd-core/workflows/complete-milestone.md +25 -5
  126. package/gsd-core/workflows/debug.md +5 -1
  127. package/gsd-core/workflows/diagnose-issues.md +5 -1
  128. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -1
  129. package/gsd-core/workflows/discuss-phase-assumptions.md +11 -3
  130. package/gsd-core/workflows/discuss-phase.md +5 -1
  131. package/gsd-core/workflows/docs-update/detail/elaboration.md +7 -6
  132. package/gsd-core/workflows/docs-update.md +27 -5
  133. package/gsd-core/workflows/edit-phase.md +5 -1
  134. package/gsd-core/workflows/eval-review.md +11 -3
  135. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +5 -1
  136. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  137. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +12 -6
  138. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +19 -2
  139. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +18 -1
  140. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +5 -1
  141. package/gsd-core/workflows/execute-phase.md +40 -7
  142. package/gsd-core/workflows/execute-plan.md +8 -4
  143. package/gsd-core/workflows/extract-learnings.md +6 -2
  144. package/gsd-core/workflows/forensics.md +5 -1
  145. package/gsd-core/workflows/import.md +5 -1
  146. package/gsd-core/workflows/insert-phase.md +5 -1
  147. package/gsd-core/workflows/manager.md +10 -2
  148. package/gsd-core/workflows/map-codebase.md +5 -1
  149. package/gsd-core/workflows/milestone-summary.md +10 -2
  150. package/gsd-core/workflows/new-milestone.md +13 -5
  151. package/gsd-core/workflows/new-project.md +9 -5
  152. package/gsd-core/workflows/next.md +8 -4
  153. package/gsd-core/workflows/pause-work.md +3 -3
  154. package/gsd-core/workflows/plan-phase/steps/auto-advance-check.md +70 -0
  155. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +5 -1
  156. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +6 -2
  157. package/gsd-core/workflows/plan-phase.md +41 -73
  158. package/gsd-core/workflows/plan-review-convergence.md +17 -5
  159. package/gsd-core/workflows/plant-seed.md +8 -4
  160. package/gsd-core/workflows/progress.md +6 -2
  161. package/gsd-core/workflows/quick-batch.md +5 -1
  162. package/gsd-core/workflows/quick.md +22 -7
  163. package/gsd-core/workflows/remove-phase.md +5 -1
  164. package/gsd-core/workflows/resume-project.md +19 -11
  165. package/gsd-core/workflows/review.md +6 -2
  166. package/gsd-core/workflows/scan.md +5 -1
  167. package/gsd-core/workflows/secure-phase.md +6 -2
  168. package/gsd-core/workflows/settings.md +3 -3
  169. package/gsd-core/workflows/ship.md +13 -10
  170. package/gsd-core/workflows/thread.md +10 -2
  171. package/gsd-core/workflows/transition.md +5 -1
  172. package/gsd-core/workflows/ui-phase.md +5 -1
  173. package/gsd-core/workflows/ui-review.md +6 -2
  174. package/gsd-core/workflows/ultraplan-phase.md +5 -1
  175. package/gsd-core/workflows/update.md +5 -1
  176. package/gsd-core/workflows/validate-phase.md +6 -2
  177. package/gsd-core/workflows/verify-work/detail/elaboration.md +1 -1
  178. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -1
  179. package/gsd-core/workflows/verify-work.md +11 -3
  180. package/hooks/dist/gsd-agent-isolation-guard.js +3 -1
  181. package/hooks/dist/gsd-check-update.js +26 -20
  182. package/hooks/dist/gsd-context-monitor.js +14 -5
  183. package/hooks/dist/gsd-cursor-subagent-start.js +2 -1
  184. package/hooks/dist/gsd-secret-read-guard.js +189 -15
  185. package/hooks/dist/gsd-statusline.js +321 -22
  186. package/hooks/dist/gsd-validate-commit.sh +27 -5
  187. package/hooks/gsd-agent-isolation-guard.js +3 -1
  188. package/hooks/gsd-check-update.js +26 -20
  189. package/hooks/gsd-context-monitor.js +14 -5
  190. package/hooks/gsd-cursor-subagent-start.js +2 -1
  191. package/hooks/gsd-secret-read-guard.js +189 -15
  192. package/hooks/gsd-statusline.js +321 -22
  193. package/hooks/gsd-validate-commit.sh +27 -5
  194. package/package.json +1 -1
  195. package/scripts/audit-workflow-script-paths.cjs +1 -1
  196. package/scripts/diagnose-validate-commit-in-tier.cjs +270 -0
  197. package/scripts/diagnose-validate-commit-phases.cjs +348 -0
  198. package/scripts/diagnose-windows-spawn-watcher.cjs +317 -0
  199. package/scripts/docs-guard-registry.cjs +2 -0
  200. package/scripts/gen-capability-registry.cjs +7 -1
  201. package/scripts/gen-loop-host-contract.cjs +4 -1
  202. package/scripts/lib/macos-conformance-tier.generated.cjs +13 -0
  203. package/scripts/lib/platform-conformance-tier.generated.cjs +26 -0
  204. package/scripts/lint-phase-arg-assignment.cjs +2 -2
  205. package/scripts/lint-workflow-shellcheck-baseline.json +16 -16
  206. package/scripts/mutation-matrix.cjs +3 -1
  207. package/scripts/release-tarball-smoke.cjs +15 -7
  208. package/scripts/xtrace-phase-timing.cjs +205 -0
  209. package/skills/gsd-code-review/SKILL.md +8 -0
  210. package/skills/gsd-pause-work/SKILL.md +1 -1
  211. package/vscode/package.json +1 -1
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * #5473 — measure the 112KB hook rows INSIDE the real conformance tier.
5
+ *
6
+ * The standalone diagnostic (scripts/diagnose-validate-commit-phases.cjs, #5476)
7
+ * found the hook costs ~0.2 s on a padded 112KB command on macos-latest under
8
+ * /bin/bash 3.2, idle or with four node-forking processes beside it, and no
9
+ * slower than an unpadded body. The conformance job's own row
10
+ * (tests/hooks-opt-in.test.cjs) costs 2.5-5 s per invocation. So whatever makes
11
+ * it slow is a property of how the row runs, not of the command. This module is
12
+ * meant to be called from a test that the tier runs
13
+ * (tests/in-tier-commit-hook-timing.test.cjs), so it sees exactly what the real
14
+ * row sees: the node:test child's environment and the tier's contention.
15
+ *
16
+ * It varies one thing at a time on the same command and reports every cell:
17
+ *
18
+ * - WHICH bash: the real row spawns `bash` by name (so whatever the PATH
19
+ * resolves, possibly a Homebrew bash 5), while the standalone diagnostic
20
+ * used /bin/bash 3.2. Both are timed, and both are named in the report;
21
+ * - the environment handed to the hook (inherited / minimal / without NODE_*
22
+ * / without GSD_* and RUN_TESTS_* / LC_ALL=C / LC_ALL=en_US.UTF-8: bash's
23
+ * pattern substitution is known to be slower in a multibyte locale);
24
+ * - how it is spawned (spawnSync, or asynchronous, detached and killed by
25
+ * process group, as the real row does). Every cell reads stdout and stderr
26
+ * through pipes, as the real row does, so spawn mode is the only difference.
27
+ *
28
+ * Runs are interleaved round-robin across the cells, rotating the starting
29
+ * cell each round, so drift in the tier's load shows up as noise and not as a
30
+ * variant effect. Then one traced run under the PATH bash. It asserts nothing
31
+ * about time. The job environment carries tokens and the report goes to a
32
+ * public log, so no variable name or value is read from the environment into
33
+ * the report: it prints counts, which of a fixed list are set, and what
34
+ * `locale` and `command -v bash` themselves say under that environment.
35
+ */
36
+
37
+ const crypto = require('node:crypto');
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+ const { spawn, spawnSync } = require('node:child_process');
42
+ const { stampPs4 } = require('./xtrace-phase-timing.cjs');
43
+ const cli = require('./diagnose-validate-commit-phases.cjs');
44
+
45
+ const SYSTEM_BASH = '/bin/bash';
46
+ const DEFAULT_RUNS = 2;
47
+ const VERSION_PROBE_BOUND_MS = 10000;
48
+
49
+ // Variables whose PRESENCE is worth reporting (a constant list: the report names
50
+ // these, never anything read from the environment).
51
+ const PRESENCE_ENV = [
52
+ 'CI', 'TEST_CONCURRENCY', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'NODE_V8_COVERAGE',
53
+ 'GSD_PLUGIN_ROOT', 'GSD_STRICT_LIVE_CONFIG_GUARD', 'RUN_TESTS_TIMING_EVENTS_FILE',
54
+ ];
55
+ const FAMILIES = ['NODE_', 'GSD_', 'RUN_TESTS_', 'LC_'];
56
+
57
+ /**
58
+ * What the report may say about an environment: how many variables, how many in
59
+ * each family, and which of PRESENCE_ENV are set. No name and no value is read
60
+ * from the environment into the result, because the job environment carries
61
+ * tokens and a report goes to a public log.
62
+ */
63
+ function describeEnv(env) {
64
+ const keys = Object.keys(env);
65
+ const families = {};
66
+ for (const prefix of FAMILIES) families[prefix] = keys.filter((k) => k.startsWith(prefix)).length;
67
+ const present = PRESENCE_ENV.filter((name) => Object.hasOwn(env, name));
68
+ return { count: keys.length, families, present };
69
+ }
70
+
71
+ /**
72
+ * The effective locale the hook would see under `env`, as `locale` itself reports
73
+ * it (LANG and LC_* lines only). Asking the tool, rather than reading the variables,
74
+ * is also what tells a missing locale from a set one.
75
+ */
76
+ function localeReport(env) {
77
+ const r = spawnSync('locale', [], { env, encoding: 'utf8', timeout: VERSION_PROBE_BOUND_MS });
78
+ if (r.error || r.status !== 0) return 'unavailable';
79
+ return r.stdout.split('\n').filter((line) => /^(LANG|LC_[A-Z]+)=/.test(line)).join(' ');
80
+ }
81
+
82
+ /** `env` without any variable whose name starts with one of `prefixes`. */
83
+ function without(env, prefixes) {
84
+ const out = {};
85
+ for (const [key, value] of Object.entries(env)) {
86
+ if (!prefixes.some((p) => key.startsWith(p))) out[key] = value;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** The first executable named `name` on `pathVar`, as `spawn(name)` would resolve it, or null. */
92
+ function findOnPath(name, pathVar) {
93
+ for (const dir of String(pathVar || '').split(path.delimiter)) {
94
+ if (!dir) continue;
95
+ const candidate = path.join(dir, name);
96
+ try {
97
+ fs.accessSync(candidate, fs.constants.X_OK);
98
+ if (fs.statSync(candidate).isFile()) return candidate;
99
+ } catch { /* not here */ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * The cells to time. `base` is the real row's environment; `bash` is what
106
+ * `spawn('bash')` resolves to under it. A cell is one (interpreter, environment,
107
+ * spawn mode); cells that would repeat another are left out.
108
+ */
109
+ function buildCells(base, bash) {
110
+ const cells = [];
111
+ const add = (label, bin, env, mode) => cells.push({ label, bin, env, mode });
112
+ add('PATH bash, inherited env', bash, base, 'sync');
113
+ add('PATH bash, inherited env', bash, base, 'async');
114
+ if (bash !== SYSTEM_BASH) {
115
+ add('/bin/bash, inherited env', SYSTEM_BASH, base, 'sync');
116
+ add('/bin/bash, inherited env', SYSTEM_BASH, base, 'async');
117
+ }
118
+ add('PATH bash, minimal env (PATH, HOME)', bash, { PATH: base.PATH, HOME: base.HOME || '' }, 'sync');
119
+ add('PATH bash, without NODE_*', bash, without(base, ['NODE_']), 'sync');
120
+ add('PATH bash, without GSD_* / RUN_TESTS_*', bash, without(base, ['GSD_', 'RUN_TESTS_']), 'sync');
121
+ add('PATH bash, LC_ALL=C', bash, { ...base, LC_ALL: 'C' }, 'sync');
122
+ add('PATH bash, LC_ALL=en_US.UTF-8', bash, { ...base, LC_ALL: 'en_US.UTF-8' }, 'sync');
123
+ return cells;
124
+ }
125
+
126
+ const elapsedMs = (started) => Number((process.hrtime.bigint() - started) / 1000000n);
127
+
128
+ /** One synchronous run; stdout and stderr go through pipes like the real row's. */
129
+ function runSync(project, command, env, bin) {
130
+ const started = process.hrtime.bigint();
131
+ const r = spawnSync(bin, [cli.HOOK], {
132
+ cwd: project,
133
+ env,
134
+ input: JSON.stringify({ tool_input: { command } }),
135
+ encoding: 'utf8',
136
+ timeout: cli.HOOK_BOUND_MS,
137
+ killSignal: 'SIGKILL',
138
+ });
139
+ return { wallMs: elapsedMs(started), status: r.status, signal: r.signal, killed: r.signal === 'SIGKILL' };
140
+ }
141
+
142
+ /**
143
+ * One run the way the real row does it (tests/hooks-opt-in.test.cjs runCmdAsync):
144
+ * asynchronous, detached, stdout and stderr read through pipes, killed by
145
+ * process group at the bound.
146
+ */
147
+ function runDetached(project, command, env, bin) {
148
+ return new Promise((resolve) => {
149
+ const started = process.hrtime.bigint();
150
+ const child = spawn(bin, [cli.HOOK], { cwd: project, env, detached: true });
151
+ let settled = false;
152
+ let killed = false;
153
+ const killer = setTimeout(() => {
154
+ killed = true;
155
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
156
+ }, cli.HOOK_BOUND_MS);
157
+ const settle = (result) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ clearTimeout(killer);
161
+ resolve({ wallMs: elapsedMs(started), killed, ...result });
162
+ };
163
+ child.on('error', (err) => settle({ status: null, signal: null, error: err.code || err.message }));
164
+ child.stdin.on('error', () => { /* the hook may exit before reading stdin */ });
165
+ child.stdout.on('data', () => {});
166
+ child.stderr.on('data', () => {});
167
+ child.on('close', (status, signal) => settle({ status, signal }));
168
+ child.stdin.end(JSON.stringify({ tool_input: { command } }));
169
+ });
170
+ }
171
+
172
+ const summarize = (walls) => {
173
+ const sorted = [...walls].sort((a, b) => a - b);
174
+ return `min ${sorted[0]} / median ${cli.median(sorted)} / max ${sorted[sorted.length - 1]}`;
175
+ };
176
+
177
+ function versionLine(bin) {
178
+ const r = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: VERSION_PROBE_BOUND_MS });
179
+ return (r.stdout || 'unknown').split('\n')[0];
180
+ }
181
+
182
+ /** The order to visit `count` cells in round `round`: every cell once, starting one further along each round. */
183
+ function roundOrder(count, round) {
184
+ return Array.from({ length: count }, (_, k) => (k + round) % count);
185
+ }
186
+
187
+ /** Where the shell itself resolves `bash` under `env` (its answer is what the report prints). */
188
+ function resolveBash(env) {
189
+ const r = spawnSync('/bin/sh', ['-c', 'command -v bash'], { env, encoding: 'utf8', timeout: VERSION_PROBE_BOUND_MS });
190
+ return r.status === 0 ? r.stdout.trim() : 'not found';
191
+ }
192
+
193
+ /** Run every cell `runs` times, round-robin, rotating the starting cell each round. */
194
+ async function measureCells(project, command, cells, runs) {
195
+ const walls = cells.map(() => []);
196
+ const last = cells.map(() => null);
197
+ for (let round = 0; round < runs; round++) {
198
+ for (const i of roundOrder(cells.length, round)) {
199
+ const cell = cells[i];
200
+ const result = cell.mode === 'async'
201
+ ? await runDetached(project, command, cell.env, cell.bin)
202
+ : runSync(project, command, cell.env, cell.bin);
203
+ walls[i].push(result.wallMs);
204
+ last[i] = result;
205
+ }
206
+ }
207
+ return cells.map((cell, i) => ({ cell, walls: walls[i], last: last[i] }));
208
+ }
209
+
210
+ /**
211
+ * @param {{ runs?: number, scenarioList?: { name: string, command: string }[] }} [options]
212
+ * scenarioList defaults to the padded + trailing-option row and the unpadded comparison.
213
+ * @returns {Promise<{ text: string, ok: boolean }>} ok is false if the traced run produced no trace.
214
+ */
215
+ async function inTierReport({ runs = DEFAULT_RUNS, scenarioList } = {}) {
216
+ if (!Number.isInteger(runs) || runs < 1) throw new RangeError('runs must be a positive integer');
217
+ const all = cli.scenarios();
218
+ const picked = scenarioList || [all[1], all[2]];
219
+ const base = cli.hookEnv();
220
+ const bash = findOnPath('bash', base.PATH) || SYSTEM_BASH;
221
+ const cells = buildCells(base, bash);
222
+ const nonce = crypto.randomBytes(6).toString('hex');
223
+ const desc = describeEnv(process.env);
224
+ const out = ['# validate-commit INSIDE the conformance tier (#5473)', ''];
225
+ out.push(`- host: ${os.type()} ${os.release()} ${process.arch}, ${os.cpus().length} cpus, load average ${os.loadavg().map((n) => n.toFixed(2)).join(' ')}, node ${process.version}`);
226
+ out.push(`- \`bash\` on the inherited PATH: ${resolveBash(base)} (${versionLine(bash)})`);
227
+ out.push(`- /bin/bash: ${versionLine(SYSTEM_BASH)}`);
228
+ out.push(`- environment: ${desc.count} variables (${FAMILIES.map((p) => `${p}* ${desc.families[p]}`).join(', ')}); set: ${desc.present.join(' ') || 'none of the watched ones'}`);
229
+ out.push(`- effective locale: ${localeReport(base)}`);
230
+ out.push(`- ${runs} runs per cell, interleaved round-robin across cells with a rotating start`, '');
231
+ let project;
232
+ let traced;
233
+ try {
234
+ project = cli.makeProject();
235
+ const hooks = cli.prepareTracedHooks(project, stampPs4(cli.STAMP_COMMAND, nonce));
236
+ for (const scenario of picked) {
237
+ out.push(`## ${scenario.name} (${scenario.command.length} bytes)`, '');
238
+ out.push(`| interpreter, environment | spawn | wall ms (${runs} runs) | last exit |`, '|---|---|---|---|');
239
+ for (const m of await measureCells(project, scenario.command, cells, runs)) {
240
+ const mode = m.cell.mode === 'async' ? 'async + detached + group kill' : 'spawnSync';
241
+ const exit = `${m.last.status}${m.last.signal ? ` ${m.last.signal}` : ''}${m.last.killed ? ' KILLED' : ''}`;
242
+ out.push(`| ${m.cell.label} | ${mode} | ${summarize(m.walls)} | ${exit} |`);
243
+ }
244
+ out.push('');
245
+ }
246
+ traced = cli.measureScenario(project, picked[0], hooks, nonce, bash);
247
+ } finally {
248
+ if (project) fs.rmSync(project, { recursive: true, force: true });
249
+ }
250
+ out.push('## traced run under the inherited environment (PATH bash)', '');
251
+ out.push(...cli.renderScenario(traced, fs.readFileSync(cli.HOOK, 'utf8').split('\n'), { top: 12 }));
252
+ out.push(`- load average after: ${os.loadavg().map((n) => n.toFixed(2)).join(' ')}`);
253
+ return { text: out.join('\n'), ok: traced.entries > 0 };
254
+ }
255
+
256
+ module.exports = {
257
+ describeEnv,
258
+ localeReport,
259
+ without,
260
+ findOnPath,
261
+ buildCells,
262
+ roundOrder,
263
+ runSync,
264
+ runDetached,
265
+ measureCells,
266
+ inTierReport,
267
+ PRESENCE_ENV,
268
+ FAMILIES,
269
+ SYSTEM_BASH,
270
+ };
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * #5473 — where does hooks/gsd-validate-commit.sh spend its time on a 112KB
6
+ * whitespace-padded command under /bin/bash 3.2 (macOS)?
7
+ *
8
+ * tests/hooks-opt-in.test.cjs's "suffix window still sees an option after a
9
+ * 112KB command" row costs ~5s on the macOS conformance job against a 10s
10
+ * kill bound, and ~12x the same-size unpadded row. This script runs the real
11
+ * hook two ways per scenario and prints where the time goes:
12
+ *
13
+ * - untraced: wall time of the hook exactly as the test runs it, repeated
14
+ * (min / median / max), because the effect under study is
15
+ * noisy on a shared runner;
16
+ * - traced: a copy of the hooks directory whose hook has one line inserted
17
+ * after its shebang (PS4 assigned, xtrace on), so every command
18
+ * is stamped with an epoch millisecond, its line number and its
19
+ * function (scripts/xtrace-phase-timing.cjs turns the stamps
20
+ * into per-line and per-region costs, shifted back to the
21
+ * hook's own line numbers).
22
+ *
23
+ * Reading the traced numbers: every traced command pays one stamp (a perl
24
+ * spawn), measured and reported below, and xtrace prints each 112KB expansion,
25
+ * a cost the untraced run does not have. The traced span is therefore NOT
26
+ * comparable to the untraced wall time; use it to RANK lines, not to total them.
27
+ * A gap is the previous command's execution plus the next command's expansion,
28
+ * so each line carries two numbers (see xtrace-phase-timing.cjs).
29
+ *
30
+ * `--load N` runs N processes that fork node in a loop alongside the hook,
31
+ * standing in for the conformance tier's `--test-concurrency=4` (four test
32
+ * files, most of them spawning subprocesses) on a small runner.
33
+ *
34
+ * It exits 1 if it could not produce a usable trace (so a broken diagnostic
35
+ * is a red step, not a silent empty report); it asserts nothing about the
36
+ * numbers. A hook run that exceeds its bound is killed and reported, not thrown.
37
+ */
38
+
39
+ const crypto = require('node:crypto');
40
+ const fs = require('node:fs');
41
+ const os = require('node:os');
42
+ const path = require('node:path');
43
+ const { spawn, spawnSync } = require('node:child_process');
44
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
45
+ const {
46
+ stampPs4,
47
+ TRACE_PS4_FILE,
48
+ INJECTED_LINES,
49
+ injectTracing,
50
+ parseTimedXtrace,
51
+ shiftLines,
52
+ phaseCosts,
53
+ slowestLines,
54
+ rollupByRegion,
55
+ abbreviate,
56
+ } = require('./xtrace-phase-timing.cjs');
57
+
58
+ const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
59
+ const HOOK_NAME = 'gsd-validate-commit.sh';
60
+ const HOOK = path.join(HOOKS_DIR, HOOK_NAME);
61
+ const BASH = '/bin/bash';
62
+ const HOOK_BOUND_MS = 30000;
63
+ const PROBE_BOUND_MS = 10000;
64
+ const MAX_TRACE_BYTES = 256 * 1024 * 1024;
65
+ const MAX_LOAD = 64;
66
+ const UNTRACED_RUNS = 3;
67
+ const CALIBRATION_COMMANDS = 200;
68
+ const REGION_LINES = 50;
69
+ const LOAD_LIFETIME_MS = 5 * 60 * 1000;
70
+ // Mirrors the test: 112KB stays under Linux's MAX_ARG_STRLEN (131072).
71
+ const PAD_BYTES = 112 * 1024;
72
+ // Millisecond clock for bash 3.2, which has neither EPOCHREALTIME nor printf %(%s)T.
73
+ // perl, not node: it is stamped once per traced command, and node's ~40ms startup
74
+ // would dwarf the costs being measured.
75
+ // LC_ALL=C and 2>/dev/null: perl prints a locale warning to stderr on EVERY stamp
76
+ // when the inherited LANG/LC_* names a locale the machine lacks, and stderr is
77
+ // the trace stream, so each warning would corrupt the command it follows.
78
+ const STAMP_COMMAND = "LC_ALL=C perl -MTime::HiRes=time -e 'printf \"%d\", time*1000' 2>/dev/null";
79
+
80
+ const bodyOf = (kb) =>
81
+ Array.from({ length: Math.ceil((kb * 1024) / 63) }, () => 'x'.repeat(62)).join('\n');
82
+
83
+ /** Same command shapes as tests/hooks-opt-in.test.cjs (#4492 rows). */
84
+ function scenarios() {
85
+ const base = `git commit -m "$(cat${' '.repeat(PAD_BYTES)} <<'EOF'\nfeat: x\nEOF\n)"`;
86
+ return [
87
+ { name: 'padded control (no trailing option)', command: base },
88
+ { name: 'padded + trailing --cleanup=verbatim', command: `${base} --cleanup=verbatim` },
89
+ {
90
+ name: 'unpadded 112KB body (comparison)',
91
+ command: `git commit --allow-empty -m "$(cat <<'EOF'\nfeat: ${'x'.repeat(66)}\n${bodyOf(112)}\nEOF\n)"`,
92
+ },
93
+ ];
94
+ }
95
+
96
+ function parseArgs(argv) {
97
+ const opts = { load: 0, top: 15 };
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ if (arg !== '--load' && arg !== '--top') {
101
+ throw new ExitError(2, `diagnose-validate-commit-phases: unknown argument "${arg}"`);
102
+ }
103
+ const value = Number(argv[++i]);
104
+ if (!Number.isInteger(value) || value < 0) {
105
+ throw new ExitError(2, `diagnose-validate-commit-phases: ${arg} needs a non-negative integer`);
106
+ }
107
+ if (arg === '--load' && value > MAX_LOAD) {
108
+ throw new ExitError(2, `diagnose-validate-commit-phases: --load is capped at ${MAX_LOAD}`);
109
+ }
110
+ opts[arg === '--load' ? 'load' : 'top'] = value;
111
+ }
112
+ return opts;
113
+ }
114
+
115
+ function makeProject() {
116
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-5473-'));
117
+ fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
118
+ fs.writeFileSync(
119
+ path.join(dir, '.planning', 'config.json'),
120
+ JSON.stringify({ model_profile: 'balanced', hooks: { community: true } }),
121
+ );
122
+ return dir;
123
+ }
124
+
125
+ /**
126
+ * A copy of the hooks directory whose hook carries the tracing line, the PS4
127
+ * text beside it, and a no-op script for calibrating the stamp. Returns the
128
+ * paths of the traced hook and the no-op.
129
+ *
130
+ * The hook's lib/git-cmd.js requires `../../gsd-core/bin/lib/token-scanner.cjs`
131
+ * relative to itself, so the copy sits at `<project>/hooks` with `<project>/gsd-core`
132
+ * linked to the real one. Without that link the classifier fails to load and the
133
+ * hook FAILS OPEN: a traced run would take a different path than the real one
134
+ * (the parity test in tests/xtrace-phase-timing.test.cjs pins this).
135
+ */
136
+ function prepareTracedHooks(project, ps4) {
137
+ const dir = path.join(project, 'hooks');
138
+ fs.cpSync(HOOKS_DIR, dir, { recursive: true });
139
+ fs.symlinkSync(path.join(HOOKS_DIR, '..', 'gsd-core'), path.join(project, 'gsd-core'), 'dir');
140
+ const hook = path.join(dir, HOOK_NAME);
141
+ fs.writeFileSync(hook, injectTracing(fs.readFileSync(HOOK, 'utf8')));
142
+ fs.writeFileSync(path.join(dir, TRACE_PS4_FILE), ps4);
143
+ const noop = path.join(dir, 'noop.sh');
144
+ const colons = Array.from({ length: CALIBRATION_COMMANDS }, () => ':').join('\n');
145
+ fs.writeFileSync(noop, injectTracing(`#!/bin/bash\n${colons}\n`));
146
+ return { hook, noop };
147
+ }
148
+
149
+ function hookEnv() {
150
+ return {
151
+ ...process.env,
152
+ PATH: `${path.dirname(process.execPath)}:${process.env.PATH || '/usr/bin:/bin'}`,
153
+ };
154
+ }
155
+
156
+ /** bash with the options every call here shares; `options` overrides them. */
157
+ function spawnBash(args, options, bin = BASH) {
158
+ return spawnSync(bin, args, { encoding: 'utf8', killSignal: 'SIGKILL', timeout: PROBE_BOUND_MS, ...options });
159
+ }
160
+
161
+ /**
162
+ * Run the hook script at `script` on `command`. stdout is discarded and a
163
+ * traced run's stderr goes to a FILE, never a pipe: the hook's node classifier
164
+ * inherits both, and a grandchild that outlives the kill would otherwise hold
165
+ * the pipe open and hang this process past its bound (the real test avoids that
166
+ * with a process-group kill; spawnSync cannot).
167
+ */
168
+ function runHook(project, command, script, traced, env = hookEnv(), bin = BASH) {
169
+ const traceFile = path.join(project, 'trace.err');
170
+ const traceFd = traced ? fs.openSync(traceFile, 'w') : null;
171
+ const started = process.hrtime.bigint();
172
+ let r;
173
+ try {
174
+ r = spawnBash([script], {
175
+ cwd: project,
176
+ env,
177
+ input: JSON.stringify({ tool_input: { command } }),
178
+ stdio: ['pipe', 'ignore', traceFd === null ? 'ignore' : traceFd],
179
+ timeout: HOOK_BOUND_MS,
180
+ }, bin);
181
+ } finally {
182
+ if (traceFd !== null) fs.closeSync(traceFd);
183
+ }
184
+ const wallMs = Number((process.hrtime.bigint() - started) / 1000000n);
185
+ let trace = '';
186
+ let error = r.error ? r.error.code || r.error.message : null;
187
+ if (traced) {
188
+ if (fs.statSync(traceFile).size > MAX_TRACE_BYTES) error = error || 'trace exceeds the size cap';
189
+ else trace = fs.readFileSync(traceFile, 'utf8');
190
+ }
191
+ return { wallMs, status: r.status, signal: r.signal, error, trace };
192
+ }
193
+
194
+ /** Cost of one stamp: a run of no-op commands under the same PS4. */
195
+ function calibrateStampMs(noop, nonce) {
196
+ const r = spawnBash([noop], { env: hookEnv() });
197
+ const { totalMs } = phaseCosts(parseTimedXtrace(r.stderr || '', nonce));
198
+ return totalMs / CALIBRATION_COMMANDS;
199
+ }
200
+
201
+ function median(sorted) {
202
+ return sorted[Math.floor(sorted.length / 2)];
203
+ }
204
+
205
+ /** Processes that keep forking node, like a tier of subprocess-spawning test files. */
206
+ function startLoad(count) {
207
+ const churn =
208
+ "const {spawnSync}=require('node:child_process');" +
209
+ `const end=Date.now()+${LOAD_LIFETIME_MS};` +
210
+ "while(Date.now()<end){spawnSync(process.execPath,['-e','0'],{stdio:'ignore'});}";
211
+ return Array.from({ length: count }, () => spawn(process.execPath, ['-e', churn], { stdio: 'ignore' }));
212
+ }
213
+
214
+ function stopLoad(children) {
215
+ for (const child of children) child.kill('SIGKILL');
216
+ }
217
+
218
+ function bashVersionLine() {
219
+ return (spawnBash(['--version']).stdout || 'unknown').split('\n')[0];
220
+ }
221
+
222
+ /** Escape a value for a markdown table cell: backslash first, then the pipe. */
223
+ const cell = (s) => String(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
224
+
225
+ /** Where a cost row points: the hook line and its source, or the function. */
226
+ function whereCell(c, source) {
227
+ if (c.fn) return { line: `${c.fn}():${c.line}`, text: '(line may be function-relative)' };
228
+ return { line: String(c.line), text: abbreviate(source[c.line - 1] || '', 70) };
229
+ }
230
+
231
+ /** Run one scenario untraced (repeated) and traced; no formatting. */
232
+ function measureScenario(project, scenario, hooks, nonce, bin = BASH) {
233
+ const walls = [];
234
+ let last = null;
235
+ for (let i = 0; i < UNTRACED_RUNS; i++) {
236
+ last = runHook(project, scenario.command, HOOK, false, hookEnv(), bin);
237
+ walls.push(last.wallMs);
238
+ }
239
+ walls.sort((a, b) => a - b);
240
+ const traced = runHook(project, scenario.command, hooks.hook, true, hookEnv(), bin);
241
+ const entries = shiftLines(parseTimedXtrace(traced.trace, nonce), INJECTED_LINES);
242
+ const { costs, totalMs } = phaseCosts(entries);
243
+ return { scenario, walls, last, traced, entries: entries.length, costs, totalMs };
244
+ }
245
+
246
+ function renderScenario(m, source, opts) {
247
+ const { scenario, walls, last, traced } = m;
248
+ const out = [`## ${scenario.name}`, ''];
249
+ out.push(
250
+ `untraced wall ms (${UNTRACED_RUNS} runs): min ${walls[0]} / median ${median(walls)} / max ${walls[walls.length - 1]}` +
251
+ ` (last exit ${last.status}${last.signal ? `, ${last.signal}` : ''}${last.error ? `, ${last.error}` : ''}); ` +
252
+ `traced span ${m.totalMs} ms over ${m.entries} commands${traced.signal ? ` (${traced.signal})` : ''}${traced.error ? ` (${traced.error})` : ''}; ` +
253
+ `command ${scenario.command.length} bytes`,
254
+ '',
255
+ );
256
+ if (m.entries === 0) {
257
+ out.push('**TRACE EMPTY** — no stamped commands were parsed; the numbers below do not exist.', '');
258
+ return out;
259
+ }
260
+ out.push(`Elapsed per ${REGION_LINES}-line region of the hook (ms):`, '');
261
+ out.push('| region | ms |', '|---|---:|');
262
+ for (const r of rollupByRegion(m.costs, REGION_LINES)) out.push(`| ${r.label} | ${r.afterMs} |`);
263
+ out.push('');
264
+ out.push('Slowest lines. `after` = gap following the line (slow EXECUTION); `before` = gap preceding it (slow EXPANSION):', '');
265
+ out.push('| hook line | after ms | before ms | runs | source | traced value |');
266
+ out.push('|---:|---:|---:|---:|---|---|');
267
+ for (const c of slowestLines(m.costs, opts.top)) {
268
+ const where = whereCell(c, source);
269
+ out.push(`| ${cell(where.line)} | ${c.afterMs} | ${c.beforeMs} | ${c.count} | \`${cell(where.text)}\` | \`${cell(abbreviate(c.sample, 60))}\` |`);
270
+ }
271
+ out.push('');
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * The full report. `scenarioList` is injectable so the tests can drive the
277
+ * whole pipeline on a cheap command; the CLI uses the three #4492 rows.
278
+ *
279
+ * @returns {{ text: string, ok: boolean }} ok is false if any scenario's trace was empty.
280
+ */
281
+ function report(opts, scenarioList = scenarios()) {
282
+ const project = makeProject();
283
+ const source = fs.readFileSync(HOOK, 'utf8').split('\n');
284
+ const nonce = crypto.randomBytes(6).toString('hex');
285
+ const hooks = prepareTracedHooks(project, stampPs4(STAMP_COMMAND, nonce));
286
+ const versionLine = bashVersionLine();
287
+ const out = ['# validate-commit phase timing (#5473)', ''];
288
+ out.push(`- shell: \`${BASH}\` (${versionLine})`);
289
+ if (!/version 3\.2/.test(versionLine)) {
290
+ out.push('- **WARNING: this is not bash 3.2, the shell the macOS failure occurs under; numbers do not reproduce it.**');
291
+ }
292
+ out.push(`- platform: ${process.platform}/${process.arch}, ${os.cpus().length} cpus, node ${process.version}`);
293
+ out.push(`- background load: ${opts.load} process(es) forking node`);
294
+ out.push(`- stamp overhead: ${calibrateStampMs(hooks.noop, nonce).toFixed(1)} ms per traced command (measured over ${CALIBRATION_COMMANDS} no-ops)`);
295
+ out.push('- traced numbers rank lines; they are not comparable to untraced wall time (every command pays a stamp and xtrace prints each 112KB expansion)', '');
296
+ const load = startLoad(opts.load);
297
+ const measured = [];
298
+ try {
299
+ for (const scenario of scenarioList) measured.push(measureScenario(project, scenario, hooks, nonce));
300
+ } finally {
301
+ stopLoad(load);
302
+ fs.rmSync(project, { recursive: true, force: true });
303
+ }
304
+ if (measured.length >= 2) {
305
+ const first = measured[0];
306
+ const last = measured[measured.length - 1];
307
+ const ratio = median(last.walls) > 0 ? (median(first.walls) / median(last.walls)).toFixed(1) : 'n/a';
308
+ out.push(`- untraced median: "${first.scenario.name}" ${median(first.walls)} ms vs "${last.scenario.name}" ${median(last.walls)} ms (ratio ${ratio}x)`, '');
309
+ }
310
+ for (const m of measured) out.push(...renderScenario(m, source, opts));
311
+ return { text: out.join('\n'), ok: measured.every((m) => m.entries > 0) };
312
+ }
313
+
314
+ function run(argv = process.argv.slice(2)) {
315
+ if (process.platform === 'win32') {
316
+ throw new ExitError(2, 'diagnose-validate-commit-phases: needs a unix /bin/bash (macOS or Linux)');
317
+ }
318
+ if (!fs.existsSync(BASH)) {
319
+ throw new ExitError(2, `diagnose-validate-commit-phases: ${BASH} not found`);
320
+ }
321
+ const { text, ok } = report(parseArgs(argv));
322
+ process.stdout.write(`${text}\n`);
323
+ const summary = process.env.GITHUB_STEP_SUMMARY;
324
+ if (summary) fs.appendFileSync(summary, `${text}\n`);
325
+ return ok ? 0 : 1;
326
+ }
327
+
328
+ module.exports = {
329
+ scenarios,
330
+ parseArgs,
331
+ whereCell,
332
+ renderScenario,
333
+ report,
334
+ prepareTracedHooks,
335
+ makeProject,
336
+ hookEnv,
337
+ runHook,
338
+ median,
339
+ measureScenario,
340
+ MAX_LOAD,
341
+ STAMP_COMMAND,
342
+ HOOKS_DIR,
343
+ HOOK,
344
+ HOOK_NAME,
345
+ HOOK_BOUND_MS,
346
+ };
347
+
348
+ if (require.main === module) runMain(run);