@opengsd/gsd-core 1.17.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 (176) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +1 -1
  4. package/agents/gsd-advisor-researcher.md +1 -1
  5. package/agents/gsd-assumptions-analyzer.compact.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +1 -1
  7. package/agents/gsd-code-fixer.compact.md +1 -1
  8. package/agents/gsd-code-fixer.md +1 -1
  9. package/agents/gsd-code-reviewer.compact.md +1 -1
  10. package/agents/gsd-code-reviewer.md +1 -1
  11. package/agents/gsd-codebase-mapper.compact.md +1 -1
  12. package/agents/gsd-codebase-mapper.md +1 -1
  13. package/agents/gsd-debugger.md +1 -1
  14. package/agents/gsd-doc-verifier.compact.md +4 -4
  15. package/agents/gsd-doc-verifier.md +4 -3
  16. package/agents/gsd-doc-writer.compact.md +23 -5
  17. package/agents/gsd-doc-writer.md +19 -6
  18. package/agents/gsd-eval-auditor.compact.md +1 -1
  19. package/agents/gsd-eval-auditor.md +1 -1
  20. package/agents/gsd-executor.md +7 -3
  21. package/agents/gsd-integration-checker.compact.md +1 -1
  22. package/agents/gsd-integration-checker.md +1 -1
  23. package/agents/gsd-nyquist-auditor.compact.md +1 -1
  24. package/agents/gsd-nyquist-auditor.md +1 -1
  25. package/agents/gsd-phase-researcher.md +1 -1
  26. package/agents/gsd-plan-checker.md +2 -1
  27. package/agents/gsd-planner.md +5 -4
  28. package/agents/gsd-project-researcher.compact.md +1 -1
  29. package/agents/gsd-project-researcher.md +1 -1
  30. package/agents/gsd-research-synthesizer.compact.md +5 -5
  31. package/agents/gsd-research-synthesizer.md +5 -5
  32. package/agents/gsd-roadmapper.compact.md +1 -1
  33. package/agents/gsd-roadmapper.md +1 -1
  34. package/agents/gsd-security-auditor.compact.md +1 -1
  35. package/agents/gsd-security-auditor.md +1 -1
  36. package/agents/gsd-ui-auditor.compact.md +1 -1
  37. package/agents/gsd-ui-auditor.md +1 -1
  38. package/agents/gsd-ui-checker.compact.md +1 -1
  39. package/agents/gsd-ui-checker.md +1 -1
  40. package/agents/gsd-ui-researcher.compact.md +13 -1
  41. package/agents/gsd-ui-researcher.md +28 -1
  42. package/agents/gsd-verifier.md +1 -1
  43. package/bin/install.js +38 -2
  44. package/commands/gsd/code-review.md +8 -0
  45. package/commands/gsd/pause-work.md +1 -1
  46. package/gsd-core/bin/gsd-tools.cjs +18 -11
  47. package/gsd-core/bin/lib/capability-ledger.cjs +25 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +29 -7
  49. package/gsd-core/bin/lib/capability-registry.cjs +70 -64
  50. package/gsd-core/bin/lib/capability-source.cjs +3 -1
  51. package/gsd-core/bin/lib/capability-state.cjs +6 -3
  52. package/gsd-core/bin/lib/capability-validator.cjs +6 -4
  53. package/gsd-core/bin/lib/commands.cjs +5 -1
  54. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  55. package/gsd-core/bin/lib/decision-coverage-support.cjs +19 -0
  56. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +15 -0
  57. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +13 -0
  58. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +23 -3
  59. package/gsd-core/bin/lib/gate-predicate.cjs +8 -12
  60. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +60 -5
  61. package/gsd-core/bin/lib/init.cjs +11 -4
  62. package/gsd-core/bin/lib/install-effort-resolver.cjs +2 -2
  63. package/gsd-core/bin/lib/install-model-override-resolver.cjs +4 -3
  64. package/gsd-core/bin/lib/install-profiles.cjs +5 -3
  65. package/gsd-core/bin/lib/phase.cjs +120 -11
  66. package/gsd-core/bin/lib/report-parser.cjs +57 -1
  67. package/gsd-core/bin/lib/review-lane-invocation.cjs +40 -0
  68. package/gsd-core/bin/lib/review-reviewer-selection.cjs +20 -1
  69. package/gsd-core/bin/lib/roadmap.cjs +56 -5
  70. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +47 -2
  71. package/gsd-core/bin/lib/runtime-homes.cjs +18 -0
  72. package/gsd-core/bin/lib/shell-command-projection.cjs +2 -0
  73. package/gsd-core/bin/lib/smart-entry.cjs +24 -16
  74. package/gsd-core/bin/lib/state-document.cjs +37 -3
  75. package/gsd-core/bin/lib/state-transition.cjs +31 -10
  76. package/gsd-core/bin/lib/state.cjs +90 -60
  77. package/gsd-core/bin/lib/task-command-router.cjs +2 -3
  78. package/gsd-core/bin/lib/tdd-red-evidence.cjs +5 -0
  79. package/gsd-core/bin/lib/tdd-test-files.cjs +42 -0
  80. package/gsd-core/bin/lib/verify.cjs +36 -1
  81. package/gsd-core/references/agent-contracts.md +1 -1
  82. package/gsd-core/references/agent-skills-bootstrap.md +26 -7
  83. package/gsd-core/references/git-integration.md +3 -3
  84. package/gsd-core/references/planner-reversibility.md +18 -4
  85. package/gsd-core/references/planner-verify-command-grounding.md +18 -0
  86. package/gsd-core/references/tdd.md +4 -3
  87. package/gsd-core/references/verifier-phase-gates.md +8 -1
  88. package/gsd-core/workflows/add-backlog.md +10 -2
  89. package/gsd-core/workflows/add-phase.md +5 -1
  90. package/gsd-core/workflows/add-tests.md +5 -1
  91. package/gsd-core/workflows/add-todo.md +5 -1
  92. package/gsd-core/workflows/ai-integration-phase.md +5 -1
  93. package/gsd-core/workflows/audit-milestone.md +21 -9
  94. package/gsd-core/workflows/autonomous/templates/minimal-context.md +41 -0
  95. package/gsd-core/workflows/autonomous.md +52 -64
  96. package/gsd-core/workflows/check-todos.md +5 -1
  97. package/gsd-core/workflows/code-review-fix.md +5 -1
  98. package/gsd-core/workflows/code-review.md +5 -1
  99. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +5 -1
  100. package/gsd-core/workflows/complete-milestone.md +25 -5
  101. package/gsd-core/workflows/debug.md +5 -1
  102. package/gsd-core/workflows/diagnose-issues.md +5 -1
  103. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -1
  104. package/gsd-core/workflows/discuss-phase-assumptions.md +11 -3
  105. package/gsd-core/workflows/discuss-phase.md +5 -1
  106. package/gsd-core/workflows/docs-update/detail/elaboration.md +7 -6
  107. package/gsd-core/workflows/docs-update.md +27 -5
  108. package/gsd-core/workflows/edit-phase.md +5 -1
  109. package/gsd-core/workflows/eval-review.md +11 -3
  110. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +5 -1
  111. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +12 -6
  112. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +19 -2
  113. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +18 -1
  114. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +5 -1
  115. package/gsd-core/workflows/execute-phase.md +37 -6
  116. package/gsd-core/workflows/execute-plan.md +8 -4
  117. package/gsd-core/workflows/extract-learnings.md +6 -2
  118. package/gsd-core/workflows/forensics.md +5 -1
  119. package/gsd-core/workflows/import.md +5 -1
  120. package/gsd-core/workflows/insert-phase.md +5 -1
  121. package/gsd-core/workflows/manager.md +10 -2
  122. package/gsd-core/workflows/map-codebase.md +5 -1
  123. package/gsd-core/workflows/milestone-summary.md +10 -2
  124. package/gsd-core/workflows/new-milestone.md +13 -5
  125. package/gsd-core/workflows/new-project.md +9 -5
  126. package/gsd-core/workflows/next.md +8 -4
  127. package/gsd-core/workflows/pause-work.md +3 -3
  128. package/gsd-core/workflows/plan-phase/steps/auto-advance-check.md +70 -0
  129. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +5 -1
  130. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +6 -2
  131. package/gsd-core/workflows/plan-phase.md +41 -73
  132. package/gsd-core/workflows/plan-review-convergence.md +17 -5
  133. package/gsd-core/workflows/plant-seed.md +8 -4
  134. package/gsd-core/workflows/progress.md +6 -2
  135. package/gsd-core/workflows/quick-batch.md +5 -1
  136. package/gsd-core/workflows/quick.md +22 -7
  137. package/gsd-core/workflows/remove-phase.md +5 -1
  138. package/gsd-core/workflows/resume-project.md +5 -1
  139. package/gsd-core/workflows/review.md +6 -2
  140. package/gsd-core/workflows/scan.md +5 -1
  141. package/gsd-core/workflows/secure-phase.md +6 -2
  142. package/gsd-core/workflows/settings.md +3 -3
  143. package/gsd-core/workflows/ship.md +13 -10
  144. package/gsd-core/workflows/thread.md +10 -2
  145. package/gsd-core/workflows/transition.md +5 -1
  146. package/gsd-core/workflows/ui-phase.md +5 -1
  147. package/gsd-core/workflows/ui-review.md +6 -2
  148. package/gsd-core/workflows/ultraplan-phase.md +5 -1
  149. package/gsd-core/workflows/update.md +5 -1
  150. package/gsd-core/workflows/validate-phase.md +6 -2
  151. package/gsd-core/workflows/verify-work/detail/elaboration.md +1 -1
  152. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -1
  153. package/gsd-core/workflows/verify-work.md +11 -3
  154. package/hooks/dist/gsd-agent-isolation-guard.js +3 -1
  155. package/hooks/dist/gsd-cursor-subagent-start.js +2 -1
  156. package/hooks/dist/gsd-secret-read-guard.js +72 -14
  157. package/hooks/dist/gsd-validate-commit.sh +27 -5
  158. package/hooks/gsd-agent-isolation-guard.js +3 -1
  159. package/hooks/gsd-cursor-subagent-start.js +2 -1
  160. package/hooks/gsd-secret-read-guard.js +72 -14
  161. package/hooks/gsd-validate-commit.sh +27 -5
  162. package/package.json +1 -1
  163. package/scripts/audit-workflow-script-paths.cjs +1 -1
  164. package/scripts/diagnose-validate-commit-in-tier.cjs +270 -0
  165. package/scripts/diagnose-validate-commit-phases.cjs +348 -0
  166. package/scripts/diagnose-windows-spawn-watcher.cjs +317 -0
  167. package/scripts/docs-guard-registry.cjs +2 -0
  168. package/scripts/lib/macos-conformance-tier.generated.cjs +9 -0
  169. package/scripts/lib/platform-conformance-tier.generated.cjs +16 -0
  170. package/scripts/lint-phase-arg-assignment.cjs +2 -2
  171. package/scripts/lint-workflow-shellcheck-baseline.json +16 -16
  172. package/scripts/release-tarball-smoke.cjs +1 -0
  173. package/scripts/xtrace-phase-timing.cjs +205 -0
  174. package/skills/gsd-code-review/SKILL.md +8 -0
  175. package/skills/gsd-pause-work/SKILL.md +1 -1
  176. package/vscode/package.json +1 -1
