session-orchestrator 3.17.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 (187) 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 +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -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);
@@ -26,7 +26,9 @@
26
26
  * - --json → `{ count, rules: [{path, alwaysOn, matchedGlobs}] }`
27
27
  *
28
28
  * Exit codes (per .claude/rules/cli-design.md):
29
- * 0 — success
29
+ * 0 — success, INCLUDING EPIPE (the reader closed its end of the pipe
30
+ * early — `| head`, `| grep -q`, any truncating consumer — before the
31
+ * full payload drained; see the process.stdout 'error' handler below)
30
32
  * 1 — user/input error (bad --wave-scope path, malformed wave-scope JSON)
31
33
  * 2 — system error (unexpected internal failure)
32
34
  * Data → stdout, diagnostics → stderr.
@@ -34,6 +36,11 @@
34
36
  * Best-effort by design: a missing rules dir, missing STATE.md, or missing
35
37
  * host.json each degrade to "no gating / no rules" rather than failing — the
36
38
  * wave-executor caller treats any non-zero exit as "inject nothing, continue".
39
+ * EPIPE is deliberately EXCLUDED from that "non-zero = inject nothing"
40
+ * contract: the actual wave-executor caller drains stdout fully
41
+ * (execFileSync/spawnSync-style capture) and never closes the pipe early, so
42
+ * EPIPE can only be triggered by an exploratory or truncating reader — never
43
+ * by the real caller this CLI exists to serve.
37
44
  *
38
45
  * Related: issue #336 (glob-scoped rules), #694 (rule-activation / FA1),
39
46
  * scripts/lib/rule-loader.mjs (loadApplicableRules),
@@ -49,6 +56,31 @@ import { findProjectRoot } from './lib/common.mjs';
49
56
  import { loadApplicableRules } from './lib/rule-loader.mjs';
50
57
  import { readHostClass } from './lib/autopilot/telemetry.mjs';
51
58
 
59
+ // ---------------------------------------------------------------------------
60
+ // EPIPE hardening (regression follow-up on #876)
61
+ // ---------------------------------------------------------------------------
62
+ //
63
+ // process.stdout.write() to a pipe is ASYNCHRONOUS in Node. When the reader
64
+ // closes its end early — `| head`, `| grep -q`, any truncating consumer —
65
+ // before the writer has finished draining, the deferred write fails and
66
+ // process.stdout emits an 'error' event carrying err.code === 'EPIPE'. Left
67
+ // unhandled, that is an UNCAUGHT EXCEPTION: Node prints a stack trace to
68
+ // stderr ("Unhandled 'error' event") and exits 1 — even though nothing
69
+ // actually failed on the producer side; the reader simply chose to stop
70
+ // consuming. Registered before any stdout write below so it covers every
71
+ // output branch (--help, --json, Markdown, and the empty-match no-op).
72
+ //
73
+ // Exit 0 on EPIPE, matching conventional Unix CLI behaviour (`cat file |
74
+ // head` reports no error to its caller) and restoring the pre-#876 exit
75
+ // contract for this specific case. Any other stdout write error is
76
+ // unexpected and is re-thrown rather than swallowed.
77
+ process.stdout.on('error', (err) => {
78
+ if (err && err.code === 'EPIPE') {
79
+ process.exit(0);
80
+ }
81
+ throw err;
82
+ });
83
+
52
84
  const HELP = `Usage: node scripts/print-applicable-rules.mjs [options]
53
85
 
54
86
  Prints the glob-scoped + always-on rule set applicable to the current wave,
@@ -223,6 +255,18 @@ try {
223
255
  fail(`Rule loading failed: ${err.message}`, 2);
224
256
  }
225
257
 
258
+ // NOTE (#876): deliberately no `process.exit(0)` after these stdout writes.
259
+ // process.stdout.write() to a pipe is ASYNCHRONOUS in Node — an explicit
260
+ // process.exit() terminates the process before the kernel pipe buffer (64KiB
261
+ // on macOS) has been fully drained, silently truncating any payload beyond
262
+ // that threshold with exit code still 0. Letting the script fall off the end
263
+ // lets Node's event loop wait for the pending write to flush before the
264
+ // process exits naturally (default exit code 0) — the fix generalizes to any
265
+ // payload size, not just today's measured ~105KB. The three branches below
266
+ // are mutually exclusive (if/else-if/else) so only one ever writes to stdout.
267
+ // When the reader closes early instead of draining to completion, the write
268
+ // fails with EPIPE — handled asynchronously by the process.stdout 'error'
269
+ // listener registered near the top of this file, not by anything here.
226
270
  if (opts.json) {
227
271
  const out = {
228
272
  count: rules.length,
@@ -233,15 +277,10 @@ if (opts.json) {
233
277
  })),
234
278
  };
235
279
  process.stdout.write(JSON.stringify(out, null, 2) + '\n');
236
- process.exit(0);
237
- }
238
-
239
- // Markdown block. Empty match set print nothing (caller injects nothing).
240
- if (rules.length === 0) {
241
- process.exit(0);
280
+ } else if (rules.length === 0) {
281
+ // Empty match set → print nothing (caller injects nothing).
282
+ } else {
283
+ const header = '## Applicable Rules (scoped to this wave)';
284
+ const body = rules.map((r) => r.content.trimEnd()).join('\n\n---\n\n');
285
+ process.stdout.write(`${header}\n\n${body}\n`);
242
286
  }
243
-
244
- const header = '## Applicable Rules (scoped to this wave)';
245
- const body = rules.map((r) => r.content.trimEnd()).join('\n\n---\n\n');
246
- process.stdout.write(`${header}\n\n${body}\n`);
247
- process.exit(0);