@event4u/agent-config 17.0.0 → 17.1.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 (119) 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 +104 -485
  5. package/README.md +2 -1
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/roadmaps.md +5 -1
  9. package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
  10. package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
  11. package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
  12. package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
  13. package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
  14. package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
  15. package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
  16. package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
  17. package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
  18. package/dist/cli-delegate/cmd_conformance.js +9 -8
  19. package/dist/cli-delegate/cmd_doctor.js +4 -4
  20. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  21. package/dist/cli-delegate/cmd_explain.js +1 -1
  22. package/dist/cli-delegate/cmd_export.js +6 -6
  23. package/dist/cli-delegate/cmd_fleet.js +3 -3
  24. package/dist/cli-delegate/cmd_preflight.js +3 -3
  25. package/dist/cli-delegate/cmd_prune.js +6 -6
  26. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  27. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  28. package/dist/cli-delegate/cmd_sync.js +6 -6
  29. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  30. package/dist/cli-delegate/cmd_update.js +9 -13
  31. package/dist/cli-delegate/cmd_validate.js +6 -6
  32. package/dist/discovery/deprecation-report.md +1 -1
  33. package/dist/discovery/discovery-manifest.json +4 -4
  34. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  35. package/dist/discovery/discovery-manifest.summary.md +1 -1
  36. package/dist/discovery/dormancy-report.md +1 -1
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +2 -2
  39. package/dist/discovery/trust-report.md +1 -1
  40. package/dist/discovery/workspaces.json +2 -2
  41. package/dist/hooks/dispatch.js +98 -74
  42. package/dist/hooks/dispatch.sha256 +1 -1
  43. package/dist/install/claudeRuleRewrite.js +10 -3
  44. package/dist/install/conflict.js +24 -2
  45. package/dist/install/conflictTracker.js +14 -3
  46. package/dist/install/install.mjs +222 -117
  47. package/dist/install/installThinLayer.js +1 -0
  48. package/dist/install/loggedWrites.js +142 -0
  49. package/dist/install/paths.js +12 -2
  50. package/dist/install/preserve.js +25 -4
  51. package/dist/install/txlog.js +10 -6
  52. package/dist/mcp/registry-manifest.json +2 -2
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +845 -294
  55. package/dist/scripts/_lib/egress_rules.js +4 -1
  56. package/dist/scripts/_lib/identifier_redaction.js +35 -2
  57. package/dist/scripts/ai_council/seat_policy.js +7 -1
  58. package/dist/server/schemas/settings.js +1 -1
  59. package/package.json +1 -1
  60. package/src/config/ci-local-parity.yml +1 -1
  61. package/src/config/gate-coverage.yml +27 -7
  62. package/src/config/gate-violation-baselines.json +8 -2
  63. package/src/config/hook-bundle-budget.json +2 -2
  64. package/src/config/production-entries.json +18 -0
  65. package/src/config/release-gate-locality.yml +0 -7
  66. package/src/install/claudeRuleRewrite.ts +10 -1
  67. package/src/install/conflict.ts +24 -1
  68. package/src/install/conflictTracker.ts +18 -2
  69. package/src/install/installThinLayer.ts +6 -0
  70. package/src/install/loggedWrites.ts +148 -0
  71. package/src/install/paths.ts +11 -2
  72. package/src/install/preserve.ts +40 -4
  73. package/src/install/txlog.ts +10 -6
  74. package/src/scripts/_cli/cmd_conformance.ts +3 -2
  75. package/src/scripts/_cli/cmd_update.ts +15 -17
  76. package/src/scripts/_lib/council_settings_block.ts +9 -2
  77. package/src/scripts/_lib/egress_rules.ts +4 -1
  78. package/src/scripts/_lib/host_capability.ts +10 -5
  79. package/src/scripts/_lib/identifier_redaction.ts +38 -2
  80. package/src/scripts/_lib/price_freshness.ts +112 -0
  81. package/src/scripts/_lib/production_reach.ts +194 -0
  82. package/src/scripts/_lib/quota_parked.ts +17 -28
  83. package/src/scripts/_lib/release_findings_ingest.ts +13 -335
  84. package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
  85. package/src/scripts/ai_council/chairman.ts +5 -2
  86. package/src/scripts/ai_council/clients.ts +4 -3
  87. package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
  88. package/src/scripts/ai_council/free_route_catalog.ts +142 -5
  89. package/src/scripts/ai_council/free_route_client.ts +4 -0
  90. package/src/scripts/ai_council/hand_payload.ts +115 -7
  91. package/src/scripts/ai_council/orchestrator.ts +7 -10
  92. package/src/scripts/ai_council/route_chooser.ts +1 -1
  93. package/src/scripts/ai_council/route_member.ts +66 -4
  94. package/src/scripts/ai_council/seat_policy.ts +8 -1
  95. package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
  96. package/src/scripts/ai_council/status_surface.ts +2 -0
  97. package/src/scripts/ask_transport.ts +137 -15
  98. package/src/scripts/check_finding_dispositions.ts +25 -71
  99. package/src/scripts/check_release_pr_shape.ts +3 -3
  100. package/src/scripts/check_step_production_reach.ts +257 -0
  101. package/src/scripts/council_cli.ts +10 -8
  102. package/src/scripts/hook_manifest.json +1 -1
  103. package/src/scripts/hook_manifest.yaml +3 -0
  104. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  105. package/src/scripts/hooks/payload_stub.ts +27 -0
  106. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
  107. package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
  108. package/src/scripts/install.ts +2 -2
  109. package/src/scripts/lint_decision_classes.ts +34 -0
  110. package/src/scripts/lint_hook_manifest.ts +24 -0
  111. package/src/scripts/lint_roadmap_blockers.ts +52 -3
  112. package/src/scripts/release.ts +5 -9
  113. package/src/scripts/release_drill.ts +11 -165
  114. package/src/scripts/release_publication.ts +0 -9
  115. package/src/scripts/report_production_reach.ts +35 -0
  116. package/src/scripts/worker_reuse_shadow_report.ts +399 -1
  117. package/src/server/schemas/settings.ts +1 -1
  118. package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
  119. /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
