@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.
Files changed (44) hide show
  1. package/.pbai4devs/INSTRUCTIONS.md +1 -1
  2. package/.pbai4devs/README.md +1 -1
  3. package/.pbai4devs/commands/sincronizar/COMMAND.md +6 -4
  4. package/.pbai4devs/scripts/inspecionar-projeto/SCRIPT.mjs +3 -2
  5. package/.pbai4devs/scripts/inspecionar-projeto/tests/TEST.mjs +18 -4
  6. package/.pbai4devs/scripts/lib/README.md +3 -0
  7. package/.pbai4devs/scripts/lib/gitignore.mjs +46 -9
  8. package/.pbai4devs/scripts/lib/state-path.mjs +20 -0
  9. package/.pbai4devs/scripts/sincronizar-agentes/README.md +8 -1
  10. package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +75 -34
  11. package/.pbai4devs/scripts/sincronizar-agentes/tests/COEXISTENCE.mjs +5 -5
  12. package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +1 -1
  13. package/.pbai4devs/scripts/sincronizar-agentes/tests/MANAGED_CONTENT.mjs +9 -9
  14. package/.pbai4devs/scripts/sincronizar-agentes/tests/PRESERVATION.mjs +306 -0
  15. package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +41 -8
  16. package/.pbai4devs/scripts/testar-maturidade/SCRIPT.mjs +6 -4
  17. package/.pbai4devs/scripts/testar-maturidade/tests/TEST.mjs +4 -3
  18. package/.pbai4devs/scripts/verificar-recursos/SCRIPT.mjs +5 -3
  19. package/.pbai4devs/scripts/verificar-recursos/tests/TEST.mjs +15 -7
  20. package/.pbai4devs/skills/produto/criar-design-doc/SKILL.md +1 -1
  21. package/.pbai4devs/skills/produto/criar-prd/SKILL.md +1 -1
  22. package/.pbai4devs/skills/produto/criar-ux-ui/SKILL.md +1 -1
  23. package/.pbai4devs/skills/projeto/configurar-projeto/SKILL.md +1 -1
  24. package/.pbai4devs/skills/projeto/proximos-passos/SKILL.md +1 -1
  25. package/.pbai4devs/skills/projeto/sincronizar-agentes/SKILL.md +1 -1
  26. package/.pbai4devs/skills/specs/criar-spec/SKILL.md +1 -1
  27. package/.pbai4devs/skills/specs/implementar-spec/SKILL.md +1 -1
  28. package/.pbai4devs/templates/entrypoints/AGENTS.md +1 -1
  29. package/.pbai4devs/templates/gitignore/local.ignore +2 -0
  30. package/.pbai4devs/templates/gitignore/shared.ignore +2 -0
  31. package/CHANGELOG.md +11 -0
  32. package/docs/guides/cli.md +46 -15
  33. package/docs/guides/installation.md +20 -8
  34. package/docs/guides/troubleshooting.md +9 -1
  35. package/docs/guides/workflow.md +1 -1
  36. package/docs/specs/README.md +2 -0
  37. package/docs/specs/safe-synchronization/SPEC.md +21 -0
  38. package/docs/specs/state-directory/SPEC.md +22 -0
  39. package/package.json +1 -1
  40. package/src/cli.mjs +2 -2
  41. package/src/config.mjs +2 -1
  42. package/src/i18n.mjs +25 -0
  43. package/src/install.mjs +373 -27
  44. /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 { chmodSync, existsSync, lstatSync, readFileSync, writeFileSync, readdirSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, rmdirSync } from 'node:fs';
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', 'estado']);
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] === 'estado') return directory && parts.length === 1 || path === 'estado/README.md';
61
- if (isGeneratedOutput(path, directory) || parts.some(part => ignored.has(part)) || parts[0] === 'adapters' || path === 'estado/agentdevkit.json' || path === '.npmignore') return false;
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()) fail('E_SYMLINK', `Symbolic link refused: ${child}`);
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, statePath); if (content === null) return null;
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, '.pbai4devs/estado/derived.json');
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
- if (bundled.length) for (const path of Object.keys(previous?.files || {})) {
277
- if (path.startsWith('.pbai4devs/') && !supplied.has(path.slice(11)) && !internal.has(path.slice(11))) rmSync(safePath(stage, path), { force: true });
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/estado/README.md'))) write(stage, '.pbai4devs/estado/README.md', `---\ndescription: AgentDevKit state.\n---\n\n# AgentDevKit\n\n${text(language, 'state')}\n`);
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
- changes.push({ path, action: current ? 'update' : 'create', content, beforeHash: current ? hash(current) : null });
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 (nativeManagedText(path) && !known) { recordPreserved(path, 'existing-file'); continue; }
360
- if (!known) conflicts.push(path);
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', legacyContent = read(target, legacy);
408
- if (legacyContent) {
409
- const known = legacyContent.toString('utf8').includes('# Adaptadores') && /Antigravity|metadados/.test(legacyContent.toString('utf8'));
410
- if (!known) conflicts.push(legacy);
411
- changes.push({ path: legacy, action: 'remove', content: null, beforeHash: hash(legacyContent) });
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) { rmSync(file, { force: true }); applied.push(signature); }
460
- else atomicWrite(target, change.path, content, beforeModes.get(change.path), mode => {
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