@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
@@ -13,7 +13,8 @@ import { randomUUID } from 'node:crypto';
13
13
  import { BRAND_CONFIG_PATH } from './brand-home.mjs';
14
14
  import { parseJsonText } from './json-file.mjs';
15
15
  import { validateRunConfig } from './config-validate.mjs';
16
- import { withFileLock } from './file-lock.mjs';
16
+ import { createHomeWriter } from './home-write.mjs';
17
+ import { withHomeFileLock } from './file-lock.mjs';
17
18
 
18
19
  function replaceValue(file, raw, key, value) {
19
20
  // The shared reader has already checked JSON syntax; tokens locate top-level value spans.
@@ -72,6 +73,14 @@ async function readBytes(file) {
72
73
  }
73
74
  }
74
75
 
76
+ async function removeTemporary(writer, temporary) {
77
+ try {
78
+ await writer.unlink('config', temporary);
79
+ } catch (error) {
80
+ if (error.code !== 'ENOENT') throw error;
81
+ }
82
+ }
83
+
75
84
  function readBytesSync(file) {
76
85
  try {
77
86
  return fsSync.readFileSync(file);
@@ -82,15 +91,17 @@ function readBytesSync(file) {
82
91
  }
83
92
 
84
93
  export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
94
+ const directory = path.dirname(path.resolve(file));
95
+ const writer = createHomeWriter({ root: directory });
96
+ writer.assertArtifact('config', file);
85
97
  const reset = key !== null && typeof key === 'object' && !Array.isArray(key)
86
98
  && key.reset === true && Object.keys(key).length === 1 && transform === undefined;
87
99
  if (!reset && (typeof key !== 'string' || !key || typeof transform !== 'function')) {
88
100
  throw new Error('Config edit requires a non-empty string key and a transformer function or { reset: true }');
89
101
  }
90
- const directory = path.dirname(file);
91
- const created = await fs.mkdir(directory, { recursive: true });
102
+ const created = await writer.mkdir('config', directory, { recursive: true });
92
103
  try {
93
- return await withFileLock(`${file}.lock`, async () => {
104
+ return await withHomeFileLock(writer, 'config', `${file}.lock`, async () => {
94
105
  let collision;
95
106
  for (let attempt = 0; attempt < 3; attempt += 1) {
96
107
  const original = await readBytes(file);
@@ -109,9 +120,9 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
109
120
  }
110
121
  // Validate the exact bytes to be persisted, before even creating a temporary file (D27).
111
122
  const config = validateRunConfig(file, parseJsonText(file, text));
112
- await fs.mkdir(path.dirname(file), { recursive: true });
123
+ await writer.mkdir('config', directory, { recursive: true });
113
124
  const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
114
- const handle = await fs.open(temporary, 'wx');
125
+ const handle = await writer.open('config', temporary, 'wx');
115
126
  try {
116
127
  await handle.writeFile(text, 'utf8');
117
128
  await handle.close();
@@ -121,14 +132,14 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
121
132
  // suspension point between the two, so a competing edit is seen by whoever compares second.
122
133
  const current = readBytesSync(file);
123
134
  if (original === null ? current !== null : current === null || !original.equals(current)) {
124
- await fs.rm(temporary, { force: true });
135
+ await removeTemporary(writer, temporary);
125
136
  continue;
126
137
  }
127
- fsSync.renameSync(temporary, file);
138
+ writer.renameSync('config', temporary, file);
128
139
  return config;
129
140
  } catch (error) {
130
141
  await handle.close().catch(() => {});
131
- await fs.rm(temporary, { force: true });
142
+ await removeTemporary(writer, temporary);
132
143
  // Windows refuses the rename outright when another process is publishing onto the same name
133
144
  // at that instant: two concurrent `model set` calls returned EPERM here on a live probe, and
134
145
  // one operator edit was lost with the file system's wording instead of an answer. A collision
@@ -149,9 +160,13 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
149
160
  if (created) {
150
161
  // Windows mkdir returns a namespaced path; compare that form on both sides of the boundary.
151
162
  const boundary = path.toNamespacedPath(path.resolve(created));
163
+ const homeRoot = path.toNamespacedPath(path.resolve(directory));
152
164
  for (let current = path.toNamespacedPath(path.resolve(directory));
153
165
  current === boundary || current.startsWith(`${boundary}${path.sep}`); current = path.dirname(current)) {
154
- try { await fs.rmdir(current); } catch { break; }
166
+ try {
167
+ if (current === homeRoot) writer.rmdirSync('config', directory);
168
+ else await fs.rmdir(current);
169
+ } catch { break; }
155
170
  }
156
171
  }
157
172
  throw error;
@@ -4,7 +4,7 @@ import {
4
4
  extractValue,
5
5
  isAbsoluteTaskFilePath,
6
6
  isInputPlaceholder,
7
- parseContinuationGrant,
7
+ parseGrant,
8
8
  REQUIRED_INPUTS,
9
9
  } from './required-inputs.mjs';
10
10
  import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
@@ -69,7 +69,9 @@ export function canonicalRunCommand(agentType, promptText) {
69
69
  tokens.push('--order-id', quote(inputs.get('order id')));
70
70
  tokens.push('--task-file', quote(inputs.get('task file')));
71
71
  if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
72
- if (parseContinuationGrant(promptText)) tokens.push('--continue');
72
+ // Plan_75 D1, TradeForge capacity incident: forward either grant; the runner owns conflicting-grant refusal.
73
+ const grant = parseGrant(promptText);
74
+ if (grant?.kind || grant?.error) tokens.push('--continue');
73
75
  return { command: tokens.join(' ') };
74
76
  }
75
77
 
@@ -2,9 +2,14 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { writeJsonAtomic } from './atomic-json.mjs';
5
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
6
6
  import { parseJsonText } from './json-file.mjs';
7
- import { withFileLock } from './file-lock.mjs';
7
+ import { withHomeFileLock } from './file-lock.mjs';
8
+ import { stateDirWriter } from './home-write.mjs';
9
+
10
+ // Plan_65 B5: every write goes through the registry id so purge accounts for records, locks and
11
+ // temporaries alike.
12
+ const ARTIFACT = 'dispatcher-state';
8
13
 
9
14
  function assertIdentity(sessionId, agentId) {
10
15
  if (typeof sessionId !== 'string' || sessionId.length === 0
@@ -55,7 +60,8 @@ export async function updateDispatcherState(ids, mutate) {
55
60
  if (typeof mutate !== 'function') throw new TypeError('mutate must be a function');
56
61
  const file = dispatcherStatePath(ids);
57
62
  const { sessionId, agentId } = ids;
58
- return withFileLock(`${file}.lock`, async () => {
63
+ const writer = stateDirWriter(ids.stateDir);
64
+ return withHomeFileLock(writer, ARTIFACT, `${file}.lock`, async () => {
59
65
  let current = readDispatcherState(ids);
60
66
  if (current === null) {
61
67
  current = { sessionId, agentId, createdAt: timestamp(clockValue(ids)) };
@@ -71,7 +77,7 @@ export async function updateDispatcherState(ids, mutate) {
71
77
  agentId,
72
78
  updatedAt: timestamp(clockValue(ids)),
73
79
  };
74
- writeJsonAtomic(file, record);
80
+ writeHomeJsonAtomic(writer, ARTIFACT, file, record);
75
81
  return record;
76
82
  }, { description: 'dispatcher state' });
77
83
  }
@@ -86,11 +92,19 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
86
92
  throw error;
87
93
  }
88
94
 
95
+ const writer = stateDirWriter(stateDir);
89
96
  const cutoff = now - olderThanMs;
90
97
  let removed = 0;
91
98
  for (const entry of entries) {
92
99
  if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
93
100
  const file = path.join(directory, entry.name);
101
+ // Only the registry's 32-hex records are ours to prune; any other name here is kept (Plan_65 D3).
102
+ try {
103
+ writer.assertArtifact(ARTIFACT, file);
104
+ } catch (error) {
105
+ if (error.code === 'EHOMEREGISTRY') continue;
106
+ throw error;
107
+ }
94
108
  let old;
95
109
  try {
96
110
  old = fs.statSync(file).mtimeMs < cutoff;
@@ -99,13 +113,14 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
99
113
  throw error;
100
114
  }
101
115
  if (!old) continue;
102
- try {
103
- fs.unlinkSync(file);
104
- removed += 1;
105
- } catch (error) {
106
- if (error.code !== 'ENOENT') throw error;
116
+ for (const target of [file, `${file}.lock`]) {
117
+ try {
118
+ writer.unlinkSync(ARTIFACT, target);
119
+ if (target === file) removed += 1;
120
+ } catch (error) {
121
+ if (error.code !== 'ENOENT') throw error;
122
+ }
107
123
  }
108
- fs.rmSync(`${file}.lock`, { force: true });
109
124
  }
110
125
  return removed;
111
126
  }
@@ -15,11 +15,11 @@ function waitForLock(delayMs) {
15
15
  * 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
16
16
  * belongs to a process that is gone.
17
17
  */
18
- async function dropStaleLock(lockPath, staleMs) {
18
+ async function dropStaleLock(lockPath, staleMs, remove) {
19
19
  try {
20
20
  const { mtimeMs } = await fs.stat(lockPath);
21
21
  if (Date.now() - mtimeMs < staleMs) return false;
22
- await fs.rm(lockPath, { force: true });
22
+ await remove(lockPath);
23
23
  return true;
24
24
  } catch {
25
25
  // Vanished or unreadable — let the next open() decide rather than guess here.
@@ -42,17 +42,17 @@ export function isLockTaken(err) {
42
42
  return LOCK_TAKEN_CODES.has(err?.code);
43
43
  }
44
44
 
45
- async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }) {
46
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
45
+ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, operations) {
46
+ await operations.mkdir(path.dirname(lockPath), { recursive: true });
47
47
  let lastTaken;
48
48
  for (let attempt = 0; attempt < retries; attempt += 1) {
49
49
  try {
50
- const handle = await fs.open(lockPath, 'wx');
51
- return { handle, lockPath };
50
+ const handle = await operations.open(lockPath);
51
+ return { handle, lockPath, remove: operations.remove };
52
52
  } catch (err) {
53
53
  if (!isLockTaken(err)) throw err;
54
54
  lastTaken = err;
55
- if (!(await dropStaleLock(lockPath, staleMs))) await waitForLock(delayMs);
55
+ if (!(await dropStaleLock(lockPath, staleMs, operations.remove))) await waitForLock(delayMs);
56
56
  }
57
57
  }
58
58
  // The code is part of the message: a timeout on EPERM points at a delete that never completed,
@@ -63,14 +63,45 @@ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, descriptio
63
63
  );
64
64
  }
65
65
 
66
- export async function withFileLock(lockPath, action, {
66
+ export async function withOutsideFileLock(writer, lockPath, action, {
67
67
  retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
68
68
  } = {}) {
69
- const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description });
69
+ writer.assertOutside(lockPath);
70
+ const remove = (target) => fs.rm(target, { force: true });
71
+ const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
72
+ mkdir(directory, options) { return fs.mkdir(directory, options); },
73
+ open(lockFile) { return fs.open(lockFile, 'wx'); },
74
+ remove,
75
+ });
70
76
  try {
71
77
  return await action();
72
78
  } finally {
73
79
  await lock.handle.close().catch(() => {});
74
- await fs.rm(lock.lockPath, { force: true }).catch(() => {});
80
+ await lock.remove(lock.lockPath).catch(() => {});
81
+ }
82
+ }
83
+
84
+ export async function withHomeFileLock(writer, id, lockPath, action, {
85
+ retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
86
+ } = {}) {
87
+ // Plan_65 B2: the lock's parent is shared by artifacts, so a wrong id is refused before mkdir.
88
+ writer.assertArtifact(id, lockPath);
89
+ const remove = async (target) => {
90
+ try {
91
+ return await writer.unlink(id, target);
92
+ } catch (error) {
93
+ if (error?.code !== 'ENOENT') throw error;
94
+ }
95
+ };
96
+ const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
97
+ mkdir(directory, options) { return writer.mkdir(id, directory, options); },
98
+ open(lockFile) { return writer.open(id, lockFile, 'wx'); },
99
+ remove,
100
+ });
101
+ try {
102
+ return await action();
103
+ } finally {
104
+ await lock.handle.close().catch(() => {});
105
+ await lock.remove(lock.lockPath).catch(() => {});
75
106
  }
76
107
  }
@@ -1,9 +1,10 @@
1
1
  /** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { writeJsonAtomic } from './atomic-json.mjs';
4
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
5
5
  import { parseJsonText } from './json-file.mjs';
6
- import { withFileLock } from './file-lock.mjs';
6
+ import { withHomeFileLock } from './file-lock.mjs';
7
+ import { stateDirWriter } from './home-write.mjs';
7
8
  import { transcriptHostVersion } from './host-version.mjs';
8
9
 
9
10
  export const WITNESS_FILE = 'handback-witness.json';
@@ -57,14 +58,16 @@ export async function recordHandbackWitness({ stateDir, kind, hostVersion, detai
57
58
  throw new TypeError('hostVersion must be a string or null');
58
59
  }
59
60
  const file = path.join(stateDir, WITNESS_FILE);
60
- return withFileLock(`${file}.lock`, async () => {
61
+ // Plan_65 B6: the witness, its lock and temporaries are the registered handback-witness artifact.
62
+ const writer = stateDirWriter(stateDir);
63
+ return withHomeFileLock(writer, 'handback-witness', `${file}.lock`, async () => {
61
64
  const current = readHandbackWitness({ stateDir });
62
65
  if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
63
66
  const at = timestamp(now);
64
67
  const record = kind === 'seen'
65
68
  ? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
66
69
  : { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
67
- writeJsonAtomic(file, record);
70
+ writeHomeJsonAtomic(writer, 'handback-witness', file, record);
68
71
  return record;
69
72
  }, { description: 'handback witness' });
70
73
  }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Declares every file the package may write into its home, each with the removal class it belongs to.
3
+ *
4
+ * `uninstall` removed only what the installation record listed, and until 0.6.7 said nothing about
5
+ * the rest: `state/` fell out of its message, and every next file a feature added would have too
6
+ * (Plan_62 D22). A registry the writers must name makes an undeclared file unwritable instead of
7
+ * forgotten. Side files (locks, temporaries) are spelled here and nowhere else, because a purge that
8
+ * knows the target but not its `.lock` leaves the lock behind. Image members are supplied by the
9
+ * caller: the home image ships without `cli/`, so this module cannot read the manifest itself.
10
+ */
11
+ import path from 'node:path';
12
+
13
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
14
+
15
+ // Plan_62 D22 requires side files to be as explicit as their owning artifacts.
16
+ const SIDE_SPELLINGS = Object.freeze({
17
+ lock: /^(.+)\.lock$/,
18
+ 'clear-gate': /^(.+)\.lock\.clear$/,
19
+ 'atomic-temporary': new RegExp(`^(.+)\\.${UUID}\\.tmp$`),
20
+ 'dot-temporary': new RegExp(`^\\.(.+)\\.${UUID}\\.tmp$`),
21
+ 'copy-temporary': new RegExp(`^\\.(.+)\\.${UUID}\\.tmp$`),
22
+ 'pid-temporary': new RegExp(`^(.+)\\.[0-9]+\\.${UUID}\\.tmp$`),
23
+ });
24
+
25
+ const entries = [
26
+ {
27
+ id: 'install-image',
28
+ removal: 'install-owned',
29
+ consequence: "The package's code and guards; reinstall restores them.",
30
+ primary: [],
31
+ sides: ['copy-temporary'],
32
+ },
33
+ {
34
+ id: 'install-record',
35
+ removal: 'install-owned',
36
+ consequence: 'The installation record is lost and uninstall can no longer identify installed files.',
37
+ primary: ['.installed.json'],
38
+ sides: ['lock', 'clear-gate', 'atomic-temporary'],
39
+ },
40
+ {
41
+ id: 'config',
42
+ removal: 'purge-only',
43
+ consequence: "The operator's model and retention settings are lost.",
44
+ primary: ['config.json'],
45
+ sides: ['lock', 'dot-temporary'],
46
+ },
47
+ {
48
+ id: 'conventions',
49
+ removal: 'purge-only',
50
+ consequence: "The operator's edited conventions are lost.",
51
+ primary: ['conventions.md'],
52
+ // Plan_65 B9: install seeds it through the planned-file copier, which publishes via a temporary.
53
+ sides: ['copy-temporary'],
54
+ },
55
+ {
56
+ id: 'host-contract',
57
+ removal: 'purge-only',
58
+ consequence: 'Refusal measurements are lost and collecting them again costs a paid probe of about two minutes of Claude quota.',
59
+ primary: ['.host-contract.json'],
60
+ sides: ['pid-temporary'],
61
+ },
62
+ {
63
+ id: 'dispatcher-contract',
64
+ removal: 'purge-only',
65
+ consequence: 'Dispatcher contract measurements are lost and collecting them again costs a paid probe of about two minutes of Claude quota.',
66
+ primary: ['state/dispatcher-contract.json'],
67
+ sides: ['atomic-temporary'],
68
+ },
69
+ {
70
+ id: 'dispatcher-state',
71
+ removal: 'purge-only',
72
+ consequence: 'A dispatcher still running loses the runner output its handback is replaced with.',
73
+ primary: {
74
+ dir: 'state/dispatchers',
75
+ pattern: /^[0-9a-f]{32}\.json$/,
76
+ },
77
+ sides: ['lock', 'atomic-temporary'],
78
+ },
79
+ {
80
+ id: 'handback-witness',
81
+ removal: 'purge-only',
82
+ consequence: 'Doctor forgets on which host a handback was last seen and every alarm it recorded.',
83
+ primary: ['state/handback-witness.json'],
84
+ sides: ['lock', 'atomic-temporary'],
85
+ },
86
+ {
87
+ id: 'host-observations',
88
+ removal: 'purge-only',
89
+ consequence: 'Doctor forgets which host versions sessions ran on until the next shell command in a session.',
90
+ primary: ['state/host-observations.json'],
91
+ sides: ['lock', 'atomic-temporary'],
92
+ },
93
+ {
94
+ id: 'guard-tries',
95
+ removal: 'purge-only',
96
+ consequence: 'Guard budgets start over.',
97
+ primary: ['state/reply-guard-tries.json'],
98
+ sides: [],
99
+ },
100
+ {
101
+ id: 'diagnostics',
102
+ removal: 'purge-only',
103
+ consequence: 'This artifact holds the full hook input of the last call.',
104
+ primary: ['state/diagnostics/order-gate.last.json', 'state/diagnostics/reply-guard.last.json'],
105
+ sides: [],
106
+ },
107
+ ];
108
+
109
+ export const HOME_ARTIFACTS = Object.freeze(entries.map((entry) => Object.freeze({
110
+ ...entry,
111
+ primary: Array.isArray(entry.primary)
112
+ ? Object.freeze([...entry.primary])
113
+ : Object.freeze({
114
+ dir: entry.primary.dir,
115
+ pattern: Object.freeze(new RegExp(entry.primary.pattern.source, entry.primary.pattern.flags)),
116
+ }),
117
+ sides: Object.freeze([...entry.sides]),
118
+ })));
119
+
120
+ export const HOME_DIRECTORIES = Object.freeze([
121
+ 'state',
122
+ 'state/dispatchers',
123
+ 'state/diagnostics',
124
+ ]);
125
+
126
+ const artifactsById = new Map(HOME_ARTIFACTS.map((entry) => [entry.id, entry]));
127
+
128
+ function validRelativePath(relativePath) {
129
+ if (typeof relativePath !== 'string' || relativePath.length === 0) return false;
130
+ if (relativePath.includes('\\') || path.posix.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath)) {
131
+ return false;
132
+ }
133
+ return !relativePath.split('/').some((segment) => (
134
+ segment.length === 0 || segment === '.' || segment === '..'
135
+ ));
136
+ }
137
+
138
+ function imagePathSet(imageMembers) {
139
+ const paths = new Set();
140
+ for (const relativePath of imageMembers ?? []) {
141
+ if (validRelativePath(relativePath)) paths.add(relativePath);
142
+ }
143
+ return paths;
144
+ }
145
+
146
+ function primaryMatches(entry, relativePath) {
147
+ if (Array.isArray(entry.primary)) return entry.primary.includes(relativePath);
148
+ const slash = relativePath.lastIndexOf('/');
149
+ const dir = slash < 0 ? '' : relativePath.slice(0, slash);
150
+ const name = slash < 0 ? relativePath : relativePath.slice(slash + 1);
151
+ return dir === entry.primary.dir && entry.primary.pattern.test(name);
152
+ }
153
+
154
+ function sideMatches(entry, relativePath) {
155
+ const slash = relativePath.lastIndexOf('/');
156
+ const dir = slash < 0 ? '' : relativePath.slice(0, slash);
157
+ const name = slash < 0 ? relativePath : relativePath.slice(slash + 1);
158
+ for (const side of entry.sides) {
159
+ const match = SIDE_SPELLINGS[side].exec(name);
160
+ if (!match) continue;
161
+ const primaryName = match[1];
162
+ const primaryPath = dir ? `${dir}/${primaryName}` : primaryName;
163
+ if (primaryMatches(entry, primaryPath)) return side;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ export function classifyHomePath(relativePath, { imageMembers } = {}) {
169
+ if (!validRelativePath(relativePath)) return null;
170
+ const images = imagePathSet(imageMembers);
171
+
172
+ for (const entry of HOME_ARTIFACTS) {
173
+ if (entry.id === 'install-image') continue;
174
+ if (primaryMatches(entry, relativePath)) return { id: entry.id, role: 'primary' };
175
+ }
176
+ for (const entry of HOME_ARTIFACTS) {
177
+ if (entry.id === 'install-image') continue;
178
+ const role = sideMatches(entry, relativePath);
179
+ if (role) return { id: entry.id, role };
180
+ }
181
+
182
+ if (images.has(relativePath)) return { id: 'install-image', role: 'primary' };
183
+ const imageArtifact = artifactsById.get('install-image');
184
+ const role = sideMatches({ ...imageArtifact, primary: [...images] }, relativePath);
185
+ return role ? { id: 'install-image', role } : null;
186
+ }
187
+
188
+ export function homeArtifact(id) {
189
+ const entry = artifactsById.get(id);
190
+ if (!entry) throw new Error(`Unknown home artifact id: ${id}`);
191
+ return entry;
192
+ }
193
+
194
+ export function directoryArtifact(relativeDirectory, { imageMembers } = {}) {
195
+ if (relativeDirectory === '') return 'install-image';
196
+ if (!validRelativePath(relativeDirectory)) return null;
197
+ const prefix = `${relativeDirectory}/`;
198
+ if (!HOME_DIRECTORIES.includes(relativeDirectory)
199
+ && [...imagePathSet(imageMembers)].some((member) => member.startsWith(prefix))) {
200
+ return 'install-image';
201
+ }
202
+ for (const entry of HOME_ARTIFACTS) {
203
+ const containsArtifact = Array.isArray(entry.primary)
204
+ ? entry.primary.some((primary) => primary.startsWith(prefix))
205
+ : entry.primary.dir === relativeDirectory || entry.primary.dir.startsWith(prefix);
206
+ if (containsArtifact) {
207
+ return entry.id;
208
+ }
209
+ }
210
+ return null;
211
+ }