session-orchestrator 3.21.0 → 3.22.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -18,6 +18,7 @@ import { promisify } from 'node:util';
18
18
  import {
19
19
  resolveRepoSpec,
20
20
  resolveRepoHost,
21
+ resolveGitlabProjectTarget,
21
22
  redactUrlCredentials,
22
23
  detectVcsFamily,
23
24
  isQueryFailure,
@@ -191,42 +192,19 @@ async function getHeadSha(repoRoot, deps = {}) {
191
192
  }
192
193
 
193
194
  /**
194
- * Get GitLab project ID via glab.
195
+ * Run a host-pinned `glab api <path>` request and return parsed JSON.
195
196
  *
196
- * #872: pins to `deps.repoSpec` via `-R` when resolved (host-pinning —
197
- * `glab repo view` otherwise falls back to the ambient `GITLAB_HOST`).
198
- *
199
- * @param {string} repoRoot
200
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string }} deps
201
- * @returns {Promise<number>}
202
- */
203
- async function getGlabProjectId(repoRoot, deps = {}) {
204
- const args = ['repo', 'view', '--output', 'json'];
205
- if (deps.repoSpec) args.push('-R', deps.repoSpec);
206
- const result = await execWithTimeout(
207
- 'glab',
208
- args,
209
- { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
210
- );
211
- const parsed = JSON.parse(result.stdout);
212
- return parsed.id;
213
- }
214
-
215
- /**
216
- * Run `glab api <path>` and return parsed JSON.
217
- *
218
- * #872: `glab api` has no repo/`-R` concept — it accepts only `--hostname`
219
- * to pin which GitLab instance the request targets. Pinned via
220
- * `deps.repoHost` when resolved.
197
+ * GitLab's API accepts neither a remote URL nor `-R`; callers provide the host
198
+ * proven by `resolveGitlabProjectTarget` so this helper cannot fall back to
199
+ * ambient `GITLAB_HOST` configuration.
221
200
  *
222
201
  * @param {string} apiPath
223
202
  * @param {string} repoRoot
224
- * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
203
+ * @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
225
204
  * @returns {Promise<unknown>}
226
205
  */
227
206
  async function glabApi(apiPath, repoRoot, deps = {}) {
228
- const args = ['api', apiPath];
229
- if (deps.repoHost) args.push('--hostname', deps.repoHost);
207
+ const args = ['api', apiPath, '--hostname', deps.repoHost];
230
208
  const result = await execWithTimeout(
231
209
  'glab',
232
210
  args,
@@ -276,17 +254,32 @@ function ageDaysFrom(isoDate, now) {
276
254
  *
277
255
  * @param {string} repoRoot
278
256
  * @param {number} now
279
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
257
+ * @param {{
258
+ * execFile?: Function,
259
+ * timeoutMs?: number,
260
+ * gitlabProject?: { host: string, encodedProjectPath: string },
261
+ * }} deps
280
262
  * @returns {Promise<object|null>}
281
263
  */
282
264
  async function checkGitlab(repoRoot, now, deps = {}) {
283
- const projectId = await getGlabProjectId(repoRoot, deps);
284
- const currentSha = await getHeadSha(repoRoot, deps);
265
+ const project = deps.gitlabProject;
266
+ if (
267
+ !project ||
268
+ typeof project.host !== 'string' ||
269
+ typeof project.encodedProjectPath !== 'string' ||
270
+ project.host === '' ||
271
+ project.encodedProjectPath === ''
272
+ ) {
273
+ return null;
274
+ }
285
275
 
276
+ const currentSha = await getHeadSha(repoRoot, deps);
277
+ const apiDeps = { ...deps, repoHost: project.host };
278
+ const projectPath = `projects/${project.encodedProjectPath}`;
286
279
  const pipelines = await glabApi(
287
- `projects/${projectId}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
280
+ `${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
288
281
  repoRoot,
289
- deps,
282
+ apiDeps,
290
283
  );
291
284
 
292
285
  if (!Array.isArray(pipelines)) return null;
@@ -316,9 +309,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
316
309
  let allowFailureJobs;
317
310
  try {
318
311
  const jobs = await glabApi(
319
- `projects/${projectId}/pipelines/${currentPipeline.id}/jobs`,
312
+ `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
320
313
  repoRoot,
321
- deps,
314
+ apiDeps,
322
315
  );
323
316
  if (Array.isArray(jobs)) {
324
317
  const softFailed = jobs
@@ -383,9 +376,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
383
376
  let failingJobName;
384
377
  try {
385
378
  const jobs = await glabApi(
386
- `projects/${projectId}/pipelines/${currentPipeline.id}/jobs`,
379
+ `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
387
380
  repoRoot,
388
- deps,
381
+ apiDeps,
389
382
  );
390
383
  if (Array.isArray(jobs)) {
391
384
  const failedJob = jobs.find((j) => j.status === 'failed');
@@ -433,9 +426,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
433
426
  * `[HOST/]OWNER/REPO` shape `resolveRepoSpec({ vcs: 'github' })` returns is
434
427
  * exactly the positional's documented input format.
435
428
  *
436
- * The asymmetry with `getGlabProjectId` is real and deliberate: `glab repo
437
- * view` DOES accept `-R`, and `gh api`/`glab api` accept neither `-R` nor a
438
- * positional only `--hostname`. Do not unify these three call sites.
429
+ * GitLab CI differs deliberately: it derives a host-pinned API target from
430
+ * the selected remote, while GitHub still needs this lookup because its API
431
+ * path requires `nameWithOwner`. Do not unify these call sites.
439
432
  *
440
433
  * `nameWithOwner` is NOT derivable from `deps.repoSpec`, so this lookup
441
434
  * cannot be dropped: the spec carries a HOST prefix the `repos/<owner>/<repo>`
@@ -540,10 +533,11 @@ async function checkGithub(repoRoot, deps = {}) {
540
533
  * execFile?: Function,
541
534
  * resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
542
535
  * resolveRepoHost?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
543
- * }} deps Dependency-injection seam for testing. `resolveRepoSpec`/
544
- * `resolveRepoHost` default to the real `vcs-repo-spec.mjs` exports
545
- * (#872 host-pinning see that module for the `-R` vs `--hostname`
546
- * contract).
536
+ * resolveGitlabProjectTarget?: (opts: { repoRoot: string }) =>
537
+ * { host: string, encodedProjectPath: string }|undefined,
538
+ * }} deps Dependency-injection seam for testing. GitLab defaults to
539
+ * `resolveGitlabProjectTarget`, which proves host and project path from one
540
+ * sanitized remote; GitHub retains the #872 spec/host resolvers.
547
541
  * @returns {Promise<null | {
548
542
  * status: 'green'|'red'|'unknown',
549
543
  * ok: boolean,
@@ -572,6 +566,7 @@ export async function checkCiStatus(opts = {}, deps = {}) {
572
566
 
573
567
  const resolveRepoSpecDep = deps.resolveRepoSpec ?? resolveRepoSpec;
574
568
  const resolveRepoHostDep = deps.resolveRepoHost ?? resolveRepoHost;
569
+ const resolveGitlabProjectTargetDep = deps.resolveGitlabProjectTarget ?? resolveGitlabProjectTarget;
575
570
 
576
571
  const depsWithExec = { execFile: execFileDep, timeoutMs };
577
572
 
@@ -605,22 +600,33 @@ export async function checkCiStatus(opts = {}, deps = {}) {
605
600
  vcs = detected.vcs;
606
601
  }
607
602
 
608
- // Step 1b (#872): resolve the -R/--hostname host-pinning spec ONCE per
609
- // checkCiStatus call a bare glab/gh spawn falls back to the ambient
610
- // GITLAB_HOST/GH_HOST env var, which can silently target the wrong
611
- // instance on a multi-instance host. `cwd: repoRoot` alone does not fix
612
- // this (ambient env still wins over cwd).
613
- const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
614
- const repoHost = resolveRepoHostDep({ repoRoot, vcs });
615
- const depsWithPinning = { ...depsWithExec, repoSpec, repoHost };
616
-
617
- // Step 2: dispatch to VCS-specific implementation.
603
+ // Step 1b: GitLab's API target must be proven before its first glab spawn.
604
+ // A missing target is never permission to fall back to ambient
605
+ // GITLAB_HOST/repository configuration. GitHub retains its distinct
606
+ // repository lookup because its API path requires `nameWithOwner`.
607
+ //
608
+ // It is also not an ABSENCE: detectVcs just proved a GitLab remote exists,
609
+ // so `!gitlabProject` means "remote present, its form was rejected" — a
610
+ // QUERY FAILURE by the same rule the block above states. Returning null
611
+ // silently put this repo back in the pre-#1039 state where "CI green" and
612
+ // "could not ask" look identical. Reachable inputs measured 2026-08-21:
613
+ // `git://` scheme, a doubled slash in the path, a query string.
618
614
  if (vcs === 'gitlab') {
619
- return await checkGitlab(repoRoot, now, depsWithPinning);
615
+ const gitlabProject = resolveGitlabProjectTargetDep({ repoRoot });
616
+ if (!gitlabProject) {
617
+ console.warn(
618
+ 'WARN ci-status-banner: a GitLab remote was detected but its host/project path ' +
619
+ 'could not be derived, banner suppressed — CI state is UNKNOWN, not "green".',
620
+ );
621
+ return null;
622
+ }
623
+ return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
620
624
  }
621
625
 
622
626
  if (vcs === 'github') {
623
- return await checkGithub(repoRoot, depsWithPinning);
627
+ const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
628
+ const repoHost = resolveRepoHostDep({ repoRoot, vcs });
629
+ return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
624
630
  }
625
631
 
626
632
  // Unknown VCS value — silent no-op.
@@ -43,13 +43,36 @@ import { hasBlockHeader } from './block-header.mjs';
43
43
  const ALLOWED_AUTONOMY = ['off', 'advisory', 'autonomous-gated'];
44
44
  const DEFAULT_CONFIDENCE_FLOOR = 0.5;
45
45
 
46
+ /**
47
+ * The display-only tail of an option LABEL, stripped before the enum match.
48
+ *
49
+ * The label and the stored value were the SAME string, which made the label
50
+ * unchangeable: both consumers (`skills/session-start/SKILL.md` Phase 1.1 and
51
+ * `skills/bootstrap/SKILL.md` Phase 3.5.1) hand the SELECTED LABEL straight to
52
+ * writeDispatcherAutonomyBlock(), and coerceAutonomy() falls back to 'off' on
53
+ * any value outside the enum. Putting AUQ-003's `(Recommended)` marker into the
54
+ * label would therefore have written `autonomy: off` for an operator who picked
55
+ * `advisory` — a silent downgrade, fail-closed and therefore invisible.
56
+ *
57
+ * Measured before this split (renderDispatcherAutonomyBlock, 2026-08-22):
58
+ * 'advisory' => autonomy: advisory
59
+ * 'Advisory (surface only)' => autonomy: off ← the downgrade
60
+ *
61
+ * Anchored at the end, so it can only ever strip a suffix. The enum values
62
+ * themselves are untouched and stay greppable.
63
+ */
64
+ const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
65
+
46
66
  /**
47
67
  * Coordinator-facing AUQ question definition for the one-time capture.
48
68
  * Mirrors the shape of `getInterviewQuestions()` entries in owner-interview.mjs:
49
69
  * { question, header, options: [{ label, description }], multiSelect }
50
70
  *
51
- * Option labels match the ALLOWED_AUTONOMY enum so the selected label maps
52
- * directly to the `autonomy` value passed to writeDispatcherAutonomyBlock().
71
+ * Each label BEGINS with its ALLOWED_AUTONOMY value; option 1 additionally
72
+ * carries the `(Recommended)` marker AUQ-003 puts in the label. `coerceAutonomy()`
73
+ * strips that marker, so the selected label still maps to the enum value written
74
+ * by writeDispatcherAutonomyBlock() — see the comment on RECOMMENDED_SUFFIX for
75
+ * why the two halves must not be the same string.
53
76
  * Option 1 (`off`) is the recommended, fail-closed default.
54
77
  *
55
78
  * @returns {{ question: string, header: string, options: Array<{ label: string, description: string }>, multiSelect: boolean }}
@@ -57,23 +80,23 @@ const DEFAULT_CONFIDENCE_FLOOR = 0.5;
57
80
  export function getDispatcherAutonomyQuestion() {
58
81
  return {
59
82
  question:
60
- 'Cross-repo dispatcher autonomy: how should this repo participate when the free-repo dispatcher routes work to it?',
61
- header: 'Dispatcher Autonomy (one-time)',
83
+ 'When /dispatcher looks across your repos for the next one to work on, how may it treat this repo?',
84
+ header: 'Dispatcher',
62
85
  options: [
63
86
  {
64
- label: 'off',
87
+ label: 'off (Recommended)',
65
88
  description:
66
- '(Recommended) Fail-closed. The dispatcher never routes work to this repo automatically. No behaviour change.',
89
+ 'Nothing changes: this repo is never offered and never started for you. Safe default — you keep picking the work.',
67
90
  },
68
91
  {
69
92
  label: 'advisory',
70
93
  description:
71
- 'The dispatcher surfaces ranked free-repo candidates for operator review no automated dispatch.',
94
+ 'This repo may appear in the ranked suggestions, so you can compare it against the others. You still start each session.',
72
95
  },
73
96
  {
74
97
  label: 'autonomous-gated',
75
98
  description:
76
- 'A deterministic confidence gate is checked first; only dispatches that clear the confidence-floor route automatically.',
99
+ 'Work may start here without asking, but only when the ranking beats the confidence floor written next to this setting.',
77
100
  },
78
101
  ],
79
102
  multiSelect: false,
@@ -128,7 +151,7 @@ export function isDispatcherAutonomyBlockPresent(claudeMdContent) {
128
151
  */
129
152
  function coerceAutonomy(value) {
130
153
  if (value === null || value === undefined) return 'off';
131
- const normalized = String(value).toLowerCase().trim();
154
+ const normalized = String(value).toLowerCase().replace(RECOMMENDED_SUFFIX, '').trim();
132
155
  return ALLOWED_AUTONOMY.includes(normalized) ? normalized : 'off';
133
156
  }
134
157
 
@@ -192,9 +192,20 @@ function _parseInlineObject(raw) {
192
192
  */
193
193
  export function _parseResourceThresholds(kv) {
194
194
  return {
195
+ // Memory thresholds are denominated in the signal `evaluate()` actually
196
+ // judges on — memory_pressure > ram_available > ram_free, in that order
197
+ // (#1089). They are NOT compared against Darwin's `os.freemem()` unless
198
+ // nothing better is published, which on Darwin never happens.
195
199
  'ram-free-min-gb': _coerceInteger(kv, 'ram-free-min-gb', 4),
196
200
  'ram-free-critical-gb': _coerceInteger(kv, 'ram-free-critical-gb', 2),
197
- 'cpu-load-max-pct': _coerceInteger(kv, 'cpu-load-max-pct', 80),
201
+ // 80 → 90 (#1089). At 80 this fired on 15.6% of 1477 measured session
202
+ // starts, largely on the decaying tail of the coordinator's own gate run;
203
+ // at 90 it fires on 12.8%, and under the two-signal rule it no longer caps
204
+ // anything on its own. Both numbers are measurements, not preferences.
205
+ 'cpu-load-max-pct': _coerceInteger(kv, 'cpu-load-max-pct', 90),
206
+ // Unchanged at 5 — but now compared against LIVE PEER SESSIONS rather than
207
+ // the Claude process count. Same number, different denominator: measured
208
+ // firing rate drops from 93.6% to 4.2% (median processes:sessions = 6.0).
198
209
  'concurrent-sessions-warn': _coerceInteger(kv, 'concurrent-sessions-warn', 5),
199
210
  'ssh-no-docker': _coerceBoolean(kv, 'ssh-no-docker', true),
200
211
  };
@@ -34,7 +34,7 @@ import path from 'node:path';
34
34
 
35
35
  import { scanBacklog } from '../backlog-scan.mjs';
36
36
  import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
37
- import { probe as realProbe, evaluate as realEvaluate } from '../resource-probe.mjs';
37
+ import { probe as realProbe, evaluate as realEvaluate, DEFAULT_RESOURCE_THRESHOLDS as CANONICAL_RESOURCE_THRESHOLDS } from '../resource-probe.mjs';
38
38
  import { isRealSession } from '../session-schema/filters.mjs';
39
39
 
40
40
  /** Staleness cap (days). Beyond this, additional age does not raise the score. */
@@ -49,12 +49,9 @@ const MS_PER_DAY = 86_400_000;
49
49
  * Used by the default `resourceVerdict` dep so ranking works standalone
50
50
  * without a parsed Session Config in hand.
51
51
  */
52
- const DEFAULT_RESOURCE_THRESHOLDS = {
53
- 'ram-free-min-gb': 4,
54
- 'ram-free-critical-gb': 2,
55
- 'cpu-load-max-pct': 80,
56
- 'concurrent-sessions-warn': 5,
57
- };
52
+ // #1089: re-exported from the single canonical definition rather than a third
53
+ // hand-maintained copy — the three copies had already drifted apart.
54
+ const DEFAULT_RESOURCE_THRESHOLDS = { ...CANONICAL_RESOURCE_THRESHOLDS };
58
55
 
59
56
  // ---------------------------------------------------------------------------
60
57
  // PURE core
@@ -36,7 +36,7 @@ const startTime = Date.now();
36
36
  const tcResult = runCheck(typecheckCmd);
37
37
  const tcErrorCount =
38
38
  tcResult.status === 'fail'
39
- ? extractCount(tcResult.output, /error TS\d+/)
39
+ ? extractCount(tcResult.fullOutput ?? tcResult.output, /error TS\d+/)
40
40
  : 0;
41
41
 
42
42
  // --- Test ---
@@ -46,14 +46,14 @@ const tcErrorCount =
46
46
  const testResult = runCheck(testCmd);
47
47
  const { passed: testPassed, failed: testFailed, total: testTotal } =
48
48
  testResult.status !== 'skip'
49
- ? extractTestCounts(testResult.output)
49
+ ? extractTestCounts(testResult.fullOutput ?? testResult.output)
50
50
  : { passed: 0, failed: 0, total: 0 };
51
51
 
52
52
  // --- Lint ---
53
53
  const lintResult = runCheck(lintCmd);
54
54
  const lintWarnings =
55
55
  lintResult.status !== 'skip'
56
- ? extractCount(lintResult.output, /warning/i)
56
+ ? extractCount(lintResult.fullOutput ?? lintResult.output, /warning/i)
57
57
  : 0;
58
58
 
59
59
  // --- Debug artifacts ---
@@ -41,17 +41,28 @@ function isTestFile(filePath) {
41
41
  /**
42
42
  * Execute a shell command and return a structured result.
43
43
  *
44
+ * `output` is a bounded TAIL for humans. `fullOutput` is the complete captured
45
+ * text and is what every COUNT parse must read.
46
+ *
47
+ * Why both: the tail was 5 lines and was also the parser's input. Vitest prints
48
+ * its `Test Files` / `Tests` summary and THEN the per-failure detail, so on any
49
+ * real failure the summary sits hundreds of lines above the tail — measured
50
+ * 2026-08-22: line 174 of 936. `extractTestCounts` then found nothing and the
51
+ * gate reported `test: fail, total 0, passed 0, failed 0`, which reads as
52
+ * "the runner never produced results" and hides WHICH test failed. An hour was
53
+ * spent chasing that phantom before the real cause (one red test) was found.
54
+ *
44
55
  * @param {string} cmd - Shell command to run, or `"skip"` / empty to skip.
45
- * @returns {{ status: 'pass'|'fail'|'skip', output: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
56
+ * @returns {{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
46
57
  */
47
58
  export function runCheck(cmd) {
48
59
  if (!cmd || cmd === 'skip') {
49
- return { status: 'skip', output: '', exitCode: 0 };
60
+ return { status: 'skip', output: '', fullOutput: '', exitCode: 0 };
50
61
  }
51
62
 
52
63
  const stub = detectStubCommand(cmd);
53
64
  if (stub.isStub) {
54
- return { status: 'pass', output: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
65
+ return { status: 'pass', output: `(stubbed: ${stub.kind})`, fullOutput: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
55
66
  }
56
67
 
57
68
  try {
@@ -61,18 +72,18 @@ export function runCheck(cmd) {
61
72
  maxBuffer: RUN_CHECK_MAX_BUFFER_BYTES,
62
73
  });
63
74
  const output = raw.split('\n').slice(-5).join('\n').trim();
64
- return { status: 'pass', output, exitCode: 0 };
75
+ return { status: 'pass', output, fullOutput: raw, exitCode: 0 };
65
76
  } catch (err) {
66
77
  const exitCode = typeof err.status === 'number' ? err.status : 1;
67
78
 
68
79
  // Exit code 127 means command not found — treat as skip.
69
80
  if (exitCode === 127) {
70
- return { status: 'skip', output: 'command not found', exitCode };
81
+ return { status: 'skip', output: 'command not found', fullOutput: '', exitCode };
71
82
  }
72
83
 
73
84
  const combined = [err.stdout ?? '', err.stderr ?? ''].join('\n');
74
85
  const output = combined.split('\n').slice(-5).join('\n').trim();
75
- return { status: 'fail', output, exitCode };
86
+ return { status: 'fail', output, fullOutput: combined, exitCode };
76
87
  }
77
88
  }
78
89