@lyupro/codex-bridge 0.6.8 → 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 (93) hide show
  1. package/CHANGELOG.md +109 -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 +91 -0
  25. package/cli/lifecycle-lock.mjs +335 -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 +2 -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/terminal-question.mjs +41 -0
  46. package/cli/uninstall-purge.mjs +139 -0
  47. package/cli/uninstall-rules.mjs +72 -0
  48. package/cli/uninstall.mjs +208 -127
  49. package/cli/unlock-lifecycle.mjs +102 -0
  50. package/cli/unlock.mjs +7 -4
  51. package/cli/update.mjs +102 -27
  52. package/package.json +4 -3
  53. package/scripts/home-write-audit.mjs +286 -0
  54. package/scripts/home-write-inventory.mjs +49 -0
  55. package/scripts/home-write-lexer.mjs +182 -0
  56. package/scripts/isolated-roots.mjs +6 -1
  57. package/scripts/open-work/check.mjs +180 -0
  58. package/scripts/open-work/live-thresholds.mjs +155 -0
  59. package/scripts/open-work/register.mjs +56 -0
  60. package/scripts/root-guard/root-entries.mjs +15 -0
  61. package/scripts/run-tests.mjs +26 -1
  62. package/src/agents/codex-advisor.md +5 -2
  63. package/src/home/conventions.md +9 -0
  64. package/src/home/hooks/guard-tries.mjs +6 -4
  65. package/src/home/hooks/order-gate.mjs +2 -8
  66. package/src/home/hooks/reply-guard.mjs +2 -7
  67. package/src/home/hooks/worktree-witness.mjs +6 -6
  68. package/src/home/lib/agents.mjs +1 -1
  69. package/src/home/lib/atomic-json.mjs +24 -6
  70. package/src/home/lib/config-edit.mjs +25 -10
  71. package/src/home/lib/dispatcher-state.mjs +25 -10
  72. package/src/home/lib/file-lock.mjs +41 -10
  73. package/src/home/lib/handback-witness.mjs +7 -4
  74. package/src/home/lib/home-registry.mjs +211 -0
  75. package/src/home/lib/home-write.mjs +189 -0
  76. package/src/home/lib/hook-diagnostics.mjs +20 -0
  77. package/src/home/lib/host-observations.mjs +7 -4
  78. package/src/home/lib/meta/advice-status.mjs +2 -2
  79. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  80. package/src/home/lib/meta/events.mjs +2 -1
  81. package/src/home/lib/meta/paths.mjs +24 -24
  82. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  83. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  84. package/src/home/lib/meta/verdict.mjs +14 -16
  85. package/src/home/lib/run-config.mjs +10 -1
  86. package/src/home/lib/runner/git-paths.mjs +74 -0
  87. package/src/home/lib/runner/git-state.mjs +38 -34
  88. package/src/home/lib/runner/launcher.mjs +2 -2
  89. package/src/home/lib/runner/preflight.mjs +7 -0
  90. package/src/home/lib/runner/prompts.mjs +35 -2
  91. package/src/home/lib/runner/schemas.mjs +4 -1
  92. package/src/home/lib/runner/scope-check.mjs +29 -20
  93. package/src/home/lib/runner/worker.mjs +3 -3
