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
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * description-surface.mjs — #878 FA2c.
4
+ *
5
+ * Measures the frontmatter `description:` field across `agents/*.md`,
6
+ * every skill's SKILL.md (`skills/<name>/SKILL.md`), and `commands/*.md` —
7
+ * a surface invisible to both sibling probes: `claude-md-budget-lint.mjs` measures only the root
8
+ * CLAUDE.md/AGENTS.md, `instruction-budget-guard.mjs` measures only
9
+ * always-on `.claude/rules/*.md`. Neither ever looks at agent/skill/command
10
+ * frontmatter.
11
+ *
12
+ * `agents/AGENTS.md` and any nested `CLAUDE.md` are excluded from the
13
+ * `agents/*.md` walk (see `INSTRUCTION_FILENAMES`) — they are the authoring
14
+ * SPEC for the `agents/` subtree, not an agent definition: no frontmatter,
15
+ * no `description:` field of the kind this module measures. Mirrors the
16
+ * identical exclusion already established for the same directory in
17
+ * `scripts/lib/validate/check-agents.mjs` (`INSTRUCTION_FILENAMES` /
18
+ * `isAgentDefFile`).
19
+ *
20
+ * The bug this module exists to avoid: a naive line-based scanner that
21
+ * greps `^description:\s*(.*)` and stops there undercounts every file whose
22
+ * description uses a YAML block scalar (`description: >`, `description: |`,
23
+ * and the chomping variants `>-`, `|-`, `>+`, `|+`) — the block indicator
24
+ * line itself carries none of the real content, which lives in the
25
+ * INDENTED CONTINUATION LINES below it. A repo-wide 27 KB estimate quoted
26
+ * in the originating PRD was itself produced by exactly this naive scanner
27
+ * (see #878 issue discussion) — this module folds the continuation lines
28
+ * and counts the REAL character/byte length instead, so the two totals
29
+ * (`naiveTotalChars` vs `correctTotalChars`/`correctTotalBytes`) can be
30
+ * reported side by side to make the undercount visible.
31
+ *
32
+ * Deliberately scoped OUT: this module does NOT enforce any length limit.
33
+ * A repo-wide grep found no `1,024-character` (or any other) description
34
+ * limit anywhere in this repo's validators (`validate-plugin`,
35
+ * `check-agents.mjs`, or any plugin schema) — `node scripts/validate-plugin.mjs`
36
+ * passes `agents/eval-judge.md`'s 1,180-char description today. Outliers are
37
+ * reported for visibility via a rank-based "largest N descriptions" list —
38
+ * NOT a statistical fence. Two statistical fences (Tukey's IQR rule and the
39
+ * Iglewicz/Hoaglin modified Z-score) were tried during development and
40
+ * REJECTED: both flagged zero files on the live corpus, so shipping either
41
+ * one would have meant tuning a threshold until it happened to catch the
42
+ * one file we wanted flagged — the exact anti-pattern this module exists to
43
+ * avoid, just aimed at a statistical parameter instead of a raw character
44
+ * count. See `DEFAULT_OUTLIER_TOP_N` for the full rationale. Never a
45
+ * pass/fail gate either way.
46
+ *
47
+ * Pure computation — `measureDescriptionSurface()` never throws and always
48
+ * returns the full shape (mirrors `computeInstructionBudget`'s contract in
49
+ * `instruction-budget-guard.mjs`).
50
+ *
51
+ * Cross-references:
52
+ * - scripts/lib/claude-md-budget-lint.mjs (sibling probe — CLAUDE.md/AGENTS.md only)
53
+ * - scripts/lib/instruction-budget-guard.mjs (sibling probe — always-on rules only, byte-dimension precedent)
54
+ * - scripts/lib/validate/check-agents.mjs (Check 11 — inline-string-only enforcement, agents/*.md ONLY, never skills)
55
+ * - issue #878 (FA2c)
56
+ */
57
+
58
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
59
+ import { fileURLToPath } from 'node:url';
60
+ import { join, relative, resolve } from 'node:path';
61
+
62
+ /** Block-scalar indicator on the `description:` line: '>' (folded) or '|' (literal), optional chomping '+'/'-'. */
63
+ const BLOCK_SCALAR_RE = /^([>|])([+-]?)\s*$/;
64
+
65
+ /**
66
+ * Default count of "largest descriptions" reported as size outliers.
67
+ *
68
+ * Rank-based, not threshold-based, by deliberate choice: this corpus's
69
+ * length distribution is a smooth long tail (no sharp cliff separating a
70
+ * few anomalies from the rest), so both statistical fences tried during
71
+ * development — Tukey's IQR rule (1.5×IQR above Q3) and the Iglewicz/Hoaglin
72
+ * modified Z-score (median + MAD, threshold 3.5) — flagged ZERO files on
73
+ * the live corpus, INCLUDING `agents/eval-judge.md` at 1,180 chars (the
74
+ * single largest description in the repo, ~17% ahead of the runner-up).
75
+ * Tuning either statistic's threshold until it happened to flag that one
76
+ * file would reproduce the exact anti-pattern this module exists to avoid
77
+ * (inventing a number to fit a desired answer) — just for a STATISTICAL
78
+ * threshold instead of an absolute character count. A plain "top N
79
+ * largest" ranking is honest about what it is (a size-awareness list, not
80
+ * an anomaly claim), and is invariant to the corpus's distribution shape.
81
+ */
82
+ export const DEFAULT_OUTLIER_TOP_N = 5;
83
+
84
+ /**
85
+ * Extracts the TOP-LEVEL frontmatter `description:` field from a markdown
86
+ * file's raw content, folding a YAML block scalar's continuation lines
87
+ * when present. Deliberately narrow — matches ONLY a column-0
88
+ * `description:` key inside the leading `---` … `---` frontmatter fence,
89
+ * so a nested `description:` field (e.g. under an `args-schema:` list item
90
+ * in SKILL.md frontmatter, which is indented) is never mistaken for the
91
+ * top-level field.
92
+ *
93
+ * @param {string} content - raw file content
94
+ * @returns {{ raw: string, isBlockScalar: boolean, style: '>' | '|' | null, chomping: '+' | '-' | null } | null}
95
+ * null when there is no frontmatter, no closing fence, or no top-level
96
+ * `description:` key inside it.
97
+ */
98
+ export function parseFrontmatterDescription(content) {
99
+ if (typeof content !== 'string' || content === '') return null;
100
+ const lines = content.split(/\r?\n/);
101
+ if (lines[0] === undefined || lines[0].trim() !== '---') return null;
102
+
103
+ let end = -1;
104
+ for (let i = 1; i < lines.length; i++) {
105
+ // Closing fence is column-0 `---` (trailing whitespace tolerated, LEADING
106
+ // whitespace is NOT — a `.trim()` here previously matched an indented
107
+ // `---` line that is actually part of a block scalar's own body, closing
108
+ // frontmatter parsing early and truncating the description).
109
+ if (lines[i].trimEnd() === '---') {
110
+ end = i;
111
+ break;
112
+ }
113
+ }
114
+ if (end === -1) return null;
115
+
116
+ for (let i = 1; i < end; i++) {
117
+ const m = lines[i].match(/^description:\s*(.*)$/);
118
+ if (!m) continue;
119
+
120
+ const rest = m[1].trim();
121
+ const blockMatch = rest.match(BLOCK_SCALAR_RE);
122
+
123
+ if (blockMatch) {
124
+ const style = blockMatch[1];
125
+ const chomping = blockMatch[2] || null;
126
+
127
+ const bodyLines = [];
128
+ let blockIndent = null;
129
+ for (let j = i + 1; j < end; j++) {
130
+ const line = lines[j];
131
+ if (line.trim() === '') {
132
+ bodyLines.push('');
133
+ continue;
134
+ }
135
+ const indent = line.length - line.replace(/^ */, '').length;
136
+ if (blockIndent === null) blockIndent = indent;
137
+ if (indent < blockIndent) break; // dedent below the block's own indent closes it
138
+ bodyLines.push(line.slice(blockIndent));
139
+ }
140
+
141
+ return {
142
+ raw: foldBlockScalar(bodyLines, style, chomping),
143
+ isBlockScalar: true,
144
+ style,
145
+ chomping,
146
+ };
147
+ }
148
+
149
+ // Inline value — strip a single layer of matching quotes.
150
+ let val = rest;
151
+ if (val.length >= 2) {
152
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
153
+ else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
154
+ }
155
+ return { raw: val, isBlockScalar: false, style: null, chomping: null };
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * Folds a YAML block scalar's dedented body lines per its style/chomping.
163
+ * Close enough to spec for LENGTH-MEASUREMENT purposes — this module is a
164
+ * measurement probe, not a full YAML parser:
165
+ * - `>` (folded): consecutive non-blank lines join with a single space;
166
+ * a blank line becomes a paragraph break (kept as `\n`).
167
+ * - `|` (literal): every line joins with `\n`, preserving line breaks.
168
+ * - chomping `-` (strip): no trailing newline appended.
169
+ * - chomping `+` (keep): every trailing blank line is preserved.
170
+ * - default (clip): a single trailing newline is appended (only when the
171
+ * block had any content).
172
+ *
173
+ * @param {string[]} bodyLines - dedented body lines (blank lines are '').
174
+ * @param {'>' | '|'} style
175
+ * @param {'+' | '-' | null} chomping
176
+ * @returns {string}
177
+ */
178
+ function foldBlockScalar(bodyLines, style, chomping) {
179
+ let trailingBlanks = 0;
180
+ const trimmed = [...bodyLines];
181
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
182
+ trimmed.pop();
183
+ trailingBlanks++;
184
+ }
185
+
186
+ let text;
187
+ if (style === '>') {
188
+ const paragraphs = [];
189
+ let current = [];
190
+ for (const line of trimmed) {
191
+ if (line === '') {
192
+ if (current.length > 0) paragraphs.push(current.join(' '));
193
+ current = [];
194
+ } else {
195
+ current.push(line);
196
+ }
197
+ }
198
+ if (current.length > 0) paragraphs.push(current.join(' '));
199
+ text = paragraphs.join('\n');
200
+ } else {
201
+ text = trimmed.join('\n');
202
+ }
203
+
204
+ if (trimmed.length === 0) return '';
205
+ if (chomping === '-') return text;
206
+ if (chomping === '+') return text + '\n'.repeat(trailingBlanks + 1);
207
+ return `${text}\n`; // default 'clip'
208
+ }
209
+
210
+ /**
211
+ * The naive line-based measurement this module exists to avoid — counts
212
+ * ONLY the characters that appear after `description:` on its own line,
213
+ * exactly what a `grep -m1 '^description:'`-style scanner would see. A block
214
+ * scalar description reads as 0 or 1 chars here (just the `>`/`|`
215
+ * indicator, if present) regardless of how large its real content is.
216
+ * Exported for the falsifiability comparison (`correctTotalChars` MUST be
217
+ * demonstrably larger than `naiveTotalChars` whenever any block scalar is
218
+ * present in the corpus).
219
+ *
220
+ * Deliberately scans the WHOLE file, not just the frontmatter block — this
221
+ * is the more faithful model of "what a real, YAML-unaware grep scanner
222
+ * would see" (a plain `grep -m1 '^description:' file.md` has no concept of
223
+ * a frontmatter fence either). This is safe for `correctChars >= naiveChars`
224
+ * because frontmatter always precedes body content: for any well-formed
225
+ * definition file (frontmatter description as one of the first keys, which
226
+ * `validate-plugin` Check 11 requires for `agents/*.md`), the first
227
+ * column-0 `description:` line this function finds IS the frontmatter one.
228
+ * The one file this bit in practice was `agents/AGENTS.md` — not a
229
+ * definition file at all (no frontmatter, an authoring-convention doc that
230
+ * happens to contain a `description:` example inside its body) — which is
231
+ * why it and `CLAUDE.md` are excluded from the walked corpus entirely (see
232
+ * `INSTRUCTION_FILENAMES`) rather than special-cased here.
233
+ *
234
+ * @param {string} content - raw file content
235
+ * @returns {number}
236
+ */
237
+ export function naiveDescriptionLength(content) {
238
+ if (typeof content !== 'string') return 0;
239
+ const lines = content.split(/\r?\n/);
240
+ for (const line of lines) {
241
+ const m = line.match(/^description:\s*(.*)$/);
242
+ if (m) return m[1].trim().length;
243
+ }
244
+ return 0;
245
+ }
246
+
247
+ /**
248
+ * Nested instruction files that document authoring conventions for a
249
+ * directory rather than defining an individual agent/command — no
250
+ * frontmatter, not a member of the surface this module measures. Mirrors
251
+ * `scripts/lib/validate/check-agents.mjs`'s identical `INSTRUCTION_FILENAMES`
252
+ * exclusion for the same `agents/` directory (`isAgentDefFile`).
253
+ */
254
+ const INSTRUCTION_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md']);
255
+
256
+ /**
257
+ * Lists every `{ path, surface }` candidate under the three measured
258
+ * surfaces. Never throws — a missing directory (e.g. no `commands/` in a
259
+ * minimal repo) simply contributes zero entries for that surface.
260
+ *
261
+ * @param {string} repoRoot
262
+ * @returns {Array<{ path: string, surface: 'agents' | 'skills' | 'commands' }>}
263
+ */
264
+ function listSurfaceFiles(repoRoot) {
265
+ const files = [];
266
+
267
+ files.push(...listMdFilesInDir(join(repoRoot, 'agents'), 'agents'));
268
+ files.push(...listMdFilesInDir(join(repoRoot, 'commands'), 'commands'));
269
+
270
+ const skillsDir = join(repoRoot, 'skills');
271
+ try {
272
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
273
+ for (const entry of entries) {
274
+ if (!entry.isDirectory()) continue;
275
+ const skillFile = join(skillsDir, entry.name, 'SKILL.md');
276
+ try {
277
+ if (statSync(skillFile).isFile()) files.push({ path: skillFile, surface: 'skills' });
278
+ } catch {
279
+ // no SKILL.md in this subdirectory — skip
280
+ }
281
+ }
282
+ } catch {
283
+ // no skills/ directory at all — contributes zero entries
284
+ }
285
+
286
+ return files;
287
+ }
288
+
289
+ function listMdFilesInDir(dir, surface) {
290
+ try {
291
+ return readdirSync(dir, { withFileTypes: true })
292
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && !INSTRUCTION_FILENAMES.has(e.name))
293
+ .map((e) => ({ path: join(dir, e.name), surface }));
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Rank-based "largest N descriptions" report over per-file `correctChars`
301
+ * (see `DEFAULT_OUTLIER_TOP_N` doc for why this is rank-based rather than a
302
+ * statistical or absolute-value threshold). Ties at the Nth rank are all
303
+ * included (never silently drops a tied file), so the returned array length
304
+ * can exceed `topN`.
305
+ *
306
+ * @param {Array<{ file: string, correctChars: number }>} perFile
307
+ * @param {number} topN
308
+ * @returns {Array<{ file: string, correctChars: number }>}
309
+ */
310
+ function selectLargest(perFile, topN) {
311
+ if (!Array.isArray(perFile) || perFile.length === 0 || topN <= 0) return [];
312
+
313
+ const sorted = [...perFile].sort(
314
+ (a, b) => b.correctChars - a.correctChars || a.file.localeCompare(b.file),
315
+ );
316
+ const cutoffValue = sorted[Math.min(topN, sorted.length) - 1].correctChars;
317
+ if (cutoffValue <= 0) return []; // no non-empty descriptions to rank
318
+
319
+ return sorted
320
+ .filter((f) => f.correctChars >= cutoffValue)
321
+ .map((f) => ({ file: f.file, correctChars: f.correctChars }));
322
+ }
323
+
324
+ /**
325
+ * Pure computation — always returns the full shape (never null, never
326
+ * throws). Walks `agents/*.md`, every skill's SKILL.md, `commands/*.md`
327
+ * under `opts.repoRoot`, measuring each file's frontmatter `description:`
328
+ * both the naive way and the block-scalar-aware way.
329
+ *
330
+ * @param {object} [opts]
331
+ * @param {string} [opts.repoRoot] project root (defaults to process.cwd()).
332
+ * @param {number} [opts.outlierTopN] size of the "largest descriptions" report (default 5; see `DEFAULT_OUTLIER_TOP_N`).
333
+ * @returns {{
334
+ * fileCount: number,
335
+ * naiveTotalChars: number,
336
+ * correctTotalChars: number,
337
+ * correctTotalBytes: number,
338
+ * deltaChars: number,
339
+ * bySurfaceBytes: { agents: number, skills: number, commands: number },
340
+ * perFile: Array<{ file: string, surface: string, naiveChars: number, correctChars: number, correctBytes: number, isBlockScalar: boolean }>,
341
+ * outliers: Array<{ file: string, correctChars: number }>,
342
+ * }}
343
+ * perFile is sorted DESC by correctChars, tie-broken by file path. `outliers`
344
+ * is informational sizing (the `outlierTopN` largest descriptions, ties
345
+ * included), never a pass/fail signal — see `DEFAULT_OUTLIER_TOP_N` doc.
346
+ * `deltaChars` = correctTotalChars - naiveTotalChars — the visible proof
347
+ * that folding recovers content the naive scanner missed. It holds >= 0
348
+ * for every file walked by `listSurfaceFiles`, because that walk is scoped
349
+ * to well-formed agent/skill/command DEFINITION files, whose frontmatter
350
+ * `description:` key always precedes any body content — see
351
+ * `naiveDescriptionLength`'s doc for why that ordering is what makes the
352
+ * whole-file naive scan safe, and `INSTRUCTION_FILENAMES` for the
353
+ * authoring-spec files (`agents/AGENTS.md`, `CLAUDE.md`) deliberately
354
+ * excluded from the walk because they violate that precondition (no
355
+ * frontmatter at all).
356
+ */
357
+ export function measureDescriptionSurface(opts = {}) {
358
+ const repoRoot = opts.repoRoot ?? process.cwd();
359
+ const outlierTopN =
360
+ typeof opts.outlierTopN === 'number' ? opts.outlierTopN : DEFAULT_OUTLIER_TOP_N;
361
+
362
+ const empty = {
363
+ fileCount: 0,
364
+ naiveTotalChars: 0,
365
+ correctTotalChars: 0,
366
+ correctTotalBytes: 0,
367
+ deltaChars: 0,
368
+ bySurfaceBytes: { agents: 0, skills: 0, commands: 0 },
369
+ perFile: [],
370
+ outliers: [],
371
+ };
372
+
373
+ let entries;
374
+ try {
375
+ entries = listSurfaceFiles(repoRoot);
376
+ } catch {
377
+ return empty;
378
+ }
379
+ if (!Array.isArray(entries) || entries.length === 0) return empty;
380
+
381
+ const perFile = [];
382
+ const bySurfaceBytes = { agents: 0, skills: 0, commands: 0 };
383
+ let naiveTotalChars = 0;
384
+ let correctTotalChars = 0;
385
+ let correctTotalBytes = 0;
386
+
387
+ for (const entry of entries) {
388
+ let content;
389
+ try {
390
+ content = readFileSync(entry.path, 'utf8');
391
+ } catch {
392
+ continue;
393
+ }
394
+
395
+ const naiveChars = naiveDescriptionLength(content);
396
+ const parsed = parseFrontmatterDescription(content);
397
+ const correctChars = parsed ? parsed.raw.length : 0;
398
+ const correctBytes = parsed ? Buffer.byteLength(parsed.raw, 'utf8') : 0;
399
+
400
+ naiveTotalChars += naiveChars;
401
+ correctTotalChars += correctChars;
402
+ correctTotalBytes += correctBytes;
403
+ if (bySurfaceBytes[entry.surface] !== undefined) bySurfaceBytes[entry.surface] += correctBytes;
404
+
405
+ perFile.push({
406
+ file: relative(repoRoot, entry.path),
407
+ surface: entry.surface,
408
+ naiveChars,
409
+ correctChars,
410
+ correctBytes,
411
+ isBlockScalar: parsed ? parsed.isBlockScalar : false,
412
+ });
413
+ }
414
+
415
+ const outliers = selectLargest(perFile, outlierTopN);
416
+
417
+ perFile.sort((a, b) => b.correctChars - a.correctChars || a.file.localeCompare(b.file));
418
+
419
+ return {
420
+ fileCount: perFile.length,
421
+ naiveTotalChars,
422
+ correctTotalChars,
423
+ correctTotalBytes,
424
+ deltaChars: correctTotalChars - naiveTotalChars,
425
+ bySurfaceBytes,
426
+ perFile,
427
+ outliers,
428
+ };
429
+ }
430
+
431
+ // ── CLI ───────────────────────────────────────────────────────────────────────
432
+
433
+ /**
434
+ * Exit code 1 per `.claude/rules/cli-design.md` § Exit Codes — every failure
435
+ * mode below (missing flag value, unrecognized flag, out-of-range value) is
436
+ * a USER/input error, never a system error (network, permissions, missing
437
+ * dependency), so none of them may use exit 2.
438
+ */
439
+ function argError(reason) {
440
+ process.stderr.write(JSON.stringify({ status: 'user-error', reason }) + '\n');
441
+ process.exit(1);
442
+ }
443
+
444
+ function parseArgs(argv) {
445
+ const out = { repoRoot: process.cwd(), json: false, outlierTopN: DEFAULT_OUTLIER_TOP_N };
446
+ for (let i = 0; i < argv.length; i++) {
447
+ const a = argv[i];
448
+ if (a === '--repo-root') {
449
+ const val = argv[++i];
450
+ // A trailing `--repo-root` with no following value must fail cleanly,
451
+ // not crash `resolve(undefined)` deeper in main() with an uncaught
452
+ // TypeError.
453
+ if (val === undefined) return argError('--repo-root requires a value');
454
+ out.repoRoot = val;
455
+ } else if (a === '--outlier-top-n') {
456
+ out.outlierTopN = Number.parseInt(argv[++i], 10);
457
+ } else if (a === '--json') {
458
+ out.json = true;
459
+ } else if (a === '--help' || a === '-h') {
460
+ process.stdout.write(
461
+ 'Usage: description-surface.mjs [--repo-root PATH] [--outlier-top-n 5] [--json]\n',
462
+ );
463
+ process.exit(0);
464
+ } else {
465
+ return argError(`unknown arg: ${a}`);
466
+ }
467
+ }
468
+ return out;
469
+ }
470
+
471
+ function formatHuman(result) {
472
+ const lines = [
473
+ `Description surface: ${result.fileCount} files — naive: ${result.naiveTotalChars} chars, ` +
474
+ `correct (block-scalar-folded): ${result.correctTotalChars} chars / ${result.correctTotalBytes} bytes ` +
475
+ `(delta: +${result.deltaChars} chars recovered from block scalars).`,
476
+ ` bySurface (bytes): agents=${result.bySurfaceBytes.agents}, skills=${result.bySurfaceBytes.skills}, commands=${result.bySurfaceBytes.commands}`,
477
+ ];
478
+ if (result.outliers.length > 0) {
479
+ lines.push(' Largest descriptions (informational sizing, NOT a limit):');
480
+ for (const o of result.outliers) {
481
+ lines.push(` - ${o.file}: ${o.correctChars} chars`);
482
+ }
483
+ }
484
+ return lines.join('\n');
485
+ }
486
+
487
+ function main() {
488
+ const args = parseArgs(process.argv.slice(2));
489
+
490
+ if (!Number.isInteger(args.outlierTopN) || args.outlierTopN <= 0) {
491
+ argError('invalid --outlier-top-n');
492
+ return;
493
+ }
494
+
495
+ const repoRoot = resolve(args.repoRoot);
496
+ const result = measureDescriptionSurface({ repoRoot, outlierTopN: args.outlierTopN });
497
+
498
+ // Deliberately no `process.exit(0)` after this stdout write — mirrors the
499
+ // TRUNCATION half of the #876 fix in scripts/print-applicable-rules.mjs.
500
+ //
501
+ // PARTIAL PARITY, deliberately (session-reviewer finding, 2026-07-25): that
502
+ // module ALSO registers a `process.stdout.on('error')` EPIPE handler so an
503
+ // early-closing reader (`| head`, `| grep -q`) exits 0 quietly instead of
504
+ // throwing an unhandled 'error' event. This module does NOT have that
505
+ // handler. It is not live-reachable today — nothing pipes this CLI (grep
506
+ // -rn "description-surface" scripts/ skills/ hooks/ finds only self-
507
+ // references and its own test) — so porting it now would ship untested
508
+ // behaviour. Port the handler (with an EPIPE test, using a fixture large
509
+ // enough to actually trigger the race — ~240 KB, NOT 80 KB) BEFORE wiring
510
+ // this CLI into any piped consumer. Tracked in issue #892.
511
+ //
512
+ // process.stdout.write() to
513
+ // a pipe is ASYNCHRONOUS in Node; an explicit process.exit() races the
514
+ // kernel pipe buffer (65,536 B on macOS) and can terminate the process
515
+ // before it fully drains, silently truncating any payload beyond that
516
+ // threshold while still reporting exit code 0. Falling off the end of
517
+ // main() lets Node's event loop wait for the pending write to flush before
518
+ // the process exits naturally (default exit code 0) — this generalizes to
519
+ // any payload size, not just today's measured corpus. The two branches
520
+ // below are mutually exclusive (if/else) so only one ever writes to stdout.
521
+ if (args.json) {
522
+ process.stdout.write(JSON.stringify(result) + '\n');
523
+ } else {
524
+ process.stdout.write(formatHuman(result) + '\n');
525
+ }
526
+ }
527
+
528
+ const __filename = fileURLToPath(import.meta.url);
529
+ const isMain =
530
+ typeof process !== 'undefined' &&
531
+ process.argv[1] !== null &&
532
+ process.argv[1] !== undefined &&
533
+ resolve(process.argv[1]) === resolve(__filename);
534
+
535
+ if (isMain) main();