@lyupro/codex-bridge 0.6.7 → 0.6.9

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/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Keeps one installation record for a package home that several hosts share: one image part, and
3
+ * one owner per host root (format 2, Plan_65 D5).
4
+ *
5
+ * The format-1 record was written whole by whichever host installed last and named no host at all:
6
+ * `install --host A`, then `install --host B` into one home left B's record alone (reproduced
7
+ * 2026-09-25), and uninstalling either would have removed the image under the other. Owners are
8
+ * keyed by the lexically normalized host root, never a resolved one. An old record migrates with
9
+ * only the migrating host as owner and the inventory marked incomplete (D6): it never recorded who
10
+ * else used the home, and the last writer is not proof of being the only one.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
14
+ import {
15
+ fileEntry,
16
+ INSTALL_METHOD_COPY,
17
+ INSTALL_METHOD_KEY,
18
+ validateInstallRecord,
19
+ } from './install-record.mjs';
20
+
21
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
22
+
23
+ export function isFormat2(parsed) {
24
+ return isObject(parsed) && parsed.format === 2;
25
+ }
26
+
27
+ export function imageFingerprint(image) {
28
+ const brand = image.fingerprints?.brand;
29
+ if (brand === undefined) return null;
30
+ const canonical = Object.keys(brand).sort()
31
+ .map((path) => `${path}\0${brand[path]}\n`).join('');
32
+ return createHash('sha256').update(canonical).digest('hex');
33
+ }
34
+
35
+ export function ownImageStampCurrent(rawRecord, host) {
36
+ if (!isFormat2(rawRecord)) return false;
37
+ const owner = rawRecord.owners?.[normalizeRepoPath(host.root)];
38
+ if (!owner) return false;
39
+ const fingerprint = imageFingerprint(rawRecord.image);
40
+ return fingerprint !== null && owner.imageFingerprint === fingerprint;
41
+ }
42
+
43
+ function filesFor(record, root) {
44
+ return record.files.map(fileEntry).filter((file) => file.root === root)
45
+ .map((file) => ({ root, path: file.path }));
46
+ }
47
+
48
+ function fingerprintsFor(record, root, files) {
49
+ if (record.fingerprints === undefined) return undefined;
50
+ const values = record.fingerprints[root]
51
+ ?? (root === 'claude' ? record.fingerprints : undefined)
52
+ ?? {};
53
+ const selected = {};
54
+ for (const file of files) {
55
+ if (values[file.path] !== undefined) selected[file.path] = values[file.path];
56
+ }
57
+ return { [root]: selected };
58
+ }
59
+
60
+ function ownerFrom(record, host) {
61
+ const files = filesFor(record, 'claude');
62
+ const fingerprints = fingerprintsFor(record, 'claude', files);
63
+ return {
64
+ root: host.root,
65
+ scope: host.scope,
66
+ version: record.version,
67
+ installedAt: record.installedAt,
68
+ files,
69
+ ...(fingerprints === undefined ? {} : { fingerprints }),
70
+ ...(record.rules === undefined ? {} : { rules: record.rules }),
71
+ hooks: (record.hooks ?? (record.hook === undefined ? [] : [record.hook])).map((hook) => (
72
+ hook.root ? { ...hook } : { ...hook, root: 'claude' }
73
+ )),
74
+ };
75
+ }
76
+
77
+ function imageFrom(record) {
78
+ const files = filesFor(record, 'brand');
79
+ const fingerprints = fingerprintsFor(record, 'brand', files);
80
+ return {
81
+ version: record.version,
82
+ installedAt: record.installedAt,
83
+ files,
84
+ ...(fingerprints === undefined ? {} : { fingerprints }),
85
+ };
86
+ }
87
+
88
+ function mergeFingerprints(image, owner) {
89
+ if (image.fingerprints === undefined && owner.fingerprints === undefined) return undefined;
90
+ return { ...(image.fingerprints ?? {}), ...(owner.fingerprints ?? {}) };
91
+ }
92
+
93
+ export function ownerView(record2, host) {
94
+ if (!isFormat2(record2)) return null;
95
+ const key = normalizeRepoPath(host.root);
96
+ const owner = record2.owners?.[key];
97
+ if (!owner) return null;
98
+ const view = {
99
+ name: record2.name,
100
+ [INSTALL_METHOD_KEY]: record2[INSTALL_METHOD_KEY],
101
+ version: owner.version,
102
+ installedAt: owner.installedAt,
103
+ files: [...record2.image.files, ...owner.files],
104
+ hooks: owner.hooks,
105
+ };
106
+ const fingerprints = mergeFingerprints(record2.image, owner);
107
+ if (fingerprints !== undefined) view.fingerprints = fingerprints;
108
+ if (owner.rules !== undefined) view.rules = owner.rules;
109
+ return view;
110
+ }
111
+
112
+ /** Decides whether the departing owner may remove the shared image using only inventory facts. */
113
+ export function imageRemoval(record2, host) {
114
+ validateFormat2(record2);
115
+ const key = normalizeRepoPath(host.root);
116
+ const isOwner = Object.hasOwn(record2.owners, key);
117
+ const remaining = Object.keys(record2.owners).length - Number(isOwner);
118
+ if (remaining > 0) return { removeImage: false, reason: 'other-owners', remaining };
119
+ if (record2.legacy !== undefined || record2.inventory !== 'complete') {
120
+ return { removeImage: false, reason: 'incomplete-inventory' };
121
+ }
122
+ if (isOwner) return { removeImage: true };
123
+ return { removeImage: false, reason: 'incomplete-inventory' };
124
+ }
125
+
126
+ /** Removes one host from the shared inventory while preserving its image and inventory facts. */
127
+ export function withoutOwner(record2, host) {
128
+ validateFormat2(record2);
129
+ const key = normalizeRepoPath(host.root);
130
+ if (!Object.hasOwn(record2.owners, key)) return record2;
131
+ const owners = { ...record2.owners };
132
+ delete owners[key];
133
+ return validateFormat2({ ...record2, owners });
134
+ }
135
+
136
+ export function withOwner(existing, host, record1, options) {
137
+ validateInstallRecord(record1);
138
+ const key = normalizeRepoPath(host.root);
139
+ const image = imageFrom(record1);
140
+ const owner = ownerFrom(record1, host);
141
+ const fingerprint = imageFingerprint(image);
142
+ if (fingerprint !== null) owner.imageFingerprint = fingerprint;
143
+
144
+ if (existing === null) {
145
+ // Only an explicit false proves no earlier installation used this home; unknown is incomplete.
146
+ const confirmedNoPriorImage = options?.homeHadImage === false;
147
+ const created = {
148
+ format: 2,
149
+ name: record1.name,
150
+ [INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
151
+ image,
152
+ inventory: options?.inventory === 'complete' || confirmedNoPriorImage ? 'complete' : 'incomplete',
153
+ owners: { [key]: owner },
154
+ };
155
+ return validateFormat2(created);
156
+ }
157
+
158
+ let previous;
159
+ if (isFormat2(existing)) {
160
+ previous = validateFormat2(existing);
161
+ } else {
162
+ if (Object.hasOwn(existing ?? {}, 'format')) {
163
+ throw new Error(`unknown installation record format: ${existing.format}`);
164
+ }
165
+ validateInstallRecord(existing);
166
+ previous = {
167
+ format: 2,
168
+ name: record1.name,
169
+ [INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
170
+ image,
171
+ inventory: 'incomplete',
172
+ owners: {},
173
+ legacy: existing,
174
+ };
175
+ }
176
+
177
+ // D9: the operator's "this host is the only one" answers what the legacy record could not tell,
178
+ // so the old record goes in the same publish instead of lingering as a second opinion.
179
+ const { legacy: _answered, ...withoutLegacy } = previous;
180
+ const base = options?.inventory === 'complete' ? { ...withoutLegacy, inventory: 'complete' } : previous;
181
+ const next = {
182
+ ...base,
183
+ name: record1.name,
184
+ [INSTALL_METHOD_KEY]: record1[INSTALL_METHOD_KEY],
185
+ image,
186
+ owners: { ...previous.owners, [key]: owner },
187
+ };
188
+ return validateFormat2(next);
189
+ }
190
+
191
+ function validatePartition(part, root, label) {
192
+ if (!isObject(part)) throw new Error(`installation record ${label} must be an object`);
193
+ if (!Array.isArray(part.files)) throw new Error(`installation record ${label} files must be a list`);
194
+ if (part.files.some((file) => fileEntry(file)?.root !== root)) {
195
+ throw new Error(`installation record ${label} files must use the ${root} root`);
196
+ }
197
+ if (part.fingerprints !== undefined) {
198
+ if (!isObject(part.fingerprints)
199
+ || Object.keys(part.fingerprints).some((key) => key !== root)) {
200
+ throw new Error(`installation record ${label} fingerprints must use the ${root} root`);
201
+ }
202
+ }
203
+ }
204
+
205
+ export function validateFormat2(parsed) {
206
+ if (!isObject(parsed) || parsed.format !== 2) {
207
+ throw new Error('installation record format must be 2');
208
+ }
209
+ if (parsed.inventory !== 'complete' && parsed.inventory !== 'incomplete') {
210
+ throw new Error('installation record inventory must be complete or incomplete');
211
+ }
212
+ if (!isObject(parsed.owners)) throw new Error('installation record owners must be an object');
213
+ if (Object.keys(parsed.owners).length === 0 && parsed.inventory !== 'incomplete') {
214
+ throw new Error('installation record with no owners must have an incomplete inventory');
215
+ }
216
+ validatePartition(parsed.image, 'brand', 'image');
217
+
218
+ for (const [key, owner] of Object.entries(parsed.owners)) {
219
+ if (!isObject(owner) || typeof owner.root !== 'string' || !owner.root) {
220
+ throw new Error(`installation record owner ${key} must have a root`);
221
+ }
222
+ if (normalizeRepoPath(owner.root) !== key) {
223
+ throw new Error(`installation record owner root must normalize to its key: ${key}`);
224
+ }
225
+ if (typeof owner.scope !== 'string' || !owner.scope) {
226
+ throw new Error(`installation record owner ${key} has invalid scope`);
227
+ }
228
+ if (Object.hasOwn(owner, 'imageFingerprint')
229
+ && (typeof owner.imageFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(owner.imageFingerprint))) {
230
+ throw new Error(`installation record owner ${key} has invalid imageFingerprint`);
231
+ }
232
+ validatePartition(owner, 'claude', `owner ${key}`);
233
+ const view = ownerView(parsed, { root: owner.root });
234
+ validateInstallRecord(view);
235
+ validateInstallRecord({ ...view, version: parsed.image.version, installedAt: parsed.image.installedAt });
236
+ }
237
+
238
+ if (typeof parsed.name !== 'string' || !parsed.name) throw new Error('installation record has invalid name');
239
+ if (parsed.legacy !== undefined) validateInstallRecord(parsed.legacy);
240
+ if (parsed[INSTALL_METHOD_KEY] !== INSTALL_METHOD_COPY) {
241
+ throw new Error(`installation record ${INSTALL_METHOD_KEY} must be ${INSTALL_METHOD_COPY}`);
242
+ }
243
+ return parsed;
244
+ }
@@ -1,8 +1,10 @@
1
1
  /** Reads, validates, and writes the installation record shared by installer commands. */
2
- import fs from 'node:fs/promises';
3
2
  import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
4
5
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
5
6
  import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
7
+ import { isFormat2, ownerView, validateFormat2, withOwner, withoutOwner } from './install-owners.mjs';
6
8
 
7
9
  /**
8
10
  * The record's own spelling of the field saying how the package was installed, and its one value.
@@ -244,6 +246,12 @@ function withoutSeededFiles(host, record) {
244
246
  return record;
245
247
  }
246
248
 
249
+ // Plan_65 H4 removes image files while preserving the seeded operator data through the shared filter.
250
+ export function imageMembers(record2, host) {
251
+ const image = withoutSeededFiles(host, { files: [...record2.image.files] });
252
+ return image.files;
253
+ }
254
+
247
255
  async function readAt(recordPath) {
248
256
  try {
249
257
  return await readJsonFile(recordPath);
@@ -264,18 +272,72 @@ export function legacyInstallRecordPath(host) {
264
272
  return path.join(host.legacyAgentsDir, LEGACY_INSTALL_RECORD_NAME);
265
273
  }
266
274
 
275
+ /**
276
+ * Reads the view owned by this host. Plan_65 D10 item 5: format-2 legacy only marks an
277
+ * incomplete inventory; it is never another host's view, enrollment template, fingerprint base,
278
+ * or source of createdGroup. Format-1 and older per-host records remain readable for migration.
279
+ */
267
280
  export async function readInstallRecord(host) {
268
- const parsed = await readAt(installRecordPath(host));
269
- const legacy = parsed === null ? await readAt(legacyInstallRecordPath(host)) : null;
270
- if (parsed === null && legacy === null) return null;
271
- return withoutSeededFiles(host, normalizeInstallRecord(parsed ?? legacy));
281
+ const parsed = await readInstallRecordFile(host);
282
+ if (parsed !== null) {
283
+ if (isFormat2(parsed)) {
284
+ validateFormat2(parsed);
285
+ const owned = ownerView(parsed, host);
286
+ return owned === null ? null : withoutSeededFiles(host, normalizeInstallRecord(owned));
287
+ }
288
+ return withoutSeededFiles(host, normalizeInstallRecord(parsed));
289
+ }
290
+ const legacy = await readAt(legacyInstallRecordPath(host));
291
+ return legacy === null ? null : withoutSeededFiles(host, normalizeInstallRecord(legacy));
272
292
  }
273
293
 
274
- export async function writeInstallRecord(host, record) {
275
- const normalized = normalizeInstallRecord(record);
294
+ export async function readInstallRecordFile(host) {
295
+ return readAt(installRecordPath(host));
296
+ }
297
+
298
+ async function publishInstallRecord(host, next) {
276
299
  const target = installRecordPath(host);
277
- await fs.mkdir(path.dirname(target), { recursive: true });
278
- await fs.writeFile(target, `${JSON.stringify(normalized, null, 2)}\n`);
300
+ const writer = createHomeWriter({ root: host.brandRoot });
301
+ const temporary = `${target}.${randomUUID()}.tmp`;
302
+ writer.assertArtifact('install-record', target);
303
+ writer.assertArtifact('install-record', temporary);
304
+ await writer.mkdir('install-record', path.dirname(target), { recursive: true });
305
+ await writer.writeFile('install-record', temporary, `${JSON.stringify(next, null, 2)}\n`);
306
+ await writer.rename('install-record', temporary, target);
307
+ }
308
+
309
+ /**
310
+ * Publishes the record whole through a temporary and a rename: a direct write left a half-written
311
+ * record for every host sharing the home if the process died mid-write (advice A3, risk r3).
312
+ * `homeHadImage: false` is the caller's proof that no earlier installation used this home; without
313
+ * it a new record starts with an incomplete inventory (D6).
314
+ * inventory: 'complete' records the operator's D9 confirmation and drops a migrated legacy record
315
+ * in the same publish.
316
+ */
317
+ export async function writeInstallRecord(host, record, { homeHadImage, inventory } = {}) {
318
+ const normalized = normalizeInstallRecord(record);
319
+ const next = withOwner(await readAt(installRecordPath(host)), host, normalized, { homeHadImage, inventory });
320
+ await publishInstallRecord(host, next);
321
+ }
322
+
323
+ /**
324
+ * Reads a parsed record as format 2, migrating a format-1 one in memory with `host` as its only
325
+ * owner and an incomplete inventory (D6). Uninstall decides and records from this one view, so the
326
+ * dry run and the real run cannot migrate differently; an unknown format throws.
327
+ */
328
+ export function asFormat2(parsed, host) {
329
+ return isFormat2(parsed) ? parsed : withOwner(parsed, host, normalizeInstallRecord(parsed));
330
+ }
331
+
332
+ /** Removes one host from the shared record so uninstall can decide what the inventory permits. */
333
+ export async function removeInstallOwner(host) {
334
+ const parsed = await readAt(installRecordPath(host));
335
+ if (parsed === null) return null;
336
+ const record2 = asFormat2(parsed, host);
337
+ const next = withoutOwner(record2, host);
338
+ if (next === record2) return next;
339
+ await publishInstallRecord(host, next);
340
+ return next;
279
341
  }
280
342
 
281
343
  export function fingerprintFor(record, file) {
package/cli/install.mjs CHANGED
@@ -14,19 +14,28 @@ import {
14
14
  seedPlan,
15
15
  writeInstallRecord,
16
16
  } from './manifest.mjs';
17
- import { copyPlannedFile, targetMatches } from './copy.mjs';
17
+ import { copyPlannedFile, planHomeWriter, targetMatches } from './copy.mjs';
18
+ import { removeOutside } from './record-removal.mjs';
18
19
  import { hookTargets, recordHasHooks } from './hook-targets.mjs';
19
- import { fingerprintFor, INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
20
+ import { fingerprintFor, readInstallRecordFile } from './install-record.mjs';
21
+ import { ownImageStampCurrent } from './install-owners.mjs';
22
+ import { syncOtherOwners } from './owner-sync-apply.mjs';
23
+ import { ownerRecord } from './owner-record.mjs';
20
24
  import {
21
25
  commandFor,
22
26
  inspectHook,
23
27
  mergeHook,
24
28
  withSettingsRun,
25
29
  } from './settings-merge.mjs';
30
+ import { withLifecycle } from './lifecycle-transaction.mjs';
26
31
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
27
32
  import { addPermissionRules, inspectPermissions } from './permissions.mjs';
28
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
33
+ import { contractStatus, readHostContract } from './host-contract.mjs';
34
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
35
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
36
+ import { sessionHostVersions } from './session-hosts.mjs';
29
37
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
38
+ import { askTransition, detectTransition, transitionOutcome } from './inventory-transition.mjs';
30
39
 
31
40
  const WARNING = '\u001b[33m';
32
41
  const RESET = '\u001b[0m';
@@ -84,7 +93,7 @@ function permissionOutput(result, settingsPath) {
84
93
  return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
85
94
  }
86
95
 
87
- async function migrateLegacySeed(host, seed) {
96
+ async function migrateLegacySeed(host, seed, writer, id) {
88
97
  if (await targetExists(seed.target)) return;
89
98
  const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
90
99
  const legacy = path.join(host.legacyAgentsDir, legacyName);
@@ -95,32 +104,60 @@ async function migrateLegacySeed(host, seed) {
95
104
  // so the previous layout is never taken down and the operator goes on editing a file nothing
96
105
  // reads. If the comparison fails the old file stays: a half-copied config is the one case where
97
106
  // having two is better than having none.
98
- await fs.mkdir(path.dirname(seed.target), { recursive: true });
99
- await fs.copyFile(legacy, seed.target);
107
+ // Plan_65 B10: the seed lands in the home only by its registry id, and the old copy under the
108
+ // host agents directory is removed only after the adapter proves it lies outside the home.
109
+ await writer.mkdir(id, path.dirname(seed.target));
110
+ await writer.copyFile(id, legacy, seed.target);
100
111
  if (await fileFingerprint(legacy) !== await fileFingerprint(seed.target)) return;
101
- await fs.rm(legacy, { force: true });
112
+ await removeOutside(writer, legacy);
102
113
  }
103
114
 
104
- async function installInRun({
105
- host,
106
- dryRun = false,
107
- force = false,
108
- packageRoot,
109
- env = process.env,
110
- contractRecord,
111
- hostVersion,
112
- } = {}) {
115
+ async function ownerSyncOutput(host, { packageRoot, env, dryRun }, output) {
116
+ const ownerSync = await syncOtherOwners(host, { packageRoot, env, dryRun });
117
+ // Update reads ownerSync to tell "another owner lags" from "this host failed" (Plan_65 B13e2d).
118
+ return {
119
+ exitCode: ownerSync.complete ? 0 : 1,
120
+ output: ownerSync.lines.length ? `${output}\n${ownerSync.lines.join('\n')}` : output,
121
+ ownerSync,
122
+ };
123
+ }
124
+
125
+ async function installInRun(options = {}) {
126
+ const {
127
+ host,
128
+ dryRun = false,
129
+ force = false,
130
+ packageRoot,
131
+ env = process.env,
132
+ contractRecord,
133
+ hostVersion,
134
+ observations,
135
+ inventoryTransition,
136
+ } = options;
137
+ const transitionState = inventoryTransition === undefined
138
+ ? await detectTransition(host) : inventoryTransition;
139
+ const { transition, homeHadImage, inventory: suppliedInventory } = transitionState;
140
+ let inventory = suppliedInventory;
113
141
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
114
142
  await readRulesRegistry(host);
115
143
  const configuredRetentionLine = retentionLine(host);
116
- const hostContract = contractStatus({
144
+ const hostObservations = observations === undefined
145
+ ? readHostObservations({ stateDir: brandStateDir(host.brandRoot) }) : observations;
146
+ const observedVersion = sessionHostVersions(hostObservations)[0] ?? null;
147
+ const currentHostVersion = hostVersion === undefined ? observedVersion : hostVersion;
148
+ let hostContract = contractStatus({
117
149
  record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
118
- version: hostVersion === undefined ? detectHostVersion() : hostVersion,
150
+ version: currentHostVersion,
119
151
  });
152
+ const noObservedHostMessage = currentHostVersion === null
153
+ ? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
154
+ if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
120
155
  const plan = await buildInstallPlan(host, packageRoot);
156
+ const { writer, idFor: copyId } = planHomeWriter(host.brandRoot, plan);
121
157
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
122
158
  const currentPackage = await packageInfo(packageRoot);
123
159
  const record = await readInstallRecord(host);
160
+ const rawRecord = await readInstallRecordFile(host);
124
161
  const targets = hookTargets(host, env);
125
162
  for (const target of targets) {
126
163
  if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
@@ -156,33 +193,36 @@ async function installInRun({
156
193
  };
157
194
  }
158
195
 
196
+ if (inventoryTransition === undefined && transition && !dryRun) {
197
+ inventory = await askTransition(host, options);
198
+ if (inventory === 'cancel') {
199
+ return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
200
+ }
201
+ }
202
+
159
203
  const changedFiles = states.filter((state) => !state.matches);
160
204
  const changedRule = !ruleState.matches;
161
205
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
162
206
  new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
163
207
  { path: rule.target, fingerprint: ruleState.fingerprint });
164
- if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
165
- && recordHasHooks(record, targets) && sameRecord) {
208
+ // D11: the command doctor recommends must clear its warning about this host's image stamp.
209
+ if (!transition && !changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
210
+ && recordHasHooks(record, targets) && sameRecord && ownImageStampCurrent(rawRecord, host)) {
166
211
  if (!dryRun) {
167
212
  await addRulesOwner(host);
168
213
  // Read, never write: this branch's whole claim is that there is nothing to do, and a
169
214
  // sentence about permission rules must not be paid for by quietly restoring one.
170
215
  const permissionResult = await inspectPermissions(host.settingsPath);
171
- return {
172
- exitCode: 0,
173
- output: contractOutput(
174
- hostContract,
175
- retentionOutput(
176
- configuredRetentionLine,
177
- `codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
178
- ),
216
+ return ownerSyncOutput(host, { packageRoot, env, dryRun }, contractOutput(
217
+ hostContract,
218
+ retentionOutput(
219
+ configuredRetentionLine,
220
+ `codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
179
221
  ),
180
- };
222
+ ));
181
223
  }
182
- return {
183
- exitCode: 0,
184
- output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
185
- };
224
+ return ownerSyncOutput(host, { packageRoot, env, dryRun },
225
+ contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')));
186
226
  }
187
227
 
188
228
  if (dryRun) {
@@ -199,22 +239,28 @@ async function installInRun({
199
239
  ? `${definition.event} hook is already registered (${registration.form} command).`
200
240
  : `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
201
241
  });
242
+ if (transition) lines.push(`A real run would ask whether this host is the only one using ${host.brandRoot}.`);
202
243
  lines.push('Would write installation record in the brand root.');
203
- return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
244
+ return ownerSyncOutput(host, { packageRoot, env, dryRun },
245
+ contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))));
204
246
  }
205
247
 
206
248
  // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
207
249
  // failure at that step left a fully installed host absent from the registry, and the next
208
250
  // uninstall elsewhere would then delete the rules out from under it.
209
251
  await addRulesOwner(host);
210
- for (const { item } of changedFiles) await copyPlannedFile(item, host.brandRoot);
211
- if (changedRule) await copyPlannedFile(rule, host.brandRoot);
252
+ for (const { item } of changedFiles) {
253
+ await copyPlannedFile(item, host.brandRoot, { writer, id: copyId(item) });
254
+ }
255
+ if (changedRule) await copyPlannedFile(rule, host.brandRoot, { writer, id: copyId(rule) });
212
256
  // Seeded files are written once and then belong to the operator: an existing one is left
213
257
  // exactly as it is, including under --force, because --force is about our files, not theirs.
214
258
  for (const seed of seedPlan(host, packageRoot)) {
215
259
  if (!(await targetExists(seed.target))) {
216
- await migrateLegacySeed(host, seed);
217
- if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.brandRoot);
260
+ await migrateLegacySeed(host, seed, writer, copyId(seed));
261
+ if (!(await targetExists(seed.target))) {
262
+ await copyPlannedFile(seed, host.brandRoot, { writer, id: copyId(seed) });
263
+ }
218
264
  }
219
265
  }
220
266
  const hookResults = [];
@@ -228,49 +274,27 @@ async function installInRun({
228
274
  const permissionResult = dryRun
229
275
  ? { dryRun: true, added: 0, present: 0, total: 0 }
230
276
  : await addPermissionRules(host.settingsPath);
231
- const fingerprints = {};
232
- for (const item of plan) {
233
- fingerprints[item.root] ??= {};
234
- fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
235
- }
236
- const hooks = targets.map(({ definition, relative, registration }, index) => {
237
- const prior = record?.hooks?.find((hook) => hook.event === definition.event
238
- && hook.root === 'brand' && hook.path === relative);
239
- const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
240
- const command = registration.command;
241
- return {
242
- event: definition.event,
243
- root: 'brand',
244
- path: relative,
245
- command,
246
- form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
247
- ...(createdGroup ? { createdGroup: true } : {}),
248
- };
249
- });
250
- await writeInstallRecord(host, {
251
- ...currentPackage,
252
- installedAt: new Date().toISOString(),
253
- [INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
254
- files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
255
- fingerprints,
256
- rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
257
- hooks,
258
- });
259
- return {
260
- exitCode: 0,
261
- output: contractOutput(
262
- hostContract,
263
- retentionOutput(
264
- configuredRetentionLine,
265
- `Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
266
- + `\n${permissionOutput(permissionResult, host.settingsPath)}`,
267
- ),
277
+ const row = await ownerRecord({ plan, rule, targets, hookResults, prior: record, currentPackage });
278
+ await writeInstallRecord(host, row, { homeHadImage, inventory });
279
+ const transitionLine = transition ? `\n${transitionOutcome(host, inventory)}` : '';
280
+ return ownerSyncOutput(host, { packageRoot, env, dryRun }, contractOutput(
281
+ hostContract,
282
+ retentionOutput(
283
+ configuredRetentionLine,
284
+ `Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
285
+ + `\n${permissionOutput(permissionResult, host.settingsPath)}${transitionLine}`,
268
286
  ),
269
- };
287
+ ));
270
288
  }
271
289
 
272
290
  export async function install(options = {}) {
273
291
  const host = options?.host;
274
292
  if (!host?.settingsPath) return installInRun(options);
275
- return withSettingsRun(host.settingsPath, () => installInRun(options));
293
+ if (options.dryRun === true) return withSettingsRun(host.settingsPath, () => installInRun(options));
294
+ return withLifecycle(
295
+ host,
296
+ 'install',
297
+ () => withSettingsRun(host.settingsPath, () => installInRun(options)),
298
+ { ticket: options.lifecycleTicket, waitMs: options.lifecycleWaitMs, createHome: true },
299
+ );
276
300
  }