@event4u/agent-config 9.34.0 → 9.36.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +64 -77
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  10. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  20. package/dist/agent-src/templates/AGENTS.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  23. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  24. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  25. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  26. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  27. package/dist/cli/registry.js +2 -2
  28. package/dist/cli/registry.js.map +1 -1
  29. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  30. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
  31. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  32. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
  33. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
  34. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  35. package/dist/cli-delegate/cmd_conformance.js +4 -4
  36. package/dist/cli-delegate/cmd_doctor.js +3 -3
  37. package/dist/cli-delegate/cmd_export.js +3 -3
  38. package/dist/cli-delegate/cmd_handoff.js +1 -1
  39. package/dist/cli-delegate/cmd_migrate.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +3 -3
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  43. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +3 -3
  46. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  47. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  48. package/dist/cli-delegate/cmd_validate.js +3 -3
  49. package/dist/discovery/deprecation-report.md +1 -1
  50. package/dist/discovery/discovery-manifest.json +14 -14
  51. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  52. package/dist/discovery/discovery-manifest.summary.md +1 -1
  53. package/dist/discovery/dormancy-report.md +1 -1
  54. package/dist/discovery/orphan-report.md +1 -1
  55. package/dist/discovery/packs.json +2 -2
  56. package/dist/discovery/trust-report.md +1 -1
  57. package/dist/discovery/workspaces.json +2 -2
  58. package/dist/hooks/dispatch.js +789 -401
  59. package/dist/install/install.mjs +21 -4
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +99 -7
  63. package/dist/router.json +1 -1
  64. package/dist/server/schemas/settings.js +6 -0
  65. package/dist/server/schemas/settings.js.map +1 -1
  66. package/docs/contracts/settings-classes.md +5 -2
  67. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  68. package/package.json +2 -2
  69. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  70. package/src/agent-src/templates/AGENTS.md +1 -1
  71. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  72. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  73. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  74. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  75. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  76. package/src/config/agent-settings.template.yml +43 -2
  77. package/src/config/gate-coverage.yml +12 -0
  78. package/src/config/quorum-attendance-budget.json +10 -2
  79. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  80. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  81. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  82. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  83. package/src/scripts/_dispatch.bash +6 -2
  84. package/src/scripts/_lib/hook_settings.ts +79 -0
  85. package/src/scripts/_lib/session_register.ts +67 -1
  86. package/src/scripts/_lib/ui_surface.ts +97 -0
  87. package/src/scripts/ai_council/config.ts +60 -0
  88. package/src/scripts/ai_council/events_log.ts +103 -4
  89. package/src/scripts/ai_council/quorum.ts +83 -6
  90. package/src/scripts/capture_skill_catalogue.ts +483 -0
  91. package/src/scripts/council_cli.ts +36 -1
  92. package/src/scripts/hook_manifest.yaml +18 -3
  93. package/src/scripts/hooks/concern_registry.ts +2 -0
  94. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  95. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  96. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  97. package/src/scripts/lint_framework_leakage.ts +195 -28
  98. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  99. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  100. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  101. package/src/scripts/memory_lookup.ts +1 -2
  102. package/src/scripts/report_consultation_rate.ts +358 -0
  103. package/src/scripts/report_skill_activation.ts +5 -1
  104. package/src/scripts/schemas/command.schema.json +4 -0
  105. package/src/scripts/schemas/skill.schema.json +4 -0
  106. package/src/scripts/session_register_hook.ts +238 -18
  107. package/src/scripts/sessions_cli.ts +377 -12
  108. package/src/server/schemas/settings.ts +12 -0
  109. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  110. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  111. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  112. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -299,14 +299,21 @@ interface AllowlistEntry {
299
299
  file?: string;
300
300
  lines?: number[] | '*';
301
301
  /**
302
- * Content anchor — a substring of the exempted line.
302
+ * Content anchor — a substring of the exempted line. The only per-line key.
303
303
  *
304
- * Position-keyed entries (`lines: [100]`) are drift-fragile by
305
- * construction: inserting a paragraph above line 100 silently re-fires the
306
- * ratchet on an entry nobody touched, which this repository has recorded as
307
- * a real cost. An anchor keys the exemption to what is actually being
308
- * exempted, so the entry survives the edit that moves it. Prefer it for new
309
- * entries; `lines` stays supported for the existing ones.
304
+ * Position keying (`lines: [100]`) is retired, not merely discouraged.
305
+ * Inserting a paragraph above line 100 re-fires the ratchet on an entry
306
+ * nobody touched — recorded twice in one pull request — and, worse, it rots
307
+ * in silence: the migration that introduced this comment found three shipped
308
+ * entries that had stopped exempting anything, one naming line 100 of a
309
+ * 68-line file and two naming blank lines. Nothing reported it, because a
310
+ * drifted position key still parses.
311
+ *
312
+ * An anchor keys the exemption to the content being exempted, so it survives
313
+ * the edit that moves it and fails loudly when that content is gone.
314
+ * `validate_allowlist` additionally requires it to match exactly one line:
315
+ * `includes` is file-scoped, so a too-short anchor would silently exempt a
316
+ * second line nobody reviewed.
310
317
  */
311
318
  anchor?: string;
312
319
  reason?: string;
@@ -328,35 +335,124 @@ function _load_allowlist(): Allowlist {
328
335
  }
329
336
  }
