@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  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 +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  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 +7 -4
  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 +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -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/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
@@ -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,15 +14,20 @@ 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
33
  import { contractStatus, readHostContract } from './host-contract.mjs';
@@ -30,6 +35,7 @@ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
30
35
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
31
36
  import { sessionHostVersions } from './session-hosts.mjs';
32
37
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
38
+ import { askTransition, detectTransition, transitionOutcome } from './inventory-transition.mjs';
33
39
 
34
40
  const WARNING = '\u001b[33m';
35
41
  const RESET = '\u001b[0m';
@@ -87,7 +93,7 @@ function permissionOutput(result, settingsPath) {
87
93
  return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
88
94
  }
89
95
 
90
- async function migrateLegacySeed(host, seed) {
96
+ async function migrateLegacySeed(host, seed, writer, id) {
91
97
  if (await targetExists(seed.target)) return;
92
98
  const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
93
99
  const legacy = path.join(host.legacyAgentsDir, legacyName);
@@ -98,22 +104,40 @@ async function migrateLegacySeed(host, seed) {
98
104
  // so the previous layout is never taken down and the operator goes on editing a file nothing
99
105
  // reads. If the comparison fails the old file stays: a half-copied config is the one case where
100
106
  // having two is better than having none.
101
- await fs.mkdir(path.dirname(seed.target), { recursive: true });
102
- 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);
103
111
  if (await fileFingerprint(legacy) !== await fileFingerprint(seed.target)) return;
104
- await fs.rm(legacy, { force: true });
112
+ await removeOutside(writer, legacy);
105
113
  }
106
114
 
107
- async function installInRun({
108
- host,
109
- dryRun = false,
110
- force = false,
111
- packageRoot,
112
- env = process.env,
113
- contractRecord,
114
- hostVersion,
115
- observations,
116
- } = {}) {
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;
117
141
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
118
142
  await readRulesRegistry(host);
119
143
  const configuredRetentionLine = retentionLine(host);
@@ -129,9 +153,11 @@ async function installInRun({
129
153
  ? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
130
154
  if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
131
155
  const plan = await buildInstallPlan(host, packageRoot);
156
+ const { writer, idFor: copyId } = planHomeWriter(host.brandRoot, plan);
132
157
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
133
158
  const currentPackage = await packageInfo(packageRoot);
134
159
  const record = await readInstallRecord(host);
160
+ const rawRecord = await readInstallRecordFile(host);
135
161
  const targets = hookTargets(host, env);
136
162
  for (const target of targets) {
137
163
  if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
@@ -167,33 +193,36 @@ async function installInRun({
167
193
  };
168
194
  }
169
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
+
170
203
  const changedFiles = states.filter((state) => !state.matches);
171
204
  const changedRule = !ruleState.matches;
172
205
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
173
206
  new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
174
207
  { path: rule.target, fingerprint: ruleState.fingerprint });
175
- if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
176
- && 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)) {
177
211
  if (!dryRun) {
178
212
  await addRulesOwner(host);
179
213
  // Read, never write: this branch's whole claim is that there is nothing to do, and a
180
214
  // sentence about permission rules must not be paid for by quietly restoring one.
181
215
  const permissionResult = await inspectPermissions(host.settingsPath);
182
- return {
183
- exitCode: 0,
184
- output: contractOutput(
185
- hostContract,
186
- retentionOutput(
187
- configuredRetentionLine,
188
- `codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
189
- ),
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)}`,
190
221
  ),
191
- };
222
+ ));
192
223
  }
193
- return {
194
- exitCode: 0,
195
- output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
196
- };
224
+ return ownerSyncOutput(host, { packageRoot, env, dryRun },
225
+ contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')));
197
226
  }
198
227
 
199
228
  if (dryRun) {
@@ -210,22 +239,28 @@ async function installInRun({
210
239
  ? `${definition.event} hook is already registered (${registration.form} command).`
211
240
  : `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
212
241
  });
242
+ if (transition) lines.push(`A real run would ask whether this host is the only one using ${host.brandRoot}.`);
213
243
  lines.push('Would write installation record in the brand root.');
214
- 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'))));
215
246
  }
216
247
 
217
248
  // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
218
249
  // failure at that step left a fully installed host absent from the registry, and the next
219
250
  // uninstall elsewhere would then delete the rules out from under it.
220
251
  await addRulesOwner(host);
221
- for (const { item } of changedFiles) await copyPlannedFile(item, host.brandRoot);
222
- 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) });
223
256
  // Seeded files are written once and then belong to the operator: an existing one is left
224
257
  // exactly as it is, including under --force, because --force is about our files, not theirs.
225
258
  for (const seed of seedPlan(host, packageRoot)) {
226
259
  if (!(await targetExists(seed.target))) {
227
- await migrateLegacySeed(host, seed);
228
- 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
+ }
229
264
  }
230
265
  }
231
266
  const hookResults = [];
@@ -239,49 +274,27 @@ async function installInRun({
239
274
  const permissionResult = dryRun
240
275
  ? { dryRun: true, added: 0, present: 0, total: 0 }
241
276
  : await addPermissionRules(host.settingsPath);
242
- const fingerprints = {};
243
- for (const item of plan) {
244
- fingerprints[item.root] ??= {};
245
- fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
246
- }
247
- const hooks = targets.map(({ definition, relative, registration }, index) => {
248
- const prior = record?.hooks?.find((hook) => hook.event === definition.event
249
- && hook.root === 'brand' && hook.path === relative);
250
- const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
251
- const command = registration.command;
252
- return {
253
- event: definition.event,
254
- root: 'brand',
255
- path: relative,
256
- command,
257
- form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
258
- ...(createdGroup ? { createdGroup: true } : {}),
259
- };
260
- });
261
- await writeInstallRecord(host, {
262
- ...currentPackage,
263
- installedAt: new Date().toISOString(),
264
- [INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
265
- files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
266
- fingerprints,
267
- rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
268
- hooks,
269
- });
270
- return {
271
- exitCode: 0,
272
- output: contractOutput(
273
- hostContract,
274
- retentionOutput(
275
- configuredRetentionLine,
276
- `Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
277
- + `\n${permissionOutput(permissionResult, host.settingsPath)}`,
278
- ),
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}`,
279
286
  ),
280
- };
287
+ ));
281
288
  }
282
289
 
283
290
  export async function install(options = {}) {
284
291
  const host = options?.host;
285
292
  if (!host?.settingsPath) return installInRun(options);
286
- 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
+ );
287
300
  }