session-orchestrator 3.16.0 → 3.19.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -10,7 +10,13 @@
10
10
  * lines — an append would leave stale duplicates.
11
11
  *
12
12
  * Store: `.orchestrator/runtime/reconcile-candidates.jsonl` — a mutable
13
- * work-queue in JSON-Lines format (one ReconcileCandidate per line).
13
+ * work-queue in JSON-Lines format (one ReconcileCandidate per line). The store
14
+ * is OWNED by `mergeCandidates`: it is the only sanctioned writer. Nothing else
15
+ * — no report, no analysis run, no agent — may append to it; a read-side shape
16
+ * guard drops any record that is not a ReconcileCandidate and COUNTS the drop.
17
+ * The count reaches readers through the ONE reader ({@link loadCandidates}
18
+ * returns `{records, skipped}`) and writers through `mergeCandidates`'s
19
+ * `skipped` — there is deliberately no lossy array-only variant beside them.
14
20
  *
15
21
  * Two responsibilities differ from the repair store:
16
22
  * 1. The IDEMPOTENCY KEY is the LOGICAL `learning_key` (issue #695), not the
@@ -66,12 +72,40 @@ function resolveStorePath(repoRoot, storePath) {
66
72
  return join(root, rel);
67
73
  }
68
74
 
75
+ /**
76
+ * Minimal shape guard for a persisted store line. A record is accepted only
77
+ * when it carries the two fields every consumer of this store depends on:
78
+ * - `learning_key` — THE logical dedupe key (`mergeCandidates`, `isProcessed`).
79
+ * - `created_at` — the recency axis (`reconcile-nudge-banner.mjs` `_lastRunAt`).
80
+ *
81
+ * This is deliberately NOT a full schema check: the store is a mutable
82
+ * work-queue whose records may gain fields across schema versions, so
83
+ * over-strict validation would silently drop legitimate future records. It
84
+ * rejects only records that no writer in this repo produces — the concrete
85
+ * incident being a hand-written report artefact using `candidate_id` /
86
+ * `generated_at` / `status:"candidate"` (2026-07-31, see
87
+ * `docs/reconcile/2026-07-31-reconcile-candidates.md`).
88
+ * @param {unknown} rec
89
+ * @returns {boolean}
90
+ */
91
+ function isCandidateShape(rec) {
92
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
93
+ const r = /** @type {Record<string, unknown>} */ (rec);
94
+ if (typeof r.learning_key !== 'string' || r.learning_key.length === 0) return false;
95
+ if (typeof r.created_at !== 'string') return false;
96
+ return true;
97
+ }
98
+
69
99
  /**
70
100
  * Read + defensively parse the store's JSONL lines into ReconcileCandidate
71
- * records. Malformed lines (bad JSON, non-object) are skipped silently. A
72
- * missing file yields `[]`. Never throws.
101
+ * records. Malformed lines (bad JSON, non-object) and shape-foreign records
102
+ * (see {@link isCandidateShape}) are skipped — the latter are COUNTED, because
103
+ * the store is a mutable work-queue that `mergeCandidates` rewrites in full, so
104
+ * a skipped line is dropped from disk on the next merge and a silent drop would
105
+ * be unattributable data loss. A missing file yields `{ records: [], skipped: 0 }`.
106
+ * Never throws.
73
107
  * @param {string} absPath
74
- * @returns {ReconcileCandidate[]}
108
+ * @returns {{ records: ReconcileCandidate[], skipped: number }}
75
109
  */
76
110
  function readStore(absPath) {
77
111
  let raw;
@@ -79,11 +113,12 @@ function readStore(absPath) {
79
113
  raw = readFileSync(absPath, 'utf8');
80
114
  } catch {
81
115
  // ENOENT or any read error → empty store.
82
- return [];
116
+ return { records: [], skipped: 0 };
83
117
  }
84
118
 
85
119
  /** @type {ReconcileCandidate[]} */
86
120
  const records = [];
121
+ let skipped = 0;
87
122
  for (const line of raw.split('\n')) {
88
123
  const trimmed = line.trim();
89
124
  if (trimmed.length === 0) continue;
@@ -91,13 +126,16 @@ function readStore(absPath) {
91
126
  try {
92
127
  parsed = JSON.parse(trimmed);
93
128
  } catch {
129
+ skipped += 1;
94
130
  continue; // skip malformed line
95
131
  }
96
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
132
+ if (isCandidateShape(parsed)) {
97
133
  records.push(/** @type {ReconcileCandidate} */ (parsed));
134
+ } else {
135
+ skipped += 1;
98
136
  }
99
137
  }
100
- return records;
138
+ return { records, skipped };
101
139
  }
102
140
 
103
141
  /**
@@ -153,13 +191,70 @@ export function makeCandidateId(learningKey, slug) {
153
191
  }
154
192
 
155
193
  /**
156
- * Load every ReconcileCandidate currently persisted in the store. Reads JSONL,
157
- * skips malformed lines, returns `[]` for a missing file. Does NOT create the
194
+ * Build a ReconcileCandidate line-record for a proposed or rejected learning.
195
+ *
196
+ * Lives here — beside the {@link ReconcileCandidate} typedef it instantiates and
197
+ * the read-side `isCandidateShape` guard that judges it — so ONE file decides
198
+ * which fields a persisted record carries. It deliberately does NOT mint the
199
+ * `id` (see {@link makeCandidateId}): rejections currently derive their id from
200
+ * `(learningKey, 'rejected-<type>')` while storing `slug: ''`, so folding the
201
+ * mint in here using the record's own slug would change every rejection
202
+ * candidate's id and orphan every rejection row already on disk. Caller-supplied
203
+ * `id` keeps that decision at the call site.
204
+ *
205
+ * `created_at` is caller-supplied (from the engine's injectable clock) so output
206
+ * stays deterministic under test. `processed_at`/`superseded_by` always start
207
+ * null — only the merge/approval path stamps them. Never throws.
208
+ *
209
+ * @param {Object} params
210
+ * @param {string} params.id - deterministic physical id, see {@link makeCandidateId}.
211
+ * @param {string|null} params.learningKey - logical dedupe key; a non-string coerces to `''`.
212
+ * @param {string} params.slug - `.claude/rules/<slug>.md` slug (`''` for rejections).
213
+ * @param {'proposed'|'rejected'} params.status
214
+ * @param {string} params.reason
215
+ * @param {number} params.confidence
216
+ * @param {string} params.createdAt - ISO timestamp.
217
+ * @returns {ReconcileCandidate}
218
+ */
219
+ export function buildCandidate({ id, learningKey, slug, status, reason, confidence, createdAt }) {
220
+ return {
221
+ id,
222
+ schema_version: 1,
223
+ learning_key: typeof learningKey === 'string' ? learningKey : '',
224
+ slug,
225
+ status,
226
+ reason,
227
+ confidence,
228
+ created_at: createdAt,
229
+ processed_at: null,
230
+ superseded_by: null,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Load the persisted store: every ReconcileCandidate that survives the read-side
236
+ * shape guard, PLUS the count of lines it rejected. Reads JSONL, skips malformed
237
+ * lines and shape-foreign records (missing `learning_key` / `created_at`),
238
+ * yields `{ records: [], skipped: 0 }` for a missing file. Does NOT create the
158
239
  * runtime dir (mkdir -p happens only on write). Never throws.
240
+ *
241
+ * `skipped` is part of the return value rather than a second, "diagnostics"
242
+ * reader beside this one, because `records.length === 0` is AMBIGUOUS on its
243
+ * own: a missing store and a store whose every line is shape-foreign both yield
244
+ * `[]`, and a consumer that sees only the array reports "no reconcile run on
245
+ * record" for a store that in fact holds quarantined evidence of one (the
246
+ * concrete defect in `scripts/lib/reconcile-nudge-banner.mjs`, GitLab #955
247
+ * finding 2). Splitting the honest reader off under a longer name left the
248
+ * OBVIOUS name as the lossy one — the next consumer would reach for
249
+ * `loadCandidates`, get `[]`, and re-derive the same wrong conclusion. One
250
+ * reader, one answer.
251
+ *
252
+ * Read-only: unlike {@link mergeCandidates} this does NOT rewrite the store, so
253
+ * the skipped lines are still on disk after this call.
159
254
  * @param {Object} [params]
160
255
  * @param {string} [params.repoRoot] - repo root; relative `storePath` is resolved against it (defaults to `process.cwd()`).
161
256
  * @param {string} [params.storePath] - store path (relative ⇒ joined to repoRoot). Defaults to {@link DEFAULT_STORE_PATH}.
162
- * @returns {ReconcileCandidate[]}
257
+ * @returns {{ records: ReconcileCandidate[], skipped: number }}
163
258
  */
164
259
  export function loadCandidates({ repoRoot, storePath } = {}) {
165
260
  const absPath = resolveStorePath(repoRoot, storePath);
@@ -198,16 +293,21 @@ export function isProcessed(candidate, existing) {
198
293
  * The runtime dir is created with mkdir -p semantics. The store is rewritten in
199
294
  * full (read-all → merge → atomic tmp+rename), never appended. Output lines are
200
295
  * sorted by `learning_key` for deterministic output. Never throws; on write
201
- * failure returns `{ merged, written: false }`.
296
+ * failure returns `written: false`.
297
+ *
298
+ * `skipped` reports how many persisted lines the read-side shape guard rejected
299
+ * (malformed JSON, or a record missing `learning_key`/`created_at`). Because the
300
+ * store is rewritten in full, those lines are DROPPED from disk by this call —
301
+ * the count is what makes that loss attributable instead of silent.
202
302
  * @param {Object} [params]
203
303
  * @param {ReconcileCandidate[]} [params.candidates] - newly minted candidates to merge.
204
304
  * @param {string} [params.repoRoot]
205
305
  * @param {string} [params.storePath]
206
- * @returns {{ merged: ReconcileCandidate[], written: boolean }}
306
+ * @returns {{ merged: ReconcileCandidate[], written: boolean, skipped: number }}
207
307
  */
208
308
  export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
209
309
  const absPath = resolveStorePath(repoRoot, storePath);
210
- const store = readStore(absPath);
310
+ const { records: store, skipped } = readStore(absPath);
211
311
 
212
312
  // Index existing records by learning_key for O(1) lookup. Last write wins for
213
313
  // any pre-existing duplicates in the file (defensive — store should be unique).
@@ -235,5 +335,5 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
235
335
  );
236
336
 
237
337
  const result = writeStore(absPath, merged);
238
- return { merged, written: result.ok === true };
338
+ return { merged, written: result.ok === true, skipped };
239
339
  }
@@ -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
  */