@pathbit/agentdevkit 0.0.23 → 0.0.24

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
@@ -457,7 +457,14 @@ function replaceJsonMember(bytes, member, value) {
457
457
  fail('E_STATE', 'Invalid JSON member separator.');
458
458
  }
459
459
  const matches = properties.filter(item => item.key === member);
460
- if (matches.length !== 1) fail('E_STATE', 'Ambiguous derived state member.');
460
+ if (matches.length > 1) fail('E_STATE', 'Ambiguous state member.');
461
+ if (!matches.length) {
462
+ if (value === undefined) return bytes;
463
+ const insertion = `${properties.length ? ',' : ''}\n ${JSON.stringify(member)}: ${JSON.stringify(value, null, 2)}\n`;
464
+ const next = text.slice(0, i) + insertion + text.slice(i);
465
+ try { JSON.parse(next); } catch { fail('E_STATE', 'Unable to preserve state safely.'); }
466
+ return Buffer.from(next);
467
+ }
461
468
  const current = matches[0], index = properties.indexOf(current);
462
469
  let next;
463
470
  if (value !== undefined) next = text.slice(0, current.valueStart) + JSON.stringify(value, null, 2) + text.slice(current.end);
@@ -468,29 +475,44 @@ function replaceJsonMember(bytes, member, value) {
468
475
  return Buffer.from(next);
469
476
  }
470
477
 
