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
@@ -20,7 +20,7 @@ import { probe } from './resource-probe.mjs';
20
20
  * "proceed" decision.
21
21
  *
22
22
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
23
- * @returns {Promise<{ramFreeGb: number, ramAvailableGb: number|null, cpuLoadPct: number, concurrentSessions: number} | {probeFailed: true}>}
23
+ * @returns {Promise<{ramFreeGb: number, ramAvailableGb: number|null, cpuLoadPct: number, cpuLoad5mPct: number|null, concurrentSessions: number} | {probeFailed: true}>}
24
24
  */
25
25
  async function extractMeasurements(opts) {
26
26
  const { probeOverride } = opts;
@@ -31,6 +31,8 @@ async function extractMeasurements(opts) {
31
31
  // Tests may supply ramAvailableGb to exercise the macOS path; absent → null.
32
32
  ramAvailableGb: probeOverride.ramAvailableGb ?? null,
33
33
  cpuLoadPct: probeOverride.cpuLoadPct,
34
+ // 5m-average CPU pct (#943); absent → null (legacy overrides → 1m-only judging).
35
+ cpuLoad5mPct: probeOverride.cpuLoad5mPct ?? null,
34
36
  concurrentSessions: probeOverride.concurrentSessions,
35
37
  };
36
38
  }
@@ -47,6 +49,9 @@ async function extractMeasurements(opts) {
47
49
  // os.freemem() is already accurate. (#667)
48
50
  ramAvailableGb: snapshot.ram_available_gb ?? null,
49
51
  cpuLoadPct: snapshot.cpu_load_pct,
52
+ // 5m load-average as pct-of-cores (#943). null on Windows/zero-load, where
53
+ // the gate falls back to judging the 1m-derived cpu_load_pct alone.
54
+ cpuLoad5mPct: snapshot.cpu_load_5m_pct ?? null,
50
55
  // concurrent sessions: number of claude processes found by the probe.
51
56
  concurrentSessions: snapshot.claude_processes_count ?? 0,
52
57
  };
