session-orchestrator 3.17.0 → 3.20.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 (221) 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/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -23,9 +23,13 @@
23
23
  * `.orchestrator/runtime/reconcile-candidates.jsonl` (owned by
24
24
  * `scripts/lib/reconcile/idempotency.mjs`). Every learning present at a given
25
25
  * run (eligible OR rejected) gets a candidate row, so:
26
- * - the MAX `created_at` across `loadCandidates({repoRoot})` is the most
27
- * recent reconcile run's timestamp (or `null` when the store is empty —
28
- * i.e. no run on record, matching the fleet finding above).
26
+ * - the MAX `created_at` across `loadCandidates({repoRoot}).records` is the
27
+ * most recent reconcile run's timestamp (or `null` when the store is
28
+ * empty — i.e. no run on record, matching the fleet finding above).
29
+ * `.skipped` is read alongside it because `records.length === 0` alone is
30
+ * AMBIGUOUS: a missing store and a store whose every line failed the
31
+ * candidate shape guard both yield `[]`. Reporting "never" for the latter
32
+ * denies a run whose record was merely quarantined (GitLab #955 finding 2).
29
33
  * - the candidate COUNT is a reasonable proxy for "how many learnings had
30
34
  * been seen as of the last run" (dedup is by `learning_key`, so it is a
31
35
  * high-water mark across all runs to date), used for the "new learnings
@@ -116,7 +120,11 @@ function _lastRunAt(candidates) {
116
120
  * delta: number,
117
121
  * nudge: boolean,
118
122
  * reasons: string[],
123
+ * skippedCandidates?: number,
119
124
  * }>}
125
+ * `skippedCandidates` is ABSENT when the candidate store was never inspected
126
+ * (early empty-corpus return, or an unreadable store); `0` means inspected and
127
+ * clean, `> 0` means that many persisted lines failed the candidate shape guard.
120
128
  */
