@event4u/agent-config 8.11.0 → 8.13.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 (87) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/CHANGELOG.md +64 -266
  4. package/README.md +2 -2
  5. package/dist/agent-src/commands/pr/create.md +26 -0
  6. package/dist/agent-src/commands/team/adversarial.md +96 -0
  7. package/dist/agent-src/commands/team/delegate.md +109 -0
  8. package/dist/agent-src/commands/team/review.md +84 -0
  9. package/dist/agent-src/commands/team/status.md +92 -0
  10. package/dist/agent-src/commands/team.md +147 -0
  11. package/dist/agent-src/contexts/execution/roadmap-ci-steps-mechanics.md +117 -0
  12. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +106 -94
  13. package/dist/agent-src/rules/architecture.md +6 -51
  14. package/dist/agent-src/rules/code-comment-discipline.md +10 -94
  15. package/dist/agent-src/rules/decision-revisit-gate.md +9 -77
  16. package/dist/agent-src/rules/improve-before-implement.md +8 -65
  17. package/dist/agent-src/rules/no-roadmap-references.md +13 -77
  18. package/dist/agent-src/rules/persona-governance.md +5 -47
  19. package/dist/agent-src/rules/provider-lifecycle-discipline.md +6 -32
  20. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +21 -104
  21. package/dist/agent-src/rules/untrusted-input-defense.md +3 -75
  22. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +83 -0
  23. package/dist/agent-src/skills/decision-review/SKILL.md +86 -0
  24. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +58 -1
  25. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/dist/agent-src/templates/contexts/knowledge-card.md +12 -0
  27. package/dist/discovery/deprecation-report.md +1 -1
  28. package/dist/discovery/discovery-manifest.json +159 -21
  29. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  30. package/dist/discovery/discovery-manifest.summary.md +3 -3
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +11 -6
  33. package/dist/discovery/trust-report.md +2 -2
  34. package/dist/discovery/workspaces.json +10 -5
  35. package/dist/install/install.mjs +38 -0
  36. package/dist/mcp/registry-manifest.json +2 -2
  37. package/dist/router.json +1 -1
  38. package/dist/server/schemas/settings.js +7 -0
  39. package/dist/server/schemas/settings.js.map +1 -1
  40. package/docs/CLAIMS.md +31 -3
  41. package/docs/architecture.md +2 -2
  42. package/docs/archive/CHANGELOG-pre-8.12.0.md +304 -0
  43. package/docs/benchmark.md +39 -0
  44. package/docs/catalog.md +10 -4
  45. package/docs/command-flows.md +6 -1
  46. package/docs/contracts/ai-council-config.md +9 -4
  47. package/docs/contracts/ai-team-config.md +191 -0
  48. package/docs/contracts/command-clusters.md +1 -0
  49. package/docs/contracts/persona-schema.md +49 -0
  50. package/docs/contracts/provider-lifecycle.md +29 -4
  51. package/docs/decisions/ADR-120-council-chairman-mode.md +70 -0
  52. package/docs/decisions/ADR-121-knowledge-sensitivity-classes.md +90 -0
  53. package/docs/decisions/INDEX.md +2 -0
  54. package/docs/design/council-vs-solo-baseline.md +92 -0
  55. package/docs/design/utilization-window-criteria.md +51 -0
  56. package/docs/featured-skills.md +1 -1
  57. package/docs/getting-started-by-role.md +3 -1
  58. package/docs/getting-started.md +31 -1
  59. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +68 -0
  60. package/docs/guidelines/agent-infra/rule-body-migration-inventory.md +241 -0
  61. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +80 -3
  62. package/docs/guidelines/code-clarity.md +86 -1
  63. package/docs/maintainers/branch-protection.md +105 -0
  64. package/docs/maintainers/system-map.md +85 -0
  65. package/docs/proof.md +15 -4
  66. package/package.json +1 -1
  67. package/src/config/agent-settings.template.yml +49 -0
  68. package/src/scripts/_cli/cmd_doctor.ts +291 -1
  69. package/src/scripts/_lib/claude_plugin.ts +39 -4
  70. package/src/scripts/_lib/knowledge_global.ts +44 -0
  71. package/src/scripts/_lib/knowledge_global_promote.ts +150 -0
  72. package/src/scripts/ai_council/orchestrator.ts +188 -1
  73. package/src/scripts/ai_team/config.ts +162 -0
  74. package/src/scripts/bench_persona_placebo.ts +308 -0
  75. package/src/scripts/bench_quality_rerun.ts +357 -0
  76. package/src/scripts/build_proof.ts +9 -0
  77. package/src/scripts/check_artefact_count_messaging.ts +56 -0
  78. package/src/scripts/check_knowledge_cards.ts +33 -4
  79. package/src/scripts/check_portability.ts +1 -0
  80. package/src/scripts/complexity_report.ts +776 -0
  81. package/src/scripts/consumer_matrix.ts +251 -12
  82. package/src/scripts/council_cli.ts +56 -1
  83. package/src/scripts/explain_run.ts +593 -0
  84. package/src/scripts/install.ts +39 -0
  85. package/src/scripts/knowledge_global_cli.ts +234 -45
  86. package/src/scripts/rule_backlinks.ts +350 -0
  87. package/src/server/schemas/settings.ts +17 -0
