@event4u/agent-config 14.5.0 → 14.6.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +128 -0
  3. package/dist/agent-src/commands/analyze/decision.md +7 -0
  4. package/dist/agent-src/commands/optimize/project.md +10 -0
  5. package/dist/agent-src/commands/roadmap/process-full.md +45 -3
  6. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
  7. package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
  8. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  9. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
  12. package/dist/cli-delegate/cmd_conformance.js +1 -1
  13. package/dist/cli-delegate/cmd_handoff.js +1 -1
  14. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +8 -8
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +334 -77
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/router.json +1 -1
  28. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  29. package/package.json +1 -1
  30. package/src/config/estate-count-budget.json +29 -1
  31. package/src/config/pack-size-budget.json +3 -2
  32. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  33. package/src/scripts/_lib/scope_guard.sh +62 -3
  34. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  35. package/src/scripts/adr/regenerate_index.ts +13 -2
  36. package/src/scripts/adr_cite_check.ts +424 -0
  37. package/src/scripts/check_adr_frontmatter.ts +187 -1
  38. package/src/scripts/check_single_delivery.ts +473 -0
  39. package/src/scripts/hooks/run_continuation_hook.ts +884 -68
  40. package/src/scripts/install.sh +12 -2
  41. package/src/scripts/memory_consolidation.ts +189 -0
  42. package/src/scripts/memory_eviction.ts +384 -0
  43. package/src/scripts/report_layer_overlap.ts +72 -0
  44. package/src/scripts/report_memory_pointers.ts +622 -0
  45. package/src/scripts/session_register_hook.ts +104 -15
@@ -783,8 +783,18 @@ run_scope_guard() {
783
783
  if [[ "$verdict" == "WARN" ]]; then
784
784
  $QUIET || {
785
785
  echo ""
786
- echo " ⚠️ Cross-scope install detected (same version, no drift)."
787
- echo "$output" | awk -F'\t' '$1=="WARN" { printf " - %s at %s (v%s)\n", $2, $3, $4 }'
786
+ echo " ⚠️ Cross-scope install detected — same version, so no drift, but NOT free:"
787
+ echo " the host loads both registrations with no dedup, so every shared entry"
788
+ echo " below is delivered TWICE in every session."
789
+ # Field 6 is the overlap count; -1 means it could not be taken (a
790
+ # single-file probe such as copilot), which is printed as such rather
791
+ # than rendered as zero.
792
+ echo "$output" | awk -F'\t' '$1=="WARN" {
793
+ if (NF >= 6 && $6 != "-1") printf " - %s at %s (v%s) — %s entr%s in both scopes\n", $2, $3, $4, $6, ($6 == 1 ? "y" : "ies");
794
+ else printf " - %s at %s (v%s) — overlap not counted (this scope is empty or unreadable; a first install here has nothing to double yet)\n", $2, $3, $4;
795
+ }'
796
+ echo ""
797
+ echo " Pick one scope if you can: ADR-236 (one artefact, one layer)."
788
798
  echo ""
789
799
  }
790
800
  return 0
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * report_layer_overlap — print what writing the project layer costs, if anything.
3
+ *
4
+ * Runs as its own step of `task generate-tools` rather than as a call inside
5
+ * `condense.ts`, and the reason is a gate rather than taste: `condense.ts` is
6
+ * already over `check_source_size_budget`'s 1500-line ceiling, and that ratchet is
7
+ * shrink-only — an import plus a call there is +2 lines it refuses, and re-pinning
8
+ * the baseline is named a defect in the gate's own message. So the notice lives
9
+ * beside the task it belongs to.
10
+ *
11
+ * WHY THE NOTICE EXISTS. `generate-tools` writes ONE of the two layers Claude Code
12
+ * loads and was silent about the other existing, while the installer's overlap gate
13
+ * runs at install time and cannot see a layer written afterwards. The overlap is
14
+ * therefore created by whichever producer runs LAST, and neither said so.
15
+ *
16
+ * ADVISORY BY CONSTRUCTION: always exits 0. The build must not fail on a topology
17
+ * the operator may not be able to change today — that would make the build unusable
18
+ * rather than the duplication visible. The check that can refuse is
19
+ * `check_single_delivery --enforce`. Invariant: ADR-236.
20
+ */
21
+
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import process from 'node:process';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+
27
+ import { warnLayerOverlap } from './_lib/layer_overlap_notice.js';
28
+
29
+ const _HERE = fileURLToPath(import.meta.url);
30
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
31
+
32
+ export function main(argv?: readonly string[]): number {
33
+ const args = argv ?? process.argv.slice(2);
34
+ let projectRoot = REPO_ROOT;
35
+ for (let i = 0; i < args.length; i += 1) {
36
+ const a = args[i];
37
+ if (a === '--project') {
38
+ const v = args[i + 1];
39
+ if (v === undefined || v.startsWith('-')) {
40
+ process.stderr.write('report_layer_overlap: --project needs a directory\n');
41
+ return 1;
42
+ }
43
+ projectRoot = path.resolve(v);
44
+ i += 1;
45
+ } else if (a === '--help' || a === '-h') {
46
+ process.stdout.write('usage: report_layer_overlap [--project DIR]\n');
47
+ return 0;
48
+ } else if (a !== undefined) {
49
+ // Unrecognised arguments are an error even here, where the exit code is
50
+ // otherwise always 0: a silently ignored typo in a build step is how a
51
+ // notice stops firing without anyone noticing.
52
+ process.stderr.write(`report_layer_overlap: unexpected argument ${a}\n`);
53
+ return 1;
54
+ }
55
+ }
56
+ warnLayerOverlap(projectRoot, (m) => process.stdout.write(`${m}\n`));
57
+ return 0;
58
+ }
59
+
60
+ function _isCliEntry(): boolean {
61
+ if (process.argv[1] === undefined) return false;
62
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
63
+ try {
64
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ if (_isCliEntry()) {
71
+ process.exit(main());
72
+ }