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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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();
|