@pareto4/pp-core 1.21.1 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/install.js +205 -28
  3. package/commands/pp-help.md +2 -1
  4. package/gemini-extension.json +1 -1
  5. package/hooks/dist/pp-check-update-worker.js +1 -1
  6. package/hooks/dist/pp-context-monitor.js +3 -3
  7. package/hooks/dist/pp-statusline.js +88 -14
  8. package/hooks/dist/pp-update-banner.js +44 -4
  9. package/hooks/dist/pp-workflow-guard.js +3 -3
  10. package/hooks/pp-check-update-worker.js +1 -1
  11. package/hooks/pp-context-monitor.js +3 -3
  12. package/hooks/pp-statusline.js +88 -14
  13. package/hooks/pp-update-banner.js +44 -4
  14. package/hooks/pp-workflow-guard.js +3 -3
  15. package/package.json +2 -2
  16. package/pp-core/bin/lib/audience-command.cjs +271 -0
  17. package/pp-core/bin/lib/audience.cjs +293 -0
  18. package/pp-core/bin/lib/commands.cjs +26 -17
  19. package/pp-core/bin/lib/discovery-charter.cjs +13 -1
  20. package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
  21. package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
  22. package/pp-core/bin/lib/discovery-input-check.cjs +11 -11
  23. package/pp-core/bin/lib/discovery-processes.cjs +97 -4
  24. package/pp-core/bin/lib/discovery-session-check.cjs +7 -7
  25. package/pp-core/bin/lib/discovery-status.cjs +419 -0
  26. package/pp-core/bin/lib/discovery-stories.cjs +36 -5
  27. package/pp-core/bin/lib/discovery-tests.cjs +398 -0
  28. package/pp-core/bin/lib/explain-command.cjs +48 -0
  29. package/pp-core/bin/lib/gate-card.cjs +97 -0
  30. package/pp-core/bin/lib/hook-messages.cjs +132 -0
  31. package/pp-core/bin/lib/init-contract.cjs +116 -76
  32. package/pp-core/bin/lib/init-interactive.cjs +110 -49
  33. package/pp-core/bin/lib/init.cjs +44 -3
  34. package/pp-core/bin/lib/io.cjs +38 -0
  35. package/pp-core/bin/lib/message-labels.cjs +75 -0
  36. package/pp-core/bin/lib/messages/core.cjs +20 -0
  37. package/pp-core/bin/lib/messages/discovery.cjs +267 -0
  38. package/pp-core/bin/lib/messages/gate.cjs +257 -0
  39. package/pp-core/bin/lib/messages/hook.cjs +114 -0
  40. package/pp-core/bin/lib/messages/init.cjs +513 -0
  41. package/pp-core/bin/lib/messages/progress.cjs +110 -0
  42. package/pp-core/bin/lib/messages.cjs +208 -0
  43. package/pp-core/bin/lib/vault-command-router.cjs +16 -3
  44. package/pp-core/bin/lib/violation-render.cjs +78 -0
  45. package/pp-core/bin/pp-tools.cjs +92 -23
  46. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  47. package/pp-core/references/audience-moments.md +119 -0
  48. package/pp-core/references/audience.md +114 -0
  49. package/pp-core/references/discovery-build-consumption.md +5 -2
  50. package/pp-core/references/discovery-charter.md +2 -2
  51. package/pp-core/references/discovery-id-conventions.md +1 -1
  52. package/pp-core/references/discovery-processes.md +31 -7
  53. package/pp-core/references/discovery-session-output.md +1 -1
  54. package/pp-core/references/discovery-tests.md +186 -0
  55. package/pp-core/templates/project-charter.md +3 -4
  56. package/pp-core/workflows/add-phase.md +2 -0
  57. package/pp-core/workflows/add-tests.md +2 -0
  58. package/pp-core/workflows/add-todo.md +2 -0
  59. package/pp-core/workflows/ai-integration-phase.md +2 -0
  60. package/pp-core/workflows/audit-milestone.md +2 -0
  61. package/pp-core/workflows/autonomous.md +2 -0
  62. package/pp-core/workflows/check-todos.md +2 -0
  63. package/pp-core/workflows/code-review-fix.md +2 -0
  64. package/pp-core/workflows/code-review.md +2 -0
  65. package/pp-core/workflows/complete-milestone.md +10 -1
  66. package/pp-core/workflows/discovery-sprint/charter.md +3 -2
  67. package/pp-core/workflows/discovery-sprint/prefill.md +3 -0
  68. package/pp-core/workflows/discovery-sprint/prototype.md +14 -0
  69. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  70. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +4 -1
  71. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
  72. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
  73. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +3 -2
  74. package/pp-core/workflows/discovery-sprint/vault-context.md +15 -0
  75. package/pp-core/workflows/discovery-sprint.md +16 -2
  76. package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
  77. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
  78. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
  79. package/pp-core/workflows/discovery-to-build.md +10 -4
  80. package/pp-core/workflows/discuss-phase/discovery.md +2 -2
  81. package/pp-core/workflows/discuss-phase-assumptions.md +2 -0
  82. package/pp-core/workflows/discuss-phase.md +1 -1
  83. package/pp-core/workflows/edit-phase.md +2 -0
  84. package/pp-core/workflows/eval-review.md +2 -0
  85. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +25 -1
  86. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -0
  87. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +2 -0
  88. package/pp-core/workflows/execute-phase.md +1 -1
  89. package/pp-core/workflows/execute-plan.md +2 -0
  90. package/pp-core/workflows/extract-learnings.md +2 -0
  91. package/pp-core/workflows/help/modes/business.md +56 -0
  92. package/pp-core/workflows/help.md +12 -2
  93. package/pp-core/workflows/import.md +10 -1
  94. package/pp-core/workflows/ingest-docs.md +2 -0
  95. package/pp-core/workflows/init.md +17 -1
  96. package/pp-core/workflows/insert-phase.md +2 -0
  97. package/pp-core/workflows/list-workspaces.md +2 -0
  98. package/pp-core/workflows/manager.md +2 -0
  99. package/pp-core/workflows/map-codebase.md +2 -0
  100. package/pp-core/workflows/milestone-summary.md +2 -0
  101. package/pp-core/workflows/new-milestone.md +10 -1
  102. package/pp-core/workflows/new-project.md +12 -2
  103. package/pp-core/workflows/new-workspace.md +2 -0
  104. package/pp-core/workflows/next.md +8 -0
  105. package/pp-core/workflows/plan-milestone-gaps.md +10 -1
  106. package/pp-core/workflows/plan-phase.md +2 -1
  107. package/pp-core/workflows/plan-review-convergence.md +1 -1
  108. package/pp-core/workflows/progress.md +10 -0
  109. package/pp-core/workflows/quick.md +11 -1
  110. package/pp-core/workflows/remove-phase.md +2 -0
  111. package/pp-core/workflows/remove-workspace.md +2 -0
  112. package/pp-core/workflows/resume-project.md +25 -0
  113. package/pp-core/workflows/review.md +2 -0
  114. package/pp-core/workflows/scan.md +2 -0
  115. package/pp-core/workflows/secure-phase.md +2 -0
  116. package/pp-core/workflows/settings.md +41 -0
  117. package/pp-core/workflows/ship.md +2 -0
  118. package/pp-core/workflows/spec-phase.md +1 -1
  119. package/pp-core/workflows/ui-phase.md +2 -0
  120. package/pp-core/workflows/ui-review.md +2 -0
  121. package/pp-core/workflows/ultraplan-phase.md +2 -0
  122. package/pp-core/workflows/validate-phase.md +2 -0
  123. package/pp-core/workflows/vault-review.md +2 -0
  124. package/pp-core/workflows/verify-phase.md +2 -0
  125. package/pp-core/workflows/verify-work.md +2 -0
  126. package/scripts/affected-tests-lib.cjs +26 -12
  127. package/scripts/ci-release-only.cjs +158 -0
  128. package/scripts/live-runs/driver.cjs +326 -0
  129. package/scripts/live-runs/run.cjs +906 -0
  130. package/scripts/live-runs/scenarios.cjs +326 -0
  131. package/scripts/live-runs/scratch-project.cjs +132 -0
  132. package/scripts/live-runs/scrub.cjs +291 -0
  133. package/scripts/live-runs/turn-guard.cjs +107 -0
  134. package/scripts/live-runs/walk-setup.cjs +198 -0
  135. package/scripts/run-tests.cjs +151 -132
  136. package/scripts/test-env.cjs +64 -0
