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
@@ -227,17 +227,56 @@ const WRONG_FAMILY_HOST = {
227
227
  github: 'gitlab.com',
228
228
  };
229
229
 
230
+ /** URI schemes that can name a supported Git remote. */
231
+ const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
232
+
230
233
  /**
231
- * Extract the bare hostname from a git remote URL, handling both the HTTPS
232
- * (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`) forms.
233
- * Returns `null` for an unrecognized shape (never throws).
234
+ * Parse a URI-style remote only when it uses one of this module's supported
235
+ * protocols. The `URL` parser makes hostname/port handling consistent between
236
+ * HTTP(S) and URI-style SSH while scp-style SSH stays a separate grammar.
237
+ *
238
+ * @param {string} url
239
+ * @returns {URL|null}
240
+ */
241
+ function parseRemoteUri(url) {
242
+ if (typeof url !== 'string' || !/^(?:https?|ssh):\/\//i.test(url)) return null;
243
+ try {
244
+ const parsed = new URL(url);
245
+ return REMOTE_URI_PROTOCOLS.has(parsed.protocol) ? parsed : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Extract the operational host from a git remote URL, handling HTTPS
253
+ * (`https://host/owner/repo.git`), scp-style SSH (`git@host:owner/repo.git`),
254
+ * and URI-style SSH (`ssh://git@host/owner/repo.git`) forms. A non-default
255
+ * URI port is preserved because callers may need it to address a self-hosted
256
+ * instance. Returns `null` for an unrecognized shape (never throws).
234
257
  *
235
258
  * @param {string} url
236
259
  * @returns {string|null}
237
260
  */
238
261
  function extractHost(url) {
239
- const httpsMatch = /^https?:\/\/([^/]+)/i.exec(url);
240
- if (httpsMatch) return httpsMatch[1].toLowerCase();
262
+ const parsed = parseRemoteUri(url);
263
+ if (parsed !== null) return parsed.host.toLowerCase() || null;
264
+ const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
265
+ if (sshMatch) return sshMatch[1].toLowerCase();
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Extract a bare hostname for VCS-family comparisons. This deliberately drops
271
+ * a URI port: `github.com:443` is still the public GitHub host, while the
272
+ * operational `extractHost()` value retains a non-default self-hosted port.
273
+ *
274
+ * @param {string} url
275
+ * @returns {string|null}
276
+ */
277
+ function extractHostname(url) {
278
+ const parsed = parseRemoteUri(url);
279
+ if (parsed !== null) return parsed.hostname.toLowerCase() || null;
241
280
  const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
242
281
  if (sshMatch) return sshMatch[1].toLowerCase();
243
282
  return null;
@@ -367,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
367
406
  }
368
407
 
369
408
  /**
370
- * Resolve the bare hostname of the matching remote, for use with
371
- * `glab api --hostname`/`gh api --hostname` — the `api` subcommand of both
372
- * CLIs does NOT accept `-R`/`--repo` (it has no repo concept), only a
373
- * `--hostname` flag to pin which instance the request targets. This is the
374
- * host-pinning counterpart to `resolveRepoSpec` for those api-only call
375
- * sites.
409
+ * Resolve the operational host of the matching remote, preserving a
410
+ * non-default self-hosted port for `glab api --hostname`/`gh api --hostname`.
411
+ * The `api` subcommand of both CLIs does NOT accept `-R`/`--repo` (it has no
412
+ * repo concept), only `--hostname` to pin which instance the request targets.
413
+ * This is the host-pinning counterpart to `resolveRepoSpec` for those api-only
414
+ * call sites.
376
415
  *
377
416
  * Applies the identical remote-preference-order + cross-family-guard
378
417
  * resolution as `resolveRepoSpec`, just returning the host instead of the
@@ -393,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
393
432
  return isUnsafeForArgv(host) ? undefined : host;
394
433
  }
395
434
 
435
+ /** Project-path characters that cannot name a GitLab namespace/project. */
436
+ // eslint-disable-next-line no-control-regex -- validate every C0/C1 control before encoding a GitLab API path
437
+ const UNSAFE_PROJECT_PATH_CHARS_RE = /[\\\x00-\x1f\x7f-\x9f?#]/;
438
+
439
+ /**
440
+ * Decode a transport project path exactly once, then validate its canonical
441
+ * namespace/project shape. A percent sign surviving the one decode is rejected
442
+ * because `%252e%252e` is indistinguishable from a literal encoded escape; do
443
+ * not normalize that ambiguity into an API target.
444
+ *
445
+ * @param {string} rawProjectPath
446
+ * @param {{ uriPath: boolean }} opts
447
+ * @returns {string|undefined}
448
+ */
449
+ function normalizeGitlabProjectPath(rawProjectPath, { uriPath }) {
450
+ if (typeof rawProjectPath !== 'string') return undefined;
451
+
452
+ let projectPath = rawProjectPath;
453
+ if (uriPath) {
454
+ // URI syntax supplies one separator before the path. More than one is a
455
+ // path segment, not syntax to trim away.
456
+ if (!projectPath.startsWith('/') || projectPath.startsWith('//')) return undefined;
457
+ projectPath = projectPath.slice(1);
458
+ }
459
+
460
+ if (
461
+ projectPath === '' ||
462
+ projectPath.startsWith('/') ||
463
+ projectPath.endsWith('/') ||
464
+ UNSAFE_PROJECT_PATH_CHARS_RE.test(projectPath)
465
+ ) {
466
+ return undefined;
467
+ }
468
+
469
+ let decodedProjectPath;
470
+ try {
471
+ decodedProjectPath = decodeURIComponent(projectPath);
472
+ } catch {
473
+ return undefined;
474
+ }
475
+
476
+ if (
477
+ decodedProjectPath.includes('%') ||
478
+ UNSAFE_PROJECT_PATH_CHARS_RE.test(decodedProjectPath) ||
479
+ isUnsafeForArgv(decodedProjectPath)
480
+ ) {
481
+ return undefined;
482
+ }
483
+
484
+ const withoutGitSuffix = decodedProjectPath.replace(/\.git$/i, '');
485
+ const segments = withoutGitSuffix.split('/');
486
+ if (
487
+ withoutGitSuffix === '' ||
488
+ withoutGitSuffix.startsWith('/') ||
489
+ withoutGitSuffix.endsWith('/') ||
490
+ segments.length < 2 ||
491
+ segments.some((segment) => segment === '' || segment === '.' || segment === '..')
492
+ ) {
493
+ return undefined;
494
+ }
495
+
496
+ return withoutGitSuffix;
497
+ }
498
+
499
+ /**
500
+ * Extract an operational GitLab host and the raw (not URL-normalized) project
501
+ * path. URI parsing owns authority/port validation, while the raw path keeps
502
+ * dot and percent-encoded traversal visible to {@link normalizeGitlabProjectPath}
503
+ * before WHATWG URL normalization could erase it.
504
+ *
505
+ * @param {string} url
506
+ * @returns {{ host: string, rawProjectPath: string, uriPath: boolean }|undefined}
507
+ */
508
+ function extractGitlabProjectTargetParts(url) {
509
+ const scpMatch = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url);
510
+ if (scpMatch) {
511
+ return { host: scpMatch[1], rawProjectPath: scpMatch[2], uriPath: false };
512
+ }
513
+
514
+ const parsed = parseRemoteUri(url);
515
+ if (
516
+ parsed === null ||
517
+ parsed.search !== '' ||
518
+ parsed.hash !== '' ||
519
+ ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
520
+ (parsed.username !== '' || parsed.password !== ''))
521
+ ) {
522
+ return undefined;
523
+ }
524
+
525
+ // `parsed.pathname` is intentionally NOT used: the URL parser resolves
526
+ // `.`/`..` before this boundary can reject them. The regex reads only the
527
+ // path from an already-parsed, supported URI.
528
+ const rawPathMatch = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?(?:[?#].*)?$/i.exec(url);
529
+ if (rawPathMatch === null) return undefined;
530
+ return { host: parsed.host, rawProjectPath: rawPathMatch[1] ?? '', uriPath: true };
531
+ }
532
+
533
+ /**
534
+ * Derive the API target for a GitLab project from the same sanitized,
535
+ * preference-selected remote that powers {@link resolveRepoSpec}. GitLab's REST
536
+ * API takes a URL-encoded `namespace/project` path rather than a remote URL or
537
+ * numeric project ID, so this projection removes the ambient project-metadata
538
+ * lookup from callers that need to pin both project and host.
539
+ *
540
+ * Supports HTTPS, scp-style SSH, and `ssh://` remotes. Credential stripping
541
+ * happens upstream in {@link listRemotes}; this helper returns only an
542
+ * operational host (including a non-default self-hosted port) and a once-only
543
+ * encoded project path, never a remote URL or HTTP userinfo. An SSH login such
544
+ * as `git@host` is transport identity, not project-path userinfo.
545
+ *
546
+ * @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
547
+ * @returns {{ host: string, encodedProjectPath: string }|undefined}
548
+ */
549
+ export function resolveGitlabProjectTarget({ repoRoot, gitRun = defaultGitRun } = {}) {
550
+ const url = resolveRawRemoteUrl({ repoRoot, vcs: 'gitlab', gitRun });
551
+ if (!url) return undefined;
552
+
553
+ const parts = extractGitlabProjectTargetParts(url);
554
+ if (!parts || isUnsafeForArgv(parts.host)) return undefined;
555
+
556
+ const projectPath = normalizeGitlabProjectPath(parts.rawProjectPath, { uriPath: parts.uriPath });
557
+ if (!projectPath) return undefined;
558
+
559
+ return {
560
+ host: parts.host.toLowerCase(),
561
+ encodedProjectPath: encodeURIComponent(projectPath),
562
+ };
563
+ }
564
+
396
565
  /**
397
566
  * @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
398
567
  * `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
@@ -636,7 +805,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
636
805
  const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
637
806
  const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
638
807
  const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
639
- const isWrongFamily = (url) => wrongFamilyHost !== null && extractHost(url) === wrongFamilyHost;
808
+ const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
640
809
 
641
810
  /** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
642
811
  const accept = (remote, via) =>
@@ -670,7 +839,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
670
839
  * @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
671
840
  */
672
841
  function classifyRemoteFamily(remote) {
673
- const host = extractHost(remote.url);
842
+ const host = extractHostname(remote.url);
674
843
  if (host !== null) {
675
844
  if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
676
845
  if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
@@ -7,7 +7,7 @@
7
7
  * Part of v3.1.0 Epic #157, Sub-Epic resource-gate. Issue #193.
8
8
  */
9
9
 
10
- import { probe } from './resource-probe.mjs';
10
+ import { probe, evaluate } from './resource-probe.mjs';
11
11
 
12
12
  // ---------------------------------------------------------------------------
13
13
  // Public API
@@ -30,10 +30,23 @@ async function extractMeasurements(opts) {
30
30
  ramFreeGb: probeOverride.ramFreeGb,
31
31
  // Tests may supply ramAvailableGb to exercise the macOS path; absent → null.
32
32
  ramAvailableGb: probeOverride.ramAvailableGb ?? null,
33
+ // macOS memory_pressure — the highest-precedence memory signal (#1089).
34
+ memoryPressurePctFree: probeOverride.memoryPressurePctFree ?? null,
33
35
  cpuLoadPct: probeOverride.cpuLoadPct,
34
36
  // 5m-average CPU pct (#943); absent → null (legacy overrides → 1m-only judging).
35
37
  cpuLoad5mPct: probeOverride.cpuLoad5mPct ?? null,
38
+ // #1089: live peer SESSIONS from the registry — the unit
39
+ // `concurrent-sessions-warn` is named for. Absent → null, and the
40
+ // rescaled process-count fallback applies.
41
+ peerSessions: probeOverride.peerSessions ?? null,
42
+ // Raw Claude PROCESS count. Historically (and misleadingly) named
43
+ // `concurrentSessions` on this override object; kept as an accepted alias
44
+ // so existing callers keep working, but it is compared against a
45
+ // process-denominated threshold now, never a session-denominated one.
46
+ claudeProcesses: probeOverride.claudeProcesses ?? probeOverride.concurrentSessions ?? null,
36
47
  concurrentSessions: probeOverride.concurrentSessions,
48
+ swapUsedMb: probeOverride.swapUsedMb ?? null,
49
+ zombieProcesses: probeOverride.zombieProcesses ?? null,
37
50
  };
38
51
  }
39
52
 
@@ -48,12 +61,22 @@ async function extractMeasurements(opts) {
48
61
  // macOS: free + reclaimable (vm_stat). null on Linux/Windows where
49
62
  // os.freemem() is already accurate. (#667)
50
63
  ramAvailableGb: snapshot.ram_available_gb ?? null,
64
+ // macOS memory_pressure — outranks both of the above (#1089).
65
+ memoryPressurePctFree: snapshot.memory_pressure_pct_free ?? null,
51
66
  cpuLoadPct: snapshot.cpu_load_pct,
52
67
  // 5m load-average as pct-of-cores (#943). null on Windows/zero-load, where
53
68
  // the gate falls back to judging the 1m-derived cpu_load_pct alone.
54
69
  cpuLoad5mPct: snapshot.cpu_load_5m_pct ?? null,
55
- // concurrent sessions: number of claude processes found by the probe.
70
+ // #1089: live peer SESSIONS (registry, self excluded) what
71
+ // `concurrent-sessions-warn` was always named for. This line used to read
72
+ // `concurrentSessions: snapshot.claude_processes_count`, a measured 6x unit
73
+ // error that made the gate reduce waves on essentially every dispatch.
74
+ peerSessions: snapshot.peer_sessions_count ?? null,
75
+ claudeProcesses: snapshot.claude_processes_count ?? null,
76
+ // Retained for the returned `measurements` object, which callers log.
56
77
  concurrentSessions: snapshot.claude_processes_count ?? 0,
78
+ swapUsedMb: snapshot.swap_used_mb ?? null,
79
+ zombieProcesses: snapshot.zombie_processes_count ?? null,
57
80
  };
58
81
  }
59
82
 
@@ -131,29 +154,32 @@ function applyHeavyRepoCap(result, opts) {
131
154
  }
132
155
 
133
156
  /**
134
- * Rules 3-8: resource-driven decision sequence (RAM/CPU/concurrent-sessions).
135
- * Extracted so `applyDecisionRules` can layer the HR-004 heavy-repo cap on
136
- * top without duplicating this sequence.
157
+ * Resource-driven decision, delegated to `evaluate()` (#1089).
137
158
  *
138
- * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
159
+ * This function used to carry its OWN copy of the RAM/CPU/concurrency rules,
160
+ * running in sequence with first-match-wins. That duplication is exactly why
161
+ * the #667 available-RAM correction only ever landed halfway: it was applied
162
+ * here and in `evaluate()` separately, and the memory_pressure precedence that
163
+ * followed reached only one of the two. There is now one rule engine and this
164
+ * is a translation layer over it.
165
+ *
166
+ * Verdict → decision mapping:
167
+ * critical (hard signal) → coordinator-direct, 0 agents
168
+ * warn (2+ soft signals) → reduce, plannedAgents / 2 (floor 1)
169
+ * green (0-1 soft signals) → proceed at plannedAgents, reasons retained
170
+ *
171
+ * The halving on `warn` is this gate's own policy and deliberately differs
172
+ * from `evaluate()`'s flat cap of 2: the gate knows `plannedAgents` (a wave of
173
+ * 3 should not be "capped" UP to nothing), `evaluate()` does not.
174
+ *
175
+ * @param {object} measurements — from extractMeasurements
139
176
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
140
177
  * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
141
178
  */
142
179
  function computeResourceDecision(measurements, opts) {
143
180
  const { config, plannedAgents } = opts;
144
- const { ramFreeGb, ramAvailableGb, cpuLoadPct, cpuLoad5mPct, concurrentSessions } = measurements;
145
181
  const T = config['resource-thresholds'];
146
182
 
147
- // macOS fix (#667): os.freemem() reports only `Pages free`, which reads
148
- // sub-1 GB even on a 128 GB host with 80+ GB reclaimable cache — a false
149
- // RAM-critical that forced spurious coordinator-direct fallbacks. When the
150
- // probe supplied a numeric `ramAvailableGb` (free + reclaimable, via vm_stat),
151
- // judge RAM thresholds on AVAILABLE; otherwise fall back to FREE (Linux/Win,
152
- // where os.freemem() is already accurate).
153
- const hasAvailable = ramAvailableGb !== null && ramAvailableGb !== undefined;
154
- const effectiveRamGb = hasAvailable ? ramAvailableGb : ramFreeGb;
155
- const ramLabel = hasAvailable ? 'RAM available' : 'RAM free';
156
-
157
183
  // Rule 3: resource-thresholds missing → degrade to proceed (defensive).
158
184
  // Handles legacy pre-#166 configs and test fixtures that omit the key.
159
185
  // The gate is considered "ran" — caller receives measurements but no enforcement.
@@ -166,77 +192,45 @@ function computeResourceDecision(measurements, opts) {
166
192
  };
167
193
  }
168
194
 
169
- // Rule 4: RAM below critical coordinator-direct.
170
- if (effectiveRamGb < T['ram-free-critical-gb']) {
195
+ // Translate the gate's measurement shape into a probe-shaped snapshot so the
196
+ // single rule engine can judge it. Field names differ because the override
197
+ // object is a documented public test seam that predates the snapshot shape.
198
+ const snapshot = {
199
+ ram_free_gb: measurements.ramFreeGb,
200
+ ram_available_gb: measurements.ramAvailableGb ?? null,
201
+ memory_pressure_pct_free: measurements.memoryPressurePctFree ?? null,
202
+ cpu_load_pct: measurements.cpuLoadPct,
203
+ cpu_load_5m_pct: measurements.cpuLoad5mPct ?? null,
204
+ peer_sessions_count: measurements.peerSessions ?? null,
205
+ claude_processes_count: measurements.claudeProcesses ?? null,
206
+ swap_used_mb: measurements.swapUsedMb ?? null,
207
+ zombie_processes_count: measurements.zombieProcesses ?? null,
208
+ };
209
+
210
+ // heavyRepo is applied by applyHeavyRepoCap() on the way out, so it is
211
+ // deliberately NOT passed here — passing it would apply the ceiling twice.
212
+ const verdict = evaluate(snapshot, T);
213
+
214
+ if (verdict.verdict === 'critical') {
171
215
  return {
172
216
  decision: 'coordinator-direct',
173
217
  agents: 0,
174
- reasons: [
175
- `${ramLabel} ${effectiveRamGb}GB < critical ${T['ram-free-critical-gb']}GB — escalating to coordinator-direct`,
176
- ],
177
- measurements,
178
- };
179
- }
180
-
181
- // Rule 5: RAM below min (but above critical) → reduce.
182
- if (effectiveRamGb < T['ram-free-min-gb']) {
183
- return {
184
- decision: 'reduce',
185
- agents: Math.max(1, Math.floor(plannedAgents / 2)),
186
- reasons: [
187
- `${ramLabel} ${effectiveRamGb}GB < min ${T['ram-free-min-gb']}GB — reducing agent count`,
188
- ],
218
+ reasons: verdict.reasons,
189
219
  measurements,
190
220
  };
191
221
  }
192
-
193
- // Rule 6: CPU overloaded → reduce. #943: this gate runs, by construction,
194
- // right after the coordinator's own CPU-saturating quality-gate run — the 1m
195
- // load average still carries that decaying tail (observed 2026-07-30:
196
- // 96% → 91% → 78% → 75% within 36s), so a 1m-only reading systematically
197
- // over-reports and halves waves without a real bottleneck. When the probe
198
- // supplied a numeric 5m percentage, judge on min(1m, 5m): only-1m-high is a
199
- // decaying transient (informational, no reduce), both-high is genuine
200
- // sustained load. `cpuLoad5mPct` null (legacy overrides, Windows) → 1m-only.
201
- const has5mCpu = typeof cpuLoad5mPct === 'number' && Number.isFinite(cpuLoad5mPct);
202
- const effectiveCpuLoadPct = has5mCpu ? Math.min(cpuLoadPct, cpuLoad5mPct) : cpuLoadPct;
203
- if (effectiveCpuLoadPct > T['cpu-load-max-pct']) {
204
- const detail = has5mCpu ? ` (min of 1m ${cpuLoadPct}% / 5m ${cpuLoad5mPct}%)` : '';
222
+ if (verdict.verdict === 'warn') {
205
223
  return {
206
224
  decision: 'reduce',
207
225
  agents: Math.max(1, Math.floor(plannedAgents / 2)),
208
- reasons: [
209
- `CPU load ${effectiveCpuLoadPct}%${detail} > max ${T['cpu-load-max-pct']}% — reducing agent count`,
210
- ],
211
- measurements,
212
- };
213
- }
214
- const cpuTransientNote =
215
- has5mCpu && cpuLoadPct > T['cpu-load-max-pct']
216
- ? `info: CPU 1m load ${cpuLoadPct}% > max ${T['cpu-load-max-pct']}% but 5m load ${cpuLoad5mPct}% is below — decaying transient (typically the coordinator's own just-finished gate run), not reducing (#943)`
217
- : null;
218
-
219
- // Rule 7: concurrent sessions above warn → proceed with warning.
220
- if (concurrentSessions > T['concurrent-sessions-warn']) {
221
- return {
222
- decision: 'proceed',
223
- agents: plannedAgents,
224
- reasons: [
225
- ...(cpuTransientNote ? [cpuTransientNote] : []),
226
- `warn: ${concurrentSessions} concurrent sessions`,
227
- ],
226
+ reasons: verdict.reasons,
228
227
  measurements,
229
228
  };
230
229
  }
231
-
232
- // Rule 8: all within bounds.
233
230
  return {
234
231
  decision: 'proceed',
235
232
  agents: plannedAgents,
236
- reasons: [
237
- ...(cpuTransientNote ? [cpuTransientNote] : []),
238
- 'all thresholds within bounds',
239
- ],
233
+ reasons: verdict.reasons.length > 0 ? verdict.reasons : ['all thresholds within bounds'],
240
234
  measurements,
241
235
  };
242
236
  }