@event4u/agent-config 14.5.0 → 14.7.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 (203) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +384 -3
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/decision.md +7 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  6. package/dist/agent-src/commands/analyze.md +1 -1
  7. package/dist/agent-src/commands/optimize/project.md +10 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +132 -4
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  10. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
  11. package/dist/agent-src/rules/context-hygiene.md +2 -0
  12. package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
  13. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  14. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  15. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  16. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  17. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  18. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  19. package/dist/agent-src/rules/skill-quality.md +0 -2
  20. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  21. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  22. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  23. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  24. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  25. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  27. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  28. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  29. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  30. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  31. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  32. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  33. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  34. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  35. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  36. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  37. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  38. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  39. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  40. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  41. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  42. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  44. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  45. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  46. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  47. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  48. package/dist/agent-src/templates/roadmaps.md +45 -2
  49. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  50. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  51. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  52. package/dist/cli/registry.js +1 -1
  53. package/dist/cli/registry.js.map +1 -1
  54. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  55. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  56. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  57. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  58. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  59. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  60. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  61. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  62. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
  63. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  64. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  65. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  66. package/dist/cli-delegate/cmd_conformance.js +459 -709
  67. package/dist/cli-delegate/cmd_doctor.js +6 -6
  68. package/dist/cli-delegate/cmd_explain.js +1 -1
  69. package/dist/cli-delegate/cmd_export.js +7 -6
  70. package/dist/cli-delegate/cmd_handoff.js +6 -6
  71. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  72. package/dist/cli-delegate/cmd_preflight.js +2 -2
  73. package/dist/cli-delegate/cmd_prune.js +7 -6
  74. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  75. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  76. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  77. package/dist/cli-delegate/cmd_sync.js +7 -6
  78. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  79. package/dist/cli-delegate/cmd_update.js +1 -1
  80. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  81. package/dist/cli-delegate/cmd_validate.js +7 -6
  82. package/dist/discovery/deprecation-report.md +1 -1
  83. package/dist/discovery/discovery-manifest.json +72 -29
  84. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  85. package/dist/discovery/discovery-manifest.summary.md +4 -4
  86. package/dist/discovery/dormancy-report.md +1 -1
  87. package/dist/discovery/orphan-report.md +1 -1
  88. package/dist/discovery/packs.json +10 -8
  89. package/dist/discovery/trust-report.md +3 -3
  90. package/dist/discovery/workspaces.json +8 -6
  91. package/dist/hooks/dispatch.js +3127 -2022
  92. package/dist/install/claudePathsPlan.js +187 -0
  93. package/dist/install/claudePathsPlan.js.map +1 -0
  94. package/dist/install/hostLayerFingerprint.js +136 -0
  95. package/dist/install/hostLayerFingerprint.js.map +1 -0
  96. package/dist/install/install.mjs +553 -425
  97. package/dist/install/partitionEligibility.js +276 -0
  98. package/dist/install/partitionEligibility.js.map +1 -0
  99. package/dist/mcp/registry-manifest.json +3 -3
  100. package/dist/mcp/server.json +2 -2
  101. package/dist/mcp/server.mjs +1744 -1191
  102. package/dist/router.json +1 -1
  103. package/dist/scripts/_lib/agent_src.js +41 -0
  104. package/dist/scripts/_lib/agent_src.js.map +1 -1
  105. package/dist/scripts/_lib/install_layout.js +83 -0
  106. package/dist/scripts/_lib/install_layout.js.map +1 -0
  107. package/dist/scripts/_lib/installed_lock.js +444 -0
  108. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  109. package/dist/scripts/condense.js +13 -164
  110. package/dist/scripts/condense.js.map +1 -1
  111. package/dist/server/schemas/settings.js +4 -0
  112. package/dist/server/schemas/settings.js.map +1 -1
  113. package/docs/contracts/settings-classes.md +12 -6
  114. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  115. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  116. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  117. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  118. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  119. package/package.json +1 -1
  120. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  121. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  122. package/src/agent-src/scripts/gate_budget.ts +282 -0
  123. package/src/agent-src/scripts/gate_execute.ts +72 -31
  124. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  125. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  126. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  127. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  128. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  129. package/src/config/agent-settings.template.yml +27 -2
  130. package/src/config/ci-local-parity.yml +18 -0
  131. package/src/config/estate-count-budget.json +367 -3
  132. package/src/config/gate-violation-baselines.json +41 -41
  133. package/src/config/pack-size-budget.json +3 -2
  134. package/src/config/rule-activation-census.json +14 -6
  135. package/src/config/rule-stub-ceilings.json +19 -6
  136. package/src/install/claudePathsPlan.ts +196 -0
  137. package/src/install/hostLayerFingerprint.ts +145 -0
  138. package/src/install/partitionEligibility.ts +325 -0
  139. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  140. package/src/scripts/_dispatch.bash +6 -1
  141. package/src/scripts/_lib/ac_heading.ts +30 -0
  142. package/src/scripts/_lib/agent_src.ts +42 -0
  143. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  144. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  145. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  146. package/src/scripts/_lib/installed_lock.ts +50 -5
  147. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  148. package/src/scripts/_lib/md_section.ts +129 -0
  149. package/src/scripts/_lib/scope_guard.sh +86 -4
  150. package/src/scripts/_lib/self_repair.ts +19 -0
  151. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  152. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  153. package/src/scripts/adr/regenerate_index.ts +13 -2
  154. package/src/scripts/adr_cite_check.ts +424 -0
  155. package/src/scripts/ai_council/cli_help.ts +55 -0
  156. package/src/scripts/before_complete_hook.ts +153 -28
  157. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  158. package/src/scripts/check_adr_frontmatter.ts +187 -1
  159. package/src/scripts/check_references.ts +60 -0
  160. package/src/scripts/check_release_published.ts +32 -11
  161. package/src/scripts/check_single_delivery.ts +517 -0
  162. package/src/scripts/ci_settle.ts +170 -0
  163. package/src/scripts/condense.ts +20 -170
  164. package/src/scripts/council_cli.ts +3 -3
  165. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  166. package/src/scripts/hook_manifest.json +1 -1
  167. package/src/scripts/hook_manifest.yaml +77 -14
  168. package/src/scripts/hooks/block_no_verify.ts +61 -10
  169. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  170. package/src/scripts/hooks/concern_registry.ts +4 -0
  171. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  172. package/src/scripts/hooks/run_continuation_hook.ts +887 -68
  173. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  174. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  175. package/src/scripts/hooks/state_io.ts +782 -20
  176. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  178. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  179. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  181. package/src/scripts/install.sh +12 -2
  182. package/src/scripts/install.ts +16 -11
  183. package/src/scripts/language_mirror_hook.ts +561 -25
  184. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  185. package/src/scripts/lint_plan_risk_register.ts +4 -3
  186. package/src/scripts/lint_skill_router_head.ts +4 -4
  187. package/src/scripts/mcp_server/tools.ts +2 -2
  188. package/src/scripts/memory_consolidation.ts +189 -0
  189. package/src/scripts/memory_eviction.ts +384 -0
  190. package/src/scripts/new_roadmap.ts +136 -0
  191. package/src/scripts/pack_mcp_content.ts +20 -5
  192. package/src/scripts/release.ts +37 -38
  193. package/src/scripts/report_layer_overlap.ts +78 -0
  194. package/src/scripts/report_memory_pointers.ts +622 -0
  195. package/src/scripts/self_repair_hook.ts +37 -5
  196. package/src/scripts/session_register_hook.ts +115 -15
  197. package/src/scripts/skill_usage_collect.ts +11 -1
  198. package/src/scripts/skill_usage_report.ts +278 -8
  199. package/src/scripts/sync_pr_branch.ts +7 -0
  200. package/src/server/schemas/settings.ts +8 -0
  201. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  202. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  203. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -88,9 +88,9 @@