@@ -39,6 +39,27 @@ cleanup_temp_files() {
39
39
  }
40
40
  trap cleanup_temp_files EXIT
41
41
 
42
+ # The first line of $1 (everything before the first newline), matched BYTE-wise.
43
+ #
44
+ # `${1%%$'\n'*}` is a longest-suffix glob match, and on bash 3.2 (macOS's
45
+ # /bin/bash) in a multibyte locale such as en_US.UTF-8 it costs ~3.5 s on a
46
+ # 112KB value (#5473, measured inside the macOS conformance job: the same hook
47
+ # on the same 114750-byte command took ~5.6 s under LC_ALL=en_US.UTF-8 and
48
+ # ~0.55 s under LC_ALL=C, and a trace put 3.5 s of it on this expansion). A
49
+ # newline is the same single byte in every locale, so matching it byte-wise
50
+ # changes no result.
51
+ #
52
+ # ONLY call this inside a command substitution, `SUBJECT=$(gsd_first_line "$MSG")`:
53
+ # the substitution's subshell is what keeps LC_ALL=C from leaking into the rest
54
+ # of the hook, whose regexes and length arithmetic keep the caller's locale. A
55
+ # substitution is a fork with no pipe between two processes, so it keeps the
56
+ # property the pure-expansion form was chosen for (no `head -1` SIGPIPE race
57
+ # under `set -euo pipefail`, #4429); printf is a builtin, so no exec either.
58
+ gsd_first_line() {
59
+ LC_ALL=C
60
+ printf '%s' "${1%%$'\n'*}"
61
+ }
62
+
42
63
  # The 10 built-in Conventional Commits types — the SINGLE declaration (#3811
