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
@@ -4,7 +4,22 @@
4
4
  *
5
5
  * Raw-file-property lint for a CLAUDE.md / AGENTS.md instruction file: line
6
6
  * count, per-line character length, and (optionally) a provenance-header
7
- * check on line 1. Deliberately narrow scope this module measures
7
+ * check on line 1. The line-count budget excludes ONE named exempt region —
8
+ * the runtime-critical `## Session Config` block, which is machine-parsed
9
+ * configuration rather than trimmable prose (#959); see
10
+ * `findSessionConfigRegion()`. Its opening-heading predicate is IMPORTED from
11
+ * the runtime parser (`config/section-extractor.mjs`) so this lint can never
12
+ * be more permissive than the parser it measures for.
13
+ *
14
+ * NOT extended to `## Skill Evolution` / `## Dispatcher Autonomy` despite
15
+ * those blocks being equally machine-parsed (19 of the 61 non-exempt lines).
16
+ * Measured reason, not taste: `config/{skill-evolution,dispatcher-autonomy}.mjs`
17
+ * anchor on the YAML KEY (`matchBlockHeader(line, 'skill-evolution')`), never
18
+ * on the `##` heading — the heading is prose decoration the parser ignores, so
19
+ * there is no runtime region boundary for a lint predicate to agree WITH.
20
+ * Exempting by heading there would assert a region the runtime does not
21
+ * delimit that way: a fresh instance of the very defect this import fixes.
22
+ * Deliberately narrow scope — this module measures
8
23
  * properties of the instruction file itself and is NOT a replacement for
9
24
  * `instruction-budget-guard.mjs` (which measures always-on directive COUNT
10
25
  * across `.claude/rules/*.md`). The two are complementary probes, not
@@ -19,16 +34,28 @@
19
34
  * Stdlib-only, no third-party deps. `lintClaudeMd()` is pure computation and
20
35
  * throws `ClaudeMdLintInfraError` on any unreadable/missing-file condition —
21
36
  * the CLI (`main()`) is the sole place that catches this and maps it to
22
- * exit 2, mirroring the `checker.mjs` contract below.
37
+ * exit 2 (a genuine infra error). CLI-argument errors (parsed in
38
+ * `parseArgs()`/`argError()`) are a SEPARATE class and map to exit 1 instead
39
+ * — see the exit-code contract below.
23
40
  *
24
- * Exit-code contract (mirrors skills/claude-md-drift-check/checker.mjs):
41
+ * Exit-code contract (#892 CLI-arg hygiene fix — DIVERGES from
42
+ * skills/claude-md-drift-check/checker.mjs, which still conflates CLI
43
+ * argument errors with infra errors under exit 2; that module is out of
44
+ * this fix's file scope, so the divergence is deliberate and not
45
+ * backported there):
25
46
  * 0 — no violations, OR violations present but --mode warn
26
- * 1 — violations present AND --mode hard (the CLI default)
27
- * 2 — infra error (missing file, unreadable file, invalid --mode)
47
+ * 1 — violations present AND --mode hard (the CLI default), OR a CLI
48
+ * argument error (missing flag value, unknown flag, invalid --mode,
49
+ * non-numeric --max-lines/--max-line-chars) — per
50
+ * .claude/rules/cli-design.md § Exit Codes, an argument error is a
51
+ * USER/input error, never a system error.
52
+ * 2 — infra error ONLY: missing/unreadable CLAUDE.md/AGENTS.md target file
53
+ * (surfaced by lintClaudeMd()/resolveLintTarget()).
28
54
  *
29
55
  * Cross-references:
30
56
  * - scripts/lib/instruction-budget-guard.mjs (sibling directive-count probe)
31
- * - skills/claude-md-drift-check/checker.mjs (exit-code contract this mirrors)
57
+ * - skills/claude-md-drift-check/checker.mjs (infra-error half of the exit-code contract this mirrors; CLI-arg-error half deliberately diverges, see above)
58
+ * - scripts/lib/description-surface.mjs (source of the argError()/CLI-arg-hygiene pattern this module now follows, #878 FA2c)
32
59
  * - scripts/lib/rules-sync.mjs (PLUGIN_HEADER_PREFIX provenance-header convention)
33
60
  * - skills/bootstrap/fast-template.md § Step 2c (bootstrap wiring)
34
61
  * - skills/bootstrap/SKILL.md § Phase 4.5 (bootstrap wiring)
@@ -36,8 +63,9 @@
36
63
 
37
64
  import { readFileSync, existsSync, statSync } from 'node:fs';
38
65
  import { fileURLToPath } from 'node:url';
39
- import { resolve } from 'node:path';
66
+ import { basename, resolve } from 'node:path';
40
67
  import { resolveInstructionFile } from './common.mjs';
68
+ import { isSessionConfigHeading } from './config/section-extractor.mjs';
41
69
 
42
70
  /** Thrown by `lintClaudeMd()` on any infra-level failure (missing/unreadable file). */
43
71
  export class ClaudeMdLintInfraError extends Error {
@@ -47,8 +75,31 @@ export class ClaudeMdLintInfraError extends Error {
47
75
  }
48
76
  }
49
77
 
50
- /** Default line-count ceiling — mirrors the "lean root" convention (pointers, not prose). */
51
- export const DEFAULT_MAX_LINES = 150;
78
+ /**
79
+ * Default line-count ceiling — mirrors the "lean root" convention (pointers,
80
+ * not prose). Applied to the file's NON-EXEMPT lines (see
81
+ * `findSessionConfigRegion()` below), never to the raw count.
82
+ *
83
+ * **Derived from measurement, not aspiration (#959).** The predecessor value
84
+ * 150 was applied to the RAW line count and was therefore structurally
85
+ * unreachable for this repo: the `## Session Config` block alone is
86
+ * runtime-critical (parsed by `scripts/parse-config.mjs`;
87
+ * `claude-md-drift-check` Check 6 enforces top-level-key parity against
88
+ * `docs/session-config-template.md`) and cannot be trimmed as prose. An
89
+ * unreachable ceiling manufactures standing deletion pressure with no
90
+ * nameable target per file — the same failure that killed the tests:src
91
+ * ceiling of 1.20 (`.claude/rules/test-value.md` § TV-003).
92
+ *
93
+ * Measured 2026-07-31 at HEAD 1f7b449: this repo's CLAUDE.md is 209 lines in
94
+ * this lint's units (`wc -l` reports 208 — see the off-by-one note on
95
+ * `lintClaudeMd`), of which the Session Config block spans 148 (heading at
96
+ * line 42, next `## ` heading at line 190), leaving **61 non-exempt lines**.
97
+ * 61 × 1.31 ≈ 80 — the same re-derivation headroom TV-003 used when it moved
98
+ * 1.20 → 1.60 (+33%). Every shipped invocation path is `--mode warn` or the
99
+ * never-gating session-start banner, so a tight ceiling costs at most one
100
+ * warn line.
101
+ */
102
+ export const DEFAULT_MAX_LINES = 80;
52
103
  /** Default per-line character ceiling. */
53
104
  export const DEFAULT_MAX_LINE_CHARS = 400;
54
105
 
@@ -56,13 +107,94 @@ export const DEFAULT_MAX_LINE_CHARS = 400;
56
107
  * the plugin's own string (a baseline-generated CLAUDE.md carries its own header). */
57
108
  const PROVENANCE_HEADER_RE = /^<!--\s*source:/;
58
109
 
110
+ /**
111
+ * Opening heading of the ONE named exempt region: the runtime-critical
112
+ * `## Session Config` block (#959).
113
+ *
114
+ * **The predicate is IMPORTED, never re-derived here.** This module measures a
115
+ * block that `parseSessionConfig` parses; if the two disagree about where the
116
+ * block begins, the measurement describes a file the runtime never sees. The
117
+ * first version of this lint owned a local regex that tolerated a trailing
118
+ * HTML comment — justified by this repo's own `## Current State <!-- … -->`
119
+ * convention — which made it the LOOSEST of five copies of this fact and the
120
+ * only one that mattered. An author decorating the heading as that cited
121
+ * convention encourages got: `_extractConfigSection` → `[]` (every runtime
122
+ * config key silently at its default) while this lint reported
123
+ * `148 exempt: "## Session Config"`, affirming the block at the exact moment
124
+ * the runtime had lost it.
125
+ *
126
+ * Invariant, now structural rather than reviewed: **this lint can never accept
127
+ * a heading the runtime parser rejects, because it asks the runtime parser.**
128
+ * The import is safe at bootstrap-scaffold time — `section-extractor.mjs`
129
+ * imports nothing (verified; keep it so).
130
+ *
131
+ * The remaining copies of this fact live in `config-protection.mjs:112`,
132
+ * `product-repo-detect.mjs:113`, `ecosystem-wizard/config-writer.mjs:214`, and
133
+ * `harness-audit/categories/category4.mjs:163`. They are outside this fix's
134
+ * file scope; each is an exact-literal match, so none is looser than the
135
+ * parser — the dangerous asymmetry was here alone.
136
+ */
137
+
138
+ /** Region terminator: the next top-level `## ` heading of ANY title (must still
139
+ * match a decorated one like `## Skill Evolution <!-- … -->`). The bare-prefix
140
+ * form deliberately does NOT match `### `, whose third char is `#`, not a space. */
141
+ const H2_PREFIX_RE = /^## /;
142
+
143
+ /**
144
+ * Locates the `## Session Config` exempt region. **Fail-closed by
145
+ * construction** — every uncertain case exempts NOTHING, so the budget can
146
+ * only ever over-count, never under-count:
147
+ *
148
+ * - **0 occurrences** → exempt nothing. A consumer repo's CLAUDE.md, or an
149
+ * AGENTS.md with no config block, is measured in full.
150
+ * - **≥2 occurrences** → exempt nothing AND report it to the caller as a
151
+ * violation. Exempting both would let an author hide arbitrary prose under a
152
+ * duplicated heading; exempting only the first is silently wrong.
153
+ * - **exactly 1** → exempt from the heading line through the line before the
154
+ * next `## ` heading, or through EOF when none follows (a real in-repo
155
+ * fixture, `tests/fixtures/harness-audit/clean-repo/CLAUDE.md`, ends that way).
156
+ *
157
+ * @param {string[]} lines - the file split on '\n'.
158
+ * @returns {{ exemptLines: number, headingLines: number[] }} `headingLines` is
159
+ * 1-based and carries EVERY occurrence found (length ≥ 2 signals a duplicate).
160
+ */
161
+ function findSessionConfigRegion(lines) {
162
+ const headingIdx = [];
163
+ for (let i = 0; i < lines.length; i++) {
164
+ if (isSessionConfigHeading(lines[i])) headingIdx.push(i);
165
+ }
166
+ const headingLines = headingIdx.map((i) => i + 1);
167
+
168
+ if (headingIdx.length !== 1) return { exemptLines: 0, headingLines };
169
+
170
+ const start = headingIdx[0];
171
+ let end = lines.length; // EOF-terminated block
172
+ for (let i = start + 1; i < lines.length; i++) {
173
+ if (H2_PREFIX_RE.test(lines[i])) {
174
+ end = i;
175
+ break;
176
+ }
177
+ }
178
+ return { exemptLines: end - start, headingLines };
179
+ }
180
+
59
181
  /**
60
182
  * Lints a CLAUDE.md / AGENTS.md file's raw properties. Pure computation —
61
183
  * never catches its own read failures; throws `ClaudeMdLintInfraError`.
62
184
  *
185
+ * Because every file ends with a trailing newline, `lineCount` is `wc -l` + 1.
186
+ * That off-by-one is DELIBERATE and load-bearing: the ceiling in
187
+ * `DEFAULT_MAX_LINES` was derived in these same units. Do not "fix" it.
188
+ *
189
+ * The `max-lines` ceiling is compared against `effectiveLineCount`
190
+ * (= `lineCount` − `exemptLines`), not against the raw count — see
191
+ * `findSessionConfigRegion()` for the one named exempt region and its
192
+ * fail-closed 0-or-≥2 rule (#959). `lineCount` stays RAW so the split is
193
+ * reportable rather than hidden.
194
+ *
63
195
  * @param {object} opts
64
196
  * @param {string} opts.filePath - absolute or cwd-relative path to the file.
65
- * @param {number} [opts.maxLines] - line-count ceiling (default 150).
197
+ * @param {number} [opts.maxLines] - NON-EXEMPT line-count ceiling (default 80).
66
198
  * @param {number} [opts.maxLineChars] - per-line char ceiling (default 400).
67
199
  * @param {boolean} [opts.requireProvenance] - when true, a missing/absent
68
200
  * provenance header on line 1 is a violation (default false).
@@ -70,9 +202,11 @@ const PROVENANCE_HEADER_RE = /^<!--\s*source:/;
70
202
  * status: 'ok' | 'invalid',
71
203
  * file: string,
72
204
  * lineCount: number,
205
+ * exemptLines: number,
206
+ * effectiveLineCount: number,
73
207
  * maxLineCharsSeen: number,
74
208
  * hasProvenance: boolean,
75
- * violations: Array<{ rule: 'max-lines' | 'max-line-chars' | 'provenance-header', message: string, line?: number }>,
209
+ * violations: Array<{ rule: 'max-lines' | 'max-line-chars' | 'provenance-header' | 'duplicate-session-config', message: string, line?: number }>,
76
210
  * }}
77
211
  * @throws {ClaudeMdLintInfraError} when filePath is missing, not a file, or unreadable.
78
212
  */
@@ -116,10 +250,28 @@ export function lintClaudeMd(opts = {}) {
116
250
  }
117
251
  }
118
252
 
119
- if (lineCount > maxLines) {
253
+ const { exemptLines, headingLines } = findSessionConfigRegion(lines);
254
+ const effectiveLineCount = lineCount - exemptLines;
255
+
256
+ if (headingLines.length > 1) {
257
+ violations.push({
258
+ rule: 'duplicate-session-config',
259
+ message:
260
+ `Found ${headingLines.length} '## Session Config' headings (lines ${headingLines.join(', ')}) — ` +
261
+ 'exactly one is expected; NOTHING is exempted while duplicates exist, so the ' +
262
+ 'max-lines budget is measured against the raw line count',
263
+ line: headingLines[1],
264
+ });
265
+ }
266
+
267
+ if (effectiveLineCount > maxLines) {
268
+ const exemptDesc =
269
+ exemptLines > 0 ? `${exemptLines} exempt: "## Session Config"` : `${exemptLines} exempt`;
120
270
  violations.push({
121
271
  rule: 'max-lines',
122
- message: `File has ${lineCount} lines, exceeds max-lines ${maxLines} — consider trimming to pointers (lean-root convention)`,
272
+ message:
273
+ `File has ${lineCount} lines (${effectiveLineCount} non-exempt, ${exemptDesc}), ` +
274
+ `exceeds max-lines ${maxLines} — consider trimming to pointers (lean-root convention)`,
123
275
  });
124
276
  }
125
277
 
@@ -137,14 +289,82 @@ export function lintClaudeMd(opts = {}) {
137
289
  status: violations.length === 0 ? 'ok' : 'invalid',
138
290
  file: filePath,
139
291
  lineCount,
292
+ exemptLines,
293
+ effectiveLineCount,
140
294
  maxLineCharsSeen,
141
295
  hasProvenance,
142
296
  violations,
143
297
  };
144
298
  }
145
299
 
300
+ /**
301
+ * Banner wrapper — session-start Phase 4 convention (#878 FA2b). Resolves
302
+ * the repo's CLAUDE.md/AGENTS.md via `resolveInstructionFile` and lints it
303
+ * via `lintClaudeMd()` in **warn-only** mode: this probe NEVER gates
304
+ * session-start — its exit code is never evaluated as a pass/fail signal,
305
+ * only its violation list is rendered (mirrors `checkInstructionBudget` /
306
+ * `checkReconcileNudge` / the other Phase 4 "banner-or-null" probes).
307
+ *
308
+ * Returns null (silent no-op) when:
309
+ * - no CLAUDE.md/AGENTS.md resolves under `opts.repoRoot`
310
+ * - the resolved file has zero violations (`status === 'ok'`)
311
+ * - `resolveInstructionFile` or `lintClaudeMd` fails for any reason
312
+ * (never throw out of a banner wrapper — mirrors every sibling probe)
313
+ *
314
+ * @param {object} [opts]
315
+ * @param {string} [opts.repoRoot] project root (defaults to process.cwd()).
316
+ * @param {number} [opts.maxLines] forwarded to lintClaudeMd (default DEFAULT_MAX_LINES).
317
+ * @param {number} [opts.maxLineChars] forwarded to lintClaudeMd (default DEFAULT_MAX_LINE_CHARS).
318
+ * @returns {{ severity: 'warn', message: string } | null}
319
+ */
320
+ export function checkClaudeMdBudgetLint(opts = {}) {
321
+ const repoRoot = opts.repoRoot ?? process.cwd();
322
+
323
+ let filePath;
324
+ try {
325
+ const instructionFile = resolveInstructionFile(repoRoot);
326
+ if (!instructionFile) return null;
327
+ filePath = instructionFile.path;
328
+ } catch {
329
+ return null;
330
+ }
331
+
332
+ let result;
333
+ try {
334
+ result = lintClaudeMd({
335
+ filePath,
336
+ maxLines: typeof opts.maxLines === 'number' ? opts.maxLines : DEFAULT_MAX_LINES,
337
+ maxLineChars: typeof opts.maxLineChars === 'number' ? opts.maxLineChars : DEFAULT_MAX_LINE_CHARS,
338
+ });
339
+ } catch {
340
+ return null; // never throw out of the banner wrapper
341
+ }
342
+
343
+ if (!result || result.violations.length === 0) return null;
344
+
345
+ const ruleNames = [...new Set(result.violations.map((v) => v.rule))].join(', ');
346
+ const message =
347
+ `⚠ CLAUDE.md budget lint: ${result.violations.length} violation(s) (${ruleNames}) in ${basename(filePath)} — ` +
348
+ `run \`node scripts/lib/claude-md-budget-lint.mjs --mode warn\` for details.`;
349
+
350
+ return { severity: 'warn', message };
351
+ }
352
+
146
353
  // ── CLI ───────────────────────────────────────────────────────────────────────
147
354
 
355
+ /**
356
+ * Exit code 1 per `.claude/rules/cli-design.md` § Exit Codes — every failure
357
+ * mode below (missing flag value, unrecognized flag, invalid --mode, a
358
+ * non-numeric --max-lines/--max-line-chars) is a USER/input error, never a
359
+ * system error (missing file, unreadable file), so none of them may use
360
+ * exit 2. Mirrors `scripts/lib/description-surface.mjs`'s identical
361
+ * `argError()` pattern (#878 FA2c) — #892 CLI-arg hygiene fix.
362
+ */
363
+ function argError(reason) {
364
+ process.stderr.write(JSON.stringify({ status: 'user-error', reason }) + '\n');
365
+ process.exit(1);
366
+ }
367
+
148
368
  function parseArgs(argv) {
149
369
  const out = {
150
370
  file: null,
@@ -157,22 +377,54 @@ function parseArgs(argv) {
157
377
  };
158
378
  for (let i = 0; i < argv.length; i++) {
159
379
  const a = argv[i];
160
- if (a === '--file') out.file = argv[++i];
161
- else if (a === '--repo-root') out.repoRoot = argv[++i];
162
- else if (a === '--max-lines') out.maxLines = Number.parseInt(argv[++i], 10);
163
- else if (a === '--max-line-chars') out.maxLineChars = Number.parseInt(argv[++i], 10);
164
- else if (a === '--require-provenance') out.requireProvenance = true;
165
- else if (a === '--mode') out.mode = argv[++i];
166
- else if (a === '--json') out.json = true;
167
- else if (a === '--help' || a === '-h') {
380
+ if (a === '--file') {
381
+ const val = argv[++i];
382
+ // A trailing `--file` with no following value must fail cleanly, not
383
+ // silently fall back to --repo-root-based resolution.
384
+ if (val === undefined) return argError('--file requires a value');
385
+ out.file = val;
386
+ } else if (a === '--repo-root') {
387
+ const val = argv[++i];
388
+ // A trailing `--repo-root` with no following value must fail cleanly,
389
+ // not crash `resolve(undefined)` deeper in resolveLintTarget() with a
390
+ // leaked internal TypeError message.
391
+ if (val === undefined) return argError('--repo-root requires a value');
392
+ out.repoRoot = val;
393
+ } else if (a === '--max-lines') {
394
+ const val = argv[++i];
395
+ if (val === undefined) return argError('--max-lines requires a value');
396
+ const parsed = Number.parseInt(val, 10);
397
+ if (!Number.isFinite(parsed)) return argError(`invalid --max-lines: ${val}`);
398
+ out.maxLines = parsed;
399
+ } else if (a === '--max-line-chars') {
400
+ const val = argv[++i];
401
+ if (val === undefined) return argError('--max-line-chars requires a value');
402
+ const parsed = Number.parseInt(val, 10);
403
+ if (!Number.isFinite(parsed)) return argError(`invalid --max-line-chars: ${val}`);
404
+ out.maxLineChars = parsed;
405
+ } else if (a === '--require-provenance') {
406
+ out.requireProvenance = true;
407
+ } else if (a === '--mode') {
408
+ const val = argv[++i];
409
+ if (val === undefined) return argError('--mode requires a value');
410
+ if (!['hard', 'warn'].includes(val)) return argError(`invalid --mode: ${val}`);
411
+ out.mode = val;
412
+ } else if (a === '--json') {
413
+ out.json = true;
414
+ } else if (a === '--help' || a === '-h') {
168
415
  process.stdout.write(
169
- 'Usage: claude-md-budget-lint.mjs [--file CLAUDE.md|AGENTS.md] [--repo-root PATH] [--max-lines 150] ' +
170
- '[--max-line-chars 400] [--require-provenance] [--mode hard|warn] [--json]\n'
416
+ 'Usage: claude-md-budget-lint.mjs [--file CLAUDE.md|AGENTS.md] [--repo-root PATH] [--max-lines 80] ' +
417
+ '[--max-line-chars 400] [--require-provenance] [--mode hard|warn] [--json]\n' +
418
+ '--max-lines applies to NON-EXEMPT lines: a single `## Session Config` block is exempt ' +
419
+ '(0 or 2+ such headings exempt nothing).\n' +
420
+ 'Exit codes: 0 = ok (no violations, or --mode warn with violations); ' +
421
+ '1 = violations in --mode hard, OR a CLI argument error (missing flag value, unknown flag, ' +
422
+ 'invalid --mode, non-numeric --max-lines/--max-line-chars); ' +
423
+ '2 = infra error (missing/unreadable CLAUDE.md/AGENTS.md target file).\n'
171
424
  );
172
425
  process.exit(0);
173
426
  } else {
174
- process.stderr.write(JSON.stringify({ status: 'infra-error', reason: `unknown arg: ${a}` }) + '\n');
175
- process.exit(2);
427
+ return argError(`unknown arg: ${a}`);
176
428
  }
177
429
  }
178
430
  return out;
@@ -191,6 +443,7 @@ function resolveLintTarget(args) {
191
443
  function formatHuman(result, mode) {
192
444
  const lines = [
193
445
  `Instruction budget lint: ${result.status} (file: ${result.file}, lines: ${result.lineCount}, ` +
446
+ `nonExempt: ${result.effectiveLineCount}, exempt: ${result.exemptLines}, ` +
194
447
  `maxLineCharsSeen: ${result.maxLineCharsSeen}, provenance: ${result.hasProvenance}, mode: ${mode})`,
195
448
  ];
196
449
  for (const v of result.violations) {
@@ -200,17 +453,13 @@ function formatHuman(result, mode) {
200
453
  }
201
454
 
202
455
  function main() {
456
+ // parseArgs() already validates --mode (enum), --max-lines/--max-line-chars
457
+ // (numeric), missing flag values, and unknown flags — calling argError()
458
+ // (exit 1) and terminating the process before returning on any violation.
459
+ // By the time control reaches here, args carries only well-formed values;
460
+ // no redundant re-validation needed (#892 CLI-arg hygiene fix).
203
461
  const args = parseArgs(process.argv.slice(2));
204
462
 
205
- if (!['hard', 'warn'].includes(args.mode)) {
206
- process.stderr.write(JSON.stringify({ status: 'infra-error', reason: `invalid --mode: ${args.mode}` }) + '\n');
207
- process.exit(2);
208
- }
209
- if (!Number.isFinite(args.maxLines) || !Number.isFinite(args.maxLineChars)) {
210
- process.stderr.write(JSON.stringify({ status: 'infra-error', reason: 'invalid --max-lines/--max-line-chars' }) + '\n');
211
- process.exit(2);
212
- }
213
-
214
463
  let result;
215
464
  try {
216
465
  const filePath = resolveLintTarget(args);