session-orchestrator 3.20.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 (202) 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/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -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
  }
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Materialize both wave file-scope declaration shapes from one canonical array.
4
+ *
5
+ * Usage:
6
+ * node scripts/materialize-wave-scope.mjs --state-dir <dir> --wave <positive-int> [--json] < scopes.json
7
+ *
8
+ * The stdin document is an array of `{ id, files }` records. The command writes
9
+ * each bare `files` array first, then writes the complete record array as the
10
+ * aggregate sidecar consumed by validate-wave-scope's --assert-disjoint and
11
+ * --union modes.
12
+ */
13
+
14
+ import { readFileSync, unlinkSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { writeJsonAtomicSync } from './lib/io.mjs';
18
+
19
+ const HELP = `Usage: node scripts/materialize-wave-scope.mjs --state-dir <dir> --wave <positive-int> [--json]
20
+
21
+ Read one JSON array of {id, files} records from stdin and materialize both
22
+ wave-scope declaration shapes from that canonical record array.
23
+
24
+ Required:
25
+ --state-dir <dir> State directory that owns filescopes/.
26
+ --wave <positive-int>
27
+ Positive wave number used in filescopes/wave-N/.
28
+
29
+ Options:
30
+ --json Emit {ok, aggregatePath, perAgentPaths} to stdout.
31
+ -h, --help Show this help and exit 0.
32
+
33
+ Output:
34
+ Human mode prints only the aggregate sidecar path. --json prints one JSON
35
+ object. Diagnostics are written only to stderr.
36
+
37
+ Examples:
38
+ printf '%s' '[{"id":"W1-I1","files":["scripts/example.mjs"]},{"id":"coordinator","files":[]}]' | \\
39
+ node scripts/materialize-wave-scope.mjs --state-dir .claude --wave 1
40
+ node scripts/materialize-wave-scope.mjs --state-dir .claude --wave 1 --json < scopes.json
41
+
42
+ Writes:
43
+ <state-dir>/filescopes/wave-N/<id>.json Bare string[] for each record
44
+ <state-dir>/filescopes/wave-N.scopes.json Aggregate [{id, files}, ...]
45
+
46
+ Exit codes:
47
+ 0 All declaration files and the aggregate sidecar were written.
48
+ 1 Usage or input validation error; no write was attempted.
49
+ 2 Filesystem or write error; earlier per-agent writes are retained and any
50
+ previous aggregate is invalidated before per-agent writes begin.
51
+ `;
52
+
53
+ class InputError extends Error {}
54
+ class WriteError extends Error {}
55
+
56
+ /**
57
+ * @param {string[]} argv
58
+ * @returns {{ stateDir: string, wave: number, json: boolean, help: boolean }}
59
+ */
60
+ export function parseCliArgs(argv) {
61
+ let stateDir;
62
+ let waveRaw;
63
+ let json = false;
64
+ let help = false;
65
+
66
+ for (let index = 0; index < argv.length; index++) {
67
+ const arg = argv[index];
68
+ if (arg === '--json') {
69
+ json = true;
70
+ continue;
71
+ }
72
+ if (arg === '--help' || arg === '-h') {
73
+ help = true;
74
+ continue;
75
+ }
76
+ if (arg === '--state-dir' || arg === '--wave') {
77
+ const value = argv[index + 1];
78
+ if (value === undefined || value.startsWith('--')) {
79
+ throw new InputError(`${arg} requires a value`);
80
+ }
81
+ if (arg === '--state-dir') {
82
+ if (stateDir !== undefined) throw new InputError('--state-dir may be specified only once');
83
+ stateDir = value;
84
+ } else {
85
+ if (waveRaw !== undefined) throw new InputError('--wave may be specified only once');
86
+ waveRaw = value;
87
+ }
88
+ index++;
89
+ continue;
90
+ }
91
+ throw new InputError(`unknown argument: ${arg}`);
92
+ }
93
+
94
+ if (help) return { stateDir: '', wave: 0, json, help: true };
95
+ if (stateDir === undefined) throw new InputError('--state-dir is required');
96
+ if (waveRaw === undefined) throw new InputError('--wave is required');
97
+ if (stateDir.length === 0 || /[\0\r\n]/.test(stateDir)) {
98
+ throw new InputError('--state-dir must be a non-empty path without NUL or newline characters');
99
+ }
100
+ if (!/^[1-9]\d*$/.test(waveRaw)) {
101
+ throw new InputError('--wave must be a positive integer');
102
+ }
103
+
104
+ const wave = Number(waveRaw);
105
+ if (!Number.isSafeInteger(wave)) throw new InputError('--wave must be a safe positive integer');
106
+ return { stateDir, wave, json, help: false };
107
+ }
108
+
109
+ /**
110
+ * @param {unknown} value
111
+ * @returns {value is Record<string, unknown>}
112
+ */
113
+ function isRecord(value) {
114
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
115
+ }
116
+
117
+ /**
118
+ * A path is scope data rather than an output target, so absolute paths remain
119
+ * valid for sanctioned out-of-repository grants. Traversal/control characters
120
+ * are rejected because no scope consumer can safely interpret them verbatim.
121
+ *
122
+ * @param {unknown} value
123
+ * @param {number} recordIndex
124
+ * @param {number} fileIndex
125
+ */
126
+ function validateScopePath(value, recordIndex, fileIndex) {
127
+ if (typeof value !== 'string' || value.length === 0 || value.trim().length === 0) {
128
+ throw new InputError(`record #${recordIndex} files[${fileIndex}] must be a non-empty string`);
129
+ }
130
+ if (/[\0\r\n]/.test(value)) {
131
+ throw new InputError(`record #${recordIndex} files[${fileIndex}] must not contain NUL or newline characters`);
132
+ }
133
+ if (value.split(/[\\/]+/).includes('..')) {
134
+ throw new InputError(`record #${recordIndex} files[${fileIndex}] must not contain path traversal`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validate the canonical record array completely before a write is attempted.
140
+ * The original values are returned without sorting, deduplication, or expansion.
141
+ *
142
+ * @param {unknown} value
143
+ * @returns {Array<{id: string, files: string[]}>}
144
+ */
145
+ export function validateScopeRecords(value) {
146
+ if (!Array.isArray(value) || value.length === 0) {
147
+ throw new InputError('stdin must be a non-empty JSON array of {id, files} records');
148
+ }
149
+
150
+ const seenIds = new Set();
151
+ let coordinatorCount = 0;
152
+ for (let recordIndex = 0; recordIndex < value.length; recordIndex++) {
153
+ const record = value[recordIndex];
154
+ if (!isRecord(record)) {
155
+ throw new InputError(`record #${recordIndex} must be an object with id and files`);
156
+ }
157
+ if (typeof record.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(record.id)) {
158
+ throw new InputError(`record #${recordIndex} has an unsafe id`);
159
+ }
160
+ const idKey = record.id.toLowerCase();
161
+ if (seenIds.has(idKey)) {
162
+ throw new InputError(`record #${recordIndex} has duplicate id (case-insensitive): ${record.id}`);
163
+ }
164
+ seenIds.add(idKey);
165
+ if (idKey === 'coordinator') {
166
+ if (record.id !== 'coordinator') {
167
+ throw new InputError(`record #${recordIndex} id must be exactly coordinator (lowercase)`);
168
+ }
169
+ coordinatorCount++;
170
+ }
171
+
172
+ if (!Array.isArray(record.files)) {
173
+ throw new InputError(`record #${recordIndex} (${record.id}) must have a files string array`);
174
+ }
175
+ for (let fileIndex = 0; fileIndex < record.files.length; fileIndex++) {
176
+ validateScopePath(record.files[fileIndex], recordIndex, fileIndex);
177
+ }
178
+ }
179
+
180
+ if (coordinatorCount !== 1) {
181
+ throw new InputError(`input must contain exactly one coordinator record; found ${coordinatorCount}`);
182
+ }
183
+ return value;
184
+ }
185
+
186
+ /**
187
+ * Materialize validated declarations in their required write order.
188
+ *
189
+ * The optional writer is a narrow seam for deterministic write-failure tests;
190
+ * production always delegates to writeJsonAtomicSync. There is intentionally no
191
+ * rollback: a prior aggregate is invalidated before per-agent writes, then the
192
+ * aggregate publication marker is written only after every per-agent declaration
193
+ * has succeeded.
194
+ *
195
+ * @param {Array<{id: string, files: string[]}>} records
196
+ * @param {{ stateDir: string, wave: number, writeJson?: typeof writeJsonAtomicSync }} options
197
+ * @returns {{ aggregatePath: string, perAgentPaths: string[] }}
198
+ */
199
+ export function materializeWaveScope(records, { stateDir, wave, writeJson = writeJsonAtomicSync }) {
200
+ const scopeDir = resolve(stateDir, 'filescopes', `wave-${wave}`);
201
+ const aggregatePath = resolve(stateDir, 'filescopes', `wave-${wave}.scopes.json`);
202
+ const perAgentPaths = records.map(({ id }) => resolve(scopeDir, `${id}.json`));
203
+
204
+ try {
205
+ unlinkSync(aggregatePath);
206
+ } catch (error) {
207
+ if (error?.code !== 'ENOENT') {
208
+ throw new WriteError(`cannot invalidate aggregate declaration ${aggregatePath}: ${error instanceof Error ? error.message : String(error)}`);
209
+ }
210
+ }
211
+
212
+ for (let index = 0; index < records.length; index++) {
213
+ const result = writeJson(perAgentPaths[index], records[index].files, { tmpPrefix: '.materialize-wave-scope' });
214
+ if (!result?.ok) {
215
+ throw new WriteError(`cannot write per-agent declaration ${perAgentPaths[index]}: ${result?.error ?? 'unknown write failure'}`);
216
+ }
217
+ }
218
+
219
+ const aggregateResult = writeJson(aggregatePath, records, { tmpPrefix: '.materialize-wave-scope' });
220
+ if (!aggregateResult?.ok) {
221
+ throw new WriteError(`cannot write aggregate declaration ${aggregatePath}: ${aggregateResult?.error ?? 'unknown write failure'}`);
222
+ }
223
+ return { aggregatePath, perAgentPaths };
224
+ }
225
+
226
+ /**
227
+ * @param {string} message
228
+ * @param {number} code
229
+ */
230
+ function fail(message, code) {
231
+ process.stderr.write(`materialize-wave-scope: ${message}\n`);
232
+ process.exitCode = code;
233
+ }
234
+
235
+ function readStdinJson() {
236
+ let raw;
237
+ try {
238
+ raw = readFileSync(0, 'utf8');
239
+ } catch (error) {
240
+ throw new WriteError(`cannot read stdin: ${error instanceof Error ? error.message : String(error)}`);
241
+ }
242
+ try {
243
+ return JSON.parse(raw);
244
+ } catch {
245
+ throw new InputError('stdin is not valid JSON');
246
+ }
247
+ }
248
+
249
+ /** Execute the CLI, mapping usage/input and I/O failures to its exit contract. */
250
+ export function main() {
251
+ let args;
252
+ try {
253
+ args = parseCliArgs(process.argv.slice(2));
254
+ if (args.help) {
255
+ process.stdout.write(HELP);
256
+ return;
257
+ }
258
+ const records = validateScopeRecords(readStdinJson());
259
+ const { aggregatePath, perAgentPaths } = materializeWaveScope(records, args);
260
+ process.stdout.write(
261
+ args.json
262
+ ? `${JSON.stringify({ ok: true, aggregatePath, perAgentPaths })}\n`
263
+ : `${aggregatePath}\n`,
264
+ );
265
+ } catch (error) {
266
+ if (error instanceof InputError) {
267
+ fail(error.message, 1);
268
+ return;
269
+ }
270
+ if (error instanceof WriteError) {
271
+ fail(error.message, 2);
272
+ return;
273
+ }
274
+ fail(`unexpected system error: ${error instanceof Error ? error.message : String(error)}`, 2);
275
+ }
276
+ }
277
+
278
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
279
+ if (invokedPath === fileURLToPath(import.meta.url)) {
280
+ main();
281
+ }
@@ -23,6 +23,13 @@
23
23
  * block therefore rides the dispatch-prompt channel the repo already owns and
24
24
  * writes itself — it adds no new delivery mechanism.
25
25
  *
26
+ * With ONE exception, closed in #1019: a learning that `/reconcile` has already
27
+ * turned into a `.claude/rules/*.md` file DOES have a native path, and shipping
28
+ * it here too is the same duplication in miniature. `--rules-dir` (default
29
+ * `.claude/rules`) feeds that set to the selector, which drops those records
30
+ * before its Top-N cut so the freed slot goes to a learning the agent has no
31
+ * other way to see. A repo with no rules directory is unaffected, byte for byte.
32
+ *
26
33
  * ## An INDEX, not a corpus
27
34
  *
28
35
  * One line per learning plus a retrieval pointer. The agent that needs the full
@@ -65,7 +72,8 @@
65
72
  * `<learnings-<token>>` … `</learnings-<token>>` fence. Empty
66
73
  * selection → NO output at all (exit 0) so the caller prepends
67
74
  * nothing.
68
- * - --json → `{ count, scopeMatched, rejected, learnings: [...] }`
75
+ * - --json → `{ count, scopeMatched, rejected, deliveredFiltered,
76
+ * learnings: [...] }`
69
77
  *
70
78
  * Exit codes (per .claude/rules/cli-design.md):
71
79
  * 0 — success, INCLUDING EPIPE (a truncating reader — `| head`, `| grep -q` —
@@ -144,6 +152,10 @@ Options:
144
152
  --max-chars <n> Hard cap on the rendered index body (default: ${LEARNINGS_INDEX_MAX_CHARS}).
145
153
  --pool-size <n> Active entries pulled before ranking (default: ${CANDIDATE_POOL_SIZE}).
146
154
  --learnings <path> Learnings JSONL (default: .orchestrator/metrics/learnings.jsonl).
155
+ --rules-dir <path> Natively-delivered rule corpus (default: .claude/rules).
156
+ Learnings already delivered as a rule file there are
157
+ excluded from the index (#1019). A path that does not
158
+ exist means "this repo delivers no rules" -> no filtering.
147
159
  --no-event Suppress the orchestrator.learnings.index.injected event.
148
160
  --json Emit { count, scopeMatched, learnings:[...] } instead of
149
161
  the Markdown block.
@@ -194,6 +206,7 @@ try {
194
206
  'max-chars': { type: 'string' },
195
207
  'pool-size': { type: 'string' },
196
208
  learnings: { type: 'string' },
209
+ 'rules-dir': { type: 'string' },
197
210
  'no-event': { type: 'boolean', default: false },
198
211
  json: { type: 'boolean', default: false },
199
212
  },
@@ -236,6 +249,13 @@ const learningsPath = opts.learnings
236
249
  ? opts.learnings
237
250
  : join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
238
251
  const eventsPath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
252
+ // #1019 — the natively-delivered rule corpus. Every `.claude/rules/*.md` reaches
253
+ // a dispatched agent in FULL through Claude Code's own project-instruction
254
+ // loading (`docs/instruction-delivery.md` §1: the `globs:`/`tier:` frontmatter is
255
+ // inert because `rule-loader.mjs` does not run on that path), so a learning that
256
+ // already became a rule must not also spend a slot in this index. An absent
257
+ // directory yields an empty set and the index is byte-identical to before.
258
+ const rulesDir = opts['rules-dir'] ? opts['rules-dir'] : join(repoRoot, '.claude', 'rules');
239
259
 
240
260
  // ---------------------------------------------------------------------------
241
261
  // Scope resolution ladder: --file-scope -> --wave-scope allowedPaths -> empty
@@ -284,7 +304,8 @@ if (opts['file-scope']) {
284
304
  const doc = readJsonOrNull(opts['file-scope'], true, '--file-scope');
285
305
  // The agent's "Files:" scope is written as a bare JSON array (#796
286
306
  // $AGENT_FILESCOPE_JSON); tolerate an {allowedPaths:[...]} wrapper too so the
287
- // same temp file can be reused for either flag without a reshape step.
307
+ // same file can be reused for either flag without a reshape step. That file is
308
+ // `<state-dir>/filescopes/wave-<N>/<agent-id>.json` (#1020), never a $TMPDIR copy.
288
309
  scopePaths = cleanPaths(Array.isArray(doc) ? doc : doc?.allowedPaths);
289
310
  if (scopePaths.length > 0) scopeSource = 'file-scope';
290
311
  }
@@ -315,7 +336,7 @@ try {
315
336
  selection = await selectLearningsFromFile(
316
337
  learningsPath,
317
338
  { file_paths: scopePaths, text: taskText },
318
- { maxScoped, maxGlobal, maxChars, poolSize },
339
+ { maxScoped, maxGlobal, maxChars, poolSize, rulesDir },
319
340
  );
320
341
  } catch (err) {
321
342
  // `selectLearningsFromFile` is contractually total (contract point 1), so this
@@ -419,6 +440,11 @@ function emitInjectedEvent(bytes) {
419
440
  // Non-zero means the untrusted-text guard dropped a record. Carried in the
420
441
  // event so a drop is observable after the fact rather than silent.
421
442
  rejected: selection.rejected,
443
+ // #1019 — records skipped because `.claude/rules/*.md` already delivers
444
+ // them natively. Same reason as `rejected`: without the count, a filter
445
+ // that stopped biting looks exactly like a corpus with no rule-derived
446
+ // learnings in it.
447
+ delivered_filtered: selection.deliveredFiltered,
422
448
  bytes,
423
449
  scope_source: scopeSource,
424
450
  });
@@ -451,6 +477,7 @@ if (opts.json) {
451
477
  count: selected.length,
452
478
  scopeMatched,
453
479
  rejected: selection.rejected,
480
+ deliveredFiltered: selection.deliveredFiltered,
454
481
  learnings: selected.map((e) => ({
455
482
  id: e.id,
456
483
  type: e.type,