@event4u/agent-config 14.13.0 → 14.14.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 (101) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +116 -387
  5. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  6. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  7. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  8. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  9. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  10. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +38 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +4 -4
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +1 -1
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +2 -2
  20. package/dist/discovery/trust-report.md +1 -1
  21. package/dist/discovery/workspaces.json +2 -2
  22. package/dist/hooks/dispatch.js +1438 -711
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +112 -17
  26. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  27. package/docs/guidelines/code-clarity.md +18 -0
  28. package/package.json +1 -1
  29. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  30. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  31. package/src/agent-src/scripts/stubs_due.ts +118 -2
  32. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  33. package/src/config/estate-count-budget.json +20 -1
  34. package/src/config/gate-coverage.yml +150 -0
  35. package/src/config/gate-violation-baselines.json +8 -3
  36. package/src/config/preamble-payload-budget.json +19 -11
  37. package/src/config/rule-stub-ceilings.json +14 -7
  38. package/src/scripts/_dispatch.bash +0 -4
  39. package/src/scripts/_lib/activation_ladder.ts +47 -3
  40. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  41. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  42. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  43. package/src/scripts/_lib/carrier_status.ts +118 -0
  44. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  45. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  46. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  47. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  48. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  49. package/src/scripts/_lib/evolution_roi.ts +562 -0
  50. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  51. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  52. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  53. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  54. package/src/scripts/_lib/pathology_archive.ts +393 -0
  55. package/src/scripts/_lib/promotion_capability.ts +315 -0
  56. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  57. package/src/scripts/_lib/promotion_review.ts +294 -0
  58. package/src/scripts/_lib/prompt_shape.ts +92 -0
  59. package/src/scripts/_lib/release_highlights.ts +70 -1
  60. package/src/scripts/_lib/release_material.ts +125 -2
  61. package/src/scripts/_lib/routing_corpus.ts +307 -0
  62. package/src/scripts/_lib/routing_index_input.ts +104 -0
  63. package/src/scripts/_lib/rule_injection.ts +22 -1
  64. package/src/scripts/_lib/semantic_noop.ts +167 -0
  65. package/src/scripts/activation_receipt.ts +258 -0
  66. package/src/scripts/ai_council/cross_exam.ts +128 -0
  67. package/src/scripts/ai_council/explain_route.ts +157 -0
  68. package/src/scripts/ai_council/information_gain.ts +293 -0
  69. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  70. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  71. package/src/scripts/ai_council/minority_retention.ts +171 -0
  72. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  73. package/src/scripts/ai_council/replay_route.ts +223 -0
  74. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  75. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  76. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  77. package/src/scripts/check_adr_frontmatter.ts +4 -9
  78. package/src/scripts/check_estate_count.ts +205 -24
  79. package/src/scripts/check_release_highlights.ts +96 -13
  80. package/src/scripts/check_roadmap_trackable.ts +17 -2
  81. package/src/scripts/corpus_manifest.ts +235 -0
  82. package/src/scripts/evolution_lab.ts +181 -8
  83. package/src/scripts/hook_manifest.json +1 -1
  84. package/src/scripts/hook_manifest.yaml +28 -6
  85. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  86. package/src/scripts/hooks/concern_registry.ts +2 -0
  87. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  88. package/src/scripts/language_mirror_hook.ts +14 -2
  89. package/src/scripts/lint_carrier_integrity.ts +820 -0
  90. package/src/scripts/lint_code_comments.ts +643 -0
  91. package/src/scripts/lint_plan_risk_register.ts +39 -19
  92. package/src/scripts/lint_promotion_paths.ts +779 -0
  93. package/src/scripts/llm_propose.ts +256 -0
  94. package/src/scripts/measure_delivery_sets.ts +361 -0
  95. package/src/scripts/measure_routing_signal.ts +307 -0
  96. package/src/scripts/model_rule_injection.ts +19 -3
  97. package/src/scripts/probe_council_retention.ts +495 -0
  98. package/src/scripts/release.ts +21 -37
  99. package/src/scripts/release_drill.ts +143 -0
  100. package/src/scripts/release_env.ts +35 -0
  101. package/src/scripts/release_publication.ts +84 -0
