@prodero/curator 0.1.0 → 0.2.0

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 (50) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/CHANGELOG.md +19 -2
  3. package/README.md +32 -33
  4. package/dist/cli.js +58 -20
  5. package/dist/contracts.js +1 -0
  6. package/dist/conversation-presentation.js +62 -27
  7. package/dist/core/agent-instructions.js +31 -18
  8. package/dist/core/curation-graphical.js +22 -10
  9. package/dist/core/curation-question.js +28 -0
  10. package/dist/core/curation-semantic-contract.js +4 -1
  11. package/dist/core/curation-semantic-jobs.js +78 -18
  12. package/dist/core/knowledge-graph.js +5 -4
  13. package/dist/core/local-preferences.js +76 -0
  14. package/dist/core/memory-index.js +2 -0
  15. package/dist/core/memory-link-repair.js +6 -2
  16. package/dist/core/memory-sync-curator.js +25 -5
  17. package/dist/core/memory-sync.js +124 -29
  18. package/dist/core/npm-update.js +289 -0
  19. package/dist/core/project.js +8 -8
  20. package/dist/core/session-index.js +10 -6
  21. package/dist/core/sessions.js +3 -3
  22. package/dist/core/settings.js +34 -16
  23. package/dist/core/skills.js +21 -2
  24. package/dist/core/storage.js +55 -1
  25. package/dist/core/sync-transaction.js +85 -49
  26. package/dist/core/update.js +9 -3
  27. package/dist/curation-review-adapter.js +1 -1
  28. package/dist/diagnostic-boundary.js +4 -3
  29. package/dist/memory-presentation.js +26 -16
  30. package/dist/memory-sync-operation.js +88 -0
  31. package/dist/presentation-server.js +25 -10
  32. package/dist/presentation.js +92 -61
  33. package/dist/server.js +59 -51
  34. package/dist/settings-definition.js +3 -3
  35. package/dist/status-presentation.js +20 -0
  36. package/dist/text-layout.js +278 -0
  37. package/dist/update-command.js +53 -0
  38. package/dist/update-instructions.js +4 -0
  39. package/dist-ui/index.html +117 -115
  40. package/docs/COMPATIBILITY.md +29 -10
  41. package/docs/MIGRATIONS.md +4 -0
  42. package/docs/RECOVERY.md +3 -1
  43. package/docs/RELEASING.md +68 -10
  44. package/docs/hosts.md +25 -7
  45. package/docs/images/prodero-context-ready-cropped.png +0 -0
  46. package/docs/images/prodero-overview-cropped.png +0 -0
  47. package/docs/images/prodero-session-learned-cropped.png +0 -0
  48. package/docs/ui-themes.md +10 -4
  49. package/instructions/AGENTS.md +24 -18
  50. package/package.json +6 -2
