@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.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +69 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/optimize/deep.md +13 -0
  5. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  6. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  7. package/dist/agent-src/rules/context-hygiene.md +17 -0
  8. package/dist/agent-src/rules/downstream-changes.md +26 -0
  9. package/dist/agent-src/rules/think-before-action.md +25 -0
  10. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  11. package/dist/agent-src/rules/token-efficiency.md +13 -0
  12. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  13. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  14. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  15. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  16. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  18. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  19. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  20. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/cli/registry.js +1 -0
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  25. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  26. package/dist/cli-delegate/cmd_export.js +2 -1
  27. package/dist/cli-delegate/cmd_prune.js +2 -1
  28. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  29. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  30. package/dist/cli-delegate/cmd_sync.js +2 -1
  31. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  32. package/dist/cli-delegate/cmd_validate.js +2 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +17 -17
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +1 -1
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +2 -2
  39. package/dist/discovery/trust-report.md +1 -1
  40. package/dist/discovery/workspaces.json +2 -2
  41. package/dist/hooks/dispatch.js +79 -1
  42. package/dist/mcp/registry-manifest.json +2 -2
  43. package/dist/mcp/server.json +2 -2
  44. package/dist/scripts/condense.js +47 -2
  45. package/dist/scripts/condense.js.map +1 -1
  46. package/dist/server/routes/settings.js +82 -0
  47. package/dist/server/routes/settings.js.map +1 -1
  48. package/dist/shared/settingsClasses.js +191 -0
  49. package/dist/shared/settingsClasses.js.map +1 -0
  50. package/docs/contracts/settings-classes.md +342 -0
  51. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  52. package/docs/guidelines/agent-infra/false-green.md +187 -0
  53. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  54. package/package.json +3 -1
  55. package/src/config/ci-local-parity.yml +12 -0
  56. package/src/config/evaluator-budgets.json +4 -4
  57. package/src/config/gate-coverage.yml +54 -12
  58. package/src/config/gate-violation-baselines.json +6 -0
  59. package/src/config/pack-size-budget.json +4 -3
  60. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  61. package/src/scripts/_dispatch.bash +16 -0
  62. package/src/scripts/_lib/gate_ledger.ts +295 -0
  63. package/src/scripts/_lib/gate_result.ts +101 -0
  64. package/src/scripts/_lib/gate_self_test.ts +129 -0
  65. package/src/scripts/_lib/measured_render.ts +97 -0
  66. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  67. package/src/scripts/check_gate_completeness.ts +244 -0
  68. package/src/scripts/check_gate_coverage.ts +35 -6
  69. package/src/scripts/check_references.ts +125 -15
  70. package/src/scripts/check_review_dispositions.ts +14 -1
  71. package/src/scripts/check_suppression_hygiene.ts +503 -0
  72. package/src/scripts/condense.ts +47 -2
  73. package/src/scripts/hook_manifest.yaml +23 -0
  74. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  75. package/src/scripts/hooks/host_semantics.ts +140 -0
  76. package/src/scripts/lint_framework_leakage.ts +147 -15
  77. package/src/scripts/lint_mandated_lines.ts +339 -0
  78. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  79. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  80. package/src/scripts/lint_settings_classes.ts +397 -0
  81. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  82. package/src/scripts/report_imperative_density.ts +229 -0
  83. package/src/scripts/verify_physical_move.ts +10 -1
  84. package/src/server/io/yamlIO.ts +173 -0
  85. 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
- return [];
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
+ }