@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +128 -0
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +45 -3
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +334 -77
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/package.json +1 -1
- package/src/config/estate-count-budget.json +29 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/scope_guard.sh +62 -3
- 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/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_single_delivery.ts +473 -0
- package/src/scripts/hooks/run_continuation_hook.ts +884 -68
- package/src/scripts/install.sh +12 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/report_layer_overlap.ts +72 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- package/src/scripts/session_register_hook.ts +104 -15
|
@@ -51,6 +51,92 @@ export const REVIEW_TRIGGER_SINCE = '2026-07-25';
|
|
|
51
51
|
/** Fields every ADR must carry — the shape that already exists in practice. */
|
|
52
52
|
const REQUIRED = ['adr', 'status', 'date', 'decision'] as const;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Reopen-authority vocabulary (`adr-layout.md` § Reopen authority).
|
|
56
|
+
*
|
|
57
|
+
* Both fields are OPTIONAL and stay optional. An absent `reopen_policy`
|
|
58
|
+
* resolves to `unclassified` at the reader, never to `owner`: with 146 accepted
|
|
59
|
+
* ADRs a fail-closed default would encode the existing blockage into the new
|
|
60
|
+
* schema, which is the one thing both council seats (2026-08-19) rejected in
|
|
61
|
+
* the same words. This validator therefore checks the VALUE when present and
|
|
62
|
+
* never requires the KEY.
|
|
63
|
+
*/
|
|
64
|
+
const ALLOWED_REOPEN_POLICY = new Set(['directional', 'owner', 'unclassified']);
|
|
65
|
+
|
|
66
|
+
const ALLOWED_PROTECTED_DIMENSIONS = new Set([
|
|
67
|
+
'purpose',
|
|
68
|
+
'security_floor',
|
|
69
|
+
'privacy_floor',
|
|
70
|
+
'external_commitment',
|
|
71
|
+
'governance',
|
|
72
|
+
'none',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Split an inline `[a, b]` list or a folded multi-value string into members. */
|
|
76
|
+
export function split_dimensions(raw: string): string[] {
|
|
77
|
+
return raw
|
|
78
|
+
.replace(/^\[/, '')
|
|
79
|
+
.replace(/\]$/, '')
|
|
80
|
+
.split(/[,\s]+/)
|
|
81
|
+
.map((s) =>
|
|
82
|
+
s
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/^-\s*/, '')
|
|
85
|
+
.replace(/^["'](.*)["']$/, '$1'),
|
|
86
|
+
)
|
|
87
|
+
.filter((s) => s !== '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate the two optional reopen-authority fields — value only, never
|
|
92
|
+
* presence. A wrong enum is a real error (it silently mis-routes authority);
|
|
93
|
+
* an absent field is the documented default and not a finding.
|
|
94
|
+
*/
|
|
95
|
+
export function check_reopen_authority(
|
|
96
|
+
rel: string,
|
|
97
|
+
fm: Record<string, string>,
|
|
98
|
+
findings: AdrFinding[],
|
|
99
|
+
): void {
|
|
100
|
+
const policy = fm['reopen_policy'];
|
|
101
|
+
if (policy !== undefined && policy !== '' && !ALLOWED_REOPEN_POLICY.has(policy)) {
|
|
102
|
+
findings.push({
|
|
103
|
+
file: rel,
|
|
104
|
+
level: 'error',
|
|
105
|
+
message:
|
|
106
|
+
`\`reopen_policy\` \`${policy}\` is not one of: ` +
|
|
107
|
+
`${[...ALLOWED_REOPEN_POLICY].sort().join(', ')} (absent = unclassified)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dims = fm['protected_dimensions'];
|
|
112
|
+
if (dims !== undefined && dims !== '') {
|
|
113
|
+
for (const d of split_dimensions(dims)) {
|
|
114
|
+
if (!ALLOWED_PROTECTED_DIMENSIONS.has(d)) {
|
|
115
|
+
findings.push({
|
|
116
|
+
file: rel,
|
|
117
|
+
level: 'error',
|
|
118
|
+
message:
|
|
119
|
+
`\`protected_dimensions\` member \`${d}\` is not one of: ` +
|
|
120
|
+
`${[...ALLOWED_PROTECTED_DIMENSIONS].sort().join(', ')}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `owner` without a named dimension is the frozen-over-classification shape
|
|
127
|
+
// the contract warns about: it reserves every future transition, including
|
|
128
|
+
// the ones that only strengthen the decision, and records no reason.
|
|
129
|
+
if (policy === 'owner' && (dims === undefined || dims === '')) {
|
|
130
|
+
findings.push({
|
|
131
|
+
file: rel,
|
|
132
|
+
level: 'warn',
|
|
133
|
+
message:
|
|
134
|
+
'`reopen_policy: owner` without `protected_dimensions` — name the reserved ' +
|
|
135
|
+
'interest, or the reservation cannot be checked against a proposed transition',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
54
140
|
const ALLOWED_STATUS = new Set([
|
|
55
141
|
'accepted',
|
|
56
142
|
'proposed',
|
|
@@ -141,6 +227,98 @@ export function check_one(rel: string, text: string): AdrFinding[] {
|
|
|
141
227
|
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
142
228
|
});
|
|
143
229
|
}
|
|
230
|
+
check_reopen_authority(rel, fm, findings);
|
|
231
|
+
check_amendment_shape(rel, fm, findings);
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `ADR-035`, `035`, `35` → `35`. Returns null when nothing resolves. */
|
|
236
|
+
export function adr_number(raw: string): string | null {
|
|
237
|
+
const m = /(\d{1,4})/.exec(raw.trim());
|
|
238
|
+
return m?.[1] === undefined ? null : String(Number(m[1]));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Validate `amends:` / `amended_by:` shape. The reciprocal half is checked in
|
|
243
|
+
* `check_amendment_links` (it needs the whole corpus); this one only rejects a
|
|
244
|
+
* value that is neither the em-dash placeholder nor an ADR reference, so a
|
|
245
|
+
* typo'd link is caught before it becomes an invisible one-sided amendment.
|
|
246
|
+
*/
|
|
247
|
+
export function check_amendment_shape(
|
|
248
|
+
rel: string,
|
|
249
|
+
fm: Record<string, string>,
|
|
250
|
+
findings: AdrFinding[],
|
|
251
|
+
): void {
|
|
252
|
+
for (const key of ['amends', 'amended_by'] as const) {
|
|
253
|
+
const v = fm[key];
|
|
254
|
+
if (v === undefined || v === '' || v === '—') continue;
|
|
255
|
+
for (const part of v.split(',')) {
|
|
256
|
+
if (adr_number(part) === null) {
|
|
257
|
+
findings.push({
|
|
258
|
+
file: rel,
|
|
259
|
+
level: 'error',
|
|
260
|
+
message: `\`${key}\` value \`${part.trim()}\` names no ADR number`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Corpus-level check: every `amends:` has its reciprocal `amended_by:` and vice
|
|
269
|
+
* versa. This is the half the `supersedes:` / `superseded_by:` pair never had —
|
|
270
|
+
* and its absence is exactly how ADR-035 kept asserting a rejection ADR-232 had
|
|
271
|
+
* reopened. A one-sided link is invisible from the side that is stale, which is
|
|
272
|
+
* the side a reader lands on first.
|
|
273
|
+
*/
|
|
274
|
+
export function check_amendment_links(
|
|
275
|
+
files: { rel: string; fm: Record<string, string> }[],
|
|
276
|
+
): AdrFinding[] {
|
|
277
|
+
const findings: AdrFinding[] = [];
|
|
278
|
+
const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
|
|
279
|
+
for (const f of files) {
|
|
280
|
+
const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
281
|
+
if (n !== null) byNumber.set(n, f);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const reciprocal = { amends: 'amended_by', amended_by: 'amends' } as const;
|
|
285
|
+
for (const f of files) {
|
|
286
|
+
for (const key of ['amends', 'amended_by'] as const) {
|
|
287
|
+
const v = f.fm[key];
|
|
288
|
+
if (v === undefined || v === '' || v === '—') continue;
|
|
289
|
+
const self = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
290
|
+
for (const part of v.split(',')) {
|
|
291
|
+
const target = adr_number(part);
|
|
292
|
+
if (target === null) continue;
|
|
293
|
+
const other = byNumber.get(target);
|
|
294
|
+
if (other === undefined) {
|
|
295
|
+
findings.push({
|
|
296
|
+
file: f.rel,
|
|
297
|
+
level: 'error',
|
|
298
|
+
message: `\`${key}: ${part.trim()}\` points at an ADR that does not exist`,
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const back = other.fm[reciprocal[key]] ?? '';
|
|
303
|
+
const names_self =
|
|
304
|
+
self !== null &&
|
|
305
|
+
back
|
|
306
|
+
.split(',')
|
|
307
|
+
.map((s) => adr_number(s))
|
|
308
|
+
.includes(self);
|
|
309
|
+
if (!names_self) {
|
|
310
|
+
findings.push({
|
|
311
|
+
file: f.rel,
|
|
312
|
+
level: 'error',
|
|
313
|
+
message:
|
|
314
|
+
`\`${key}: ${part.trim()}\` is one-sided — ${other.rel} carries no ` +
|
|
315
|
+
`reciprocal \`${reciprocal[key]}\`. A one-sided amendment link is ` +
|
|
316
|
+
`invisible from the stale side.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
144
322
|
return findings;
|
|
145
323
|
}
|
|
146
324
|
|
|
@@ -163,13 +341,20 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
|
|
|
163
341
|
const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
|
|
164
342
|
ledger?.plan(candidates);
|
|
165
343
|
|
|
344
|
+
// Collected for the corpus-level reciprocal-amendment check below, which
|
|
345
|
+
// cannot be decided from one file.
|
|
346
|
+
const corpus: { rel: string; fm: Record<string, string> }[] = [];
|
|
347
|
+
|
|
166
348
|
for (const name of candidates) {
|
|
167
349
|
if (!/^ADR-.*\.md$/.test(name)) {
|
|
168
350
|
ledger?.outOfScope(name, 'not_applicable_kind');
|
|
169
351
|
continue;
|
|
170
352
|
}
|
|
171
353
|
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
172
|
-
const
|
|
354
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf-8');
|
|
355
|
+
const parsed = parse_frontmatter(text);
|
|
356
|
+
if (parsed !== null) corpus.push({ rel, fm: parsed });
|
|
357
|
+
const own = check_one(rel, text);
|
|
173
358
|
findings.push(...own);
|
|
174
359
|
|
|
175
360
|
const errors = own.filter((f) => f.level === 'error').length;
|
|
@@ -179,6 +364,7 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
|
|
|
179
364
|
ledger?.complete(name);
|
|
180
365
|
}
|
|
181
366
|
}
|
|
367
|
+
findings.push(...check_amendment_links(corpus));
|
|
182
368
|
return findings;
|
|
183
369
|
}
|
|
184
370
|
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check_single_delivery — assert that no artefact is delivered from both layers.
|
|
3
|
+
*
|
|
4
|
+
* THE INVARIANT (ADR-236, one-artefact-one-layer): every rule and every skill is
|
|
5
|
+
* delivered from exactly ONE layer. Claude Code loads `~/.claude/**` AND
|
|
6
|
+
* `<project>/.claude/**`, both, user layer first, with no dedup — so an artefact
|
|
7
|
+
* present in both is delivered twice, in every session, forever.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS AS A SEPARATE CHECK, rather than inside a producer. Two
|
|
10
|
+
* producers write these layers and neither can see the other:
|
|
11
|
+
*
|
|
12
|
+
* - the INSTALLER writes the global layer, and `_gate_rule_layer_overlap`
|
|
13
|
+
* (install.ts) checks the overlap at install time — rules only, Claude only,
|
|
14
|
+
* and never again afterwards;
|
|
15
|
+
* - `task generate-tools` writes the project layer, and contains zero
|
|
16
|
+
* references to that gate or to `rule_layer_overlap`.
|
|
17
|
+
*
|
|
18
|
+
* So the overlap is created by whichever producer runs LAST, and an install-time
|
|
19
|
+
* gate cannot see a layer written afterwards. This check is deliberately outside
|
|
20
|
+
* both: it reads the filesystem and does not care who wrote it.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT COUNTS, and why the two halves are separate:
|
|
23
|
+
*
|
|
24
|
+
* 1. RAW OVERLAP — names present in both layers. The token cost.
|
|
25
|
+
* 2. SCOPE DEFEAT — shared rules where one copy carries `paths:` and the other
|
|
26
|
+
* does not. `report_carrier_divergence` names the mechanism: a copy that
|
|
27
|
+
* LACKS `paths:` defeats the other copy's scoping, so the rule loads
|
|
28
|
+
* unconditionally however carefully the other copy was scoped. This is a
|
|
29
|
+
* correctness fact rather than a token fact, and it needs a different
|
|
30
|
+
* remedy, so it is never folded into the overlap number.
|
|
31
|
+
*
|
|
32
|
+
* REPORT BY DEFAULT, ENFORCE ON REQUEST — and that is not timidity. The
|
|
33
|
+
* invariant is not true yet: `road-to-single-delivery` Phase 2, which makes the
|
|
34
|
+
* producers write disjoint layers, is HALTED on blocker
|
|
35
|
+
* `partition-requires-global-layer`. A blocking default would therefore red every
|
|
36
|
+
* run on a defect nobody can currently fix, which teaches readers to ignore it —
|
|
37
|
+
* the exact failure this estate has recorded twice already. Pass `--enforce` once
|
|
38
|
+
* Phase 2 lands.
|
|
39
|
+
*
|
|
40
|
+
* A LAYER THAT DOES NOT EXIST IS NOT A PASS. Where a layer is absent there is no
|
|
41
|
+
* overlap to find, and saying "invariant holds" would be a gate reading nothing
|
|
42
|
+
* and reporting green. Absent layers are named, and `--enforce` refuses rather
|
|
43
|
+
* than passing when BOTH layers of every type are missing.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import * as fs from 'node:fs';
|
|
47
|
+
import * as os from 'node:os';
|
|
48
|
+
import * as path from 'node:path';
|
|
49
|
+
import process from 'node:process';
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
51
|
+
|
|
52
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
53
|
+
|
|
54
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
55
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
56
|
+
|
|
57
|
+
/** Artefact types the host loads from both layers. */
|
|
58
|
+
const TYPES = ['rules', 'skills', 'commands', 'agents'] as const;
|
|
59
|
+
type ArtefactType = (typeof TYPES)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a layer's entries physically ARE — symlinks, directories, or regular files.
|
|
63
|
+
*
|
|
64
|
+
* R2 finding 2: without this, name-equality was reported as "delivered twice" for
|
|
65
|
+
* two layers holding entirely different payload shapes. Measured on this repo:
|
|
66
|
+
* project `.claude/commands/` is **40 directories** of symlinks into
|
|
67
|
+
* `src/domains/**` while global `.claude/commands/` is **52 regular files**. Those
|
|
68
|
+
* 40 shared names are not 40 duplicated artefacts in the sense the rules row means,
|
|
69
|
+
* and a check that prints one number for both teaches the reader they are.
|
|
70
|
+
*/
|
|
71
|
+
export interface LayerShape {
|
|
72
|
+
symlinks: number;
|
|
73
|
+
dirs: number;
|
|
74
|
+
files: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TypeReading {
|
|
78
|
+
type: ArtefactType;
|
|
79
|
+
/** null when that layer's directory does not exist — distinct from empty. */
|
|
80
|
+
globalNames: string[] | null;
|
|
81
|
+
projectNames: string[] | null;
|
|
82
|
+
globalShape: LayerShape | null;
|
|
83
|
+
projectShape: LayerShape | null;
|
|
84
|
+
/**
|
|
85
|
+
* True when the two layers' dominant entry shape differs, so a shared NAME is
|
|
86
|
+
* not evidence of a shared artefact. The overlap is still reported — it is
|
|
87
|
+
* still a delivery collision — but it is labelled, never summed with the rest.
|
|
88
|
+
*/
|
|
89
|
+
shapeMismatch: boolean;
|
|
90
|
+
both: string[];
|
|
91
|
+
globalOnly: string[];
|
|
92
|
+
projectOnly: string[];
|
|
93
|
+
/** Shared rules whose two copies disagree on `paths:`. Rules only. */
|
|
94
|
+
scopeDefeat: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dominant(s: LayerShape): 'symlink' | 'dir' | 'file' | 'empty' {
|
|
98
|
+
const max = Math.max(s.symlinks, s.dirs, s.files);
|
|
99
|
+
if (max === 0) return 'empty';
|
|
100
|
+
if (s.symlinks === max) return 'symlink';
|
|
101
|
+
if (s.dirs === max) return 'dir';
|
|
102
|
+
return 'file';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface LayerReading {
|
|
106
|
+
names: string[];
|
|
107
|
+
shape: LayerShape;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readLayer(dir: string): LayerReading | null {
|
|
111
|
+
let entries: fs.Dirent[];
|
|
112
|
+
try {
|
|
113
|
+
if (!fs.statSync(dir).isDirectory()) return null;
|
|
114
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const shape: LayerShape = { symlinks: 0, dirs: 0, files: 0 };
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
// isSymbolicLink() is checked FIRST and deliberately: readdir with
|
|
121
|
+
// withFileTypes does not follow links, but a reader who tested isDirectory
|
|
122
|
+
// first would classify a symlink-to-directory as a directory and lose the
|
|
123
|
+
// distinction this whole field exists for.
|
|
124
|
+
if (e.isSymbolicLink()) shape.symlinks += 1;
|
|
125
|
+
else if (e.isDirectory()) shape.dirs += 1;
|
|
126
|
+
else shape.files += 1;
|
|
127
|
+
}
|
|
128
|
+
return { names: entries.map((e) => e.name).sort(), shape };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Does this rule file declare a `paths:` key in its frontmatter?
|
|
133
|
+
*
|
|
134
|
+
* Frontmatter only, deliberately: a `paths:` mentioned in prose is not a load
|
|
135
|
+
* schedule, and matching it would manufacture scope-defeat findings out of
|
|
136
|
+
* documentation.
|
|
137
|
+
*/
|
|
138
|
+
export function declaresPaths(file: string): 'yes' | 'no' | 'unreadable' {
|
|
139
|
+
// Three-valued on purpose (R2 finding): a boolean collapsed "no paths: key"
|
|
140
|
+
// and "could not read the file" into the same answer, so an unreadable copy
|
|
141
|
+
// silently matched an unscoped one and the pair vanished from the scope-defeat
|
|
142
|
+
// set — the direction that hides a finding rather than inventing one.
|
|
143
|
+
let text: string;
|
|
144
|
+
try {
|
|
145
|
+
text = fs.readFileSync(file, 'utf8');
|
|
146
|
+
} catch {
|
|
147
|
+
return 'unreadable';
|
|
148
|
+
}
|
|
149
|
+
if (!text.startsWith('---')) return 'no';
|
|
150
|
+
const end = text.indexOf('\n---', 3);
|
|
151
|
+
if (end < 0) return 'no';
|
|
152
|
+
return /^paths:/m.test(text.slice(3, end)) ? 'yes' : 'no';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function readType(type: ArtefactType, globalRoot: string, projectRoot: string): TypeReading {
|
|
156
|
+
const gDir = path.join(globalRoot, type);
|
|
157
|
+
const pDir = path.join(projectRoot, type);
|
|
158
|
+
const gLayer = readLayer(gDir);
|
|
159
|
+
const pLayer = readLayer(pDir);
|
|
160
|
+
const globalNames = gLayer?.names ?? null;
|
|
161
|
+
const projectNames = pLayer?.names ?? null;
|
|
162
|
+
const globalShape = gLayer?.shape ?? null;
|
|
163
|
+
const projectShape = pLayer?.shape ?? null;
|
|
164
|
+
const shapeMismatch =
|
|
165
|
+
gLayer !== null && pLayer !== null && dominant(gLayer.shape) !== dominant(pLayer.shape);
|
|
166
|
+
const g = new Set(globalNames ?? []);
|
|
167
|
+
const p = new Set(projectNames ?? []);
|
|
168
|
+
|
|
169
|
+
const both = [...g].filter((n) => p.has(n)).sort();
|
|
170
|
+
const globalOnly = [...g].filter((n) => !p.has(n)).sort();
|
|
171
|
+
const projectOnly = [...p].filter((n) => !g.has(n)).sort();
|
|
172
|
+
|
|
173
|
+
// Scope defeat is a rules-only property: `paths:` is the one frontmatter key
|
|
174
|
+
// this host reads as a load schedule.
|
|
175
|
+
const scopeDefeat =
|
|
176
|
+
type === 'rules'
|
|
177
|
+
? both
|
|
178
|
+
.filter((n) => n.endsWith('.md'))
|
|
179
|
+
.filter((n) => {
|
|
180
|
+
const g2 = declaresPaths(path.join(gDir, n));
|
|
181
|
+
const p2 = declaresPaths(path.join(pDir, n));
|
|
182
|
+
// An unreadable copy is reported as a disagreement rather
|
|
183
|
+
// than dropped: not knowing whether a rule is scoped is a
|
|
184
|
+
// finding, and the alternative silently shrinks the set.
|
|
185
|
+
if (g2 === 'unreadable' || p2 === 'unreadable') return true;
|
|
186
|
+
return g2 !== p2;
|
|
187
|
+
})
|
|
188
|
+
.sort()
|
|
189
|
+
: [];
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
type,
|
|
193
|
+
globalNames,
|
|
194
|
+
projectNames,
|
|
195
|
+
globalShape,
|
|
196
|
+
projectShape,
|
|
197
|
+
shapeMismatch,
|
|
198
|
+
both,
|
|
199
|
+
globalOnly,
|
|
200
|
+
projectOnly,
|
|
201
|
+
scopeDefeat,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface Verdict {
|
|
206
|
+
readings: TypeReading[];
|
|
207
|
+
/**
|
|
208
|
+
* Shared names in types where BOTH layers hold the same dominant shape — the
|
|
209
|
+
* number that means "this artefact is delivered twice".
|
|
210
|
+
*/
|
|
211
|
+
duplicated: number;
|
|
212
|
+
/**
|
|
213
|
+
* Shared names in types whose layers hold DIFFERENT shapes. Still a delivery
|
|
214
|
+
* collision on the same name, but not the same claim, so it is reported on its
|
|
215
|
+
* own line and never summed into `duplicated` (R2 finding 2).
|
|
216
|
+
*/
|
|
217
|
+
nameOverlapDifferentShape: number;
|
|
218
|
+
/** Shared rules whose copies disagree on `paths:`. */
|
|
219
|
+
defeated: number;
|
|
220
|
+
/** Types where at least one layer's directory is missing. */
|
|
221
|
+
absent: string[];
|
|
222
|
+
/**
|
|
223
|
+
* Types where BOTH layers were present, i.e. where a comparison actually
|
|
224
|
+
* happened. Carried explicitly rather than derived as
|
|
225
|
+
* `readings.length - absent.length`: `absent` counts LAYERS (up to two per
|
|
226
|
+
* type) while readings counts TYPES, so that subtraction goes negative. It
|
|
227
|
+
* printed `types_compared=-2` before this field existed.
|
|
228
|
+
*/
|
|
229
|
+
typesCompared: number;
|
|
230
|
+
/**
|
|
231
|
+
* Entry names read across every present layer — the gate's dead-scope unit.
|
|
232
|
+
*
|
|
233
|
+
* Deliberately NOT `typesCompared`: a machine with only one layer is a normal
|
|
234
|
+
* consumer state, and crashing the gate there would punish the topology this
|
|
235
|
+
* roadmap is trying to reach. Zero names read, on the other hand, means every
|
|
236
|
+
* directory the gate knows about is gone, which is blindness rather than
|
|
237
|
+
* cleanliness — so it has no `allowEmpty` reason and `reportScanned` throws.
|
|
238
|
+
*/
|
|
239
|
+
namesRead: number;
|
|
240
|
+
/** True when no type had BOTH layers present — nothing was actually compared. */
|
|
241
|
+
readNothing: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function evaluate(globalRoot: string, projectRoot: string): Verdict {
|
|
245
|
+
const readings = TYPES.map((t) => readType(t, globalRoot, projectRoot));
|
|
246
|
+
const absent: string[] = [];
|
|
247
|
+
let compared = 0;
|
|
248
|
+
for (const r of readings) {
|
|
249
|
+
if (r.globalNames === null) absent.push(`${r.type} (global)`);
|
|
250
|
+
if (r.projectNames === null) absent.push(`${r.type} (project)`);
|
|
251
|
+
if (r.globalNames !== null && r.projectNames !== null) compared += 1;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
readings,
|
|
255
|
+
duplicated: readings.reduce((n, r) => n + (r.shapeMismatch ? 0 : r.both.length), 0),
|
|
256
|
+
nameOverlapDifferentShape: readings.reduce(
|
|
257
|
+
(n, r) => n + (r.shapeMismatch ? r.both.length : 0),
|
|
258
|
+
0,
|
|
259
|
+
),
|
|
260
|
+
defeated: readings.reduce((n, r) => n + r.scopeDefeat.length, 0),
|
|
261
|
+
absent,
|
|
262
|
+
typesCompared: compared,
|
|
263
|
+
namesRead: readings.reduce(
|
|
264
|
+
(n, r) => n + (r.globalNames?.length ?? 0) + (r.projectNames?.length ?? 0),
|
|
265
|
+
0,
|
|
266
|
+
),
|
|
267
|
+
readNothing: compared === 0,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function render(v: Verdict, globalRoot: string, projectRoot: string): string {
|
|
272
|
+
const out: string[] = [];
|
|
273
|
+
out.push('check_single_delivery · invariant: one artefact, one layer (ADR-236)');
|
|
274
|
+
out.push(` global: ${globalRoot}`);
|
|
275
|
+
out.push(` project: ${projectRoot}`);
|
|
276
|
+
out.push('');
|
|
277
|
+
const shapeOf = (s: LayerShape | null): string =>
|
|
278
|
+
s === null ? 'absent' : `${dominant(s)} (l${s.symlinks}/d${s.dirs}/f${s.files})`;
|
|
279
|
+
for (const r of v.readings) {
|
|
280
|
+
const g = r.globalNames === null ? 'absent' : String(r.globalNames.length);
|
|
281
|
+
const p = r.projectNames === null ? 'absent' : String(r.projectNames.length);
|
|
282
|
+
out.push(
|
|
283
|
+
` ${r.type.padEnd(9)} global ${g.padStart(6)} · project ${p.padStart(6)}` +
|
|
284
|
+
` · BOTH ${String(r.both.length).padStart(4)}` +
|
|
285
|
+
` · global-only ${String(r.globalOnly.length).padStart(4)}` +
|
|
286
|
+
` · project-only ${String(r.projectOnly.length).padStart(4)}`,
|
|
287
|
+
);
|
|
288
|
+
out.push(` shape: global ${shapeOf(r.globalShape)} · project ${shapeOf(r.projectShape)}`);
|
|
289
|
+
if (r.shapeMismatch && r.both.length > 0) {
|
|
290
|
+
out.push(
|
|
291
|
+
` ⚠️ SHAPE MISMATCH — this check cannot establish that the` +
|
|
292
|
+
` ${r.both.length} shared name(s) are the same artefact.`,
|
|
293
|
+
);
|
|
294
|
+
out.push(
|
|
295
|
+
' It does not establish that they differ either: a project symlink into' +
|
|
296
|
+
' dist/ and a real',
|
|
297
|
+
);
|
|
298
|
+
out.push(
|
|
299
|
+
' directory installed globally can hold identical content reached two' +
|
|
300
|
+
' ways. Counted on its own',
|
|
301
|
+
);
|
|
302
|
+
out.push(
|
|
303
|
+
' line rather than as duplication, because only the same-shape rows' +
|
|
304
|
+
' support that claim.',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (r.scopeDefeat.length > 0) {
|
|
308
|
+
out.push(
|
|
309
|
+
` scope defeat: ${r.scopeDefeat.length} shared rule(s) where one copy` +
|
|
310
|
+
' carries `paths:` and the other does not —',
|
|
311
|
+
);
|
|
312
|
+
out.push(
|
|
313
|
+
' the unscoped copy DEFEATS the other copy\'s scoping, so the rule' +
|
|
314
|
+
' loads unconditionally:',
|
|
315
|
+
);
|
|
316
|
+
for (const n of r.scopeDefeat) out.push(` - ${n}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (v.absent.length > 0) {
|
|
320
|
+
out.push('');
|
|
321
|
+
out.push(` layers not present (nothing to compare there): ${v.absent.join(', ')}`);
|
|
322
|
+
}
|
|
323
|
+
out.push('');
|
|
324
|
+
out.push(
|
|
325
|
+
`check_single_delivery ledger: duplicated=${v.duplicated}` +
|
|
326
|
+
` name_overlap_different_shape=${v.nameOverlapDifferentShape}` +
|
|
327
|
+
` scope_defeat=${v.defeated}` +
|
|
328
|
+
` types_compared=${v.typesCompared} of ${v.readings.length}` +
|
|
329
|
+
` names_read=${v.namesRead}`,
|
|
330
|
+
);
|
|
331
|
+
return out.join('\n');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function main(argv?: readonly string[]): number {
|
|
335
|
+
const args = argv ?? process.argv.slice(2);
|
|
336
|
+
let globalRoot = path.join(os.homedir(), '.claude');
|
|
337
|
+
let projectRoot = path.join(REPO_ROOT, '.claude');
|
|
338
|
+
let enforce = false;
|
|
339
|
+
|
|
340
|
+
const value = (i: number): string | null => {
|
|
341
|
+
const v = args[i + 1];
|
|
342
|
+
return v === undefined || v.startsWith('-') ? null : v;
|
|
343
|
+
};
|
|
344
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
345
|
+
const a = args[i];
|
|
346
|
+
if (a === '--global' || a === '--project') {
|
|
347
|
+
const v = value(i);
|
|
348
|
+
if (v === null) {
|
|
349
|
+
process.stderr.write(`check_single_delivery: ${a} needs a directory\n`);
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
if (a === '--global') globalRoot = path.resolve(v);
|
|
353
|
+
else projectRoot = path.resolve(v);
|
|
354
|
+
i += 1;
|
|
355
|
+
} else if (a === '--enforce') {
|
|
356
|
+
enforce = true;
|
|
357
|
+
} else if (a === '--help' || a === '-h') {
|
|
358
|
+
process.stdout.write(
|
|
359
|
+
'usage: check_single_delivery [--global DIR] [--project DIR] [--enforce]\n' +
|
|
360
|
+
'\n' +
|
|
361
|
+
'Reports by default and exits 0: the invariant is not true yet, because\n' +
|
|
362
|
+
'road-to-single-delivery Phase 2 is halted on partition-requires-global-layer.\n' +
|
|
363
|
+
'--enforce exits 1 on any overlap, and is what to register once Phase 2 lands.\n',
|
|
364
|
+
);
|
|
365
|
+
return 0;
|
|
366
|
+
} else if (a !== undefined) {
|
|
367
|
+
// R2 finding: this used to reject only `--`-prefixed tokens, so
|
|
368
|
+
// `-enforce` (one dash) and a bare positional both fell through
|
|
369
|
+
// SILENTLY — the gate then reported without enforcing while the caller
|
|
370
|
+
// believed it had asked for enforcement. A typo that turns a blocking
|
|
371
|
+
// gate advisory is worse than a crash, so anything unrecognised is an
|
|
372
|
+
// error, dash or not.
|
|
373
|
+
process.stderr.write(
|
|
374
|
+
`check_single_delivery: unexpected argument ${a}` +
|
|
375
|
+
' (did you mean --enforce, --global DIR or --project DIR?)\n',
|
|
376
|
+
);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const v = evaluate(globalRoot, projectRoot);
|
|
382
|
+
process.stdout.write(`${render(v, globalRoot, projectRoot)}\n`);
|
|
383
|
+
// Dead-scope assertion via the shared primitive rather than a hand-rolled
|
|
384
|
+
// `scanned:` line: `check_gate_coverage`'s hardening ratchet accepts an
|
|
385
|
+
// emitted line only from a gate registered in the enforced manifest, and this
|
|
386
|
+
// one is deliberately unregistered while step 4.3's binding surface is an open
|
|
387
|
+
// blocker. No `allowEmpty` reason — see the `namesRead` docstring for why zero
|
|
388
|
+
// is blindness here rather than a valid empty corpus.
|
|
389
|
+
//
|
|
390
|
+
// Caught rather than allowed to propagate: an uncaught `DeadScopeError` prints
|
|
391
|
+
// a Node stack trace, which is the correct exit code wearing an unusable
|
|
392
|
+
// message. The error carries the gate name and the roots, so printing it plus
|
|
393
|
+
// the repoint hint gives the reader what the trace was burying.
|
|
394
|
+
reportScanned({
|
|
395
|
+
gate: 'check_single_delivery',
|
|
396
|
+
scanned: v.namesRead,
|
|
397
|
+
units: 'artefact name(s) across both layers',
|
|
398
|
+
roots: [globalRoot, projectRoot],
|
|
399
|
+
// R2 finding 1: without this reason the gate returned 1 UNCONDITIONALLY
|
|
400
|
+
// wherever it is meant to be bound. `.claude/` is gitignored and no CI leg
|
|
401
|
+
// installs at user scope, so `namesRead` is 0 there, the DeadScopeError
|
|
402
|
+
// fired before both the `readNothing` branch and `--enforce`, and the
|
|
403
|
+
// gate contradicted its own `--help` ("Reports by default and exits 0").
|
|
404
|
+
//
|
|
405
|
+
// OPTIONAL_SURFACE is the correct one of the three prefixes: these two
|
|
406
|
+
// directories are an INSTALL-TIME surface, not a corpus this repo owns.
|
|
407
|
+
// Applying the doc's operational test — if the scan root were deleted,
|
|
408
|
+
// would the reason still make sense? — yes: no layers installed means
|
|
409
|
+
// there is no delivery to duplicate, which is a true answer rather than
|
|
410
|
+
// blindness. The blindness case is narrower and is still refused below:
|
|
411
|
+
// layers that exist but never pair up (`readNothing`).
|
|
412
|
+
allowEmpty:
|
|
413
|
+
'OPTIONAL_SURFACE: both host layers are install-time surfaces, absent by' +
|
|
414
|
+
' construction in CI (.claude/ is gitignored) and on any machine that has' +
|
|
415
|
+
' not installed. Zero names means nothing is delivered, so nothing can be' +
|
|
416
|
+
' delivered twice — a true verdict, not a dead scope. The unverifiable' +
|
|
417
|
+
' case (layers present but never paired) is refused separately.',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (v.readNothing) {
|
|
421
|
+
// Never a pass. A gate that compared nothing has not verified anything,
|
|
422
|
+
// and saying "invariant holds" here is the false-green shape this repo
|
|
423
|
+
// has recorded from other gates.
|
|
424
|
+
process.stdout.write(
|
|
425
|
+
'⚠️ check_single_delivery: no artefact type had BOTH layers present, so nothing was' +
|
|
426
|
+
' compared. This is NOT a pass — repoint with --global/--project.\n',
|
|
427
|
+
);
|
|
428
|
+
return enforce ? 1 : 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (v.duplicated === 0 && v.defeated === 0 && v.nameOverlapDifferentShape === 0) {
|
|
432
|
+
process.stdout.write('✅ check_single_delivery: one artefact, one layer — no overlap.\n');
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const parts: string[] = [];
|
|
437
|
+
if (v.duplicated > 0) parts.push(`${v.duplicated} artefact(s) delivered twice`);
|
|
438
|
+
if (v.nameOverlapDifferentShape > 0) {
|
|
439
|
+
parts.push(
|
|
440
|
+
`${v.nameOverlapDifferentShape} name collision(s) across layers of DIFFERENT shape` +
|
|
441
|
+
' (a delivery collision, not a duplicated artefact)',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (v.defeated > 0) {
|
|
445
|
+
parts.push(`${v.defeated} shared rule(s) with defeated \`paths:\` scoping`);
|
|
446
|
+
}
|
|
447
|
+
const detail = parts.join(', and ');
|
|
448
|
+
if (enforce) {
|
|
449
|
+
process.stdout.write(`❌ check_single_delivery: ${detail}. Invariant ADR-236 violated.\n`);
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
process.stdout.write(
|
|
453
|
+
`⚠️ check_single_delivery: ${detail}. Reported, not enforced — ` +
|
|
454
|
+
'road-to-single-delivery Phase 2 is halted on `partition-requires-global-layer`, ' +
|
|
455
|
+
'so this is a known-open defect rather than a regression. Re-run with --enforce ' +
|
|
456
|
+
'once the partition ships.\n',
|
|
457
|
+
);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function _isCliEntry(): boolean {
|
|
462
|
+
if (process.argv[1] === undefined) return false;
|
|
463
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
464
|
+
try {
|
|
465
|
+
return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
466
|
+
} catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (_isCliEntry()) {
|
|
472
|
+
process.exit(main());
|
|
473
|
+
}
|