43
64
  # review finding: this was previously hand-typed a second time inside the
44
65
  # node -e script below, a generative-fix-divergence risk per CLAUDE.md's
@@ -575,18 +596,19 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
575
596
  SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
576
597
  const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
577
598
  process.stdout.write(resolveCommitSubject(process.env.MSG));
578
- " 2>/dev/null) || SUBJECT="${MSG%%$'\n'*}"
599
+ " 2>/dev/null) || SUBJECT=$(gsd_first_line "$MSG")
579
600
  else
580
- # Pure parameter expansion, not `echo "$MSG" | head -1`: that pipeline
601
+ # Pure parameter expansion (in gsd_first_line), not
602
+ # `echo "$MSG" | head -1`: that pipeline
581
603
  # raced a SIGPIPE under `set -euo pipefail` whenever $MSG had a body
582
604
  # (the common case) — `head -1` can close its read end as soon as it
583
605
  # has the first line, and if `echo`'s write lands after that close,
584
606
  # `echo` dies with signal 13 (exit 141), which is NOT suppressed by
585
607
  # `set -e` and aborted the whole hook intermittently (observed in
586
- # tests/hooks-opt-in.test.cjs's --fixup=HEAD "round 7" case). Zero
587
- # subprocesses here means zero pipe/race surface. Equivalent to
608
+ # tests/hooks-opt-in.test.cjs's --fixup=HEAD "round 7" case). No pipe
609
+ # means no race surface. Equivalent to
588
610
  # `head -1` for single-line, multi-line, and trailing-newline input.
589
- SUBJECT="${MSG%%$'\n'*}"
611
+ SUBJECT=$(gsd_first_line "$MSG")
590
612
  fi
591
613
  # Single source of truth for the accepted commit-type list (#3811): the
592
614
  # 10 built-ins plus whatever passed the safe-token filter above. Both the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -24,7 +24,7 @@ const AUDIT_FINDING = Object.freeze({
24
24
  // Match `${GSD_HOME}` or `${GSD_HOME:-...}` followed by a /-rooted path
25
25
  // ending in .cjs/.js/.sh. The path is captured verbatim (relative to
26
26
  // the install root).
27
- const REF_RE = /\$\{GSD_HOME(?::-[^}]*)?\}\/([A-Za-z0-9_./-]+\.(?:cjs|js|sh))/g;
27
+ const REF_RE = /\$\{GSD_HOME(?::-[^}]*)?\}\/([A-Za-z0-9_./-]+\.(?:cjs|js|sh)(?![A-Za-z0-9_]))/g;
28
28
 
29
29
  function listWorkflowFiles(dir) {
30
30
  if (!fs.existsSync(dir)) return [];
@@ -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
+ };