@@ -0,0 +1,776 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Lightweight complexity report — council-adjudicated (Phase 5 of the
4
+ * feedback-8-11 roadmap): a REPORT-ONLY soft ratchet over five deterministic
5
+ * proxy metrics for package complexity. No per-feature declaration duty, no
6
+ * pass/fail gate — it prints the current numbers, the delta against the
7
+ * previous run (if any), and a kill criterion the metric itself is subject
8
+ * to. Exit code is ALWAYS 0.
9
+ *
10
+ * Metrics (each documents its own method inline in the rendered report):
11
+ * 1. Active settings axes — top + second-level keys in
12
+ * `src/config/agent-settings.template.yml`.
13
+ * 2. Runtime-state surfaces — distinct `agents/runtime/state/<x>`
14
+ * path literals referenced by `src/**​/*.ts`
15
+ * (grepped, NOT a live directory listing —
16
+ * the dir is gitignored and usually absent).
17
+ * 3. Cross-subsystem dep edges — edge count from the committed discovery
18
+ * graph cache if present; otherwise a
19
+ * proxy count of import edges between
20
+ * top-level `src/scripts/*.ts` modules
21
+ * (never triggers a graph rebuild — that
22
+ * would make this "lightweight" report
23
+ * spawn the full manifest builder).
24
+ * 4. Always-loaded rule bytes — byte sum of the rules the router marks
25
+ * `type: "always"` (the 9-rule kernel),
26
+ * read from `dist/router.json` + the
27
+ * condensed `dist/agent-src/rules/*.md`,
28
+ * falling back to `src/rules/*.md`
29
+ * frontmatter when the dist tree is
30
+ * absent (fresh checkout, pre-`task sync`).
31
+ * 5. Mandatory gates / workflow — case-insensitive whole-word `gate`
32
+ * mention count across the work-engine
33
+ * dispatcher directives
34
+ * (`src/agent-src/templates/scripts/work_engine/directives/`),
35
+ * averaged per directive file. A rough,
36
+ * deterministic proxy — NOT a semantic
37
+ * gate-graph analysis.
38
+ * 6. Rule→skill coupling — routing-target + backlink counts from
39
+ * `rule_backlinks.ts`'s `collect()` over
40
+ * `src/rules/*.md` (module reused as-is —
41
+ * no new scanning logic here).
42
+ *
43
+ * Soft ratchet: when the checked-in baseline
44
+ * `internal/reports/complexity-baseline.json` exists, the report renders a
45
+ * `## Ratchet vs baseline` section — per metric baseline vs current vs Δ,
46
+ * with a WARN line for anything above baseline. Still report-only: the WARN
47
+ * asks for a justification-or-rebaseline in the raising PR, it never fails
48
+ * anything. Exit code stays ALWAYS 0.
49
+ *
50
+ * Every function below takes an explicit root/path argument (no hidden
51
+ * module-level mutable state) so tests can point the counters at small
52
+ * fixture trees and get fully deterministic output.
53
+ *
54
+ * Usage:
55
+ * ./scripts-run src/scripts/complexity_report [--root <path>] [--out <path>] [--quiet]
56
+ *
57
+ * CI wiring: `task complexity-report` (defined in `taskfiles/content.yml`,
58
+ * wired into both CI task lists in `Taskfile.yml` since PR #918) — report-only,
59
+ * exit code always 0, so it never fails the build.
60
+ */
61
+ import * as fs from 'node:fs';
62
+ import * as path from 'node:path';
63
+ import process from 'node:process';
64
+ import { fileURLToPath, pathToFileURL } from 'node:url';
65
+ import YAML from 'yaml';
66
+
67
+ import { collect as collectRuleBacklinks } from './rule_backlinks.js';
68
+
69
+ const PROG = 'complexity_report';
70
+ const _HERE = fileURLToPath(import.meta.url);
71
+ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
72
+ export const DEFAULT_OUT = path.join(REPO_ROOT, 'internal', 'reports', 'complexity-report.md');
73
+
74
+ export const KILL_CRITERION =
75
+ 'Kill criterion: if this report is cited by zero decisions (ADR/roadmap/PR) within 3 releases, ' +
76
+ 'delete the script and record the honest null.';
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // fs helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function _exists(p: string): boolean {
83
+ try {
84
+ fs.statSync(p);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function _isFile(p: string): boolean {
92
+ try {
93
+ return fs.statSync(p).isFile();
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** Recursively collect files under `dir` whose name matches `ext` (e.g. '.ts'). */
100
+ function _walkFiles(dir: string, ext: string, acc: string[] = []): string[] {
101
+ let entries: fs.Dirent[];
102
+ try {
103
+ entries = fs.readdirSync(dir, { withFileTypes: true });
104
+ } catch {
105
+ return acc;
106
+ }
107
+ for (const e of entries) {
108
+ const p = path.join(dir, e.name);
109
+ if (e.isDirectory()) {
110
+ _walkFiles(p, ext, acc);
111
+ } else if (e.isFile() && e.name.endsWith(ext)) {
112
+ acc.push(p);
113
+ }
114
+ }
115
+ return acc;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Metric 1 — active settings axes
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export interface SettingsAxesResult {
123
+ top: number;
124
+ second: number;
125
+ total: number;
126
+ method: string;
127
+ }
128
+
129
+ /**
130
+ * Top-level key count + the sum of second-level key counts for every
131
+ * top-level key whose value is a plain mapping (arrays/scalars contribute 0
132
+ * at the second level). Deterministic over a YAML parse — no comment-line
133
+ * counting, no regex.
134
+ */
135
+ export function countSettingsAxes(templatePath: string): SettingsAxesResult {
136
+ const method = `YAML-parsed \`${path.basename(templatePath)}\`: top-level keys + (sum of key-counts of every top-level mapping value).`;
137
+ if (!_isFile(templatePath)) {
138
+ return { top: 0, second: 0, total: 0, method: `${method} File not found.` };
139
+ }
140
+ const doc = YAML.parse(fs.readFileSync(templatePath, 'utf-8')) as Record<string, unknown> | null;
141
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
142
+ return { top: 0, second: 0, total: 0, method: `${method} File did not parse to a mapping.` };
143
+ }
144
+ const topKeys = Object.keys(doc);
145
+ let second = 0;
146
+ for (const k of topKeys) {
147
+ const v = doc[k];
148
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
149
+ second += Object.keys(v as Record<string, unknown>).length;
150
+ }
151
+ }
152
+ return { top: topKeys.length, second, total: topKeys.length + second, method };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Metric 2 — runtime-state surfaces
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface RuntimeStateResult {
160
+ count: number;
161
+ names: string[];
162
+ method: string;
163
+ }
164
+
165
+ const STATE_SLASH_RE = /agents\/runtime\/state\/([^\s'"`,)\]]+)/g;
166
+ const STATE_JOIN_RE = /['"]agents['"]\s*,\s*['"]runtime['"]\s*,\s*['"]state['"]\s*,\s*['"]([^'"]+)['"]/g;
167
+
168
+ /**
169
+ * Distinct `agents/runtime/state/<x>` surfaces referenced by shipped
170
+ * TypeScript source, derived by grepping the two literal shapes the
171
+ * codebase actually uses (`'agents/runtime/state/<x>'` and
172
+ * `path.join(..., 'agents', 'runtime', 'state', '<x>')`) — never by
173
+ * listing a live directory, which is gitignored and normally absent on a
174
+ * fresh checkout / in CI.
175
+ */
176
+ export function countRuntimeStateSurfaces(srcDir: string): RuntimeStateResult {
177
+ const method =
178
+ `Grepped \`agents/runtime/state/<x>\` (slash literal) and ` +
179
+ `\`'agents','runtime','state','<x>'\` (path.join literal) across every ` +
180
+ `.ts file under \`${path.relative(REPO_ROOT, srcDir) || srcDir}\`; counts the ` +
181
+ `distinct first path segment after \`state/\` once per name, regardless of ` +
182
+ `call-site count. Tokens containing glob/template characters ` +
183
+ `(\`*\`, \`<\`, \`>\`) are dropped as grep artifacts, not surfaces. ` +
184
+ `Proxy for "surfaces the shipped code writes", not a ` +
185
+ `runtime read/write trace.`;
186
+ // Excludes this reporter's own source file — its docstrings quote the
187
+ // `agents/runtime/state/<x>` literal shapes as documentation, which
188
+ // would otherwise self-match and pollute the count with placeholder noise.
189
+ const files = _walkFiles(srcDir, '.ts').filter((f) => path.basename(f) !== 'complexity_report.ts');
190
+ const names = new Set<string>();
191
+ for (const f of files) {
192
+ const text = fs.readFileSync(f, 'utf-8');
193
+ for (const re of [STATE_SLASH_RE, STATE_JOIN_RE]) {
194
+ re.lastIndex = 0;
195
+ let m: RegExpExecArray | null;
196
+ while ((m = re.exec(text)) !== null) {
197
+ const seg = (m[1] as string).split('/')[0]?.replace(/\.$/, '');
198
+ // Requires at least one alphanumeric char — drops pure-prose
199
+ // punctuation artifacts (e.g. a doc comment ending "state/…")
200
+ // without trying to be clever about what counts as a "real" name.
201
+ // Also drops glob/template placeholder tokens (`*.json`,
202
+ // `<concern>.json`, `<id>.json`) — grep artifacts from prose,
203
+ // patterns, and docstrings, not actual surfaces.
204
+ if (seg && /[A-Za-z0-9]/.test(seg) && !/[*<>]/.test(seg)) names.add(seg);
205
+ }
206
+ }
207
+ }
208
+ const sorted = [...names].sort();
209
+ return { count: sorted.length, names: sorted, method };
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Metric 3 — cross-subsystem dependency edges
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export interface DependencyEdgesResult {
217
+ count: number;
218
+ source: 'graph-cache' | 'import-proxy';
219
+ method: string;
220
+ }
221
+
222
+ /**
223
+ * Prefer the committed discovery-graph cache (`agents/runtime/state/discovery-graph-v1.json`,
224
+ * written by `discovery_graph.ts build`) when it exists and parses — its edge
225
+ * count is the real cross-subsystem relation graph. When absent (the common
226
+ * case: the cache is gitignored and this report never triggers a rebuild,
227
+ * which would spawn `build_discovery_manifest.ts` and stop being
228
+ * "lightweight"), fall back to a proxy: import edges between top-level
229
+ * `src/scripts/*.ts` modules (direct children only — `_lib/`, `_cli/`, and
230
+ * other subdirectories are excluded, since those are intra-module helpers,
231
+ * not cross-subsystem edges).
232
+ */
233
+ export function countDependencyEdges(root: string): DependencyEdgesResult {
234
+ const graphCache = path.join(root, 'agents', 'runtime', 'state', 'discovery-graph-v1.json');
235
+ if (_isFile(graphCache)) {
236
+ try {
237
+ const parsed = JSON.parse(fs.readFileSync(graphCache, 'utf-8')) as { edges?: unknown[] };
238
+ if (Array.isArray(parsed.edges)) {
239
+ return {
240
+ count: parsed.edges.length,
241
+ source: 'graph-cache',
242
+ method: `Read \`edges.length\` from the committed discovery-graph cache at ` + `\`${path.relative(root, graphCache)}\`.`,
243
+ };
244
+ }
245
+ } catch {
246
+ // fall through to the proxy below
247
+ }
248
+ }
249
+ const scriptsDir = path.join(root, 'src', 'scripts');
250
+ let entries: fs.Dirent[] = [];
251
+ try {
252
+ entries = fs.readdirSync(scriptsDir, { withFileTypes: true });
253
+ } catch {
254
+ // scriptsDir doesn't exist in this tree (e.g. a fixture root) — 0 edges.
255
+ }
256
+ const topFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.ts')).map((e) => e.name);
257
+ const known = new Set(topFiles.map((f) => f.replace(/\.ts$/, '')));
258
+ // Matches both from-imports and bare side-effect imports of a sibling
259
+ // `.js` specifier — both are real edges between top-level modules.
260
+ // (Wording avoids literal import-shaped strings: prepack-check's
261
+ // shipped-import scanner reads comments too.)
262
+ const importRe = /(?:from|import)\s+['"]\.\/([A-Za-z0-9_-]+)\.js['"]/g;
263
+ let edges = 0;
264
+ for (const f of topFiles) {
265
+ const mod = f.replace(/\.ts$/, '');
266
+ const text = fs.readFileSync(path.join(scriptsDir, f), 'utf-8');
267
+ importRe.lastIndex = 0;
268
+ let m: RegExpExecArray | null;
269
+ while ((m = importRe.exec(text)) !== null) {
270
+ const target = m[1] as string;
271
+ if (target !== mod && known.has(target)) edges += 1;
272
+ }
273
+ }
274
+ return {
275
+ count: edges,
276
+ source: 'import-proxy',
277
+ method:
278
+ 'No usable discovery-graph cache found at `agents/runtime/state/discovery-graph-v1.json` ' +
279
+ '(gitignored, rebuilding it here would spawn the full manifest builder) — counted ' +
280
+ 'relative sibling-`.js` import edges between top-level ' +
281
+ '`src/scripts/*.ts` files instead (subdirectories like `_lib/`, `_cli/` excluded).',
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Metric 4 — always-loaded rule bytes
287
+ // ---------------------------------------------------------------------------
288
+
289
+ export interface AlwaysRuleBytesResult {
290
+ count: number;
291
+ bytes: number;
292
+ ids: string[];
293
+ method: string;
294
+ }
295
+
296
+ const ALWAYS_TYPE_RE = /^type:\s*"?always"?\s*$/m;
297
+
298
+ /**
299
+ * Byte sum of the rules the discovery layer marks as always-loaded — the
300
+ * kernel. Prefers `dist/router.json`'s `kernel` array (the built router the
301
+ * agent actually consults) resolved against the condensed
302
+ * `dist/agent-src/rules/<id>.md` files; falls back to scanning
303
+ * `src/rules/*.md` frontmatter for `type: "always"` when `dist/` hasn't been
304
+ * generated yet (fresh checkout, pre-`task sync`).
305
+ */
306
+ export function countAlwaysRuleBytes(root: string): AlwaysRuleBytesResult {
307
+ const routerPath = path.join(root, 'dist', 'router.json');
308
+ if (_isFile(routerPath)) {
309
+ try {
310
+ const router = JSON.parse(fs.readFileSync(routerPath, 'utf-8')) as { kernel?: unknown };
311
+ if (Array.isArray(router.kernel)) {
312
+ const ids = (router.kernel as unknown[]).filter((x): x is string => typeof x === 'string').sort();
313
+ let bytes = 0;
314
+ const found: string[] = [];
315
+ for (const id of ids) {
316
+ const p = path.join(root, 'dist', 'agent-src', 'rules', `${id}.md`);
317
+ if (_isFile(p)) {
318
+ bytes += Buffer.byteLength(fs.readFileSync(p, 'utf-8'), 'utf-8');
319
+ found.push(id);
320
+ }
321
+ }
322
+ if (found.length > 0) {
323
+ return {
324
+ count: found.length,
325
+ bytes,
326
+ ids: found,
327
+ method:
328
+ "Byte sum of `dist/agent-src/rules/<id>.md` for every id in `dist/router.json`'s " +
329
+ '`kernel` array (the always-loaded rule set the router ships).',
330
+ };
331
+ }
332
+ }
333
+ } catch {
334
+ // fall through to the frontmatter scan below
335
+ }
336
+ }
337
+ const rulesDir = path.join(root, 'src', 'rules');
338
+ let bytes = 0;
339
+ const ids: string[] = [];
340
+ let files: string[] = [];
341
+ try {
342
+ files = fs
343
+ .readdirSync(rulesDir, { withFileTypes: true })
344
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
345
+ .map((e) => e.name);
346
+ } catch {
347
+ files = [];
348
+ }
349
+ for (const name of files.sort()) {
350
+ const p = path.join(rulesDir, name);
351
+ const text = fs.readFileSync(p, 'utf-8');
352
+ const fmEnd = text.indexOf('\n---', 4);
353
+ const frontmatter = text.startsWith('---') && fmEnd > -1 ? text.slice(0, fmEnd) : '';
354
+ if (ALWAYS_TYPE_RE.test(frontmatter)) {
355
+ bytes += Buffer.byteLength(text, 'utf-8');
356
+ ids.push(name.replace(/\.md$/, ''));
357
+ }
358
+ }
359
+ return {
360
+ count: ids.length,
361
+ bytes,
362
+ ids,
363
+ method:
364
+ '`dist/router.json` / `dist/agent-src/rules/` unavailable — byte sum of `src/rules/*.md` ' +
365
+ 'files whose frontmatter has `type: "always"` instead.',
366
+ };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Metric 5 — mandatory gates per core workflow (proxy)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export interface GateMentionsResult {
374
+ total: number;
375
+ files: number;
376
+ perFile: number;
377
+ method: string;
378
+ }
379
+
380
+ const GATE_RE = /\bgate\b/gi;
381
+
382
+ /**
383
+ * Total case-insensitive whole-word `gate` mentions across the work-engine
384
+ * dispatcher directives, averaged per directive file. A static text-mention
385
+ * count, NOT a semantic count of enforced preconditions — documented as
386
+ * such in the rendered report.
387
+ */
388
+ export function countGateMentions(directivesDir: string): GateMentionsResult {
389
+ const method =
390
+ `Case-insensitive whole-word \`gate\` mention count across every .ts file under ` +
391
+ `\`${path.relative(REPO_ROOT, directivesDir) || directivesDir}\`, divided by the file count. ` +
392
+ 'A static text-mention proxy (docstrings + code both count), not a semantic gate-graph analysis.';
393
+ const files = _walkFiles(directivesDir, '.ts');
394
+ let total = 0;
395
+ for (const f of files) {
396
+ const text = fs.readFileSync(f, 'utf-8');
397
+ GATE_RE.lastIndex = 0;
398
+ const matches = text.match(GATE_RE);
399
+ total += matches ? matches.length : 0;
400
+ }
401
+ const perFile = files.length > 0 ? Math.round((total / files.length) * 100) / 100 : 0;
402
+ return { total, files: files.length, perFile, method };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Metric 6 — rule→skill coupling
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export interface RuleSkillCouplingResult {
410
+ targets: number;
411
+ backlinks: number;
412
+ method: string;
413
+ }
414
+
415
+ /**
416
+ * Distinct routing targets + total rule→target backlinks, computed by
417
+ * reusing `rule_backlinks.ts`'s `collect()` (frontmatter `routes_to:` +
418
+ * "Body migrated to" prose over `src/rules/*.md`) — no new scanning logic
419
+ * lives here. A target is a skill / guideline / context / contract a rule
420
+ * routes into; the backlink total is how many rule→target routes exist.
421
+ */
422
+ export function countRuleSkillCoupling(rulesDir: string): RuleSkillCouplingResult {
423
+ const byTarget = collectRuleBacklinks(rulesDir);
424
+ let backlinks = 0;
425
+ for (const links of byTarget.values()) backlinks += links.length;
426
+ return {
427
+ targets: byTarget.size,
428
+ backlinks,
429
+ method:
430
+ "Reused `rule_backlinks.ts`'s `collect()` over `src/rules/*.md` — distinct routing " +
431
+ 'targets (frontmatter `routes_to:` + "Body migrated to" prose) and total ' +
432
+ 'rule→target backlinks. No new scanning logic.',
433
+ };
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Soft-ratchet baseline
438
+ // ---------------------------------------------------------------------------
439
+
440
+ export interface BaselineMetrics {
441
+ settings_axes_total: number;
442
+ runtime_state_surfaces: number;
443
+ dependency_edges: number;
444
+ always_rule_bytes: number;
445
+ gate_mentions_total: number;
446
+ rule_skill_coupling_backlinks: number;
447
+ }
448
+
449
+ export interface Baseline {
450
+ schema_version: 1;
451
+ baselined_at: string;
452
+ reason: string;
453
+ metrics: BaselineMetrics;
454
+ }
455
+
456
+ export const BASELINE_RELPATH = path.join('internal', 'reports', 'complexity-baseline.json');
457
+
458
+ /**
459
+ * Read the checked-in soft-ratchet baseline. Returns null when the file is
460
+ * absent or unparseable — the report then skips the ratchet section instead
461
+ * of failing (report-only, exit 0 always).
462
+ */
463
+ export function loadBaseline(baselinePath: string): Baseline | null {
464
+ if (!_isFile(baselinePath)) return null;
465
+ try {
466
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8')) as Baseline;
467
+ if (parsed === null || typeof parsed !== 'object' || typeof parsed.metrics !== 'object' || parsed.metrics === null) {
468
+ return null;
469
+ }
470
+ return parsed;
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Snapshot assembly + rendering
478
+ // ---------------------------------------------------------------------------
479
+
480
+ export interface Snapshot {
481
+ schema_version: 1;
482
+ generated_at: string;
483
+ settings_axes: SettingsAxesResult;
484
+ runtime_state: RuntimeStateResult;
485
+ dependency_edges: DependencyEdgesResult;
486
+ always_rule_bytes: AlwaysRuleBytesResult;
487
+ gate_mentions: GateMentionsResult;
488
+ rule_skill_coupling: RuleSkillCouplingResult;
489
+ }
490
+
491
+ export interface Roots {
492
+ root: string;
493
+ settingsTemplate: string;
494
+ srcDir: string;
495
+ directivesDir: string;
496
+ rulesDir: string;
497
+ }
498
+
499
+ export function defaultRoots(root: string): Roots {
500
+ return {
501
+ root,
502
+ settingsTemplate: path.join(root, 'src', 'config', 'agent-settings.template.yml'),
503
+ srcDir: path.join(root, 'src'),
504
+ directivesDir: path.join(root, 'src', 'agent-src', 'templates', 'scripts', 'work_engine', 'directives'),
505
+ rulesDir: path.join(root, 'src', 'rules'),
506
+ };
507
+ }
508
+
509
+ export function collectSnapshot(roots: Roots, now: Date): Snapshot {
510
+ return {
511
+ schema_version: 1,
512
+ generated_at: now.toISOString().slice(0, 10),
513
+ settings_axes: countSettingsAxes(roots.settingsTemplate),
514
+ runtime_state: countRuntimeStateSurfaces(roots.srcDir),
515
+ dependency_edges: countDependencyEdges(roots.root),
516
+ always_rule_bytes: countAlwaysRuleBytes(roots.root),
517
+ gate_mentions: countGateMentions(roots.directivesDir),
518
+ rule_skill_coupling: countRuleSkillCoupling(roots.rulesDir),
519
+ };
520
+ }
521
+
522
+ const RAW_BLOCK_RE = /<!-- complexity-report-raw\n([\s\S]*?)\n-->/;
523
+
524
+ /** Extract the embedded machine-parseable snapshot from a previously rendered report. */
525
+ export function parsePreviousSnapshot(text: string): Snapshot | null {
526
+ const m = RAW_BLOCK_RE.exec(text);
527
+ if (!m) return null;
528
+ try {
529
+ return JSON.parse(m[1] as string) as Snapshot;
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+
535
+ function _fmt(n: number): string {
536
+ return n.toLocaleString('en-US');
537
+ }
538
+
539
+ function _deltaCell(curr: number, prev: number | undefined): string {
540
+ if (prev === undefined) return '—';
541
+ const d = curr - prev;
542
+ if (d === 0) return '0';
543
+ return d > 0 ? `+${_fmt(d)}` : _fmt(d);
544
+ }
545
+
546
+ const RATCHET_INSTRUCTION =
547
+ 'justify in the PR that raises it, or re-baseline deliberately ' +
548
+ '(update complexity-baseline.json in the same PR with a one-line reason field).';
549
+
550
+ /**
551
+ * The soft-ratchet section: per metric, checked-in baseline vs current vs Δ.
552
+ * Metrics above baseline get a WARN line (justify-or-rebaseline instruction);
553
+ * metrics below baseline render as improvements. Never fails anything —
554
+ * the caller still always exits 0.
555
+ */
556
+ function _renderRatchetSection(current: Snapshot, baseline: Baseline | null): string[] {
557
+ const lines: string[] = [];
558
+ lines.push('## Ratchet vs baseline');
559
+ lines.push('');
560
+ if (baseline === null) {
561
+ lines.push(`No baseline file found at \`${BASELINE_RELPATH}\` — ratchet comparison skipped.`);
562
+ return lines;
563
+ }
564
+ lines.push(`Baseline: \`${BASELINE_RELPATH}\` (baselined ${baseline.baselined_at} — "${baseline.reason}").`);
565
+ lines.push('');
566
+ const rows: Array<{ label: string; base: number; curr: number }> = [
567
+ { label: 'Active settings axes', base: baseline.metrics.settings_axes_total, curr: current.settings_axes.total },
568
+ { label: 'Runtime-state surfaces', base: baseline.metrics.runtime_state_surfaces, curr: current.runtime_state.count },
569
+ { label: 'Cross-subsystem dependency edges', base: baseline.metrics.dependency_edges, curr: current.dependency_edges.count },
570
+ { label: 'Always-loaded rule bytes', base: baseline.metrics.always_rule_bytes, curr: current.always_rule_bytes.bytes },
571
+ { label: 'Gate mentions (total)', base: baseline.metrics.gate_mentions_total, curr: current.gate_mentions.total },
572
+ {
573
+ label: 'Rule→skill coupling (backlinks)',
574
+ base: baseline.metrics.rule_skill_coupling_backlinks,
575
+ curr: current.rule_skill_coupling.backlinks,
576
+ },
577
+ ];
578
+ lines.push('| Metric | Baseline | Current | Δ |');
579
+ lines.push('|---|---|---|---|');
580
+ for (const r of rows) {
581
+ lines.push(`| ${r.label} | ${_fmt(r.base)} | ${_fmt(r.curr)} | ${_deltaCell(r.curr, r.base)} |`);
582
+ }
583
+ lines.push('');
584
+ const above = rows.filter((r) => r.curr > r.base);
585
+ const below = rows.filter((r) => r.curr < r.base);
586
+ for (const r of above) {
587
+ lines.push(`WARN: ${r.label} is above baseline (${_fmt(r.base)} → ${_fmt(r.curr)}, +${_fmt(r.curr - r.base)}) — ${RATCHET_INSTRUCTION}`);
588
+ }
589
+ for (const r of below) {
590
+ lines.push(`Improved: ${r.label} is below baseline (${_fmt(r.base)} → ${_fmt(r.curr)}, ${_fmt(r.curr - r.base)}).`);
591
+ }
592
+ if (above.length === 0 && below.length === 0) {
593
+ lines.push('All metrics at baseline.');
594
+ }
595
+ return lines;
596
+ }
597
+
598
+ export function renderReport(current: Snapshot, previous: Snapshot | null, baseline: Baseline | null = null): string {
599
+ const lines: string[] = [];
600
+ lines.push('# Complexity Report');
601
+ lines.push('');
602
+ lines.push(`Generated: ${current.generated_at} · generator: \`src/scripts/complexity_report.ts\` (report-only, always exits 0).`);
603
+ lines.push('');
604
+ lines.push(`> ${KILL_CRITERION}`);
605
+ lines.push('');
606
+ lines.push(
607
+ 'This is a soft ratchet, not a gate: no feature carries a per-change ' +
608
+ 'declaration duty against these numbers, and the script never fails CI. ' +
609
+ 'It exists to make complexity growth *visible* over time.',
610
+ );
611
+ lines.push('');
612
+ lines.push(
613
+ '**Wired into CI as `task complexity-report`** (report-only, exit 0 always — ' +
614
+ 'it never fails the build). Regenerate manually with ' +
615
+ '`npx tsx src/scripts/complexity_report.ts` or `task complexity-report`.',
616
+ );
617
+ lines.push('');
618
+ lines.push('## Metrics');
619
+ lines.push('');
620
+ lines.push('| # | Metric | Value | Method |');
621
+ lines.push('|---|---|---|---|');
622
+ lines.push(
623
+ `| 1 | Active settings axes | ${_fmt(current.settings_axes.total)} ` +
624
+ `(${current.settings_axes.top} top + ${current.settings_axes.second} second-level) | ${current.settings_axes.method} |`,
625
+ );
626
+ lines.push(`| 2 | Runtime-state surfaces (PROXY) | ${_fmt(current.runtime_state.count)} | ${current.runtime_state.method} |`);
627
+ const edgesProxy = current.dependency_edges.source === 'import-proxy' ? ' (PROXY)' : '';
628
+ lines.push(
629
+ `| 3 | Cross-subsystem dependency edges${edgesProxy} | ${_fmt(current.dependency_edges.count)} (source: ${current.dependency_edges.source}) | ` +
630
+ `${current.dependency_edges.method} |`,
631
+ );
632
+ lines.push(
633
+ `| 4 | Always-loaded rule bytes | ${_fmt(current.always_rule_bytes.bytes)} bytes across ${current.always_rule_bytes.count} rule(s) | ` +
634
+ `${current.always_rule_bytes.method} |`,
635
+ );
636
+ lines.push(
637
+ `| 5 | Mandatory gates per core workflow (PROXY) | ${current.gate_mentions.perFile.toFixed(2)} avg/file ` +
638
+ `(${current.gate_mentions.total} mentions across ${current.gate_mentions.files} files) | ${current.gate_mentions.method} |`,
639
+ );
640
+ lines.push(
641
+ `| 6 | Rule→skill coupling | ${_fmt(current.rule_skill_coupling.targets)} targets, ` +
642
+ `${_fmt(current.rule_skill_coupling.backlinks)} backlinks | ${current.rule_skill_coupling.method} |`,
643
+ );
644
+ lines.push('');
645
+ lines.push(`**Runtime-state surfaces found:** ${current.runtime_state.names.map((n) => `\`${n}\``).join(', ') || '—'}`);
646
+ lines.push('');
647
+ lines.push(`**Always-loaded rule ids:** ${current.always_rule_bytes.ids.map((n) => `\`${n}\``).join(', ') || '—'}`);
648
+ lines.push('');
649
+
650
+ lines.push('## Delta vs previous report');
651
+ lines.push('');
652
+ if (previous === null) {
653
+ lines.push('No previous report found — this is the baseline run.');
654
+ } else {
655
+ lines.push(`Previous report generated: ${previous.generated_at}.`);
656
+ lines.push('');
657
+ lines.push('| Metric | Previous | Current | Δ |');
658
+ lines.push('|---|---|---|---|');
659
+ lines.push(
660
+ `| Active settings axes | ${_fmt(previous.settings_axes.total)} | ${_fmt(current.settings_axes.total)} | ` +
661
+ `${_deltaCell(current.settings_axes.total, previous.settings_axes.total)} |`,
662
+ );
663
+ lines.push(
664
+ `| Runtime-state surfaces | ${_fmt(previous.runtime_state.count)} | ${_fmt(current.runtime_state.count)} | ` +
665
+ `${_deltaCell(current.runtime_state.count, previous.runtime_state.count)} |`,
666
+ );
667
+ lines.push(
668
+ `| Cross-subsystem dependency edges | ${_fmt(previous.dependency_edges.count)} | ${_fmt(current.dependency_edges.count)} | ` +
669
+ `${_deltaCell(current.dependency_edges.count, previous.dependency_edges.count)} |`,
670
+ );
671
+ lines.push(
672
+ `| Always-loaded rule bytes | ${_fmt(previous.always_rule_bytes.bytes)} | ${_fmt(current.always_rule_bytes.bytes)} | ` +
673
+ `${_deltaCell(current.always_rule_bytes.bytes, previous.always_rule_bytes.bytes)} |`,
674
+ );
675
+ lines.push(
676
+ `| Gate mentions (total) | ${_fmt(previous.gate_mentions.total)} | ${_fmt(current.gate_mentions.total)} | ` +
677
+ `${_deltaCell(current.gate_mentions.total, previous.gate_mentions.total)} |`,
678
+ );
679
+ // Reports rendered before metric 6 existed have no rule_skill_coupling
680
+ // in their embedded snapshot — degrade to an em-dash previous cell.
681
+ const prevCoupling = previous.rule_skill_coupling as RuleSkillCouplingResult | undefined;
682
+ lines.push(
683
+ `| Rule→skill coupling (backlinks) | ${prevCoupling ? _fmt(prevCoupling.backlinks) : '—'} | ` +
684
+ `${_fmt(current.rule_skill_coupling.backlinks)} | ` +
685
+ `${_deltaCell(current.rule_skill_coupling.backlinks, prevCoupling?.backlinks)} |`,
686
+ );
687
+ }
688
+ lines.push('');
689
+ lines.push(..._renderRatchetSection(current, baseline));
690
+ lines.push('');
691
+ lines.push('## Raw metrics (machine-parseable — do not hand-edit)');
692
+ lines.push('');
693
+ lines.push('The delta section above is computed by parsing this block out of the previous report.');
694
+ lines.push('');
695
+ lines.push('<!-- complexity-report-raw');
696
+ lines.push(JSON.stringify(current, null, 2));
697
+ lines.push('-->');
698
+ lines.push('');
699
+ return lines.join('\n');
700
+ }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // CLI
704
+ // ---------------------------------------------------------------------------
705
+
706
+ interface ParsedArgs {
707
+ root: string;
708
+ out: string;
709
+ quiet: boolean;
710
+ }
711
+
712
+ export function parseArgs(argv: string[]): ParsedArgs {
713
+ const out: ParsedArgs = { root: REPO_ROOT, out: DEFAULT_OUT, quiet: false };
714
+ for (let i = 0; i < argv.length; i++) {
715
+ const a = argv[i] as string;
716
+ if (a === '--root') {
717
+ out.root = path.resolve(argv[++i] ?? out.root);
718
+ } else if (a.startsWith('--root=')) {
719
+ out.root = path.resolve(a.slice('--root='.length));
720
+ } else if (a === '--out') {
721
+ out.out = path.resolve(argv[++i] ?? out.out);
722
+ } else if (a.startsWith('--out=')) {
723
+ out.out = path.resolve(a.slice('--out='.length));
724
+ } else if (a === '--quiet') {
725
+ out.quiet = true;
726
+ } else if (a === '-h' || a === '--help') {
727
+ process.stdout.write(`usage: ${PROG} [--root PATH] [--out PATH] [--quiet]\n`);
728
+ process.exit(0);
729
+ }
730
+ // Unrecognized args are ignored, not fatal — this is a report-only
731
+ // soft ratchet; exit code is always 0 (see module docstring).
732
+ }
733
+ if (out.out === DEFAULT_OUT && out.root !== REPO_ROOT) {
734
+ out.out = path.join(out.root, 'internal', 'reports', 'complexity-report.md');
735
+ }
736
+ return out;
737
+ }
738
+
739
+ /** Always returns 0 — report-only soft ratchet, per the roadmap spec. */
740
+ export function main(argv?: string[]): number {
741
+ try {
742
+ const args = parseArgs(argv ?? process.argv.slice(2));
743
+ const roots = defaultRoots(args.root);
744
+ const snapshot = collectSnapshot(roots, new Date());
745
+ const previousText = _exists(args.out) ? fs.readFileSync(args.out, 'utf-8') : null;
746
+ const previous = previousText ? parsePreviousSnapshot(previousText) : null;
747
+ const baseline = loadBaseline(path.join(args.root, BASELINE_RELPATH));
748
+ const report = renderReport(snapshot, previous, baseline);
749
+ fs.mkdirSync(path.dirname(args.out), { recursive: true });
750
+ fs.writeFileSync(args.out, report, 'utf-8');
751
+ if (!args.quiet) {
752
+ process.stdout.write(`${PROG}: wrote ${path.relative(args.root, args.out)}\n`);
753
+ }
754
+ } catch (exc) {
755
+ // Report-only: never fail the caller. Surface the error for
756
+ // diagnosis, still exit 0.
757
+ process.stderr.write(`${PROG}: error (non-fatal, report may be incomplete): ${String(exc)}\n`);
758
+ }
759
+ return 0;
760
+ }
761
+
762
+ function _isCliEntry(): boolean {
763
+ if (process.argv[1] === undefined) return false;
764
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
765
+ if (import.meta.url === argvUrl) return true;
766
+ try {
767
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
768
+ const argv = fs.realpathSync(path.resolve(process.argv[1] as string));
769
+ return here === argv;
770
+ } catch {
771
+ return false;
772
+ }
773
+ }
774
+ if (_isCliEntry()) {
775
+ process.exitCode = main();
776
+ }