@pathbit/agentdevkit 0.0.16 → 0.0.18
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/.pbai4devs/INSTRUCTIONS.md +1 -1
- package/.pbai4devs/README.md +1 -1
- package/.pbai4devs/commands/sincronizar/COMMAND.md +6 -4
- package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +3 -2
- package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +18 -4
- package/.pbai4devs/scripts/lib/README.md +3 -0
- package/.pbai4devs/scripts/lib/gitignore.mjs +46 -9
- package/.pbai4devs/scripts/lib/state-path.mjs +20 -0
- package/.pbai4devs/scripts/sincronizar-agentes/README.md +8 -1
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +75 -34
- package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +5 -5
- package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +1 -1
- package/.pbai4devs/scripts/sincronizar-agentes/tests/MANAGED_CONTENT.mjs +9 -9
- package/.pbai4devs/scripts/sincronizar-agentes/tests/PRESERVATION.mjs +306 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +41 -8
- package/.pbai4devs/scripts/testar-maturidade/SCRIPT.mjs +6 -4
- package/.pbai4devs/scripts/testar-maturidade/tests/TEST.mjs +4 -3
- package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +5 -3
- package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +15 -7
- package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +1 -1
- package/.pbai4devs/skills/produto/criar-prd/SKILL.md +1 -1
- package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +1 -1
- package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +1 -1
- package/.pbai4devs/skills/projeto/proximos-passos/SKILL.md +1 -1
- package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +1 -1
- package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -1
- package/.pbai4devs/skills/specs/implementar-spec/SKILL.md +1 -1
- package/.pbai4devs/templates/entrypoints/AGENTS.md +1 -1
- package/.pbai4devs/templates/gitignore/local.ignore +2 -0
- package/.pbai4devs/templates/gitignore/shared.ignore +2 -0
- package/CHANGELOG.md +11 -0
- package/docs/guides/cli.md +46 -15
- package/docs/guides/installation.md +20 -8
- package/docs/guides/troubleshooting.md +9 -1
- package/docs/guides/workflow.md +1 -1
- package/docs/specs/README.md +2 -0
- package/docs/specs/safe-synchronization/SPEC.md +21 -0
- package/docs/specs/state-directory/SPEC.md +22 -0
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/config.mjs +2 -1
- package/src/i18n.mjs +25 -0
- package/src/install.mjs +373 -27
- /package/.pbai4devs/{estado → state}/README.md +0 -0
package/src/install.mjs
CHANGED
|
@@ -1,20 +1,137 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import { synchronize, isReference } from '../.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs';
|
|
6
|
-
import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack } from '../.pbai4devs/scripts/lib/gitignore.mjs';
|
|
7
|
+
import { isGeneratedOutput, planLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, applyLocalIgnore, planUntrack, applyUntrack, restoreUntrack, indexContentMatches } from '../.pbai4devs/scripts/lib/gitignore.mjs';
|
|
7
8
|
import { nativePathBoundary } from '../.pbai4devs/scripts/lib/native-paths.mjs';
|
|
8
9
|
import { canonicalEntrypoint, resolveEntrypoint, entrypointReference, renderEntrypointImports, extractManagedBlock, managedBlockDigest, mergeManagedContent, stripManagedContent } from '../.pbai4devs/scripts/lib/managed-content.mjs';
|
|
9
10
|
import { extractTomlManagedBlock, tomlManagedBlockDigest, mergeTomlContent, stripTomlContent } from '../.pbai4devs/scripts/lib/managed-toml.mjs';
|
|
10
11
|
import { writeAdapterManifest, adapterRoots, normalizeClients, resolveAdapters } from './adapters/index.mjs';
|
|
11
|
-
import { packageRoot, packageInfo, statePath, entryNames, begin, end } from './config.mjs';
|
|
12
|
+
import { packageRoot, packageInfo, statePath, legacyStatePath, entryNames, begin, end } from './config.mjs';
|
|
12
13
|
import { normalizeLanguage, text } from './i18n.mjs';
|
|
13
14
|
|
|
14
15
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
15
16
|
const portable = value => value.split(sep).join('/');
|
|
16
17
|
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
17
18
|
const stat = path => { try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
19
|
+
|
|
20
|
+
// Protect staged human bytes that differ from the working tree, without
|
|
21
|
+
// treating an unchanged HEAD entry as a staged edit or rewriting the index.
|
|
22
|
+
function stagedDivergences(root) {
|
|
23
|
+
let anchor = resolve(root);
|
|
24
|
+
while (!existsSync(anchor)) {
|
|
25
|
+
const parent = dirname(anchor);
|
|
26
|
+
if (parent === anchor) break;
|
|
27
|
+
anchor = parent;
|
|
28
|
+
}
|
|
29
|
+
const prefix = portable(relative(anchor, root));
|
|
30
|
+
let hasGitMarker = false;
|
|
31
|
+
for (let current = anchor; ; current = dirname(current)) {
|
|
32
|
+
if (stat(join(current, '.git'))) { hasGitMarker = true; break; }
|
|
33
|
+
if (dirname(current) === current) break;
|
|
34
|
+
}
|
|
35
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
36
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_EXTERNAL_DIFF']) delete env[key];
|
|
37
|
+
const run = args => spawnSync('git', ['-C', anchor, '-c', 'core.fsmonitor=false', ...args], {
|
|
38
|
+
env, timeout: 30000, windowsHide: true, maxBuffer: 16 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
const repository = run(['rev-parse', '--is-inside-work-tree']);
|
|
41
|
+
if (repository.error || repository.status !== 0) {
|
|
42
|
+
if (hasGitMarker) fail('E_GIT', 'Unable to inspect the existing Git repository safely.');
|
|
43
|
+
if (repository.error?.code === 'ENOENT' || !repository.error && repository.status !== 0) return new Set();
|
|
44
|
+
}
|
|
45
|
+
if (repository.error || repository.status !== 0 || repository.stdout.toString('utf8').trim() !== 'true') fail('E_GIT', 'Unable to inspect Git worktree.');
|
|
46
|
+
// `git diff` against the worktree respects assume-unchanged and
|
|
47
|
+
// skip-worktree, so compare the actual bytes to the index blobs instead.
|
|
48
|
+
const stagedResult = run(['diff', '--cached', '--no-renames', '--no-ext-diff', '--no-textconv', '--name-only', '--relative', '-z']);
|
|
49
|
+
const indexResult = run(['ls-files', '--stage', '-z']);
|
|
50
|
+
if (stagedResult.error || stagedResult.status !== 0 || indexResult.error || indexResult.status !== 0) fail('E_GIT', 'Unable to inspect staged changes safely.');
|
|
51
|
+
const decode = buffer => {
|
|
52
|
+
const value = buffer.toString('utf8');
|
|
53
|
+
if (!Buffer.from(value, 'utf8').equals(buffer)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const records = buffer => {
|
|
57
|
+
const result = []; let start = 0;
|
|
58
|
+
for (let i = 0; i < buffer.length; i++) if (buffer[i] === 0) { result.push(buffer.subarray(start, i)); start = i + 1; }
|
|
59
|
+
if (start !== buffer.length) fail('E_GIT', 'Unable to parse Git output safely.');
|
|
60
|
+
return result;
|
|
61
|
+
};
|
|
62
|
+
const paths = new Set(records(stagedResult.stdout).map(decode));
|
|
63
|
+
const entries = new Map();
|
|
64
|
+
for (const bytes of records(indexResult.stdout)) {
|
|
65
|
+
const tab = bytes.indexOf(9);
|
|
66
|
+
const record = bytes.subarray(0, tab).toString('ascii');
|
|
67
|
+
if (tab < 0) fail('E_GIT', 'Unable to parse the Git index safely.');
|
|
68
|
+
const match = /^(\d+) ([0-9a-f]+) ([0-3])$/.exec(record);
|
|
69
|
+
if (!match) fail('E_GIT', 'Unable to parse the Git index safely.');
|
|
70
|
+
const rawName = bytes.subarray(tab + 1);
|
|
71
|
+
const name = rawName.toString('utf8');
|
|
72
|
+
if (!paths.has(name)) continue;
|
|
73
|
+
if (!Buffer.from(name, 'utf8').equals(rawName)) fail('E_GIT', 'Unable to safely inspect a non-UTF-8 staged path.');
|
|
74
|
+
if (entries.has(name) || match[3] !== '0') entries.set(name, null);
|
|
75
|
+
else entries.set(name, { mode: match[1], oid: match[2] });
|
|
76
|
+
}
|
|
77
|
+
const divergent = new Set();
|
|
78
|
+
divergent.deleted = new Set();
|
|
79
|
+
for (const name of paths) {
|
|
80
|
+
if (prefix && !name.startsWith(`${prefix}/`)) continue;
|
|
81
|
+
const path = prefix ? name.slice(prefix.length + 1) : name;
|
|
82
|
+
const entry = entries.get(name);
|
|
83
|
+
// Distinguish a staged deletion from unresolved index stages.
|
|
84
|
+
if (!entries.has(name)) { divergent.add(path); divergent.deleted.add(path); continue; }
|
|
85
|
+
if (!entry || !['100644', '100755'].includes(entry.mode) || ![40, 64].includes(entry.oid.length)) { divergent.add(path); continue; }
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
const file = safePath(root, path), info = stat(file);
|
|
89
|
+
if (!info?.isFile()) { divergent.add(path); continue; }
|
|
90
|
+
fd = openSync(file, 'r');
|
|
91
|
+
const before = fstatSync(fd);
|
|
92
|
+
const digest = createHash(entry.oid.length === 64 ? 'sha256' : 'sha1').update(`blob ${before.size}\0`);
|
|
93
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
94
|
+
let count = 0, n;
|
|
95
|
+
while ((n = readSync(fd, buffer, 0, buffer.length, null)) !== 0) { digest.update(buffer.subarray(0, n)); count += n; }
|
|
96
|
+
const after = fstatSync(fd);
|
|
97
|
+
if (count !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { divergent.add(path); continue; }
|
|
98
|
+
if (digest.digest('hex') !== entry.oid) {
|
|
99
|
+
const indexed = run(['cat-file', 'blob', entry.oid]);
|
|
100
|
+
const working = readFileSync(file), latest = fstatSync(fd);
|
|
101
|
+
if (indexed.error || indexed.status !== 0) fail('E_GIT', 'Unable to inspect staged content safely.');
|
|
102
|
+
if (latest.size !== after.size || latest.mtimeMs !== after.mtimeMs || !indexContentMatches(root, path, working, indexed.stdout)) divergent.add(path);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Treat paths that cannot be inspected (including symbolic links) as
|
|
106
|
+
// divergent; the normal safety checks still reject unsafe writes.
|
|
107
|
+
if (error.code === 'E_SYMLINK' || error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'E_PATH') divergent.add(path);
|
|
108
|
+
else throw error;
|
|
109
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
110
|
+
}
|
|
111
|
+
return divergent;
|
|
112
|
+
}
|
|
113
|
+
// A staged name may use another case for the same path on macOS or Windows.
|
|
114
|
+
// Probe an existing directory only; do not create files during a dry run.
|
|
115
|
+
function stagedContains(root, paths, path) {
|
|
116
|
+
if (paths.has(path)) return true;
|
|
117
|
+
let current = root;
|
|
118
|
+
while (!stat(current) && dirname(current) !== current) current = dirname(current);
|
|
119
|
+
while (dirname(current) !== current) {
|
|
120
|
+
const base = current.slice(dirname(current).length + 1);
|
|
121
|
+
const index = base.search(/[A-Za-z]/);
|
|
122
|
+
if (index >= 0) {
|
|
123
|
+
const letter = base[index];
|
|
124
|
+
const other = letter === letter.toUpperCase() ? letter.toLowerCase() : letter.toUpperCase();
|
|
125
|
+
const alternative = join(dirname(current), `${base.slice(0, index)}${other}${base.slice(index + 1)}`);
|
|
126
|
+
const a = stat(current), b = stat(alternative);
|
|
127
|
+
if (!a || !b || a.dev !== b.dev || a.ino !== b.ino) return false;
|
|
128
|
+
return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
|
|
129
|
+
}
|
|
130
|
+
current = dirname(current);
|
|
131
|
+
}
|
|
132
|
+
// If no existing name can be probed, preserve on ambiguity.
|
|
133
|
+
return [...paths].some(candidate => candidate.toLowerCase() === path.toLowerCase());
|
|
134
|
+
}
|
|
18
135
|
const ignored = new Set(['.git', '.local', '.cache', 'node_modules', '.agent-tests', 'opencode-loop']);
|
|
19
136
|
const reservedTargets = new Set(['.git', 'node_modules', '.pbai4devs']);
|
|
20
137
|
const checkTarget = path => {
|
|
@@ -54,11 +171,11 @@ export function safePath(root, path) {
|
|
|
54
171
|
|
|
55
172
|
function resourceAllowed(path, directory = false) {
|
|
56
173
|
const parts = path.split('/');
|
|
57
|
-
const folders = new Set(['agents', 'commands', 'external-skills', 'rules', 'scripts', 'skills', 'templates', 'tools', '
|
|
174
|
+
const folders = new Set(['agents', 'commands', 'external-skills', 'rules', 'scripts', 'skills', 'templates', 'tools', 'state']);
|
|
58
175
|
const files = new Set(['README.md', 'INSTRUCTIONS.md', 'COMPATIBILIDADE.md', 'LICENSE', '.gitignore']);
|
|
59
176
|
if (!folders.has(parts[0]) && !(parts.length === 1 && files.has(parts[0]))) return false;
|
|
60
|
-
if (parts[0] === '
|
|
61
|
-
if (isGeneratedOutput(path, directory) || parts.some(part => ignored.has(part)) || parts[0] === 'adapters' || path === '
|
|
177
|
+
if (parts[0] === 'state') return directory && parts.length === 1 || path === 'state/README.md';
|
|
178
|
+
if (isGeneratedOutput(path, directory) || parts.some(part => ignored.has(part)) || parts[0] === 'adapters' || path === 'state/agentdevkit.json' || path === '.npmignore') return false;
|
|
62
179
|
if (parts[0] === 'tools') return parts.length === 1 || (directory && parts.length === 2 && !['bin', 'npm-cache'].includes(parts[1])) || (parts.length === 2 && parts[1] === 'README.md') || (parts.length === 3 && ['TOOL.json', 'SCRIPT.mjs', 'README.md'].includes(parts[2]));
|
|
63
180
|
if (parts[0] === 'external-skills') return parts.length === 1 || (parts.length === 2 && ['README.md', 'CATALOG.json'].includes(parts[1]));
|
|
64
181
|
return true;
|
|
@@ -71,7 +188,10 @@ function walk(root, path = '', filter = () => true) {
|
|
|
71
188
|
for (const name of readdirSync(directory).sort()) {
|
|
72
189
|
const child = path ? `${path}/${name}` : name;
|
|
73
190
|
const info = lstatSync(join(root, child));
|
|
74
|
-
if (info.isSymbolicLink())
|
|
191
|
+
if (info.isSymbolicLink()) {
|
|
192
|
+
if (!filter(child, false) && !filter(child, true)) continue;
|
|
193
|
+
fail('E_SYMLINK', `Symbolic link refused: ${child}`);
|
|
194
|
+
}
|
|
75
195
|
if (!filter(child, info.isDirectory())) continue;
|
|
76
196
|
if (info.isDirectory()) files.push(...walk(root, child, filter));
|
|
77
197
|
else if (info.isFile()) files.push(child);
|
|
@@ -109,6 +229,7 @@ function read(root, path) {
|
|
|
109
229
|
}
|
|
110
230
|
|
|
111
231
|
function allowedManaged(path) {
|
|
232
|
+
if (['.pbai4devs/estado/README.md', '.pbai4devs/adapters/README.md'].includes(path)) return true;
|
|
112
233
|
if (canonicalEntrypoint(path) || path === '.gitignore') return true;
|
|
113
234
|
if (nativeMarkdown(path) || nativeToml(path)) return true;
|
|
114
235
|
if (['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf'].some(name => path === `.pbai4devs/${name}`)) return true;
|
|
@@ -122,7 +243,7 @@ function nativeMarkdown(path) {
|
|
|
122
243
|
&& adapterRoots.some(root => {
|
|
123
244
|
if (!path.startsWith(`${root}/`)) return false;
|
|
124
245
|
const relative = path.slice(root.length + 1).split('/');
|
|
125
|
-
return !['tools', 'scripts', 'templates', 'external-skills', 'estado', 'tests', 'docs', 'adapters', 'workflows'].includes(relative[0].toLowerCase())
|
|
246
|
+
return !['tools', 'scripts', 'templates', 'external-skills', 'estado', 'state', 'tests', 'docs', 'adapters', 'workflows'].includes(relative[0].toLowerCase())
|
|
126
247
|
&& !['readme.md', 'instructions.md'].includes(relative.at(-1).toLowerCase());
|
|
127
248
|
});
|
|
128
249
|
}
|
|
@@ -130,7 +251,7 @@ const nativeToml = path => path.startsWith('.gemini/commands/') && path.endsWith
|
|
|
130
251
|
const nativeManagedText = path => nativeMarkdown(path) || nativeToml(path);
|
|
131
252
|
|
|
132
253
|
export function loadState(root) {
|
|
133
|
-
const content = read(root,
|
|
254
|
+
const content = read(root, actualStatePath(root)); if (content === null) return null;
|
|
134
255
|
let state; try { state = JSON.parse(content); } catch { fail('E_STATE', 'Invalid AgentDevKit state JSON.'); }
|
|
135
256
|
if (state?.schemaVersion !== 1 || !normalizeLanguage(state.language) || typeof state.version !== 'string' || !state.files || typeof state.files !== 'object' || Array.isArray(state.files) || !state.blocks || typeof state.blocks !== 'object' || Array.isArray(state.blocks)) fail('E_STATE', 'Invalid AgentDevKit state.');
|
|
136
257
|
if (state.clients !== undefined) {
|
|
@@ -144,6 +265,81 @@ export function loadState(root) {
|
|
|
144
265
|
return state;
|
|
145
266
|
}
|
|
146
267
|
|
|
268
|
+
function actualStatePath(root) {
|
|
269
|
+
const legacy = stat(safePath(root, '.pbai4devs/estado'));
|
|
270
|
+
const current = stat(safePath(root, '.pbai4devs/state'));
|
|
271
|
+
if (legacy && !legacy.isDirectory() || current && !current.isDirectory()) fail('E_STATE', 'AgentDevKit state must be a directory.');
|
|
272
|
+
if (legacy && current) fail('E_STATE', 'Both .pbai4devs/estado and .pbai4devs/state exist; preserve both and resolve the conflict.');
|
|
273
|
+
return legacy ? legacyStatePath : statePath;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readLegacyConfiguration(root, path) {
|
|
277
|
+
const limit = 4 * 1024 * 1024, fd = openSync(safePath(root, path), 'r');
|
|
278
|
+
try {
|
|
279
|
+
if (!fstatSync(fd).isFile() || fstatSync(fd).size > limit) fail('E_STATE', 'Legacy configuration is too large or not a regular file.');
|
|
280
|
+
const buffer = Buffer.allocUnsafe(limit + 1);
|
|
281
|
+
let total = 0, count;
|
|
282
|
+
while (total < buffer.length && (count = readSync(fd, buffer, total, buffer.length - total, null)) > 0) total += count;
|
|
283
|
+
if (total > limit) fail('E_STATE', 'Legacy configuration is too large to inspect safely.');
|
|
284
|
+
return buffer.subarray(0, total);
|
|
285
|
+
} finally { closeSync(fd); }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertNoLegacyReferences(value) {
|
|
289
|
+
const legacy = /\.pbai4devs\/estado(?=$|\/|[\s"'\],}:#])/i;
|
|
290
|
+
const reject = () => fail('E_LEGACY_PATH', 'Replace .pbai4devs/estado references with .pbai4devs/state in the legacy configuration before running update.');
|
|
291
|
+
if (legacy.test(value)) reject();
|
|
292
|
+
const normalized = value.replaceAll('\r', '');
|
|
293
|
+
const header = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1];
|
|
294
|
+
if (header === undefined) {
|
|
295
|
+
if (normalized.startsWith('---')) fail('E_CONFIG_PARSE', 'Legacy configuration has an incomplete frontmatter block.');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
let documents = false;
|
|
299
|
+
for (const line of header.split('\n')) {
|
|
300
|
+
const top = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
301
|
+
if (top) documents = top[1] === 'documentos';
|
|
302
|
+
const nested = !top && documents ? line.match(/^ ([a-z_]+):\s*(.*)$/) : null;
|
|
303
|
+
const raw = (top?.[2] ?? nested?.[2])?.trim();
|
|
304
|
+
if (!raw?.startsWith('"')) continue;
|
|
305
|
+
let decoded;
|
|
306
|
+
try { decoded = JSON.parse(raw); }
|
|
307
|
+
catch { fail('E_CONFIG_PARSE', 'Legacy configuration contains an ambiguous quoted scalar.'); }
|
|
308
|
+
if (typeof decoded !== 'string') fail('E_CONFIG_PARSE', 'Legacy configuration contains an invalid quoted scalar.');
|
|
309
|
+
if (legacy.test(decoded)) reject();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function legacyStateMigration(root) {
|
|
314
|
+
if (actualStatePath(root) !== legacyStatePath) return null;
|
|
315
|
+
const base = '.pbai4devs/estado', files = [], directories = [];
|
|
316
|
+
const visit = folder => {
|
|
317
|
+
const info = stat(safePath(root, folder));
|
|
318
|
+
if (!info?.isDirectory()) fail('E_STATE', `Invalid state directory: ${folder}`);
|
|
319
|
+
const names = readdirSync(safePath(root, folder)).sort();
|
|
320
|
+
directories.push({ path: folder, mode: info.mode & 0o777, names });
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
const path = `${folder}/${name}`, item = stat(safePath(root, path));
|
|
323
|
+
if (item?.isDirectory()) visit(path);
|
|
324
|
+
else if (item?.isFile()) {
|
|
325
|
+
const configuration = path === `${base}/configuracao/ESTADO.md`;
|
|
326
|
+
const content = configuration ? readLegacyConfiguration(root, path) : read(root, path);
|
|
327
|
+
if (configuration) {
|
|
328
|
+
if (content.includes(0)) fail('E_STATE', 'Legacy configuration contains binary data.');
|
|
329
|
+
let value;
|
|
330
|
+
try { value = new TextDecoder('utf-8', { fatal: true }).decode(content); }
|
|
331
|
+
catch { fail('E_STATE', 'Legacy configuration is not valid UTF-8.'); }
|
|
332
|
+
assertNoLegacyReferences(value);
|
|
333
|
+
}
|
|
334
|
+
files.push({ path, target: path.replace(`${base}/`, '.pbai4devs/state/'), mode: item.mode & 0o777, content });
|
|
335
|
+
}
|
|
336
|
+
else fail('E_STATE', `Unsupported state entry: ${path}`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
visit(base);
|
|
340
|
+
return { files, directories };
|
|
341
|
+
}
|
|
342
|
+
|
|
147
343
|
function extractBlock(content, first = begin, last = end) {
|
|
148
344
|
const start = content.indexOf(first), finish = content.indexOf(last);
|
|
149
345
|
if (start < 0 && finish < 0) return null;
|
|
@@ -194,7 +390,7 @@ function copyResources(source, destination) {
|
|
|
194
390
|
|
|
195
391
|
function readDerivedState(target) {
|
|
196
392
|
let entries = {}, outputs = {}, blocks = {}, files = {}, removedBlocks = [];
|
|
197
|
-
const derived = read(target,
|
|
393
|
+
const derived = read(target, actualStatePath(target).replace(/agentdevkit\.json$/, 'derived.json'));
|
|
198
394
|
if (derived) {
|
|
199
395
|
try {
|
|
200
396
|
const value = JSON.parse(derived);
|
|
@@ -216,6 +412,10 @@ function readDerivedState(target) {
|
|
|
216
412
|
|
|
217
413
|
export function planInstallation({ target, language = 'pt', mode = 'init', force = false, clients }) {
|
|
218
414
|
const previous = loadState(target);
|
|
415
|
+
const migration = legacyStateMigration(target);
|
|
416
|
+
// A migration must update the installed scripts and pointers in the same
|
|
417
|
+
// transaction; a plain sync/agents/lang would leave old hard-coded paths.
|
|
418
|
+
if (migration && !['init', 'update'].includes(mode)) fail('E_UPGRADE', 'Run AgentDevKit update before migrating the legacy state directory.');
|
|
219
419
|
if (mode !== 'init' && !previous) fail('E_MISSING', text(language, 'missing'));
|
|
220
420
|
const selected = normalizeClients(clients ?? previous?.clients ?? ['all']);
|
|
221
421
|
const active = resolveAdapters(selected);
|
|
@@ -236,6 +436,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
236
436
|
?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
|
|
237
437
|
const preserved = new Map();
|
|
238
438
|
const recordPreserved = (path, reason) => { preserved.set(path, { path, reason }); return true; };
|
|
439
|
+
const stagedHuman = stagedDivergences(target);
|
|
239
440
|
const native = path => canonicalEntrypoint(path) || path === 'README.md' || adapterRoots.some(root => path === root || path.startsWith(`${root}/`));
|
|
240
441
|
const boundary = path => {
|
|
241
442
|
if (!native(path)) return false;
|
|
@@ -272,9 +473,28 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
272
473
|
bundled.push('LICENSE');
|
|
273
474
|
}
|
|
274
475
|
const supplied = new Set(bundled);
|
|
275
|
-
const internal = new Set(['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf', '.gitignore', 'estado/README.md']);
|
|
276
|
-
|
|
277
|
-
|
|
476
|
+
const internal = new Set(['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf', '.gitignore', 'state/README.md', 'estado/README.md']);
|
|
477
|
+
// A pathname, matching contents, or --force alone do not establish that a
|
|
478
|
+
// project's hub source belongs to the kit. Restore human files *before*
|
|
479
|
+
// deriving native pointers so that the staged catalogue describes the
|
|
480
|
+
// source that will actually remain in the project.
|
|
481
|
+
const preservedHub = new Set();
|
|
482
|
+
for (const relative of existing) {
|
|
483
|
+
const path = `.pbai4devs/${relative}`;
|
|
484
|
+
if (internal.has(relative)) continue;
|
|
485
|
+
const current = read(target, path);
|
|
486
|
+
if (!current || previous?.files?.[path] === hash(current) && !stagedContains(target, stagedHuman, path)) continue;
|
|
487
|
+
if (supplied.has(relative)) write(stage, path, current);
|
|
488
|
+
preservedHub.add(path);
|
|
489
|
+
recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : previous?.files?.[path] ? 'edited-managed-file' : 'existing-file');
|
|
490
|
+
}
|
|
491
|
+
if (bundled.length) for (const [path, digest] of Object.entries(previous?.files || {})) {
|
|
492
|
+
if (!path.startsWith('.pbai4devs/') || supplied.has(path.slice(11)) || internal.has(path.slice(11))) continue;
|
|
493
|
+
const current = read(target, path);
|
|
494
|
+
if (current && (digest !== hash(current) || stagedContains(target, stagedHuman, path))) {
|
|
495
|
+
preservedHub.add(path);
|
|
496
|
+
recordPreserved(path, stagedContains(target, stagedHuman, path) ? 'staged-content-differs' : 'edited-managed-file');
|
|
497
|
+
} else rmSync(safePath(stage, path), { force: true });
|
|
278
498
|
}
|
|
279
499
|
for (const root of active.roots) {
|
|
280
500
|
if (preserveNative(`${root}/.gitignore`)) continue;
|
|
@@ -302,10 +522,32 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
302
522
|
}
|
|
303
523
|
}
|
|
304
524
|
if (!preserveNative('README.md') && !read(target, 'README.md')) write(stage, 'README.md', `---\ndescription: ${JSON.stringify(text(language, 'readme'))}\nlanguage: ${language}\n---\n\n# ${text(language, 'project')}\n\n${text(language, 'readme')}\n`);
|
|
305
|
-
if (!existsSync(join(stage, '.pbai4devs/
|
|
525
|
+
if (!existsSync(join(stage, '.pbai4devs/state/README.md'))) write(stage, '.pbai4devs/state/README.md', `---\ndescription: AgentDevKit state.\n---\n\n# AgentDevKit\n\n${text(language, 'state')}\n`);
|
|
306
526
|
const unknown = new Set(existing.filter(path => !supplied.has(path) && !previous?.files[`.pbai4devs/${path}`] && !internal.has(path)));
|
|
307
527
|
const paths = walk(stage, '', path => !path.split('/').includes('.agent-tests')).filter(path => !path.startsWith('.pbai4devs/') || !unknown.has(path.slice(11)));
|
|
308
528
|
const desired = new Map(paths.filter(path => !preserveNative(path)).map(path => [path, readFileSync(join(stage, path))]));
|
|
529
|
+
const migratedHuman = new Set();
|
|
530
|
+
for (const item of migration?.files || []) {
|
|
531
|
+
if (item.path === legacyStatePath) continue;
|
|
532
|
+
if (item.path === '.pbai4devs/estado/README.md') {
|
|
533
|
+
const managed = previous?.files?.[item.path] === hash(item.content);
|
|
534
|
+
const matchesTemplate = desired.get(item.target)?.equals(item.content);
|
|
535
|
+
desired.set(item.target, item.content);
|
|
536
|
+
if (!managed || !matchesTemplate) { recordPreserved(item.target, 'existing-file'); migratedHuman.add(item.target); }
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (desired.has(item.target)) fail('E_STATE', `State migration collides with a managed file: ${item.target}`);
|
|
540
|
+
desired.set(item.target, item.content);
|
|
541
|
+
migratedHuman.add(item.target);
|
|
542
|
+
}
|
|
543
|
+
// Preserve human README content; verified managed files may be updated.
|
|
544
|
+
const stateReadme = '.pbai4devs/state/README.md';
|
|
545
|
+
const currentStateReadme = read(target, stateReadme);
|
|
546
|
+
if (currentStateReadme && previous?.files?.[stateReadme] !== hash(currentStateReadme)) {
|
|
547
|
+
desired.set(stateReadme, currentStateReadme);
|
|
548
|
+
migratedHuman.add(stateReadme);
|
|
549
|
+
recordPreserved(stateReadme, 'existing-file');
|
|
550
|
+
}
|
|
309
551
|
for (const [path, content] of desired) {
|
|
310
552
|
if (!nativeManagedText(path) || canonicalEntrypoint(path)) continue;
|
|
311
553
|
const current = read(target, path);
|
|
@@ -330,7 +572,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
330
572
|
const manifest = { schemaVersion: 1, version: bundled.length ? packageInfo.version : previous.version, language, files: {}, blocks: {} };
|
|
331
573
|
if (mode !== 'sync' || clients !== undefined || previous?.clients !== undefined) manifest.clients = selected;
|
|
332
574
|
for (const [path, content] of desired) {
|
|
333
|
-
if (path === 'README.md') continue;
|
|
575
|
+
if (path === 'README.md' || migratedHuman.has(path) || preservedHub.has(path)) continue;
|
|
334
576
|
const block = blockDigest(path, content);
|
|
335
577
|
if (block) manifest.blocks[path] = block;
|
|
336
578
|
else manifest.files[path] = hash(content);
|
|
@@ -348,19 +590,22 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
348
590
|
const conflict = current && path !== statePath && !managed && !appendBlock;
|
|
349
591
|
const changedBlock = current && knownBlocks[path] && !managed;
|
|
350
592
|
if (conflict || changedBlock) conflicts.push(path);
|
|
351
|
-
|
|
593
|
+
const migrated = migration?.files.find(item => item.target === path);
|
|
594
|
+
changes.push({ path, action: current ? 'update' : 'create', content, beforeHash: current ? hash(current) : null, ...(migrated ? { mode: migrated.mode } : {}) });
|
|
352
595
|
}
|
|
353
596
|
const wholeRecords = [previous?.files || {}, derivedEntries, derivedFiles];
|
|
354
597
|
for (const path of new Set(wholeRecords.flatMap(record => Object.keys(record)))) if (!desired.has(path)) {
|
|
598
|
+
if (migration && path.startsWith('.pbai4devs/estado/')) continue;
|
|
355
599
|
if (path === '.gitignore') { recordPreserved(path, 'project-owned'); continue; }
|
|
356
600
|
if (aliasedEntrypoint(path)) continue;
|
|
357
601
|
const current = read(target, path); if (!current) continue;
|
|
358
602
|
const known = wholeRecords.some(record => record[path] === hash(current));
|
|
359
|
-
if (
|
|
360
|
-
if (!known)
|
|
603
|
+
if (stagedContains(target, stagedHuman, path)) { recordPreserved(path, 'staged-content-differs'); continue; }
|
|
604
|
+
if (!known) { recordPreserved(path, 'edited-managed-file'); continue; }
|
|
361
605
|
changes.push({ path, action: 'remove', content: null, beforeHash: hash(current) });
|
|
362
606
|
}
|
|
363
607
|
for (const [recordedPath, digest] of Object.entries(knownBlocks)) {
|
|
608
|
+
if (migration && recordedPath.startsWith('.pbai4devs/estado/')) continue;
|
|
364
609
|
const path = canonicalEntrypoint(recordedPath) ? entryPaths.get(canonicalEntrypoint(recordedPath)) : recordedPath;
|
|
365
610
|
if (desired.has(path)) continue;
|
|
366
611
|
if (changes.some(change => change.path === path)) continue;
|
|
@@ -404,12 +649,53 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
404
649
|
if (preserved.has(full) || !known || !generatedReference(current)) { recordPreserved(full, 'existing-file'); continue; }
|
|
405
650
|
changes.push({ path: full, action: 'remove', content: null, beforeHash: hash(current) });
|
|
406
651
|
}
|
|
407
|
-
const legacy = '.pbai4devs/adapters/README.md',
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
652
|
+
const legacy = '.pbai4devs/adapters/README.md', legacyRoot = '.pbai4devs/adapters';
|
|
653
|
+
const legacyRootInfo = stat(join(target, legacyRoot));
|
|
654
|
+
const legacyInfo = legacyRootInfo?.isDirectory() ? stat(join(target, legacy)) : null;
|
|
655
|
+
if (legacyRootInfo && !legacyRootInfo.isDirectory()) recordPreserved(legacyRoot, legacyRootInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-file');
|
|
656
|
+
if (legacyInfo && !legacyInfo.isFile()) recordPreserved(legacy, legacyInfo.isSymbolicLink() ? 'symbolic-link' : 'existing-directory');
|
|
657
|
+
const legacyContent = legacyInfo?.isFile() ? read(target, legacy) : null;
|
|
658
|
+
if (legacyContent && !changes.some(change => change.path === legacy)) {
|
|
659
|
+
if (!stagedContains(target, stagedHuman, legacy) && previous?.files?.[legacy] === hash(legacyContent)) changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
|
|
660
|
+
else recordPreserved(legacy, stagedContains(target, stagedHuman, legacy) ? 'staged-content-differs' : 'existing-file');
|
|
412
661
|
}
|
|
662
|
+
for (const item of migration?.files || []) changes.push({ path: item.path, action: 'remove', content: null, beforeHash: hash(item.content) });
|
|
663
|
+
// The generated catalogues must agree with the installed sources. A staged
|
|
664
|
+
// deletion has no indexed bytes to overwrite; allow their regeneration only
|
|
665
|
+
// when the current file exactly matches the previously owned digest.
|
|
666
|
+
const generatedCatalogues = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json']);
|
|
667
|
+
for (const change of changes) if (change.action === 'update' && generatedCatalogues.has(change.path)
|
|
668
|
+
&& stagedContains(target, stagedHuman.deleted || new Set(), change.path)
|
|
669
|
+
&& change.beforeHash && previous?.files?.[change.path] === change.beforeHash) change.stagedDeletionAllowed = true;
|
|
670
|
+
for (const change of changes) {
|
|
671
|
+
if (!stagedContains(target, stagedHuman, change.path)) continue;
|
|
672
|
+
const current = read(target, change.path);
|
|
673
|
+
if (change.action === 'update' && current && change.content && nativeManagedText(change.path)) {
|
|
674
|
+
// Preserve every byte outside a known managed block. Appending a block
|
|
675
|
+
// is also safe when the complete existing file is an exact prefix.
|
|
676
|
+
if (!nativeToml(change.path) && change.content.subarray(0, current.length).equals(current)) {
|
|
677
|
+
try {
|
|
678
|
+
const digest = managedBlockDigest(change.content);
|
|
679
|
+
const stripped = digest && stripManagedContent(change.content, digest);
|
|
680
|
+
const extra = stripped?.slice(current.toString('utf8').length);
|
|
681
|
+
if (stripped?.startsWith(current.toString('utf8')) && extra !== undefined && /^[\r\n]*$/.test(extra)) change.stagedBlockAllowed = true;
|
|
682
|
+
} catch { /* Invalid or unproven block: keep the path blocked. */ }
|
|
683
|
+
} else if (knownBlocks[change.path]) {
|
|
684
|
+
try {
|
|
685
|
+
const strip = nativeToml(change.path) ? stripTomlContent : stripManagedContent;
|
|
686
|
+
if (Buffer.from(strip(current, knownBlocks[change.path])).equals(change.content)) change.stagedBlockAllowed = true;
|
|
687
|
+
} catch { /* An edited block is not safe to remove. */ }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (change.action === 'remove' && migration) {
|
|
691
|
+
const item = migration.files.find(file => file.path === change.path);
|
|
692
|
+
const copy = item && changes.find(candidate => candidate.path === item.target && candidate.action === 'create');
|
|
693
|
+
if (item && current?.equals(item.content) && copy?.content?.equals(item.content)
|
|
694
|
+
&& changes.indexOf(copy) < changes.indexOf(change)) change.stagedMigrationTarget = item.target;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const stagedChanges = changes.filter(change => stagedContains(target, stagedHuman, change.path) && !change.stagedDeletionAllowed && !change.stagedBlockAllowed && !change.stagedMigrationTarget);
|
|
698
|
+
if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
|
|
413
699
|
const retiredRoots = adapterRoots.filter(root => !active.roots.includes(root) && changes.some(change => change.action === 'remove' && change.path.startsWith(`${root}/`) && (previous?.files[change.path] || previous?.blocks[change.path])));
|
|
414
700
|
const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !mixedPaths.has(path) && !overlapsPreserved(path));
|
|
415
701
|
const hashes = {};
|
|
@@ -419,12 +705,13 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
|
|
|
419
705
|
const current = read(target, path);
|
|
420
706
|
if (current) { const source = current.toString('utf8'), block = extractBlock(source, '# BEGIN AGENT IGNORE', '# END AGENT IGNORE'); if (block && source.trim() === block.trim() && hash(block) === previous.blocks[path]) hashes[path] = hash(current); }
|
|
421
707
|
}
|
|
422
|
-
if (previous) hashes[statePath] = hash(read(target, statePath));
|
|
708
|
+
if (previous && !migration) hashes[statePath] = hash(read(target, statePath));
|
|
423
709
|
const untrack = existsSync(target) ? planUntrack(target, hashes) : null;
|
|
710
|
+
if (migration && untrack?.paths.length) fail('E_STATE', 'State migration would change the Git index; preserve and resolve tracked outputs first.');
|
|
424
711
|
const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
|
|
425
712
|
const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
|
|
426
713
|
conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
|
|
427
|
-
return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: (!conflicts.length || force) && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
|
|
714
|
+
return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: (!conflicts.length || force) && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, migration, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
|
|
428
715
|
} finally { rmSync(stage, { recursive: true, force: true }); }
|
|
429
716
|
}
|
|
430
717
|
|
|
@@ -432,6 +719,35 @@ export function applyPlan(plan) {
|
|
|
432
719
|
if (!plan.canApply) fail('E_CONFLICT', text(plan.language, 'conflict'));
|
|
433
720
|
assertLocalIgnoreUnchanged(plan.localIgnore);
|
|
434
721
|
const { target } = plan;
|
|
722
|
+
const stagedHuman = stagedDivergences(target);
|
|
723
|
+
const currentState = loadState(target);
|
|
724
|
+
const stagedChanges = plan.changes.filter(change => {
|
|
725
|
+
if (!stagedContains(target, stagedHuman, change.path)) return false;
|
|
726
|
+
const current = read(target, change.path);
|
|
727
|
+
if ((current ? hash(current) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
|
|
728
|
+
if (!current) return true;
|
|
729
|
+
if (change.stagedDeletionAllowed && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
|
|
730
|
+
&& currentState?.files?.[change.path] === change.beforeHash) return false;
|
|
731
|
+
if (change.stagedBlockAllowed) return false; // Exact source and planned bytes were checked above.
|
|
732
|
+
if (change.stagedMigrationTarget) {
|
|
733
|
+
const copy = plan.changes.find(candidate => candidate.path === change.stagedMigrationTarget && candidate.action === 'create');
|
|
734
|
+
if (copy?.content?.equals(current) && plan.changes.indexOf(copy) < plan.changes.indexOf(change)
|
|
735
|
+
&& !stat(safePath(target, change.stagedMigrationTarget))) return false;
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
});
|
|
739
|
+
if (stagedChanges.length) fail('E_STAGED', `Staged content differs from the working tree for planned changes: ${stagedChanges.map(change => `${change.action} ${change.path}`).join(', ')}. Preserve and review these paths before updating.`);
|
|
740
|
+
if (plan.migration) {
|
|
741
|
+
if (stat(safePath(target, '.pbai4devs/state'))) fail('E_CHANGED', 'State destination appeared after planning.');
|
|
742
|
+
for (const directory of plan.migration.directories) {
|
|
743
|
+
const info = stat(safePath(target, directory.path));
|
|
744
|
+
if (!info?.isDirectory() || (info.mode & 0o777) !== directory.mode || JSON.stringify(readdirSync(safePath(target, directory.path)).sort()) !== JSON.stringify(directory.names)) fail('E_CHANGED', `State directory changed after planning: ${directory.path}`);
|
|
745
|
+
}
|
|
746
|
+
for (const item of plan.migration.files) {
|
|
747
|
+
const info = stat(safePath(target, item.path));
|
|
748
|
+
if (!info?.isFile() || (info.mode & 0o777) !== item.mode || hash(readFileSync(safePath(target, item.path))) !== hash(item.content)) fail('E_CHANGED', `State file changed after planning: ${item.path}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
435
751
|
const backup = `.agent-tests/agentdevkit/backups/${Date.now()}-${randomUUID()}`;
|
|
436
752
|
safePath(target, backup);
|
|
437
753
|
for (const change of plan.changes) safePath(target, change.path);
|
|
@@ -456,11 +772,36 @@ export function applyPlan(plan) {
|
|
|
456
772
|
const file = safePath(target, change.path);
|
|
457
773
|
const content = change.content;
|
|
458
774
|
const signature = { path: change.path, hash: content === null ? null : hash(content) };
|
|
459
|
-
if (content === null) {
|
|
460
|
-
|
|
775
|
+
if (content === null) {
|
|
776
|
+
const migrated = plan.migration?.files.find(item => item.path === change.path);
|
|
777
|
+
if (migrated) {
|
|
778
|
+
const copy = plan.changes.find(candidate => candidate.path === migrated.target && candidate.action === 'create');
|
|
779
|
+
const copied = read(target, migrated.target);
|
|
780
|
+
if (!copy?.content || !copied?.equals(copy.content)
|
|
781
|
+
|| migrated.path !== legacyStatePath && !copied.equals(before.get(change.path))) {
|
|
782
|
+
fail('E_CHANGED', `Migrated copy changed before removing: ${change.path}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
rmSync(file, { force: true }); applied.push(signature);
|
|
786
|
+
}
|
|
787
|
+
else atomicWrite(target, change.path, content, beforeModes.get(change.path) ?? change.mode, mode => {
|
|
461
788
|
signature.mode = mode; applied.push(signature);
|
|
462
789
|
}, () => assertOriginal(change.path));
|
|
463
790
|
}
|
|
791
|
+
if (plan.migration) {
|
|
792
|
+
for (const directory of plan.migration.directories) {
|
|
793
|
+
const destination = directory.path.replace(/^\.pbai4devs\/estado/, '.pbai4devs/state');
|
|
794
|
+
const file = safePath(target, destination), existing = stat(file);
|
|
795
|
+
if (existing && !existing.isDirectory()) fail('E_CHANGED', `Invalid state destination: ${destination}`);
|
|
796
|
+
if (!existing) mkdirSync(file, { recursive: true, mode: directory.mode });
|
|
797
|
+
chmodSync(file, directory.mode);
|
|
798
|
+
}
|
|
799
|
+
for (const directory of [...plan.migration.directories].sort((a, b) => b.path.length - a.path.length)) {
|
|
800
|
+
const file = safePath(target, directory.path), info = stat(file);
|
|
801
|
+
if (!info?.isDirectory() || readdirSync(file).length) fail('E_CHANGED', `State directory changed during migration: ${directory.path}`);
|
|
802
|
+
rmdirSync(file); removedDirectories.push({ path: directory.path, mode: directory.mode });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
464
805
|
pruneRemovedDirectories(plan, directory => removedDirectories.push(directory));
|
|
465
806
|
const changesFallback = plan.changes.some(change => change.path === '.gitignore');
|
|
466
807
|
// Metadata is committed last; its writer rolls back partial writes itself.
|
|
@@ -498,6 +839,11 @@ export function applyPlan(plan) {
|
|
|
498
839
|
else rollbackErrors.push(failure);
|
|
499
840
|
}
|
|
500
841
|
}
|
|
842
|
+
if (plan.migration) for (const directory of [...plan.migration.directories].sort((a, b) => b.path.length - a.path.length)) {
|
|
843
|
+
const destination = directory.path.replace(/^\.pbai4devs\/estado/, '.pbai4devs/state');
|
|
844
|
+
try { const file = safePath(target, destination); if (stat(file)?.isDirectory() && !readdirSync(file).length) rmdirSync(file); }
|
|
845
|
+
catch (failure) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(failure.code)) rollbackErrors.push(failure); }
|
|
846
|
+
}
|
|
501
847
|
if (preserved.length) {
|
|
502
848
|
try { error.rollbackPreservedPaths = preserved; } catch { /* Preserve a exceção original. */ }
|
|
503
849
|
}
|
|
File without changes
|