@pathbit/agentdevkit 0.0.17 → 0.0.19

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/src/install.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
2
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, 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';
@@ -15,6 +16,122 @@ 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 => {
@@ -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,7 +229,7 @@ function read(root, path) {
109
229
  }
110
230
 
111
231
  function allowedManaged(path) {
112
- if (path === '.pbai4devs/estado/README.md') return true;
232
+ if (['.pbai4devs/estado/README.md', '.pbai4devs/adapters/README.md'].includes(path)) return true;
113
233
  if (canonicalEntrypoint(path) || path === '.gitignore') return true;
114
234
  if (nativeMarkdown(path) || nativeToml(path)) return true;
115
235
  if (['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf'].some(name => path === `.pbai4devs/${name}`)) return true;
@@ -316,6 +436,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
316
436
  ?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
317
437
  const preserved = new Map();
318
438
  const recordPreserved = (path, reason) => { preserved.set(path, { path, reason }); return true; };
439
+ const stagedHuman = stagedDivergences(target);
319
440
  const native = path => canonicalEntrypoint(path) || path === 'README.md' || adapterRoots.some(root => path === root || path.startsWith(`${root}/`));
320
441
  const boundary = path => {
321
442
  if (!native(path)) return false;
@@ -353,8 +474,27 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
353
474
  }
354
475
  const supplied = new Set(bundled);
355
476
  const internal = new Set(['catalog.json', 'skills.json', 'agents.json', 'rules.json', 'espelhos.conf', '.gitignore', 'state/README.md', 'estado/README.md']);
356
- if (bundled.length) for (const path of Object.keys(previous?.files || {})) {
357
- if (path.startsWith('.pbai4devs/') && !supplied.has(path.slice(11)) && !internal.has(path.slice(11))) rmSync(safePath(stage, path), { force: true });
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 });
358
498
  }
359
499
  for (const root of active.roots) {
360
500
  if (preserveNative(`${root}/.gitignore`)) continue;
@@ -432,7 +572,7 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
432
572
  const manifest = { schemaVersion: 1, version: bundled.length ? packageInfo.version : previous.version, language, files: {}, blocks: {} };
433
573
  if (mode !== 'sync' || clients !== undefined || previous?.clients !== undefined) manifest.clients = selected;
434
574
  for (const [path, content] of desired) {
435
- if (path === 'README.md' || migratedHuman.has(path)) continue;
575
+ if (path === 'README.md' || migratedHuman.has(path) || preservedHub.has(path)) continue;
436
576
  const block = blockDigest(path, content);
437
577
  if (block) manifest.blocks[path] = block;
438
578
  else manifest.files[path] = hash(content);
@@ -460,8 +600,8 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
460
600
  if (aliasedEntrypoint(path)) continue;
461
601
  const current = read(target, path); if (!current) continue;
462
602
  const known = wholeRecords.some(record => record[path] === hash(current));
463
- if (nativeManagedText(path) && !known) { recordPreserved(path, 'existing-file'); continue; }
464
- 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; }
465
605
  changes.push({ path, action: 'remove', content: null, beforeHash: hash(current) });
466
606
  }
467
607
  for (const [recordedPath, digest] of Object.entries(knownBlocks)) {
@@ -509,25 +649,58 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
509
649
  if (preserved.has(full) || !known || !generatedReference(current)) { recordPreserved(full, 'existing-file'); continue; }
510
650
  changes.push({ path: full, action: 'remove', content: null, beforeHash: hash(current) });
511
651
  }
512
- const legacy = '.pbai4devs/adapters/README.md', legacyContent = read(target, legacy);
513
- if (legacyContent) {
514
- const known = legacyContent.toString('utf8').includes('# Adaptadores') && /Antigravity|metadados/.test(legacyContent.toString('utf8'));
515
- if (!known) conflicts.push(legacy);
516
- 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');
517
661
  }
