@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,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
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Locks the kernel releases on its holder's death, shared by the lifecycle lock and the order claim.
3
+ * Plan_60 D4: on 2026-09-24 two launchers with one order id started 2.8 seconds apart and both ran
4
+ * and billed; the runner needs the lifecycle lock's primitive and may not import `cli/`.
5
+ * Where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
6
+ * pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
7
+ * free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
8
+ * The name comes from the directory's dev+ino, not its path: aliases must reach the same claim.
9
+ * A second listen inside the holder must fail, or the platform did not give exclusivity and the
10
+ * call refuses rather than switch protocols (Plan_65 D5/D8). What the holder answers a waiter is
11
+ * diagnosis only — it never grants anything.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+
17
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
18
+
19
+ export function kernelLockStrategy(platform = process.platform) {
20
+ if (platform === 'win32') return 'named-pipe';
21
+ return platform === 'linux' ? 'abstract-socket' : null;
22
+ }
23
+
24
+ export function kernelLockAddress(purpose, digest, platform = process.platform) {
25
+ if (typeof purpose !== 'string' || !/^[a-z][a-z-]*$/.test(purpose)) {
26
+ throw new TypeError('purpose must match ^[a-z][a-z-]*$');
27
+ }
28
+ if (platform === 'win32') return `\\\\.\\pipe\\codex-bridge-${purpose}-${digest}`;
29
+ return platform === 'linux' ? `\0codex-bridge-${purpose}-${digest}` : null;
30
+ }
31
+
32
+ export function directoryDigest(directory, { suffix } = {}) {
33
+ try {
34
+ const stat = fs.statSync(directory, { bigint: true });
35
+ if (!stat.isDirectory()) throw new Error(`${directory} is not a directory`);
36
+ const identity = `${stat.dev}:${stat.ino}`;
37
+ const extra = typeof suffix === 'string' && suffix.length > 0 ? `:${suffix}` : '';
38
+ return createHash('sha256').update(identity + extra).digest('hex');
39
+ } catch (error) {
40
+ if (error?.code === 'ENOENT') return null;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ function listen(server, address) {
46
+ return new Promise((resolve, reject) => {
47
+ const listening = () => { server.removeListener('error', failed); resolve(); };
48
+ const failed = (error) => { server.removeListener('listening', listening); reject(error); };
49
+ server.once('listening', listening);
50
+ server.once('error', failed);
51
+ try {
52
+ server.listen(address);
53
+ } catch (error) {
54
+ server.removeListener('listening', listening);
55
+ server.removeListener('error', failed);
56
+ reject(error);
57
+ }
58
+ });
59
+ }
60
+
61
+ function closeServer(server) {
62
+ if (!server) return Promise.resolve();
63
+ return new Promise((resolve, reject) => {
64
+ try {
65
+ server.close((error) => {
66
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
67
+ else resolve();
68
+ });
69
+ } catch (error) {
70
+ if (error.code === 'ERR_SERVER_NOT_RUNNING') resolve();
71
+ else reject(error);
72
+ }
73
+ });
74
+ }
75
+
76
+ async function closeQuietly(server) {
77
+ try { await closeServer(server); } catch { /* Preserve the original failure. */ }
78
+ }
79
+
80
+ async function selfCheck(server, address, createServer, label) {
81
+ let probe;
82
+ let detail;
83
+ try {
84
+ probe = createServer();
85
+ await listen(probe, address);
86
+ detail = 'a second listener succeeded';
87
+ } catch (error) {
88
+ if (!error || error.code !== 'EADDRINUSE') detail = (error && (error.code || error.message)) || String(error);
89
+ }
90
+ if (detail === undefined) {
91
+ await closeQuietly(probe);
92
+ return;
93
+ }
94
+ await Promise.all([closeQuietly(probe), closeQuietly(server)]);
95
+ throw new Error(`${label} self-check failed: ${detail}`);
96
+ }
97
+
98
+ export async function tryHoldSocket(address, createServer = net.createServer, label = 'kernel lock') {
99
+ const server = createServer((socket) => socket.destroy());
100
+ try {
101
+ await listen(server, address);
102
+ await selfCheck(server, address, createServer, label);
103
+ let releasePromise;
104
+ return () => (releasePromise ??= closeServer(server));
105
+ } catch (error) {
106
+ await closeQuietly(server);
107
+ if (error?.code === 'EADDRINUSE') return 'in-use';
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ function readSocketHolder(address, timeoutMs, parseAnswer) {
113
+ return new Promise((resolve) => {
114
+ let data = '';
115
+ let settled = false;
116
+ const socket = net.createConnection(address);
117
+ const timer = setTimeout(() => finish(null), timeoutMs);
118
+ const finish = (holder) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ socket.destroy();
123
+ resolve(holder);
124
+ };
125
+ socket.on('data', (chunk) => {
126
+ data += chunk.toString('utf8');
127
+ const newline = data.indexOf('\n');
128
+ if (newline !== -1) finish(parseAnswer(data.slice(0, newline)));
129
+ });
130
+ socket.once('end', () => finish(parseAnswer(data)));
131
+ socket.once('error', () => finish(null));
132
+ });
133
+ }
134
+
135
+ export async function acquireKernelLock({
136
+ address, holder, waitMs, retryMs, answerTimeoutMs, parseAnswer, label,
137
+ createServer = net.createServer,
138
+ }) {
139
+ const accepted = new Set();
140
+ const onConnection = (socket) => {
141
+ accepted.add(socket);
142
+ socket.once('close', () => accepted.delete(socket));
143
+ socket.end(`${JSON.stringify(holder)}\n`);
144
+ };
145
+ const deadline = Date.now() + waitMs;
146
+
147
+ for (;;) {
148
+ const server = createServer(onConnection);
149
+ try {
150
+ await listen(server, address);
151
+ holder.acquiredAt = new Date().toISOString();
152
+ await selfCheck(server, address, createServer, label);
153
+ server.unref();
154
+ let releasePromise;
155
+ const release = async () => {
156
+ const closing = closeServer(server);
157
+ for (const socket of accepted) socket.destroy();
158
+ await closing;
159
+ };
160
+ return { held: true, release: () => (releasePromise ??= release()) };
161
+ } catch (error) {
162
+ await closeQuietly(server);
163
+ if (!error || error.code !== 'EADDRINUSE') throw error;
164
+ const remaining = deadline - Date.now();
165
+ if (remaining <= 0) break;
166
+ await wait(Math.min(retryMs, remaining));
167
+ }
168
+ }
169
+
170
+ return { held: false, holder: await readSocketHolder(address, answerTimeoutMs, parseAnswer) };
171
+ }
@@ -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) {
@@ -12,6 +12,10 @@ import { createHash } from 'node:crypto';
12
12
  import { startedRuns } from './pre-start.mjs';
13
13
  import { normalizePath, readJson } from './paths.mjs';
14
14
 
15
+ // Plan_75 D5: 2 hashes the header-free body. Runs without this field used earlier schemes:
16
+ // the whole task text, then the task without grant lines (Plan_75 P1).
17
+ export const TASK_HASH_SCHEME = 2;
18
+
15
19
  /**
16
20
  * What makes two runs the same task when their names disagree.
17
21
  *
@@ -21,6 +25,8 @@ import { normalizePath, readJson } from './paths.mjs';
21
25
  * built to refuse. Whitespace and case are normalized away because the same order re-sent
22
26
  * through a shell is rewrapped, not rewritten; anything more forgiving would tie together
23
27
  * tasks that merely look alike.
28
+ * Plan_75 D5: the runner supplies the header-free body; every body line counts, including
29
+ * prose beginning with a grant label. Stripping it again would silently change identity.
24
30
  */
25
31
  export const taskFingerprint = (taskText) => {
26
32
  const normalized = String(taskText ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
@@ -35,14 +41,19 @@ export const taskFingerprint = (taskText) => {
35
41
  * honestly, changed nothing and was told it had done no work. Folder names rather than
36
42
  * full paths; the current run joins the list as soon as its status.json exists.
37
43
  */
38
- export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
44
+ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '', grantRun = '') {
39
45
  const wantedRepo = normalizePath(repo);
40
- const wantedSlug = String(slug ?? '').trim().toLowerCase();
41
- const wantedHash = String(taskHash ?? '').trim().toLowerCase();
42
- const wantedOrderId = String(orderId ?? '').trim();
46
+ if (!wantedRepo) return [];
47
+ const namedStatus = typeof grantRun === 'string' && grantRun &&
48
+ !/[\\/]/.test(grantRun) && grantRun !== '.' && grantRun !== '..'
49
+ ? readJson(path.join(runsRoot, grantRun, 'status.json')) : null;
50
+ const namedRun = namedStatus && normalizePath(namedStatus.repo) === wantedRepo ? namedStatus : null;
51
+ const wantedSlugs = new Set([slug, namedRun?.slug].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
52
+ const wantedHashes = new Set([taskHash, namedRun?.task_hash].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
53
+ const wantedOrderIds = new Set([orderId, namedRun?.order_id].map((value) => String(value ?? '').trim()).filter(Boolean));
43
54
  // No handle on the task, or no repo, means nothing to chain — an empty context must find
44
55
  // nothing rather than fall back to "every run in this folder".
45
- if (!wantedRepo || (!wantedSlug && !wantedHash && !wantedOrderId)) return [];
56
+ if (!wantedSlugs.size && !wantedHashes.size && !wantedOrderIds.size) return [];
46
57
  let entries;
47
58
  try {
48
59
  entries = fs.readdirSync(runsRoot, { withFileTypes: true });
@@ -56,9 +67,9 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
56
67
  if (!status) continue;
57
68
  // Either handle identifies the task. The hash catches a repeat that renamed itself; the
58
69
  // slug still catches the follow-up whose wording the orchestrator edited on purpose.
59
- const sameSlug = Boolean(wantedSlug) && String(status.slug ?? '').trim().toLowerCase() === wantedSlug;
60
- const sameTask = Boolean(wantedHash) && String(status.task_hash ?? '').trim().toLowerCase() === wantedHash;
61
- const sameOrder = Boolean(wantedOrderId) && String(status.order_id ?? '').trim() === wantedOrderId;
70
+ const sameSlug = wantedSlugs.has(String(status.slug ?? '').trim().toLowerCase());
71
+ const sameTask = wantedHashes.has(String(status.task_hash ?? '').trim().toLowerCase());
72
+ const sameOrder = wantedOrderIds.has(String(status.order_id ?? '').trim());
62
73
  if (!sameSlug && !sameTask && !sameOrder) continue;
63
74
  if (normalizePath(status.repo) !== wantedRepo) continue;
64
75
  runs.push({ name: entry.name, at: String(status.started_at || '') });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Judges startup-context references and unread repository coordinates in scout responses.
3
+ * Plan_60 D1: honest answers about handed context need no command, unlike the 2026-09-16
4
+ * dead sandbox; a marker must never excuse invented addresses or an unsourced answer.
5
+ */
6
+ import { QUESTION_KIND } from '../runner/question-kind.mjs';
7
+
8
+ export function allStartupContext(questions) {
9
+ return Array.isArray(questions) && questions.length > 0
10
+ && questions.every((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT);
11
+ }
12
+
13
+ export function contextOnlyGap({ questions, result, commandsExecuted }) {
14
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
15
+ const byId = new Map();
16
+ for (const answer of answers) {
17
+ const id = String(answer?.question_id || '').trim().toUpperCase();
18
+ if (id && !byId.has(id)) byId.set(id, answer);
19
+ }
20
+ const unsourced = (Array.isArray(questions) ? questions : [])
21
+ .filter((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
22
+ .filter((question) => {
23
+ const answer = byId.get(String(question.id || '').trim().toUpperCase());
24
+ return !Array.isArray(answer?.evidence)
25
+ || !answer.evidence.some((item) => /^startup:\s*\S/.test(String(item ?? '')));
26
+ })
27
+ .map((question) => question.id);
28
+ if (unsourced.length) {
29
+ return `responses to startup-context questions without a startup:<source> reference: ${unsourced.join(', ')}`;
30
+ }
31
+ if (commandsExecuted !== 0) return null;
32
+ const references = answers.flatMap((answer) => Array.isArray(answer?.evidence) ? answer.evidence : []);
33
+ references.push(...(Array.isArray(result?.findings) ? result.findings : []).map((finding) => finding?.where));
34
+ for (const reference of references) {
35
+ const text = String(reference ?? '');
36
+ if (text.startsWith('startup:')) continue;
37
+ const coordinate = text.match(/\S*\.[A-Za-z0-9]+:\d+/)?.[0];
38
+ if (coordinate) {
39
+ return `scout cited ${coordinate.slice(0, 60)} without executing a command: an address nobody read`;
40
+ }
41
+ }
42
+ return null;
43
+ }
@@ -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
@@ -13,10 +14,19 @@ import path from 'node:path';
13
14
  */
14
15
  const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
15
16
 
17
+ // Plan_60 D2: add a new measured sample only together with its verbatim fixture.
18
+ const UNAVAILABLE_SAMPLES = [
19
+ {
20
+ cli: 'codex-cli 0.159.0',
21
+ measured: '2026-10-03',
22
+ pattern: /^unexpected status 401 Unauthorized: Missing bearer or basic authentication in header\b/,
23
+ },
24
+ ];
25
+
16
26
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
17
27
 
18
28
  const compact = (value, max = 300) =>
19
- String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
29
+ safeSlice(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
20
30
 
21
31
  /**
22
32
  * A broken final line is expected when the runner is stopped while Codex is writing.
@@ -128,6 +138,8 @@ function transportErrorOf(event) {
128
138
  const status = payload.status ?? nested.status ?? null;
129
139
  const marker = [errorType, message].join(' ');
130
140
  const quota = Number(status) === 429 || LIMIT_RE.test(marker);
141
+ const unavailable = !quota && event.type === 'turn.failed' &&
142
+ UNAVAILABLE_SAMPLES.some((sample) => sample.pattern.test(message));
131
143
  const reason = compact(
132
144
  [errorType, message, status === null || status === undefined ? '' : `status ${status}`]
133
145
  .filter(Boolean)
@@ -136,7 +148,7 @@ function transportErrorOf(event) {
136
148
  (typeof event.error?.message === 'string' ? event.error.message : '') ||
137
149
  event.type,
138
150
  );
139
- return { event, payload, status, error_type: errorType, message, quota, reason };
151
+ return { event, payload, status, error_type: errorType, message, quota, unavailable, reason };
140
152
  }
141
153
 
142
154
  /**
@@ -149,7 +161,8 @@ export function readEvents(runDir) {
149
161
  const accounting = usageOf(events);
150
162
  const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
151
163
  const errors = events.map(transportErrorOf).filter(Boolean);
152
- const transport_error = errors.find((error) => error.quota) || errors[0] || null;
164
+ const transport_error = errors.find((error) => error.quota) ||
165
+ errors.find((error) => error.unavailable) || errors[0] || null;
153
166
  return {
154
167
  events,
155
168
  // The file was written, whatever it holds. Pass 2 judges a run by this: a stream the