session-orchestrator 3.17.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +105 -412
- package/README.md +12 -9
- 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/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- 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/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- 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/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- 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/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/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/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- 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/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 +9 -3
- 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/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-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/validator.mjs +16 -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/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/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/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 +17 -3
- 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/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 +17 -1
- 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 +34 -13
- 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 +73 -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/validator.mjs +108 -7
- 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();
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* enumerate.mjs — Candidate-repo enumeration + free/busy resolution.
|
|
3
3
|
*
|
|
4
4
|
* Epic #673 Phase 2 (issue #676, PRD §2 P2.1+P2.2, §4). Enumerates candidate
|
|
5
|
-
* repos below a confinement root (recursive walk, depth
|
|
6
|
-
* {@link DEFAULT_MAX_DEPTH}
|
|
5
|
+
* repos below a confinement root (recursive walk, fixed at a depth of
|
|
6
|
+
* {@link DEFAULT_MAX_DEPTH} — see that constant for the measurement backing
|
|
7
|
+
* the value) and resolves each as free or busy via its per-repo
|
|
7
8
|
* `session.lock` v2 lease (heartbeat-based liveness).
|
|
8
9
|
*
|
|
9
10
|
* Source of truth for free/busy: the same lease semantics as
|
|
@@ -16,8 +17,9 @@
|
|
|
16
17
|
* the {@link Candidate} contract defined here.
|
|
17
18
|
*
|
|
18
19
|
* Exports:
|
|
19
|
-
* enumerateCandidates — walk a startDir up to
|
|
20
|
-
* each repo's free/busy status
|
|
20
|
+
* enumerateCandidates — walk a startDir up to {@link DEFAULT_MAX_DEPTH}
|
|
21
|
+
* levels deep, resolve each repo's free/busy status
|
|
22
|
+
* from its lease.
|
|
21
23
|
* freeCandidates — filter helper: keep only `free === true` candidates.
|
|
22
24
|
*
|
|
23
25
|
* No top-level side effects. All filesystem + lock access is dependency-injected
|
|
@@ -49,7 +51,7 @@ const STATUS_IN_PROGRESS = 'in-progress';
|
|
|
49
51
|
const STATUS_FORCE_CLOSED = 'force-closed';
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
54
|
+
* Fixed walk depth. `1` = immediate children of the scan root only (the
|
|
53
55
|
* pre-#832 behaviour); `2` additionally covers `<root>/<org>/<repo>`.
|
|
54
56
|
*
|
|
55
57
|
* Measured on the reference host (~/Projects, 2026-07-19; warm dentry cache —
|
|
@@ -61,15 +63,20 @@ const STATUS_FORCE_CLOSED = 'force-closed';
|
|
|
61
63
|
* depth 3 → 47 of 47 repos, ~8ms (node_modules is pruned, but the extra
|
|
62
64
|
* level still multiplies the node count)
|
|
63
65
|
* for two additional repos, both archived.
|
|
64
|
-
* Depth 2 is therefore the
|
|
65
|
-
* negligible cost, while depth 3 costs ~8x the walk for two dead repos.
|
|
66
|
+
* Depth 2 is therefore the fixed bound: it recovers 96% of the host's repos
|
|
67
|
+
* at negligible cost, while depth 3 costs ~8x the walk for two dead repos.
|
|
68
|
+
*
|
|
69
|
+
* This was previously exposed as a caller-tunable `maxDepth` option (clamped
|
|
70
|
+
* 1..3). Issue #838 removed the knob: all three production callers
|
|
71
|
+
* (`scripts/lib/dispatcher/cli.mjs`, `scripts/lib/lock-reaper.mjs`,
|
|
72
|
+
* `scripts/lib/vault-status/board-writer.mjs`) always omitted it and relied on
|
|
73
|
+
* this default, and no CLI surface ever forwarded a caller-supplied value —
|
|
74
|
+
* a config knob whose only exercised path was its own test suite. The walk
|
|
75
|
+
* BOUND is unchanged; only the ability to override it at the call boundary
|
|
76
|
+
* is gone.
|
|
66
77
|
*/
|
|
67
78
|
const DEFAULT_MAX_DEPTH = 2;
|
|
68
79
|
|
|
69
|
-
/** Hard bounds for {@link clampMaxDepth}. Depth 3 is the ceiling by measurement. */
|
|
70
|
-
const MIN_MAX_DEPTH = 1;
|
|
71
|
-
const MAX_MAX_DEPTH = 3;
|
|
72
|
-
|
|
73
80
|
/**
|
|
74
81
|
* Directory names never DESCENDED into during the walk.
|
|
75
82
|
*
|
|
@@ -90,25 +97,6 @@ function shouldDescendInto(name) {
|
|
|
90
97
|
return true;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
/**
|
|
94
|
-
* Normalise a caller-supplied `maxDepth` into the supported 1..3 range.
|
|
95
|
-
* Anything that is not a positive finite number falls back to
|
|
96
|
-
* {@link DEFAULT_MAX_DEPTH} — including `0`, negatives, `NaN`, and non-numbers
|
|
97
|
-
* such as the string `'3'` (no coercion: a string is a caller bug, and silently
|
|
98
|
-
* honouring it would make an unvalidated config value widen the walk).
|
|
99
|
-
*
|
|
100
|
-
* @param {unknown} value
|
|
101
|
-
* @returns {number} an integer in [MIN_MAX_DEPTH, MAX_MAX_DEPTH].
|
|
102
|
-
*/
|
|
103
|
-
function clampMaxDepth(value) {
|
|
104
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_MAX_DEPTH;
|
|
105
|
-
const truncated = Math.trunc(value);
|
|
106
|
-
if (truncated <= 0) return DEFAULT_MAX_DEPTH;
|
|
107
|
-
if (truncated < MIN_MAX_DEPTH) return MIN_MAX_DEPTH;
|
|
108
|
-
if (truncated > MAX_MAX_DEPTH) return MAX_MAX_DEPTH;
|
|
109
|
-
return truncated;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
100
|
/**
|
|
113
101
|
* Expand a leading `~` to the current user's home directory. Mirrors the helper
|
|
114
102
|
* in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
|
|
@@ -188,9 +176,10 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
188
176
|
* free or busy via its local lease.
|
|
189
177
|
*
|
|
190
178
|
* Algorithm:
|
|
191
|
-
* 1. Depth-first walk of `startDir`, up to
|
|
192
|
-
* immediate children). Every directory node is a repo
|
|
193
|
-
* `<node>/.git` exists (dir or file — the file form covers
|
|
179
|
+
* 1. Depth-first walk of `startDir`, up to {@link DEFAULT_MAX_DEPTH} levels
|
|
180
|
+
* deep (depth 1 = immediate children). Every directory node is a repo
|
|
181
|
+
* candidate iff `<node>/.git` exists (dir or file — the file form covers
|
|
182
|
+
* worktrees).
|
|
194
183
|
* 2. Confinement guard (`validatePathInsideProject(nodeAbs, startDir)`) runs
|
|
195
184
|
* on EVERY node BEFORE it is emitted AND before it is opened — see the
|
|
196
185
|
* security notes on the walk body below.
|
|
@@ -212,8 +201,6 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
212
201
|
*
|
|
213
202
|
* @param {object} [opts]
|
|
214
203
|
* @param {string} [opts.startDir] — scan root; defaults to {@link getConfinementRoot}().
|
|
215
|
-
* @param {number} [opts.maxDepth] — walk depth, clamped to 1..3; defaults to
|
|
216
|
-
* {@link DEFAULT_MAX_DEPTH} (2) for anything non-numeric, non-finite, or <= 0.
|
|
217
204
|
* @param {number} [opts.now] — clock seam in ms; defaults to Date.now().
|
|
218
205
|
* @param {object} [opts.deps] — dependency-injection seam (Wave-4 testability).
|
|
219
206
|
* @param {Function} [opts.deps.readdirSync] — node:fs readdirSync.
|
|
@@ -225,7 +212,7 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
225
212
|
* @param {Function} [opts.deps.now] — () => ms (overridden by opts.now when set).
|
|
226
213
|
* @returns {Promise<Candidate[]>}
|
|
227
214
|
*/
|
|
228
|
-
export async function enumerateCandidates({ startDir, now,
|
|
215
|
+
export async function enumerateCandidates({ startDir, now, deps } = {}) {
|
|
229
216
|
const d = deps ?? {};
|
|
230
217
|
const readdirSyncFn = d.readdirSync ?? readdirSync;
|
|
231
218
|
const existsSyncFn = d.existsSync ?? existsSync;
|
|
@@ -239,7 +226,6 @@ export async function enumerateCandidates({ startDir, now, maxDepth, deps } = {}
|
|
|
239
226
|
? startDir
|
|
240
227
|
: getConfinementRoot();
|
|
241
228
|
const nowMs = typeof now === 'number' ? now : nowFn();
|
|
242
|
-
const depthCap = clampMaxDepth(maxDepth);
|
|
243
229
|
|
|
244
230
|
// Dedup set keyed by resolved absolute path; preserves first-seen ordering
|
|
245
231
|
// (FS-scan repos first, config-declared additions after).
|
|
@@ -281,8 +267,8 @@ export async function enumerateCandidates({ startDir, now, maxDepth, deps } = {}
|
|
|
281
267
|
//
|
|
282
268
|
// Unbounded recursion is impossible: `Dirent.isDirectory()` is false for a
|
|
283
269
|
// symlink-to-directory (verified empirically), so symlink cycles never enter
|
|
284
|
-
// the walk — and `
|
|
285
|
-
// entries that do not implement isDirectory().
|
|
270
|
+
// the walk — and `DEFAULT_MAX_DEPTH` bounds it regardless, including for
|
|
271
|
+
// stubbed entries that do not implement isDirectory().
|
|
286
272
|
const walk = (dirAbs, depth) => {
|
|
287
273
|
let entries;
|
|
288
274
|
try {
|
|
@@ -318,7 +304,7 @@ export async function enumerateCandidates({ startDir, now, maxDepth, deps } = {}
|
|
|
318
304
|
// (the umbrella-repo case documented above). Prune by name only, and only
|
|
319
305
|
// for the descent decision — emission above is untouched, which keeps the
|
|
320
306
|
// depth-1 contract byte-identical to the pre-#832 scan.
|
|
321
|
-
if (depth <
|
|
307
|
+
if (depth < DEFAULT_MAX_DEPTH && shouldDescendInto(entry.name)) {
|
|
322
308
|
walk(childAbs, depth + 1);
|
|
323
309
|
}
|
|
324
310
|
}
|