88
88
  "reason": "Quoted anti-example ('❌ Describing how Laravel middleware works') — the line teaches what NOT to write."
89
89
  },
90
90
  {
91
- "file": "src/skills/skill-writing/SKILL.md",
91
+ "file": "src/skills/skill-writing/references/procedure.md",
92
92
  "anchor": "Use when creating Laravel middleware for request filtering",
93
- "reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content."
93
+ "reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content. Repointed 2026-08-20: the K6 router-head retrofit moved the quoted description examples out of the head into references/procedure.md, so the anchor no longer matched its file and the entry exempted nothing. The content is unchanged and still meta."
94
94
  },
95
95
  {
96
96
  "file": "src/skills/supply-chain-intake/SKILL.md",
@@ -39,6 +39,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { parse as parseYaml } from 'yaml';
41
41
 
42
+ import { isAcceptanceCriteriaHeading } from './_lib/ac_heading.js';
42
43
  import { workspaceIdentity } from './_lib/git_common_dir.js';
43
44
  import { gitEnv } from './_lib/git_env.js';
44
45
  import { splitMarkdownRow } from './_lib/md_table.js';
@@ -52,7 +53,7 @@ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
52
53
  */
53
54
  export const RISK_REGISTER_GATE_ACTIVATION = '2026-08-04';
54
55
 
55
- const MARKER_RE =
56
+ export const MARKER_RE =
56
57
  /^<!--\s*risk-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*reviewer:\s*(.+?)\s*-->\s*$/;
57
58
  const HONEST_NULL_RE = /^\*\*Honest-null:\*\*(.*)$/;
58
59
  const CHECKBOX_RE = /^\s*- \[( |x|~|-)\]/;
@@ -72,7 +73,7 @@ export interface PlanFeatures {
72
73
  phaseHeadings: string[];
73
74
  /** Total count of deliverable checkbox lines (state-insensitive). */
74
75
  checkboxCount: number;
75
- /** sha256 of the `## Acceptance Criteria` section body (checkbox state normalized). */
76
+ /** sha256 of the acceptance-criteria section body (heading matched per `_lib/ac_heading`; checkbox state normalized). */
76
77
  acHash: string;
77
78
  }
78
79
 
@@ -115,7 +116,7 @@ export function extractFeatures(text: string): PlanFeatures {
115
116
  if (CHECKBOX_RE.test(entry.line)) {
116
117
  checkboxCount += 1;
117
118
  }
118
- if (/^##\s+Acceptance Criteria\s*$/.test(entry.line)) {
119
+ if (isAcceptanceCriteriaHeading(entry.line)) {
119
120
  acStart = i;
120
121
  }
121
122
  }
@@ -59,10 +59,10 @@ export const MODE_BODY_DIRS: readonly string[] = ['tasks', 'references'];
59
59
  * Measured, not guessed. REMOVE entries as skills are restructured; never add.
60
60
  */
61
61
  export const GRANDFATHERED: readonly string[] = [
62
- 'ai-council', // 1055
63
- 'skill-writing', // 767
64
- 'roadmap-management', // 552
65
- 'quality-tools', // 445
62
+ // Emptied 2026-08-20: all four seeded entries were restructured into router
63
+ // heads (entry head + `references/` mode bodies) under the published K6 cap.
64
+ // Shrink-only means this list may never grow again — a new oversized
65
+ // monolith fails the gate instead of joining it.
66
66
  ];
67
67
 
68
68
  export interface Finding {
@@ -669,7 +669,7 @@ async function _roadmapProgressHandler(
669
669
  };
670
670
  }
671
671
  const roadmaps = urp.collect(roadmapRoot);
672
- const content = urp.render(roadmaps, urp.collect_bundles(root));
672
+ const content = urp.render(roadmaps, urp.collect_bundles(root), roadmapRoot);
673
673
  const stepsDone = roadmaps.reduce((s, r) => s + r.done, 0);
674
674
  const stepsTotal = roadmaps.reduce((s, r) => s + r.total_active, 0);
675
675
  if (!dryRun) {
@@ -719,7 +719,7 @@ async function _roadmapArchiveHandler(
719
719
  const roadmaps = urp.collect(roadmapRoot);
720
720
  fs.writeFileSync(
721
721
  path.join(root, 'agents', 'roadmaps-progress.md'),
722
- urp.render(roadmaps, urp.collect_bundles(root)),
722
+ urp.render(roadmaps, urp.collect_bundles(root), roadmapRoot),
723
723
  'utf-8',
724
724
  );
725
725
  dashboardRegenerated = true;
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Store-wide consolidation candidates for the curated engineering memory.
4
+ *
5
+ * WHY THIS EXISTS (road-to-context-fidelity Phase 2, step 2).
6
+ * The session index injects a capped list of curated entries
7
+ * (`SESSION_INDEX_ROW_CAP = 30` in `session_memory_index.ts`), so two entries
8
+ * saying the same thing do not merely waste a row — they spend a scarce one
9
+ * twice and crowd out an entry the session never sees.
10
+ *
11
+ * WHAT IS NEW HERE IS THE POLICY, NOT THE MACHINERY. The similarity function
12
+ * is `_lib/text_similarity`, unchanged and shared with
13
+ * `check_memory_similarity` — the same Jaccard tokeniser and the same
14
+ * `MERGE_THRESHOLD` / `WARN_THRESHOLD` bands a `/memory propose` run already
15
+ * uses. What that script cannot do is look at the store: it takes one
16
+ * *candidate* body and scans intake. This iterates the curated store itself,
17
+ * which is a different entry point, and it is the only thing this file adds.
18
+ *
19
+ * IT NEVER MERGES. A merge decides which of two claims survives, which is a
20
+ * semantic judgement — the same class of judgement cf04 measured a mechanical
21
+ * proxy for and found at 0.00x lift over the base rate. Auto-resolution was
22
+ * also put on a prior council's explicit REJECT list for the contradiction
23
+ * detector, and the reasoning transfers unchanged. This reports pairs; a human
24
+ * decides.
25
+ *
26
+ * Not a gate: always exits 0, no gate-coverage entry. A blocking gate on a
27
+ * similarity threshold would fail a PR for writing a second entry about a
28
+ * topic that legitimately has two.
29
+ *
30
+ * Usage:
31
+ * memory_consolidation # merge + warn bands
32
+ * memory_consolidation --threshold 0.5
33
+ * memory_consolidation --format json
34
+ *
35
+ * Exit codes: 0 always (report), 3 = internal error.
36
+ */
37
+
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+ import process from 'node:process';
41
+ import { fileURLToPath } from 'node:url';
42
+ import YAML from 'yaml';
43
+
44
+ import {
45
+ MERGE_THRESHOLD,
46
+ WARN_THRESHOLD,
47
+ classifySimilarity,
48
+ jaccardSimilarity,
49
+ type SimilarityClass,
50
+ } from './_lib/text_similarity.js';
51
+
52
+ const PROG = 'memory_consolidation.ts';
53
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
54
+ const DEFAULT_STORE = 'agents/memory';
55
+
56
+ export interface Pair {
57
+ a: string;
58
+ b: string;
59
+ type_a: string;
60
+ type_b: string;
61
+ score: number;
62
+ band: SimilarityClass;
63
+ }
64
+
65
+ interface Row {
66
+ id: string;
67
+ type: string;
68
+ text: string;
69
+ }
70
+
71
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
72
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
73
+ }
74
+
75
+ function loadRows(storeDir: string): Row[] {
76
+ const base = path.isAbsolute(storeDir) ? storeDir : path.join(REPO_ROOT, storeDir);
77
+ let names: string[] = [];
78
+ try {
79
+ names = fs.readdirSync(base).filter((n) => n.endsWith('.yml')).sort();
80
+ } catch {
81
+ return [];
82
+ }
83
+ const rows: Row[] = [];
84
+ for (const n of names) {
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = YAML.parse(fs.readFileSync(path.join(base, n), 'utf-8'));
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (!isPlainObject(parsed) || !Array.isArray(parsed['entries'])) continue;
92
+ for (const e of parsed['entries'] as unknown[]) {
93
+ if (!isPlainObject(e)) continue;
94
+ const parts: string[] = [];
95
+ for (const f of ['key', 'body', 'rule', 'symptom', 'description', 'summary']) {
96
+ const v = e[f];
97
+ if (typeof v === 'string' && v.trim()) parts.push(v);
98
+ }
99
+ rows.push({ id: String(e['id'] ?? ''), type: n.replace(/\.yml$/, ''), text: parts.join('\n') });
100
+ }
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ /**
106
+ * All pairs at or above `threshold`, each reported once.
107
+ *
108
+ * Cross-type pairs are included deliberately: the same claim recorded once as
109
+ * an incident learning and once as a product rule is exactly the duplication
110
+ * worth surfacing, and it is invisible to any per-type scan.
111
+ *
112
+ * `threshold` is the ONLY filter, and the shared band is informational. The
113
+ * first version filtered on the band as well, which silently made
114
+ * `--threshold` inert below `WARN_THRESHOLD` — an instrument that could not
115
+ * fire and reported zero either way, which is indistinguishable from a clean
116
+ * store and is precisely the false-green shape this repo has a name for.
117
+ */
118
+ export function candidatePairs(rows: readonly Row[], threshold: number): Pair[] {
119
+ const out: Pair[] = [];
120
+ for (let i = 0; i < rows.length; i += 1) {
121
+ const left = rows[i];
122
+ if (!left) continue;
123
+ for (let j = i + 1; j < rows.length; j += 1) {
124
+ const right = rows[j];
125
+ if (!right) continue;
126
+ const score = jaccardSimilarity(left.text, right.text);
127
+ if (score < threshold) continue;
128
+ const band = classifySimilarity(score);
129
+ out.push({
130
+ a: left.id,
131
+ b: right.id,
132
+ type_a: left.type,
133
+ type_b: right.type,
134
+ score,
135
+ band,
136
+ });
137
+ }
138
+ }
139
+ out.sort((x, y) => y.score - x.score || x.a.localeCompare(y.a));
140
+ return out;
141
+ }
142
+
143
+ export function main(argv: readonly string[]): number {
144
+ let storeDir = DEFAULT_STORE;
145
+ let format = 'text';
146
+ let threshold = WARN_THRESHOLD;
147
+ for (let i = 0; i < argv.length; i += 1) {
148
+ const a = argv[i];
149
+ if (a === '--path') storeDir = argv[++i] ?? DEFAULT_STORE;
150
+ else if (a === '--format') format = argv[++i] ?? 'text';
151
+ else if (a === '--threshold') threshold = parseFloat(argv[++i] ?? '') || WARN_THRESHOLD;
152
+ else if (a === '--help' || a === '-h') {
153
+ process.stdout.write(
154
+ `usage: ${PROG} [--path DIR] [--format text|json] [--threshold N]\n` +
155
+ `Near-duplicate pairs in the curated memory store. Reports only —\n` +
156
+ `a merge decides which claim survives, and that is a human call.\n`,
157
+ );
158
+ return 0;
159
+ }
160
+ }
161
+
162
+ const rows = loadRows(storeDir);
163
+ const pairs = candidatePairs(rows, threshold);
164
+ if (format === 'json') {
165
+ process.stdout.write(`${JSON.stringify({ scanned: rows.length, pairs }, null, 2)}\n`);
166
+ return 0;
167
+ }
168
+ const lines = [
169
+ `${PROG} · consolidation candidates — reports pairs, never merges`,
170
+ `scanned: ${rows.length} entr(ies) · thresholds: merge ≥ ${MERGE_THRESHOLD}, warn ≥ ${WARN_THRESHOLD}`,
171
+ `candidates: ${pairs.length}`,
172
+ '',
173
+ ];
174
+ for (const p of pairs) {
175
+ lines.push(` ${p.band.padEnd(5)} ${p.score.toFixed(2)} ${p.type_a}/${p.a} ↔ ${p.type_b}/${p.b}`);
176
+ }
177
+ process.stdout.write(`${lines.join('\n')}\n`);
178
+ return 0;
179
+ }
180
+
181
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
182
+ if (invokedDirectly) {
183
+ try {
184
+ process.exit(main(process.argv.slice(2)));
185
+ } catch (e) {
186
+ process.stderr.write(`${PROG}: internal error: ${(e as Error).message}\n`);
187
+ process.exit(3);
188
+ }
189
+ }
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Eviction ladder for the curated engineering memory store.
4
+ *
5
+ * WHY THIS EXISTS (road-to-context-fidelity Phase 2, spike cf02).
6
+ * 21.5 % of the curated store contradicted the tree while the shipped
7
+ * instrument reported 0.0 % stale, because every entry carried the same
8
+ * `last_validated` and the same 365-day window — a check that could not fire
9
+ * before 2027. Entries do not get removed by anyone, so the store grows a
10
+ * false fraction that is injected at session start and read as fact.
11
+ *
12
+ * WHAT DRIVES A DEMOTION — and, just as load-bearing, what does NOT.
13
+ * Two signals, both human-anchored:
14
+ *
15
+ * 1. `semantic_verdict: stale` — a recorded human reading that the tree
16
+ * contradicts the entry. Demotes IMMEDIATELY, regardless of age. This is
17
+ * Phase 2's "contradiction outranks retention": age is a proxy,
18
+ * contradiction is the actual signal.
19
+ * 2. Age past the per-store window without re-verification. A fallback for
20
+ * entries nobody has looked at, not a claim that they are wrong.
21
+ *
22
+ * `report_memory_pointers` output NEVER drives a demotion. The council said so
23
+ * as a precaution on 2026-08-19; cf04 then measured pointer liveness at 0.00x
24
+ * lift over the base rate (0.75x with anchor drift, still sub-random), which
25
+ * turns the precaution into a finding. A dead citation is documentation debt.
26
+ * It is not evidence that a claim is false.
27
+ *
28
+ * THE LADDER
29
+ * active → the entry is inside its window, or re-verified since.
30
+ * due → past `last_validated + review_after_days`. Surfaced, kept.
31
+ * quarantine → past due by one more window, or verdict `stale`. Moved to
32
+ * `agents/memory/quarantine/<type>.yml`, still inspectable,
33
+ * no longer injected.
34
+ * delete → past due by two more windows while in quarantine.
35
+ *
36
+ * `unverifiable` entries (external systems, past events, host behaviour,
37
+ * recorded preferences) never quarantine on age. The tree cannot re-confirm
38
+ * them, so an age threshold would evict them on a schedule for a reason that
39
+ * can never be discharged — 11 of 107 entries at cf02.
40
+ *
41
+ * Dry-run by DEFAULT. `--apply` is the only path that writes, and it never
42
+ * deletes an entry that has not been sitting in quarantine.
43
+ *
44
+ * Usage:
45
+ * memory_eviction # plan, writes nothing
46
+ * memory_eviction --format json
47
+ * memory_eviction --apply # execute the plan
48
+ *
49
+ * Exit codes: 0 = plan produced (or applied), 3 = internal error.
50
+ */
51
+
52
+ import * as fs from 'node:fs';
53
+ import * as path from 'node:path';
54
+ import process from 'node:process';
55
+ import { fileURLToPath } from 'node:url';
56
+ import YAML from 'yaml';
57
+
58
+ const PROG = 'memory_eviction.ts';
59
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
60
+ const DEFAULT_STORE = 'agents/memory';
61
+ /**
62
+ * Quarantine lives OUTSIDE the memory root, not in a subdirectory of it.
63
+ * `check_memory` derives an entry's type from its parent directory name, so a
64
+ * `agents/memory/quarantine/` would validate as a memory type called
65
+ * "quarantine" and warn on every run. The semantics agree with the mechanics:
66
+ * a quarantined entry is not curated memory any more.
67
+ */
68
+ const QUARANTINE_ROOT = 'agents/memory-quarantine';
69
+
70
+ export type LadderState = 'active' | 'due' | 'quarantine' | 'delete';
71
+ export type SemanticVerdict = 'still-true' | 'stale' | 'unverifiable';
72
+
73
+ export interface LadderRow {
74
+ id: string;
75
+ type: string;
76
+ state: LadderState;
77
+ reason: string;
78
+ age_days: number | null;
79
+ window_days: number | null;
80
+ semantic_verdict: SemanticVerdict | null;
81
+ }
82
+
83
+ export interface LadderPlan {
84
+ scanned: number;
85
+ rows: LadderRow[];
86
+ }
87
+
88
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
89
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
90
+ }
91
+
92
+ function asDate(v: unknown): Date | null {
93
+ if (v instanceof Date) return v;
94
+ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v)) {
95
+ const d = new Date(`${v.slice(0, 10)}T00:00:00Z`);
96
+ return Number.isNaN(d.getTime()) ? null : d;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function daysBetween(a: Date, b: Date): number {
102
+ return Math.floor((a.getTime() - b.getTime()) / 86_400_000);
103
+ }
104
+
105
+ /**
106
+ * Classify one entry.
107
+ *
108
+ * Exported and pure so the ladder's arithmetic is testable without a store on
109
+ * disk — the thresholds are the part a reader is most likely to disagree with.
110
+ */
111
+ export function classify(
112
+ entry: Record<string, unknown>,
113
+ today: Date,
114
+ inQuarantine: boolean,
115
+ ): { state: LadderState; reason: string; age: number | null; window: number | null } {
116
+ const verdict = entry['semantic_verdict'];
117
+ const lv = asDate(entry['last_validated']);
118
+ const window = typeof entry['review_after_days'] === 'number' ? (entry['review_after_days'] as number) : null;
119
+ const age = lv ? daysBetween(today, lv) : null;
120
+
121
+ // Contradiction outranks retention — the whole point of the field.
122
+ if (verdict === 'stale') {
123
+ return inQuarantine
124
+ ? { state: 'quarantine', reason: 'recorded stale verdict, already quarantined', age, window }
125
+ : { state: 'quarantine', reason: 'recorded semantic verdict: stale (contradiction outranks age)', age, window };
126
+ }
127
+
128
+ if (age == null || window == null) {
129
+ return { state: 'active', reason: 'no age axis (missing last_validated or review_after_days)', age, window };
130
+ }
131
+
132
+ // The tree can never re-confirm these, so an age threshold would evict
133
+ // them on a schedule for a reason nothing can discharge.
134
+ if (verdict === 'unverifiable') {
135
+ return { state: age > window ? 'due' : 'active', reason: 'unverifiable — surfaced on age, never quarantined', age, window };
136
+ }
137
+
138
+ if (inQuarantine) {
139
+ return age > window * 3
140
+ ? { state: 'delete', reason: `in quarantine and ${age}d past a ${window}d window (limit ${window * 3}d)`, age, window }
141
+ : { state: 'quarantine', reason: `in quarantine, ${age}d of ${window * 3}d before deletion`, age, window };
142
+ }
143
+
144
+ if (age > window * 2) {
145
+ return { state: 'quarantine', reason: `${age}d unverified, past two ${window}d windows`, age, window };
146
+ }
147
+ if (age > window) {
148
+ return { state: 'due', reason: `${age}d unverified, past its ${window}d window`, age, window };
149
+ }
150
+ return { state: 'active', reason: `${age}d of ${window}d`, age, window };
151
+ }
152
+
153
+ interface StoreFile {
154
+ abs: string;
155
+ rel: string;
156
+ type: string;
157
+ doc: Record<string, unknown>;
158
+ entries: Array<Record<string, unknown>>;
159
+ }
160
+
161
+ function readStoreFile(abs: string, type: string): StoreFile | null {
162
+ let parsed: unknown;
163
+ try {
164
+ parsed = YAML.parse(fs.readFileSync(abs, 'utf-8'));
165
+ } catch {
166
+ return null;
167
+ }
168
+ if (!isPlainObject(parsed) || !Array.isArray(parsed['entries'])) return null;
169
+ const entries = (parsed['entries'] as unknown[]).filter(isPlainObject) as Array<Record<string, unknown>>;
170
+ return { abs, rel: path.relative(REPO_ROOT, abs), type, doc: parsed, entries };
171
+ }
172
+
173
+ function listStores(storeDir: string): { curated: StoreFile[]; quarantined: StoreFile[] } {
174
+ const base = path.isAbsolute(storeDir) ? storeDir : path.join(REPO_ROOT, storeDir);
175
+ const curated: StoreFile[] = [];
176
+ const quarantined: StoreFile[] = [];
177
+ let names: string[] = [];
178
+ try {
179
+ names = fs.readdirSync(base).filter((n) => n.endsWith('.yml')).sort();
180
+ } catch {
181
+ return { curated, quarantined };
182
+ }
183
+ for (const n of names) {
184
+ const f = readStoreFile(path.join(base, n), n.replace(/\.yml$/, ''));
185
+ if (f) curated.push(f);
186
+ }
187
+ const qDir = path.join(REPO_ROOT, QUARANTINE_ROOT);
188
+ let qNames: string[] = [];
189
+ try {
190
+ qNames = fs.readdirSync(qDir).filter((n) => n.endsWith('.yml')).sort();
191
+ } catch {
192
+ return { curated, quarantined };
193
+ }
194
+ for (const n of qNames) {
195
+ const f = readStoreFile(path.join(qDir, n), n.replace(/\.yml$/, ''));
196
+ if (f) quarantined.push(f);
197
+ }
198
+ return { curated, quarantined };
199
+ }
200
+
201
+ export function plan(storeDir: string, today: Date): LadderPlan {
202
+ const { curated, quarantined } = listStores(storeDir);
203
+ const rows: LadderRow[] = [];
204
+ for (const [files, inQ] of [
205
+ [curated, false],
206
+ [quarantined, true],
207
+ ] as Array<[StoreFile[], boolean]>) {
208
+ for (const f of files) {
209
+ for (const e of f.entries) {
210
+ const c = classify(e, today, inQ);
211
+ rows.push({
212
+ id: typeof e['id'] === 'string' ? (e['id'] as string) : '',
213
+ type: f.type,
214
+ state: c.state,
215
+ reason: c.reason,
216
+ age_days: c.age,
217
+ window_days: c.window,
218
+ semantic_verdict: (typeof e['semantic_verdict'] === 'string'
219
+ ? (e['semantic_verdict'] as SemanticVerdict)
220
+ : null),
221
+ });
222
+ }
223
+ }
224
+ }
225
+ const order: Record<LadderState, number> = { delete: 0, quarantine: 1, due: 2, active: 3 };
226
+ rows.sort((a, b) => order[a.state] - order[b.state] || a.type.localeCompare(b.type) || a.id.localeCompare(b.id));
227
+ return { scanned: rows.length, rows };
228
+ }
229
+
230
+ /**
231
+ * Split a store file into its header and one text block per entry.
232
+ *
233
+ * The move is TEXTUAL, not a YAML round-trip, and that is a deliberate
234
+ * constraint rather than an implementation shortcut. Every body in this store
235
+ * is a folded multi-line scalar; re-emitting the document would reformat all
236
+ * 107 of them and bury a 22-entry move in a whole-file rewrite. A reviewer has
237
+ * to be able to see which entries moved.
238
+ */
239
+ function splitEntries(text: string): { header: string[]; blocks: Array<{ id: string; lines: string[] }> } {
240
+ const lines = text.split('\n');
241
+ const header: string[] = [];
242
+ const blocks: Array<{ id: string; lines: string[] }> = [];
243
+ let current: { id: string; lines: string[] } | null = null;
244
+ for (const line of lines) {
245
+ const m = /^(\s*)-\s+id:\s*(\S+)\s*$/.exec(line);
246
+ if (m) {
247
+ if (current) blocks.push(current);
248
+ current = { id: m[2] ?? '', lines: [line] };
249
+ continue;
250
+ }
251
+ if (current) current.lines.push(line);
252
+ else header.push(line);
253
+ }
254
+ if (current) blocks.push(current);
255
+ return { header, blocks };
256
+ }
257
+
258
+ function readText(abs: string): string | null {
259
+ try {
260
+ return fs.readFileSync(abs, 'utf-8');
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Execute the plan.
268
+ *
269
+ * Demotion is a MOVE between two tracked YAML files, never an in-place status
270
+ * flip: an entry has to leave the injected store to stop being read as fact,
271
+ * and it has to stay readable to be appealable.
272
+ */
273
+ function apply(storeDir: string, today: Date): string[] {
274
+ const { curated, quarantined } = listStores(storeDir);
275
+ const log: string[] = [];
276
+ const qDir = path.join(REPO_ROOT, QUARANTINE_ROOT);
277
+
278
+ for (const f of curated) {
279
+ const demoteIds = new Set(
280
+ f.entries
281
+ .filter((e) => classify(e, today, false).state === 'quarantine')
282
+ .map((e) => String(e['id'])),
283
+ );
284
+ if (demoteIds.size === 0) continue;
285
+
286
+ const text = readText(f.abs);
287
+ if (text == null) continue;
288
+ const { header, blocks } = splitEntries(text);
289
+ const keep = blocks.filter((b) => !demoteIds.has(b.id));
290
+ const move = blocks.filter((b) => demoteIds.has(b.id));
291
+
292
+ fs.mkdirSync(qDir, { recursive: true });
293
+ const qPath = path.join(qDir, `${f.type}.yml`);
294
+ const qText = readText(qPath);
295
+ const qLines = qText == null
296
+ ? [
297
+ '# Quarantined curated-memory entries — demoted by memory_eviction.ts.',
298
+ '# Kept readable on purpose: a demotion is appealable, and an entry',
299
+ '# re-verified against the tree returns to the curated store.',
300
+ 'version: 1',
301
+ 'entries:',
302
+ ]
303
+ : splitEntries(qText).header.filter((l, i, arr) => !(l === '' && i === arr.length - 1));
304
+ const qExisting = qText == null ? [] : splitEntries(qText).blocks;
305
+ const qOut = [...qLines, ...qExisting.flatMap((b) => b.lines), ...move.flatMap((b) => b.lines)];
306
+ fs.writeFileSync(qPath, `${qOut.join('\n').replace(/\n+$/, '')}\n`, 'utf-8');
307
+
308
+ const out = [...header, ...keep.flatMap((b) => b.lines)];
309
+ fs.writeFileSync(f.abs, `${out.join('\n').replace(/\n+$/, '')}\n`, 'utf-8');
310
+ for (const id of [...demoteIds].sort()) log.push(`quarantined ${f.type}/${id}`);
311
+ }
312
+
313
+ for (const f of quarantined) {
314
+ const dropIds = new Set(
315
+ f.entries.filter((e) => classify(e, today, true).state === 'delete').map((e) => String(e['id'])),
316
+ );
317
+ if (dropIds.size === 0) continue;
318
+ const text = readText(f.abs);
319
+ if (text == null) continue;
320
+ const { header, blocks } = splitEntries(text);
321
+ const keep = blocks.filter((b) => !dropIds.has(b.id));
322
+ fs.writeFileSync(f.abs, `${[...header, ...keep.flatMap((b) => b.lines)].join('\n').replace(/\n+$/, '')}\n`, 'utf-8');
323
+ for (const id of [...dropIds].sort()) log.push(`deleted ${f.type}/${id}`);
324
+ }
325
+ return log;
326
+ }
327
+
328
+ function render(p: LadderPlan): string {
329
+ const counts: Record<LadderState, number> = { active: 0, due: 0, quarantine: 0, delete: 0 };
330
+ for (const r of p.rows) counts[r.state] += 1;
331
+ const lines = [
332
+ `${PROG} · dry-run plan — nothing written without --apply`,
333
+ `scanned: ${p.scanned} entr(ies)`,
334
+ `active: ${counts.active} · due: ${counts.due} · quarantine: ${counts.quarantine} · delete: ${counts.delete}`,
335
+ '',
336
+ ];
337
+ for (const r of p.rows) {
338
+ if (r.state === 'active') continue;
339
+ lines.push(` ${r.state.padEnd(10)} ${r.type}/${r.id}`);
340
+ lines.push(` ${r.reason}`);
341
+ }
342
+ return lines.join('\n');
343
+ }
344
+
345
+ export function main(argv: readonly string[]): number {
346
+ let storeDir = DEFAULT_STORE;
347
+ let format = 'text';
348
+ let doApply = false;
349
+ for (let i = 0; i < argv.length; i += 1) {
350
+ const a = argv[i];
351
+ if (a === '--path') storeDir = argv[++i] ?? DEFAULT_STORE;
352
+ else if (a === '--format') format = argv[++i] ?? 'text';
353
+ else if (a === '--apply') doApply = true;
354
+ else if (a === '--help' || a === '-h') {
355
+ process.stdout.write(
356
+ `usage: ${PROG} [--path DIR] [--format text|json] [--apply]\n` +
357
+ `Eviction ladder for the curated memory store: due → quarantine → delete.\n` +
358
+ `Driven by recorded human verdicts and age. Never by pointer output.\n` +
359
+ `Dry-run unless --apply.\n`,
360
+ );
361
+ return 0;
362
+ }
363
+ }
364
+
365
+ const today = new Date();
366
+ const p = plan(storeDir, today);
367
+ if (doApply) {
368
+ const log = apply(storeDir, today);
369
+ process.stdout.write(log.length ? `${log.join('\n')}\n` : 'nothing to demote or delete\n');
370
+ return 0;
371
+ }
372
+ process.stdout.write(format === 'json' ? `${JSON.stringify(p, null, 2)}\n` : `${render(p)}\n`);
373
+ return 0;
374
+ }
375
+
376
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
377
+ if (invokedDirectly) {
378
+ try {
379
+ process.exit(main(process.argv.slice(2)));
380
+ } catch (e) {
381
+ process.stderr.write(`${PROG}: internal error: ${(e as Error).message}\n`);
382
+ process.exit(3);
383
+ }
384
+ }