@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.
- package/CHANGELOG.md +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- 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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
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()
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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
|
|
68
|
-
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
83
|
-
|
|
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
|
+
}
|