@@ -54,15 +59,89 @@ async function extractMeasurements(opts) {
54
59
 
55
60
  /**
56
61
  * Apply the gate decision rule sequence (rules 3-8) given measurements and
57
- * config. Returns the full gate result.
62
+ * config, then apply the HR-004 heavy-repo preflight ceiling on top. Returns
63
+ * the full gate result.
58
64
  *
59
- * @param {{ramFreeGb: number, cpuLoadPct: number, concurrentSessions: number}} measurements
65
+ * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
60
66
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
61
67
  * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
62
68
  */
63
69
  function applyDecisionRules(measurements, opts) {
70
+ const result = computeResourceDecision(measurements, opts);
71
+ return applyHeavyRepoCap(result, opts);
72
+ }
73
+
74
+ /**
75
+ * Resolve an `agents-per-wave` config value into a plain numeric cap, or
76
+ * `null` when no cap should apply.
77
+ *
78
+ * `_coerceInteger()` (scripts/lib/config/coercers.mjs) parses the documented
79
+ * HR-003 parenthetical-override syntax — `agents-per-wave: 4 (deep: 18)` —
80
+ * into an OBJECT `{ default: 4, deep: 18 }`, not a plain number. Feeding that
81
+ * object straight into a `typeof cap !== 'number'` guard makes the heavy-repo
82
+ * cap silently no-op for every repo using the override syntax, which defeats
83
+ * HR-004 exactly where it matters most (a heavy repo that also runs deep
84
+ * sessions).
85
+ *
86
+ * `evaluateWaveResourceGate()` has no session-mode input in scope — `waveRole`
87
+ * is a wave role (e.g. "Impl-Core"), not a session mode (e.g. "deep") — so the
88
+ * object shape resolves to `cap.default` here. That is the conservative
89
+ * choice: the documented HR-003 convention writes the override as
90
+ * `<default> (mode: <higher-ceiling>)`, i.e. `default` is the MORE
91
+ * restrictive of the pair. Falling back to it can only under-apply a looser
92
+ * mode-specific ceiling; it never lets a heavy repo exceed its base cap.
93
+ *
94
+ * @param {number|{default: number, [mode: string]: number}|*} cap
95
+ * @returns {number|null}
96
+ */
97
+ function resolveApwCap(cap) {
98
+ if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
99
+ if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
100
+ const def = cap.default;
101
+ return typeof def === 'number' && Number.isFinite(def) ? def : null;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * HR-003/HR-004 heavy-repo preflight ceiling (baseline #60). A STATIC cap
108
+ * independent of the live resource-probe verdict: when `config['heavy-repo']`
109
+ * is `true`, `agents` is clamped to at most `config['agents-per-wave']`
110
+ * (resolved via {@link resolveApwCap} to handle the object-override shape).
111
+ * More-restrictive-wins — this only ever LOWERS `agents`, never raises it
112
+ * above what the resource-driven rules already decided (e.g. a
113
+ * coordinator-direct 0 stays 0).
114
+ *
115
+ * @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
116
+ * @param {object} opts - Same opts shape as evaluateWaveResourceGate
117
+ * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
118
+ */
119
+ function applyHeavyRepoCap(result, opts) {
120
+ const { config } = opts;
121
+ if (!config || config['heavy-repo'] !== true) return result;
122
+ const cap = resolveApwCap(config['agents-per-wave']);
123
+ if (cap === null) return result;
124
+ if (result.agents <= cap) return result; // already within the ceiling — never raise
125
+ return {
126
+ ...result,
127
+ decision: result.decision === 'coordinator-direct' ? 'coordinator-direct' : 'reduce',
128
+ agents: cap,
129
+ reasons: [...result.reasons, `heavy-repo: true caps agents-per-wave to ${cap} (HR-004)`],
130
+ };
131
+ }
132
+
133
+ /**
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.
137
+ *
138
+ * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
139
+ * @param {object} opts - Same opts shape as evaluateWaveResourceGate
140
+ * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
141
+ */
142
+ function computeResourceDecision(measurements, opts) {
64
143
  const { config, plannedAgents } = opts;
65
- const { ramFreeGb, ramAvailableGb, cpuLoadPct, concurrentSessions } = measurements;
144
+ const { ramFreeGb, ramAvailableGb, cpuLoadPct, cpuLoad5mPct, concurrentSessions } = measurements;
66
145
  const T = config['resource-thresholds'];
67
146
 
68
147
  // macOS fix (#667): os.freemem() reports only `Pages free`, which reads
@@ -111,24 +190,41 @@ function applyDecisionRules(measurements, opts) {
111
190
  };
112
191
  }
113
192
 
114
- // Rule 6: CPU overloaded → reduce.
115
- if (cpuLoadPct > T['cpu-load-max-pct']) {
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}%)` : '';
116
205
  return {
117
206
  decision: 'reduce',
118
207
  agents: Math.max(1, Math.floor(plannedAgents / 2)),
119
208
  reasons: [
120
- `CPU load ${cpuLoadPct}% > max ${T['cpu-load-max-pct']}% — reducing agent count`,
209
+ `CPU load ${effectiveCpuLoadPct}%${detail} > max ${T['cpu-load-max-pct']}% — reducing agent count`,
121
210
  ],
122
211
  measurements,
123
212
  };
124
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;
125
218
 
126
219
  // Rule 7: concurrent sessions above warn → proceed with warning.
127
220
  if (concurrentSessions > T['concurrent-sessions-warn']) {
128
221
  return {
129
222
  decision: 'proceed',
130
223
  agents: plannedAgents,
131
- reasons: [`warn: ${concurrentSessions} concurrent sessions`],
224
+ reasons: [
225
+ ...(cpuTransientNote ? [cpuTransientNote] : []),
226
+ `warn: ${concurrentSessions} concurrent sessions`,
227
+ ],
132
228
  measurements,
133
229
  };
134
230
  }
@@ -137,7 +233,10 @@ function applyDecisionRules(measurements, opts) {
137
233
  return {
138
234
  decision: 'proceed',
139
235
  agents: plannedAgents,
140
- reasons: ['all thresholds within bounds'],
236
+ reasons: [
237
+ ...(cpuTransientNote ? [cpuTransientNote] : []),
238
+ 'all thresholds within bounds',
239
+ ],
141
240
  measurements,
142
241
  };
143
242
  }
@@ -148,14 +247,19 @@ function applyDecisionRules(measurements, opts) {
148
247
  * @param {object} opts.config - Parsed Session Config (from parse-config.sh output)
149
248
  * @param {number} opts.plannedAgents - Number of agents the session-plan wants to dispatch
150
249
  * @param {string} opts.waveRole - e.g. "Impl-Core", "Quality"
151
- * @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, concurrentSessions} for
152
- * testing; when omitted, calls resource-probe
250
+ * @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, cpuLoad5mPct?, concurrentSessions}
251
+ * for testing; when omitted, calls resource-probe
153
252
  * @returns {Promise<{decision: "proceed"|"reduce"|"coordinator-direct", agents: number, reasons: string[], measurements: object}>}
154
253
  */
155
254
  export async function evaluateWaveResourceGate(opts) {
156
255
  const { config, plannedAgents } = opts;
157
256
 
158
257
  // Rule 1: resource-awareness disabled — skip all probing.
258
+ // `resource-awareness: false` is a FULL opt-out, INCLUDING the HR-004
259
+ // heavy-repo static cap below (applyHeavyRepoCap runs only inside
260
+ // applyDecisionRules, which this early return bypasses entirely). The
261
+ // static cap only applies on the resource-aware path — deliberate,
262
+ // reviewed 2026-07-23, baseline #60.
159
263
  if (config['resource-awareness'] === false) {
160
264
  return {
161
265
  decision: 'proceed',
@@ -2,7 +2,8 @@
2
2
  * worktree/listing.mjs — list and filter worktrees.
3
3
  *
4
4
  * Exports:
5
- * listWorktrees() — list all git worktrees
5
+ * listWorktrees() — list all git worktrees (bare array; swallows git failure)
6
+ * listWorktreesChecked() — same listing WITH a "git actually ran" signal (#919.3)
6
7
  * applyWorktreeExcludes(wtPath, patterns) — remove top-level dirs from worktree
7
8
  *
8
9
  * No imports from lifecycle.mjs — this module is intentionally a leaf of the
@@ -18,23 +19,39 @@ import path from 'node:path';
18
19
  // ---------------------------------------------------------------------------
19
20
 
20
21
  /**
21
- * List all git worktrees in the current repository.
22
+ * List all git worktrees WITH an explicit "git actually ran" signal (#919.3).
23
+ *
24
+ * The bare `listWorktrees()` below swallows a failing `git worktree list` into
25
+ * an empty array — indistinguishable from "git ran, repo has no extra
26
+ * worktrees". That collapse is the last fail-open gap in the
27
+ * `checkLiveForeignSession` full path (peer-discovery.mjs, #906/#908 residual):
28
+ * a total surface failure read as "nobody home". This variant keeps the listing
29
+ * contract but makes the failure DISTINGUISHABLE, so callers that need the
30
+ * distinction can make the fail-safe call themselves.
22
31
  *
23
32
  * @param {object} [opts]
24
33
  * @param {Function} [opts.$] Optional zx-compatible executor. Defaults to real zx.$.
25
34
  * Tests pass a mock here to avoid vi.mock('zx') under fork pool.
26
- * @returns {Promise<Array<{path: string, branch: string, head: string}>>}
27
- * Array of worktree descriptors; empty array if none or on parse error.
35
+ * @returns {Promise<{ok: boolean, worktrees: Array<{path: string, branch: string, head: string}>, error?: string}>}
36
+ * `ok: true` — `git worktree list` ran; `worktrees` is the (possibly empty)
37
+ * parsed listing. An empty list here is a MEASUREMENT.
38
+ * `ok: false` — the git invocation itself failed (spawn error, non-zero
39
+ * exit, not a repo, …); `worktrees` is `[]` and `error` carries
40
+ * the failure message. An empty list here is NOT a measurement.
28
41
  */
29
- export async function listWorktrees(opts = {}) {
42
+ export async function listWorktreesChecked(opts = {}) {
30
43
  const dollar = opts.$ ?? defaultDollar;
31
44
  const git = dollar({ cwd: process.cwd() });
32
45
  let output;
33
46
  try {
34
47
  const result = await git`git worktree list --porcelain`;
35
48
  output = result.stdout;
36
- } catch {
37
- return [];
49
+ } catch (err) {
50
+ return {
51
+ ok: false,
52
+ worktrees: [],
53
+ error: err instanceof Error ? err.message : String(err),
54
+ };
38
55
  }
39
56
 
40
57
  const worktrees = [];
@@ -67,6 +84,26 @@ export async function listWorktrees(opts = {}) {
67
84
  worktrees.push(current);
68
85
  }
69
86
 
87
+ return { ok: true, worktrees };
88
+ }
89
+
90
+ /**
91
+ * List all git worktrees in the current repository.
92
+ *
93
+ * Thin backward-compatible wrapper over `listWorktreesChecked()`: existing
94
+ * callers get the bare array they always got, INCLUDING the historical
95
+ * swallow-to-`[]` on git failure. Callers that must distinguish "git ran,
96
+ * empty" from "git failed" use `listWorktreesChecked()` instead (#919.3).
97
+ *
98
+ * @param {object} [opts]
99
+ * @param {Function} [opts.$] Optional zx-compatible executor. Defaults to real zx.$.
100
+ * Tests pass a mock here to avoid vi.mock('zx') under fork pool.
101
+ * @returns {Promise<Array<{path: string, branch: string, head: string}>>}
102
+ * Array of worktree descriptors; empty array if none OR on git failure
103
+ * (the two are indistinguishable here — by design, see wrapper note).
104
+ */
105
+ export async function listWorktrees(opts = {}) {
106
+ const { worktrees } = await listWorktreesChecked(opts);
70
107
  return worktrees;
71
108
  }
72
109
 
@@ -8,7 +8,8 @@ set -euo pipefail
8
8
  # - session_config — reads Session Config from the project instruction file
9
9
  # (CLAUDE.md, or AGENTS.md alias on Codex CLI — see
10
10
  # skills/_shared/instruction-file-resolution.md)
11
- # - session_metrics — reads last 5 session metrics entries
11
+ # - session_metrics — reads last 5 REAL session metrics entries (#834:
12
+ # abandoned phantom stubs are filtered out first)
12
13
 
13
14
  # ---------------------------------------------------------------------------
14
15
  # Helpers
@@ -75,7 +76,7 @@ handle_tools_list() {
75
76
  },
76
77
  {
77
78
  "name": "session_metrics",
78
- "description": "Reads the last 5 session metrics entries from .orchestrator/metrics/sessions.jsonl",
79
+ "description": "Reads the last 5 REAL session metrics entries from .orchestrator/metrics/sessions.jsonl (abandoned phantom stubs excluded)",
79
80
  "inputSchema": {"type": "object", "properties": {}, "required": []}
80
81
  }
81
82
  ]
@@ -127,9 +128,23 @@ tool_session_config() {
127
128
  return
128
129
  fi
129
130
 
130
- # Extract everything from "## Session Config" to the next heading or EOF
131
- local config
132
- config=$(sed -n '/^## Session Config$/,/^## /{/^## Session Config$/d;/^## /d;p;}' "$instr_file" 2>/dev/null) || true
131
+ # Extract everything from "## Session Config" to the next heading or EOF.
132
+ #
133
+ # The address matches isSessionConfigHeading() in
134
+ # scripts/lib/config/section-extractor.mjs (the SSOT) exactly: the literal
135
+ # heading, plus an optional CR so a CRLF checkout is read the same way. Shell
136
+ # cannot import the JS predicate, so the alignment is by hand and this
137
+ # comment names the authority. Before #968 this was the only STRICTER site in
138
+ # the repo — a bare `$` anchors before the CR in a BRE, so on a CRLF checkout
139
+ # (this plugin ships to Windows-side Codex/Cursor users) the tool reported
140
+ # "no Session Config section" for a file the runtime parses fine.
141
+ #
142
+ # `\r` is not portable inside a BRE (GNU sed only), so the CR is injected as
143
+ # a literal byte via printf.
144
+ local cr config sc_re
145
+ cr=$(printf '\r')
146
+ sc_re="^## Session Config${cr}\{0,1\}\$"
147
+ config=$(sed -n "/$sc_re/,/^## /{/$sc_re/d;/^## /d;p;}" "$instr_file" 2>/dev/null) || true
133
148
 
134
149
  if [[ -z "$config" ]]; then
135
150
  respond "$id" "$(text_content "No '## Session Config' section found in $instr_file")"
@@ -156,8 +171,19 @@ tool_session_metrics() {
156
171
  return
157
172
  fi
158
173
 
174
+ # Filter out phantom `status: 'abandoned'` stubs (#834, session-close-backfill
175
+ # — 0 waves, seconds of runtime) BEFORE taking the tail, so a recent phantom
176
+ # cannot displace real session records out of the last-5 window.
177
+ #
178
+ # `-R` (raw-input) + `fromjson?` parses each line individually and SKIPS
179
+ # unparseable ones instead of aborting the whole stream — plain
180
+ # `jq -c 'select(...)'` aborts at the FIRST malformed line (jq: parse error,
181
+ # exit 5), which is fatal here because sessions.jsonl is append-only from
182
+ # multiple writers and a torn write is exactly the case that matters. This
183
+ # mirrors the per-line try/catch behaviour of the .mjs path
184
+ # (scripts/lib/session-schema/filters.mjs).
159
185
  local entries
160
- entries=$(tail -n 5 "$metrics_file" 2>/dev/null) || true
186
+ entries=$(jq -R -c 'fromjson? | select(.status != "abandoned")' "$metrics_file" 2>/dev/null | tail -n 5) || true
161
187
 
162
188
  if [[ -z "$entries" ]]; then
163
189
  respond "$id" "$(text_content "No metrics found (file is empty)")"
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # measure-context-overhead.sh — measure the REAL instruction overhead of a directory.
3
+ #
4
+ # Why this exists (and why token-audit.sh does not replace it):
5
+ # token-audit.sh measures instruction bytes ON DISK. That is a proxy, and a poor
6
+ # one: it cannot see which rule files the loader classifies as always-on, it
7
+ # cannot see plugin skill descriptions, and it cannot see the tool definitions.
8
+ # This script measures what actually reaches the model, by sending a trivial
9
+ # prompt and reading the token accounting back out of the API response. Every
10
+ # token it reports is overhead, because the prompt itself does no work.
11
+ #
12
+ # Method: `claude -p "<trivial prompt>" --output-format json`, then sum
13
+ # input_tokens + cache_creation_input_tokens + cache_read_input_tokens.
14
+ # The cache split varies between runs; the SUM is the stable quantity and is
15
+ # what the model is charged for reading.
16
+ #
17
+ # COST WARNING: each measurement is a real API call against the configured
18
+ # model. Measured 2026-07-30 on claude-opus-5[1m]: USD 0.27 (empty directory)
19
+ # to USD 1.06 (this repo). Budget accordingly before running a large matrix.
20
+ #
21
+ # Usage:
22
+ # bash scripts/measure-context-overhead.sh <dir> [<dir> ...]
23
+ # bash scripts/measure-context-overhead.sh --ablate <repo-root>
24
+ #
25
+ # --ablate builds throwaway copies of <repo-root>'s instruction surface
26
+ # (CLAUDE.md (or AGENTS.md on Codex CLI) + .claude/rules/) under $TMPDIR and
27
+ # measures the full / reduced / stripped variants, so the cost of each layer can
28
+ # be attributed. It never writes to, and never deletes from, the source repository.
29
+ #
30
+ # Instruction-file resolution follows skills/_shared/instruction-file-resolution.md:
31
+ # CLAUDE.md wins, AGENTS.md is the Codex CLI alias, empty counts as absent, and
32
+ # exactly ONE of the two is ever read. The resolved file is copied into every
33
+ # variant under the name the measuring harness reads (`claude -p` -> CLAUDE.md),
34
+ # so an AGENTS.md-only repo gets its instruction layer measured instead of
35
+ # silently reported as 0 bytes.
36
+ #
37
+ # Baseline recorded 2026-07-30 (claude-opus-5[1m], CLI 2.1.220) for THIS repo,
38
+ # which resolves to CLAUDE.md — a repo resolving to AGENTS.md has its own:
39
+ # full 110687 tok | no top-3 rules 83138 | no rules 43291 | nothing 42001
40
+ # Reading: the 26 rule files account for 67396 tokens (61% of total); the
41
+ # instruction file itself accounts for 1290. Optimising the instruction file is
42
+ # optimising the wrong file.
43
+
44
+ set -uo pipefail
45
+
46
+ PROMPT='Antworte nur mit dem Wort: OK'
47
+
48
+ measure_one() {
49
+ local dir="$1" label="$2"
50
+ if [ ! -d "$dir" ]; then
51
+ printf '%s\tNO-SUCH-DIR\n' "$label"
52
+ return
53
+ fi
54
+ ( cd "$dir" && claude -p "$PROMPT" --output-format json 2>/dev/null ) | node -e "
55
+ let s = '';
56
+ process.stdin.on('data', (d) => (s += d)).on('end', () => {
57
+ const label = process.argv[1];
58
+ try {
59
+ const j = JSON.parse(s);
60
+ const u = j.usage || {};
61
+ const ctx =
62
+ (u.input_tokens || 0) +
63
+ (u.cache_creation_input_tokens || 0) +
64
+ (u.cache_read_input_tokens || 0);
65
+ process.stdout.write(
66
+ [label, ctx, u.cache_creation_input_tokens || 0, u.cache_read_input_tokens || 0,
67
+ u.output_tokens || 0, (j.total_cost_usd || 0).toFixed(4)].join('\t') + '\n',
68
+ );
69
+ } catch {
70
+ process.stdout.write(label + '\tPARSE-ERROR\n');
71
+ }
72
+ });
73
+ " "$label"
74
+ }
75
+
76
+ header() { printf 'LABEL\tCONTEXT_TOK\tcache_create\tcache_read\toutput\tUSD\n'; }
77
+
78
+ # ── --ablate mode ───────────────────────────────────────────────────
79
+ if [ "${1:-}" = "--ablate" ]; then
80
+ SRC="${2:-$(pwd)}"
81
+ [ -d "$SRC" ] || { echo "not a directory: $SRC" >&2; exit 1; }
82
+
83
+ # Resolve the project-instruction file. CLAUDE.md wins; AGENTS.md is the
84
+ # Codex CLI alias; an empty file counts as absent; never both.
85
+ if [ -s "$SRC/CLAUDE.md" ]; then INSTR_SRC="$SRC/CLAUDE.md"
86
+ elif [ -s "$SRC/AGENTS.md" ]; then INSTR_SRC="$SRC/AGENTS.md"
87
+ else
88
+ echo "no project-instruction file in $SRC (looked for CLAUDE.md, then AGENTS.md)." >&2
89
+ echo "Refusing to ablate: v2-no-rules and v3-bare differ ONLY by that file, so" >&2
90
+ echo "the table would render as 'the instruction layer costs 0 tokens' — the" >&2
91
+ echo "one reading this tool must never produce without evidence." >&2
92
+ exit 1
93
+ fi
94
+ # The measuring harness is `claude -p`, whose instruction file is CLAUDE.md.
95
+ # Copy the resolved source under THAT name so the content is actually loaded,
96
+ # whichever of the two aliases the source repo happens to use.
97
+ INSTR_DEST="CLAUDE.md"
98
+ echo "instruction file: $INSTR_SRC -> <variant>/$INSTR_DEST" >&2
99
+ src_rules=("$SRC"/.claude/rules/*.md)
100
+ [ -e "${src_rules[0]}" ] || \
101
+ echo "note: no .claude/rules/*.md in $SRC — the rule layers measure as zero." >&2
102
+
103
+ BASE="${TMPDIR:-/tmp}/so-ablation-$$"
104
+ mkdir -p "$BASE"
105
+ # Copy ONLY the instruction surface. No git, no source, no side effects.
106
+ build() {
107
+ local v="$BASE/$1"
108
+ mkdir -p "$v/.claude/rules"
109
+ cp "$INSTR_SRC" "$v/$INSTR_DEST" || {
110
+ echo "failed to copy $INSTR_SRC -> $v/$INSTR_DEST" >&2
111
+ exit 1
112
+ }
113
+ cp "$SRC"/.claude/rules/*.md "$v/.claude/rules/" 2>/dev/null || true
114
+ }
115
+ build v0-full
116
+ build v1-no-top3
117
+ rm -f "$BASE/v1-no-top3/.claude/rules/loop-and-monitor.md" \
118
+ "$BASE/v1-no-top3/.claude/rules/parallel-sessions.md" \
119
+ "$BASE/v1-no-top3/.claude/rules/security.md"
120
+ build v2-no-rules
121
+ rm -f "$BASE"/v2-no-rules/.claude/rules/*.md
122
+ build v3-bare
123
+ rm -f "$BASE"/v3-bare/.claude/rules/*.md "$BASE/v3-bare/$INSTR_DEST"
124
+
125
+ echo "=== instruction bytes on disk ==="
126
+ for v in v0-full v1-no-top3 v2-no-rules v3-bare; do
127
+ b=$(cat "$BASE/$v/$INSTR_DEST" "$BASE/$v"/.claude/rules/*.md 2>/dev/null | wc -c | tr -d ' ')
128
+ n=$(ls "$BASE/$v"/.claude/rules/*.md 2>/dev/null | wc -l | tr -d ' ')
129
+ printf ' %-14s %8s B rules=%s\n' "$v" "${b:-0}" "${n:-0}"
130
+ done
131
+ echo
132
+ header
133
+ for v in v0-full v1-no-top3 v2-no-rules v3-bare; do
134
+ measure_one "$BASE/$v" "$v"
135
+ done
136
+ echo
137
+ echo "variants left in $BASE (throwaway; remove when done)"
138
+ exit 0
139
+ fi
140
+
141
+ # ── direct mode ─────────────────────────────────────────────────────
142
+ if [ "$#" -eq 0 ]; then
143
+ echo "usage: bash scripts/measure-context-overhead.sh <dir> [<dir> ...]" >&2
144
+ echo " bash scripts/measure-context-overhead.sh --ablate <repo-root>" >&2
145
+ exit 1
146
+ fi
147
+
148
+ header
149
+ for d in "$@"; do
150
+ measure_one "$d" "$(basename "$d")"
151
+ done
@@ -42,7 +42,7 @@
42
42
  import { parseArgs } from 'node:util';
43
43
  import { readFileSync, existsSync } from 'node:fs';
44
44
  import { spawnSync } from 'node:child_process';
45
- import { dirname, join } from 'node:path';
45
+ import { dirname, join, isAbsolute } from 'node:path';
46
46
  import { fileURLToPath } from 'node:url';
47
47
 
48
48
  import { isWaveAgentContext, WAVE_AGENT_ENV_VAR, WAVE_AGENT_ENV_VALUE } from './lib/wave-context.mjs';
@@ -104,7 +104,8 @@ const rawArgv = process.argv.slice(2);
104
104
  if (rawArgv.includes('--help') || rawArgv.includes('-h')) {
105
105
  process.stdout.write(
106
106
  'Usage: SO_WAVE_AGENT=1 memory-propose.mjs --type <type> --subject "..." ' +
107
- '--insight "..." --evidence "..." --confidence <0-1> [--dry-run]\n\n' +
107
+ '--insight "..." --evidence "..." --confidence <0-1> ' +
108
+ '[--file-paths "a.mjs,b.mjs"] [--dry-run]\n\n' +
108
109
  'Environment:\n' +
109
110
  ' SO_WAVE_AGENT=1 — REQUIRED. The CLI returns exit 3 (rejected-wrong-context)\n' +
110
111
  ' when this env-var is absent or not exactly "1".\n' +
@@ -113,7 +114,14 @@ if (rawArgv.includes('--help') || rawArgv.includes('-h')) {
113
114
  ' --dry-run — Validate the proposal (argv + schema) but do NOT write to\n' +
114
115
  ' proposals.jsonl. Bypasses the STATE.md / SO_WAVE_AGENT /\n' +
115
116
  ' current-wave context gates so it can be run safely from\n' +
116
- ' coordinator context (issue #741.3).\n\n' +
117
+ ' coordinator context (issue #741.3).\n' +
118
+ ' --file-paths — Optional. Repo-relative path(s) this learning applies to.\n' +
119
+ ' Repeatable AND/OR comma-separated (`--file-paths a.mjs\n' +
120
+ ' --file-paths b.mjs,c.mjs`), deduped. Rejects absolute\n' +
121
+ ' paths, ".." segments, embedded newlines, entries over 256\n' +
122
+ ' chars, and more than 20 entries (exit 4). Without\n' +
123
+ ' --file-paths this learning can never become /reconcile-\n' +
124
+ ' eligible (issue #900).\n\n' +
117
125
  'Exit codes / stdout status:\n' +
118
126
  ` 0 — ${STATUS.QUEUED} (or ${STATUS.DRY_RUN_OK} under --dry-run)\n` +
119
127
  ` 1 — ${STATUS.QUOTA_EXCEEDED}\n` +
@@ -133,12 +141,13 @@ try {
133
141
  parsedArgs = parseArgs({
134
142
  args: rawArgv,
135
143
  options: {
136
- type: { type: 'string' },
137
- subject: { type: 'string' },
138
- insight: { type: 'string' },
139
- evidence: { type: 'string' },
140
- confidence: { type: 'string' },
141
- 'dry-run': { type: 'boolean' },
144
+ type: { type: 'string' },
145
+ subject: { type: 'string' },
146
+ insight: { type: 'string' },
147
+ evidence: { type: 'string' },
148
+ confidence: { type: 'string' },
149
+ 'dry-run': { type: 'boolean' },
150
+ 'file-paths': { type: 'string', multiple: true },
142
151
  },
143
152
  strict: false, // emit unknown flags as positionals rather than throwing
144
153
  });
@@ -173,6 +182,59 @@ if (confidenceRaw !== undefined) {
173
182
  }
174
183
  }
175
184
 
185
+ // ---------------------------------------------------------------------------
186
+ // Step 1b — Parse + validate --file-paths (issue #900 C)
187
+ // ---------------------------------------------------------------------------
188
+ //
189
+ // --file-paths is repeatable AND each occurrence may itself be comma-separated
190
+ // (`--file-paths a.mjs --file-paths b.mjs,c.mjs`). Flattened, trimmed,
191
+ // empty-filtered, and deduped BEFORE validation so callers see one clean
192
+ // error per genuinely-bad entry rather than noise from formatting.
193
+ //
194
+ // Validation runs here (Step 1, argv-level) — BEFORE createProposalRecord
195
+ // (Step 6) — so a malformed --file-paths value produces the same exit-4
196
+ // argv-error contract as every other required/optional flag, never a
197
+ // downstream schema-validation surprise.
198
+
199
+ const FILE_PATHS_MAX_COUNT = 20;
200
+ const FILE_PATH_MAX_CHARS = 256;
201
+
202
+ const filePathsRaw = parsedArgs.values['file-paths'];
203
+ /** @type {string[]|undefined} */
204
+ let filePaths;
205
+ if (Array.isArray(filePathsRaw) && filePathsRaw.length > 0) {
206
+ const flattened = filePathsRaw
207
+ .flatMap((entry) => String(entry).split(','))
208
+ .map((p) => p.trim())
209
+ .filter((p) => p.length > 0);
210
+ filePaths = [...new Set(flattened)];
211
+ }
212
+
213
+ if (filePaths !== undefined) {
214
+ if (filePaths.length > FILE_PATHS_MAX_COUNT) {
215
+ argErrors.push(
216
+ `--file-paths accepts at most ${FILE_PATHS_MAX_COUNT} paths (got ${filePaths.length})`,
217
+ );
218
+ }
219
+ for (const p of filePaths) {
220
+ if (isAbsolute(p)) {
221
+ argErrors.push(`--file-paths must be repo-relative — absolute path rejected: "${p}"`);
222
+ } else if (p.split(/[\\/]/).includes('..')) {
223
+ argErrors.push(`--file-paths must not contain ".." path segments: "${p}"`);
224
+ } else if (/[\r\n]/.test(p)) {
225
+ argErrors.push(`--file-paths entries must not contain newline characters: "${p}"`);
226
+ } else if (/[*?[\]{}]/.test(p)) {
227
+ argErrors.push(
228
+ `--file-paths must not contain glob metacharacters (* ? [ ] { }) — a literal repo-relative path is required: "${p}"`,
229
+ );
230
+ } else if (p.length > FILE_PATH_MAX_CHARS) {
231
+ argErrors.push(
232
+ `--file-paths entry exceeds ${FILE_PATH_MAX_CHARS} chars (got ${p.length}): "${p.slice(0, 40)}..."`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
176
238
  if (argErrors.length > 0) {
177
239
  exit({ status: STATUS.ERROR, validation: argErrors }, 4);
178
240
  }
@@ -362,6 +424,7 @@ try {
362
424
  evidence,
363
425
  confidence,
364
426
  waveId,
427
+ filePaths,
365
428
  });
366
429
  } catch (err) {
367
430
  exit({ status: STATUS.ERROR, validation: [`Failed to create proposal record: ${err.message}`] }, 4);