@@ -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
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Performs a write into the package home only when the path belongs to the artifact id it names.
3
+ *
4
+ * The registry alone is a list someone can forget to consult; routing the write through it is what
5
+ * makes `uninstall --purge` complete by construction (Plan_62 D22). The check is lexical and
6
+ * in-memory — no lock, no stat, no listing — because hooks call it on every shell command of every
7
+ * session, and links are deliberately not resolved, as everywhere else in the package.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { classifyHomePath, HOME_ARTIFACTS, HOME_DIRECTORIES } from './home-registry.mjs';
12
+
13
+ function registryError(id, absolutePath, reason) {
14
+ const error = new Error(`Home artifact "${id}" refused path "${absolutePath}": ${reason}`);
15
+ error.code = 'EHOMEREGISTRY';
16
+ return error;
17
+ }
18
+
19
+ function toPosix(relativePath) {
20
+ return relativePath.split(path.sep).join('/');
21
+ }
22
+
23
+ function homeRelativePath(root, id, absolutePath) {
24
+ if (typeof absolutePath !== 'string' || !path.isAbsolute(absolutePath)) {
25
+ throw registryError(id, absolutePath, 'an absolute path is required');
26
+ }
27
+ const relativeNative = path.relative(root, absolutePath);
28
+ if (
29
+ path.isAbsolute(relativeNative)
30
+ || relativeNative === '..'
31
+ || relativeNative.startsWith(`..${path.sep}`)
32
+ ) {
33
+ throw registryError(id, absolutePath, 'path is outside the home root');
34
+ }
35
+ return toPosix(relativeNative);
36
+ }
37
+
38
+ function directoryContainsArtifact(entry, relativeDirectory, imageMembers) {
39
+ if (Array.isArray(entry.primary)) {
40
+ if (entry.primary.some((candidate) => candidate.startsWith(`${relativeDirectory}/`))) return true;
41
+ } else if (
42
+ entry.primary.dir === relativeDirectory
43
+ || entry.primary.dir.startsWith(`${relativeDirectory}/`)
44
+ ) {
45
+ return true;
46
+ }
47
+
48
+ if (entry.id === 'install-image') {
49
+ return imageMembers.some((candidate) => candidate.startsWith(`${relativeDirectory}/`));
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function directoryIsAllowed(id, relativeDirectory, imageMembers) {
55
+ const entry = HOME_ARTIFACTS.find((candidate) => candidate.id === id);
56
+ if (!entry) return false;
57
+ if (relativeDirectory === '') return true;
58
+
59
+ if (
60
+ HOME_DIRECTORIES.includes(relativeDirectory)
61
+ && directoryContainsArtifact(entry, relativeDirectory, imageMembers)
62
+ ) {
63
+ return true;
64
+ }
65
+
66
+ // Every ancestor of an image member, not only its direct parent: the installer creates the chain
67
+ // recursively, and uninstall takes emptied ones down one by one (Plan_65 B15b2b — `agents/` holds
68
+ // only subfolders, and refusing it left an empty folder or threw mid-uninstall).
69
+ if (entry.id === 'install-image') {
70
+ return imageMembers.some((candidate) => candidate.startsWith(`${relativeDirectory}/`));
71
+ }
72
+
73
+ if (Array.isArray(entry.primary)) {
74
+ return entry.primary.some((candidate) => {
75
+ const slash = candidate.lastIndexOf('/');
76
+ const parent = slash < 0 ? '' : candidate.slice(0, slash);
77
+ return parent === relativeDirectory;
78
+ });
79
+ }
80
+ return entry.primary.dir === relativeDirectory;
81
+ }
82
+
83
+ function recursiveOptions(options) {
84
+ if (typeof options === 'string') return { encoding: options, recursive: true };
85
+ return { ...options, recursive: true };
86
+ }
87
+
88
+ /**
89
+ * The state modules receive `stateDir`, which is always `brandStateDir(root)` (Plan_62 D14), so the
90
+ * home root is its parent. A stateDir anywhere else is refused by the registry, not guessed.
91
+ */
92
+ export function stateDirWriter(stateDir) {
93
+ return createHomeWriter({ root: path.dirname(path.resolve(stateDir)) });
94
+ }
95
+
96
+ export function createHomeWriter({ root, imageMembers } = {}) {
97
+ const homeRoot = path.resolve(root);
98
+ const declaredImageMembers = Object.freeze([...(imageMembers ?? [])]);
99
+
100
+ function assertArtifactPath(id, absolutePath) {
101
+ const relativePath = homeRelativePath(homeRoot, id, absolutePath);
102
+ const artifact = classifyHomePath(relativePath, { imageMembers: declaredImageMembers });
103
+ if (!artifact || artifact.id !== id) {
104
+ throw registryError(id, absolutePath, 'path is not declared for this artifact id');
105
+ }
106
+ }
107
+
108
+ function assertDirectoryPath(id, absolutePath) {
109
+ const relativeDirectory = homeRelativePath(homeRoot, id, absolutePath);
110
+ if (!directoryIsAllowed(id, relativeDirectory, declaredImageMembers)) {
111
+ throw registryError(id, absolutePath, 'directory is not an allowed artifact directory');
112
+ }
113
+ }
114
+
115
+ return {
116
+ // Plan_65 B2: a helper whose first write is a shared parent (`state/`) checks the target here, so a
117
+ // wrong id is refused before mkdir creates anything — without opening the file to find out.
118
+ assertArtifact(id, absolutePath) {
119
+ assertArtifactPath(id, absolutePath);
120
+ },
121
+ // Plan_65 B9 (advice A1, order 11): a helper that also writes outside the home — the installer's
122
+ // copier for Claude host files and Codex rules — takes its raw route only past this check, so the
123
+ // raw route can never land in the home and escape the registry.
124
+ assertOutside(absolutePath) {
125
+ if (typeof absolutePath !== 'string' || !path.isAbsolute(absolutePath)) {
126
+ throw registryError('outside', absolutePath, 'an absolute path is required');
127
+ }
128
+ const relativeNative = path.relative(homeRoot, absolutePath);
129
+ const inside = relativeNative === ''
130
+ || !(path.isAbsolute(relativeNative) || relativeNative === '..'
131
+ || relativeNative.startsWith(`..${path.sep}`));
132
+ if (inside) throw registryError('outside', absolutePath, 'path is inside the home root');
133
+ },
134
+ writeFileSync(id, absolutePath, ...args) {
135
+ assertArtifactPath(id, absolutePath);
136
+ return fs.writeFileSync(absolutePath, ...args);
137
+ },
138
+ writeFile(id, absolutePath, ...args) {
139
+ assertArtifactPath(id, absolutePath);
140
+ return fs.promises.writeFile(absolutePath, ...args);
141
+ },
142
+ mkdirSync(id, absolutePath, options) {
143
+ assertDirectoryPath(id, absolutePath);
144
+ return fs.mkdirSync(absolutePath, recursiveOptions(options));
145
+ },
146
+ mkdir(id, absolutePath, options) {
147
+ assertDirectoryPath(id, absolutePath);
148
+ return fs.promises.mkdir(absolutePath, recursiveOptions(options));
149
+ },
150
+ openSync(id, absolutePath, ...args) {
151
+ assertArtifactPath(id, absolutePath);
152
+ return fs.openSync(absolutePath, ...args);
153
+ },
154
+ open(id, absolutePath, ...args) {
155
+ assertArtifactPath(id, absolutePath);
156
+ return fs.promises.open(absolutePath, ...args);
157
+ },
158
+ renameSync(id, oldAbsolutePath, newAbsolutePath) {
159
+ assertArtifactPath(id, oldAbsolutePath);
160
+ assertArtifactPath(id, newAbsolutePath);
161
+ return fs.renameSync(oldAbsolutePath, newAbsolutePath);
162
+ },
163
+ rename(id, oldAbsolutePath, newAbsolutePath) {
164
+ assertArtifactPath(id, oldAbsolutePath);
165
+ assertArtifactPath(id, newAbsolutePath);
166
+ return fs.promises.rename(oldAbsolutePath, newAbsolutePath);
167
+ },
168
+ copyFile(id, sourceAbsolutePath, destinationAbsolutePath, ...args) {
169
+ assertArtifactPath(id, destinationAbsolutePath);
170
+ return fs.promises.copyFile(sourceAbsolutePath, destinationAbsolutePath, ...args);
171
+ },
172
+ unlinkSync(id, absolutePath) {
173
+ assertArtifactPath(id, absolutePath);
174
+ return fs.unlinkSync(absolutePath);
175
+ },
176
+ unlink(id, absolutePath) {
177
+ assertArtifactPath(id, absolutePath);
178
+ return fs.promises.unlink(absolutePath);
179
+ },
180
+ rmdirSync(id, absolutePath) {
181
+ assertDirectoryPath(id, absolutePath);
182
+ return fs.rmdirSync(absolutePath);
183
+ },
184
+ async rmdir(id, absolutePath) {
185
+ assertDirectoryPath(id, absolutePath);
186
+ return fs.promises.rmdir(absolutePath);
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Records the last hook input through the home artifact registry. Plan_65 B3 consolidates two
3
+ * duplicated direct writes whose paths must remain purgeable and whose failures must stay fail-open.
4
+ */
5
+ import path from 'node:path';
6
+ import { BRAND_HOME, brandStateDir } from './brand-home.mjs';
7
+ import { createHomeWriter } from './home-write.mjs';
8
+
9
+ export function recordHookDiagnostic(hookName, input, { root = BRAND_HOME.root } = {}) {
10
+ try {
11
+ const diagnosticsDir = path.join(brandStateDir(root), 'diagnostics');
12
+ const diagnosticFile = path.join(diagnosticsDir, `${hookName}.last.json`);
13
+ const writer = createHomeWriter({ root });
14
+ writer.assertArtifact('diagnostics', diagnosticFile);
15
+ writer.mkdirSync('diagnostics', diagnosticsDir, { recursive: true });
16
+ writer.writeFileSync('diagnostics', diagnosticFile, `${JSON.stringify(input, null, 2)}\n`);
17
+ } catch {
18
+ // Diagnostics are a convenience, never a reason to fail the turn.
19
+ }
20
+ }
@@ -1,8 +1,9 @@
1
1
  /** Records session host versions after the 2026-09-25 PATH host identity incident. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { writeJsonAtomic } from './atomic-json.mjs';
5
- import { withFileLock } from './file-lock.mjs';
4
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
5
+ import { withHomeFileLock } from './file-lock.mjs';
6
+ import { stateDirWriter } from './home-write.mjs';
6
7
  import { parseJsonText } from './json-file.mjs';
7
8
  import { transcriptHostVersion } from './host-version.mjs';
8
9
 
@@ -37,7 +38,9 @@ export async function observeSessionHost({
37
38
  if (readHostObservations({ stateDir }).sessions[sessionId]) return { recorded: false };
38
39
  const version = await readVersion(transcriptPath);
39
40
  if (version === null) return { recorded: false };
40
- return withFileLock(`${file}.lock`, async () => {
41
+ // Plan_65 B6: the observations, their lock and temporaries are the registered host-observations artifact.
42
+ const writer = stateDirWriter(stateDir);
43
+ return withHomeFileLock(writer, 'host-observations', `${file}.lock`, async () => {
41
44
  const current = readHostObservations({ stateDir });
42
45
  if (current.corrupt) throw new Error(`Cannot update corrupt host observations: ${file}`);
43
46
  if (current.sessions[sessionId]) return { recorded: false };
@@ -51,7 +54,7 @@ export async function observeSessionHost({
51
54
  sessions[sessionId] = { version, at };
52
55
  const existing = hosts[version];
53
56
  hosts[version] = { firstSeen: existing?.firstSeen || at, lastSeen: at };
54
- writeJsonAtomic(file, { hosts, sessions });
57
+ writeHomeJsonAtomic(writer, 'host-observations', file, { hosts, sessions });
55
58
  return { recorded: true };
56
59
  }, { description: 'host observations' });
57
60
  }
@@ -3,11 +3,11 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { DEFAULTS } from '../run-config.mjs';
5
5
  import { judgeAdvice } from './advice-verdict.mjs';
6
- import { readJson } from './paths.mjs';
6
+ import { readJson, safeSlice } from './paths.mjs';
7
7
 
8
8
  function contractReason(reasons) {
9
9
  const message = `advice contract broken (${reasons.length}): ${reasons.slice(0, 2).join('; ')}`;
10
- return message.length <= 200 ? message : `${message.slice(0, 197)}...`;
10
+ return message.length <= 200 ? message : `${safeSlice(message, 197)}...`;
11
11
  }
12
12
 
13
13
  export function adviceGap(runDir, result, eventData) {
@@ -76,6 +76,16 @@ function* addressesIn(value, root) {
76
76
  }
77
77
  }
78
78
 
79
+ // Plan_68 D7: `<anything>:<digits>` is not evidence of intent. On 2026-09-28 the prose "so 00:05 under
80
+ // yesterday's log" failed a complete advise run as file `00`, line 5 — a lost pass of the quota. A left side
81
+ // with a directory is always a claim and stays strict; a bare name is one only when that root file exists,
82
+ // so times, ratios and host:port pass while package.json and Makefile keep the read boundary. No extension
83
+ // test: `api.example.com:443` would fail as a file. The accepted cost — an invented bare name is not caught.
84
+ function isCitationInProse(address, root) {
85
+ const file = address.match(/^(.+):\d+(?:-\d+)?$/)?.[1];
86
+ return file !== undefined && (/[\\/]/.test(file) || entryInRepo(root, file)?.isFile === true);
87
+ }
88
+
79
89
  function citationProblem(file, start, end, root, allowed) {
80
90
  if (typeof file !== 'string' || !file) return 'use a repository-relative path:line or path:line-line address';
81
91
  const entry = entryInRepo(root, file);
@@ -126,7 +136,9 @@ function checkCitations({ phase, result, task, repoRoot, scopeResult }, reasons)
126
136
  // Structured evidence and the free-text early_check.target are handled separately below.
127
137
  for (const { field, value } of strings(result)) {
128
138
  if (/(?:^|\.)evidence(?:\[|\.|$)|\.early_check\.target$/.test(field)) continue;
129
- for (const address of addressesIn(value, root)) checkAddress(field, address);
139
+ for (const address of addressesIn(value, root)) {
140
+ if (isCitationInProse(address, root)) checkAddress(field, address);
141
+ }
130
142
  }
131
143
  const checkEvidence = (field, evidence) => {
132
144
  for (const [index, location] of evidence.entries()) checkLocation(`${field}[${index}]`, location);
@@ -157,6 +169,22 @@ function checkCitations({ phase, result, task, repoRoot, scopeResult }, reasons)
157
169
  }
158
170
  }
159
171
 
172
+ // Plan_68 D6: on 2026-09-27 an advise answer cut by the schema ended in U+0000 and passed as OK.
173
+ // This is integrity, not completeness: a cut that ends on a whole character is not detectable here,
174
+ // which is why the cause — the hidden length cap — was removed from the schema instead.
175
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/;
176
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
177
+ const codePoint = (char) => `U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
178
+
179
+ function integrityProblems(result, reasons) {
180
+ for (const { field, value } of strings(result)) {
181
+ const control = value.match(CONTROL)?.[0];
182
+ const surrogate = value.match(LONE_SURROGATE)?.[0];
183
+ if (control) reasons.push(`integrity ${field}: remove control character ${codePoint(control)}.`);
184
+ else if (surrogate) reasons.push(`integrity ${field}: remove lone surrogate ${codePoint(surrogate)}.`);
185
+ }
186
+ }
187
+
160
188
  export function judgeAdvice({ phase, result, task, repoRoot, commandsRun, language, scopeResult }) {
161
189
  if (phase !== 'scope' && phase !== 'advise') throw new RangeError('phase must be "scope" or "advise".');
162
190
  if (!Number.isInteger(commandsRun) || commandsRun < 0) {
@@ -168,6 +196,7 @@ export function judgeAdvice({ phase, result, task, repoRoot, commandsRun, langua
168
196
  throw new TypeError('result and a parsed advisor task are required.');
169
197
  }
170
198
  const reasons = [];
199
+ integrityProblems(result, reasons);
171
200
  if (commandsRun === 0) reasons.push('D4 commandsRun 0: inspect repository evidence before answering.');
172
201
  if (phase === 'scope') {
173
202
  if (!result.taken_on_trust.length) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { safeSlice } from './paths.mjs';
8
9
 
9
10
  /**
10
11
  * Quota exhaustion, and nothing else that merely says "limit". A bare `limit` also appears in
@@ -16,7 +17,7 @@ const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b4
16
17
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
17
18
 
18
19
  const compact = (value, max = 300) =>
19
- String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
20
+ safeSlice(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
20
21
 
21
22
  /**
22
23
  * A broken final line is expected when the runner is stopped while Codex is writing.
@@ -3,11 +3,21 @@
3
3
  *
4
4
  * Two sides spell the same file differently — git prints repo-relative paths with forward
5
5
  * slashes, Codex writes whatever it likes — so nothing above this module compares a path
6
- * before it has been through here. This is also the only module that touches the
7
- * filesystem for reading artifacts: everything else receives text, JSON or a byte count from it.
6
+ * before it has been through here. Strict snapshot judgement belongs to run-snapshots.mjs.
8
7
  */
9
8
  import fs from 'node:fs';
10
9
  import { readJsonFileSync } from '../json-file.mjs';
10
+ import { compareSnapshots, decodeSnapshot } from './snapshot-format.mjs';
11
+
12
+ /**
13
+ * `slice` counts UTF-16 units and can leave half of an emoji or a rare CJK character at the cut;
14
+ * a lone surrogate in a reply is the same kind of broken text Plan_68 D6 refuses from the model.
15
+ */
16
+ export const safeSlice = (value, max) => {
17
+ const text = String(value ?? '').toWellFormed();
18
+ const result = text.slice(0, max);
19
+ return result.isWellFormed() ? result : result.slice(0, -1);
20
+ };
11
21
 
12
22
  /** One line, no newlines, bounded length — a five-line reply must stay five lines. */
13
23
  export const line = (value, max = 200) => {
@@ -15,7 +25,7 @@ export const line = (value, max = 200) => {
15
25
  if (text.length <= max) return text;
16
26
 
17
27
  const ellipsis = '...';
18
- if (max <= ellipsis.length) return text.slice(0, Math.max(0, max));
28
+ if (max <= ellipsis.length) return safeSlice(text, Math.max(0, max));
19
29
 
20
30
  const contentLimit = max - ellipsis.length;
21
31
  const boundary = text.lastIndexOf(' ', contentLimit);
@@ -24,11 +34,11 @@ export const line = (value, max = 200) => {
24
34
  // where a word ended. Dropping a trailing number here as well cost `All 298 12345678 rest`
25
35
  // its 298 — a number that fitted whole — because the digit test compared the limit rather
26
36
  // than the point the text was actually cut at.
27
- if (boundary > 0) return `${text.slice(0, boundary)}${ellipsis}`;
37
+ if (boundary > 0) return `${safeSlice(text, boundary)}${ellipsis}`;
28
38
 
29
39
  // No space to retreat to: the limit lands inside a single long word. Only here can the cut
30
40
  // land mid-number, so only here is a partial number dropped.
31
- return `${text.slice(0, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
41
+ return `${safeSlice(text, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
32
42
  };
33
43
 
34
44
  export const readText = (file) => {
@@ -60,32 +70,22 @@ export const size = (file) => {
60
70
  };
61
71
 
62
72
  /**
63
- * Snapshot lines are `<added>\t<deleted>\t<path>` for tracked files and `U\t<bytes>\t<path>`
64
- * for untracked ones — see worktreeSnapshot() in runner/git-state.mjs. Path last, state first.
73
+ * Tolerant display reader for reply, run-state and write-meta; invalid snapshots display
74
+ * an empty Map. Strict judgement goes through run-snapshots.mjs.
65
75
  */
66
76
  export const snapshotMap = (text) => {
67
- const map = new Map();
68
- for (const row of text.split(/\r?\n/).filter(Boolean)) {
69
- const parts = row.split('\t');
70
- if (parts.length < 3) continue;
71
- map.set(parts.slice(2).join('\t').trim(), `${parts[0]}\t${parts[1]}`);
72
- }
73
- return map;
77
+ const snapshot = decodeSnapshot(text);
78
+ return snapshot.ok ? snapshot.rows : new Map();
74
79
  };
75
80
 
76
81
  /**
77
- * Paths Codex actually touched, by content rather than by porcelain letter: a file that
78
- * was already ` M` stays ` M` after further edits, so status codes would report zero
79
- * work. Line counts (or size, for untracked files) do change.
82
+ * Tolerant display reader for reply, run-state and write-meta; invalid comparisons display
83
+ * no changes. Strict judgement goes through run-snapshots.mjs. Snapshot states measure
84
+ * edits even when a file's porcelain letter stays unchanged.
80
85
  */
81
86
  export const changedPaths = (before, after) => {
82
- const was = snapshotMap(before);
83
- const now = snapshotMap(after);
84
- const paths = [];
85
- for (const [file, state] of now) if (was.get(file) !== state) paths.push(file);
86
- // A path that dropped out was touched too: Codex restored it to its committed state.
87
- for (const file of was.keys()) if (!now.has(file)) paths.push(file);
88
- return paths;
87
+ const comparison = compareSnapshots(before, after);
88
+ return comparison.ok ? comparison.changed : [];
89
89
  };
90
90
 
91
91
  /** Directories a delegated build has no business editing, whatever the task says. */
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Read a run's before/after snapshots and compare them strictly.
3
+ * On 2026-09-30 git-quoted Cyrillic names made an in-scope edit look out of scope;
4
+ * missing or untrustworthy snapshots must refuse judgement, never imply a clean tree.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { compareSnapshots } from './snapshot-format.mjs';
9
+
10
+ export function readSnapshot(file) {
11
+ try {
12
+ return fs.readFileSync(file, 'utf8');
13
+ } catch (error) {
14
+ if (error.code === 'ENOENT') return null;
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export function runSnapshotChanges(runDir) {
20
+ return compareSnapshots(
21
+ readSnapshot(path.join(runDir, 'state-before.txt')),
22
+ readSnapshot(path.join(runDir, 'state-after.txt')),
23
+ );
24
+ }
25
+
26
+ export function snapshotRefusal(comparison) {
27
+ const issue = comparison.side ? `${comparison.side} ${comparison.issue}` : comparison.issue;
28
+ return `worktree snapshots cannot be compared (${issue}): ${comparison.detail}`;
29
+ }