518
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.`);
519
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])));
520
700
  const ownedOutputs = [...new Set([...synchronized.ownedOutputs, ...ownedEntrypoints, statePath])].filter(path => !mixedPaths.has(path) && !overlapsPreserved(path));
521
- const hashes = {};
522
- for (const [path, digest] of Object.entries(previous?.files || {})) if (path !== '.gitignore' && (ownedOutputs.includes(path) || !path.startsWith('.pbai4devs/')) && !mixedPaths.has(path) && !overlapsPreserved(path) && !aliasedEntrypoint(path)) hashes[path] = digest;
523
- for (const path of ownedEntrypoints) { const current = read(target, path); if (current && (previous?.blocks[path] || derivedEntries[path] === hash(current))) hashes[path] = hash(current); }
524
- for (const path of Object.keys(previous?.blocks || {})) if (path.endsWith('/.gitignore') && adapterRoots.some(root => path === `${root}/.gitignore`)) {
525
- const current = read(target, path);
526
- 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); }
527
- }
528
- if (previous && !migration) hashes[statePath] = hash(read(target, statePath));
529
- const untrack = existsSync(target) ? planUntrack(target, hashes) : null;
530
- if (migration && untrack?.paths.length) fail('E_STATE', 'State migration would change the Git index; preserve and resolve tracked outputs first.');
701
+ // Ignore rules affect only untracked files. Never remove existing index
702
+ // entries on behalf of the project; staged bytes are checked separately.
703
+ const untrack = { root: target, paths: [], hashes: {}, index: '', conflicts: [] };
531
704
  const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
532
705
  const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
533
706
  conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
@@ -539,6 +712,24 @@ export function applyPlan(plan) {
539
712
  if (!plan.canApply) fail('E_CONFLICT', text(plan.language, 'conflict'));
540
713
  assertLocalIgnoreUnchanged(plan.localIgnore);
541
714
  const { target } = plan;
715
+ const stagedHuman = stagedDivergences(target);
716
+ const currentState = loadState(target);
717
+ const stagedChanges = plan.changes.filter(change => {
718
+ if (!stagedContains(target, stagedHuman, change.path)) return false;
719
+ const current = read(target, change.path);
720
+ if ((current ? hash(current) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
721
+ if (!current) return true;
722
+ if (change.stagedDeletionAllowed && stagedContains(target, stagedHuman.deleted || new Set(), change.path)
723
+ && currentState?.files?.[change.path] === change.beforeHash) return false;
724
+ if (change.stagedBlockAllowed) return false; // Exact source and planned bytes were checked above.
725
+ if (change.stagedMigrationTarget) {
726
+ const copy = plan.changes.find(candidate => candidate.path === change.stagedMigrationTarget && candidate.action === 'create');
727
+ if (copy?.content?.equals(current) && plan.changes.indexOf(copy) < plan.changes.indexOf(change)
728
+ && !stat(safePath(target, change.stagedMigrationTarget))) return false;
729
+ }
730
+ return true;
731
+ });
732
+ 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.`);
542
733
  if (plan.migration) {
543
734
  if (stat(safePath(target, '.pbai4devs/state'))) fail('E_CHANGED', 'State destination appeared after planning.');
544
735
  for (const directory of plan.migration.directories) {
@@ -561,20 +752,29 @@ export function applyPlan(plan) {
561
752
  for (const change of plan.changes) if ((before.get(change.path) ? hash(before.get(change.path)) : null) !== change.beforeHash) fail('E_CHANGED', `File changed after planning: ${change.path}`);
562
753
  for (const [path, content] of before) if (content) write(target, `${backup}/${path}`, content, beforeModes.get(path));
563
754
  const applied = [], removedDirectories = [];
564
- let untracked = false;
565
755
  const assertOriginal = path => {
566
756
  const file = safePath(target, path), info = stat(file), bytes = before.get(path);
567
757
  if (bytes === null ? info !== null : !info?.isFile() || !readFileSync(file).equals(bytes) || (info.mode & 0o777) !== beforeModes.get(path)) fail('E_CHANGED', `File changed before writing: ${path}`);
568
758
  };
569
759
  try {
570
760
  for (const change of plan.changes) assertOriginal(change.path);
571
- if (plan.untrack?.paths.length) { applyUntrack(plan.untrack); untracked = true; }
572
761
  for (const change of plan.changes) {
573
762
  assertOriginal(change.path);
574
763
  const file = safePath(target, change.path);
575
764
  const content = change.content;
576
765
  const signature = { path: change.path, hash: content === null ? null : hash(content) };
577
- if (content === null) { rmSync(file, { force: true }); applied.push(signature); }
766
+ if (content === null) {
767
+ const migrated = plan.migration?.files.find(item => item.path === change.path);
768
+ if (migrated) {
769
+ const copy = plan.changes.find(candidate => candidate.path === migrated.target && candidate.action === 'create');
770
+ const copied = read(target, migrated.target);
771
+ if (!copy?.content || !copied?.equals(copy.content)
772
+ || migrated.path !== legacyStatePath && !copied.equals(before.get(change.path))) {
773
+ fail('E_CHANGED', `Migrated copy changed before removing: ${change.path}`);
774
+ }
775
+ }
776
+ rmSync(file, { force: true }); applied.push(signature);
777
+ }
578
778
  else atomicWrite(target, change.path, content, beforeModes.get(change.path) ?? change.mode, mode => {
579
779
  signature.mode = mode; applied.push(signature);
580
780
  }, () => assertOriginal(change.path));
@@ -597,7 +797,7 @@ export function applyPlan(plan) {
597
797
  const changesFallback = plan.changes.some(change => change.path === '.gitignore');
598
798
  // Metadata is committed last; its writer rolls back partial writes itself.
599
799
  const ignored = applyLocalIgnore(changesFallback ? planLocalIgnore(target, { outputs: plan.ownedOutputs }) : plan.localIgnore);
600
- return { changed: Boolean(plan.changes.length || ignored.changed || untracked), backup: [...before.values()].some(Boolean) ? backup : null };
800
+ return { changed: Boolean(plan.changes.length || ignored.changed), backup: [...before.values()].some(Boolean) ? backup : null };
601
801
  } catch (error) {
602
802
  const rollbackErrors = [], preserved = [];
603
803
  for (const removed of [...removedDirectories].sort((a, b) => a.path.length - b.path.length)) {
@@ -638,7 +838,6 @@ export function applyPlan(plan) {
638
838
  if (preserved.length) {
639
839
  try { error.rollbackPreservedPaths = preserved; } catch { /* Preserve a exceção original. */ }
640
840
  }
641
- if (untracked) try { restoreUntrack(plan.untrack); } catch (failure) { rollbackErrors.push(failure); }
642
841
  if (rollbackErrors.length) throw Object.assign(new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error }), { code: error.code || 'E_OPERATION' });
643
842
  throw error;
644
843
  }