@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +384 -3
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.md +1 -1
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +132 -4
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +45 -2
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
- package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_conformance.js +459 -709
- package/dist/cli-delegate/cmd_doctor.js +6 -6
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +7 -6
- package/dist/cli-delegate/cmd_handoff.js +6 -6
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +7 -6
- package/dist/cli-delegate/cmd_session_recycle.js +840 -30
- package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +7 -6
- package/dist/cli-delegate/cmd_uninstall.js +9 -7
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +7 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +72 -29
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +3127 -2022
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +276 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1744 -1191
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- package/dist/scripts/condense.js +13 -164
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +18 -0
- package/src/config/estate-count-budget.json +367 -3
- package/src/config/gate-violation-baselines.json +41 -41
- package/src/config/pack-size-budget.json +3 -2
- package/src/config/rule-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +19 -6
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +325 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +6 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +38 -8
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/scope_guard.sh +86 -4
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -0
- package/src/scripts/_lib/subagent_capsule.ts +81 -1
- package/src/scripts/adr/regenerate_index.ts +13 -2
- package/src/scripts/adr_cite_check.ts +424 -0
- package/src/scripts/ai_council/cli_help.ts +55 -0
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- package/src/scripts/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_references.ts +60 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_single_delivery.ts +517 -0
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +20 -170
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +887 -68
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +782 -20
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/install.sh +12 -2
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +78 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +115 -15
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +7 -0
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- /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/
|
|
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
|
|
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 (
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|