@@ -28,11 +28,13 @@ import {
28
28
  decideDeployWrite,
29
29
  foreignSidecarMessage,
30
30
  isLedgerMigration,
31
+ logProofApplies,
31
32
  migrationPreservedMessage,
32
33
  preservedFileMessage,
33
34
  sidecarPathFor,
34
35
  type DeployWriteDecision,
35
36
  } from './preserve.js';
37
+ import { loggedWriteDigests, wroteTheseBytes, type LoggedWrites } from './loggedWrites.js';
36
38
  import { ledgerListedPaths, recordedHashesWithLedger, type RecordedHashes } from './recordedOwnership.js';
37
39
 
38
40
  /**
@@ -89,6 +91,8 @@ export interface ConflictState {
89
91
  * compare a file against a listing this very run had just produced.
90
92
  */
91
93
  ledgerFiles: ReadonlySet<string> | null;
94
+ /** The transaction log's writes, read lazily once per run for the same reason. */
95
+ logged: LoggedWrites | null;
92
96
  preserved: string[];
93
97
  /**
94
98
  * Targets this run preserved on the migration branch.
@@ -148,6 +152,7 @@ export function createConflictTracker(host: ConflictTrackerHost): ConflictTracke
148
152
  root: null,
149
153
  recorded: null,
150
154
  ledgerFiles: null,
155
+ logged: null,
151
156
  preserved: [],
152
157
  migrated: new Set(),
153
158
  staged: new Map(),
@@ -331,6 +336,7 @@ export function createConflictTracker(host: ConflictTrackerHost): ConflictTracke
331
336
  conflictState.root = root;
332
337
  conflictState.recorded = null;
333
338
  conflictState.ledgerFiles = null;
339
+ conflictState.logged = null;
334
340
  conflictState.preserved = [];
335
341
  conflictState.migrated = new Set();
336
342
  conflictState.staged = new Map();
@@ -344,13 +350,23 @@ export function createConflictTracker(host: ConflictTrackerHost): ConflictTracke
344
350
  */
