@laitszkin/apollo-toolkit 3.11.4 → 3.11.6

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 (38) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +5 -1
  4. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  5. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  6. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  7. package/commit-and-push/README.md +0 -2
  8. package/commit-and-push/SKILL.md +5 -5
  9. package/commit-and-push/agents/openai.yaml +1 -1
  10. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  11. package/generate-spec/SKILL.md +17 -13
  12. package/generate-spec/agents/openai.yaml +3 -3
  13. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  14. package/init-project-html/lib/atlas/cli.js +208 -91
  15. package/init-project-html/lib/atlas/render.js +29 -0
  16. package/init-project-html/lib/atlas/state.js +89 -7
  17. package/init-project-html/scripts/architecture.js +10 -17
  18. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  19. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  20. package/package.json +1 -1
  21. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  22. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  23. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  24. package/review-change-set/README.md +3 -3
  25. package/review-change-set/SKILL.md +36 -24
  26. package/review-change-set/agents/openai.yaml +2 -2
  27. package/review-spec-related-changes/README.md +3 -2
  28. package/review-spec-related-changes/SKILL.md +12 -7
  29. package/review-spec-related-changes/agents/openai.yaml +1 -1
  30. package/spec-to-project-html/SKILL.md +4 -4
  31. package/spec-to-project-html/agents/openai.yaml +1 -1
  32. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  33. package/update-project-html/README.md +38 -0
  34. package/update-project-html/SKILL.md +125 -0
  35. package/update-project-html/agents/openai.yaml +11 -0
  36. package/version-release/README.md +0 -2
  37. package/version-release/SKILL.md +3 -3
  38. package/version-release/agents/openai.yaml +1 -1
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // Global flags:
25
25
  // --project <root> project root; creates resources/project-architecture/ if missing
26
- // --spec <spec_dir> spec directory; mutations go to <spec_dir>/architecture_diff/atlas/
26
+ // --spec <spec_dir> single specs write to <spec_dir>/architecture_diff/atlas/; batch member paths resolve to the coordination.md root
27
27
  // --no-render skip auto-render after a mutation
28
28
  // --no-open for open/diff: skip launching the browser
29
29
  // --out <dir> for diff: override viewer output directory
@@ -41,6 +41,7 @@ const ATLAS_INDEX_REL = path.join(ATLAS_REL, 'index.html');
41
41
  const ATLAS_DIRNAME = stateLib.ATLAS_DIRNAME;
42
42
  const DIFF_DIRNAME = 'architecture_diff';
43
43
  const PLANS_REL = path.join('docs', 'plans');
44
+ const COORDINATION_FILE = 'coordination.md';
44
45
  const REMOVED_TXT = '_removed.txt';
45
46
  const DEFAULT_DIFF_OUT_REL = path.join('.apollo-toolkit', 'architecture-diff');
46
47
 
@@ -63,12 +64,12 @@ Verbs:
63
64
  meta set edit meta.title / meta.summary
64
65
  actor add|remove manage top-level actors
65
66
  validate run schema + referential checks
66
- undo revert the most recent mutation
67
+ undo revert the most recent mutation (use --steps <n> for multi-step rollback)
67
68
  help show this help
68
69
 
69
70
  Global flags:
70
71
  --project <root> explicit project root (default: nearest ancestor with atlas markers, else cwd); missing directories under resources/project-architecture/ are created automatically
71
- --spec <spec_dir> mutations write to <spec_dir>/architecture_diff/atlas/
72
+ --spec <spec_dir> single specs write to <spec_dir>/architecture_diff/atlas/; batch member paths write to the coordination.md root
72
73
  --no-render skip auto-render after a mutation
73
74
  --no-open for open/diff: skip launching the browser
74
75
  --out <dir> for diff: override viewer output directory
@@ -81,6 +82,7 @@ Examples:
81
82
  apltk architecture dataflow add --feature register --submodule api --step "Validate body" --fn handlePost --reads "body" --writes "token"
82
83
  apltk architecture --spec docs/plans/2026-05-11/add-2fa submodule set --feature register --slug api --role "..."
83
84
  apltk architecture validate
85
+ apltk architecture undo --steps 3 --spec docs/plans/2026-05-11/add-2fa
84
86
  apltk architecture diff