478
+ function reconcileStateBytes(original, previous, next) {
479
+ if (!original) return Buffer.from(`${JSON.stringify(next, null, 2)}\n`);
480
+ const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
481
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
482
+ : Array.isArray(value) ? value.map(canonical) : value;
483
+ let result = original;
484
+ for (const key of ['schemaVersion', 'version', 'language', 'clients', 'files', 'blocks']) {
485
+ if (JSON.stringify(canonical(previous?.[key])) !== JSON.stringify(canonical(next[key]))) result = replaceJsonMember(result, key, next[key]);
486
+ }
487
+ return result;
488
+ }
489
+
471
490
  function readDerivedState(target) {
472
- let entries = {}, outputs = {}, blocks = {}, files = {}, removedBlocks = [], removedFiles = {};
491
+ let entries = {}, outputs = {}, blocks = {}, files = {}, catalogs = {}, config = null, removedBlocks = [], removedFiles = {};
473
492
  const derived = read(target, actualStatePath(target).replace(/agentdevkit\.json$/, 'derived.json'));
474
493
  if (derived) {
475
494
  try {
476
495
  const value = JSON.parse(derived);
477
496
  if (value.schemaVersion !== 1 || !value.entries || typeof value.entries !== 'object' || Array.isArray(value.entries)
478
- || ['outputs', 'blocks', 'files', 'removedFiles'].some(key => value[key] !== undefined && (!value[key] || typeof value[key] !== 'object' || Array.isArray(value[key])))) fail('E_STATE', 'Invalid derived entrypoint state.');
479
- entries = value.entries; outputs = value.outputs ?? {}; blocks = value.blocks ?? {}; files = value.files ?? {}; removedFiles = value.removedFiles ?? {};
497
+ || ['outputs', 'blocks', 'files', 'catalogs', 'removedFiles'].some(key => value[key] !== undefined && (!value[key] || typeof value[key] !== 'object' || Array.isArray(value[key])))) fail('E_STATE', 'Invalid derived entrypoint state.');
498
+ entries = value.entries; outputs = value.outputs ?? {}; blocks = value.blocks ?? {}; files = value.files ?? {}; catalogs = value.catalogs ?? {}; removedFiles = value.removedFiles ?? {};
499
+ if (value.config !== undefined && (typeof value.config !== 'string' || !/^[a-f0-9]{64}$/.test(value.config))) fail('E_STATE', 'Invalid derived configuration state.');
500
+ config = value.config ?? null;
480
501
  removedBlocks = value.removedBlocks ?? [];
481
502
  if (!Array.isArray(removedBlocks) || removedBlocks.some(path => typeof path !== 'string' || !nativeManagedText(path))) fail('E_STATE', 'Invalid removed block state.');
482
503
  for (const [path, digest] of Object.entries(entries)) if (!canonicalEntrypoint(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived entrypoint state.');
483
504
  for (const [path, digest] of Object.entries(outputs)) if (!allowedManaged(path) || !/^\.[^/]+\/(?:skills|agents|rules)\.json$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived output state.');
484
- for (const [path, digest] of Object.entries(blocks)) if (!(nativeManagedText(path) || adapterRoots.some(root => path === `${root}/.gitignore`)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived block state.');
505
+ for (const [path, digest] of Object.entries(blocks)) if (!(nativeManagedText(path) || path === '.pbai4devs/.gitignore' || adapterRoots.some(root => path === `${root}/.gitignore`)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived block state.');
485
506
  for (const [path, digest] of Object.entries(files)) if (!allowedManaged(path) || !/\.(?:md|mdc|toml)$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived file state.');
507
+ for (const [path, digest] of Object.entries(catalogs)) if (!/^\.pbai4devs\/(?:catalog|skills|agents|rules)\.json$/.test(path) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid derived catalogue state.');
486
508
  for (const [path, digest] of Object.entries(removedFiles)) if (canonicalEntrypoint(path) || !(nativeMarkdown(path) || nativeToml(path)) || !/^[a-f0-9]{64}$/.test(digest)) fail('E_STATE', 'Invalid removed file state.');
487
509
  const installed = loadState(target);
488
510
  if (Object.entries(removedFiles).some(([path, digest]) => installed?.files?.[path] && installed.files[path] !== digest)) fail('E_STATE', 'Removed file state differs from installation.');
489
511
  }
490
512
  catch { fail('E_STATE', 'Invalid derived entrypoint state.'); }
491
- for (const path of [...Object.keys(entries), ...Object.keys(outputs), ...Object.keys(blocks), ...Object.keys(files), ...Object.keys(removedFiles), ...removedBlocks]) safePath(target, path);
513
+ for (const path of [...Object.keys(entries), ...Object.keys(outputs), ...Object.keys(blocks), ...Object.keys(files), ...Object.keys(catalogs), ...Object.keys(removedFiles), ...removedBlocks]) safePath(target, path);
492
514
  }
493
- return { entries, outputs, blocks, files, removedBlocks, removedFiles };
515
+ return { entries, outputs, blocks, files, catalogs, config, removedBlocks, removedFiles };
494
516
  }
495
517
 
496
518
  export function planInstallation({ target, language = 'pt', mode = 'init', force = false, clients }) {
@@ -511,11 +533,12 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
511
533
  if (original === null ? content !== null : !content?.equals(original)) fail('E_CHANGED', `File changed while planning: ${path}`);
512
534
  } else nativeInputs.set(path, content);
513
535
  };
514
- const { entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, removedBlocks, removedFiles } = readDerivedState(target);
536
+ const { entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, catalogs: derivedCatalogs, config: derivedConfig, removedBlocks, removedFiles } = readDerivedState(target);
537
+ const derivedConfigFiles = derivedConfig ? { '.pbai4devs/espelhos.conf': derivedConfig } : {};
515
538
  if (previous) previous = { ...previous, files: Object.fromEntries(Object.entries(previous.files).filter(([path, digest]) => removedFiles[path] !== digest)) };
516
539
  const previousBlocks = Object.fromEntries(Object.entries(previous?.blocks || {}).filter(([path]) => !removedBlocks.includes(path)));
517
540
  const knownBlocks = { ...previousBlocks, ...derivedBlocks };
518
- const ownedPaths = [...Object.keys(previous?.files || {}), ...Object.keys(previousBlocks), ...Object.keys(derivedEntries), ...Object.keys(derivedOutputs), ...Object.keys(derivedBlocks), ...Object.keys(derivedFiles)];
541
+ const ownedPaths = [...Object.keys(previous?.files || {}), ...Object.keys(previousBlocks), ...Object.keys(derivedEntries), ...Object.keys(derivedOutputs), ...Object.keys(derivedBlocks), ...Object.keys(derivedFiles), ...Object.keys(derivedCatalogs), ...Object.keys(derivedConfigFiles)];
519
542
  const entryPaths = new Map(entryNames.map(name => [name, resolveEntrypoint(target, name)]));
520
543
  const blockHash = path => knownBlocks[path]
521
544
  ?? (canonicalEntrypoint(path) ? Object.entries(knownBlocks).find(([candidate]) => canonicalEntrypoint(candidate) === canonicalEntrypoint(path))?.[1] : undefined);
@@ -597,7 +620,17 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
597
620
  const ownIgnore = read(target, '.pbai4devs/.gitignore');
598
621
  if (ownIgnore) write(stage, '.pbai4devs/.gitignore', ownIgnore);
599
622
  writeAdapterManifest(stage, { clients: selected });
600
- const synchronized = synchronize({ root: stage, force: true, localIgnores: false, generateEntrypoints: false, recordDerivedState: false, outputBlocked, output() {} });
623
+ // Supply the already-validated ownership records to the private stage so
624
+ // the synchronizer can distinguish an owned ignore block from a user's.
625
+ // These temporary records never replace or bypass checks on the target.
626
+ const stagingState = '.pbai4devs/state/agentdevkit.json', stagingDerived = '.pbai4devs/state/derived.json';
627
+ if (previous) write(stage, stagingState, JSON.stringify(previous));
628
+ if (Object.keys(derivedEntries).length || Object.keys(derivedOutputs).length || Object.keys(derivedBlocks).length || Object.keys(derivedFiles).length || Object.keys(derivedCatalogs).length || derivedConfig || removedBlocks.length || Object.keys(removedFiles).length) {
629
+ write(stage, stagingDerived, JSON.stringify({ schemaVersion: 1, entries: derivedEntries, outputs: derivedOutputs, blocks: derivedBlocks, files: derivedFiles, catalogs: derivedCatalogs, ...(derivedConfig ? { config: derivedConfig } : {}), removedBlocks, removedFiles }));
630
+ }
631
+ let synchronized;
632
+ try { synchronized = synchronize({ root: stage, force: true, localIgnores: false, generateEntrypoints: false, recordDerivedState: false, outputBlocked, output() {} }); }
633
+ finally { rmSync(join(stage, stagingState), { force: true }); rmSync(join(stage, stagingDerived), { force: true }); }
601
634
  for (const canonical of active.entrypoints) {
602
635
  const name = entryPaths.get(canonical);
603
636
  if (preserveNative(name)) continue;
@@ -673,14 +706,14 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
673
706
  // A standalone sync records its proven outputs separately. During a later
674
707
  // check/sync, compare that overlay before declaring the installation state
675
708
  // stale; update still consolidates it into the main state transactionally.
676
- let stateContent = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
709
+ let stateContent = reconcileStateBytes(installedState ? read(target, actualStatePath(target)) : null, installedState, manifest);
677
710
  if (mode === 'sync' && installedState && !changingClients) {
678
711
  const files = { ...installedState.files }, blocks = { ...installedState.blocks };
679
712
  for (const [path, digest] of Object.entries(removedFiles)) {
680
713
  if (files[path] === digest && !stat(safePath(target, path))) delete files[path];
681
714
  }
682
715
  for (const path of removedBlocks) delete blocks[path];
683
- for (const [path, digest] of Object.entries({ ...derivedFiles, ...derivedOutputs, ...derivedEntries })) { files[path] = digest; delete blocks[path]; }
716
+ for (const [path, digest] of Object.entries({ ...derivedFiles, ...derivedOutputs, ...derivedEntries, ...derivedCatalogs, ...derivedConfigFiles })) { files[path] = digest; delete blocks[path]; }
684
717
  for (const [path, digest] of Object.entries(derivedBlocks)) { blocks[path] = digest; delete files[path]; }
685
718
  const same = (a, b) => {
686
719
  const keys = Object.keys(a).sort();
@@ -692,23 +725,70 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
692
725
  desired.set(statePath, stateContent);
693
726
  const derivedPath = actualStatePath(target).replace(/agentdevkit\.json$/, 'derived.json');
694
727
  const derivedBytes = !migration ? read(target, derivedPath) : null;
695
- if (derivedBytes && Object.keys(removedFiles).length) {
728
+ let nextDerivedBytes = derivedBytes;
729
+ if (nextDerivedBytes && Object.keys(removedFiles).length) {
696
730
  const finalState = JSON.parse(stateContent);
697
731
  const remaining = Object.fromEntries(Object.entries(removedFiles).filter(([path, digest]) =>
698
732
  finalState.files[path] === digest && !stat(safePath(target, path))));
699
733
  if (Object.keys(remaining).length !== Object.keys(removedFiles).length) {
700
734
  observeNative(derivedPath, derivedBytes);
701
- desired.set(derivedPath, replaceJsonMember(derivedBytes, 'removedFiles', Object.keys(remaining).length ? remaining : undefined));
735
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'removedFiles', Object.keys(remaining).length ? remaining : undefined);
702
736
  }
703
737
  }
738
+ if (nextDerivedBytes && Object.keys(derivedCatalogs).length) {
739
+ const finalState = JSON.parse(stateContent);
740
+ const remaining = Object.fromEntries(Object.entries(derivedCatalogs).filter(([path]) => {
741
+ const content = desired.get(path);
742
+ return !content || finalState.files[path] !== hash(content);
743
+ }));
744
+ if (Object.keys(remaining).length !== Object.keys(derivedCatalogs).length) {
745
+ observeNative(derivedPath, derivedBytes);
746
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'catalogs', Object.keys(remaining).length ? remaining : undefined);
747
+ }
748
+ }
749
+ if (nextDerivedBytes && Object.keys(derivedBlocks).length) {
750
+ const finalState = JSON.parse(stateContent);
751
+ const remaining = Object.fromEntries(Object.entries(derivedBlocks).filter(([path, digest]) => {
752
+ const content = desired.get(path);
753
+ // A deselected generated block is removed later in this transaction.
754
+ // Drop its overlay only if its current bytes are still proven; edited
755
+ // blocks remain conflicts and no metadata is committed on failure.
756
+ if (!content && !canonicalEntrypoint(path) && !finalState.blocks[path] && !finalState.files[path]) {
757
+ const current = read(target, path);
758
+ return Boolean(current && blockDigest(path, current) !== digest);
759
+ }
760
+ return !content || finalState.blocks[path] !== blockDigest(path, content);
761
+ }));
762
+ if (Object.keys(remaining).length !== Object.keys(derivedBlocks).length) {
763
+ observeNative(derivedPath, derivedBytes);
764
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'blocks', Object.keys(remaining).length ? remaining : undefined);
765
+ }
766
+ }
767
+ if (nextDerivedBytes && derivedConfig) {
768
+ const finalState = JSON.parse(stateContent), path = '.pbai4devs/espelhos.conf', content = desired.get(path);
769
+ if (content && finalState.files[path] === hash(content)) {
770
+ observeNative(derivedPath, derivedBytes);
771
+ nextDerivedBytes = replaceJsonMember(nextDerivedBytes, 'config', undefined);
772
+ }
773
+ }
774
+ if (nextDerivedBytes && !nextDerivedBytes.equals(derivedBytes)) desired.set(derivedPath, nextDerivedBytes);
704
775
  const changes = [], conflicts = [];
705
776
  const aliasedEntrypoint = path => canonicalEntrypoint(path) && entryPaths.get(canonicalEntrypoint(path)) !== path;
777
+ const generatedInternal = new Set(['.pbai4devs/catalog.json', '.pbai4devs/skills.json', '.pbai4devs/agents.json', '.pbai4devs/rules.json', '.pbai4devs/espelhos.conf']);
706
778
  for (const [path, content] of desired) {
707
779
  const current = read(target, path);
708
780
  if (nativeInputs.has(path) && (nativeInputs.get(path) === null ? current !== null : !current?.equals(nativeInputs.get(path)))) fail('E_CHANGED', `File changed while planning: ${path}`);
709
- if (current?.equals(content)) continue;
781
+ const wholeFileOwned = current && [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path], derivedCatalogs[path], derivedConfigFiles[path]].some(digest => digest && digest === hash(current));
782
+ const existingBlock = current && (nativeManagedText(path) || canonicalEntrypoint(path) || path.endsWith('/.gitignore')) ? blockDigest(path, current) : null;
783
+ if (existingBlock && !wholeFileOwned && knownBlocks[path] !== existingBlock) conflicts.push(path);
784
+ if (current?.equals(content)) {
785
+ // Identical bytes do not prove ownership of a pre-existing catalogue.
786
+ // Refuse adoption: a later sync could otherwise overwrite user data.
787
+ if (!wholeFileOwned && (generatedInternal.has(path) || native(path) && !existingBlock && !mixedPaths.has(path) && !preserved.has(path))) conflicts.push(path);
788
+ continue;
789
+ }
710
790
  const managed = path === derivedPath && derivedBytes && current?.equals(derivedBytes)
711
- || [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path]].some(digest => digest && hash(current || '') === digest)
791
+ || [previous?.files[path], derivedOutputs[path], derivedEntries[path], derivedFiles[path], derivedCatalogs[path], derivedConfigFiles[path]].some(digest => digest && hash(current || '') === digest)
712
792
  || knownBlocks[path] && blockDigest(path, current || Buffer.alloc(0)) === knownBlocks[path];
713
793
  const appendBlock = mixedPaths.has(path) || canonicalEntrypoint(path) || path === '.gitignore' || path.endsWith('/.gitignore');
714
794
  const conflict = current && path !== statePath && !managed && !appendBlock;
@@ -828,7 +908,9 @@ export function planInstallation({ target, language = 'pt', mode = 'init', force
828
908
  const localIgnore = planLocalIgnore(target, { outputs: ownedOutputs, templateRoot: stage });
829
909
  const localIgnoreCheck = checkLocalIgnore(localIgnore, { replacedBlocks: replacedIgnoreBlocks(target, changes, previous) });
830
910
  conflicts.push(...(untrack?.conflicts || []), ...localIgnoreCheck.missing);
831
- 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, fallbackChecks: [...fallbackChecks], fallbackOwnedPaths: ownedPaths, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
911
+ // A conflict means ownership or the managed bytes could not be proven.
912
+ // Backups do not authorize replacing user content, even with --force.
913
+ return { target, language, mode, changes, conflicts: [...new Set(conflicts)].sort(), canApply: !conflicts.length && !untrack?.conflicts?.length && !localIgnoreCheck.missing.length, manifest, retiredRoots, ownedOutputs, untrack, localIgnore, localIgnoreCheck, migration, fallbackChecks: [...fallbackChecks], fallbackOwnedPaths: ownedPaths, preserved: [...preserved.values()].sort((a, b) => a.path.localeCompare(b.path)) };
832
914
  } finally { rmSync(stage, { recursive: true, force: true }); }
833
915
  }
834
916
 
@@ -1019,7 +1101,7 @@ export function inspect(target) {
1019
1101
  if (content) issues.push({ code: 'modified', path });
1020
1102
  continue;
1021
1103
  }
1022
- if (!content || hash(content) !== digest && hash(content) !== derived.outputs[path] && hash(content) !== derived.files[path] && hash(content) !== derived.entries[path]) issues.push({ code: content ? 'modified' : 'missing', path });
1104
+ if (!content || hash(content) !== digest && hash(content) !== derived.outputs[path] && hash(content) !== derived.files[path] && hash(content) !== derived.entries[path] && hash(content) !== derived.catalogs[path] && !(path === '.pbai4devs/espelhos.conf' && hash(content) === derived.config)) issues.push({ code: content ? 'modified' : 'missing', path });
1023
1105
  }
1024
1106
  for (const [path, digest] of Object.entries({ ...state.blocks, ...derived.blocks })) {
1025
1107
  if (derived.removedBlocks.includes(path) && !Object.hasOwn(derived.blocks, path)) continue;