121
129
  export async function computeReconcileNudge(opts = {}) {
122
130
  const empty = {
@@ -167,20 +175,39 @@ export async function computeReconcileNudge(opts = {}) {
167
175
 
168
176
  /** @type {Array<Record<string, unknown>>} */
169
177
  let candidates;
178
+ /** @type {number|undefined} */
179
+ let skippedCandidates;
170
180
  try {
171
- candidates = loadCandidates({ repoRoot });
181
+ const diag = loadCandidates({ repoRoot });
182
+ candidates = Array.isArray(diag?.records) ? diag.records : [];
183
+ // Absence-preserving, mirroring `engine.mjs` `summary.skipped`: only a
184
+ // finite count means "the store was inspected". Absent ⇒ never checked,
185
+ // 0 ⇒ checked and clean.
186
+ if (Number.isFinite(diag?.skipped)) skippedCandidates = Number(diag.skipped);
172
187
  } catch {
173
188
  candidates = [];
189
+ // Store never inspected → leave `skippedCandidates` absent rather than
190
+ // fabricating a clean 0.
174
191
  }
175
192
 
176
193
  const lastRunAt = _lastRunAt(candidates);
177
194
  const lastRunCandidateCount = Array.isArray(candidates) ? candidates.length : 0;
178
195
  const delta = entries.length - lastRunCandidateCount;
196
+ const quarantined = typeof skippedCandidates === 'number' ? skippedCandidates : 0;
179
197
 
180
198
  const reasons = [];
181
- // (a) — plenty of active learnings, but no reconcile run has ever recorded them.
199
+ // (a) — plenty of active learnings and no DATEABLE run on record. When the
200
+ // store holds quarantined lines the honest claim is "undeterminable", not
201
+ // "never": the evidence exists, it is merely unreadable. /reconcile is still
202
+ // the right action either way — mergeCandidates rewrites the store in full and
203
+ // purges the bad lines — so the nudge fires in both cases, only the wording
204
+ // differs.
182
205
  if (active.length >= NUDGE_MIN_LEARNINGS && lastRunAt === null) {
183
- reasons.push(`${active.length} active learnings with no reconcile run on record`);
206
+ reasons.push(
207
+ quarantined > 0
208
+ ? `${active.length} active learnings; last reconcile run undeterminable — ${quarantined} unreadable record(s) in the candidate store`
209
+ : `${active.length} active learnings with no reconcile run on record`,
210
+ );
184
211
  }
185
212
  // (b) — a determinable prior run exists, and the corpus has grown meaningfully since.
186
213
  if (lastRunAt !== null && delta > NUDGE_MIN_DELTA) {
@@ -191,7 +218,7 @@ export async function computeReconcileNudge(opts = {}) {
191
218
  reasons.push(`${eligibleCount} rule-eligible learnings`);
192
219
  }
193
220
 
194
- return {
221
+ const computed = {
195
222
  totalLearnings: entries.length,
196
223
  activeLearnings: active.length,
197
224
  eligibleCount,
@@ -201,6 +228,12 @@ export async function computeReconcileNudge(opts = {}) {
201
228
  nudge: reasons.length > 0,
202
229
  reasons,
203
230
  };
231
+ // Additive + absence-preserving: the key exists ONLY when the candidate store
232
+ // was actually inspected, so no consumer can read a false `skippedCandidates: 0`.
233
+ if (typeof skippedCandidates === 'number') {
234
+ /** @type {any} */ (computed).skippedCandidates = skippedCandidates;
235
+ }
236
+ return computed;
204
237
  }
205
238
 
206
239
  /**
@@ -261,10 +294,33 @@ export async function checkReconcileNudge(opts = {}) {
261
294
  }
262
295
  }
263
296
 
264
- const lastRunLabel =
297
+ // Three-state last-run label. `never` is a claim about history and must only
298
+ // be made when the store was inspected and held nothing: a store whose lines
299
+ // were quarantined by the shape guard is EVIDENCE OF A RUN that can no longer
300
+ // be dated, so it reads `undeterminable` — saying "never" there would assert
301
+ // an absence the file on disk contradicts (GitLab #955 finding 2).
302
+ const quarantined =
303
+ Number.isFinite(computed.skippedCandidates) && computed.skippedCandidates > 0
304
+ ? Number(computed.skippedCandidates)
305
+ : 0;
306
+ const dated =
265
307
  typeof computed.lastRunAt === 'string' && computed.lastRunAt.length >= 10
266
308
  ? computed.lastRunAt.slice(0, 10)
267
- : 'never';
309
+ : null;
310
+ let lastRunLabel;
311
+ if (dated !== null) {
312
+ // Partially contaminated: the date is real but derived only from the
313
+ // surviving records, so flag that it may under-report.
314
+ lastRunLabel =
315
+ quarantined > 0
316
+ ? `${dated} (+${quarantined} unreadable record(s) — date may be stale)`
317
+ : dated;
318
+ } else {
319
+ lastRunLabel =
320
+ quarantined > 0
321
+ ? `undeterminable (${quarantined} unreadable record(s) in the candidate store)`
322
+ : 'never';
323
+ }
268
324
 
269
325
  const lines = [
270
326
  `⚠ reconcile-nudge: ${computed.activeLearnings} active learnings, ${computed.eligibleCount} rule-eligible, ` +
@@ -47,14 +47,54 @@ const MACOS_HEALTHY_PRESSURE_PCT = 30;
47
47
  * and return a verdict used by session-start Phase 4.5.
48
48
  * @param {object} snapshot — output of probe()
49
49
  * @param {object} thresholds — resource-thresholds block from parseSessionConfig
50
+ * @param {{heavyRepo?: boolean, agentsPerWave?: number|{default: number, [mode: string]: number}}} [options] — HR-003/HR-004
51
+ * preflight ceiling (baseline #60). When `heavyRepo` is true and `agentsPerWave`
52
+ * resolves to a number (see {@link resolveAgentsPerWaveCap}), `recommended_agents_per_wave_cap`
53
+ * is forced to at most that number REGARDLESS of the live-probe verdict — a
54
+ * static preflight ceiling, not a runtime signal. More-restrictive-wins:
55
+ * effective = min(existing cap ?? Infinity, agentsPerWave). Omitted entirely
56
+ * = today's behaviour (back-compat).
57
+ *
58
+ * `agentsPerWave` accepts either a plain number OR the `{default, <mode>: N}`
59
+ * object `_coerceInteger()` (scripts/lib/config/coercers.mjs) produces for
60
+ * the parenthetical override syntax (e.g. `agents-per-wave: 4 (deep: 18)`) —
61
+ * `skills/session-start/phase-4-5-resource-health.md` documents wiring
62
+ * `config['agents-per-wave']` straight into this option, so the object shape
63
+ * is a real input here, not a hypothetical one.
50
64
  * @returns {{verdict: 'green'|'warn'|'degraded'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null}}
51
65
  */
52
- export function evaluate(snapshot, thresholds) {
66
+
67
+ /**
68
+ * Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
69
+ * when no cap should apply. Mirrors `resolveApwCap()` in
70
+ * `../wave-resource-gate.mjs` — kept as a local pure helper here rather than
71
+ * a cross-module import since both sites are ≤10 lines and evolve
72
+ * independently per their own gate's options shape.
73
+ *
74
+ * `evaluate()` has no session-mode input in scope, so the object shape
75
+ * resolves to `cap.default` — the documented HR-003 convention writes the
76
+ * override as `<default> (mode: <higher-ceiling>)`, i.e. `default` is the
77
+ * MORE restrictive of the pair, so this can only under-apply a looser
78
+ * mode-specific ceiling, never let a heavy repo exceed its base cap.
79
+ *
80
+ * @param {number|{default: number, [mode: string]: number}|*} cap
81
+ * @returns {number|null}
82
+ */
83
+ function resolveAgentsPerWaveCap(cap) {
84
+ if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
85
+ if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
86
+ const def = cap.default;
87
+ return typeof def === 'number' && Number.isFinite(def) ? def : null;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ export function evaluate(snapshot, thresholds, options = {}) {
53
93
  const reasons = [];
54
94
  let verdict = 'green';
55
95
  let cap = null;
56
96
 
57
- const { ram_free_gb, cpu_load_pct, claude_processes_count, memory_pressure_pct_free, ram_available_gb } = snapshot;
97
+ const { ram_free_gb, cpu_load_pct, cpu_load_5m_pct, claude_processes_count, memory_pressure_pct_free, ram_available_gb } = snapshot;
58
98
  const {
59
99
  'ram-free-min-gb': ramMin,
60
100
  'ram-free-critical-gb': ramCrit,
@@ -93,10 +133,24 @@ export function evaluate(snapshot, thresholds) {
93
133
  reasons.push(`macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${MACOS_HEALTHY_PRESSURE_PCT}%) — free-RAM signal suppressed (Pages-free underreports on Darwin).`);
94
134
  }
95
135
 
96
- if (cpu_load_pct > cpuMax) {
136
+ // CPU axis (#943): the 1m load average systematically carries the decaying
137
+ // tail of the coordinator's own just-finished gate run (the caller sits right
138
+ // after the inter-wave Quality Gate by construction). When the probe supplied
139
+ // a numeric `cpu_load_5m_pct`, judge CPU on min(1m, 5m) — only-1m-high is a
140
+ // decaying transient (informational, no cap), both-high is genuine sustained
141
+ // load. Mirrors the memory_pressure healthy-suppression pattern above.
142
+ // `cpu_load_5m_pct` absent/null (legacy snapshots, Windows) → legacy 1m-only.
143
+ const has5mCpu = typeof cpu_load_5m_pct === 'number' && Number.isFinite(cpu_load_5m_pct);
144
+ const effectiveCpuPct = has5mCpu ? Math.min(cpu_load_pct, cpu_load_5m_pct) : cpu_load_pct;
145
+ if (effectiveCpuPct > cpuMax) {
97
146
  if (verdict === 'green') verdict = 'warn';
98
147
  cap = cap === null ? 2 : Math.min(cap, 2);
99
- reasons.push(`CPU load ${cpu_load_pct}% above threshold ${cpuMax}% capping agents-per-wave at 2.`);
148
+ const detail = has5mCpu ? ` (min of 1m ${cpu_load_pct}% / 5m ${cpu_load_5m_pct}%)` : '';
149
+ reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}% — capping agents-per-wave at 2.`);
150
+ } else if (has5mCpu && cpu_load_pct > cpuMax) {
151
+ // Informational only: 1m spike with a calm 5m average = decaying transient
152
+ // (typically the coordinator's own just-finished gate run). No cap.
153
+ reasons.push(`CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); no cap (#943).`);
100
154
  }
101
155
 
102
156
  if (claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count >= concWarn) {
@@ -186,5 +240,17 @@ export function evaluate(snapshot, thresholds) {
186
240
  cap = 0;
187
241
  }
188
242
 
243
+ // ---------------------------------------------------------------------------
244
+ // HR-003/HR-004 heavy-repo preflight ceiling (#60): a STATIC cap independent
245
+ // of the live-probe verdict. Applies only when heavyRepo is true and
246
+ // agentsPerWave is a finite number — more-restrictive-wins against whatever
247
+ // the live-probe signals already computed.
248
+ // ---------------------------------------------------------------------------
249
+ const { heavyRepo, agentsPerWave } = options;
250
+ const resolvedApwCap = resolveAgentsPerWaveCap(agentsPerWave);
251
+ if (heavyRepo === true && resolvedApwCap !== null) {
252
+ cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
253
+ }
254
+
189
255
  return { verdict, reasons, recommended_agents_per_wave_cap: cap };
190
256
  }
@@ -15,6 +15,8 @@
15
15
  * ram_used_pct: 78,
16
16
  * cpu_load_1m: 2.4,
17
17
  * cpu_load_pct: 65,
18
+ * cpu_load_5m: 1.8,
19
+ * cpu_load_5m_pct: 45 | null, // 5m load-average as pct-of-cores; null on Windows/zero-load (#943)
18
20
  * claude_processes_count: 3 | null,
19
21
  * codex_processes_count: 0 | null,
20
22
  * other_node_processes: 12 | null,
@@ -37,6 +39,7 @@
37
39
  * - scripts/lib/resource-probe/evaluate.mjs — verdict + threshold logic
38
40
  */
39
41
 
42
+ import os from 'node:os';
40
43
  import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctFree, ramAvailableGb } from './resource-probe/probe-platform.mjs';
41
44
 
42
45
  // ---------------------------------------------------------------------------
@@ -63,6 +66,20 @@ export async function probe(opts = {}) {
63
66
  const start = Date.now();
64
67
  const ram = ramSnapshot();
65
68
  const cpu = cpuSnapshot();
69
+
70
+ // #943: additionally sample the 5-minute load average. The wave-resource-gate
71
+ // runs, by construction, right after the coordinator's own CPU-saturating
72
+ // quality-gate run — the 1m average still carries that decaying tail
73
+ // (observed 2026-07-30: 96% → 91% → 78% → 75% within 36s after a Full Gate at
74
+ // 813% CPU), while the 5m average smooths it. Consumers (evaluate(),
75
+ // wave-resource-gate) judge CPU on min(1m, 5m) when the 5m signal exists.
76
+ // Windows reports loadavg [0,0,0] → cpu_load_5m_pct stays null there and
77
+ // consumers fall back to cpu_load_pct alone (which cpuSnapshot() derives from
78
+ // per-core times on Windows).
79
+ const load5m = os.loadavg()[1];
80
+ const cpuCores = (os.cpus() || []).length || 1;
81
+ const cpu_load_5m = Math.round(load5m * 10) / 10;
82
+ const cpu_load_5m_pct = load5m > 0 ? Math.min(100, Math.round((load5m / cpuCores) * 100)) : null;
66
83
  const zombieThresholdMin = opts.zombieThresholdMin ?? null;
67
84
  const procs = opts.skipProcessCounts
68
85
  ? { claude_processes_count: null, codex_processes_count: null, other_node_processes: null, zombie_processes_count: null }
@@ -84,6 +101,8 @@ export async function probe(opts = {}) {
84
101
  timestamp: new Date().toISOString(),
85
102
  ...ram,
86
103
  ...cpu,
104
+ cpu_load_5m,
105
+ cpu_load_5m_pct,
87
106
  ...procs,
88
107
  swap_used_mb,
89
108
  memory_pressure_pct_free,
@@ -88,6 +88,12 @@ function getPicomatch() {
88
88
  * Minimal glob-to-RegExp fallback used only when picomatch is absent.
89
89
  * Handles `**`, `*`, and literal character matching.
90
90
  *
91
+ * Drift contract (W4 B2): a semantically FROZEN twin of this function lives in
92
+ * scripts/lib/command-blocker.mjs (`redirectGlobToRegExp`) — duplicated there
93
+ * because that module is hook-hot-path pure and must not import this one.
94
+ * Syntax extensions made here do NOT automatically apply there; mirror them
95
+ * deliberately or document the divergence.
96
+ *
91
97
  * @param {string} pattern
92
98
  * @returns {RegExp}
93
99
  */