@@ -33,6 +33,7 @@ const { readdirSync } = require('fs');
33
33
  const { join, basename } = require('path');
34
34
  const { execFileSync, spawnSync } = require('child_process');
35
35
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
36
+ const { prepareSuiteEnv } = require('./test-env.cjs');
36
37
 
37
38
  const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
38
39
 
@@ -459,142 +460,160 @@ function main() {
459
460
  // exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from
460
461
  // each <tmp>/.planning and silently diverge from the clean CI/Docker env. Strip
461
462
  // them so the local runner matches CI; tests that need them set them explicitly.
462
- delete process.env.GSD_PROJECT;
463
- delete process.env.GSD_WORKSTREAM;
464
- delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
465
-
466
- // Log selected files to stderr for CI / harness-test visibility.
467
- // node:test default reporter doesn't echo filenames, so this gives
468
- // operators a single stable line they can grep.
469
- console.error(
470
- `run-tests: suite="${suite || 'all'}" files=${selected.length}: ${selected
471
- .map(f => f.split(/[\\/]/).pop())
472
- .join(' ')}`,
473
- );
474
-
475
- // Default concurrency: 4 on Linux/macOS, 2 on Windows.
476
- //
477
- // Windows has significantly higher per-subprocess overhead than Linux/macOS:
478
- // - Windows Defender scans each spawned process on first execution, adding
479
- // latency proportional to the number of concurrent spawns.
480
- // - NTFS has higher file-system latency under concurrent access compared to
481
- // ext4/APFS, which amplifies contention when multiple test chunks run in
482
- // parallel and all read/write the same fixture directories.
483
- // Reducing to 2 halves the peak concurrent subprocess count on Windows and
484
- // keeps per-chunk wall-clock time well within the 20m CI job cap.
485
- //
486
- // Operator override via TEST_CONCURRENCY env var for local debugging.
487
- const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
488
- const concurrency = process.env.TEST_CONCURRENCY
489
- ? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
490
- : `--test-concurrency=${defaultConcurrency}`;
491
-
492
- // Windows `CreateProcess` caps the full command line at 32,767 chars
493
- // (lpCommandLine). With 500+ test paths the spawn fails instantly with no
494
- // test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
495
- // fine there. Split into chunks sized for the tightest target so behavior
496
- // is identical across platforms. (#3597)
497
- // Operator override (also used by tests to force chunking with short paths).
498
- const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
499
- ? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
500
- : 28000; // headroom below the 32,767 Windows ceiling
501
- const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
502
- ? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
503
- : 180;
504
-
505
- // node:test does not exit until the event loop drains. A unit test that leaks
506
- // an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
507
- // makes a chunk's `node --test` child hang ~150s on Windows AFTER its last test
508
- // prints; two such stalls push the windows full lane past its 20m cap and the
509
- // job is CANCELLED with no failed step — a false-negative gate (#1051, recurrence
510
- // of #869). --test-force-exit (Node >=22; engines requires >=22.0.0) exits the
511
- // runner once all tests finish regardless of lingering handles. The leaking
512
- // tests are also fixed at the source; this is the defensive backstop.
513
- // RUN_TESTS_NO_FORCE_EXIT=1 disables it (used by the harness regression test to
514
- // observe the pre-fix hang).
515
- const nodeMajor = Number(process.versions.node.split('.')[0]);
516
- const forceExit = nodeMajor >= 22 && !process.env.RUN_TESTS_NO_FORCE_EXIT;
517
-
518
- const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + (forceExit ? '--test-force-exit'.length + 1 : 0) + 8;
519
- const chunks = [];
520
- let current = [];
521
- let currentLen = FIXED_OVERHEAD;
522
- for (const file of selected) {
523
- const add = file.length + 1; // +1 for the inter-arg separator
524
- if (
525
- current.length > 0 &&
526
- (currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK)
527
- ) {
528
- chunks.push(current);
529
- current = [];
530
- currentLen = FIXED_OVERHEAD;
531
- }
532
- current.push(file);
533
- currentLen += add;
534
- }
535
- if (current.length > 0) chunks.push(current);
536
-
537
- // A chunk that still hangs (a leak the backstop somehow misses, or a wedged
538
- // subprocess) must fail loudly rather than silently burn the job's wall-clock
539
- // budget until the CI runner cancels the whole job. Default 10 min per chunk:
540
- // well above a healthy chunk (~4-5 min on the windows lane) but below the 20m
541
- // job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
542
- const chunkTimeoutMs = process.env.RUN_TESTS_CHUNK_TIMEOUT_MS
543
- ? Number(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS)
544
- : 600000;
545
-
546
- let firstFailureExit = 0;
547
- for (let i = 0; i < chunks.length; i++) {
548
- if (chunks.length > 1) {
549
- console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
550
- }
551
- const chunkFiles = chunks[i].map(f => f.split(/[\\/]/).pop()).join(' ');
552
- // stdout is captured, not inherited, so the chunk's node:test summary can be
553
- // checked; it is written through unchanged once the chunk ends. stderr stays
554
- // live. A chunk can exit 0 with its output cut off and no summary — on CI
555
- // (2026-10-28) two of five chunks did, and ~1,000 tests never reported while
556
- // the job stayed green — so a missing summary fails the run.
557
- const result = spawnSync(
558
- process.execPath,
559
- ['--test', ...(forceExit ? ['--test-force-exit'] : []), concurrency, ...chunks[i]],
560
- {
561
- stdio: ['inherit', 'pipe', 'inherit'],
562
- env: { ...process.env },
563
- timeout: chunkTimeoutMs,
564
- maxBuffer: 1024 * 1024 * 1024,
565
- },
463
+ // The same goes for the person's audience setting (AUD-04): PP_AUDIENCE and the
464
+ // person file (`~/.gsd/person.json`, rooted at GSD_HOME || home) are stripped /
465
+ // redirected so a developer whose own audience is business gets CI's results.
466
+ // Tests that need a setting pass their own GSD_HOME / PP_AUDIENCE.
467
+ // The same preparation backs `npm run test:affected` (scripts/test-env.cjs).
468
+ const suiteEnv = prepareSuiteEnv(process.env);
469
+
470
+ try {
471
+ // Log selected files to stderr for CI / harness-test visibility.
472
+ // node:test default reporter doesn't echo filenames, so this gives
473
+ // operators a single stable line they can grep.
474
+ console.error(
475
+ `run-tests: suite="${suite || 'all'}" files=${selected.length}: ${selected
476
+ .map(f => f.split(/[\\/]/).pop())
477
+ .join(' ')}`,
566
478
  );
567
- if (result.stdout && result.stdout.length > 0) process.stdout.write(result.stdout);
568
- // When the per-chunk timeout fires, spawnSync kills the child and reports
569
- // error.code === 'ETIMEDOUT' (POSIX) and/or a kill signal (platform-dependent).
570
- // Check both so detection holds on Windows and POSIX.
571
- const timedOut = (result.error && result.error.code === 'ETIMEDOUT') ||
572
- (result.signal !== null && result.status === null && !result.error);
573
- let code = result.status === null ? 1 : result.status;
574
- if (result.error && result.error.code === 'ETIMEDOUT') code = 1;
575
- if (timedOut) {
576
- console.error(
577
- `run-tests: chunk ${i + 1}/${chunks.length} exceeded the per-chunk timeout ` +
578
- `of ${chunkTimeoutMs}ms and was killed — a test in this chunk is likely leaking ` +
579
- `an open handle (un-terminated Worker, un-killed child process, or ref'd timer) ` +
580
- `so node --test never exits. Files: ${chunkFiles}`,
581
- );
582
- } else if (result.error) {
583
- console.error(`run-tests: chunk ${i + 1}/${chunks.length} could not run: ${result.error.message}`);
584
- code = 1;
585
- } else if (code === 0 && !hasTestSummary(result.stdout)) {
586
- console.error(
587
- `run-tests: chunk ${i + 1}/${chunks.length} exited without a test summary ` +
588
- `(no "ℹ tests N" / "ℹ fail N" lines) — its output was cut off, so some of its ` +
589
- `tests never reported. Files: ${chunkFiles}`,
479
+
480
+ // Default concurrency: 4 on Linux/macOS, 2 on Windows.
481
+ //
482
+ // Windows has significantly higher per-subprocess overhead than Linux/macOS:
483
+ // - Windows Defender scans each spawned process on first execution, adding
484
+ // latency proportional to the number of concurrent spawns.
485
+ // - NTFS has higher file-system latency under concurrent access compared to
486
+ // ext4/APFS, which amplifies contention when multiple test chunks run in
487
+ // parallel and all read/write the same fixture directories.
488
+ // Reducing to 2 halves the peak concurrent subprocess count on Windows and
489
+ // keeps per-chunk wall-clock time well within the 20m CI job cap.
490
+ //
491
+ // Operator override via TEST_CONCURRENCY env var for local debugging.
492
+ const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
493
+ const concurrency = process.env.TEST_CONCURRENCY
494
+ ? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
495
+ : `--test-concurrency=${defaultConcurrency}`;
496
+
497
+ // Windows `CreateProcess` caps the full command line at 32,767 chars
498
+ // (lpCommandLine). With 500+ test paths the spawn fails instantly with no
499
+ // test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
500
+ // fine there. Split into chunks sized for the tightest target so behavior
501
+ // is identical across platforms. (#3597)
502
+ // Operator override (also used by tests to force chunking with short paths).
503
+ const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
504
+ ? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
505
+ : 28000; // headroom below the 32,767 Windows ceiling
506
+ const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
507
+ ? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
508
+ : 180;
509
+
510
+ // node:test does not exit until the event loop drains. A unit test that leaks
511
+ // an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
512
+ // makes a chunk's `node --test` child hang ~150s on Windows AFTER its last test
513
+ // prints; two such stalls push the windows full lane past its 20m cap and the
514
+ // job is CANCELLED with no failed step — a false-negative gate (#1051, recurrence
515
+ // of #869). --test-force-exit (Node >=22) exits the runner once all tests
516
+ // finish regardless of lingering handles — but it exits before the reporter has
517
+ // written everything, so it DROPS reported tests: a 180-file chunk reported
518
+ // 4,688 / 4,726 / 4,705 tests with it and 4,759 every time without it (Linux,
519
+ // 1.5 CPUs, 2026-11-10), and on GitHub runners it cut whole chunk summaries
520
+ // (PR #31/#32). The leaking tests were fixed at the source and no CI lane runs
521
+ // Windows any more, so it is now OPT-IN: RUN_TESTS_FORCE_EXIT=1. The per-chunk
522
+ // timeout below still bounds a genuine hang, loudly.
523
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
524
+ const forceExit = nodeMajor >= 22 && process.env.RUN_TESTS_FORCE_EXIT === '1';
525
+
526
+ const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + (forceExit ? '--test-force-exit'.length + 1 : 0) + 8;
527
+ const chunks = [];
528
+ let current = [];
529
+ let currentLen = FIXED_OVERHEAD;
530
+ for (const file of selected) {
531
+ const add = file.length + 1; // +1 for the inter-arg separator
532
+ if (
533
+ current.length > 0 &&
534
+ (currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK)
535
+ ) {
536
+ chunks.push(current);
537
+ current = [];
538
+ currentLen = FIXED_OVERHEAD;
539
+ }
540
+ current.push(file);
541
+ currentLen += add;
542
+ }
543
+ if (current.length > 0) chunks.push(current);
544
+
545
+ // A chunk that still hangs (a leak the backstop somehow misses, or a wedged
546
+ // subprocess) must fail loudly rather than silently burn the job's wall-clock
547
+ // budget until the CI runner cancels the whole job. Default 10 min per chunk:
548
+ // well above a healthy chunk (~4-5 min on the windows lane) but below the 20m
549
+ // job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
550
+ const chunkTimeoutMs = process.env.RUN_TESTS_CHUNK_TIMEOUT_MS
551
+ ? Number(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS)
552
+ : 600000;
553
+
554
+ let firstFailureExit = 0;
555
+ for (let i = 0; i < chunks.length; i++) {
556
+ if (chunks.length > 1) {
557
+ console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
558
+ }
559
+ const chunkFiles = chunks[i].map(f => f.split(/[\\/]/).pop()).join(' ');
560
+ // stdout is captured, not inherited, so the chunk's node:test summary can be
561
+ // checked; it is written through unchanged once the chunk ends. stderr stays
562
+ // live. A chunk can exit 0 with its output cut off and no summary — on CI
563
+ // (2026-10-28) two of five chunks did, and ~1,000 tests never reported while
564
+ // the job stayed green — so a missing summary fails the run.
565
+ const result = spawnSync(
566
+ process.execPath,
567
+ ['--test', ...(forceExit ? ['--test-force-exit'] : []), concurrency, ...chunks[i]],
568
+ {
569
+ stdio: ['inherit', 'pipe', 'inherit'],
570
+ env: { ...process.env },
571
+ timeout: chunkTimeoutMs,
572
+ maxBuffer: 1024 * 1024 * 1024,
573
+ },
590
574
  );
591
- code = 1;
575
+ if (result.stdout && result.stdout.length > 0) process.stdout.write(result.stdout);
576
+ // When the per-chunk timeout fires, spawnSync kills the child and reports
577
+ // error.code === 'ETIMEDOUT' (POSIX) and/or a kill signal (platform-dependent).
578
+ // Check both so detection holds on Windows and POSIX.
579
+ const timedOut = (result.error && result.error.code === 'ETIMEDOUT') ||
580
+ (result.signal !== null && result.status === null && !result.error);
581
+ let code = result.status === null ? 1 : result.status;
582
+ if (result.error && result.error.code === 'ETIMEDOUT') code = 1;
583
+ if (timedOut) {
584
+ console.error(
585
+ `run-tests: chunk ${i + 1}/${chunks.length} exceeded the per-chunk timeout ` +
586
+ `of ${chunkTimeoutMs}ms and was killed — a test in this chunk is likely leaking ` +
587
+ `an open handle (un-terminated Worker, un-killed child process, or ref'd timer) ` +
588
+ `so node --test never exits. Files: ${chunkFiles}`,
589
+ );
590
+ } else if (result.error) {
591
+ console.error(`run-tests: chunk ${i + 1}/${chunks.length} could not run: ${result.error.message}`);
592
+ code = 1;
593
+ } else if (code === 0 && !hasTestSummary(result.stdout)) {
594
+ console.error(
595
+ `run-tests: chunk ${i + 1}/${chunks.length} exited without a test summary ` +
596
+ `(no "ℹ tests N" / "ℹ fail N" lines) — its output was cut off, so some of its ` +
597
+ `tests never reported. Files: ${chunkFiles}`,
598
+ );
599
+ code = 1;
600
+ }
601
+ // Run every chunk so the operator sees all failures in one pass; report
602
+ // the first non-zero exit at the end.
603
+ if (code !== 0 && firstFailureExit === 0) firstFailureExit = code;
604
+ }
605
+
606
+ // A test that wrote the shared suite person file would poison every file that
607
+ // runs after it, so it fails the run loudly instead of causing flakiness.
608
+ const leak = suiteEnv.leakMessage();
609
+ if (leak) {
610
+ console.error(`run-tests: ${leak}`);
611
+ return firstFailureExit !== 0 ? firstFailureExit : 1;
592
612
  }
593
- // Run every chunk so the operator sees all failures in one pass; report
594
- // the first non-zero exit at the end.
595
- if (code !== 0 && firstFailureExit === 0) firstFailureExit = code;
613
+ if (firstFailureExit !== 0) return firstFailureExit;
614
+ } finally {
615
+ suiteEnv.cleanup();
596
616
  }
597
- if (firstFailureExit !== 0) return firstFailureExit;
598
617
  }
599
618
 
600
619
  /**
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared test-suite environment preparation (Phase 39 D-16, WR-04).
5
+ *
6
+ * Both entry points that spawn `node --test` — `scripts/run-tests.cjs`
7
+ * (`npm test`) and `scripts/affected-tests-lib.cjs#runNodeTestFiles`
8
+ * (`npm run test:affected`) — call this, so a developer's own shell never
9
+ * changes what the suite sees:
10
+ *
11
+ * - GSD_PROJECT / GSD_WORKSTREAM would redirect fixture `.planning` reads;
12
+ * - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS changes agent dispatch;
13
+ * - PP_AUDIENCE and the person file (`~/.gsd/person.json`, rooted at
14
+ * GSD_HOME || home) would change the resolved audience (AUD-04).
15
+ *
16
+ * The person file's home is redirected to a fresh throwaway directory for the
17
+ * whole run. A test that writes the person file there would poison every file
18
+ * that runs after it, so `leakMessage()` reports it and the caller fails the
19
+ * run. Tests that need a setting pass their own GSD_HOME / PP_AUDIENCE.
20
+ *
21
+ * Kept out of `scripts/lib/` on purpose: the installer copies every file in
22
+ * `scripts/lib/` into each user's config dir, and this is a dev-only helper.
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const STRIPPED_VARS = Object.freeze([
30
+ 'GSD_PROJECT',
31
+ 'GSD_WORKSTREAM',
32
+ 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
33
+ 'PP_AUDIENCE',
34
+ ]);
35
+
36
+ /**
37
+ * Strip the session variables from `env` (mutated in place) and point its
38
+ * GSD_HOME at a new temporary suite home.
39
+ *
40
+ * @param {NodeJS.ProcessEnv} [env=process.env]
41
+ * @returns {{ env: NodeJS.ProcessEnv, home: string, leakMessage: () => string|null, cleanup: () => void }}
42
+ */
43
+ function prepareSuiteEnv(env = process.env) {
44
+ for (const name of STRIPPED_VARS) delete env[name];
45
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pp-suite-home-'));
46
+ env.GSD_HOME = home;
47
+ const personFile = path.join(home, '.gsd', 'person.json');
48
+ return {
49
+ env,
50
+ home,
51
+ leakMessage() {
52
+ if (!fs.existsSync(personFile)) return null;
53
+ return (
54
+ `a test wrote the person file in the shared suite home (${personFile}) — ` +
55
+ 'every test that runs audience set/answer or installs with --audience must pass its own GSD_HOME'
56
+ );
57
+ },
58
+ cleanup() {
59
+ fs.rmSync(home, { recursive: true, force: true });
60
+ },
61
+ };
62
+ }
63
+
64
+ module.exports = { prepareSuiteEnv, STRIPPED_VARS };