@event4u/agent-config 9.22.0 → 9.23.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 +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -10,6 +10,27 @@
|
|
|
10
10
|
* 2. JUSTIFICATION — every `rich`-tagged skill carries a
|
|
11
11
|
* `## Why this skill is rich` section explaining the irreducible
|
|
12
12
|
* complexity.
|
|
13
|
+
* 3. SIZE — a `rich`-tagged skill stays under the declared ceiling
|
|
14
|
+
* (3,500 tokens, ADR-217). Measured with the exact BPE tokenizer
|
|
15
|
+
* where `js-tiktoken` resolves and with the character proxy where it does
|
|
16
|
+
* not; the gate says which. A proxy measurement sitting within its own
|
|
17
|
+
* error margin of the ceiling is reported UNRESOLVED rather than silently
|
|
18
|
+
* classified — the proxy runs ~5 % off per file IN EITHER DIRECTION, and a
|
|
19
|
+
* reading whose error band straddles the ceiling has not decided anything.
|
|
20
|
+
*
|
|
21
|
+
* The band was documentation until ADR-217: the ceiling described no artifact
|
|
22
|
+
* that existed (measured max 3,331 of a declared 5,000), so nothing ever
|
|
23
|
+
* noticed that its top half was unused. An unused permission costs nothing
|
|
24
|
+
* until someone uses it.
|
|
25
|
+
*
|
|
26
|
+
* The band's FLOOR is documented and deliberately NOT gated. Running the check
|
|
27
|
+
* once found `accessibility-auditor` at 1,931 tokens — under the 2,000 floor
|
|
28
|
+
* while legitimately holding the class, because `rich` buys exemption from
|
|
29
|
+
* condensation and that is a claim about what compression would LOSE, not
|
|
30
|
+
* about how large the file is. The published measurement supplies a
|
|
31
|
+
* degradation threshold, which is a ceiling; nothing measures a minimum. A
|
|
32
|
+
* floor gate would have forced a WCAG-criteria skill out of the exemption on a
|
|
33
|
+
* number with no evidence behind it.
|
|
13
34
|
*
|
|
14
35
|
* Additionally, any `token_budget_class` value outside
|
|
15
36
|
* `lean | standard | rich` is a finding (schema drift).
|
|
@@ -20,6 +41,8 @@ import * as fs from 'node:fs';
|
|
|
20
41
|
import * as path from 'node:path';
|
|
21
42
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
43
|
|
|
44
|
+
import { gpt_tokens } from './_lib/token_count.js';
|
|
45
|
+
|
|
23
46
|
const _HERE = fileURLToPath(import.meta.url);
|
|
24
47
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
25
48
|
|
|
@@ -32,6 +55,17 @@ export const VALID_CLASSES = ['lean', 'standard', 'rich'] as const;
|
|
|
32
55
|
|
|
33
56
|
export const RICH_SECTION_HEADING = '## Why this skill is rich';
|
|
34
57
|
|
|
58
|
+
/** Declared band for the rich class (ADR-217). Floor unchanged; ceiling 5,000 → 3,500. */
|
|
59
|
+
export const RICH_MIN_TOKENS = 2000;
|
|
60
|
+
export const RICH_MAX_TOKENS = 3500;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Worst-case per-file error of the character proxy, measured over the four
|
|
64
|
+
* rich artifacts on 2026-08-06 (−5.3 % on the largest). A proxy measurement
|
|
65
|
+
* inside this margin of a boundary is not a verdict.
|
|
66
|
+
*/
|
|
67
|
+
export const PROXY_ERROR_MARGIN = 0.06;
|
|
68
|
+
|
|
35
69
|
export interface Finding {
|
|
36
70
|
/** repo-relative path */
|
|
37
71
|
file: string;
|
|
@@ -44,6 +78,45 @@ export interface ScanResult {
|
|
|
44
78
|
scanned: number;
|
|
45
79
|
/** Skills declaring token_budget_class: rich. */
|
|
46
80
|
richCount: number;
|
|
81
|
+
/** Per-rich-skill size measurements, for the report line. */
|
|
82
|
+
sizes: RichSize[];
|
|
83
|
+
/** True when every size came from the exact BPE tokenizer. */
|
|
84
|
+
exactThroughout: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RichSize {
|
|
88
|
+
file: string;
|
|
89
|
+
tokens: number;
|
|
90
|
+
exact: boolean;
|
|
91
|
+
/** Set when a proxy measurement lands inside its own error margin of a boundary. */
|
|
92
|
+
unresolved: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Measure one artifact, exact where the tokenizer resolved.
|
|
97
|
+
*
|
|
98
|
+
* Split out so the size rule is testable without a filesystem: the boundary
|
|
99
|
+
* cases that matter (exactly at the cap, one token over, a proxy reading a
|
|
100
|
+
* hair under) are the ones a fixture tree makes awkward and a function call
|
|
101
|
+
* makes trivial.
|
|
102
|
+
*/
|
|
103
|
+
export function classify_size(tokens: number, exact: boolean): { over: boolean; unresolved: boolean } {
|
|
104
|
+
if (exact) {
|
|
105
|
+
return { over: tokens > RICH_MAX_TOKENS, unresolved: false };
|
|
106
|
+
}
|
|
107
|
+
// A proxy reading is a band around the truth, and the band is SYMMETRIC.
|
|
108
|
+
// The first version applied the margin upward only — guarding a proxy that
|
|
109
|
+
// reads low — while the measured error on the largest artifact runs the
|
|
110
|
+
// other way: 3,518 by proxy against 3,331 exact, reading 5.3 % HIGH. That
|
|
111
|
+
// version therefore hard-failed the one artifact ADR-217 rules in-band,
|
|
112
|
+
// on any machine without the tokenizer (`js-tiktoken` is a devDependency).
|
|
113
|
+
//
|
|
114
|
+
// So: a proxy verdict counts only when the WHOLE band falls on one side of
|
|
115
|
+
// the ceiling. A band straddling it is `unresolved` — which is a finding,
|
|
116
|
+
// but one that says "measure properly" rather than "this is too big".
|
|
117
|
+
const margin = Math.ceil(tokens * PROXY_ERROR_MARGIN);
|
|
118
|
+
const straddles = tokens - margin <= RICH_MAX_TOKENS && tokens + margin > RICH_MAX_TOKENS;
|
|
119
|
+
return { over: !straddles && tokens - margin > RICH_MAX_TOKENS, unresolved: straddles };
|
|
47
120
|
}
|
|
48
121
|
|
|
49
122
|
/** `token_budget_class` value from a SKILL.md's YAML frontmatter, or null. */
|
|
@@ -63,6 +136,7 @@ export function frontmatter_class(content: string): string | null {
|
|
|
63
136
|
/** Scan one skills root (directory of `<skill>/SKILL.md`). */
|
|
64
137
|
export function scan_skills(skillsRoot: string): ScanResult {
|
|
65
138
|
const findings: Finding[] = [];
|
|
139
|
+
const sizes: RichSize[] = [];
|
|
66
140
|
let scanned = 0;
|
|
67
141
|
let richCount = 0;
|
|
68
142
|
const entries = fs
|
|
@@ -97,6 +171,28 @@ export function scan_skills(skillsRoot: string): ScanResult {
|
|
|
97
171
|
message: `rich-tagged skill missing the '${RICH_SECTION_HEADING}' justification section`,
|
|
98
172
|
});
|
|
99
173
|
}
|
|
174
|
+
const measured = gpt_tokens(content);
|
|
175
|
+
const verdict = classify_size(measured.tokens, measured.exact);
|
|
176
|
+
sizes.push({ file: rel, tokens: measured.tokens, exact: measured.exact, unresolved: verdict.unresolved });
|
|
177
|
+
const how = measured.exact ? 'exact BPE' : 'chars/4 proxy';
|
|
178
|
+
if (verdict.over) {
|
|
179
|
+
findings.push({
|
|
180
|
+
file: rel,
|
|
181
|
+
message:
|
|
182
|
+
`rich-class size band exceeded: ${String(measured.tokens)} tokens (${how}) > ` +
|
|
183
|
+
`${String(RICH_MAX_TOKENS)} (ADR-217). Split by responsibility, or argue the ` +
|
|
184
|
+
'ceiling in a decision record — not in this file.',
|
|
185
|
+
});
|
|
186
|
+
} else if (verdict.unresolved) {
|
|
187
|
+
findings.push({
|
|
188
|
+
file: rel,
|
|
189
|
+
message:
|
|
190
|
+
`rich-class size UNRESOLVED: ${String(measured.tokens)} tokens (${how}) sits within the ` +
|
|
191
|
+
`${String(PROXY_ERROR_MARGIN * 100)} % proxy margin of a band boundary. Install the exact ` +
|
|
192
|
+
'tokenizer (js-tiktoken is a declared dependency) and re-run — a proxy reading this ' +
|
|
193
|
+
'close has not decided anything.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
100
196
|
}
|
|
101
197
|
}
|
|
102
198
|
if (scanned > 0 && richCount > scanned * RICH_RATIO_CAP) {
|
|
@@ -108,7 +204,7 @@ export function scan_skills(skillsRoot: string): ScanResult {
|
|
|
108
204
|
`max ${String(Math.floor(scanned * RICH_RATIO_CAP))})`,
|
|
109
205
|
});
|
|
110
206
|
}
|
|
111
|
-
return { findings, scanned, richCount };
|
|
207
|
+
return { findings, scanned, richCount, sizes, exactThroughout: sizes.every((s) => s.exact) };
|
|
112
208
|
}
|
|
113
209
|
|
|
114
210
|
export function main(argv?: readonly string[]): number {
|
|
@@ -134,9 +230,21 @@ export function main(argv?: readonly string[]): number {
|
|
|
134
230
|
process.stdout.write(`❌ ${f.file} ${f.message}\n`);
|
|
135
231
|
}
|
|
136
232
|
if (result.findings.length === 0 && !quiet) {
|
|
233
|
+
const how = result.exactThroughout ? 'exact BPE' : 'chars/4 proxy (js-tiktoken did not resolve)';
|
|
234
|
+
const largest = result.sizes.reduce((max, s) => (s.tokens > max ? s.tokens : max), 0);
|
|
137
235
|
process.stdout.write(
|
|
138
236
|
`✅ token-budget discipline: ${String(result.richCount)} rich of ${String(result.scanned)} skills within the ${String(RICH_RATIO_CAP * 100)} % cap; every rich skill justifies itself\n`,
|
|
139
237
|
);
|
|
238
|
+
// Publish the sizes on the GREEN path too. A band nobody sees the
|
|
239
|
+
// numbers for is how this one spent months describing no artifact
|
|
240
|
+
// that existed.
|
|
241
|
+
process.stdout.write(
|
|
242
|
+
` size ceiling ${String(RICH_MAX_TOKENS)} (ADR-217; the ${String(RICH_MIN_TOKENS)} floor is documented, not gated), ` +
|
|
243
|
+
`measured ${how}; largest ${String(largest)}\n`,
|
|
244
|
+
);
|
|
245
|
+
for (const s of result.sizes) {
|
|
246
|
+
process.stdout.write(` ${String(s.tokens).padStart(5)} ${s.file}\n`);
|
|
247
|
+
}
|
|
140
248
|
}
|
|
141
249
|
// gate-coverage contract (src/config/gate-coverage.yml): files inspected.
|
|
142
250
|
process.stdout.write(`scanned: ${String(result.scanned)}\n`);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* report_imperative_density.ts — an advisory number whose direction is unknown.
|
|
4
|
+
*
|
|
5
|
+
* Prints the ratio of imperative-marker lines to prose lines across the
|
|
6
|
+
* authored rules and skills. **It gates on nothing, and it must not acquire a
|
|
7
|
+
* threshold without the measurement that would justify one.**
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS NOT A GATE, AND MUST NOT BECOME ONE
|
|
10
|
+
* -----------------------------------------------
|
|
11
|
+
* Three external sources disagree about which direction is better. One asserts
|
|
12
|
+
* reasoning-based phrasing outperforms rigid directives. One ships a metric
|
|
13
|
+
* that scores instruction files HIGHER for imperative-marker density — the
|
|
14
|
+
* opposite ranking from the same observation. A third sits between them and
|
|
15
|
+
* supplies the only part all three can live with: a rule the agent consistently
|
|
16
|
+
* fails to follow needs structural enforcement or deletion, not a louder rule.
|
|
17
|
+
*
|
|
18
|
+
* This suite's house style sits at the imperative end and has no measurement
|
|
19
|
+
* either way. A gate would prejudge exactly the question the sweep left open
|
|
20
|
+
* (`skill-ecosystem-sweep-2026-08.md` § R2). An advisory number is the input a
|
|
21
|
+
* future decision needs; a target is the thing that would corrupt it, because
|
|
22
|
+
* the moment a number is optimised it stops measuring what it measured.
|
|
23
|
+
*
|
|
24
|
+
* So: no exit-2, no baseline entry, no `gate-coverage.yml` row, and the header
|
|
25
|
+
* of every run says the direction is unsettled. Read it as a description, not
|
|
26
|
+
* as a score.
|
|
27
|
+
*
|
|
28
|
+
* Exit: 0 always (except a usage/IO error, which is 1). That is deliberate.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
36
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
37
|
+
|
|
38
|
+
export const RULES_ROOT = 'src/rules';
|
|
39
|
+
export const SKILLS_ROOT = 'src/skills';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lines counted as imperative markers.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately narrow and stated: an all-caps directive line, a bullet opening
|
|
45
|
+
* with a modal or a bare imperative verb, and the fenced Iron-Law blocks this
|
|
46
|
+
* suite uses. A broader net would inflate the number without making it mean
|
|
47
|
+
* more, and the number's meaning is already the open question.
|
|
48
|
+
*/
|
|
49
|
+
const IMPERATIVE_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
50
|
+
['all-caps directive', /^[^a-z]*\b(NEVER|ALWAYS|MUST|DO NOT|STOP|REFUSE|FORBIDDEN)\b[^a-z]*$/],
|
|
51
|
+
['inline modal', /\b(MUST|NEVER|ALWAYS|DO NOT|SHALL)\b/],
|
|
52
|
+
['imperative bullet', /^\s*[-*]\s+(Always|Never|Do not|Don't|Use|Run|Check|Verify|Refuse|Stop|Add|Remove|Keep|Prefer|Avoid)\b/],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export interface FileDensity {
|
|
56
|
+
file: string;
|
|
57
|
+
prose: number;
|
|
58
|
+
imperative: number;
|
|
59
|
+
ratio: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DensityReport {
|
|
63
|
+
files: FileDensity[];
|
|
64
|
+
totalProse: number;
|
|
65
|
+
totalImperative: number;
|
|
66
|
+
/** Ratio over the whole corpus, or null when nothing was counted. */
|
|
67
|
+
ratio: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lines that carry prose. Fenced code, frontmatter, table rows, and blanks are
|
|
72
|
+
* excluded — counting them would make the ratio a function of how much YAML a
|
|
73
|
+
* file happens to carry.
|
|
74
|
+
*/
|
|
75
|
+
export function proseLines(text: string): string[] {
|
|
76
|
+
const lines = text.split('\n');
|
|
77
|
+
|
|
78
|
+
// Frontmatter only when the opening `---` actually CLOSES. A file whose
|
|
79
|
+
// first line is a horizontal rule used to swallow everything to the next
|
|
80
|
+
// `---`, and a file with an unterminated opener swallowed all of it.
|
|
81
|
+
let start = 0;
|
|
82
|
+
if (lines[0]?.trim() === '---') {
|
|
83
|
+
const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
84
|
+
// …and only when the block LOOKS like YAML. `---` opening a document
|
|
85
|
+
// that then reads as prose is a horizontal rule, and treating it as
|
|
86
|
+
// frontmatter silently ate everything up to the next one.
|
|
87
|
+
const looksYaml = close > 0 && lines.slice(1, close).some((l) => /^\s*[\w.-]+:\s/.test(l));
|
|
88
|
+
if (close !== -1 && looksYaml) start = close + 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fences are matched BY CHARACTER, so `~~~` cannot close a ```-opened
|
|
92
|
+
// block, and an opener that never closes is treated as though it never
|
|
93
|
+
// opened — dropping the rest of a file silently is worse than counting a
|
|
94
|
+
// few code lines as prose, because the denominator is the thing this
|
|
95
|
+
// report publishes.
|
|
96
|
+
const openers: Array<{ index: number; marker: string }> = [];
|
|
97
|
+
const fenced = new Set<number>();
|
|
98
|
+
let open: { index: number; marker: string } | null = null;
|
|
99
|
+
for (let i = start; i < lines.length; i++) {
|
|
100
|
+
const m = /^\s*(`{3,}|~{3,})/.exec(lines[i] ?? '');
|
|
101
|
+
if (m === null) continue;
|
|
102
|
+
const marker = (m[1] as string)[0] as string;
|
|
103
|
+
if (open === null) {
|
|
104
|
+
open = { index: i, marker };
|
|
105
|
+
openers.push(open);
|
|
106
|
+
} else if (open.marker === marker) {
|
|
107
|
+
for (let j = open.index; j <= i; j++) fenced.add(j);
|
|
108
|
+
open = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out: string[] = [];
|
|
113
|
+
for (let i = start; i < lines.length; i++) {
|
|
114
|
+
if (fenced.has(i)) continue;
|
|
115
|
+
const line = (lines[i] ?? '').trimEnd();
|
|
116
|
+
if (line.trim() === '') continue;
|
|
117
|
+
if (/^\s*\|/.test(line)) continue;
|
|
118
|
+
out.push(line);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function countImperative(lines: readonly string[]): number {
|
|
124
|
+
let n = 0;
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
if (IMPERATIVE_PATTERNS.some(([, re]) => re.test(line))) n += 1;
|
|
127
|
+
}
|
|
128
|
+
return n;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _walkMarkdown(dir: string): string[] {
|
|
132
|
+
let entries: fs.Dirent[];
|
|
133
|
+
try {
|
|
134
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const out: string[] = [];
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
const full = path.join(dir, e.name);
|
|
141
|
+
if (e.isDirectory()) out.push(..._walkMarkdown(full));
|
|
142
|
+
else if (e.name.endsWith('.md')) out.push(full);
|
|
143
|
+
}
|
|
144
|
+
return out.sort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildReport(root: string): DensityReport {
|
|
148
|
+
const files: FileDensity[] = [];
|
|
149
|
+
let totalProse = 0;
|
|
150
|
+
let totalImperative = 0;
|
|
151
|
+
for (const dir of [RULES_ROOT, SKILLS_ROOT]) {
|
|
152
|
+
for (const abs of _walkMarkdown(path.join(root, dir))) {
|
|
153
|
+
const lines = proseLines(fs.readFileSync(abs, 'utf-8'));
|
|
154
|
+
if (lines.length === 0) continue;
|
|
155
|
+
const imperative = countImperative(lines);
|
|
156
|
+
totalProse += lines.length;
|
|
157
|
+
totalImperative += imperative;
|
|
158
|
+
files.push({
|
|
159
|
+
file: path.relative(root, abs).split(path.sep).join('/'),
|
|
160
|
+
prose: lines.length,
|
|
161
|
+
imperative,
|
|
162
|
+
ratio: imperative / lines.length,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
files,
|
|
168
|
+
totalProse,
|
|
169
|
+
totalImperative,
|
|
170
|
+
ratio: totalProse === 0 ? null : totalImperative / totalProse,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const HEADER = [
|
|
175
|
+
'imperative-density report — ADVISORY, and the direction of this number is UNSETTLED.',
|
|
176
|
+
'',
|
|
177
|
+
'Three sources disagree on whether higher is better. One prefers reasoning-based',
|
|
178
|
+
'phrasing; one scores instruction files higher for imperative density; the third',
|
|
179
|
+
'declines the question and supplies the operational fork instead. This suite has',
|
|
180
|
+
'no measurement either way — see skill-ecosystem-sweep-2026-08.md § R2.',
|
|
181
|
+
'',
|
|
182
|
+
'Do not read this as a score, and do not optimise it. It gates nothing on purpose:',
|
|
183
|
+
'a threshold here would prejudge the open question, and a number that gets',
|
|
184
|
+
'optimised stops measuring what it measured.',
|
|
185
|
+
].join('\n');
|
|
186
|
+
|
|
187
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
188
|
+
const top = argv.includes('--top') ? Number.parseInt(argv[argv.indexOf('--top') + 1] ?? '10', 10) : 10;
|
|
189
|
+
const json = argv.includes('--json');
|
|
190
|
+
|
|
191
|
+
const report = buildReport(REPO_ROOT);
|
|
192
|
+
if (json) {
|
|
193
|
+
process.stdout.write(`${JSON.stringify({ note: 'advisory; direction unsettled (§ R2)', ...report }, null, 2)}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
process.stdout.write(`${HEADER}\n\n`);
|
|
198
|
+
if (report.ratio === null) {
|
|
199
|
+
// `renderShare`'s rule, applied by hand: an empty denominator renders as
|
|
200
|
+
// "not measured", never as 0.0 %.
|
|
201
|
+
process.stdout.write('corpus ratio: not measured — zero prose lines found under the scanned roots\n');
|
|
202
|
+
process.stdout.write(`files: ${String(report.files.length)}\n`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
process.stdout.write(
|
|
206
|
+
`corpus ratio: ${(report.ratio * 100).toFixed(1)} % ` +
|
|
207
|
+
`(${String(report.totalImperative)} imperative lines of ${String(report.totalProse)} prose lines, ` +
|
|
208
|
+
`${String(report.files.length)} files)\n\n`,
|
|
209
|
+
);
|
|
210
|
+
const ranked = [...report.files].sort((a, b) => b.ratio - a.ratio).slice(0, top);
|
|
211
|
+
process.stdout.write(`densest ${String(ranked.length)}:\n`);
|
|
212
|
+
for (const f of ranked) {
|
|
213
|
+
process.stdout.write(
|
|
214
|
+
` ${(f.ratio * 100).toFixed(1).padStart(5)} % ${String(f.imperative).padStart(4)}/${String(f.prose).padEnd(5)} ${f.file}\n`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
// `files:` and not `scanned:` on purpose. The latter is the machine-read
|
|
218
|
+
// coverage signal `check_gate_coverage` parses, and emitting it would
|
|
219
|
+
// enlist an advisory report as a gate with a coverage floor — the exact
|
|
220
|
+
// promotion this report must not undergo.
|
|
221
|
+
process.stdout.write(`\nfiles: ${String(report.files.length)}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
226
|
+
process.exit(main());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { REPO_ROOT };
|
|
@@ -179,7 +179,16 @@ export function _diff_tree(
|
|
|
179
179
|
|
|
180
180
|
export function _diff_manifest(before: Obj | null, after: Obj | null): string[] {
|
|
181
181
|
if (before === null && after === null) {
|
|
182
|
-
|
|
182
|
+
// Equality without validity — the literal `diff a b` where NEITHER side
|
|
183
|
+
// exists and the answer comes back "equal". An empty issue list means
|
|
184
|
+
// clean to every caller, so both-absent used to certify a move that was
|
|
185
|
+
// never compared. Two absent sides are the absence of a comparison, not
|
|
186
|
+
// the presence of a match. (Audited estate-wide in
|
|
187
|
+
// `docs/guidelines/agent-infra/false-green.md § The audited sync/parity gates`.)
|
|
188
|
+
return [
|
|
189
|
+
' manifest: BOTH the pre-move snapshot and the post-move manifest are missing — ' +
|
|
190
|
+
'nothing was compared, so nothing is verified',
|
|
191
|
+
];
|
|
183
192
|
}
|
|
184
193
|
if (before === null) {
|
|
185
194
|
return [' manifest: pre-move snapshot missing'];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML round-trip helpers for `.agent-settings.yml`.
|
|
3
|
+
*
|
|
4
|
+
* Important contract: the template ships with comments that explain
|
|
5
|
+
* every key — we MUST NOT discard them when the wizard writes back.
|
|
6
|
+
* `js-yaml.dump` cannot preserve comments, so we use a key-level
|
|
7
|
+
* replace strategy that mirrors `scripts/install.py::_replace_template_value`:
|
|
8
|
+
* scan the rendered template line-by-line, replace only the scalar
|
|
9
|
+
* value at each dotted path, leave comments and indentation alone.
|
|
10
|
+
*
|
|
11
|
+
* For arrays and nested objects we fall back to re-serializing the
|
|
12
|
+
* subtree (`js-yaml.dump`) because line-level replace can't safely
|
|
13
|
+
* splice multi-line blocks. The form renderer keeps depth ≤ 2, so
|
|
14
|
+
* the subtree footprint is small.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
|
|
18
|
+
|
|
19
|
+
export interface ReadResult {
|
|
20
|
+
values: Record<string, unknown>;
|
|
21
|
+
raw: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseYaml(raw: string): Record<string, unknown> {
|
|
25
|
+
const parsed = yamlLoad(raw);
|
|
26
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
27
|
+
throw new Error('settings file did not parse to an object');
|
|
28
|
+
}
|
|
29
|
+
return parsed as Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatScalar(value: unknown): string {
|
|
33
|
+
if (typeof value === 'string') {
|
|
34
|
+
// js-yaml safely quotes strings that need it; trim the trailing newline.
|
|
35
|
+
return yamlDump(value, { lineWidth: -1 }).replace(/\n$/, '').trim();
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
38
|
+
if (value === null || value === undefined) return '""';
|
|
39
|
+
// Arrays / nested objects: dump inline. Form keeps depth ≤ 2 so this is rare.
|
|
40
|
+
return yamlDump(value, { lineWidth: -1, flowLevel: 0 }).replace(/\n$/, '').trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FlatEntry {
|
|
44
|
+
path: string[];
|
|
45
|
+
value: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function flatten(value: unknown, prefix: string[] = []): FlatEntry[] {
|
|
49
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
50
|
+
return [{ path: prefix, value }];
|
|
51
|
+
}
|
|
52
|
+
const entries: FlatEntry[] = [];
|
|
53
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
54
|
+
entries.push(...flatten(v, [...prefix, k]));
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Replace the scalar at `dottedPath` in `template` with `value`.
|
|
61
|
+
* Comments and unrelated keys are preserved verbatim. Returns the
|
|
62
|
+
* template unchanged when the path cannot be located (matches the
|
|
63
|
+
* Python installer's tolerant behaviour).
|
|
64
|
+
*/
|
|
65
|
+
export function replaceScalar(template: string, dottedPath: string[], value: unknown): string {
|
|
66
|
+
if (dottedPath.length === 0) return template;
|
|
67
|
+
const sections = dottedPath.slice(0, -1);
|
|
68
|
+
const key = dottedPath[dottedPath.length - 1];
|
|
69
|
+
const targetIndent = ' '.repeat(sections.length);
|
|
70
|
+
|
|
71
|
+
const lines = template.split('\n');
|
|
72
|
+
const currentPath: (string | null)[] = new Array<string | null>(sections.length).fill(null);
|
|
73
|
+
const formatted = formatScalar(value);
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
if (line === undefined) continue;
|
|
78
|
+
const stripped = line.trim();
|
|
79
|
+
if (stripped === '' || stripped.startsWith('#')) continue;
|
|
80
|
+
const indentLen = line.length - line.trimStart().length;
|
|
81
|
+
if (indentLen % 2 !== 0) continue;
|
|
82
|
+
const level = indentLen / 2;
|
|
83
|
+
if (level > sections.length) continue;
|
|
84
|
+
|
|
85
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(stripped);
|
|
86
|
+
if (!m) continue;
|
|
87
|
+
const lineKey = m[1];
|
|
88
|
+
if (lineKey === undefined) continue;
|
|
89
|
+
if (level < sections.length) {
|
|
90
|
+
if (lineKey === sections[level]) {
|
|
91
|
+
currentPath[level] = lineKey;
|
|
92
|
+
for (let j = level + 1; j < currentPath.length; j++) currentPath[j] = null;
|
|
93
|
+
} else if (currentPath[level] !== null && lineKey !== currentPath[level]) {
|
|
94
|
+
currentPath[level] = null;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// level === sections.length → leaf candidate
|
|
99
|
+
const parentsMatch = sections.every((s, idx) => currentPath[idx] === s);
|
|
100
|
+
if (!parentsMatch) continue;
|
|
101
|
+
if (lineKey !== key) continue;
|
|
102
|
+
lines[i] = `${targetIndent}${key}: ${formatted}`;
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
return template;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Apply every leaf change from `newValues` to `templateBody`. Paths that
|
|
110
|
+
* are not present in the template are appended at the end. Comments are
|
|
111
|
+
* preserved for every key that already exists in the template.
|
|
112
|
+
*/
|
|
113
|
+
export function mergeIntoTemplate(templateBody: string, newValues: Record<string, unknown>): string {
|
|
114
|
+
let body = templateBody;
|
|
115
|
+
const appended: string[] = [];
|
|
116
|
+
for (const entry of flatten(newValues)) {
|
|
117
|
+
const before = body;
|
|
118
|
+
body = replaceScalar(body, entry.path, entry.value);
|
|
119
|
+
if (body === before) {
|
|
120
|
+
// Path is not in the template — append a flat key=value at EOF.
|
|
121
|
+
// Form coverage is asserted by parity test, so this branch only
|
|
122
|
+
// fires on hand-edited templates.
|
|
123
|
+
appended.push(`${entry.path.join('.')}: ${formatScalar(entry.value)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (appended.length > 0) {
|
|
127
|
+
if (!body.endsWith('\n')) body += '\n';
|
|
128
|
+
body += `\n# Wizard-added keys (no template entry)\n${appended.join('\n')}\n`;
|
|
129
|
+
}
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function diffValues(
|
|
134
|
+
before: Record<string, unknown>,
|
|
135
|
+
after: Record<string, unknown>,
|
|
136
|
+
): Array<{ path: string; from: unknown; to: unknown }> {
|
|
137
|
+
const out: Array<{ path: string; from: unknown; to: unknown }> = [];
|
|
138
|
+
const bMap = new Map(flatten(before).map((e) => [e.path.join('.'), e.value]));
|
|
139
|
+
const aMap = new Map(flatten(after).map((e) => [e.path.join('.'), e.value]));
|
|
140
|
+
const paths = new Set<string>([...bMap.keys(), ...aMap.keys()]);
|
|
141
|
+
for (const path of paths) {
|
|
142
|
+
const fromV = bMap.get(path);
|
|
143
|
+
const toV = aMap.get(path);
|
|
144
|
+
if (JSON.stringify(fromV) !== JSON.stringify(toV)) out.push({ path, from: fromV, to: toV });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
150
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Recursive dict merge — overlay wins, nested dicts are merged, lists
|
|
155
|
+
* are replaced (not concatenated). Mirrors `scripts/install.py::deep_merge`
|
|
156
|
+
* so the Python installer and the Fastify server produce the same
|
|
157
|
+
* three-layer settings tree (`defaults < global < project`).
|
|
158
|
+
*/
|
|
159
|
+
export function deepMerge(
|
|
160
|
+
base: Record<string, unknown>,
|
|
161
|
+
overlay: Record<string, unknown>,
|
|
162
|
+
): Record<string, unknown> {
|
|
163
|
+
const result: Record<string, unknown> = { ...base };
|
|
164
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
165
|
+
const existing = result[key];
|
|
166
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
167
|
+
result[key] = deepMerge(existing, value);
|
|
168
|
+
} else {
|
|
169
|
+
result[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|