@lyupro/codex-bridge 0.6.7 → 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 +148 -0
- package/README.md +10 -6
- package/bin/codex-bridge.mjs +35 -172
- 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 +21 -11
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +35 -7
- package/cli/handback-witness-check.mjs +10 -18
- 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 +36 -21
- 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 +101 -77
- 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/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -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/session-hosts.mjs +51 -0
- 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 -126
- 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 +33 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- 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 +24 -9
- 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 +33 -25
- 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 +60 -0
- package/src/home/lib/host-version.mjs +27 -0
- 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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Records session host versions after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
5
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
6
|
+
import { stateDirWriter } from './home-write.mjs';
|
|
7
|
+
import { parseJsonText } from './json-file.mjs';
|
|
8
|
+
import { transcriptHostVersion } from './host-version.mjs';
|
|
9
|
+
|
|
10
|
+
export const HOST_OBSERVATIONS_FILE = 'host-observations.json';
|
|
11
|
+
|
|
12
|
+
export function readHostObservations({ stateDir }) {
|
|
13
|
+
const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') return { hosts: {}, sessions: {} };
|
|
19
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const record = parseJsonText(file, raw);
|
|
23
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
24
|
+
|| !record.hosts || typeof record.hosts !== 'object' || Array.isArray(record.hosts)
|
|
25
|
+
|| !record.sessions || typeof record.sessions !== 'object' || Array.isArray(record.sessions)) {
|
|
26
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
27
|
+
}
|
|
28
|
+
return { hosts: record.hosts, sessions: record.sessions };
|
|
29
|
+
} catch {
|
|
30
|
+
return { hosts: {}, sessions: {}, corrupt: true };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function observeSessionHost({
|
|
35
|
+
stateDir, sessionId, transcriptPath, now = new Date(), readVersion = transcriptHostVersion,
|
|
36
|
+
}) {
|
|
37
|
+
const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
|
|
38
|
+
if (readHostObservations({ stateDir }).sessions[sessionId]) return { recorded: false };
|
|
39
|
+
const version = await readVersion(transcriptPath);
|
|
40
|
+
if (version === null) return { recorded: false };
|
|
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 () => {
|
|
44
|
+
const current = readHostObservations({ stateDir });
|
|
45
|
+
if (current.corrupt) throw new Error(`Cannot update corrupt host observations: ${file}`);
|
|
46
|
+
if (current.sessions[sessionId]) return { recorded: false };
|
|
47
|
+
const at = new Date(now).toISOString();
|
|
48
|
+
const cutoffSessions = Date.parse(at) - 7 * 24 * 60 * 60 * 1000;
|
|
49
|
+
const cutoffHosts = Date.parse(at) - 30 * 24 * 60 * 60 * 1000;
|
|
50
|
+
const sessions = Object.fromEntries(Object.entries(current.sessions)
|
|
51
|
+
.filter(([, entry]) => Date.parse(entry.at) >= cutoffSessions));
|
|
52
|
+
const hosts = Object.fromEntries(Object.entries(current.hosts)
|
|
53
|
+
.filter(([, entry]) => Date.parse(entry.lastSeen) >= cutoffHosts));
|
|
54
|
+
sessions[sessionId] = { version, at };
|
|
55
|
+
const existing = hosts[version];
|
|
56
|
+
hosts[version] = { firstSeen: existing?.firstSeen || at, lastSeen: at };
|
|
57
|
+
writeHomeJsonAtomic(writer, 'host-observations', file, { hosts, sessions });
|
|
58
|
+
return { recorded: true };
|
|
59
|
+
}, { description: 'host observations' });
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reads the host version from the transcript tail after the 2026-09-25 PATH host identity incident. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { parseJsonText } from './json-file.mjs';
|
|
4
|
+
|
|
5
|
+
export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536 } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const handle = await fs.open(transcriptPath, 'r');
|
|
8
|
+
try {
|
|
9
|
+
const { size } = await handle.stat();
|
|
10
|
+
const length = Math.min(size, tailBytes);
|
|
11
|
+
const buffer = Buffer.alloc(length);
|
|
12
|
+
await handle.read(buffer, 0, length, size - length);
|
|
13
|
+
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
14
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
15
|
+
try {
|
|
16
|
+
const entry = parseJsonText(transcriptPath, lines[index]);
|
|
17
|
+
if (typeof entry?.version === 'string' && /^\d+\.\d+\.\d+/.test(entry.version)) {
|
|
18
|
+
return entry.version;
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
} finally {
|
|
23
|
+
await handle.close();
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
@@ -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.
|