@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
@@ -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);
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * #5473 — a job-long watcher for the Windows conformance tier's bounded-child stalls.
5
+ *
6
+ * In one Windows run (job 39083981500) three different tests each hit their
7
+ * bound EXACTLY: the #4784 `sh -c 'printf | sed | head -1'` at 10 s, an
8
+ * `echo | grep | awk` bash pipeline at 5 s, and `npm ls` through cmd.exe at 30 s.
9
+ * Measured inside the tier, a bare `bash` starts in ~35 ms, Defender real-time
10
+ * protection is off, and 2,407 pipeline calls under 6-way concurrency never
11
+ * stalled past 2 s. So the stall is rare and does not reproduce on demand. This
12
+ * watcher runs beside the whole test step on the same VM and writes down, with a
13
+ * UTC wall-clock timestamp, every moment the VM itself stops making progress:
14
+ *
15
+ * - `loop-gap`: this process's own timer fired more than LOOP_GAP_MS late
16
+ * (the whole VM, or at least this process, was not scheduled);
17
+ * - `probe-slow`: a probe child (the #4784 sh pipeline, or `cmd /c exit`)
18
+ * took more than PROBE_SLOW_MS, or hit its bound;
19
+ * - `procs`: a snapshot of the live node/sh/bash/cmd/npm/git processes (name,
20
+ * parent, age, CPU time so far, and the *.test.cjs names in their
21
+ * arguments), taken after either anomaly and every two minutes, so a stall
22
+ * can be lined up with what else was running in the chunk. Earlier red
23
+ * `next`s (see defaultMaxUnmeasuredPerChunk in scripts/run-tests.cjs) lost
24
+ * whole chunks at 600000 ms with zero failing tests; the periodic snapshots
25
+ * show which test file a killed chunk was inside and whether it was idle.
26
+ *
27
+ * Timestamps are ISO UTC, the same clock as the lines in the job log, so a
28
+ * failing test's end time minus its duration can be lined up against these
29
+ * events: a stall that is the VM freezing shows up here at the same instant; a
30
+ * stall that is only that one child hanging does not.
31
+ *
32
+ * node scripts/diagnose-windows-spawn-watcher.cjs start [dir] spawn detached, return at once
33
+ * node scripts/diagnose-windows-spawn-watcher.cjs report [dir] stop it, print and summarize the events
34
+ *
35
+ * `dir` defaults to RUNNER_TEMP/spawn-watcher. No environment variable or value
36
+ * is read into the report. TEMPORARY: delete
37
+ * with scripts/diagnose-validate-commit-*.cjs once #5473 is resolved.
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const { spawn } = require('node:child_process');
43
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
44
+
45
+ const TICK_MS = 250;
46
+ const LOOP_GAP_MS = 750;
47
+ // The watcher must not become the load it is looking for: a stall that shows up once in
48
+ // thousands of calls is easy to shift with extra spawn churn, so probes are sparse (one
49
+ // sh and one cmd every 5 s, about 480 pairs over a 40 minute job) and the CIM query,
50
+ // the heavy part, runs every 5 minutes plus at most once a minute after an anomaly.
51
+ const PROBE_EVERY_MS = 5000;
52
+ const PROBE_BOUND_MS = 15_000;
53
+ const PROBE_SLOW_MS = 1500;
54
+ const MAX_LIFETIME_MS = 40 * 60_000;
55
+ const PIPELINE = "printf '%s\\n' x | sed -n 's/x/y/p' | head -1";
56
+
57
+ const REPORT_WAIT_MS = 20_000;
58
+ const EVENTS_FILE = 'events.log';
59
+ const STOP_FILE = 'stop';
60
+ const SUMMARY_FILE = 'summary.json';
61
+
62
+ /** One event line: `<ISO UTC> <kind> <ms> <detail>`. */
63
+ function formatEvent(at, kind, ms, detail) {
64
+ return `${new Date(at).toISOString()} ${kind} ${ms} ${detail}`;
65
+ }
66
+
67
+ const SHELL_NAMES = new Set(['sh', 'bash', 'cmd']);
68
+ const LINGER_AGE_S = 30;
69
+
70
+ /**
71
+ * One compact line for a process snapshot. `rows` are `{pid, ppid, name, born, cpuMs, cmd}`
72
+ * (born = epoch ms, cmd = the command line). Only what identifies the work is kept: the
73
+ * process name, its age, its CPU time so far, and the `*.test.cjs` file names in its
74
+ * arguments (the node:test chunk children carry them). Nothing else from a command line
75
+ * is reported, because the job environment carries tokens and a report is public.
76
+ */
77
+ function formatProcs(rows, now) {
78
+ return rows
79
+ .map((r) => {
80
+ const tests = [...new Set(String(r.cmd || '').match(/[\w.-]+\.test\.cjs/g) || [])].slice(0, 3);
81
+ const age = Math.max(0, Math.round((now - r.born) / 1000));
82
+ const cpu = Math.round(r.cpuMs / 1000);
83
+ // A shell that outlives every bound the tests set is the signature of a descendant
84
+ // still holding a spawnSync stdio pipe: node's timer then reports ETIMEDOUT at the
85
+ // exact bound although the direct child already exited (node src/spawn_sync.cc).
86
+ const lingering = SHELL_NAMES.has(r.name) && age > LINGER_AGE_S ? ' LINGERING' : '';
87
+ return `${r.name}#${r.pid}<${r.ppid} age ${age}s cpu ${cpu}s${lingering}${tests.length ? ` [${tests.join(' ')}]` : ''}`;
88
+ })
89
+ .join(' | ');
90
+ }
91
+
92
+ /** Inverse of formatEvent; null for a line that is not an event. */
93
+ function parseEvent(line) {
94
+ const m = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z) (loop-gap|probe-slow|probe-error|procs) (\d+) (.*)$/.exec(line);
95
+ if (!m) return null;
96
+ return { at: m[1], kind: m[2], ms: Number(m[3]), detail: m[4] };
97
+ }
98
+
99
+ /** Counts and extremes per kind, over parsed events. */
100
+ function summarizeEvents(events) {
101
+ const out = {};
102
+ for (const e of events) {
103
+ const s = out[e.kind] || (out[e.kind] = { count: 0, maxMs: 0 });
104
+ s.count += 1;
105
+ if (e.ms > s.maxMs) s.maxMs = e.ms;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Percentile (0..1) of numbers, nearest-rank on a sorted copy; null when empty. */
111
+ function percentile(values, p) {
112
+ if (values.length === 0) return null;
113
+ const sorted = [...values].sort((a, b) => a - b);
114
+ return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
115
+ }
116
+
117
+ /**
118
+ * Run one probe child. `onDone(label, ms, timedOut, started, error)`: `error` is the spawn
119
+ * error code (or the thrown message) when the probe never ran, else null. A probe that
120
+ * never ran must not look like a fast success, so callers keep it out of the latencies.
121
+ */
122
+ function probe(label, bin, args, onDone) {
123
+ const started = Date.now();
124
+ let timedOut = false;
125
+ let settled = false;
126
+ let timer = null;
127
+ const finish = (error) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ clearTimeout(timer);
131
+ onDone(label, Date.now() - started, timedOut, started, error || null);
132
+ };
133
+ let child;
134
+ try {
135
+ child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
136
+ } catch (e) {
137
+ finish(`threw ${e && e.code ? e.code : 'error'}`);
138
+ return;
139
+ }
140
+ timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch { /* gone */ } finish(); }, PROBE_BOUND_MS);
141
+ child.stdout.resume();
142
+ child.stderr.resume();
143
+ child.on('error', (e) => finish(`spawn ${e && e.code ? e.code : 'error'}`));
144
+ child.on('close', () => finish());
145
+ }
146
+
147
+ const PS_PROCS = "Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|sh|bash|cmd|npm|git)\\.exe$' } | ForEach-Object { [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name -replace '\\.exe$', ''; born = [int64](([datetime]$_.CreationDate).ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds; cpuMs = [int64](($_.KernelModeTime + $_.UserModeTime) / 10000); cmd = $_.CommandLine } } | ConvertTo-Json -Compress";
148
+ const SNAPSHOT_EVERY_MS = 300_000;
149
+ const SNAPSHOT_COOLDOWN_MS = 60_000;
150
+ const SNAPSHOT_BOUND_MS = 30_000;
151
+
152
+ /** Snapshot the live node/sh/bash/cmd/npm/git processes; calls back with the compact line. */
153
+ function snapshotProcs(onDone) {
154
+ const started = Date.now();
155
+ let out = '';
156
+ let err = '';
157
+ let child;
158
+ try {
159
+ child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', PS_PROCS], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
160
+ } catch (e) {
161
+ onDone(Date.now() - started, `snapshot unavailable (threw ${e && e.code ? e.code : 'error'})`);
162
+ return;
163
+ }
164
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, SNAPSHOT_BOUND_MS);
165
+ child.stdout.on('data', (d) => { out += d; });
166
+ // PowerShell's own error text carries no command lines; a short head tells a failure from a timeout.
167
+ child.stderr.on('data', (d) => { if (err.length < 200) err += d; });
168
+ child.on('error', (e) => { clearTimeout(timer); onDone(Date.now() - started, `snapshot unavailable (spawn ${e && e.code ? e.code : 'error'})`); });
169
+ child.on('close', (code, signal) => {
170
+ clearTimeout(timer);
171
+ let rows = [];
172
+ try { const parsed = JSON.parse(out); rows = Array.isArray(parsed) ? parsed : [parsed]; } catch { /* no rows */ }
173
+ // The watcher's own process and its probe/snapshot children are not what we are lining up against.
174
+ rows = rows.filter((r) => r.pid !== process.pid && r.ppid !== process.pid);
175
+ const why = signal ? `killed ${signal}` : `exit ${code}`;
176
+ onDone(
177
+ Date.now() - started,
178
+ rows.length ? `${rows.length} procs: ${formatProcs(rows, Date.now())}` : `snapshot unavailable (${why}${err ? `: ${err.replace(/\s+/g, ' ').trim().slice(0, 200)}` : ''})`,
179
+ );
180
+ });
181
+ }
182
+
183
+ function run(dir) {
184
+ const eventsPath = path.join(dir, EVENTS_FILE);
185
+ const record = (at, kind, ms, detail) => {
186
+ try { fs.appendFileSync(eventsPath, `${formatEvent(at, kind, ms, detail)}\n`); } catch { /* dir gone: nothing to do */ }
187
+ };
188
+ let lastSnapshotAt = 0;
189
+ let snapshotting = false;
190
+ const takeSnapshot = (why) => {
191
+ const now = Date.now();
192
+ if (snapshotting || now - lastSnapshotAt < SNAPSHOT_COOLDOWN_MS) return;
193
+ snapshotting = true;
194
+ lastSnapshotAt = now;
195
+ snapshotProcs((ms, detail) => { snapshotting = false; record(Date.now(), 'procs', ms, `${why}: ${detail}`); });
196
+ };
197
+ const snapshotTimer = setInterval(() => takeSnapshot('periodic'), SNAPSHOT_EVERY_MS);
198
+ const lifetimeEnd = Date.now() + MAX_LIFETIME_MS;
199
+ const probeMs = { sh: [], cmd: [] };
200
+ let probeCount = 0;
201
+ let last = Date.now();
202
+
203
+ const tick = setInterval(() => {
204
+ const now = Date.now();
205
+ const late = now - last - TICK_MS;
206
+ if (late > LOOP_GAP_MS) { record(now, 'loop-gap', late, `timer fired ${late}ms late`); takeSnapshot('after loop-gap'); }
207
+ last = now;
208
+ if (now > lifetimeEnd || fs.existsSync(path.join(dir, STOP_FILE))) finishRun();
209
+ }, TICK_MS);
210
+
211
+ const probeErrors = { sh: 0, cmd: 0 };
212
+ const onProbe = (label, ms, timedOut, started, error) => {
213
+ if (error) {
214
+ probeErrors[label] += 1;
215
+ record(Date.now(), 'probe-error', ms, `${label} never ran: ${error}`);
216
+ return;
217
+ }
218
+ probeCount += 1;
219
+ probeMs[label].push(ms);
220
+ if (ms > PROBE_SLOW_MS || timedOut) {
221
+ record(Date.now(), 'probe-slow', ms, `${label} started ${new Date(started).toISOString()}${timedOut ? ' hit the bound' : ''}`);
222
+ takeSnapshot('after probe-slow');
223
+ }
224
+ };
225
+
226
+ let probing = true;
227
+ let loopTimer = null;
228
+ // Run the two probes one after the other, then wait out the rest of the interval.
229
+ const loop = () => {
230
+ if (!probing) return;
231
+ const cycleStart = Date.now();
232
+ probe('sh', 'sh', ['-c', PIPELINE], (l, ms, to, st, er) => {
233
+ onProbe(l, ms, to, st, er);
234
+ probe('cmd', 'cmd', ['/c', 'exit', '0'], (l2, ms2, to2, st2, er2) => {
235
+ onProbe(l2, ms2, to2, st2, er2);
236
+ loopTimer = setTimeout(loop, Math.max(0, PROBE_EVERY_MS - (Date.now() - cycleStart)));
237
+ });
238
+ });
239
+ };
240
+ loop();
241
+
242
+ function finishRun() {
243
+ probing = false;
244
+ clearInterval(tick);
245
+ clearInterval(snapshotTimer);
246
+ clearTimeout(loopTimer);
247
+ const summary = {
248
+ probes: probeCount,
249
+ sh: { n: probeMs.sh.length, errors: probeErrors.sh, median: percentile(probeMs.sh, 0.5), p99: percentile(probeMs.sh, 0.99), max: percentile(probeMs.sh, 1) },
250
+ cmd: { n: probeMs.cmd.length, errors: probeErrors.cmd, median: percentile(probeMs.cmd, 0.5), p99: percentile(probeMs.cmd, 0.99), max: percentile(probeMs.cmd, 1) },
251
+ };
252
+ // Write then rename, so report never sees the file before its content has landed.
253
+ try {
254
+ const target = path.join(dir, SUMMARY_FILE);
255
+ fs.writeFileSync(`${target}.tmp`, JSON.stringify(summary));
256
+ fs.renameSync(`${target}.tmp`, target);
257
+ } catch { /* best effort */ }
258
+ // No process.exit (ADR-3889): every timer is cleared above, so the process ends when the
259
+ // probe or snapshot child still in flight (each bounded) has exited; report has already
260
+ // been released by the summary file, so nothing waits on this.
261
+ }
262
+ }
263
+
264
+ function start(dir) {
265
+ fs.mkdirSync(dir, { recursive: true });
266
+ const child = spawn(process.execPath, [__filename, 'run', dir], { detached: true, stdio: 'ignore', windowsHide: true });
267
+ child.unref();
268
+ }
269
+
270
+ function report(dir) {
271
+ try { fs.writeFileSync(path.join(dir, STOP_FILE), ''); } catch { /* start never ran */ }
272
+ // The stop file is only seen on the watcher's 250ms tick, so a VM stall longer than this
273
+ // wait (the very case under study) leaves no summary: say so below instead of "crashed".
274
+ const deadline = Date.now() + REPORT_WAIT_MS;
275
+ while (!fs.existsSync(path.join(dir, SUMMARY_FILE)) && Date.now() < deadline) {
276
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
277
+ }
278
+ let events = [];
279
+ try {
280
+ events = fs.readFileSync(path.join(dir, EVENTS_FILE), 'utf8').split('\n').map(parseEvent).filter(Boolean);
281
+ } catch { /* no events file: nothing recorded */ }
282
+ let summary = null;
283
+ try { summary = JSON.parse(fs.readFileSync(path.join(dir, SUMMARY_FILE), 'utf8')); } catch { /* watcher did not finish */ }
284
+ const lines = [
285
+ '### #5473 Windows spawn-stall watcher (whole test step, same VM)',
286
+ '',
287
+ summary
288
+ ? `probes: ${summary.probes} ; sh pipeline median ${summary.sh.median} / p99 ${summary.sh.p99} / max ${summary.sh.max} ms (n=${summary.sh.n}, never ran ${summary.sh.errors}) ; cmd /c exit median ${summary.cmd.median} / p99 ${summary.cmd.p99} / max ${summary.cmd.max} ms (n=${summary.cmd.n}, never ran ${summary.cmd.errors})`
289
+ : `watcher summary missing after ${REPORT_WAIT_MS / 1000}s: it either crashed or was stalled past the wait (the events below show which: a stall leaves loop-gap/probe-slow lines up to the last tick)`,
290
+ `events: ${JSON.stringify(summarizeEvents(events))}`,
291
+ '',
292
+ ...events.map((e) => `- ${e.at} ${e.kind} ${e.ms}ms ${e.detail}`),
293
+ ];
294
+ const text = lines.join('\n');
295
+ process.stdout.write(`${text}\n`);
296
+ if (process.env.GITHUB_STEP_SUMMARY) {
297
+ try { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`); } catch { /* best effort */ }
298
+ }
299
+ }
300
+
301
+ // CONTRIBUTING forbids `${{ }}` inside a workflow `run:` block, so the workflow
302
+ // passes no directory: GitHub sets RUNNER_TEMP itself, and start and report agree on it here.
303
+ function defaultDir() {
304
+ return path.join(process.env.RUNNER_TEMP || require('node:os').tmpdir(), 'spawn-watcher');
305
+ }
306
+
307
+ function main() {
308
+ const [, , mode, dirArg] = process.argv;
309
+ if (!['start', 'run', 'report'].includes(mode)) {
310
+ throw new ExitError(2, 'usage: diagnose-windows-spawn-watcher.cjs start|report [dir]');
311
+ }
312
+ ({ start, run, report })[mode](path.resolve(dirArg || defaultDir()));
313
+ }
314
+
315
+ if (require.main === module) runMain(main);
316
+
317
+ module.exports = { formatEvent, formatProcs, parseEvent, summarizeEvents, percentile, LOOP_GAP_MS, PROBE_SLOW_MS };
@@ -338,12 +338,14 @@ const DOCS_GUARD_TESTS = {
338
338
  // (no-pending-3212-markers.test.cjs:37-46), which includes all of docs/ —
339
339
  // cannot be resolved to a fixed docs/ path set.
340
340
  'tests/no-pending-3212-markers.test.cjs': ['*'],
341
+ 'tests/pause-work-improvements.test.cjs': ['docs/zh-CN/references/git-integration.md'],
341
342
  'tests/phase6-capability-docs.test.cjs': ['docs/how-to/develop-a-capability.md', 'docs/README.md'],
342
343
  'tests/phase6-review-capabilities.test.cjs': [
343
344
  'docs/reference/review-verification-capabilities.md',
344
345
  'docs/how-to/develop-a-capability.md',
345
346
  'docs/README.md',
346
347
  ],
348
+ 'tests/plan-authoring-verification-gaps.test.cjs': ['docs/reference/plan-md.md'],
347
349
  'tests/plan-checker-coupling.test.cjs': ['docs/AGENTS.md'],
348
350
  'tests/plan-phase-drift-guard.test.cjs': [
349
351
  'docs/CONFIGURATION.md',