@@ -0,0 +1,289 @@
1
+ /** Terminal-owned npm update. Every controller import is a Node builtin and is
2
+ * loaded before npm may replace this package; no project or host writers. */
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { spawn } from 'node:child_process';
9
+ import { gunzip } from 'node:zlib';
10
+ import { promisify } from 'node:util';
11
+ const unzip = promisify(gunzip);
12
+ const NAME = '@prodero/curator', REGISTRY = 'https://registry.npmjs.org/';
13
+ const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
14
+ const MAX_TARBALL = 32 * 1024 * 1024, MAX_UNPACKED = 128 * 1024 * 1024;
15
+ export class NpmUpdateError extends Error {
16
+ code;
17
+ exitCode;
18
+ constructor(code, message, exitCode = 1) {
19
+ super(message);
20
+ this.code = code;
21
+ this.exitCode = exitCode;
22
+ }
23
+ }
24
+ function fail(code, message) { throw new NpmUpdateError(code, message); }
25
+ // The registry normalizes away ./; allow only these equivalent exact paths.
26
+ const supportedBin = (value) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && Object.hasOwn(value, 'prodero') && ['bin/prodero', './bin/prodero'].includes(value.prodero);
27
+ const comparison = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; };
28
+ const supportedEngine = (engine, node) => {
29
+ // The initial allowlist intentionally accepts only the project's published
30
+ // engine contract. Unknown range syntax is unsupported, never guessed.
31
+ return engine === '>=22.14.0 <23' && stable.test(node) && comparison(node, '22.14.0') >= 0 && comparison(node, '23.0.0') < 0;
32
+ };
33
+ async function bounded(response, max) {
34
+ if (!response.ok || !response.body)
35
+ fail('NPM_UPDATE_REGISTRY', 'The public npm artifact or metadata is unavailable. No install started.');
36
+ const length = Number(response.headers.get('content-length'));
37
+ if (length > max)
38
+ fail('NPM_UPDATE_ARTIFACT', 'The npm response exceeds the supported size.');
39
+ const reader = response.body.getReader(), parts = [];
40
+ let bytes = 0;
41
+ try {
42
+ while (true) {
43
+ const chunk = await reader.read();
44
+ if (chunk.done)
45
+ break;
46
+ bytes += chunk.value.length;
47
+ if (bytes > max)
48
+ fail('NPM_UPDATE_ARTIFACT', 'The npm response exceeds the supported size.');
49
+ parts.push(Buffer.from(chunk.value));
50
+ }
51
+ }
52
+ finally {
53
+ await reader.cancel().catch(() => { });
54
+ }
55
+ return Buffer.concat(parts);
56
+ }
57
+ function childEnv(temp, node) {
58
+ return { HOME: path.join(temp, 'home'), TMPDIR: path.join(temp, 'tmp'), PATH: path.dirname(node), LANG: 'C', LC_ALL: 'C', NO_COLOR: '1', npm_config_update_notifier: 'false' };
59
+ }
60
+ /** Wait for actual exit, but never wait indefinitely for descendants holding
61
+ * inherited output pipes. Only this command's direct child is signalled. */
62
+ export async function runNpmCommand(node, args, options) {
63
+ return new Promise(resolve => {
64
+ let stdout = '', stderr = '', timedOut = false, done = false, exited = false, code = null, signal = null;
65
+ const child = spawn(node, args, { cwd: options.cwd, env: options.env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
66
+ let hardTimer, drainTimer, finalTimer;
67
+ const finish = (outputIncomplete) => { if (done)
68
+ return; done = true; clearTimeout(deadline); clearTimeout(hardTimer); clearTimeout(drainTimer); clearTimeout(finalTimer); child.stdout.destroy(); child.stderr.destroy(); child.unref(); resolve({ code, stdout, stderr, timedOut, exited, outputIncomplete, signal }); };
69
+ child.stdout.on('data', data => { stdout = (stdout + String(data)).slice(-65536); });
70
+ child.stderr.on('data', data => { stderr = (stderr + String(data)).slice(-65536); });
71
+ const deadline = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); hardTimer = setTimeout(() => { child.kill('SIGKILL'); finalTimer = setTimeout(() => finish(true), 250); }, 5000); }, options.timeout);
72
+ child.once('error', () => finish(true));
73
+ child.once('exit', (exitCode, exitSignal) => { exited = true; code = exitCode; signal = exitSignal; clearTimeout(deadline); clearTimeout(hardTimer); drainTimer = setTimeout(() => finish(true), 250); });
74
+ child.once('close', () => finish(false));
75
+ });
76
+ }
77
+ function flags(temp, prefix) { return ['--prefix', prefix, '--userconfig', path.join(temp, 'user.npmrc'), '--globalconfig', path.join(temp, 'global.npmrc'), '--cache', path.join(temp, 'cache'), '--registry', REGISTRY]; }
78
+ async function owned(target, directory = false) {
79
+ const stat = await fs.lstat(target);
80
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022))
81
+ fail('NPM_UPDATE_LAYOUT', 'This updater requires an ordinary same-user npm installation without writable shared paths. Use your package manager for this layout.');
82
+ if (directory && !(stat.mode & 0o200))
83
+ fail('NPM_UPDATE_PERMISSION', 'This npm installation is not writable. Repair permissions with its package manager; this updater does not use sudo.');
84
+ if (directory)
85
+ await fs.access(target, fs.constants.W_OK).catch(() => fail('NPM_UPDATE_PERMISSION', 'This npm installation is not writable. Repair permissions with its package manager; this updater does not use sudo.'));
86
+ return stat;
87
+ }
88
+ async function layout(deps, temp) {
89
+ const selectedRoot = path.resolve(deps.packageRoot ?? fileURLToPath(new URL('../..', import.meta.url))), root = await fs.realpath(selectedRoot), prefix = path.resolve(root, '../../../..');
90
+ if (selectedRoot !== root)
91
+ fail('NPM_UPDATE_LAYOUT', 'Linked package roots are unsupported. Invoke this installation’s own public executable.');
92
+ const entry = path.resolve(deps.entryPath ?? process.argv[1] ?? '');
93
+ const publicEntries = [path.join(prefix, 'bin/prodero'), path.join(root, 'bin/prodero')], internalCli = path.join(root, 'dist/cli.js');
94
+ if (!publicEntries.includes(entry) && entry !== internalCli || await fs.realpath(entry) !== (entry === internalCli ? internalCli : path.join(root, 'bin/prodero')))
95
+ fail('NPM_UPDATE_LAYOUT', 'The invoked executable does not belong to this exact npm installation. Linked aliases cannot select a different prefix.');
96
+ if (root !== path.join(prefix, 'lib/node_modules/@prodero/curator') || await fs.stat(path.join(root, 'src/cli.ts')).catch(() => null))
97
+ fail('NPM_UPDATE_LAYOUT', 'Only an installed global npm @prodero/curator package is supported. Linked packages and source checkouts need their package manager.');
98
+ for (const dir of [prefix, path.join(prefix, 'bin'), path.join(prefix, 'lib'), path.join(prefix, 'lib/node_modules'), path.join(prefix, 'lib/node_modules/@prodero'), root])
99
+ await owned(dir, true);
100
+ await owned(path.join(root, 'package.json'));
101
+ await owned(path.join(root, 'bin/prodero'));
102
+ const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
103
+ if (pkg.name !== NAME || !stable.test(pkg.version) || !supportedBin(pkg.bin))
104
+ fail('NPM_UPDATE_LAYOUT', 'The installed package identity or public executable is unsupported.');
105
+ const publicBin = path.join(prefix, 'bin/prodero');
106
+ const bin = await fs.lstat(publicBin);
107
+ if (!bin.isSymbolicLink() || process.getuid && bin.uid !== process.getuid() || await fs.realpath(publicBin) !== path.join(root, 'bin/prodero'))
108
+ fail('NPM_UPDATE_LAYOUT', 'The public executable does not belong to this npm package. No other installation will be changed.');
109
+ const node = await fs.realpath(deps.nodePath ?? process.execPath);
110
+ await fs.access(node, fs.constants.X_OK);
111
+ const npm = await fs.realpath(path.join(path.dirname(node), 'npm')).catch(() => fail('NPM_UPDATE_TOOLCHAIN', 'Cannot find npm beside the current Node executable. Use this installation’s package manager.'));
112
+ const npmRoot = path.resolve(path.dirname(npm), '..'), npmPkg = JSON.parse(await fs.readFile(path.join(npmRoot, 'package.json'), 'utf8'));
113
+ if (npm !== path.join(npmRoot, 'bin/npm-cli.js') || npmPkg.name !== 'npm' || npmPkg.bin?.npm !== 'bin/npm-cli.js' || !/^10\.9\.\d+$/.test(npmPkg.version))
114
+ fail('NPM_UPDATE_TOOLCHAIN', 'This updater supports npm 10.9.x with Node 22.14–22.x. Other toolchains need their package manager.');
115
+ if (!supportedEngine('>=22.14.0 <23', process.versions.node))
116
+ fail('NPM_UPDATE_TOOLCHAIN', 'This updater requires Node 22.14–22.x.');
117
+ for (const file of [node, npm, path.join(npmRoot, 'package.json')]) {
118
+ const s = await fs.lstat(file);
119
+ if (!s.isFile() || s.mode & 0o022 || process.getuid && s.uid !== process.getuid() && s.uid !== 0)
120
+ fail('NPM_UPDATE_TOOLCHAIN', 'The Node/npm toolchain ownership or permissions are unsupported.');
121
+ }
122
+ if (node.startsWith(root + path.sep) || npm.startsWith(root + path.sep))
123
+ fail('NPM_UPDATE_TOOLCHAIN', 'Node and npm must be outside the package being replaced.');
124
+ const run = deps.run ?? runNpmCommand, options = { cwd: temp, env: childEnv(temp, node), timeout: 15000 }, fixed = flags(temp, prefix);
125
+ const npmRootResult = await run(node, [npm, 'root', '--global', ...fixed], options), listing = await run(node, [npm, 'ls', '--global', '--parseable', '--depth=0', NAME, ...fixed], options);
126
+ if (npmRootResult.code !== 0 || npmRootResult.timedOut || npmRootResult.outputIncomplete || npmRootResult.exited === false || npmRootResult.stdout.trim() !== path.join(prefix, 'lib/node_modules') || listing.code !== 0 || listing.timedOut || listing.outputIncomplete || listing.exited === false || !listing.stdout.trim().split(/\r?\n/).includes(root))
127
+ fail('NPM_UPDATE_LAYOUT', 'The verified npm does not recognize this exact global installation. No package was changed.');
128
+ const identities = await Promise.all([entry, node, npm, path.join(npmRoot, 'package.json'), path.join(root, 'package.json'), path.join(root, 'bin/prodero')].map(async (file) => { const s = await fs.stat(file); return [file, s.dev, s.ino, s.size, s.mtimeMs, s.uid, s.mode]; }));
129
+ return { root, prefix, node, npm, version: pkg.version, identity: JSON.stringify(identities) };
130
+ }
131
+ function manifest(value, version) {
132
+ const v = value;
133
+ if (!v || v.name !== NAME || !stable.test(v.version) || version && v.version !== version || !supportedBin(v.bin) || !supportedEngine(v.engines?.node, process.versions.node))
134
+ fail('NPM_UPDATE_TARGET', 'The npm target identity, executable or Node engine contract is unsupported.');
135
+ if (v.scripts && (typeof v.scripts !== 'object' || Array.isArray(v.scripts)))
136
+ fail('NPM_UPDATE_TARGET', 'Invalid npm target script metadata.');
137
+ if (v.scripts && ['preinstall', 'install', 'postinstall'].some(k => v.scripts[k]))
138
+ fail('NPM_UPDATE_SCRIPTS', 'The target requires install lifecycle hooks, which this updater does not run.');
139
+ return v;
140
+ }
141
+ /** Inspect the verified archive before npm can create bins or install anything.
142
+ * Normal ustar and npm PAX headers are supported; links/escaping paths are not. */
143
+ async function archiveManifest(compressed) {
144
+ const tar = await unzip(compressed, { maxOutputLength: MAX_UNPACKED }), files = new Set();
145
+ let pkg, paxPath;
146
+ for (let offset = 0; offset + 512 <= tar.length;) {
147
+ const h = tar.subarray(offset, offset + 512);
148
+ if (h.every(b => b === 0))
149
+ break;
150
+ const field = (start, size) => h.subarray(start, start + size).toString('utf8').replace(/\0.*$/s, '');
151
+ const sizeText = field(124, 12).trim();
152
+ if (!/^[0-7]+$/.test(sizeText))
153
+ fail('NPM_UPDATE_ARTIFACT', 'Unsupported npm archive size field.');
154
+ const size = parseInt(sizeText, 8), end = offset + 512 + size;
155
+ if (!Number.isSafeInteger(size) || end > tar.length)
156
+ fail('NPM_UPDATE_ARTIFACT', 'Incomplete npm archive.');
157
+ const checksum = parseInt(field(148, 8).trim(), 8), sum = h.reduce((a, b, i) => a + (i >= 148 && i < 156 ? 32 : b), 0);
158
+ if (checksum !== sum)
159
+ fail('NPM_UPDATE_ARTIFACT', 'Invalid npm archive header.');
160
+ const type = field(156, 1), body = tar.subarray(offset + 512, end);
161
+ offset += 512 + Math.ceil(size / 512) * 512;
162
+ if (type === 'x' || type === 'g') {
163
+ let at = 0;
164
+ while (at < body.length) {
165
+ const space = body.indexOf(32, at), length = Number(body.subarray(at, space).toString());
166
+ if (space < at || !Number.isSafeInteger(length) || length <= space - at + 1 || at + length > body.length)
167
+ fail('NPM_UPDATE_ARTIFACT', 'Invalid npm archive metadata.');
168
+ const record = body.subarray(space + 1, at + length - 1).toString(), equal = record.indexOf('=');
169
+ if (record.slice(0, equal) === 'path')
170
+ paxPath = record.slice(equal + 1);
171
+ if (['linkpath', 'size'].includes(record.slice(0, equal)))
172
+ fail('NPM_UPDATE_ARTIFACT', 'Unsupported npm archive metadata.');
173
+ at += length;
174
+ }
175
+ continue;
176
+ }
177
+ const name = paxPath ?? [field(345, 155), field(0, 100)].filter(Boolean).join('/');
178
+ paxPath = undefined;
179
+ if (!name.startsWith('package/') || name.includes('\\') || name.split('/').includes('..') || path.posix.normalize(name).replace(/\/$/, '') !== name.replace(/\/$/, ''))
180
+ fail('NPM_UPDATE_ARTIFACT', 'Unsafe npm archive path.');
181
+ if (type === '5')
182
+ continue;
183
+ if (type !== '' && type !== '0')
184
+ fail('NPM_UPDATE_ARTIFACT', 'Linked or unsupported npm archive entries are not supported.');
185
+ const relative = name.slice(8);
186
+ if (files.has(relative))
187
+ fail('NPM_UPDATE_ARTIFACT', 'Duplicate npm archive entry.');
188
+ files.add(relative);
189
+ if (relative.endsWith('.node') || path.posix.basename(relative) === 'binding.gyp')
190
+ fail('NPM_UPDATE_SCRIPTS', 'A target native build is unsupported with install scripts disabled.');
191
+ if (relative === 'package.json') {
192
+ if (size > 1024 * 1024)
193
+ fail('NPM_UPDATE_ARTIFACT', 'The package manifest is too large.');
194
+ try {
195
+ pkg = JSON.parse(body.toString('utf8'));
196
+ }
197
+ catch {
198
+ fail('NPM_UPDATE_ARTIFACT', 'Invalid package manifest in npm archive.');
199
+ }
200
+ }
201
+ }
202
+ if (!pkg || !files.has('bin/prodero') || !files.has('dist/cli.js'))
203
+ fail('NPM_UPDATE_ARTIFACT', 'The target is missing its prebuilt public executable or CLI.');
204
+ return { pkg, files };
205
+ }
206
+ export async function runNpmUpdate(options = {}, deps = {}) {
207
+ if (options.targetVersion && !stable.test(options.targetVersion))
208
+ fail('NPM_UPDATE_VERSION', 'Use one exact stable version (for example 0.2.0), without ranges, tags or URLs.');
209
+ if (!deps.interactive && !(options.yes && options.targetVersion))
210
+ fail('NPM_UPDATE_CONFIRMATION', 'Use an interactive terminal, or explicitly run prodero update <exact-version> --yes after ending work and closing Codex and Claude.');
211
+ const write = deps.write ?? console.log, temp = await fs.mkdtemp(path.join(os.tmpdir(), 'prodero-npm-update-'));
212
+ let started = false;
213
+ try {
214
+ await Promise.all(['home', 'tmp', 'cache'].map(dir => fs.mkdir(path.join(temp, dir), { mode: 0o700 })));
215
+ await Promise.all(['user.npmrc', 'global.npmrc'].map(file => fs.writeFile(path.join(temp, file), '', { mode: 0o600 })));
216
+ const current = await layout(deps, temp), request = deps.request ?? fetch;
217
+ let target;
218
+ try {
219
+ const data = await bounded(await request(`${REGISTRY}@prodero%2fcurator/${options.targetVersion ?? 'latest'}`, { signal: AbortSignal.timeout(15000), redirect: 'error' }), 1024 * 1024);
220
+ target = manifest(JSON.parse(data.toString()), options.targetVersion);
221
+ }
222
+ catch (error) {
223
+ if (error instanceof NpmUpdateError)
224
+ throw error;
225
+ fail('NPM_UPDATE_REGISTRY', 'Cannot verify the public npm target. No install started.');
226
+ }
227
+ if (comparison(target.version, current.version) < 0)
228
+ fail('NPM_UPDATE_VERSION', 'Downgrades are not supported. No package was changed.');
229
+ if (target.version === current.version)
230
+ return { ok: true, status: 'current', installedVersion: current.version, targetVersion: target.version, prefix: current.prefix, restartRequired: false, message: 'This exact npm version is already installed. No package was changed.', exitCode: 0 };
231
+ let url;
232
+ try {
233
+ url = new URL(target.dist.tarball);
234
+ }
235
+ catch {
236
+ fail('NPM_UPDATE_TARGET', 'The npm artifact URL is missing or invalid.');
237
+ }
238
+ if (url.protocol !== 'https:' || url.hostname !== 'registry.npmjs.org' || url.port || url.username || url.password || url.search || url.hash || url.pathname !== `/@prodero/curator/-/curator-${target.version}.tgz` || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(target.dist.integrity))
239
+ fail('NPM_UPDATE_TARGET', 'Only a verified standard npm registry artifact is supported.');
240
+ let tarball;
241
+ try {
242
+ tarball = await bounded(await request(url.href, { signal: AbortSignal.timeout(30000), redirect: 'error' }), MAX_TARBALL);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof NpmUpdateError)
246
+ throw error;
247
+ fail('NPM_UPDATE_REGISTRY', 'Cannot download the verified npm artifact. No install started.');
248
+ }
249
+ if (`sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}` !== target.dist.integrity)
250
+ fail('NPM_UPDATE_INTEGRITY', 'The downloaded npm artifact does not match its approved integrity. No install started.');
251
+ const archived = manifest((await archiveManifest(tarball)).pkg, target.version);
252
+ if (archived.engines.node !== target.engines.node)
253
+ fail('NPM_UPDATE_TARGET', 'The artifact engine metadata differs from the registry.');
254
+ write(`Update ${NAME}: ${current.version} → ${target.version}\nDestination: ${current.prefix}\nInstall scripts are disabled. Standard npm production dependencies will be resolved from the public registry.\nFinish work and close Codex and Claude before continuing. This command does not detect or close running applications.`);
255
+ if (options.yes)
256
+ write('--yes confirms that you have finished work and closed Codex and Claude.');
257
+ if (!options.yes && !await deps.confirm?.('Have you finished work and closed Codex and Claude? Continue with this update? [y/N] '))
258
+ return { ok: false, status: 'cancelled', installedVersion: current.version, targetVersion: target.version, prefix: current.prefix, restartRequired: false, message: 'Update cancelled. No package was changed.', exitCode: 1 };
259
+ if ((await layout(deps, temp)).identity !== current.identity)
260
+ fail('NPM_UPDATE_CHANGED', 'The installation or toolchain changed during confirmation. Run the update again to review it.');
261
+ const artifact = path.join(temp, 'curator.tgz');
262
+ await fs.writeFile(artifact, tarball, { mode: 0o600 });
263
+ write('Installing the checked npm artifact. Keep Codex and Claude closed until verification finishes.');
264
+ const run = deps.run ?? runNpmCommand, execOptions = { cwd: temp, env: childEnv(temp, current.node), timeout: 180000 };
265
+ started = true;
266
+ const installed = await run(current.node, [current.npm, 'install', '--global', ...flags(temp, current.prefix), '--ignore-scripts', '--engine-strict', '--omit=dev', '--no-audit', '--no-fund', artifact], execOptions);
267
+ if (installed.code !== 0 || installed.timedOut || installed.outputIncomplete || installed.exited === false)
268
+ return { ok: false, status: 'failed', installedVersion: null, targetVersion: target.version, prefix: current.prefix, restartRequired: true, message: 'npm did not complete successfully. The installation may be partial; keep applications closed and inspect/repair this npm installation before retrying. No rollback is claimed.', exitCode: installed.code && installed.code > 0 ? installed.code : 1 };
269
+ // From this boundary onward use only builtin readers/child-process code already
270
+ // loaded above. Never import new helpers from the replaced installation.
271
+ const pkg = JSON.parse(await fs.readFile(path.join(current.root, 'package.json'), 'utf8'));
272
+ if (pkg.name !== NAME || pkg.version !== target.version || !supportedBin(pkg.bin) || await fs.realpath(path.join(current.prefix, 'bin/prodero')) !== path.join(current.root, 'bin/prodero'))
273
+ fail('NPM_UPDATE_VERIFY', 'npm finished, but the installed package or public executable does not match the target. Keep applications closed and repair the npm installation.');
274
+ await fs.access(path.join(current.prefix, 'bin/prodero'), fs.constants.X_OK);
275
+ const version = await run(current.node, [path.join(current.root, 'bin/prodero'), '--version'], { ...execOptions, timeout: 15000 }), help = await run(current.node, [path.join(current.root, 'bin/prodero'), '--help'], { ...execOptions, timeout: 15000 });
276
+ if (version.outputIncomplete || help.outputIncomplete || version.exited === false || help.exited === false || version.timedOut || help.timedOut || version.code !== 0 || version.stdout.trim() !== target.version || help.code !== 0 || !help.stdout.includes('Usage: prodero'))
277
+ fail('NPM_UPDATE_VERIFY', 'The installed target failed its CLI smoke checks. Keep applications closed and repair the npm installation.');
278
+ return { ok: true, status: 'updated', installedVersion: target.version, targetVersion: target.version, prefix: current.prefix, restartRequired: true, message: `Installed ${NAME}@${target.version} and verified its public CLI. Reopen Codex or Claude and reconnect MCP to load the new runtime. Project data and saved host/dev selections were not changed by this updater.`, exitCode: 0 };
279
+ }
280
+ catch (error) {
281
+ if (error instanceof NpmUpdateError)
282
+ throw error;
283
+ throw new NpmUpdateError(started ? 'NPM_UPDATE_VERIFY' : 'NPM_UPDATE_FAILED', started ? 'The update outcome could not be verified. Keep applications closed and inspect/repair the npm installation before retrying.' : 'The npm installation or target could not be verified. No install started.');
284
+ }
285
+ finally {
286
+ await fs.rm(temp, { recursive: true, force: true }).catch(() => { });
287
+ }
288
+ }
289
+ //# sourceMappingURL=npm-update.js.map
@@ -33,7 +33,7 @@ import { discoverSessions } from "./session-index.js";
33
33
  import { closeSession, createSession, readSession, recordSessionObservations, updateSessionTaskContext } from "./sessions.js";