85
87
  `;
86
88
 
@@ -171,7 +173,15 @@ function resolveProjectRoot(flags) {
171
173
 
172
174
  function specOverlayDir(projectRoot, specFlag) {
173
175
  const specDir = path.isAbsolute(String(specFlag)) ? String(specFlag) : path.resolve(projectRoot, String(specFlag));
174
- return { specDir, overlayDir: path.join(specDir, DIFF_DIRNAME, ATLAS_DIRNAME), htmlOutDir: path.join(specDir, DIFF_DIRNAME) };
176
+ const plansRoot = path.join(projectRoot, PLANS_REL);
177
+ const batchRoot = fs.existsSync(path.join(specDir, COORDINATION_FILE)) ? specDir : findBatchRoot(specDir, plansRoot);
178
+ const rootDir = batchRoot || specDir;
179
+ return {
180
+ specDir,
181
+ rootDir,
182
+ overlayDir: path.join(rootDir, DIFF_DIRNAME, ATLAS_DIRNAME),
183
+ htmlOutDir: path.join(rootDir, DIFF_DIRNAME),
184
+ };
175
185
  }
176
186
 
177
187
  function baseAtlasDir(projectRoot) {
@@ -207,26 +217,21 @@ function ensureBaseAtlasDir(projectRoot) {
207
217
  async function performMutation(projectRoot, flags, action, args, mutate) {
208
218
  const isSpec = Boolean(flags.spec);
209
219
  const base = stateLib.load(baseAtlasDir(projectRoot));
210
- let overlay = null;
211
220
  let merged = base;
212
- let touchedFeatureSlugs = new Set();
213
221
 
214
222
  if (isSpec) {
215
223
  const { overlayDir } = specOverlayDir(projectRoot, flags.spec);
216
- overlay = stateLib.loadOverlay(overlayDir);
224
+ const overlay = stateLib.loadOverlay(overlayDir);
217
225
  merged = stateLib.mergeOverlay(base, overlay);
218
226
  const before = JSON.parse(JSON.stringify({ base, overlay }));
219
- const result = mutate(merged, base, overlay) || {};
220
- if (result.touchedFeatures) for (const slug of result.touchedFeatures) touchedFeatureSlugs.add(slug);
227
+ mutate(merged, base, overlay);
221
228
  stateLib.writeUndoSnapshot(overlayDir, before);
222
- syncOverlayFromMerged({ base, overlay, merged, touchedFeatureSlugs, removalsHint: result.removalsHint });
223
- stateLib.saveOverlay(overlayDir, overlay);
229
+ stateLib.saveOverlay(overlayDir, stateLib.deriveOverlay(base, merged));
224
230
  stateLib.appendHistory(overlayDir, { action, args, mode: 'spec' });
225
231
  } else {
226
232
  ensureBaseAtlasDir(projectRoot);
227
233
  const before = JSON.parse(JSON.stringify({ base }));
228
- const result = mutate(base, base, null) || {};
229
- if (result.touchedFeatures) for (const slug of result.touchedFeatures) touchedFeatureSlugs.add(slug);
234
+ mutate(base, base, null);
230
235
  stateLib.writeUndoSnapshot(baseAtlasDir(projectRoot), before);
231
236
  stateLib.save(baseAtlasDir(projectRoot), base);
232
237
  stateLib.appendHistory(baseAtlasDir(projectRoot), { action, args, mode: 'base' });
@@ -237,47 +242,6 @@ async function performMutation(projectRoot, flags, action, args, mutate) {
237
242
  }
238
243
  }
239
244
 
240
- function syncOverlayFromMerged({ base, overlay, merged, touchedFeatureSlugs, removalsHint }) {
241
- // Compare merged top-level fields against base; if they differ, sync into overlay.
242
- if (JSON.stringify(merged.meta || {}) !== JSON.stringify(base.meta || {})) overlay.meta = merged.meta;
243
- if (JSON.stringify(merged.actors || []) !== JSON.stringify(base.actors || [])) overlay.actors = merged.actors || [];
244
- if (JSON.stringify(merged.edges || []) !== JSON.stringify(base.edges || [])) overlay.edges = merged.edges || [];
245
-
246
- const baseOrder = (base.features || []).map((f) => f.slug);
247
- const mergedOrder = (merged.features || []).map((f) => f.slug);
248
- if (JSON.stringify(baseOrder) !== JSON.stringify(mergedOrder)) {
249
- overlay.featureOrder = mergedOrder;
250
- }
251
-
252
- // Persist touched (or simply differing) features into overlay.features
253
- const baseFeatureMap = new Map((base.features || []).map((f) => [f.slug, f]));
254
- const mergedFeatureMap = new Map((merged.features || []).map((f) => [f.slug, f]));
255
- for (const slug of touchedFeatureSlugs) {
256
- if (!mergedFeatureMap.has(slug)) continue;
257
- const baseFeat = baseFeatureMap.get(slug);
258
- const mergedFeat = mergedFeatureMap.get(slug);
259
- if (!baseFeat || JSON.stringify(baseFeat) !== JSON.stringify(mergedFeat)) {
260
- overlay.features[slug] = mergedFeat;
261
- }
262
- }
263
- // Apply hinted removals
264
- if (removalsHint) {
265
- if (Array.isArray(removalsHint.features)) {
266
- const seen = new Set(overlay.removed.features || []);
267
- for (const slug of removalsHint.features) seen.add(slug);
268
- overlay.removed.features = [...seen];
269
- // also drop from overlay.features if present
270
- for (const slug of removalsHint.features) delete overlay.features[slug];
271
- }
272
- if (Array.isArray(removalsHint.submodules)) {
273
- const seen = new Map();
274
- for (const item of overlay.removed.submodules || []) seen.set(`${item.feature}::${item.submodule}`, item);
275
- for (const item of removalsHint.submodules) seen.set(`${item.feature}::${item.submodule}`, item);
276
- overlay.removed.submodules = [...seen.values()];
277
- }
278
- }
279
- }
280
-
281
245
  async function runRender({ projectRoot, flags }) {
282
246
  if (flags.spec) {
283
247
  const { overlayDir, htmlOutDir } = specOverlayDir(projectRoot, flags.spec);
@@ -642,9 +606,14 @@ async function verbValidate(flags, projectRoot, io) {
642
606
 
643
607
  async function verbUndo(flags, projectRoot, io) {
644
608
  const dir = flags.spec ? specOverlayDir(projectRoot, flags.spec).overlayDir : baseAtlasDir(projectRoot);
645
- const snapshot = stateLib.readUndoSnapshot(dir);
609
+ const stepsRaw = flags.steps === undefined ? 1 : Number(flags.steps);
610
+ if (!Number.isInteger(stepsRaw) || stepsRaw < 1) {
611
+ io.stderr.write('--steps must be a positive integer.\n');
612
+ return 1;
613
+ }
614
+ const snapshot = stateLib.consumeUndoSnapshot(dir, stepsRaw);
646
615
  if (!snapshot) {
647
- io.stderr.write('No undo snapshot found.\n');
616
+ io.stderr.write(stepsRaw === 1 ? 'No undo snapshot found.\n' : `Unable to undo ${stepsRaw} steps; history is shorter.\n`);
648
617
  return 1;
649
618
  }
650
619
  if (flags.spec) {
@@ -655,9 +624,8 @@ async function verbUndo(flags, projectRoot, io) {
655
624
  stateLib.save(baseAtlasDir(projectRoot), snapshot.base);
656
625
  stateLib.appendHistory(baseAtlasDir(projectRoot), { action: 'undo', mode: 'base' });
657
626
  }
658
- stateLib.clearUndoSnapshot(dir);
659
627
  if (!flags['no-render']) await runRender({ projectRoot, flags });
660
- io.stdout.write('atlas: undo applied\n');
628
+ io.stdout.write(`atlas: undo applied (${stepsRaw} step${stepsRaw === 1 ? '' : 's'})\n`);
661
629
  return 0;
662
630
  }
663
631
 
@@ -678,36 +646,27 @@ async function verbOpen(flags, projectRoot, io) {
678
646
  async function verbDiff(flags, projectRoot, io) {
679
647
  const outDir = flags.out ? path.resolve(String(flags.out)) : path.join(projectRoot, DEFAULT_DIFF_OUT_REL);
680
648
  fs.mkdirSync(outDir, { recursive: true });
649
+ const changes = await collectDiffChanges({ projectRoot, outDir });
681
650
 
651
+ const html = renderDiffViewer({ changes, projectRoot, outDir });
652
+ const indexPath = path.join(outDir, 'index.html');
653
+ fs.writeFileSync(indexPath, html, 'utf8');
654
+ io.stdout.write(`${indexPath}\n`);
655
+ io.stdout.write(`Diff pages: ${changes.length} (modified=${changes.filter((c) => c.kind === 'modified').length}, added=${changes.filter((c) => c.kind === 'added').length}, removed=${changes.filter((c) => c.kind === 'removed').length})\n`);
656
+ if (!flags['no-open']) openInBrowser(indexPath);
657
+ return 0;
658
+ }
659
+
660
+ async function collectDiffChanges({ projectRoot, outDir }) {
682
661
  const plansRoot = path.join(projectRoot, PLANS_REL);
683
- const diffDirs = walkArchitectureDiffDirs(plansRoot);
684
- const resourcesRoot = path.join(projectRoot, ATLAS_REL);
662
+ const groups = groupDiffDirsByBatch({ projectRoot, plansRoot });
685
663
  const changes = [];
686
664
 
687
- for (const diffDir of diffDirs) {
688
- const specDir = path.dirname(diffDir);
689
- const specLabel = path.relative(projectRoot, specDir);
690
- for (const after of walkAfterStateHtml(diffDir)) {
691
- const beforeAbs = path.join(resourcesRoot, after.rel);
692
- const beforeExists = fs.existsSync(beforeAbs);
693
- changes.push({
694
- kind: beforeExists ? 'modified' : 'added',
695
- rel: after.rel,
696
- spec: specLabel,
697
- beforePath: beforeExists ? path.relative(projectRoot, beforeAbs) : null,
698
- afterPath: path.relative(projectRoot, after.abs),
699
- });
700
- }
701
- for (const removedRel of readRemovedManifest(diffDir)) {
702
- const beforeAbs = path.join(resourcesRoot, removedRel);
703
- if (!fs.existsSync(beforeAbs)) continue;
704
- changes.push({
705
- kind: 'removed',
706
- rel: removedRel,
707
- spec: specLabel,
708
- beforePath: path.relative(projectRoot, beforeAbs),
709
- afterPath: null,
710
- });
665
+ for (const group of groups) {
666
+ if (group.kind === 'batch') {
667
+ changes.push(...await collectBatchGroupChanges({ projectRoot, outDir, group }));
668
+ } else {
669
+ changes.push(...collectSingleSpecChanges({ projectRoot, specDir: group.specDir, specLabel: group.label }));
711
670
  }
712
671
  }
713
672
 
@@ -716,14 +675,171 @@ async function verbDiff(flags, projectRoot, io) {
716
675
  if (a.kind !== b.kind) return a.kind.localeCompare(b.kind);
717
676
  return a.rel.localeCompare(b.rel);
718
677
  });
678
+ return changes;
679
+ }
719
680
 
720
- const html = renderDiffViewer({ changes, projectRoot, outDir });
721
- const indexPath = path.join(outDir, 'index.html');
722
- fs.writeFileSync(indexPath, html, 'utf8');
723
- io.stdout.write(`${indexPath}\n`);
724
- io.stdout.write(`Diff pages: ${changes.length} (modified=${changes.filter((c) => c.kind === 'modified').length}, added=${changes.filter((c) => c.kind === 'added').length}, removed=${changes.filter((c) => c.kind === 'removed').length})\n`);
725
- if (!flags['no-open']) openInBrowser(indexPath);
726
- return 0;
681
+ function groupDiffDirsByBatch({ projectRoot, plansRoot }) {
682
+ const groups = new Map();
683
+ for (const diffDir of walkArchitectureDiffDirs(plansRoot)) {
684
+ const specDir = path.dirname(diffDir);
685
+ const batchRoot = findBatchRoot(specDir, plansRoot);
686
+ const isBatchMember = Boolean(batchRoot && batchRoot !== specDir);
687
+ const key = isBatchMember ? batchRoot : specDir;
688
+ if (!groups.has(key)) {
689
+ groups.set(key, {
690
+ kind: isBatchMember ? 'batch' : 'single',
691
+ key,
692
+ label: path.relative(projectRoot, key),
693
+ specDir: isBatchMember ? null : specDir,
694
+ members: [],
695
+ });
696
+ }
697
+ groups.get(key).members.push({ specDir, diffDir, label: path.relative(projectRoot, specDir) });
698
+ }
699
+ return [...groups.values()]
700
+ .map((group) => ({ ...group, members: group.members.sort((a, b) => a.specDir.localeCompare(b.specDir)) }))
701
+ .sort((a, b) => a.key.localeCompare(b.key));
702
+ }
703
+
704
+ function findBatchRoot(specDir, plansRoot) {
705
+ const absolutePlansRoot = path.resolve(plansRoot);
706
+ let current = path.resolve(path.dirname(specDir));
707
+ while (current.startsWith(`${absolutePlansRoot}${path.sep}`) || current === absolutePlansRoot) {
708
+ if (fs.existsSync(path.join(current, COORDINATION_FILE))) return current;
709
+ if (current === absolutePlansRoot) break;
710
+ current = path.dirname(current);
711
+ }
712
+ return null;
713
+ }
714
+
715
+ function collectSingleSpecChanges({ projectRoot, specDir, specLabel }) {
716
+ const overlayDir = path.join(specDir, DIFF_DIRNAME, ATLAS_DIRNAME);
717
+ if (!hasOverlayState(overlayDir)) {
718
+ return collectHtmlManifestChanges({ projectRoot, diffDir: path.join(specDir, DIFF_DIRNAME), specLabel });
719
+ }
720
+ const base = stateLib.load(baseAtlasDir(projectRoot));
721
+ const overlay = stateLib.loadOverlay(overlayDir);
722
+ const merged = stateLib.mergeOverlay(base, overlay);
723
+ const diff = stateLib.diffPages(base, merged);
724
+ return diffToChanges({
725
+ projectRoot,
726
+ specLabel,
727
+ htmlRoot: path.join(specDir, DIFF_DIRNAME),
728
+ diff,
729
+ });
730
+ }
731
+
732
+ function hasOverlayState(overlayDir) {
733
+ return fs.existsSync(path.join(overlayDir, stateLib.INDEX_FILE))
734
+ || fs.existsSync(path.join(overlayDir, stateLib.FEATURES_DIR))
735
+ || fs.existsSync(path.join(overlayDir, stateLib.REMOVED_FILE));
736
+ }
737
+
738
+ async function collectBatchGroupChanges({ projectRoot, outDir, group }) {
739
+ const batchRootOverlayDir = path.join(group.key, DIFF_DIRNAME, ATLAS_DIRNAME);
740
+ if (hasOverlayState(batchRootOverlayDir)) {
741
+ return collectSingleSpecChanges({ projectRoot, specDir: group.key, specLabel: group.label });
742
+ }
743
+
744
+ const memberOverlayDirs = group.members.map((member) => ({
745
+ ...member,
746
+ overlayDir: path.join(member.specDir, DIFF_DIRNAME, ATLAS_DIRNAME),
747
+ }));
748
+ if (memberOverlayDirs.some((member) => !hasOverlayState(member.overlayDir))) {
749
+ return group.members.flatMap((member) => (
750
+ collectSingleSpecChanges({ projectRoot, specDir: member.specDir, specLabel: member.label })
751
+ ));
752
+ }
753
+
754
+ const base = stateLib.load(baseAtlasDir(projectRoot));
755
+ let merged = JSON.parse(JSON.stringify(base));
756
+ for (const member of memberOverlayDirs) {
757
+ const overlay = stateLib.loadOverlay(member.overlayDir);
758
+ merged = stateLib.mergeOverlay(merged, overlay);
759
+ }
760
+ const diff = stateLib.diffPages(base, merged);
761
+ const htmlRoot = path.join(outDir, '_batch', group.label);
762
+ await renderLib.renderAll({
763
+ outDir: htmlRoot,
764
+ state: merged,
765
+ scope: renderLib.scopeFromDiff(diff),
766
+ removedPaths: renderLib.removedPagePathsFromDiff(diff),
767
+ });
768
+ return diffToChanges({
769
+ projectRoot,
770
+ specLabel: group.label,
771
+ htmlRoot,
772
+ diff,
773
+ });
774
+ }
775
+
776
+ function diffToChanges({ projectRoot, specLabel, htmlRoot, diff }) {
777
+ const resourcesRoot = path.join(projectRoot, ATLAS_REL);
778
+ const changes = [];
779
+ const add = (kind, rel) => {
780
+ const beforeAbs = path.join(resourcesRoot, rel);
781
+ const afterAbs = kind === 'removed' ? null : path.join(htmlRoot, rel);
782
+ if (kind === 'removed' && !fs.existsSync(beforeAbs)) return;
783
+ changes.push({
784
+ kind,
785
+ rel,
786
+ spec: specLabel,
787
+ beforePath: kind === 'added' ? null : path.relative(projectRoot, beforeAbs),
788
+ afterPath: afterAbs ? path.relative(projectRoot, afterAbs) : null,
789
+ });
790
+ };
791
+
792
+ if (diff.macroChanged) {
793
+ add('modified', renderLib.pagePathFor('macro'));
794
+ }
795
+ for (const slug of diff.modifiedFeatures || []) {
796
+ add('modified', renderLib.pagePathFor('feature', { featureSlug: slug }));
797
+ }
798
+ for (const slug of diff.addedFeatures || []) {
799
+ add('added', renderLib.pagePathFor('feature', { featureSlug: slug }));
800
+ }
801
+ for (const item of diff.modifiedSubmodules || []) {
802
+ add('modified', renderLib.pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
803
+ }
804
+ for (const item of diff.addedSubmodules || []) {
805
+ add('added', renderLib.pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
806
+ }
807
+ for (const slug of diff.removedFeatures || []) {
808
+ add('removed', renderLib.pagePathFor('feature', { featureSlug: slug }));
809
+ }
810
+ for (const item of diff.removedSubmodules || []) {
811
+ add('removed', renderLib.pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
812
+ }
813
+
814
+ return changes;
815
+ }
816
+
817
+ function collectHtmlManifestChanges({ projectRoot, diffDir, specLabel }) {
818
+ const resourcesRoot = path.join(projectRoot, ATLAS_REL);
819
+ const changes = [];
820
+ for (const after of walkAfterStateHtml(diffDir)) {
821
+ const beforeAbs = path.join(resourcesRoot, after.rel);
822
+ const beforeExists = fs.existsSync(beforeAbs);
823
+ changes.push({
824
+ kind: beforeExists ? 'modified' : 'added',
825
+ rel: after.rel,
826
+ spec: specLabel,
827
+ beforePath: beforeExists ? path.relative(projectRoot, beforeAbs) : null,
828
+ afterPath: path.relative(projectRoot, after.abs),
829
+ });
830
+ }
831
+ for (const removedRel of readRemovedManifest(diffDir)) {
832
+ const beforeAbs = path.join(resourcesRoot, removedRel);
833
+ if (!fs.existsSync(beforeAbs)) continue;
834
+ changes.push({
835
+ kind: 'removed',
836
+ rel: removedRel,
837
+ spec: specLabel,
838
+ beforePath: path.relative(projectRoot, beforeAbs),
839
+ afterPath: null,
840
+ });
841
+ }
842
+ return changes;
727
843
  }
728
844
 
729
845
  function walkArchitectureDiffDirs(plansRoot) {
@@ -1021,6 +1137,7 @@ module.exports = {
1021
1137
  specOverlayDir,
1022
1138
  runRender,
1023
1139
  walkArchitectureDiffDirs,
1140
+ collectDiffChanges,
1024
1141
  walkAfterStateHtml,
1025
1142
  readRemovedManifest,
1026
1143
  renderDiffViewer,
@@ -560,11 +560,40 @@ async function renderAll({ outDir, state, scope = null, removedPaths = [] }) {
560
560
  // do not linger with the previous (broken) markup or styling.
561
561
  if (!scope) {
562
562
  sweepOrphanFeaturePages(outDir, state);
563
+ } else {
564
+ sweepScopedHtml(outDir, new Set(written.map((file) => file.split(path.sep).join('/'))));
563
565
  }
564
566
 
565
567
  return { written, layout };
566
568
  }
567
569
 
570
+ function sweepScopedHtml(outDir, keepPaths) {
571
+ function recurse(dir) {
572
+ let entries;
573
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
574
+ for (const entry of entries) {
575
+ if (entry.name === 'assets' || entry.name === 'atlas' || entry.name.startsWith('.')) continue;
576
+ const full = path.join(dir, entry.name);
577
+ if (entry.isDirectory()) {
578
+ recurse(full);
579
+ let remaining;
580
+ try { remaining = fs.readdirSync(full); } catch (_e) { remaining = null; }
581
+ if (remaining && remaining.length === 0) {
582
+ fs.rmSync(full, { recursive: true, force: true });
583
+ }
584
+ continue;
585
+ }
586
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
587
+ const rel = path.relative(outDir, full).split(path.sep).join('/');
588
+ if (!keepPaths.has(rel)) {
589
+ fs.rmSync(full, { force: true });
590
+ }
591
+ }
592
+ }
593
+
594
+ recurse(outDir);
595
+ }
596
+
568
597
  function sweepOrphanFeaturePages(outDir, state) {
569
598
  const featuresRoot = path.join(outDir, 'features');
570
599
  if (!fs.existsSync(featuresRoot)) return;
@@ -26,6 +26,7 @@ const REMOVED_FILE = '_removed.yaml';
26
26
  const FEATURES_DIR = 'features';
27
27
  const HISTORY_FILE = 'atlas.history.log';
28
28
  const UNDO_FILE = 'atlas.history.undo.json';
29
+ const UNDO_STACK_FILE = 'atlas.history.undo.stack.json';
29
30
  const ATLAS_DIRNAME = 'atlas';
30
31
 
31
32
  function readYaml(file) {
@@ -257,6 +258,51 @@ function mergeOverlay(base, overlay) {
257
258
  return merged;
258
259
  }
259
260
 
261
+ function deriveOverlay(base, merged) {
262
+ const overlay = {
263
+ meta: null,
264
+ actors: null,
265
+ edges: null,
266
+ featureOrder: null,
267
+ features: {},
268
+ removed: { features: [], submodules: [] },
269
+ };
270
+
271
+ if (JSON.stringify(merged.meta || {}) !== JSON.stringify(base.meta || {})) {
272
+ overlay.meta = merged.meta || {};
273
+ }
274
+ if (JSON.stringify(merged.actors || []) !== JSON.stringify(base.actors || [])) {
275
+ overlay.actors = merged.actors || [];
276
+ }
277
+ if (JSON.stringify(merged.edges || []) !== JSON.stringify(base.edges || [])) {
278
+ overlay.edges = merged.edges || [];
279
+ }
280
+
281
+ const baseOrder = (base.features || []).map((feature) => feature.slug);
282
+ const mergedOrder = (merged.features || []).map((feature) => feature.slug);
283
+ if (JSON.stringify(mergedOrder) !== JSON.stringify(baseOrder)) {
284
+ overlay.featureOrder = mergedOrder;
285
+ }
286
+
287
+ const baseFeatures = new Map((base.features || []).map((feature) => [feature.slug, feature]));
288
+ const mergedFeatures = new Map((merged.features || []).map((feature) => [feature.slug, feature]));
289
+
290
+ for (const [slug, feature] of mergedFeatures) {
291
+ const baseFeature = baseFeatures.get(slug);
292
+ if (!baseFeature || JSON.stringify(feature) !== JSON.stringify(baseFeature)) {
293
+ overlay.features[slug] = feature;
294
+ }
295
+ }
296
+
297
+ for (const slug of baseFeatures.keys()) {
298
+ if (!mergedFeatures.has(slug)) {
299
+ overlay.removed.features.push(slug);
300
+ }
301
+ }
302
+
303
+ return overlay;
304
+ }
305
+
260
306
  // diffPages compares the merged after-state against the base and
261
307
  // classifies which HTML pages must be regenerated (modified) versus
262
308
  // emitted fresh (added) versus listed in _removed.txt (removed).
@@ -361,19 +407,52 @@ function appendHistory(atlasDir, entry) {
361
407
  }
362
408
 
363
409
  function writeUndoSnapshot(atlasDir, state) {
364
- fs.mkdirSync(atlasDir, { recursive: true });
365
- fs.writeFileSync(path.join(atlasDir, UNDO_FILE), JSON.stringify(state, null, 2), 'utf8');
410
+ const stack = readUndoStack(atlasDir);
411
+ stack.push(state);
412
+ writeUndoStack(atlasDir, stack);
366
413
  }
367
414
 
368
415
  function readUndoSnapshot(atlasDir) {
369
- const file = path.join(atlasDir, UNDO_FILE);
370
- if (!fs.existsSync(file)) return null;
371
- return JSON.parse(fs.readFileSync(file, 'utf8'));
416
+ const stack = readUndoStack(atlasDir);
417
+ return stack.length > 0 ? stack[stack.length - 1] : null;
372
418
  }
373
419
 
374
420
  function clearUndoSnapshot(atlasDir) {
375
- const file = path.join(atlasDir, UNDO_FILE);
376
- if (fs.existsSync(file)) fs.rmSync(file);
421
+ writeUndoStack(atlasDir, []);
422
+ }
423
+
424
+ function consumeUndoSnapshot(atlasDir, steps = 1) {
425
+ if (!Number.isInteger(steps) || steps < 1) return null;
426
+ const stack = readUndoStack(atlasDir);
427
+ if (stack.length < steps) return null;
428
+ const snapshot = stack[stack.length - steps];
429
+ writeUndoStack(atlasDir, stack.slice(0, stack.length - steps));
430
+ return snapshot;
431
+ }
432
+
433
+ function readUndoStack(atlasDir) {
434
+ const stackFile = path.join(atlasDir, UNDO_STACK_FILE);
435
+ if (fs.existsSync(stackFile)) {
436
+ return JSON.parse(fs.readFileSync(stackFile, 'utf8'));
437
+ }
438
+ const latestFile = path.join(atlasDir, UNDO_FILE);
439
+ if (fs.existsSync(latestFile)) {
440
+ return [JSON.parse(fs.readFileSync(latestFile, 'utf8'))];
441
+ }
442
+ return [];
443
+ }
444
+
445
+ function writeUndoStack(atlasDir, stack) {
446
+ const stackFile = path.join(atlasDir, UNDO_STACK_FILE);
447
+ const latestFile = path.join(atlasDir, UNDO_FILE);
448
+ if (!stack || stack.length === 0) {
449
+ if (fs.existsSync(stackFile)) fs.rmSync(stackFile);
450
+ if (fs.existsSync(latestFile)) fs.rmSync(latestFile);
451
+ return;
452
+ }
453
+ fs.mkdirSync(atlasDir, { recursive: true });
454
+ fs.writeFileSync(stackFile, JSON.stringify(stack, null, 2), 'utf8');
455
+ fs.writeFileSync(latestFile, JSON.stringify(stack[stack.length - 1], null, 2), 'utf8');
377
456
  }
378
457
 
379
458
  module.exports = {
@@ -383,6 +462,7 @@ module.exports = {
383
462
  FEATURES_DIR,
384
463
  HISTORY_FILE,
385
464
  UNDO_FILE,
465
+ UNDO_STACK_FILE,
386
466
  readYaml,
387
467
  writeYaml,
388
468
  load,
@@ -390,6 +470,7 @@ module.exports = {
390
470
  loadOverlay,
391
471
  saveOverlay,
392
472
  mergeOverlay,
473
+ deriveOverlay,
393
474
  diffPages,
394
475
  normalizeFeature,
395
476
  normalizeSubmodule,
@@ -397,6 +478,7 @@ module.exports = {
397
478
  writeUndoSnapshot,
398
479
  readUndoSnapshot,
399
480
  clearUndoSnapshot,
481
+ consumeUndoSnapshot,
400
482
  macroVisualOf,
401
483
  featureVisualOf,
402
484
  };
@@ -43,12 +43,12 @@ Usage:
43
43
  Manage component rows and edges
44
44
  apltk architecture meta set Update meta.title / meta.summary
45
45
  apltk architecture actor add|remove Manage top-level actors
46
- apltk architecture undo Revert the most recent mutation
46
+ apltk architecture undo [--steps <n>] Revert the most recent mutation or roll back multiple steps
47
47
  apltk architecture --help Show this help
48
48
 
49
49
  Global flags:
50
50
  --project <root> Project root (default: nearest ancestor with resources/project-architecture/, else cwd); missing layout dirs are created when needed
51
- --spec <spec_dir> Mutations write to <spec_dir>/architecture_diff/atlas/
51
+ --spec <spec_dir> Single specs write locally; batch member paths write to the coordination.md root architecture_diff/atlas/
52
52
  --no-render Skip auto-render after a mutation
53
53
  --no-open For open/diff: skip launching the browser
54
54
  --out <dir> For diff: override viewer output directory
@@ -255,21 +255,14 @@ function runDiff(opts, io) {
255
255
  if (!projectRoot) {
256
256
  projectRoot = process.cwd();
257
257
  }
258
- fs.mkdirSync(path.join(projectRoot, RESOURCES_REL), { recursive: true });
259
- const outDir = opts.out || path.join(projectRoot, DEFAULT_OUT_REL);
260
- fs.mkdirSync(outDir, { recursive: true });
261
-
262
- const changes = collectChanges(projectRoot);
263
- const html = renderViewer({ changes, projectRoot, outDir });
264
- const indexPath = path.join(outDir, 'index.html');
265
- fs.writeFileSync(indexPath, html, 'utf8');
266
-
267
- io.stdout.write(`${indexPath}\n`);
268
- io.stdout.write(
269
- `Diff pages: ${changes.length} (modified=${changes.filter((c) => c.kind === 'modified').length}, added=${changes.filter((c) => c.kind === 'added').length}, removed=${changes.filter((c) => c.kind === 'removed').length})\n`,
270
- );
271
- if (opts.open) openInBrowser(indexPath);
272
- return 0;
258
+ const bootstrap = path.join(__dirname, 'architecture-bootstrap-render.js');
259
+ const args = [bootstrap, 'diff', '--project', projectRoot];
260
+ if (opts.out) args.push('--out', opts.out);
261
+ if (!opts.open) args.push('--no-open');
262
+ const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
263
+ if (result.stdout) io.stdout.write(result.stdout);
264
+ if (result.stderr) io.stderr.write(result.stderr);
265
+ return result.status || 0;
273
266
  }
274
267
 
275
268
  // main(argv, io) is sync and supports the legacy verbs `open` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laitszkin/apollo-toolkit",
3
- "version": "3.11.4",
3
+ "version": "3.11.6",
4
4
  "description": "Apollo Toolkit npm installer for managed skill copying across Codex, OpenClaw, and Trae.",
5
5
  "license": "MIT",
6
6
  "author": "LaiTszKin",