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
|
@@ -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.
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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')
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
else if (a === '--
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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);
|