34
34
  import { DEFAULT_CONTEXT_BUDGET_TOKENS, DEFAULT_REQUIRED_CONTEXT_BUDGET_TOKENS, memoryCompression, summarizeSessionMemory, TOKEN_COUNT_METHOD } from "./telemetry.js";
35
35
  import { INDEX_TEMPLATE } from "./templates.js";
36
- import { ensureDerivedStateRoot, checkedDataPath, ensureProjectDirectories, ensureRuntimeDirectories, ensureLocalStateIgnored, exists, projectDataPath, readConfig, sha256, writeFileAtomic, writeConfig, } from "./storage.js";
36
+ import { CANONICAL_ID_PATTERN, ensureDerivedStateRoot, checkedDataPath, ensureProjectDirectories, ensureRuntimeDirectories, ensureLocalStateIgnored, exists, projectDataPath, readConfig, sha256, writeFileAtomic, writeConfig, } from "./storage.js";
37
37
  export { PRODERO_SCHEMA_VERSION, PRODERO_VERSION } from "./constants.js";
38
38
  export { createSession, parseSession, SESSION_SECTIONS } from "./sessions.js";
39
39
  export { getContext } from "./context.js";
@@ -102,13 +102,13 @@ export async function getDetailedProjectStatus(start = process.cwd(), options =
102
102
  const statusReads = [
103
103
  config ? (async () => {
104
104
  const phaseStarted = performance.now();
105
- const result = await discoverSessions(root);
105
+ const result = await discoverSessions(root, { persistCache: options.persistCache });
106
106
  options.instrument?.({ phase: "session-index", durationMs: performance.now() - phaseStarted, cache: result.cacheState, filesScanned: result.sessionFilesScanned, bodiesRead: result.sessionBodiesRead });
107
107
  return result;
108
108
  })() : null,
109
109
  config ? (async () => {
110
110
  const phaseStarted = performance.now();
111
- const result = await loadMemoryCatalog(root);
111
+ const result = await loadMemoryCatalog(root, { persistCache: options.persistCache });
112
112
  options.instrument?.({ phase: "memory-index", durationMs: performance.now() - phaseStarted, cache: result.cacheState, filesScanned: result.filesScanned, bodiesRead: result.bodiesIndexed });
113
113
  return result;
114
114
  })() : null,
@@ -128,7 +128,7 @@ export async function getDetailedProjectStatus(start = process.cwd(), options =
128
128
  const [discovery, catalog, fast, skills] = statusResults;
129
129
  const telemetryStarted = performance.now();
130
130
  const knowledge = catalog?.summaries ?? [];
131
- const graph = config && catalog ? await loadKnowledgeGraph(root, catalog) : null;
131
+ const graph = config && catalog ? await loadKnowledgeGraph(root, catalog, { persistCache: options.persistCache }) : null;
132
132
  const disputedIds = new Set(knowledge.filter((item) => item.status === "disputed").map((item) => item.id));
133
133
  const conflictPairs = new Set();
134
134
  for (const item of knowledge.filter((candidate) => candidate.status === "disputed")) {
@@ -138,7 +138,7 @@ export async function getDetailedProjectStatus(start = process.cwd(), options =
138
138
  conflictPairs.add(item.id);
139
139
  }
140
140
  const counts = skillCounts(skills);
141
- const recommendations = config ? await promotionRecommendations(root) : [];
141
+ const recommendations = config ? await promotionRecommendations(root, { persistCache: options.persistCache }) : [];
142
142
  const status = {
143
143
  ...fast,
144
144
  knowledgeCount: knowledge.filter((item) => item.status === "active" || item.status === "disputed").length,
@@ -165,8 +165,8 @@ export async function getDetailedProjectStatus(start = process.cwd(), options =
165
165
  }
166
166
  }
167
167
  /** Complete status remains the compatibility API for correctness-oriented callers. */
168
- export async function getProjectStatus(start = process.cwd()) {
169
- return getDetailedProjectStatus(start);
168
+ export async function getProjectStatus(start = process.cwd(), options = {}) {
169
+ return getDetailedProjectStatus(start, options);
170
170
  }
171
171
  export async function initializeProject(start = process.cwd()) {
172
172
  const root = await findProjectRoot(start);
@@ -510,7 +510,7 @@ export async function rememberProjectKnowledge(start, input, provider) {
510
510
  if (relationships.length > 12)
511
511
  throw new Error("Remember supports at most 12 explicit relationships.");
512
512
  for (const relation of relationships) {
513
- if (!["related_to", "depends_on", "implements"].includes(relation.type) || !/^mem_[a-zA-Z0-9_]+$/.test(relation.target))
513
+ if (!["related_to", "depends_on", "implements"].includes(relation.type) || !CANONICAL_ID_PATTERN.test(relation.target))
514
514
  throw new Error("Remember relationship is malformed.");
515
515
  const target = beforeKnowledge.find((item) => item.id === relation.target);
516
516
  if (!target || (target.status !== "active" && target.status !== "disputed"))
@@ -13,8 +13,9 @@ const cacheSchema = z.object({
13
13
  checkpoints: z.record(stampSchema.extend({ id: z.string(), createdAt: z.string(), processedKeys: z.array(z.string()), observationHashes: z.array(z.string()).optional() })),
14
14
  sessions: z.record(stampSchema.extend({ id: z.string(), parentSessionId: z.string().regex(/^sess_[a-zA-Z0-9_]+$/).optional(), sha256: z.string().regex(/^[a-f0-9]{64}$/), status: z.enum(["active", "open", "closed"]), hasObservations: z.boolean() })),
15
15
  });
16
- async function readCache(root) {
17
- await ensureDerivedStateRoot(root);
16
+ async function readCache(root, persistCache) {
17
+ if (persistCache)
18
+ await ensureDerivedStateRoot(root);
18
19
  const target = await checkedDataPath(root, ".cache", "session-index.json");
19
20
  if (!(await exists(target)))
20
21
  return { cache: structuredClone(EMPTY_CACHE), state: "missing" };
@@ -25,7 +26,8 @@ async function readCache(root) {
25
26
  catch {
26
27
  // Disposable derived JSON is rebuilt below; canonical sessions/checkpoints remain authoritative.
27
28
  }
28
- await fs.rm(target, { force: true });
29
+ if (persistCache)
30
+ await fs.rm(target, { force: true });
29
31
  return { cache: structuredClone(EMPTY_CACHE), state: "invalid" };
30
32
  }
31
33
  function parseCheckpoint(content, filename) {
@@ -33,7 +35,7 @@ function parseCheckpoint(content, filename) {
33
35
  return { observationHashes: checkpoint.observationHashes ?? [], id: checkpoint.id, createdAt: checkpoint.createdAt, processedKeys: checkpoint.sessions.map((session) => `${session.path}:${session.sha256}`), size: 0, mtimeMs: 0, ctimeMs: 0 };
34
36
  }
35
37
  export async function discoverSessions(root, options = {}) {
36
- const { cache, state } = await readCache(root);
38
+ const { cache, state } = await readCache(root, options.persistCache !== false);
37
39
  const nextCheckpoints = {};
38
40
  const processed = new Set();
39
41
  const processedObservations = new Set();
@@ -93,8 +95,10 @@ export async function discoverSessions(root, options = {}) {
93
95
  sessions.push({ id: cached.id, parentSessionId: cached.parentSessionId, path: name, sha256: cached.sha256, status: cached.status, hasObservations: cached.hasObservations, pending, session: parsed });
94
96
  }
95
97
  const nextCache = { version: 3, checkpoints: nextCheckpoints, sessions: nextSessions };
96
- await ensureDerivedStateRoot(root);
97
- await writeFileAtomic(projectDataPath(root, ".cache", "session-index.json"), `${JSON.stringify(nextCache)}\n`);
98
+ if (options.persistCache !== false) {
99
+ await ensureDerivedStateRoot(root);
100
+ await writeFileAtomic(projectDataPath(root, ".cache", "session-index.json"), `${JSON.stringify(nextCache)}\n`);
101
+ }
98
102
  const checkpointEntries = Object.values(nextCheckpoints).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
99
103
  return {
100
104
  processedObservations,
@@ -1,4 +1,4 @@
1
- import { writeFileExclusive } from "./storage.js";
1
+ import { writeFileExclusive, CANONICAL_ID_PATTERN } from "./storage.js";
2
2
  import { validateApplicability, inferApplicability, buildTaskContext } from "./applicability.js";
3
3
  import { assertPathScope } from "./path-scope.js";
4
4
  import { assertNoSecrets } from "./secrets.js";
@@ -218,9 +218,9 @@ export function validateEntries(entries) {
218
218
  if (entry.proposedReason && entry.proposedReason.length > 1_000)
219
219
  throw new Error("Skill proposal reasons may not exceed 1,000 characters.");
220
220
  }
221
- if (entry.supersedes?.some((id) => !/^mem_[a-zA-Z0-9_]+$/.test(id)))
221
+ if (entry.supersedes?.some((id) => !CANONICAL_ID_PATTERN.test(id)))
222
222
  throw new Error("Superseded Prodero memory ids are malformed.");
223
- if (entry.relations?.some((relation) => !["related_to", "depends_on", "implements"].includes(relation.type) || !/^mem_[a-zA-Z0-9_]+$/.test(relation.target)))
223
+ if (entry.relations?.some((relation) => !["related_to", "depends_on", "implements"].includes(relation.type) || !CANONICAL_ID_PATTERN.test(relation.target)))
224
224
  throw new Error("Prodero memory relationships are malformed.");
225
225
  if (content.length > 10_000)
226
226
  throw new Error("A Prodero session observation may not exceed 10,000 characters.");
@@ -1,23 +1,34 @@
1
1
  import { SETTINGS, settingKeySchema } from '../settings-definition.js';
2
2
  import fs from 'node:fs/promises';
3
3
  import { presentationConfigSchema } from '../settings-definition.js';
4
- import { readConfig, writeConfig, projectDataPath, assertSafePath, exists } from './storage.js';
4
+ import { readConfig, writeConfig, projectDataPath, assertSafePath, exists, assertSupportedSchema, assertSupportedKnowledgeSchema } from './storage.js';
5
5
  import { withSyncLock } from './sync-transaction.js';
6
- export function effectiveSettings(config) {
7
- const values = { 'presentation.mode': config?.presentation?.mode, 'presentation.theme': config?.presentation?.theme, 'context.max_tokens': config?.context?.max_tokens ?? config?.context?.budgetTokens };
6
+ import { readLocalPreferences, saveLocalPreference } from './local-preferences.js';
7
+ async function supportedConfig(root) {
8
+ const config = await readConfig(root);
9
+ if (config) {
10
+ assertSupportedSchema(config.schemaVersion);
11
+ assertSupportedKnowledgeSchema(config.knowledgeSchemaVersion);
12
+ }
13
+ return config;
14
+ }
15
+ export function effectiveSettings(config, local = {}) {
16
+ const legacy = { 'presentation.mode': config?.presentation?.mode, 'presentation.theme': config?.presentation?.theme, 'context.max_tokens': config?.context?.max_tokens ?? config?.context?.budgetTokens };
17
+ const overrides = { 'presentation.mode': local.mode, 'presentation.theme': local.theme, 'context.max_tokens': undefined };
18
+ const values = { ...legacy, 'presentation.mode': local.mode ?? legacy['presentation.mode'], 'presentation.theme': local.theme ?? legacy['presentation.theme'] };
8
19
  return SETTINGS.map(def => {
9
20
  const value = values[def.key] ?? def.defaultValue;
10
21
  const option = 'options' in def ? def.options.find(o => o.id === value) : undefined;
11
22
  return { key: def.key, group: def.group, label: def.label, description: option && 'description' in option ? option.description : def.description, type: def.type, value,
12
23
  displayValue: option?.label ?? (typeof value === 'number' ? `${value.toLocaleString('en-US')} ${'unit' in def ? def.unit : ''}`.trim() : String(value)),
13
- source: values[def.key] === undefined ? 'default' : 'project override',
24
+ source: overrides[def.key] !== undefined ? 'local override' : legacy[def.key] === undefined ? 'default' : def.group === 'Presentation' ? 'legacy project default' : 'project override',
14
25
  defaultValue: def.defaultValue, ...('options' in def ? { options: def.options } : {}), ...('min' in def ? { min: def.min, max: def.max } : {}) };
15
26
  });
16
27
  }
17
28
  export async function projectSettings(root) {
18
29
  let config;
19
30
  try {
20
- config = await readConfig(root);
31
+ config = await supportedConfig(root);
21
32
  }
22
33
  catch (error) {
23
34
  // Presentation preferences are metadata, never a permission to read/write newer knowledge.
@@ -37,8 +48,9 @@ export async function projectSettings(root) {
37
48
  const presentation = presentationConfigSchema.safeParse(raw?.presentation);
38
49
  config = { presentation: presentation.success ? presentation.data : undefined };
39
50
  }
40
- const settings = effectiveSettings(config);
41
- return { root, initialized: config !== null, settings, presentation: { mode: config?.presentation?.mode ?? SETTINGS[0].defaultValue, theme: config?.presentation?.theme ?? SETTINGS[1].defaultValue } };
51
+ const local = await readLocalPreferences(root), settings = effectiveSettings(config, local.presentation);
52
+ return { root, initialized: config !== null, settings, presentation: { mode: local.presentation.mode ?? config?.presentation?.mode ?? SETTINGS[0].defaultValue, theme: local.presentation.theme ?? config?.presentation?.theme ?? SETTINGS[1].defaultValue },
53
+ presentationSources: { mode: settings[0].source, theme: settings[1].source }, diagnostics: local.diagnostics };
42
54
  }
43
55
  export function renderSettings(view) {
44
56
  const lines = ['Prodero · Project Settings'];
@@ -47,30 +59,36 @@ export function renderSettings(view) {
47
59
  for (const s of view.settings.filter(s => s.group === group))
48
60
  lines.push(`${s.label}: ${s.displayValue} (${s.source})`, s.description);
49
61
  }
62
+ lines.push('Display mode and theme are saved on this checkout. Context budget is shared project configuration.');
63
+ for (const diagnostic of view.diagnostics)
64
+ lines.push(`${diagnostic.code}: ${diagnostic.cause}`, diagnostic.recovery);
50
65
  if (!view.initialized)
51
66
  lines.push('Project is not initialized. Defaults are shown; initialize Prodero before saving settings.');
52
67
  return lines.join('\n\n');
53
68
  }
54
- export async function setProjectSetting(root, key, value) {
69
+ export async function setProjectSetting(root, key, value, options = {}) {
55
70
  const name = settingKeySchema.parse(key), def = SETTINGS.find(s => s.key === name);
56
- const parsed = def.validation.parse(value);
57
- if (!await readConfig(root))
71
+ const reset = options.reset === true;
72
+ if (reset && (value !== undefined || name === 'context.max_tokens'))
73
+ throw new Error('Reset requires a presentation key and no value.');
74
+ const parsed = reset ? undefined : def.validation.parse(value);
75
+ if (!await supportedConfig(root))
58
76
  throw new Error('Initialize Prodero before changing project settings.');
59
77
  return withSyncLock(root, async () => {
60
- const config = (await readConfig(root));
78
+ const config = await supportedConfig(root);
79
+ if (!config)
80
+ throw new Error('Initialize Prodero before changing project settings.');
61
81
  switch (name) {
62
82
  case 'presentation.mode':
63
- config.presentation = { ...config.presentation, mode: parsed };
64
- break;
65
83
  case 'presentation.theme':
66
- config.presentation = { ...config.presentation, theme: parsed };
67
- break;
84
+ await saveLocalPreference(root, name === 'presentation.mode' ? 'mode' : 'theme', parsed, reset);
85
+ return projectSettings(root);
68
86
  case 'context.max_tokens':
69
87
  config.context = { ...config.context, max_tokens: parsed };
70
88
  break;
71
89
  }
72
90
  await writeConfig(root, config);
73
91
  return projectSettings(root);
74
- }, { waitMs: 5000 });
92
+ }, { waitMs: 5000, ...(name === 'context.max_tokens' ? {} : { recoverLifecycle: false, maintainIntegration: false }) });
75
93
  }
76
94
  //# sourceMappingURL=settings.js.map
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { z } from "zod";
6
6
  import { frontmatter, parseFrontmatter } from "./format.js";
7
7
  import { sortableId } from "./ids.js";
8
- import { tokens } from "./matching.js";
8
+ import { tokens, normalized } from "./matching.js";
9
9
  import { exists, checkedDataPath, ensureDataDirectory, readConfig, sha256, writeFileAtomic } from "./storage.js";
10
10
  import { withSyncLock, recoverSyncTransaction, safeProjectPath, commitSyncTransaction, fileHash } from "./sync-transaction.js";
11
11
  import { estimateTokens } from "./telemetry.js";
@@ -284,7 +284,26 @@ export function scoreSkill(query, skill) {
284
284
  const matched = new Set(signals.flatMap(s => s.matches));
285
285
  const domainMatch = [...matched].some(t => !ambiguous.has(t));
286
286
  const enoughContext = matched.size >= 2;
287
- return { score: domainMatch || enoughContext ? signals.reduce((sum, signal) => sum + signal.score, 0) : 0, signals };
287
+ // Topic overlap (even several nouns) is not a request to execute a procedure.
288
+ // Match a small action family in both the task and skill metadata. Installation
289
+ // as an audit subject deliberately does not imply an install request.
290
+ const intentFamilies = [
291
+ ['build', 'building', 'rebuild', 'rebuilding'],
292
+ ['install', 'installing', 'uninstall', 'uninstalling'],
293
+ ['switch', 'switching', 'enable', 'enabling', 'disable', 'disabling'],
294
+ ['test', 'tests', 'testing', 'verify', 'verification', 'validate', 'validation', 'check', 'checks'],
295
+ ['recover', 'recovery', 'restore', 'restoring'],
296
+ ['debug', 'debugging', 'diagnose', 'diagnosing', 'inspect', 'inspection', 'troubleshoot', 'troubleshooting'],
297
+ ['change', 'changes', 'changing', 'edit', 'editing', 'fix', 'fixing', 'implement', 'update'],
298
+ ];
299
+ const rawQuery = tokenize(query), metadata = tokenize(fields.map(([, value]) => value).join(' '));
300
+ const sharedIntent = intentFamilies.find(family => family.some(t => rawQuery.has(t)) && family.some(t => metadata.has(t)));
301
+ const directionalMode = rawQuery.has('dev') && rawQuery.has('npm') && /(?:↔|→|←|->|<-|\bto\b)/i.test(query)
302
+ && metadata.has('dev') && (metadata.has('npm') || metadata.has('package'));
303
+ const evidence = new Set([...matched, ...(sharedIntent ? ['procedural-intent'] : [])]);
304
+ const named = normalized(query) === normalized(skill.title) && normalized(query).length > 0;
305
+ const eligible = named || ((sharedIntent || directionalMode) && (domainMatch || enoughContext) && evidence.size >= 2);
306
+ return { score: eligible ? Math.max(named ? 1 : 0, signals.reduce((sum, signal) => sum + signal.score, 0)) : 0, signals };
288
307
  }
289
308
  export async function projectSkillsView(root, options = {}) {
290
309
  await requireProject(root);
@@ -70,6 +70,59 @@ export async function ensureStorageDirectory(target, forcePrivate = false) {
70
70
  if (forcePrivate)
71
71
  await tightenPrivateMode(target);
72
72
  }
73
+ const privateIgnoreUpdates = new Map();
74
+ /** Preference writes must not repair shared integration. Private self-exclusion
75
+ * protects files even when root ignore rules are absent; custom rules are never edited.
76
+ */
77
+ export async function ensureSelfIgnoredPrivateDirectory(root, directory) {
78
+ const key = projectDataPath(root, directory), pending = privateIgnoreUpdates.get(key);
79
+ if (pending)
80
+ return pending;
81
+ const failure = () => new Error('PRODERO_LOCAL_IGNORE_UNAVAILABLE: Private ignore protection is unavailable. Preserve the existing local state and restore a safe self-excluding .gitignore containing only * in the private directory before retrying; shared project ignore rules were not changed.');
82
+ const update = (async () => {
83
+ try {
84
+ const target = await checkedDataPath(root, directory);
85
+ const ignore = await checkedDataPath(root, directory, '.gitignore');
86
+ const read = () => fs.readFile(ignore, 'utf8').catch(error => { if (error.code === 'ENOENT')
87
+ return null; throw error; });
88
+ const validate = (text) => {
89
+ const rules = text.split(/\r?\n/).filter(line => line !== '' && !line.startsWith('#'));
90
+ if (rules.length !== 1 || rules[0] !== '*')
91
+ throw failure();
92
+ };
93
+ const readReady = async () => {
94
+ let current = await read();
95
+ // Another process may have opened the exclusive marker but not written its
96
+ // one rule yet. Wait briefly; never overwrite an existing empty/custom file.
97
+ for (let attempt = 0; current === '' && attempt < 3; attempt++) {
98
+ await new Promise(resolve => setTimeout(resolve, 10));
99
+ current = await read();
100
+ }
101
+ return current;
102
+ };
103
+ const current = await readReady();
104
+ if (current !== null)
105
+ validate(current);
106
+ else {
107
+ await ensureStorageDirectory(target);
108
+ await writeFileExclusive(ignore, '*\n').catch(error => { if (error.code !== 'EEXIST')
109
+ throw error; });
110
+ validate((await readReady()) ?? '');
111
+ }
112
+ return target;
113
+ }
114
+ catch {
115
+ throw failure();
116
+ }
117
+ })();
118
+ privateIgnoreUpdates.set(key, update);
119
+ try {
120
+ return await update;
121
+ }
122
+ finally {
123
+ privateIgnoreUpdates.delete(key);
124
+ }
125
+ }
73
126
  export async function writeFileExclusive(target, content) {
74
127
  await assertSafePath(target);
75
128
  await protectPrivatePath(target);
@@ -209,8 +262,9 @@ export async function ensureDataDirectory(root, relative) {
209
262
  await ensureStorageDirectory(target);
210
263
  return target;
211
264
  }
265
+ export const CANONICAL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
212
266
  export function canonicalId(value, filename) {
213
- if (typeof value !== "string" || !/^[a-zA-Z0-9_-]+$/.test(value))
267
+ if (typeof value !== "string" || !CANONICAL_ID_PATTERN.test(value))
214
268
  throw new Error(`Invalid canonical ID: ${filename}`);
215
269
  }
216
270
  const positive = z.number().int().positive();