@event4u/agent-config 9.12.0 → 9.14.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/CHANGELOG.md +110 -238
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +147 -0
- package/dist/agent-src/commands/optimize.md +12 -6
- package/dist/agent-src/rules/active-remediation.md +0 -2
- package/dist/agent-src/rules/architecture.md +0 -1
- package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
- package/dist/agent-src/rules/autonomous-execution.md +1 -2
- package/dist/agent-src/rules/broken-access-control.md +0 -2
- package/dist/agent-src/rules/code-comment-discipline.md +0 -3
- package/dist/agent-src/rules/code-provenance.md +0 -3
- package/dist/agent-src/rules/communication-through-line.md +2 -3
- package/dist/agent-src/rules/content-quoting-floor.md +0 -2
- package/dist/agent-src/rules/context-hygiene.md +1 -3
- package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
- package/dist/agent-src/rules/design-fidelity.md +29 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
- package/dist/agent-src/rules/downstream-changes.md +10 -1
- package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
- package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
- package/dist/agent-src/rules/git-history-discipline.md +0 -7
- package/dist/agent-src/rules/guidelines.md +0 -2
- package/dist/agent-src/rules/improve-before-implement.md +4 -2
- package/dist/agent-src/rules/invite-challenge.md +0 -4
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
- package/dist/agent-src/rules/missing-tool-handling.md +0 -1
- package/dist/agent-src/rules/no-attribution-footers.md +0 -3
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
- package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
- package/dist/agent-src/rules/no-roadmap-references.md +0 -2
- package/dist/agent-src/rules/output-discipline.md +0 -1
- package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
- package/dist/agent-src/rules/preservation-guard.md +0 -3
- package/dist/agent-src/rules/role-mode-adherence.md +0 -1
- package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
- package/dist/agent-src/rules/session-canary.md +0 -2
- package/dist/agent-src/rules/size-enforcement.md +0 -5
- package/dist/agent-src/rules/source-confidentiality.md +0 -3
- package/dist/agent-src/rules/source-discovery-gate.md +0 -2
- package/dist/agent-src/rules/think-before-action.md +3 -3
- package/dist/agent-src/rules/token-budget-discipline.md +0 -1
- package/dist/agent-src/rules/token-efficiency.md +1 -2
- package/dist/agent-src/rules/tool-safety.md +0 -1
- package/dist/agent-src/rules/user-interaction.md +0 -3
- package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
- package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
- package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
- package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
- package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
- package/dist/agent-src/skills/design-review/SKILL.md +11 -0
- package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
- package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
- package/dist/agent-src/skills/performance/SKILL.md +14 -0
- package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
- package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
- package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
- package/dist/agent-src/skills/video-director/SKILL.md +9 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/dist/cli-delegate/{chunk-QFKQLQ7X.js → chunk-GCVFLBP2.js} +399 -365
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +123 -73
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +174 -13
- package/dist/install/install.mjs +648 -614
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +783 -507
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
- package/docs/guidelines/code-clarity.md +9 -2
- package/docs/guidelines/design-fidelity-mechanics.md +44 -0
- package/llms.txt +63 -27
- package/package.json +4 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/src/config/agent-settings.template.yml +8 -0
- package/src/scripts/_lib/counted_probe.ts +142 -0
- package/src/scripts/_lib/exec_evidence.ts +5 -0
- package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
- package/src/scripts/_lib/release_scope.ts +207 -0
- package/src/scripts/_lib/scoped_projection.ts +212 -0
- package/src/scripts/ai_council/stance_tally.ts +37 -2
- package/src/scripts/audit_auto_rules.ts +9 -9
- package/src/scripts/audit_likelihood.ts +3 -3
- package/src/scripts/audit_overlap.ts +2 -2
- package/src/scripts/audit_skill_overlap.ts +233 -95
- package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
- package/src/scripts/build_proof.ts +15 -1
- package/src/scripts/check_artefact_count_messaging.ts +128 -15
- package/src/scripts/check_dependency_floors.ts +16 -6
- package/src/scripts/check_gate_coverage.ts +6 -6
- package/src/scripts/check_kernel_rule_bundle.ts +5 -4
- package/src/scripts/check_token_quality_golden.ts +0 -18
- package/src/scripts/code_graph/loader.ts +35 -6
- package/src/scripts/compile_router.ts +3 -8
- package/src/scripts/condense.ts +2 -2
- package/src/scripts/count_scoped_projection.ts +104 -0
- package/src/scripts/explain_run.ts +0 -1
- package/src/scripts/generate_pack_manifests.ts +3 -20
- package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
- package/src/scripts/hooks/block_no_verify.ts +101 -0
- package/src/scripts/install.ts +13 -90
- package/src/scripts/lint_agent_security.ts +5 -4
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_regression.ts +92 -1
- package/src/scripts/lint_skill_descriptions.ts +93 -5
- package/src/scripts/project_thin_rules.ts +2 -2
- package/src/scripts/release.ts +107 -3
- package/src/scripts/resolve_lint_scope.ts +53 -0
- package/src/scripts/router_telemetry.ts +0 -5
- package/src/scripts/runtime_dispatcher.ts +40 -0
- package/src/scripts/schemas/pack.schema.json +1 -5
- package/src/scripts/schemas/rule.schema.json +1 -2
- package/src/scripts/self_review_gate.ts +15 -73
- package/src/scripts/skill_linter.ts +160 -11
- package/src/scripts/sweep_dead_scan_roots.ts +550 -0
- package/src/scripts/trigger_coverage.ts +11 -37
- package/src/scripts/update_counts.ts +90 -11
|
@@ -3,35 +3,51 @@
|
|
|
3
3
|
* Skill-family content-overlap analysis (6.0.0-C Phase 4 Step 8).
|
|
4
4
|
*
|
|
5
5
|
* Ported from the retired Python `src/scripts/audit_skill_overlap.py` (ADR-200 —
|
|
6
|
-
* Python→TS migration, Phase 8 / Wave 8c).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Python→TS migration, Phase 8 / Wave 8c). The numeric contract — keyword
|
|
7
|
+
* vector, cosine, banker's rounding, pair ordering, `json.dumps(..., indent=2)`
|
|
8
|
+
* byte shape, and the Markdown renderer — is preserved so historical reports
|
|
9
|
+
* stay comparable. The CLI grew a `--root` flag; the exit codes are unchanged
|
|
10
|
+
* (0 ok / 3 dead scan scope).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* `_skill_roots()`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* SCAN ROOT (repaired 2026-08-02, road-to-overlap-truth-and-skill-cut Phase 1):
|
|
13
|
+
* the ported `_skill_roots()` resolved the pre-ADR-051 source containers, both
|
|
14
|
+
* of which were deleted when `src/skills` became the source of truth. The tool
|
|
15
|
+
* therefore walked a directory that does not exist and could never report an
|
|
16
|
+
* overlap for the 287-skill corpus. That resolution is GONE, not kept as a
|
|
17
|
+
* fallback branch: the default root is the shared `SRC_SKILLS()` resolver, and
|
|
18
|
+
* an empty scan is a hard failure via {@link assertScanned} rather than an
|
|
19
|
+
* empty result.
|
|
16
20
|
*
|
|
17
21
|
* Walks every `SKILL.md`, builds a keyword vector from the body
|
|
18
22
|
* (frontmatter stripped), and flags pairs whose content cosine-similarity
|
|
19
23
|
* is >= OVERLAP_THRESHOLD. Same-domain (shared `packs:`) >= threshold pairs
|
|
20
24
|
* are the merge candidates. THIS SCRIPT MERGES NOTHING.
|
|
21
25
|
*
|
|
22
|
-
*
|
|
26
|
+
* `--strict` makes a same-pack pair at or above threshold a BUILD FAILURE
|
|
27
|
+
* (exit 1) unless the pair carries a reviewed justification in
|
|
28
|
+
* `audit_skill_overlap_allowlist.json`, capped at 20 entries. The cap is the
|
|
29
|
+
* point: it forces periodic re-litigation instead of an allowlist that grows
|
|
30
|
+
* quietly until the gate means nothing. Cross-pack pairs never block — a
|
|
31
|
+
* cross-pack merge changes install shape and is a different decision.
|
|
23
32
|
*/
|
|
24
33
|
import * as fs from 'node:fs';
|
|
25
34
|
import * as path from 'node:path';
|
|
26
35
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
36
|
import { parse as parseYaml } from 'yaml';
|
|
28
37
|
|
|
38
|
+
import { SRC_SKILLS } from './_lib/agent_src.js';
|
|
39
|
+
import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
|
|
40
|
+
|
|
29
41
|
const _HERE = fileURLToPath(import.meta.url);
|
|
30
42
|
// src/scripts/audit_skill_overlap.py → parent.parent.parent == repo root.
|
|
31
43
|
export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
32
44
|
export const REPORT_DIR = path.join(ROOT, 'agents', 'reports');
|
|
33
|
-
export const
|
|
34
|
-
export const
|
|
45
|
+
export const OUT_JSON_NAME = 'skill-overlap.json';
|
|
46
|
+
export const OUT_MD_NAME = 'skill-overlap.md';
|
|
47
|
+
export const OUT_JSON = path.join(REPORT_DIR, OUT_JSON_NAME);
|
|
48
|
+
export const OUT_MD = path.join(REPORT_DIR, OUT_MD_NAME);
|
|
49
|
+
export const ALLOWLIST = path.join(ROOT, 'src', 'scripts', 'audit_skill_overlap_allowlist.json');
|
|
50
|
+
export const ALLOWLIST_CAP = 20;
|
|
35
51
|
// re.compile(r"^---\n(.*?)\n---", re.DOTALL) — used with .search (anywhere).
|
|
36
52
|
const FM_RE = /^---\n([\s\S]*?)\n---/m;
|
|
37
53
|
export const OVERLAP_THRESHOLD = 0.7;
|
|
@@ -70,49 +86,16 @@ function _isFile(p: string): boolean {
|
|
|
70
86
|
return false;
|
|
71
87
|
}
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
/** Sorted immediate child entries (mirrors `sorted(p.iterdir())` / `p.glob`). */
|
|
75
|
-
function _iterdirSorted(p: string): string[] {
|
|
76
|
-
let names: string[];
|
|
77
|
-
try {
|
|
78
|
-
names = fs.readdirSync(p);
|
|
79
|
-
} catch {
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
|
-
names.sort();
|
|
83
|
-
return names.map((n) => path.join(p, n));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
89
|
/**
|
|
87
|
-
*
|
|
88
|
-
* roots = [d for d in (ROOT/"packages").glob("*\/.agent-src.uncondensed/skills")
|
|
89
|
-
* if d.is_dir()] if (ROOT/"packages").is_dir() else []
|
|
90
|
-
* legacy = ROOT/".agent-src.uncondensed"/"skills"
|
|
91
|
-
* if not roots and legacy.is_dir(): roots = [legacy]
|
|
90
|
+
* The one scan root: `src/skills`, via the shared ADR-051 resolver.
|
|
92
91
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* children; the resulting root order only affects first-win dedup, and no
|
|
98
|
-
* skill dir name collides across packages in practice.)
|
|
92
|
+
* There is deliberately no fallback. A second, silently-tried root is how the
|
|
93
|
+
* previous resolution stayed green while reading nothing — the caller passes an
|
|
94
|
+
* explicit `--root` when it wants a different tree (fixtures, tests), and any
|
|
95
|
+
* root that yields zero skills fails loudly in {@link main}.
|
|
99
96
|
*/
|
|
100
|
-
export function
|
|
101
|
-
|
|
102
|
-
let roots: string[] = [];
|
|
103
|
-
if (_isDir(pkgs)) {
|
|
104
|
-
for (const child of _iterdirSorted(pkgs)) {
|
|
105
|
-
const cand = path.join(child, '.agent-src.uncondensed', 'skills');
|
|
106
|
-
if (_isDir(cand)) {
|
|
107
|
-
roots.push(cand);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const legacy = path.join(ROOT, '.agent-src.uncondensed', 'skills');
|
|
112
|
-
if (roots.length === 0 && _isDir(legacy)) {
|
|
113
|
-
roots = [legacy];
|
|
114
|
-
}
|
|
115
|
-
return roots;
|
|
97
|
+
export function _default_skill_root(): string {
|
|
98
|
+
return SRC_SKILLS();
|
|
116
99
|
}
|
|
117
100
|
|
|
118
101
|
/**
|
|
@@ -162,8 +145,19 @@ export function _cosine(a: Map<string, number>, b: Map<string, number>): number
|
|
|
162
145
|
return da && db ? num / (da * db) : 0.0;
|
|
163
146
|
}
|
|
164
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Which text the similarity vector is built from.
|
|
150
|
+
*
|
|
151
|
+
* `body` is the CANONICAL metric — the one every historical report, the 0.70
|
|
152
|
+
* threshold, and the merge decisions are calibrated against. `description` is
|
|
153
|
+
* an explicitly non-canonical second view: routing happens on descriptions, and
|
|
154
|
+
* body similarity does not measure it, so the two answer different questions
|
|
155
|
+
* and their scores are never mixed or compared against the same threshold.
|
|
156
|
+
*/
|
|
157
|
+
export type Field = 'body' | 'description';
|
|
158
|
+
|
|
165
159
|
/** Mirror `_parse`: yaml.safe_load frontmatter, body after the FM match. */
|
|
166
|
-
export function _parse(md: string): Skill {
|
|
160
|
+
export function _parse(md: string, field: Field = 'body'): Skill {
|
|
167
161
|
const text = fs.readFileSync(md, 'utf-8');
|
|
168
162
|
let fm: Json = {};
|
|
169
163
|
let body = text;
|
|
@@ -179,11 +173,13 @@ export function _parse(md: string): Skill {
|
|
|
179
173
|
const isObj = fm !== null && typeof fm === 'object' && !Array.isArray(fm);
|
|
180
174
|
const name = isObj ? (fm as Record<string, Json>)['name'] : null;
|
|
181
175
|
const packsVal = isObj ? (fm as Record<string, Json>)['packs'] : null;
|
|
176
|
+
const descVal = isObj ? (fm as Record<string, Json>)['description'] : null;
|
|
177
|
+
const source = field === 'description' ? (descVal ? String(descVal) : '') : body;
|
|
182
178
|
return {
|
|
183
179
|
name: name ? String(name) : path.basename(path.dirname(md)),
|
|
184
180
|
relpath: _relPosix(md, ROOT),
|
|
185
181
|
packs: Array.isArray(packsVal) ? new Set(packsVal.map((x) => String(x))) : new Set(),
|
|
186
|
-
vector: _keyword_vector(
|
|
182
|
+
vector: _keyword_vector(source),
|
|
187
183
|
};
|
|
188
184
|
}
|
|
189
185
|
|
|
@@ -247,22 +243,20 @@ function _pathlibCompare(a: string, b: string): number {
|
|
|
247
243
|
return pa.length - pb.length;
|
|
248
244
|
}
|
|
249
245
|
|
|
250
|
-
export function collect(): Skill[] {
|
|
246
|
+
export function collect(root: string = _default_skill_root(), field: Field = 'body'): Skill[] {
|
|
251
247
|
const skills: Skill[] = [];
|
|
252
248
|
const seen = new Set<string>();
|
|
253
|
-
for (const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
const key = path.basename(path.dirname(md));
|
|
260
|
-
if (seen.has(key)) {
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
seen.add(key);
|
|
264
|
-
skills.push(_parse(md));
|
|
249
|
+
for (const md of _rglobSkillMd(root)) {
|
|
250
|
+
// if "_archive" in md.parts: continue
|
|
251
|
+
if (md.split(path.sep).includes('_archive')) {
|
|
252
|
+
continue;
|
|
265
253
|
}
|
|
254
|
+
const key = path.basename(path.dirname(md));
|
|
255
|
+
if (seen.has(key)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
seen.add(key);
|
|
259
|
+
skills.push(_parse(md, field));
|
|
266
260
|
}
|
|
267
261
|
return skills;
|
|
268
262
|
}
|
|
@@ -354,17 +348,30 @@ function _pct(x: number): string {
|
|
|
354
348
|
return `${pct}%`;
|
|
355
349
|
}
|
|
356
350
|
|
|
357
|
-
export function render_md(
|
|
351
|
+
export function render_md(
|
|
352
|
+
skills: Skill[],
|
|
353
|
+
pairs: Pair[],
|
|
354
|
+
threshold: number,
|
|
355
|
+
field: Field = 'body',
|
|
356
|
+
): string {
|
|
358
357
|
const merge = pairs.filter((p) => p.same_domain);
|
|
359
358
|
const cross = pairs.filter((p) => !p.same_domain);
|
|
360
359
|
const L: string[] = [
|
|
361
360
|
'# Skill-family overlap report (6.0.0-C Phase 4 Step 8)\n',
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
361
|
+
field === 'description'
|
|
362
|
+
? `> **NON-CANONICAL measurement.** Cosine over the \`description:\` ` +
|
|
363
|
+
`frontmatter only, across ${skills.length} skills, at ≥ ${_pct(threshold)}. ` +
|
|
364
|
+
`Routing happens on descriptions, so this answers a question the ` +
|
|
365
|
+
`canonical body metric does not — but its scores are NOT comparable ` +
|
|
366
|
+
`to the canonical 0.70 merge threshold and must never be mixed into ` +
|
|
367
|
+
`a merge decision. The canonical run is the one without ` +
|
|
368
|
+
`\`--descriptions\`.\n`
|
|
369
|
+
: `> Content cosine-similarity over ${skills.length} skills; pairs at ` +
|
|
370
|
+
`≥ ${_pct(threshold)}. **Same-domain pairs (shared \`packs:\`) are the ` +
|
|
371
|
+
`merge candidates** consumed by ` +
|
|
372
|
+
`[\`evidence-based-pruning.md\`](../../docs/contracts/evidence-based-pruning.md). ` +
|
|
373
|
+
`This report merges NOTHING — it is input to a future, human-driven ` +
|
|
374
|
+
`consolidation roadmap.\n`,
|
|
368
375
|
`\n- Skills scanned: **${skills.length}**`,
|
|
369
376
|
`\n- Overlap pairs ≥ ${_pct(threshold)}: **${pairs.length}** ` +
|
|
370
377
|
`(${merge.length} same-domain merge candidates, ${cross.length} cross-domain)\n`,
|
|
@@ -476,30 +483,126 @@ class FloatTag {
|
|
|
476
483
|
|
|
477
484
|
export function main(argv: string[] | null = null): number {
|
|
478
485
|
const args = parse_args(argv ?? process.argv.slice(2));
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
486
|
+
const root = args.root ?? _default_skill_root();
|
|
487
|
+
const skills = collect(root, args.field);
|
|
488
|
+
try {
|
|
489
|
+
assertScanned({
|
|
490
|
+
gate: 'audit_skill_overlap',
|
|
491
|
+
scanned: skills.length,
|
|
492
|
+
units: 'skill(s)',
|
|
493
|
+
roots: [_relPosix(root, ROOT)],
|
|
494
|
+
});
|
|
495
|
+
} catch (exc) {
|
|
496
|
+
if (!(exc instanceof DeadScopeError)) {
|
|
497
|
+
throw exc;
|
|
498
|
+
}
|
|
499
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
482
500
|
return 3;
|
|
483
501
|
}
|
|
484
502
|
const pairs = find_pairs(skills, args.threshold);
|
|
485
|
-
|
|
503
|
+
const outDir = args.outDir ?? REPORT_DIR;
|
|
504
|
+
const outJson = path.join(outDir, OUT_JSON_NAME);
|
|
505
|
+
const outMd = path.join(outDir, OUT_MD_NAME);
|
|
506
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
486
507
|
const jsonObj = {
|
|
487
508
|
threshold: new FloatTag(args.threshold),
|
|
488
509
|
skills_scanned: skills.length,
|
|
489
510
|
pairs: pairs.map(_pairToJson),
|
|
490
511
|
};
|
|
491
|
-
fs.writeFileSync(
|
|
492
|
-
fs.writeFileSync(
|
|
512
|
+
fs.writeFileSync(outJson, _dumpsWithFloats(jsonObj), 'utf-8');
|
|
513
|
+
fs.writeFileSync(outMd, render_md(skills, pairs, args.threshold, args.field), 'utf-8');
|
|
514
|
+
const merge = pairs.filter((p) => p.same_domain);
|
|
493
515
|
if (!args.quiet) {
|
|
494
|
-
const merge = pairs.filter((p) => p.same_domain).length;
|
|
495
516
|
process.stdout.write(
|
|
496
517
|
`✅ Skill overlap: ${skills.length} skills, ${pairs.length} pair(s) ` +
|
|
497
|
-
`≥ ${_pct(args.threshold)} (${merge} same-domain merge candidate(s)).\n`,
|
|
518
|
+
`≥ ${_pct(args.threshold)} (${merge.length} same-domain merge candidate(s)).\n`,
|
|
519
|
+
);
|
|
520
|
+
process.stdout.write(` JSON: ${_relPosix(outJson, ROOT)}\n`);
|
|
521
|
+
process.stdout.write(` MD: ${_relPosix(outMd, ROOT)}\n`);
|
|
522
|
+
}
|
|
523
|
+
// Deliberately NOT gated on --quiet: an advisory CI step whose only output
|
|
524
|
+
// is suppressed by the pipeline's default --quiet is a report nobody reads,
|
|
525
|
+
// which is one step away from the silent-green defect this tool just came
|
|
526
|
+
// back from. The counts line always ships when the table is requested.
|
|
527
|
+
if (args.printTable) {
|
|
528
|
+
process.stdout.write(
|
|
529
|
+
`ℹ️ skill overlap${args.strict ? '' : ' (advisory)'}: ${skills.length} skills scanned, ` +
|
|
530
|
+
`${merge.length} same-domain pair(s) ≥ ${_pct(args.threshold)}\n`,
|
|
531
|
+
);
|
|
532
|
+
for (const p of merge) {
|
|
533
|
+
process.stdout.write(
|
|
534
|
+
` ${p.similarity.toFixed(3)} ${p.a} ↔ ${p.b} [${p.shared_packs.join(', ')}]\n`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!args.strict) {
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
let allow: Set<string>;
|
|
543
|
+
try {
|
|
544
|
+
allow = _loadAllowlist(args.allowlist ?? ALLOWLIST);
|
|
545
|
+
} catch (exc) {
|
|
546
|
+
process.stderr.write(`❌ ${exc instanceof Error ? exc.message : String(exc)}\n`);
|
|
547
|
+
return 2;
|
|
548
|
+
}
|
|
549
|
+
const blocking = merge.filter((p) => !allow.has(_pairKey(p.a, p.b)));
|
|
550
|
+
if (blocking.length === 0) {
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
process.stderr.write(
|
|
554
|
+
`❌ audit_skill_overlap: ${blocking.length} same-pack pair(s) at or above ` +
|
|
555
|
+
`${_pct(args.threshold)} with no reviewed justification:\n`,
|
|
556
|
+
);
|
|
557
|
+
for (const p of blocking) {
|
|
558
|
+
process.stderr.write(
|
|
559
|
+
` ${p.similarity.toFixed(3)} ${p.a} ↔ ${p.b} [${p.shared_packs.join(', ')}]\n`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
process.stderr.write(
|
|
563
|
+
` Merge the pair, or add {"pair": "${_pairKey(blocking[0]!.a, blocking[0]!.b)}", "reason": "…"} ` +
|
|
564
|
+
`to ${_relPosix(args.allowlist ?? ALLOWLIST, ROOT)} (cap ${ALLOWLIST_CAP}).\n`,
|
|
565
|
+
);
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Order-independent key for an unordered pair. */
|
|
570
|
+
export function _pairKey(a: string, b: string): string {
|
|
571
|
+
return a < b ? `${a}::${b}` : `${b}::${a}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Reviewed same-pack overlaps, capped. Over-cap throws rather than warning:
|
|
576
|
+
* per the autonomous-execution allowlist-growth antipattern, an allowlist that
|
|
577
|
+
* outgrows its cap means the threshold is wrong, not that the corpus needs
|
|
578
|
+
* twenty-one exceptions.
|
|
579
|
+
*/
|
|
580
|
+
export function _loadAllowlist(file: string): Set<string> {
|
|
581
|
+
if (!_isFile(file)) {
|
|
582
|
+
return new Set();
|
|
583
|
+
}
|
|
584
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as {
|
|
585
|
+
entries?: Array<{ pair: string; reason: string }>;
|
|
586
|
+
};
|
|
587
|
+
const entries = Array.isArray(data.entries) ? data.entries : [];
|
|
588
|
+
if (entries.length > ALLOWLIST_CAP) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`audit_skill_overlap: allowlist has ${entries.length} entries (> ${ALLOWLIST_CAP}). ` +
|
|
591
|
+
'Per the autonomous-execution allowlist-growth antipattern, the threshold is wrong, ' +
|
|
592
|
+
'not the corpus — re-litigate the bar, do not grow the list.',
|
|
498
593
|
);
|
|
499
|
-
process.stdout.write(` JSON: ${_relPosix(OUT_JSON, ROOT)}\n`);
|
|
500
|
-
process.stdout.write(` MD: ${_relPosix(OUT_MD, ROOT)}\n`);
|
|
501
594
|
}
|
|
502
|
-
|
|
595
|
+
const out = new Set<string>();
|
|
596
|
+
for (const e of entries) {
|
|
597
|
+
const [a, b] = String(e.pair).split('::');
|
|
598
|
+
if (a === undefined || b === undefined || (e.reason ?? '').trim() === '') {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`audit_skill_overlap: allowlist entry ${JSON.stringify(e)} needs "pair": "a::b" and a non-empty "reason".`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
out.add(_pairKey(a, b));
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
503
606
|
}
|
|
504
607
|
|
|
505
608
|
/** json.dumps(indent=2) with FloatTag → Python-float repr. */
|
|
@@ -545,23 +648,58 @@ function _dumpsWithFloats(obj: Json, level = 0): string {
|
|
|
545
648
|
interface Args {
|
|
546
649
|
threshold: number;
|
|
547
650
|
quiet: boolean;
|
|
651
|
+
/** Absolute scan root; `undefined` = the `src/skills` default. */
|
|
652
|
+
root?: string;
|
|
653
|
+
/** Absolute report directory; `undefined` = `agents/reports`. */
|
|
654
|
+
outDir?: string;
|
|
655
|
+
/** Print the same-domain pair table to stdout even under `--quiet`. */
|
|
656
|
+
printTable: boolean;
|
|
657
|
+
/** Fail the build on an unjustified same-pack pair at or above threshold. */
|
|
658
|
+
strict: boolean;
|
|
659
|
+
/** Allowlist path override (tests); `undefined` = the shipped file. */
|
|
660
|
+
allowlist?: string;
|
|
661
|
+
/** Which text the vector is built from. `description` is NON-canonical. */
|
|
662
|
+
field: Field;
|
|
548
663
|
}
|
|
549
664
|
|
|
550
665
|
export function parse_args(argv: string[]): Args {
|
|
551
|
-
const args: Args = {
|
|
666
|
+
const args: Args = {
|
|
667
|
+
threshold: OVERLAP_THRESHOLD,
|
|
668
|
+
quiet: false,
|
|
669
|
+
printTable: false,
|
|
670
|
+
strict: false,
|
|
671
|
+
field: 'body',
|
|
672
|
+
};
|
|
673
|
+
const takeValue = (flag: string, inline: string | undefined, next: () => string | undefined): string => {
|
|
674
|
+
const v = inline ?? next();
|
|
675
|
+
if (v === undefined) {
|
|
676
|
+
process.stderr.write(`argument ${flag}: expected one argument\n`);
|
|
677
|
+
process.exit(2);
|
|
678
|
+
}
|
|
679
|
+
return v;
|
|
680
|
+
};
|
|
552
681
|
for (let i = 0; i < argv.length; i++) {
|
|
553
682
|
const a = argv[i] as string;
|
|
554
683
|
if (a === '--quiet') {
|
|
555
684
|
args.quiet = true;
|
|
556
|
-
} else if (a === '--
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
args.
|
|
563
|
-
} else if (a
|
|
564
|
-
args.
|
|
685
|
+
} else if (a === '--strict') {
|
|
686
|
+
args.strict = true;
|
|
687
|
+
} else if (a === '--allowlist' || a.startsWith('--allowlist=')) {
|
|
688
|
+
const inline = a.startsWith('--allowlist=') ? a.slice('--allowlist='.length) : undefined;
|
|
689
|
+
args.allowlist = path.resolve(ROOT, takeValue('--allowlist', inline, () => argv[++i]));
|
|
690
|
+
} else if (a === '--descriptions') {
|
|
691
|
+
args.field = 'description';
|
|
692
|
+
} else if (a === '--print-table') {
|
|
693
|
+
args.printTable = true;
|
|
694
|
+
} else if (a === '--threshold' || a.startsWith('--threshold=')) {
|
|
695
|
+
const inline = a.startsWith('--threshold=') ? a.slice('--threshold='.length) : undefined;
|
|
696
|
+
args.threshold = Number(takeValue('--threshold', inline, () => argv[++i]));
|
|
697
|
+
} else if (a === '--root' || a.startsWith('--root=')) {
|
|
698
|
+
const inline = a.startsWith('--root=') ? a.slice('--root='.length) : undefined;
|
|
699
|
+
args.root = path.resolve(ROOT, takeValue('--root', inline, () => argv[++i]));
|
|
700
|
+
} else if (a === '--out-dir' || a.startsWith('--out-dir=')) {
|
|
701
|
+
const inline = a.startsWith('--out-dir=') ? a.slice('--out-dir='.length) : undefined;
|
|
702
|
+
args.outDir = path.resolve(ROOT, takeValue('--out-dir', inline, () => argv[++i]));
|
|
565
703
|
} else {
|
|
566
704
|
process.stderr.write(`unrecognized arguments: ${a}\n`);
|
|
567
705
|
process.exit(2);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Reviewed same-pack overlaps at or above the canonical 0.70 threshold. Each entry needs a real reason — a pair is here because a human read both skills and decided the overlap is structural, not because the gate was noisy. Capped at 20 by audit_skill_overlap.ts: crossing the cap means the threshold is wrong, not that the corpus needs a twenty-first exception. Empty is the healthy state.",
|
|
3
|
+
"entries": []
|
|
4
|
+
}
|
|
@@ -146,6 +146,11 @@ function render(): string {
|
|
|
146
146
|
const entries = [...ledger.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
147
147
|
const backed = entries.filter((e) => e.status === 'backed');
|
|
148
148
|
const unbacked = entries.filter((e) => e.status === 'unbacked');
|
|
149
|
+
// `resolved-null` is neither backed nor debt: the question was asked, the
|
|
150
|
+
// answer came back null, and the entry is closed. Counting it as debt
|
|
151
|
+
// would inflate the inventory with items that can never shrink; counting
|
|
152
|
+
// it as backed would claim a win that was never measured.
|
|
153
|
+
const resolvedNull = entries.filter((e) => e.status === 'resolved-null');
|
|
149
154
|
|
|
150
155
|
const L: string[] = [];
|
|
151
156
|
L.push('<!-- GENERATED by src/scripts/build_proof.ts — do NOT hand-edit.');
|
|
@@ -232,6 +237,13 @@ function render(): string {
|
|
|
232
237
|
L.push('allowed to carry a marker in public prose. Hiding them would be the');
|
|
233
238
|
L.push('opposite of the point.');
|
|
234
239
|
}
|
|
240
|
+
if (resolvedNull.length > 0) {
|
|
241
|
+
L.push('');
|
|
242
|
+
L.push(`And our **nulls**: ${resolvedNull.length} claim(s) are \`resolved-null\` —`);
|
|
243
|
+
L.push('measured, the threshold was missed, and the entry is closed rather than');
|
|
244
|
+
L.push('left open forever. A null that stays filed as pending debt is a claim');
|
|
245
|
+
L.push('quietly waiting to be re-argued.');
|
|
246
|
+
}
|
|
235
247
|
L.push('');
|
|
236
248
|
L.push('## 2. We publish honest nulls');
|
|
237
249
|
L.push('');
|
|
@@ -419,9 +431,11 @@ function render(): string {
|
|
|
419
431
|
{
|
|
420
432
|
const ledger = [...load_ledger().values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
421
433
|
const backed = ledger.filter((e) => e.status === 'backed').length;
|
|
434
|
+
const nulls = ledger.filter((e) => e.status === 'resolved-null').length;
|
|
422
435
|
L.push(
|
|
423
436
|
`**Axis 2 — evidence form per public claim.** ${ledger.length} ledger entries · ` +
|
|
424
|
-
`${backed} backed · ${ledger.length - backed} unbacked inventory
|
|
437
|
+
`${backed} backed · ${ledger.length - backed - nulls} unbacked inventory · ` +
|
|
438
|
+
`${nulls} resolved-null.`,
|
|
425
439
|
);
|
|
426
440
|
L.push('');
|
|
427
441
|
L.push('| Claim id | Kind | Status | Evidence pointer |');
|
|
@@ -33,7 +33,7 @@ import * as path from 'node:path';
|
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
34
|
|
|
35
35
|
import { canonical_counts } from './check_command_count_messaging.js';
|
|
36
|
-
import { count } from './update_counts.js';
|
|
36
|
+
import { count, TARGETS } from './update_counts.js';
|
|
37
37
|
|
|
38
38
|
const _HERE = fileURLToPath(import.meta.url);
|
|
39
39
|
export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -164,6 +164,93 @@ export function scan_structured(
|
|
|
164
164
|
return { findings, seen };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// --- Anchor-coverage pass (road-to-reproducible-artefact-counts) ----------
|
|
168
|
+
//
|
|
169
|
+
// The two gates used to overlap only by accident. This scanner flags any
|
|
170
|
+
// count-shaped prose; `update_counts` rewrites a hand-written list of
|
|
171
|
+
// anchors. Nothing asserted the second list covered the first, so positions
|
|
172
|
+
// existed that the scanner could turn red and the generator could not fix —
|
|
173
|
+
// the README Commands badge and the getting-started browse line drifted to
|
|
174
|
+
// 191 against a canonical 192 exactly there, each explicitly left unanchored
|
|
175
|
+
// to "avoid double-ownership". The reconciliation was a human's job, and the
|
|
176
|
+
// gate's own advice said so ("run update_counts … OR correct the prose").
|
|
177
|
+
//
|
|
178
|
+
// This pass removes the human: every position this scanner can flag must be
|
|
179
|
+
// rewritable by the generator, or be a generated file whose source is. A new
|
|
180
|
+
// count-shaped sentence with no anchor now fails CI as a coverage gap.
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Scanner kind → the generator kinds that legitimately write it.
|
|
184
|
+
*
|
|
185
|
+
* One prose kind can have several canonical numbers and they are NOT
|
|
186
|
+
* interchangeable: "N commands" in prose means ACTIVE commands (total minus
|
|
187
|
+
* deprecation shims), and a scoped-projection sentence carries the projected
|
|
188
|
+
* skill count beside the catalog total. Anchoring an active-count position
|
|
189
|
+
* to the raw `commands` total would silently write the wrong number the day
|
|
190
|
+
* a command is superseded — which is why this is a mapping and not equality.
|
|
191
|
+
*/
|
|
192
|
+
const ANCHOR_KINDS: Readonly<Record<string, readonly string[]>> = {
|
|
193
|
+
skills: ['skills', 'skills_scoped'],
|
|
194
|
+
commands: ['commands', 'commands_active'],
|
|
195
|
+
rules: ['rules'],
|
|
196
|
+
guidelines: ['guidelines'],
|
|
197
|
+
personas: ['personas'],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Surfaces regenerated from an anchored source — exempt because fixing the
|
|
202
|
+
* source fixes them. Each entry names the generator, so the exemption stays
|
|
203
|
+
* falsifiable rather than becoming a place to hide drift.
|
|
204
|
+
*/
|
|
205
|
+
export const GENERATED_DOWNSTREAM: Readonly<Record<string, string>> = {
|
|
206
|
+
'docs/proof.md': 'generated from docs/CLAIMS.md by src/scripts/build_proof.ts',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface CoverageGap {
|
|
210
|
+
file: string;
|
|
211
|
+
line: number;
|
|
212
|
+
kind: string;
|
|
213
|
+
text: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Positions this scanner can flag that `update_counts` cannot rewrite.
|
|
218
|
+
*
|
|
219
|
+
* A position counts as anchored when some `TARGETS` pattern for the same
|
|
220
|
+
* file, bound to a compatible kind, matches the same line.
|
|
221
|
+
*/
|
|
222
|
+
export function anchor_coverage_gaps(root: string = ROOT): CoverageGap[] {
|
|
223
|
+
const anchorsByFile = new Map<string, ReadonlyArray<[string, string]>>();
|
|
224
|
+
for (const [rel, patterns] of TARGETS) {
|
|
225
|
+
anchorsByFile.set(rel, patterns);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const gaps: CoverageGap[] = [];
|
|
229
|
+
for (const rel of SURFACES) {
|
|
230
|
+
if (rel in GENERATED_DOWNSTREAM) continue;
|
|
231
|
+
const p = path.join(root, rel);
|
|
232
|
+
if (!fs.existsSync(p)) continue;
|
|
233
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n');
|
|
234
|
+
const anchors = anchorsByFile.get(rel) ?? [];
|
|
235
|
+
for (const [kind, pattern] of KIND_PATTERNS) {
|
|
236
|
+
const allowed = ANCHOR_KINDS[kind] ?? [kind];
|
|
237
|
+
for (let i = 0; i < lines.length; i++) {
|
|
238
|
+
const line = lines[i]!;
|
|
239
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
240
|
+
if (!re.test(line)) continue;
|
|
241
|
+
const anchored = anchors.some(
|
|
242
|
+
([raw, anchorKind]) =>
|
|
243
|
+
allowed.includes(anchorKind) && new RegExp(raw).test(line),
|
|
244
|
+
);
|
|
245
|
+
if (!anchored) {
|
|
246
|
+
gaps.push({ file: rel, line: i + 1, kind, text: line.trim().slice(0, 100) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return gaps;
|
|
252
|
+
}
|
|
253
|
+
|
|
167
254
|
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
168
255
|
const QUIET = argv.includes('--quiet');
|
|
169
256
|
const expected: Record<string, number> = {};
|
|
@@ -213,29 +300,55 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
|
213
300
|
}
|
|
214
301
|
}
|
|
215
302
|
|
|
216
|
-
|
|
303
|
+
// Anchor coverage — every position this gate can flag must be a position
|
|
304
|
+
// `update_counts` can rewrite, so the two never need a human to reconcile.
|
|
305
|
+
const gaps = anchor_coverage_gaps();
|
|
306
|
+
|
|
307
|
+
if (allFindings.length === 0 && inconsistent.length === 0 && gaps.length === 0) {
|
|
217
308
|
if (!QUIET) {
|
|
218
|
-
process.stdout.write(
|
|
309
|
+
process.stdout.write(
|
|
310
|
+
'✅ All artefact-count prose in sync with source, every position anchored.\n',
|
|
311
|
+
);
|
|
219
312
|
}
|
|
220
313
|
return 0;
|
|
221
314
|
}
|
|
222
315
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
316
|
+
if (allFindings.length > 0 || inconsistent.length > 0) {
|
|
317
|
+
process.stdout.write(
|
|
318
|
+
`❌ Artefact-count messaging drift — ${allFindings.length} mismatch(es):\n`,
|
|
319
|
+
);
|
|
320
|
+
for (const f of allFindings) {
|
|
321
|
+
const tag = f.approx ? ' (approximation "~" not allowed on flagship surfaces)' : '';
|
|
322
|
+
process.stdout.write(
|
|
323
|
+
` ${f.file}:${f.line}: ${f.kind} says ${f.found}, expected ${f.expected}${tag}\n`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
for (const line of inconsistent) {
|
|
327
|
+
process.stdout.write(` internal inconsistency — ${line}\n`);
|
|
328
|
+
}
|
|
228
329
|
process.stdout.write(
|
|
229
|
-
|
|
330
|
+
'\nFix: run `./scripts-run src/scripts/update_counts` — every checked position\n' +
|
|
331
|
+
'is anchored, so the generator can write it. Never hand-type a count.\n',
|
|
230
332
|
);
|
|
231
333
|
}
|
|
232
|
-
|
|
233
|
-
|
|
334
|
+
|
|
335
|
+
if (gaps.length > 0) {
|
|
336
|
+
process.stdout.write(
|
|
337
|
+
`❌ Anchor-coverage gap — ${gaps.length} position(s) this gate checks but\n` +
|
|
338
|
+
' `update_counts` cannot rewrite:\n',
|
|
339
|
+
);
|
|
340
|
+
for (const g of gaps) {
|
|
341
|
+
process.stdout.write(` ${g.file}:${g.line}: ${g.kind} — ${g.text}\n`);
|
|
342
|
+
}
|
|
343
|
+
process.stdout.write(
|
|
344
|
+
'\nFix: add an anchor for each position to `TARGETS` in\n' +
|
|
345
|
+
'`src/scripts/update_counts.ts`, bound to the kind that position\n' +
|
|
346
|
+
'actually carries (`commands_active` for active-command mentions,\n' +
|
|
347
|
+
'`skills_scoped` for the projected-skill figure). If the file is\n' +
|
|
348
|
+
'generated from an anchored source, register it in\n' +
|
|
349
|
+
'`GENERATED_DOWNSTREAM` with the generator that produces it.\n',
|
|
350
|
+
);
|
|
234
351
|
}
|
|
235
|
-
process.stdout.write(
|
|
236
|
-
'\nFix: run `./scripts-run src/scripts/update_counts` for anchored positions,\n' +
|
|
237
|
-
'or correct the prose to the canonical number (never hand-type a new one).\n',
|
|
238
|
-
);
|
|
239
352
|
return 1;
|
|
240
353
|
}
|
|
241
354
|
|