345
351
  resolve(target: string, force: boolean, would: WouldWrite): DeployWriteDecision {
346
352
  const exists = pathExists(target);
347
- const base = {
353
+ const recordedSha256 = exists ? recordedHashFor(target) : undefined;
354
+ const unproven = {
348
355
  exists,
349
- recordedSha256: exists ? recordedHashFor(target) : undefined,
356
+ recordedSha256,
350
357
  onDiskSha256: exists ? sha256OfFile(target) : null,
351
358
  force,
352
359
  ledgerListed: exists && ledgerLists(target),
360
+ recordedIsStaged:
361
+ typeof recordedSha256 === 'string' && recordedSha256 === recordedHashFor(sidecarPathFor(target)),
353
362
  };
363
+ // The log is opened only where the record alone would preserve
364
+ // without cause, and read once per run: this run's own appends
365
+ // cannot prove a file it has not reached yet.
366
+ const loggedWrite =
367
+ logProofApplies(unproven) &&
368
+ wroteTheseBytes(target, unproven.onDiskSha256 as string, (conflictState.logged ??= loggedWriteDigests()));
369
+ const base = { ...unproven, loggedWrite };
354
370
  // The packaged digest costs a temp write and a tag, so it is
355
371
  // computed only for the targets that can consume it. On a tree
356
372
  // where every ledger-listed path carries a digest — which is every
@@ -109,6 +109,11 @@ export interface ThinInstalledLayerOptions {
109
109
  * the staged runtime, which outlives a cleared package-manager cache.
110
110
  */
111
111
  readonly bodyLinkRoot?: string | null;
112
+ /**
113
+ * Called with each path this pass rewrote — the installer logs it, because
114
+ * the transaction log is read back as proof the bytes on disk are ours.
115
+ */
116
+ readonly onWrite?: (target: string) => void;
112
117
  }
113
118
 
114
119
  /**
@@ -212,6 +217,7 @@ export function thinInstalledRuleLayer(opts: ThinInstalledLayerOptions): ThinIns
212
217
  try {
213
218
  if (next !== installed) {
214
219
  fs.writeFileSync(target, next, 'utf-8');
220
+ opts.onWrite?.(target);
215
221
  rewritten += 1;
216
222
  }
217
223
  thinned += 1;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The install transaction log read as an ownership record.
3
+ *
4
+ * WHY. The writer preserves any managed file it cannot prove is still the bytes
5
+ * it wrote. For a file deployed before the ownership ledger recorded digests,
6
+ * the ledger has nothing to compare against, so EVERY such file was preserved
7
+ * and a sidecar staged beside it — thousands of them on one upgrade, all
8
+ * untouched. Worse, the ledger then records the staged digest for each target,
9
+ * so the next run calls the same untouched file "user-modified".
10
+ *
11
+ * The proof was on disk the whole time. `src/scripts/install.ts` appends a
12
+ * `write` entry carrying the SHA-256 of the target's bytes immediately after
13
+ * every copy. A file whose bytes equal a digest the log records this installer
14
+ * writing to that exact path is the installer's own output, unedited. A path
15
+ * with no matching record keeps the existing answer. WHEN that proof may decide
16
+ * is narrower than "whenever it matches" — see `decideDeployWrite`.
17
+ *
18
+ * WHAT IS READ. The active log and every rotated sibling, plain or gzipped
19
+ * (`txlog.ts` rotates at 10 MB or 30 days, and the gzip step can leave both
20
+ * forms behind). An unreadable or truncated file contributes nothing — the
21
+ * failure direction is "no proof", which preserves.
22
+ *
23
+ * COST. Parsed per FILE and cached on that file's size and mtime. A rotated
24
+ * archive never changes, so only the active log is re-read when it grows — and
25
+ * an install appends to it after every file it touches, so a cache keyed on the
26
+ * whole set would re-parse the entire history once per candidate.
27
+ */
28
+
29
+ import { gunzipSync } from 'node:zlib';
30
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
31
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
32
+
33
+ import { getLogPath } from './paths.js';
34
+
35
+ /** Absolute target path → every digest the log records this installer writing there. */
36
+ export type LoggedWrites = ReadonlyMap<string, ReadonlySet<string>>;
37
+
38
+ const WRITE_MARKER = '"kind":"write"';
39
+
40
+ const fileCache = new Map<string, { readonly stamp: string; readonly entries: ReadonlyArray<readonly [string, string]> }>();
41
+ const dirRealpaths = new Map<string, string>();
42
+
43
+ /**
44
+ * One canonical spelling for a path: its directory realpath'd, its basename
45
+ * kept. Both the log keys and the queries go through it, so `/var/…` and its
46
+ * macOS realpath twin meet. The directory is realpath'd rather than the file
47
+ * because the file may not exist, and is memoised because a log names a few
48
+ * hundred directories across tens of thousands of entries.
49
+ */
50
+ export function canonicalPath(p: string): string {
51
+ const abs = resolve(p);
52
+ const dir = dirname(abs);
53
+ let real = dirRealpaths.get(dir);
54
+ if (real === undefined) {
55
+ try {
56
+ real = realpathSync(dir);
57
+ } catch {
58
+ real = dir;
59
+ }
60
+ dirRealpaths.set(dir, real);
61
+ }
62
+ return join(real, basename(abs));
63
+ }
64
+
65
+ /** The active log plus its rotated siblings, sorted by name. */
66
+ function logFiles(logPath: string): string[] {
67
+ const dir = dirname(logPath);
68
+ if (!existsSync(dir)) return [];
69
+ const stem = basename(logPath).replace(/\.jsonl$/, '');
70
+ try {
71
+ return readdirSync(dir)
72
+ .filter((n) => n.startsWith(stem) && (n.endsWith('.jsonl') || n.endsWith('.jsonl.gz')))
73
+ .map((n) => join(dir, n))
74
+ .sort();
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ function writesIn(file: string): ReadonlyArray<readonly [string, string]> {
81
+ let stamp: string;
82
+ try {
83
+ const st = statSync(file);
84
+ stamp = `${st.size}:${Math.trunc(st.mtimeMs)}`;
85
+ } catch {
86
+ return [];
87
+ }
88
+ const cached = fileCache.get(file);
89
+ if (cached !== undefined && cached.stamp === stamp) return cached.entries;
90
+
91
+ let text = '';
92
+ try {
93
+ const raw = readFileSync(file);
94
+ text = (file.endsWith('.gz') ? gunzipSync(raw) : raw).toString('utf8');
95
+ } catch {
96
+ text = '';
97
+ }
98
+ const entries: Array<readonly [string, string]> = [];
99
+ for (const line of text.split('\n')) {
100
+ if (!line.includes(WRITE_MARKER)) continue;
101
+ let entry: { path?: unknown; sha256?: unknown };
102
+ try {
103
+ entry = JSON.parse(line) as { path?: unknown; sha256?: unknown };
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (typeof entry.path !== 'string' || typeof entry.sha256 !== 'string' || entry.sha256 === '') continue;
108
+ entries.push([canonicalPath(entry.path), entry.sha256]);
109
+ }
110
+ fileCache.set(file, { stamp, entries });
111
+ return entries;
112
+ }
113
+
114
+ /** Every `write` the transaction log records, keyed by canonical path. */
115
+ export function loggedWriteDigests(logPath: string = getLogPath()): LoggedWrites {
116
+ const writes = new Map<string, Set<string>>();
117
+ for (const file of logFiles(logPath)) {
118
+ for (const [target, sha] of writesIn(file)) {
119
+ let set = writes.get(target);
120
+ if (set === undefined) writes.set(target, (set = new Set()));
121
+ set.add(sha);
122
+ }
123
+ }
124
+ return writes;
125
+ }
126
+
127
+ /**
128
+ * Wrap a log writer so it records only paths this run's copy deploys.
129
+ *
130
+ * The claude-code rewrite and thinning passes walk the whole rule directory,
131
+ * including rules the user authored there; logging those rewrites would make
132
+ * the log vouch for content that was never the package's.
133
+ */
134
+ export function onlyDeployed(
135
+ anchor: string,
136
+ currentFiles: ReadonlySet<string>,
137
+ log: (target: string) => void,
138
+ ): (target: string) => void {
139
+ return (target) => {
140
+ const rel = relative(resolve(anchor), resolve(target)).split(sep).join('/');
141
+ if (currentFiles.has(rel)) log(target);
142
+ };
143
+ }
144
+
145
+ /** Did this installer write exactly `onDiskSha256` to `target`? */
146
+ export function wroteTheseBytes(target: string, onDiskSha256: string, writes: LoggedWrites): boolean {
147
+ return writes.get(canonicalPath(target))?.has(onDiskSha256) === true;
148
+ }
@@ -69,12 +69,21 @@ export function getInstallRoot(home?: string): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Absolute path to the active transaction log:
73
- * `~/.event4u/agent-config/install-log.jsonl`.
72
+ * Absolute path to the active transaction log: `install-log.jsonl` in
73
+ * `$EVENT4U_CONFIG_HOME` when that is set and no explicit `home` is given,
74
+ * otherwise in `~/.event4u/agent-config/`.
74
75
  *
75
76
  * Phase A4 writers append here on every successful atomic rename.
77
+ *
78
+ * Without the variable, the hermetic test environment — which sets only that
79
+ * variable — appended every test install to the real user's log, and the log
80
+ * is now read back as ownership proof. A history written under the other root
81
+ * before a user switched roots is not read; the failure direction is "no
82
+ * proof", which preserves.
76
83
  */
77
84
  export function getLogPath(home?: string): string {
85
+ const configHome = home ? undefined : process.env['EVENT4U_CONFIG_HOME'];
86
+ if (configHome) return join(configHome, INSTALL_LOG_FILENAME);
78
87
  return join(getInstallRoot(home), INSTALL_LOG_FILENAME);
79
88
  }
80
89
 
@@ -109,6 +109,23 @@ export interface DeployWriteInputs {
109
109
  * `null` when the file could not be read.
110
110
  */
111
111
  readonly onDiskSha256: string | null;
112
+ /**
113
+ * True when the install transaction log records this installer writing
114
+ * exactly the bytes now on disk to this path (`./loggedWrites.ts`).
115
+ * Absent means false. Decides only where {@link logProofApplies} says so.
116
+ */
117
+ readonly loggedWrite?: boolean;
118
+ /**
119
+ * True when the recorded digest is the one this installer STAGED beside the
120
+ * target as a sidecar, rather than bytes it wrote to the target itself.
121
+ *
122
+ * The ledger records a preserved target under the staged digest, so such a
123
+ * target reads `recorded-modified` without anyone having edited it. Only
124
+ * then may the log override the record: a recorded digest of bytes we DID
125
+ * write diverging from disk is a real edit — including a user restoring an
126
+ * older shipped version on purpose, which the log would also match.
127
+ */
128
+ readonly recordedIsStaged?: boolean;
112
129
  /** True when the run carries `--force`. */
113
130
  readonly force: boolean;
114
131
  }
@@ -135,18 +152,36 @@ export interface DeployWriteInputs {
135
152
  * behaviour stands rather than being changed on a case nobody reasoned about.
136
153
  */
137
154
  export function isLedgerMigration(inputs: Omit<DeployWriteInputs, 'packagedSha256'>): boolean {
138
- if (!inputs.exists || inputs.force) return false;
155
+ if (!inputs.exists || inputs.force || inputs.loggedWrite === true) return false;
139
156
  if (inputs.ledgerListed !== true) return false;
140
157
  if (inputs.onDiskSha256 === null) return false;
141
158
  return classifyOwnership(inputs.recordedSha256, inputs.onDiskSha256) === 'unknown';
142
159
  }
143
160
 
161
+ /**
162
+ * May the transaction-log proof decide this target? Only where the verdict
163
+ * would otherwise be a preservation the record cannot justify: a ledger-listed
164
+ * file with no digest (the migration branch), or a `recorded-modified` file
165
+ * whose recorded digest is a staged one. Everywhere else the log is not read —
166
+ * the ledger alone already decides, and a genuine edit must stay preserved.
167
+ */
168
+ export function logProofApplies(inputs: Omit<DeployWriteInputs, 'packagedSha256' | 'loggedWrite'>): boolean {
169
+ if (!inputs.exists || inputs.force || inputs.onDiskSha256 === null) return false;
170
+ const ownership = classifyOwnership(inputs.recordedSha256, inputs.onDiskSha256);
171
+ if (ownership === 'recorded-modified') return inputs.recordedIsStaged === true;
172
+ // `loggedWrite` is cleared because `isLedgerMigration` excludes a proven
173
+ // target, and this asks whether the target is a candidate BEFORE the proof.
174
+ return ownership === 'unknown' && isLedgerMigration({ ...inputs, loggedWrite: false });
175
+ }
176
+
144
177
  /**
145
178
  * Decide whether the writer may replace one managed file.
146
179
  *
147
- * A target that does not exist is written. A forced run writes. Otherwise the
148
- * recorded-ownership verdict decides: `recorded-modified` preserves, and
149
- * `recorded-unchanged` writes.
180
+ * A target that does not exist is written. A forced run writes. A target whose
181
+ * bytes the transaction log records this installer writing there is written
182
+ * where {@link logProofApplies} — there is no edit to lose. Otherwise the
183
+ * recorded-ownership verdict decides:
184
+ * `recorded-modified` preserves, and `recorded-unchanged` writes.
150
185
  *
151
186
  * The `unknown` arm is the one worth stating out loud. No recorded digest
152
187
  * means no divergence to measure — a tree with no manifest, a global anchor
@@ -169,6 +204,7 @@ export function isLedgerMigration(inputs: Omit<DeployWriteInputs, 'packagedSha25
169
204
  export function decideDeployWrite(inputs: DeployWriteInputs): DeployWriteDecision {
170
205
  if (!inputs.exists) return 'write';
171
206
  if (inputs.force) return 'write';
207
+ if (inputs.loggedWrite === true && logProofApplies(inputs)) return 'write';
172
208
  const ownership = classifyOwnership(inputs.recordedSha256, inputs.onDiskSha256);
173
209
  if (ownership === 'recorded-modified') return 'preserve';
174
210
  if (ownership === 'unknown' && isLedgerMigration(inputs)) {
@@ -6,14 +6,18 @@
6
6
  * nothing is un-written automatically. No PID lockfile (per Phase-A4
7
7
  * decision 8).
8
8
  *
9
- * WHAT ACTUALLY WRITES THIS FILE, as of 2026-09-15: two call sites.
10
- * `src/server/routes/install.ts`'s recovery-dismiss handler appends a
11
- * `rollback` marker with an empty path and a null hash; `src/scripts/install.ts`
12
- * — the surviving headless writer, per-file, through `_log_tx_entry` — appends
13
- * a `write` or `skip` entry per copied/skipped path during
14
- * `_copy_dir_dereferencing_symlinks`. Both call `appendTxLog` directly, so the
9
+ * WHAT ACTUALLY WRITES THIS FILE: `src/server/routes/install.ts`'s
10
+ * recovery-dismiss handler appends a `rollback` marker with an empty path and a
11
+ * null hash; `src/scripts/install.ts`, through `_log_tx_entry`, appends a
12
+ * `write` or `skip` entry per path it copies, preserves or stages a sidecar
13
+ * for, and a `write` entry per deployed Claude rule the host-form rewrite and
14
+ * the thinning pass rewrite after the copy. All call `appendTxLog`, so the
15
15
  * entry shape is identical by construction rather than by convention.
16
16
  *
17
+ * WHAT READS IT: recovery (the active log only) and `./loggedWrites.ts`, which
18
+ * reads every `write` digest in the active log AND its rotated siblings as
19
+ * proof that bytes on disk are the installer's own output.
20
+ *
17
21
  * Rotation: 10 MB OR 30 days, whichever first. Rotated copies move to
18
22
  * `install-log.<ISO>.jsonl.gz` siblings; recovery never scans them.
19
23
  *
@@ -39,6 +39,7 @@ import * as YAML from 'yaml';
39
39
 
40
40
  import * as installed_tools from '../_lib/installed_tools.js';
41
41
  import { atomicAppendLine } from '../../install/atomic.js';
42
+ import { getLogPath } from '../../install/paths.js';
42
43
  import { readRecentEntries } from '../../install/txlog.js';
43
44
  import { detectToolPresence } from '../../install/detect.js';
44
45
  import { resolvePackageRoot } from '../_lib/package_root.js';
@@ -120,13 +121,13 @@ export function conformanceLogPath(): string {
120
121
  return path.join(os.homedir(), '.event4u', 'agent-config', 'conformance-log.jsonl');
121
122
  }
122
123
 
123
- /** Default install txlog path (kept in sync with `src/install/txlog.ts` docs). */
124
+ /** Install txlog path — the one the installer writes, via `getLogPath()`. */
124
125
  export function installLogPath(): string {
125
126
  const override = process.env['AGENT_CONFIG_INSTALL_LOG'];
126
127
  if (override && override.trim().length > 0) {
127
128
  return override;
128
129
  }
129
- return path.join(os.homedir(), '.event4u', 'agent-config', 'install-log.jsonl');
130
+ return getLogPath();
130
131
  }
131
132
 
132
133
  // ---------------------------------------------------------------------------
@@ -542,7 +542,7 @@ export async function main(argv: string[] | null = null, options: MainOptions =
542
542
  cache_warmer(latest);
543
543
  }
544
544
  _refresh_state(latest, latest, state_path);
545
- _refresh_global_lockfile(latest, out);
545
+ _report_global_install(latest, out);
546
546
  return 0;
547
547
  }
548
548
 
@@ -556,30 +556,28 @@ function _relativeToOr(target: string, base: string): string {
556
556
  }
557
557
 
558
558
  /**
559
- * Update the global `installed.lock` if it exists.
559
+ * Name `agent-config upgrade` when the global install is older than the pin.
560
560
  *
561
- * Resolution prefers `~/.event4u/agent-config/installed.lock` and falls back to
562
- * the legacy `~/.config/agent-config/installed.lock`.
563
- *
564
- * Phase 1.6 — the lockfile is only present when the user has run a global
565
- * install; we never create one here, but we keep it in lockstep when `update`
566
- * flips the pin. Atomic write goes through `installed_lock.write_lockfile`.
561
+ * Read-only by design: `installed.lock` records what the global install
562
+ * deployed, and `upgrade` (plus its `--check` and unattended paths) reads it as
563
+ * the installed version. This command used to rewrite it to the pin, so after
564
+ * `update` the lock claimed a version no `npm install -g` had installed and
565
+ * `upgrade --check` reported "up to date" over the old binary.
567
566
  */
568
- function _refresh_global_lockfile(version: string, out: OutSink): void {
569
- const read_path = installed_lock.lockfile_path();
570
- const write_path = installed_lock.lockfile_write_path();
571
- const existing = installed_lock.read_lockfile(read_path);
567
+ function _report_global_install(version: string, out: OutSink): void {
568
+ const existing = installed_lock.read_lockfile(installed_lock.lockfile_path());
572
569
  if (existing === null) {
573
570
  return;
574
571
  }
575
572
  const recorded = (existing as unknown as Record<string, unknown>)['agent_config_version'];
576
- const tools = [...(existing.tools ?? [])];
577
- if (recorded === version && read_path === write_path) {
578
- _print(out, `ℹ️ ${write_path} already records ${version}.`);
573
+ if (typeof recorded !== 'string' || !update_check._is_newer(version, _normalize(recorded))) {
579
574
  return;
580
575
  }
581
- installed_lock.write_lockfile(version, tools, { path: write_path });
582
- _print(out, `✅ Refreshed global lockfile at ${write_path}.`);
576
+ _print(
577
+ out,
578
+ `ℹ️ The global install is ${_normalize(recorded)}; this pin does not change it. ` +
579
+ `Run \`agent-config upgrade\` to install ${version} globally.`,
580
+ );
583
581
  }
584
582
 
585
583
  /** Read `version` from the package's own `package.json`. */
@@ -16,8 +16,8 @@
16
16
  * `load_settings` does not hand `build_members` the config FILE — it hands it
17
17
  * this block. So a key the loader validates and this projection drops is
18
18
  * enforced at load and invisible at runtime. That has now happened to
19
- * `quorum`, to `quorum_min_present`, and to `fallback.api_on_quota` in turn;
20
- * each carries its own comment below. A new key belongs in BOTH places or in
19
+ * `quorum`, to `quorum_min_present`, to `fallback.api_on_quota`, and to a
20
+ * member's `base_url` route fields in turn; each carries its own comment below. A new key belongs in BOTH places or in
21
21
  * neither.
22
22
  */
23
23
 
@@ -48,6 +48,13 @@ export function synthesizeAiCouncilBlock(cfg: CouncilConfig): Dict {
48
48
  if (m.prompt_cache_ttl !== '5m') {
49
49
  entry['prompt_cache_ttl'] = m.prompt_cache_ttl;
50
50
  }
51
+ // Dropping these turned every configured `base_url` route into an
52
+ // unknown provider at `build_members`, so no free route was ever built.
53
+ if (m.route) {
54
+ entry['base_url'] = m.route.base_url;
55
+ entry['content_ceiling'] = m.route.declared_ceiling;
56
+ if (m.route.proof !== null) entry['billing_proof'] = { ...m.route.proof };
57
+ }
51
58
  members[name] = entry;
52
59
  }
53
60
  const advisors: Dict = {};
@@ -198,7 +198,10 @@ export function pemMaskEnd(text: string, span: SpanBounds): number | null {
198
198
 
199
199
  // IBAN length per country (ISO 13616 registry), packed as `CCnn` pairs. A country
200
200
  // missing here leaves its IBANs unmasked, so the table errs towards inclusion.
201
- const IBAN_LENGTHS =
201
+ // Checked 2026-10-22 against the registry's Release 103: all 89 countries and
202
+ // lengths agree. The test pins this release to the table's digest.
203
+ export const IBAN_REGISTRY_RELEASE = 103;
204
+ export const IBAN_LENGTHS =
202
205
  'AD24AE23AL28AT20AZ28BA20BE16BG22BH22BI27BR29BY28CH21CR22CY28CZ24DE22DJ27DK18DO28EE20EG29ES24FI18FK18FO18FR27GB22GE22GI23GL18GR27GT28HN28HR21HU28IE22IL23IQ23IS26IT27JO30KW30KZ20LB28LC32LI21LT20LU20LV21LY25MC27MD24ME22MK19MN20MR27MT31MU30NI28NL18NO15OM23PK24PL28PS29PT25QA29RO24RS22RU33SA24SC31SD18SE24SI19SK24SM27SO23ST25SV28TL23TN24TR26UA29VA22VG24XK20YE30';
203
206
 
204
207
  export function isValidIban(value: string): boolean {
@@ -365,19 +365,24 @@ export function resolveHostCapabilities(
365
365
  * settings decision, so there is no override parameter here (that stays on
366
366
  * {@link resolveHostCapabilities} for back-compat callers only).
367
367
  *
368
- * The one probed fact today: `agent_teams` resolves `true` when
368
+ * Two probed facts today. `agent_teams` resolves `true` when
369
369
  * `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set (any non-empty value) in the
370
370
  * process environment — the one host fact this repo can observe about Claude
371
371
  * Code's documented-but-unshipped-by-default multi-instance Agent Teams
372
372
  * primitive. A registry row can also carry `agent_teams: true` directly for a
373
373
  * host where it is unconditionally available; the two sources OR together —
374
- * a probe never turns a registry-granted capability back off.
374
+ * a probe never turns a registry-granted capability back off. `worker_resume`
375
+ * follows the same flag on `claude` only, because the by-name re-task was
376
+ * observed there under it and nowhere else.
375
377
  */
376
378
  export function probeHostCapabilities(hostId: string | null | undefined): HostCapabilityManifest {
377
379
  const base = resolveHostCapabilities(hostId);
378
380
  const flag = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
379
381
  const probedAgentTeams = typeof flag === 'string' && flag !== '';
380
- return { ...base, agent_teams: base.agent_teams || probedAgentTeams };
382
+ // By-name resume was observed on claude only under this flag (cold-start
383
+ // 1.4, 2.1.306); council 2026-10-22 decision 1 made it a live probe.
384
+ const probedResume = probedAgentTeams && hostId === 'claude';
385
+ return { ...base, agent_teams: base.agent_teams || probedAgentTeams, worker_resume: base.worker_resume || probedResume };
381
386
  }
382
387
 
383
388
  /** Every manifest field that carries a capability answer (i.e. not `schema_version`). */
@@ -448,9 +453,9 @@ export function describeHostCapabilities(
448
453
  // A row entry claims the field whatever its value: an explicit
449
454
  // `false` in the table is an observation ("checked, absent"), which is
450
455
  // a different fact from the default's "nobody answered".
451
- if (row !== undefined && typeof row[field] === 'boolean') {
456
+ if (row !== undefined && typeof row[field] === 'boolean' && !(row[field] === false && manifest[field] === true)) {
452
457
  sources[field] = 'registry';
453
- } else if (field === 'agent_teams' && probedAgentTeams) {
458
+ } else if ((field === 'agent_teams' || (field === 'worker_resume' && hostId === 'claude')) && probedAgentTeams) {
454
459
  sources[field] = 'live-probe';
455
460
  } else {
456
461
  sources[field] = 'default';
@@ -7,11 +7,47 @@
7
7
  * error and a context word is not required here. The rows' checksums still
8
8
  * hold — a number that fails Luhn is not a card, a string that fails mod-97 is
9
9
  * not an IBAN. The tag spelling and the counter keys are this consumer's.
10
+ *
11
+ * Without the context word a digit row must stand on its own shape, so the store
12
+ * tightens four rows where tree text measured false positives (PREREG (b), store
13
+ * reading): phone and card digits may not touch a letter on either side (a
14
+ * digit-led hex SHA is not a card) nor follow a decimal point, and a national
15
+ * number may not continue a `-` or `/` list (`ADR-046/047`); a leading `+` at a line start — a diff's added
16
+ * line — needs a grouped number (`+49 30 …`), a dotted quad is never a phone, and
17
+ * a North-American number keeps its context word unless it is written formatted,
18
+ * because a bare ten-digit integer (`2147483647`) has the same digits.
10
19
  */
11
20
  import { detect, type EgressRule, IDENTIFIER_RULES, replaceSpans } from './egress_rules.js';
12
21
 
13
- /** The identifier rows with every context requirement dropped. */
14
- export const STORAGE_IDENTIFIER_RULES: readonly EgressRule[] = IDENTIFIER_RULES.map(({ context: _context, ...rest }) => rest);
22
+ /** Store patterns for the rows the shape alone cannot carry; the rest are the table's own. */
23
+ const STORE_PATTERN: Readonly<Record<string, RegExp>> = {
24
+ 'payment-card': /(?<![0-9A-Za-z.])[0-9](?:[ -]?[0-9]){12,18}(?![0-9A-Za-z])/g,
25
+ 'phone-international':
26
+ /(?<![A-Za-z0-9+])(?:(?<!^|\n)|(?=\+[1-9][0-9]{0,2}[ (]))\+[1-9][0-9]{0,2}(?:[ ./-]?(?:\([0-9]{1,6}\)|(?=([0-9]{1,6}))\1)){1,5}(?![0-9A-Za-z])/g,
27
+ 'phone-national': /(?<![A-Za-z0-9+(./-])(?:\(0[1-9][0-9]{1,4}\)|0[1-9][0-9]{1,4})(?:[ ./-]?(?=([0-9]{2,8}))\1){1,3}(?![0-9A-Za-z])/g,
28
+ };
29
+
30
+ const DOTTED_QUAD = /^\+?[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/;
31
+
32
+ /** A North-American number that is written as one: two separators, or a bracketed area code. */
33
+ function isFormattedNanp(value: string): boolean {
34
+ return value.includes('(') || (value.match(/[ .-]/g) ?? []).length >= 2;
35
+ }
36
+
37
+ function storeRow(row: EgressRule): EgressRule[] {
38
+ const { context: _context, ...rest } = row;
39
+ if (row.rule === 'phone-nanp') {
40
+ // Two rows: the table's own with its context word, and a formatted shape without one.
41
+ return [row, { ...rest, rule: 'phone-nanp-formatted', validator: isFormattedNanp }];
42
+ }
43
+ const pattern = STORE_PATTERN[row.rule];
44
+ if (!pattern) return [rest];
45
+ if (row.rule === 'phone-international') return [{ ...rest, pattern, exclusions: [...(rest.exclusions ?? []), DOTTED_QUAD] }];
46
+ return [{ ...rest, pattern }];
47
+ }
48
+
49
+ /** The identifier rows as the stores read them: context dropped, except where shape alone is not evidence. */
50
+ export const STORAGE_IDENTIFIER_RULES: readonly EgressRule[] = IDENTIFIER_RULES.flatMap(storeRow);
15
51
 
16
52
  /** Counter key and tag per placeholder type; `CC` predates the table's `CARD`. */
17
53
  const TAG: Readonly<Record<string, string>> = { CARD: 'CC' };