@@ -0,0 +1,726 @@
1
+ /**
2
+ * The equivalence-preserving pin for the metered-proposer experiment's subject.
3
+ *
4
+ * The experiment enumerates its corpus from `.claude/rules/`, which is a
5
+ * GENERATED projection and is gitignored in its entirety. Two consequences,
6
+ * both measured rather than argued: the same commit yields a different corpus
7
+ * on two machines, and the generator SKIPS any rule already installed
8
+ * byte-identically at user scope, so the corpus is a function of the operator's
9
+ * home directory as well as of the commit. A recorded commit therefore does not
10
+ * pin the subject, and a comparison against a subject nobody can reconstruct is
11
+ * not comparable to anything.
12
+ *
13
+ * What this module pins instead is the whole of what decides the projection:
14
+ * the commit, the generator's own bytes, the ordered subject inventory with
15
+ * hashes and provenance, the included and excluded paths, every user-scope file
16
+ * capable of causing a skip, the generator configuration and the non-secret
17
+ * environment values it reads, the runtime and platform facts, and the produced
18
+ * file inventory. {@link subjectDigest} folds the subject half into one value,
19
+ * so two runs are comparable exactly when that value matches.
20
+ *
21
+ * The manifest is a CAPTURE, never a plan. It records the tree as it stands;
22
+ * whether a clean checkout reproduces it is the question {@link diffManifests}
23
+ * answers, and answering it requires actually regenerating in that checkout. A
24
+ * module that predicted the projection instead of reading it would be asserting
25
+ * the very equivalence it exists to test.
26
+ *
27
+ * PRIVACY. User-scope paths are recorded home-relative, so no account name
28
+ * leaves the machine, and only file hashes are stored — never content. The
29
+ * environment capture is a closed allowlist of non-secret keys rather than a
30
+ * filtered dump, because a filter is a list of what somebody remembered to
31
+ * exclude.
32
+ */
33
+
34
+ import { createHash } from 'node:crypto';
35
+ import * as fs from 'node:fs';
36
+ import * as os from 'node:os';
37
+ import * as path from 'node:path';
38
+ import { spawnSync } from 'node:child_process';
39
+
40
+ import {
41
+ globalRuleLayerNames,
42
+ globalRuleLayerPath,
43
+ hostLayerCarries,
44
+ toolIdForProjectRuleDir,
45
+ } from '../../install/globalRuleLayers.js';
46
+ import { isExclusivelyPackageOnly } from '../../install/partitionEligibility.js';
47
+ import { dedupableRules } from '../../install/ruleLayerPartition.js';
48
+
49
+ export const CORPUS_MANIFEST_VERSION = 'corpus-manifest-v1';
50
+
51
+ /** The tool directory the experiment's corpus is enumerated from. */
52
+ export const CORPUS_TOOL_DIR = '.claude/rules';
53
+
54
+ /** Where a projected rule's bytes come from, and what the dedup compares against. */
55
+ export const PROJECTION_SOURCE_DIR = path.join('dist', 'agent-src', 'rules');
56
+
57
+ /**
58
+ * The files whose bytes decide the projection, so a manifest can say WHICH
59
+ * generator produced it. Hashing the generator is the difference between "the
60
+ * corpus differs" and "the corpus differs because the generator changed".
61
+ */
62
+ export const GENERATOR_MODULES: readonly string[] = [
63
+ path.join('src', 'scripts', 'condense.ts'),
64
+ path.join('src', 'install', 'ruleLayerPartition.ts'),
65
+ path.join('src', 'install', 'claudePathsPlan.ts'),
66
+ ];
67
+
68
+ /**
69
+ * Non-secret environment keys the projection actually reads.
70
+ *
71
+ * A closed list, and `HOME` is deliberately absent: it decides the dedup, but
72
+ * its VALUE is an account name. The dedup evidence is carried by the
73
+ * user-scope hash table instead, which says what was found without saying who
74
+ * owns it.
75
+ */
76
+ export const CAPTURED_ENV_KEYS: readonly string[] = [
77
+ 'AGENT_CONFIG_DEV_MODE',
78
+ 'AGENT_CONFIG_SCOPE',
79
+ 'AGENT_CONFIG_PROJECTION_MODE',
80
+ ];
81
+
82
+ export class CorpusManifestError extends Error {
83
+ constructor(message: string) {
84
+ super(message);
85
+ this.name = 'CorpusManifestError';
86
+ }
87
+ }
88
+
89
+ export interface SubjectEntry {
90
+ /** Repo-relative path of the projected file the experiment mutates. */
91
+ readonly path: string;
92
+ readonly sha256: string;
93
+ readonly bytes: number;
94
+ /** The projection source this file was emitted from, repo-relative. */
95
+ readonly provenance: string;
96
+ /** Hash of that source, or `null` when it is absent from this tree. */
97
+ readonly provenance_sha256: string | null;
98
+ }
99
+
100
+ export interface ExcludedEntry {
101
+ readonly path: string;
102
+ readonly why: string;
103
+ }
104
+
105
+ export interface UserScopeTwin {
106
+ /** Home-relative, never absolute: the account name is not evidence. */
107
+ readonly path: string;
108
+ readonly sha256: string;
109
+ /** The raw comparison: this twin is byte-identical to the projection source. */
110
+ readonly byte_identical: boolean;
111
+ /**
112
+ * Byte-identical AND the generator's byte-identity dedup is switched on.
113
+ *
114
+ * The two are separate fields because they answer separate questions and
115
+ * folding them recorded a skip that did not happen. The generator calls
116
+ * `dedupableRules` only when `projection.scope_dedup` resolves true; that
117
+ * setting is absent on every layer this repository carries, so on this tree
118
+ * a byte-identical twin causes nothing. A manifest that asserted otherwise
119
+ * would re-introduce the very mis-attribution the projection block below
120
+ * was written to correct.
121
+ */
122
+ readonly causes_skip: boolean;
123
+ }
124
+
125
+ /**
126
+ * The evidence that decides which rules reach the corpus directory at all.
127
+ *
128
+ * This block exists because the byte-identity table below accounts for NONE of
129
+ * the skips actually observed. Measured on a freshly generated tree: the
130
+ * generator reported 101 rules skipped and the byte-identity comparison
131
+ * returned zero, because `projection.scope_dedup` is absent on every layer this
132
+ * repository carries and therefore off. The real mechanism is the per-host rule
133
+ * partition, which withholds a rule when this host's GLOBAL layer is verified
134
+ * to carry its NAME. So the field a recorded commit cannot supply is the global
135
+ * layer's name inventory, and a manifest that pinned only byte-identical twins
136
+ * would have missed every skip that happened.
137
+ */
138
+ export interface ProjectionDecision {
139
+ /**
140
+ * Whether the per-host partition is active, or `null` when not captured.
141
+ *
142
+ * `null` is the default and is the honest value for a capture that cannot
143
+ * establish it. Every other field in this struct is a function of the
144
+ * `(repoRoot, userHome)` pair the capture was given; this one is not.
145
+ * `partitionActive` resolves through `resolvePartitionVerdict`, which reads
146
+ * the host layer at `os.homedir()` and the install lockfile at its own
147
+ * fixed location — ignoring `userHome` entirely — and memoises the answer
148
+ * for the life of the process. So a capture over a temporary tree that
149
+ * called it would record the operator's machine, and a second capture in
150
+ * one process would reuse the first verdict. A caller for which those
151
+ * globals ARE the run's own host supplies the predicate; nobody else does.
152
+ */
153
+ readonly partition_active: boolean | null;
154
+ readonly tool_id: string | null;
155
+ /** The global layer directory, home-relative; `null` when this host has none. */
156
+ readonly layer_dir: string | null;
157
+ readonly carries: boolean;
158
+ readonly reason: string;
159
+ /** Names the partition would withhold that the global layer does not carry. */
160
+ readonly missing: readonly string[];
161
+ /**
162
+ * Rules classified exclusively package-only, and therefore never withheld.
163
+ *
164
+ * Counted over every rule in the projection source, which is a SUPERSET of
165
+ * what the generator classifies: the generator filters by workspace scope
166
+ * and by manual type first. So this number is an explanatory input and is
167
+ * not the produced count. Measured on one tree: 15 here against 13 files
168
+ * actually produced. `produced` is the authoritative inventory.
169
+ */
170
+ readonly package_only_count: number;
171
+ /** The global layer inventory, with hashes: names decide, hashes explain. */
172
+ readonly layer_inventory: readonly { readonly name: string; readonly sha256: string | null }[];
173
+ /** Folds the inventory into one comparable value. */
174
+ readonly layer_digest: string;
175
+ }
176
+
177
+ export interface CorpusManifest {
178
+ readonly version: typeof CORPUS_MANIFEST_VERSION;
179
+ readonly commit: string | null;
180
+ /** True when the working tree carries uncommitted changes at capture time. */
181
+ readonly tree_dirty: boolean;
182
+ readonly package_version: string | null;
183
+ readonly generator: readonly { readonly path: string; readonly sha256: string | null }[];
184
+ /** The enumeration rule this manifest was captured under, by id. */
185
+ readonly enumeration_rule: string;
186
+ readonly included: readonly SubjectEntry[];
187
+ readonly excluded: readonly ExcludedEntry[];
188
+ readonly user_scope: readonly UserScopeTwin[];
189
+ /** Which rule population the user-scope table was built over. */
190
+ readonly user_scope_population: UserScopePopulation;
191
+ readonly projection: ProjectionDecision;
192
+ readonly generator_config: Readonly<Record<string, string | null>>;
193
+ readonly runtime: {
194
+ readonly node: string;
195
+ readonly platform: string;
196
+ readonly arch: string;
197
+ };
198
+ /** Every file the projection produced in the corpus directory, with hashes. */
199
+ readonly produced: readonly { readonly path: string; readonly sha256: string }[];
200
+ /** Folds the subject half into one comparable value. */
201
+ readonly subject_digest: string;
202
+ }
203
+
204
+ export function sha256OfFile(abs: string): string | null {
205
+ try {
206
+ return createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ function sha256OfString(s: string): string {
213
+ return createHash('sha256').update(s, 'utf-8').digest('hex');
214
+ }
215
+
216
+ /** Byte-wise, locale-independent order. Never `localeCompare`. */
217
+ function byteCompare(a: string, b: string): number {
218
+ return a < b ? -1 : a > b ? 1 : 0;
219
+ }
220
+
221
+ function git(root: string, args: readonly string[]): string | null {
222
+ const r = spawnSync('git', [...args], { cwd: root, encoding: 'utf-8' });
223
+ if (r.status !== 0 || typeof r.stdout !== 'string') return null;
224
+ return r.stdout.trim();
225
+ }
226
+
227
+ /**
228
+ * The corpus enumeration, executable rather than prose.
229
+ *
230
+ * Until now the rule lived only in the protocol document, so nothing could
231
+ * check that a capture had followed it. The four clauses are: every `*.md`
232
+ * directly under the corpus directory, sorted byte-wise by filename, the first
233
+ * `limit` of them, and the rest recorded as excluded with the reason. Reading
234
+ * the directory here rather than predicting it is deliberate: what the
235
+ * experiment mutates is what is on disk.
236
+ */
237
+ export function enumerateCorpus(
238
+ repoRoot: string,
239
+ limit: number,
240
+ ): { included: string[]; excluded: ExcludedEntry[] } {
241
+ const dir = path.join(repoRoot, CORPUS_TOOL_DIR);
242
+ let names: string[];
243
+ try {
244
+ names = fs
245
+ .readdirSync(dir, { withFileTypes: true })
246
+ .filter((d) => d.isFile() || d.isSymbolicLink())
247
+ .map((d) => d.name)
248
+ .filter((n) => n.endsWith('.md'));
249
+ } catch {
250
+ throw new CorpusManifestError(
251
+ `${CORPUS_TOOL_DIR} does not exist in ${repoRoot} — the corpus is a generated ` +
252
+ 'projection, so a capture before generation would pin an empty subject and ' +
253
+ 'read as a successful pin of nothing',
254
+ );
255
+ }
256
+ if (names.length === 0) {
257
+ throw new CorpusManifestError(
258
+ `${CORPUS_TOOL_DIR} exists in ${repoRoot} but holds no *.md file — the corpus is a ` +
259
+ 'generated projection, so an existing-but-empty directory pins an empty subject ' +
260
+ 'exactly as an absent one does. Two empty pins share a digest and compare ' +
261
+ 'SUBJECT EQUIVALENT, which certifies two runs that measured nothing as having ' +
262
+ 'measured the same thing. The directory survives a generation that emitted zero ' +
263
+ 'files and an operator clearing its contents, so this is the reachable half',
264
+ );
265
+ }
266
+ names.sort(byteCompare);
267
+ const included = names.slice(0, limit);
268
+ const excluded = names.slice(limit).map((n) => ({
269
+ path: path.posix.join(CORPUS_TOOL_DIR, n),
270
+ why: `beyond the first ${String(limit)} in byte order`,
271
+ }));
272
+ return { included: included.map((n) => path.posix.join(CORPUS_TOOL_DIR, n)), excluded };
273
+ }
274
+
275
+ /**
276
+ * Which rule population a user-scope table was built over.
277
+ *
278
+ * `projection-source-superset` is the default and is a SUPERSET of what the
279
+ * generator considers: the generator filters the same directory by workspace
280
+ * scope, pack scope, ADR-004 manual type and the compile-disabled toggle before
281
+ * it looks for twins. Replicating those four filters here would be the
282
+ * re-implementation this module refuses everywhere else, so the population is
283
+ * recorded rather than silently narrowed, and a caller holding the generator's
284
+ * own list passes it and gets `caller-supplied`.
285
+ */
286
+ export type UserScopePopulation = 'projection-source-superset' | 'caller-supplied';
287
+
288
+ export interface UserScopeOptions {
289
+ /**
290
+ * Whether the generator's byte-identity dedup runs at all.
291
+ *
292
+ * Required rather than defaulted. A default here is the defect: the twin
293
+ * table is evidence about a mechanism that is switched off on this tree,
294
+ * and a capture that did not read the switch cannot say whether a twin
295
+ * skips anything.
296
+ */
297
+ readonly dedupEnabled: boolean;
298
+ /** The generator's own candidate rule basenames, when the caller has them. */
299
+ readonly ruleNames?: readonly string[] | undefined;
300
+ }
301
+
302
+ /**
303
+ * Every user-scope file that can suppress a rule at project scope, with hashes.
304
+ *
305
+ * This is the field a recorded commit cannot supply and the one that made the
306
+ * subject irreproducible. `byte_identical` replays the generator's own
307
+ * comparison — byte-identity against the projection source — rather than
308
+ * re-implementing a judgement about it. `causes_skip` is that fact conjoined
309
+ * with whether the generator runs the comparison at all, so a manifest and a
310
+ * generation cannot disagree about what a skip is.
311
+ */
312
+ export function captureUserScope(
313
+ repoRoot: string,
314
+ userHome: string,
315
+ opts: UserScopeOptions,
316
+ ): UserScopeTwin[] {
317
+ const sourceDir = path.join(repoRoot, PROJECTION_SOURCE_DIR);
318
+ let names: string[] = [];
319
+ if (opts.ruleNames === undefined) {
320
+ try {
321
+ names = fs
322
+ .readdirSync(sourceDir)
323
+ .filter((n) => n.endsWith('.md'))
324
+ .sort(byteCompare);
325
+ } catch {
326
+ return [];
327
+ }
328
+ } else {
329
+ names = [...opts.ruleNames].sort(byteCompare);
330
+ }
331
+ const identical = dedupableRules({
332
+ toolDir: CORPUS_TOOL_DIR,
333
+ rules: names,
334
+ userHome,
335
+ rulesSource: sourceDir,
336
+ });
337
+ const out: UserScopeTwin[] = [];
338
+ for (const n of names) {
339
+ const rel = path.posix.join('.claude', 'rules', n);
340
+ const hash = sha256OfFile(path.join(userHome, '.claude', 'rules', n));
341
+ if (hash === null) continue;
342
+ const same = identical.has(n);
343
+ out.push({
344
+ path: rel,
345
+ sha256: hash,
346
+ byte_identical: same,
347
+ causes_skip: same && opts.dedupEnabled,
348
+ });
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * Capture the partition evidence, reading the same predicates the generator
355
+ * reads rather than a re-implementation of them.
356
+ *
357
+ * `hostLayerCarries` decides on NAME presence, not on content, so the names are
358
+ * what pin the corpus and the hashes are recorded alongside only to explain a
359
+ * difference. Recording the hashes without the names would have been the wrong
360
+ * half: two layers holding the same names with different bytes produce the same
361
+ * corpus.
362
+ */
363
+ export interface ProjectionDecisionOptions {
364
+ /**
365
+ * The host partition verdict, supplied by a caller for which the process
366
+ * globals it reads are this run's own host. Omitted → `null`.
367
+ */
368
+ readonly partitionActive?: ((repoRoot: string) => boolean) | undefined;
369
+ }
370
+
371
+ export function captureProjectionDecision(
372
+ repoRoot: string,
373
+ userHome: string,
374
+ opts: ProjectionDecisionOptions = {},
375
+ ): ProjectionDecision {
376
+ const sourceDir = path.join(repoRoot, PROJECTION_SOURCE_DIR);
377
+ let names: string[] = [];
378
+ try {
379
+ names = fs
380
+ .readdirSync(sourceDir)
381
+ .filter((n) => n.endsWith('.md'))
382
+ .sort(byteCompare);
383
+ } catch {
384
+ names = [];
385
+ }
386
+ const toolId = toolIdForProjectRuleDir(CORPUS_TOOL_DIR);
387
+ const packageOnly = names.filter((n) => isExclusivelyPackageOnly(path.join(sourceDir, n)));
388
+ const keep = new Set(packageOnly);
389
+ const withheld = names.filter((n) => !keep.has(n));
390
+ const verdict =
391
+ toolId === null
392
+ ? { carries: false, layerPath: null, missing: withheld, reason: 'no-layer-for-host' }
393
+ : hostLayerCarries(toolId, withheld, userHome);
394
+ const layerNames = toolId === null ? null : globalRuleLayerNames(toolId, userHome);
395
+ const layerPath = toolId === null ? null : globalRuleLayerPath(toolId, userHome);
396
+ const inventory = (layerNames ?? [])
397
+ .slice()
398
+ .sort(byteCompare)
399
+ .map((n) => ({
400
+ name: n,
401
+ sha256: layerPath === null ? null : sha256OfFile(path.join(layerPath, n)),
402
+ }));
403
+ return {
404
+ partition_active: opts.partitionActive === undefined ? null : opts.partitionActive(repoRoot),
405
+ tool_id: toolId,
406
+ layer_dir: layerPath === null ? null : homeRelative(layerPath, userHome),
407
+ carries: verdict.carries,
408
+ reason: verdict.reason,
409
+ missing: [...verdict.missing].sort(byteCompare),
410
+ package_only_count: packageOnly.length,
411
+ layer_inventory: inventory,
412
+ layer_digest: sha256OfString(inventory.map((e) => `${e.name} ${String(e.sha256)}`).join('\n')),
413
+ };
414
+ }
415
+
416
+ /**
417
+ * A path with the home prefix replaced, so no account name is captured.
418
+ *
419
+ * Falls back to the basename rather than to the absolute path when the prefix
420
+ * does not match: a fallback that leaked the full path would defeat the whole
421
+ * point on exactly the machines where the home layout is unusual.
422
+ */
423
+ export function homeRelative(abs: string, userHome: string): string {
424
+ const rel = path.relative(userHome, abs);
425
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
426
+ return path.basename(abs);
427
+ }
428
+ return rel.split(path.sep).join('/');
429
+ }
430
+
431
+ /**
432
+ * The one value two runs compare on.
433
+ *
434
+ * Folds the ordered subject inventory and the enumeration rule, and nothing
435
+ * else. The commit, the runtime and the user-scope table are all EXPLANATIONS
436
+ * of a difference and must not be inside the value that detects one — a digest
437
+ * that changed when the node version changed would report a subject change on
438
+ * every upgrade, and a reader would stop believing it.
439
+ */
440
+ export function subjectDigest(
441
+ enumerationRule: string,
442
+ included: readonly SubjectEntry[],
443
+ ): string {
444
+ const body = [enumerationRule, ...included.map((e) => `${e.path} ${e.sha256}`)].join('\n');
445
+ return sha256OfString(body);
446
+ }
447
+
448
+ export interface CaptureOptions {
449
+ readonly repoRoot: string;
450
+ readonly userHome: string;
451
+ readonly limit: number;
452
+ readonly enumerationRule: string;
453
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
454
+ /** See {@link ProjectionDecisionOptions.partitionActive}. Omitted → `null`. */
455
+ readonly partitionActive?: ((repoRoot: string) => boolean) | undefined;
456
+ /** See {@link UserScopeOptions.ruleNames}. Omitted → the projection-source superset. */
457
+ readonly ruleNames?: readonly string[] | undefined;
458
+ }
459
+
460
+ export function captureManifest(opts: CaptureOptions): CorpusManifest {
461
+ const { repoRoot, userHome, limit, enumerationRule } = opts;
462
+ const env = opts.env ?? process.env;
463
+ const { included: paths, excluded } = enumerateCorpus(repoRoot, limit);
464
+
465
+ const included: SubjectEntry[] = paths.map((rel) => {
466
+ const abs = path.join(repoRoot, rel);
467
+ const hash = sha256OfFile(abs);
468
+ if (hash === null) {
469
+ throw new CorpusManifestError(`corpus member '${rel}' vanished between listing and read`);
470
+ }
471
+ const base = path.basename(rel);
472
+ const provenance = path.posix.join('dist', 'agent-src', 'rules', base);
473
+ return {
474
+ path: rel,
475
+ sha256: hash,
476
+ bytes: fs.statSync(abs).size,
477
+ provenance,
478
+ provenance_sha256: sha256OfFile(path.join(repoRoot, provenance)),
479
+ };
480
+ });
481
+
482
+ let produced: { path: string; sha256: string }[] = [];
483
+ try {
484
+ produced = fs
485
+ .readdirSync(path.join(repoRoot, CORPUS_TOOL_DIR))
486
+ .filter((n) => n.endsWith('.md'))
487
+ .sort(byteCompare)
488
+ .map((n) => ({
489
+ path: path.posix.join(CORPUS_TOOL_DIR, n),
490
+ sha256: sha256OfFile(path.join(repoRoot, CORPUS_TOOL_DIR, n)) ?? '',
491
+ }));
492
+ } catch {
493
+ produced = [];
494
+ }
495
+
496
+ const generatorConfig: Record<string, string | null> = {};
497
+ for (const k of CAPTURED_ENV_KEYS) {
498
+ generatorConfig[k] = env[k] ?? null;
499
+ }
500
+ const scopeDedup = readScopeDedup(repoRoot);
501
+ generatorConfig['projection.scope_dedup'] = scopeDedup;
502
+
503
+ let packageVersion: string | null = null;
504
+ try {
505
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')) as {
506
+ version?: unknown;
507
+ };
508
+ packageVersion = typeof pkg.version === 'string' ? pkg.version : null;
509
+ } catch {
510
+ packageVersion = null;
511
+ }
512
+
513
+ const status = git(repoRoot, ['status', '--porcelain']);
514
+
515
+ return {
516
+ version: CORPUS_MANIFEST_VERSION,
517
+ commit: git(repoRoot, ['rev-parse', 'HEAD']),
518
+ tree_dirty: status === null ? true : status !== '',
519
+ package_version: packageVersion,
520
+ generator: GENERATOR_MODULES.map((rel) => ({
521
+ path: rel,
522
+ sha256: sha256OfFile(path.join(repoRoot, rel)),
523
+ })),
524
+ enumeration_rule: enumerationRule,
525
+ included,
526
+ excluded,
527
+ user_scope: captureUserScope(repoRoot, userHome, {
528
+ dedupEnabled: scopeDedupEnabled(scopeDedup),
529
+ ruleNames: opts.ruleNames,
530
+ }),
531
+ user_scope_population:
532
+ opts.ruleNames === undefined ? 'projection-source-superset' : 'caller-supplied',
533
+ projection: captureProjectionDecision(repoRoot, userHome, {
534
+ partitionActive: opts.partitionActive,
535
+ }),
536
+ generator_config: generatorConfig,
537
+ runtime: { node: process.version, platform: os.platform(), arch: os.arch() },
538
+ produced,
539
+ subject_digest: subjectDigest(enumerationRule, included),
540
+ };
541
+ }
542
+
543
+ /**
544
+ * The generator setting that decides whether user-scope dedup runs at all.
545
+ *
546
+ * Read as raw text rather than through the settings resolver, because a
547
+ * manifest must record what the file SAID at capture time; a resolver applies a
548
+ * cascade whose upper layers are not in this repository and would silently
549
+ * substitute one machine's answer for another's.
550
+ */
551
+ export function readScopeDedup(repoRoot: string): string | null {
552
+ for (const rel of ['.agent-settings.yml', path.join('src', 'config', 'agent-settings.template.yml')]) {
553
+ let text: string;
554
+ try {
555
+ text = fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
556
+ } catch {
557
+ continue;
558
+ }
559
+ const m = /^\s*scope_dedup:\s*(\S+)/m.exec(text);
560
+ if (m) return `${rel}:${m[1] as string}`;
561
+ }
562
+ // Not `null`: absent is a value here, and the reader has to be able to tell
563
+ // it from a read that failed. Absent means the byte-identity dedup is off,
564
+ // which is what makes the partition the only live skip mechanism.
565
+ return 'absent:default-off';
566
+ }
567
+
568
+ /**
569
+ * Does the recorded `projection.scope_dedup` value switch the dedup ON?
570
+ *
571
+ * Reads the value half of what {@link readScopeDedup} recorded, using the same
572
+ * truthy set the generator's own reader uses, so the manifest's `causes_skip`
573
+ * and the generator's behaviour cannot disagree. `absent:default-off` is false,
574
+ * which is the whole reason the field exists as a string in the first place.
575
+ */
576
+ export function scopeDedupEnabled(recorded: string | null): boolean {
577
+ if (recorded === null) return false;
578
+ const value = recorded.slice(recorded.lastIndexOf(':') + 1).trim().toLowerCase();
579
+ return ['true', 'yes', 'on', '1'].includes(value);
580
+ }
581
+
582
+ export interface ManifestDifference {
583
+ readonly field: string;
584
+ readonly expected: string;
585
+ readonly actual: string;
586
+ }
587
+
588
+ /**
589
+ * The reconstruction check, and it reports EVERY difference rather than the
590
+ * first.
591
+ *
592
+ * Stopping at the first mismatch would let an operator fix one hash, re-run,
593
+ * and meet the next one — a check that reveals its findings one per cycle
594
+ * trains the reader to believe the last one was the last one. The subject
595
+ * differences are listed first because they are the ones that invalidate a
596
+ * comparison; the rest explain why they happened.
597
+ */
598
+ export function diffManifests(expected: CorpusManifest, actual: CorpusManifest): ManifestDifference[] {
599
+ const out: ManifestDifference[] = [];
600
+ const push = (field: string, e: unknown, a: unknown): void => {
601
+ if (String(e) !== String(a)) out.push({ field, expected: String(e), actual: String(a) });
602
+ };
603
+
604
+ push('subject_digest', expected.subject_digest, actual.subject_digest);
605
+ push('enumeration_rule', expected.enumeration_rule, actual.enumeration_rule);
606
+ push('included.length', expected.included.length, actual.included.length);
607
+
608
+ const actualByPath = new Map(actual.included.map((e) => [e.path, e]));
609
+ for (const e of expected.included) {
610
+ const a = actualByPath.get(e.path);
611
+ if (a === undefined) {
612
+ out.push({ field: `included:${e.path}`, expected: e.sha256, actual: 'ABSENT' });
613
+ continue;
614
+ }
615
+ push(`included:${e.path}`, e.sha256, a.sha256);
616
+ }
617
+ const expectedPaths = new Set(expected.included.map((e) => e.path));
618
+ for (const a of actual.included) {
619
+ if (!expectedPaths.has(a.path)) {
620
+ out.push({ field: `included:${a.path}`, expected: 'ABSENT', actual: a.sha256 });
621
+ }
622
+ }
623
+
624
+ push('commit', expected.commit, actual.commit);
625
+ push('produced.length', expected.produced.length, actual.produced.length);
626
+ for (const g of expected.generator) {
627
+ const a = actual.generator.find((x) => x.path === g.path);
628
+ // `NO-ROW` rather than a nullish fallback: a generator module absent
629
+ // from the tree hashes to `null`, and folding the two states together
630
+ // reported a difference between two captures that had both recorded the
631
+ // same absence. The states are three, not two, and the third is common.
632
+ push(`generator:${g.path}`, g.sha256, a === undefined ? 'NO-ROW' : a.sha256);
633
+ }
634
+ const skipping = (m: CorpusManifest): string =>
635
+ m.user_scope.filter((t) => t.causes_skip).map((t) => t.path).sort(byteCompare).join(',');
636
+ const identical = (m: CorpusManifest): string =>
637
+ m.user_scope.filter((t) => t.byte_identical).map((t) => t.path).sort(byteCompare).join(',');
638
+ push('user_scope.skipping', skipping(expected), skipping(actual));
639
+ // Both halves of the skip verdict, because `skipping` alone cannot tell a
640
+ // tree with no twins from a tree whose twins are switched off — the two
641
+ // states differ in what a later generation would do. The configuration keys
642
+ // are the other half and were outside the comparison entirely, so the one
643
+ // field that disambiguates a mis-attributed skip was unchecked.
644
+ push('user_scope.byte_identical', identical(expected), identical(actual));
645
+ push('user_scope_population', expected.user_scope_population, actual.user_scope_population);
646
+ for (const k of [...new Set([
647
+ ...Object.keys(expected.generator_config),
648
+ ...Object.keys(actual.generator_config),
649
+ ])].sort(byteCompare)) {
650
+ push(
651
+ `generator_config:${k}`,
652
+ k in expected.generator_config ? expected.generator_config[k] : 'NO-KEY',
653
+ k in actual.generator_config ? actual.generator_config[k] : 'NO-KEY',
654
+ );
655
+ }
656
+ // The partition half, which is where the observed skips actually came from.
657
+ // Diffed by digest rather than by name list because the inventory runs to
658
+ // three figures and a diff nobody reads is a diff that does not exist.
659
+ push('projection.partition_active', expected.projection.partition_active, actual.projection.partition_active);
660
+ push('projection.carries', expected.projection.carries, actual.projection.carries);
661
+ push('projection.reason', expected.projection.reason, actual.projection.reason);
662
+ push('projection.layer_digest', expected.projection.layer_digest, actual.projection.layer_digest);
663
+ push(
664
+ 'projection.layer_names',
665
+ expected.projection.layer_inventory.map((e) => e.name).join(','),
666
+ actual.projection.layer_inventory.map((e) => e.name).join(','),
667
+ );
668
+ return out;
669
+ }
670
+
671
+ /** Two manifests describe the same experimental subject. Nothing weaker. */
672
+ export function subjectsEquivalent(expected: CorpusManifest, actual: CorpusManifest): boolean {
673
+ return (
674
+ expected.subject_digest === actual.subject_digest &&
675
+ expected.enumeration_rule === actual.enumeration_rule
676
+ );
677
+ }
678
+
679
+ export function serialiseManifest(m: CorpusManifest): string {
680
+ return `${JSON.stringify(m, null, 2)}\n`;
681
+ }
682
+
683
+ export function parseManifest(raw: unknown): CorpusManifest {
684
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
685
+ throw new CorpusManifestError('a manifest must be a JSON object');
686
+ }
687
+ const o = raw as Record<string, unknown>;
688
+ if (o['version'] !== CORPUS_MANIFEST_VERSION) {
689
+ throw new CorpusManifestError(
690
+ `manifest version ${JSON.stringify(o['version'])} is not ${CORPUS_MANIFEST_VERSION} — ` +
691
+ 'a manifest of another shape cannot be compared field-for-field, and comparing it ' +
692
+ 'partially would report equivalence from the fields that happened to survive',
693
+ );
694
+ }
695
+ if (typeof o['subject_digest'] !== 'string' || !Array.isArray(o['included'])) {
696
+ throw new CorpusManifestError('manifest is missing subject_digest or included');
697
+ }
698
+ const rule = o['enumeration_rule'];
699
+ if (typeof rule !== 'string' || rule === '') {
700
+ throw new CorpusManifestError(
701
+ 'manifest is missing enumeration_rule, which is inside the digest and decides which ' +
702
+ 'subjects the rule selected — a pin without it cannot be recomputed',
703
+ );
704
+ }
705
+ const included = o['included'] as readonly SubjectEntry[];
706
+ if (included.length === 0) {
707
+ throw new CorpusManifestError(
708
+ 'manifest pins an empty subject: two empty pins share a digest and compare equivalent, ' +
709
+ 'so accepting one would let a verification certify that nothing was measured twice',
710
+ );
711
+ }
712
+ // The pin adjudicates against its own stored digest, so a manifest whose
713
+ // digest and body disagree — hand-edited, truncated, or emitted by a future
714
+ // bug — would be judged on a value its `included` list does not support,
715
+ // while the per-path rows printed beside the verdict described a different
716
+ // subject. Recomputing is one call to the function that wrote it.
717
+ const recomputed = subjectDigest(rule, included);
718
+ if (recomputed !== o['subject_digest']) {
719
+ throw new CorpusManifestError(
720
+ `manifest subject_digest ${o['subject_digest']} does not match the digest of its own ` +
721
+ `enumeration rule and included list (${recomputed}) — the two disagree, so neither ` +
722
+ 'can be used to decide whether another capture measured the same subject',
723
+ );
724
+ }
725
+ return raw as CorpusManifest;
726
+ }