330
337
 
331
- function _allowlisted(
338
+ /**
339
+ * Every allowlist entry must still exempt something, and exactly one thing.
340
+ *
341
+ * A position-keyed entry rots in silence: the line it names drifts, the entry
342
+ * keeps parsing, and nothing reports that an exemption stopped exempting. This
343
+ * repository shipped three such entries — one naming line 100 of a 68-line file,
344
+ * two naming blank lines — and the rot was invisible until the whole set was read
345
+ * at once. An anchor cannot drift, but it can over-reach: `includes` is
346
+ * file-scoped, so an anchor occurring twice silently exempts a second line nobody
347
+ * reviewed.
348
+ *
349
+ * Both failures are decidable from the file, so they are checked rather than
350
+ * trusted. `lines: '*'` stays legal — a deliberate whole-file exemption is a
351
+ * different thing from a position key, and it cannot drift.
352
+ */
353
+ /**
354
+ * The exact text `_allowlisted` compares an anchor against.
355
+ *
356
+ * The matcher sees `Hit.snippet`, not the raw line — trimmed and cut at 160
357
+ * chars. A validator reading the raw line would clear an anchor copied with its
358
+ * indentation, or drawn from past column 160, and the gate would then fail to
359
+ * suppress a hit the maintainer was told is exempt. One function, both callers.
360
+ */
361
+ export function anchor_haystack(line: string): string {
362
+ return line.trim().slice(0, 160);
363
+ }
364
+
365
+ export function validate_allowlist(allowlist: Allowlist, repo_root?: string): string[] {
366
+ // Default to the MUTABLE root, not the real one: the scan resolves entry
367
+ // paths against `REPO_ROOT`, and a validator resolving them anywhere else
368
+ // would report every entry missing the moment a caller redirects the tree.
369
+ const root = repo_root ?? REPO_ROOT;
370
+ const problems: string[] = [];
371
+ for (const entry of allowlist.entries ?? []) {
372
+ const file = entry.file;
373
+ if (typeof file !== 'string' || file === '') {
374
+ problems.push('entry without a `file`');
375
+ continue;
376
+ }
377
+ if (Array.isArray(entry.lines)) {
378
+ problems.push(
379
+ `${file}: position-keyed (\`lines: [${entry.lines.join(', ')}]\`) — ` +
380
+ 'use `anchor` with a substring of the exempted line; a line number ' +
381
+ 'drifts out from under its own exemption on any edit above it',
382
+ );
383
+ continue;
384
+ }
385
+ if (entry.lines === '*') {
386
+ // A whole-file exemption never consults the anchor, so validating one
387
+ // here would stop every scan for a field the matcher does not read.
388
+ continue;
389
+ }
390
+ const anchor = entry.anchor;
391
+ if (typeof anchor !== 'string' || anchor === '') {
392
+ problems.push(`${file}: neither an \`anchor\` nor \`lines: "*"\``);
393
+ continue;
394
+ }
395
+ const abs = path.join(root, file);
396
+ if (!_isFile(abs)) {
397
+ problems.push(`${file}: anchored entry names a file that does not exist`);
398
+ continue;
399
+ }
400
+ const matches = fs
401
+ .readFileSync(abs, 'utf-8')
402
+ .split('\n')
403
+ .filter((l) => anchor_haystack(l).includes(anchor)).length;
404
+ if (matches === 0) {
405
+ problems.push(
406
+ `${file}: anchor matches no line — the exempted content is gone, so the ` +
407
+ `entry exempts nothing: ${JSON.stringify(anchor.slice(0, 50))}`,
408
+ );
409
+ } else if (matches > 1) {
410
+ problems.push(
411
+ `${file}: anchor matches ${matches} lines — it would exempt more than the ` +
412
+ `reviewed one; lengthen it: ${JSON.stringify(anchor.slice(0, 50))}`,
413
+ );
414
+ }
415
+ }
416
+ return problems;
417
+ }
418
+
419
+ /**
420
+ * Index of the entry that exempts this hit, or `-1`.
421
+ *
422
+ * Returns the index rather than a boolean so the run can report which
423
+ * exemptions were never used. An entry that suppresses nothing is the failure
424
+ * this gate shipped: three had rotted under position keys, and re-keying them to
425
+ * anchors preserved the rot, because an anchor resolving to a line says nothing
426
+ * about that line producing a hit.
427
+ */
428
+ function _allowlist_index(
332
429
  rel_path: string,
333
430
  line_no: number,
334
431
  allowlist: Allowlist,
335
432
  line_text = '',
336
- ): boolean {
433
+ ): number {
337
434
  const logical = strip_source_prefix(rel_path);
338
- for (const entry of allowlist.entries ?? []) {
435
+ const entries = allowlist.entries ?? [];
436
+ for (let i = 0; i < entries.length; i += 1) {
437
+ const entry = entries[i] as AllowlistEntry;
339
438
  const entry_file = entry.file;
340
439
  const entry_logical =
341
440
  typeof entry_file === 'string' ? strip_source_prefix(entry_file) : null;
342
441
  if (entry_file !== rel_path && (logical === null || entry_logical !== logical)) {
343
442
  continue;
344
443
  }
345
- const lines = entry.lines;
346
- if (lines === '*') {
347
- return true;
444
+ if (entry.lines === '*') {
445
+ return i;
348
446
  }
349
- // Content anchor before line number: an anchored entry keeps matching
350
- // after an insertion moves the line, which a position key cannot.
447
+ // Content anchor, and nothing else. A numeric `lines` entry never reaches
448
+ // this point — `validate_allowlist` stops the run on one — so matching it
449
+ // here would be a branch that only fires for input the gate rejects.
351
450
  const anchor = entry.anchor;
352
451
  if (typeof anchor === 'string' && anchor !== '' && line_text.includes(anchor)) {
353
- return true;
354
- }
355
- if (Array.isArray(lines) && lines.includes(line_no)) {
356
- return true;
452
+ return i;
357
453
  }
358
454
  }
359
- return false;
455
+ return -1;
360
456
  }
361
457
 
362
458
  function _families_in_window(lines: string[], idx: number, radius = 10): Set<string> {
@@ -502,7 +598,12 @@ function* iter_md_files(paths: Iterable<string>): Generator<string> {
502
598
  }
503
599
  }
504
600
 
505
- function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; paths: string[] } {
601
+ function parse_args(argv: readonly string[]): {
602
+ json: boolean;
603
+ quiet: boolean;
604
+ narrowed: boolean;
605
+ paths: string[];
606
+ } {
506
607
  let json = false;
507
608
  let quiet = false;
508
609
  const paths: string[] = [];
@@ -535,16 +636,26 @@ function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; p
535
636
  process.exit(2);
536
637
  }
537
638
  }
538
- return { json, quiet, paths: paths.length > 0 ? paths : _default_paths() };
639
+ // `narrowed` distinguishes an explicit `--paths` from the default full set.
640
+ // `paths.length === 0` cannot: the default is three subdirectories, so a full
641
+ // run also arrives here with a non-empty list — which silently disabled the
642
+ // unused-exemption check the first time it was written against that test.
643
+ return {
644
+ json,
645
+ quiet,
646
+ narrowed: paths.length > 0,
647
+ paths: paths.length > 0 ? paths : _default_paths(),
648
+ };
539
649
  }
540
650
 
541
651
  /**
542
652
  * The allowlist entry that would silence one finding, ready to paste.
543
653
  *
544
- * Anchored on the matched token rather than on the line number: a
545
- * position-keyed entry re-fires the moment an insertion above moves the line,
546
- * on an entry nobody touched, which `check_suppression_hygiene` counts every
547
- * run until the existing 18 migrate.
654
+ * Anchored on the matched token rather than on the line number: a position key
655
+ * re-fires the moment an insertion above moves the line, on an entry nobody
656
+ * touched. The form is no longer merely discouraged — `validate_allowlist`
657
+ * refuses it, and the last 18 position-keyed entries were migrated to anchors
658
+ * in the change that introduced the refusal.
548
659
  */
549
660
  export function suppressionKey(rel: string, hit: Hit): string {
550
661
  const anchor = hit.snippet.trim().slice(0, 60);
@@ -613,7 +724,19 @@ export function main(argv?: readonly string[]): number {
613
724
  const args = parse_args(raw);
614
725
 
615
726
  const allowlist = _load_allowlist();
727
+ // Judge the exemptions before judging any file. A rotted entry produces a
728
+ // green run that means nothing, which is the one failure mode a gate must
729
+ // not have. Structural problems are collected here and reported at the end,
730
+ // NOT returned on early: an early return skipped the `scanned:` line the
731
+ // gate-coverage collector consumes and left `--json` emitting nothing, so a
732
+ // consumer parsing the envelope threw on exactly the runs where the gate
733
+ // reports itself unusable.
734
+ const allowlist_problems = validate_allowlist(allowlist);
616
735
  const file_hits: Array<[string, Hit[]]> = [];
736
+ // Which exemptions actually suppressed something. Only meaningful over a full
737
+ // scan: with `--paths` narrowing the tree, an entry for a file outside the
738
+ // scope is unused for a reason that says nothing about the entry.
739
+ const used_entries = new Set<number>();
617
740
  let total_hits = 0;
618
741
  let allowlisted_total = 0;
619
742
  // Every enumerated file is planned up front, so the five exemption branches
@@ -661,7 +784,9 @@ export function main(argv?: readonly string[]): number {
661
784
  if (h.cross_stack) {
662
785
  continue;
663
786
  }
664
- if (_allowlisted(rel, h.line, allowlist, h.snippet)) {
787
+ const idx = _allowlist_index(rel, h.line, allowlist, h.snippet);
788
+ if (idx >= 0) {
789
+ used_entries.add(idx);
665
790
  h.allowlisted = true;
666
791
  allowlisted_total += 1;
667
792
  continue;
@@ -679,10 +804,31 @@ export function main(argv?: readonly string[]): number {
679
804
  }
680
805
  }
681
806
 
807
+ // An exemption that suppressed nothing over a FULL scan exempts nothing at
808
+ // all — the rot the anchor migration was supposed to end, and which anchoring
809
+ // alone does not end: an anchor resolving to a line says nothing about that
810
+ // line producing a hit. Only checked on a full scan, because `--paths` makes
811
+ // "unused" mean "out of scope" instead.
812
+ if (!args.narrowed) {
813
+ const entries = allowlist.entries ?? [];
814
+ for (let i = 0; i < entries.length; i += 1) {
815
+ if (used_entries.has(i)) {
816
+ continue;
817
+ }
818
+ const e = entries[i] as AllowlistEntry;
819
+ allowlist_problems.push(
820
+ `${String(e.file)}: exemption suppressed nothing this run — the line it names ` +
821
+ 'produces no leakage hit, so it exempts nothing: ' +
822
+ JSON.stringify(String(e.anchor ?? e.lines).slice(0, 50)),
823
+ );
824
+ }
825
+ }
826
+
682
827
  const summary = {
683
828
  total_hits,
684
829
  files: file_hits.length,
685
830
  allowlisted: allowlisted_total,
831
+ allowlist_problems: allowlist_problems.length,
686
832
  };
687
833
 
688
834
  // Finalize before either output branch: an unaccounted target must throw
@@ -696,10 +842,11 @@ export function main(argv?: readonly string[]): number {
696
842
  hits.map((h) => ({ file: relToRepo(p), ...h })),
697
843
  ),
698
844
  summary,
845
+ allowlist_problems,
699
846
  ledger: tally,
700
847
  };
701
848
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
702
- return total_hits ? 1 : 0;
849
+ return total_hits || allowlist_problems.length ? 1 : 0;
703
850
  }
704
851
 
705
852
  if (!args.quiet) {
@@ -731,6 +878,26 @@ export function main(argv?: readonly string[]): number {
731
878
  // judged) and is the same "reading M as coverage" trap the human summary
732
879
  // carries. The ledger makes the honest number the cheap one to publish.
733
880
  process.stderr.write(`scanned: ${String(tally.completed + tally.failed)}\n`);
881
+
882
+ // Reported AFTER the ledger and the `scanned:` line, so a broken allowlist
883
+ // still produces both output contracts. An exemption problem fails the run
884
+ // on its own: a gate that scans cleanly through unusable exemptions is
885
+ // reporting on a question nobody asked.
886
+ if (allowlist_problems.length > 0) {
887
+ process.stderr.write(
888
+ `\n❌ ${allowlist_problems.length} unusable allowlist entr` +
889
+ `${allowlist_problems.length === 1 ? 'y' : 'ies'}:\n`,
890
+ );
891
+ for (const p of allowlist_problems) {
892
+ process.stderr.write(` · ${p}\n`);
893
+ }
894
+ process.stderr.write(
895
+ ' An exemption that names nothing, suppresses nothing, or reaches further\n' +
896
+ ' than the line it was reviewed for is not an exemption. Remove it or\n' +
897
+ ' re-anchor it to the content it is meant to cover.\n',
898
+ );
899
+ return 1;
900
+ }
734
901
  return total_hits ? 1 : 0;
735
902
  }
736
903
 
@@ -1,152 +1,160 @@
1
1
  {
2
2
  "version": 1,
3
- "_doc": "Each entry: { file: relative path from repo root, lines: [int,...] | \"*\" for whole file, reason: short justification }. Use sparingly — first ask whether the file should be neutralized or carved out. Policy cap: > 20 entries means the linter (or the content) is wrong — fix the tool or the text, never grow the list. Rebuilt 2026-07-12 (road-to-opt-hygiene-and-debt Phase 1): 104 → minimal set after heuristic upgrades + 25 content neutralizations + 2 carve-outs.",
3
+ "_doc": "Each entry: { file: relative path from repo root, anchor: a substring of the exempted line (the ONLY per-line key — position keys are refused by validate_allowlist), or lines: \"*\" for the whole file, reason: short justification }. Use sparingly — first ask whether the file should be neutralized or carved out. Policy cap: > 20 exempted FILES means the linter (or the content) is wrong — fix the tool or the text, never grow the list. The cap counts files because that is the unit it was written in, when one entry meant one file; entries are now per-line, so the two numbers differ on purpose (15 files / 31 entries as of the anchor migration) and the entry count is not the cap. Rebuilt 2026-07-12 (road-to-opt-hygiene-and-debt Phase 1): 104 -> minimal set after heuristic upgrades + 25 content neutralizations + 2 carve-outs. Re-keyed to content anchors 2026-08-12: 8 of the 39 exempted lines were found to exempt nothing — three named blank or out-of-range lines, five named lines that produce no hit — and were dropped rather than carried forward; the gate now refuses both shapes.",
4
4
  "entries": [
5
- {
6
- "file": "src/rules/code-comment-discipline.md",
7
- "lines": [
8
- 100
9
- ],
10
- "reason": "Worked PHP docblock demo inside the rule's own wrong/right example block — quoted teaching content."
11
- },
12
5
  {
13
6
  "file": "src/skills/api-testing/SKILL.md",
14
- "lines": [
15
- 218
16
- ],
7
+ "anchor": "- Don't test framework internals (e.g., \"does Laravel return",
17
8
  "reason": "Quoted example of a framework-internal behavior explicitly marked as what NOT to test — meta content, not a mandate."
18
9
  },
19
10
  {
20
11
  "file": "src/skills/learning-to-rule-or-skill/SKILL.md",
21
- "lines": [
22
- 320,
23
- 322
24
- ],
12
+ "anchor": "Decision: Create focused skill for Laravel route inspection ",
13
+ "reason": "Example learnings/decisions quoted verbatim in the promotion-flow walkthrough — meta content."
14
+ },
15
+ {
16
+ "file": "src/skills/learning-to-rule-or-skill/SKILL.md",
17
+ "anchor": "Learning: \"I forgot to run PHPStan once.\"",
25
18
  "reason": "Example learnings/decisions quoted verbatim in the promotion-flow walkthrough — meta content."
26
19
  },
27
20
  {
28
21
  "file": "src/skills/markitdown/SKILL.md",
29
- "lines": [
30
- 74,
31
- 111
32
- ],
22
+ "anchor": "uv pip install 'markitdown-mcp==0.0.1a4'",
33
23
  "reason": "Tool-intrinsic install lines — markitdown IS a pip package; the install command is the tool's identity, not stack leakage."
34
24
  },
25
+ {
26
+ "file": "src/skills/markitdown/SKILL.md",
27
+ "anchor": "Plugins enable arbitrary code paths inside the conversion pi",
28
+ "reason": "Tool-intrinsic install lines — markitdown IS a pip package; the install command is the tool's identity, not stack leakage."
29
+ },
30
+ {
31
+ "file": "src/skills/module-management/SKILL.md",
32
+ "anchor": "### Laravel HMVC carve-out",
33
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
34
+ },
35
+ {
36
+ "file": "src/skills/module-management/SKILL.md",
37
+ "anchor": "│ ├── api.php web.php console.php",
38
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
39
+ },
40
+ {
41
+ "file": "src/skills/module-management/SKILL.md",
42
+ "anchor": "**Auto-loading:** `app/Providers/ModuleServiceProvider.php` ",
43
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
44
+ },
45
+ {
46
+ "file": "src/skills/module-management/SKILL.md",
47
+ "anchor": "`app/Modules/` and registers routes (`Routes/api.php` → `/ap",
48
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
49
+ },
50
+ {
51
+ "file": "src/skills/module-management/SKILL.md",
52
+ "anchor": "`api` middleware; `Routes/web.php` → `web` middleware) plus ",
53
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
54
+ },
55
+ {
56
+ "file": "src/skills/module-management/SKILL.md",
57
+ "anchor": "// Routes/api.php — auto-prefixed /api",
58
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
59
+ },
35
60
  {
36
61
  "file": "src/skills/module-management/SKILL.md",
37
- "lines": [
38
- 87,
39
- 99,
40
- 107,
41
- 108,
42
- 109,
43
- 115,
44
- 124,
45
- 156,
46
- 172
47
- ],
62
+ "anchor": "### Symfony DDD-lite carve-out",
63
+ "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
64
+ },
65
+ {
66
+ "file": "src/skills/module-management/SKILL.md",
67
+ "anchor": "metadata in `pyproject.toml`.",
48
68
  "reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
49
69
  },
50
70
  {
51
71
  "file": "src/skills/po-discovery/SKILL.md",
52
- "lines": [
53
- 45
54
- ],
72
+ "anchor": "(\"upgrade Laravel to 11\") — discovery adds noise; skip.",
55
73
  "reason": "Quoted example ticket title ('upgrade Laravel to 11') illustrating when discovery adds noise — meta content."
56
74
  },
57
75
  {
58
76
  "file": "src/skills/repomix-packer/SKILL.md",
59
- "lines": [
60
- 44
61
- ],
77
+ "anchor": "npm install -g repomix",
62
78
  "reason": "Tool-intrinsic install line — repomix IS an npm package."
63
79
  },
64
80
  {
65
81
  "file": "src/skills/rule-writing/SKILL.md",
66
- "lines": [
67
- 78
68
- ],
82
+ "anchor": "description: \"Writing or reviewing PHP code — strict types, ",
69
83
  "reason": "Quoted rule-description example (the php-coding rule's own description string) — meta content. Re-anchored 76→78 after the description-cap edit above it."
70
84
  },
71
- {
72
- "file": "src/skills/security-audit/SKILL.md",
73
- "lines": [
74
- 122
75
- ],
76
- "reason": "Multi-ecosystem XSS row (Blade/JSX/Jinja peers on one line) + a quoted false-positive example ('Laravel's CSRF is already handled') — documentation, not a mandate."
77
- },
78
85
  {
79
86
  "file": "src/skills/skill-reviewer/SKILL.md",
80
- "lines": [
81
- 70
82
- ],
87
+ "anchor": "- ❌ Describing how Laravel middleware works (in a middlewar",
83
88
  "reason": "Quoted anti-example ('❌ Describing how Laravel middleware works') — the line teaches what NOT to write."
84
89
  },
85
90
  {
86
91
  "file": "src/skills/skill-writing/SKILL.md",
87
- "lines": [
88
- 148,
89
- 456
90
- ],
92
+ "anchor": "Use when creating Laravel middleware for request filtering",
91
93
  "reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content."
92
94
  },
93
95
  {
94
96
  "file": "src/skills/supply-chain-intake/SKILL.md",
95
- "lines": [
96
- 80,
97
- 82
98
- ],
97
+ "anchor": "rg -n '\"[^\"]+\":\\s*\"(\\^|~|\\*|latest)' package.json",
99
98
  "reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
100
99
  },
101
100
  {
102
- "file": "src/skills/test-driven-development/SKILL.md",
103
- "lines": [
104
- 197,
105
- 200,
106
- 208,
107
- 212
108
- ],
109
- "reason": "Worked Example A (PHP/Pest) paired with Example B (TypeScript/Jest) farther apart than the ±10-line window — canonical two-stack demo."
101
+ "file": "src/skills/supply-chain-intake/SKILL.md",
102
+ "anchor": "[ -f package.json ] && [ ! -f package-lock.json ] && echo \"n",
103
+ "reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
104
+ },
105
+ {
106
+ "file": "src/skills/testing-anti-patterns/SKILL.md",
107
+ "anchor": "Replacement: a `tests/Support/cleanupSession.php` helper or ",
108
+ "reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
109
+ },
110
+ {
111
+ "file": "src/skills/testing-anti-patterns/SKILL.md",
112
+ "anchor": "**Concrete capture tools** for recording the real shape: `cu",
113
+ "reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
110
114
  },
111
115
  {
112
116
  "file": "src/skills/testing-anti-patterns/SKILL.md",
113
- "lines": [
114
- 87,
115
- 124,
116
- 185
117
- ],
117
+ "anchor": "rg -n '\\b(it|test|describe)\\.skip\\b|\\.only\\b|xit\\(|xdescribe",
118
118
  "reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
119
119
  },
120
120
  {
121
121
  "file": "src/skills/test-driven-development/SKILL.md",
122
- "lines": [
123
- 228,
124
- 231,
125
- 239,
126
- 243
127
- ],
122
+ "anchor": "### Example A — PHP / Pest",
123
+ "reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
124
+ },
125
+ {
126
+ "file": "src/skills/test-driven-development/SKILL.md",
127
+ "anchor": "// tests/Unit/EmailValidatorTest.php — RED",
128
+ "reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
129
+ },
130
+ {
131
+ "file": "src/skills/test-driven-development/SKILL.md",
132
+ "anchor": "Run: `./vendor/bin/pest --filter='rejects empty email'` → fa",
133
+ "reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
134
+ },
135
+ {
136
+ "file": "src/skills/test-driven-development/SKILL.md",
137
+ "anchor": "// app/Validators/EmailValidator.php — GREEN (minimum)",
128
138
  "reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
129
139
  },
130
140
  {
131
141
  "file": "src/skills/image-editing/SKILL.md",
132
- "lines": [
133
- 48
134
- ],
142
+ "anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/ada",
135
143
  "reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
136
144
  },
137
145
  {
138
146
  "file": "src/skills/image-generation/SKILL.md",
139
- "lines": [
140
- 45
141
- ],
147
+ "anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/ada",
148
+ "reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
149
+ },
150
+ {
151
+ "file": "src/skills/prompt-engineering-image/SKILL.md",
152
+ "anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/adapters/<provider>.sh`",
142
153
  "reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
143
154
  },
144
155
  {
145
156
  "file": "src/skills/prompt-engineering-image/SKILL.md",
146
- "lines": [
147
- 74,
148
- 115
149
- ],
157
+ "anchor": "- `node_modules/@event4u/agent-config/src/scripts/ai-image/a",
150
158
  "reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
151
159
  }
152
160
  ]
@@ -216,6 +216,7 @@ function lint_roadmap(
216
216
  _check_human_gate_steps(text, warnings);
217
217
  _check_human_gate_phase_headings(text, warnings);
218
218
  _check_human_gate_exit_criteria(text, warnings);
219
+ _check_external_population_gates(text, warnings);
219
220
  }
220
221
  return problems;
221
222
  }
@@ -383,6 +384,65 @@ function _check_human_gate_exit_criteria(text: string, warnings: string[]): void
383
384
  }
384
385
  }
385
386
 
387
+ // A gate whose opening condition depends on a quantity the project cannot
388
+ // produce is not a gate — it is a permanent no in the shape of an answer. The
389
+ // measured case: a single-user private tool carried a roadmap gate on "external
390
+ // installations with write activity", which held 33 steps closed and would have
391
+ // held them closed forever. Matched only inside gate-shaped context (exit
392
+ // criteria, acceptance criteria, a blocker's `Resolved when:`), because these
393
+ // nouns are ordinary vocabulary in a roadmap for an actual product.
394
+ const EXTERNAL_POPULATION_PATS: Array<[RegExp, string]> = [
395
+ [/\bexternal (users?|installs?|installations?|adopters?|consumers?)\b/i, 'an external user population'],
396
+ // Bare `adopters` is deliberately absent: measured against the real roadmap
397
+ // corpus it matched four internal counts ("4 adopters" of a gate helper),
398
+ // and an external adopter count is already covered by the pattern above.
399
+ [/\b(installations?|sign-?ups?|subscribers?|active users?)\b/i, 'an external adoption count'],
400
+ [/\bmarket (demand|validation|traction|fit|signal)\b/i, 'a market signal'],
401
+ [/\bchurn(ing|ed)?\b/i, 'a metric only a real user population produces'],
402
+ [/\buser (population|base|segment)\b/i, 'a user population'],
403
+ [/\b(?:at least |≥\s?)\d+ (people|users|customers|installs|adopters)\b/i, 'a headcount threshold of external people'],
404
+ ];
405
+
406
+ const RESOLVED_WHEN_PAT = /^\s*(?:[-*]\s+)?\*{0,2}resolved when\*{0,2}\s*:/i;
407
+
408
+ const AUDIENCE_HINT =
409
+ 'is this project meant to have that population at all? A gate whose ' +
410
+ 'condition depends on a quantity the project cannot produce is a permanent ' +
411
+ 'no, not a gate — see project.audience and § 8-pre of ' +
412
+ 'docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md';
413
+
414
+ function _check_external_population_gates(text: string, warnings: string[]): void {
415
+ let inHeadingBlock = false;
416
+ let inInlineBlock = false;
417
+ for (const rawLine of text.split('\n')) {
418
+ const heading = /^#{1,6}\s+(.*)$/.exec(rawLine);
419
+ if (heading) {
420
+ inHeadingBlock = /\b(acceptance|exit criteria)\b/i.test(heading[1]!);
421
+ inInlineBlock = false;
422
+ continue;
423
+ }
424
+ if (rawLine.trim() === '') {
425
+ inInlineBlock = false;
426
+ continue;
427
+ }
428
+ if (EXIT_CRITERIA_OPENER_PAT.test(rawLine)) {
429
+ inInlineBlock = true;
430
+ }
431
+ const isResolvedWhen = RESOLVED_WHEN_PAT.test(rawLine);
432
+ if (!inHeadingBlock && !inInlineBlock && !isResolvedWhen) {
433
+ continue;
434
+ }
435
+ for (const [re, label] of EXTERNAL_POPULATION_PATS) {
436
+ if (re.test(rawLine)) {
437
+ warnings.push(
438
+ `gate rests on ${label}: "${rawLine.trim().slice(0, 80)}" — ` + AUDIENCE_HINT,
439
+ );
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ }
445
+
386
446
  function _check_execution_mode(fm: string, problems: string[]): void {
387
447
  if (!fm || !/^execution:/m.test(fm)) {
388
448
  return; // absent field = interactive default — valid