@laitszkin/apollo-toolkit 3.9.7 → 3.11.0

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 (55) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +6 -0
  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/cjk-pdf/agents/openai.yaml +5 -0
  8. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  9. package/generate-spec/SKILL.md +26 -4
  10. package/generate-spec/agents/openai.yaml +1 -0
  11. package/generate-spec/references/TEMPLATE_SPEC.md +117 -0
  12. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  13. package/init-project-html/SKILL.md +137 -0
  14. package/init-project-html/agents/openai.yaml +22 -0
  15. package/init-project-html/lib/atlas/assets/architecture.css +140 -0
  16. package/init-project-html/lib/atlas/assets/viewer.client.js +93 -0
  17. package/init-project-html/lib/atlas/cli.js +995 -0
  18. package/init-project-html/lib/atlas/layout.js +229 -0
  19. package/init-project-html/lib/atlas/render.js +485 -0
  20. package/init-project-html/lib/atlas/schema.js +310 -0
  21. package/init-project-html/lib/atlas/state.js +402 -0
  22. package/init-project-html/references/TEMPLATE_SPEC.md +137 -0
  23. package/init-project-html/references/architecture-page.template.html +35 -0
  24. package/init-project-html/references/architecture.css +1059 -0
  25. package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +140 -0
  26. package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +93 -0
  27. package/init-project-html/sample-demo/resources/project-architecture/atlas/atlas.index.yaml +34 -0
  28. package/init-project-html/sample-demo/resources/project-architecture/atlas/features/get-invite-codes.yaml +159 -0
  29. package/init-project-html/sample-demo/resources/project-architecture/atlas/features/invite-code-registration.yaml +160 -0
  30. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/index.html +69 -0
  31. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-code-generator.html +50 -0
  32. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +72 -0
  33. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +66 -0
  34. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +70 -0
  35. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +67 -0
  36. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/index.html +63 -0
  37. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/postgresql.html +68 -0
  38. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +65 -0
  39. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +79 -0
  40. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +67 -0
  41. package/init-project-html/sample-demo/resources/project-architecture/index.html +234 -0
  42. package/init-project-html/scripts/architecture.js +314 -0
  43. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  44. package/lib/cli.js +2 -0
  45. package/lib/tool-runner.js +7 -0
  46. package/merge-conflict-resolver/agents/openai.yaml +5 -0
  47. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  48. package/package.json +6 -2
  49. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  50. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  51. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  52. package/spec-to-project-html/SKILL.md +114 -0
  53. package/spec-to-project-html/agents/openai.yaml +18 -0
  54. package/spec-to-project-html/references/TEMPLATE_SPEC.md +111 -0
  55. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
@@ -0,0 +1,995 @@
1
+ 'use strict';
2
+
3
+ // cli.js — declarative atlas command tree under `apltk architecture`.
4
+ //
5
+ // Verbs (always operate on the resolved atlas; --spec switches reads
6
+ // and writes to the overlay snapshot under <spec_dir>/architecture_diff/):
7
+ //
8
+ // open open base atlas in browser
9
+ // diff render paginated before/after viewer
10
+ // render force-regenerate HTML from current state
11
+ // feature add|set|remove feature lifecycle
12
+ // submodule add|set|remove sub-module lifecycle
13
+ // function add|remove function I/O rows
14
+ // variable add|remove variable rows
15
+ // dataflow add|remove|reorder ordered internal flow steps
16
+ // error add|remove error rows
17
+ // edge add|remove edges (intra-feature if both endpoints share a feature, otherwise cross-feature)
18
+ // meta set meta.title / meta.summary
19
+ // actor add|remove top-level actors
20
+ // validate schema + referential integrity check
21
+ // undo revert the most recent mutation
22
+ // help / --help / -h usage
23
+ //
24
+ // Global flags:
25
+ // --project <root> project root (default: nearest ancestor with resources/project-architecture/)
26
+ // --spec <spec_dir> spec directory; mutations go to <spec_dir>/architecture_diff/atlas/
27
+ // --no-render skip auto-render after a mutation
28
+ // --no-open for open/diff: skip launching the browser
29
+ // --out <dir> for diff: override viewer output directory
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+ const { spawn } = require('node:child_process');
34
+
35
+ const schema = require('./schema');
36
+ const stateLib = require('./state');
37
+ const renderLib = require('./render');
38
+
39
+ const ATLAS_REL = path.join('resources', 'project-architecture');
40
+ const ATLAS_INDEX_REL = path.join(ATLAS_REL, 'index.html');
41
+ const ATLAS_DIRNAME = stateLib.ATLAS_DIRNAME;
42
+ const DIFF_DIRNAME = 'architecture_diff';
43
+ const PLANS_REL = path.join('docs', 'plans');
44
+ const REMOVED_TXT = '_removed.txt';
45
+ const DEFAULT_DIFF_OUT_REL = path.join('.apollo-toolkit', 'architecture-diff');
46
+
47
+ const USAGE = `apltk architecture — declarative atlas CLI.
48
+
49
+ Usage:
50
+ apltk architecture [verb] [options]
51
+
52
+ Verbs:
53
+ open open the base atlas in a browser
54
+ diff render every architecture_diff/ overlay as a paginated viewer
55
+ render regenerate atlas HTML from the current state
56
+ feature add|set|remove manage feature modules
57
+ submodule add|set|remove manage sub-modules
58
+ function add|remove manage function I/O rows
59
+ variable add|remove manage variable rows
60
+ dataflow add|remove|reorder manage ordered internal flow steps
61
+ error add|remove manage error rows
62
+ edge add|remove manage call/return/data-row/failure edges
63
+ meta set edit meta.title / meta.summary
64
+ actor add|remove manage top-level actors
65
+ validate run schema + referential checks
66
+ undo revert the most recent mutation
67
+ help show this help
68
+
69
+ Global flags:
70
+ --project <root> project root (default: nearest ancestor with resources/project-architecture/)
71
+ --spec <spec_dir> mutations write to <spec_dir>/architecture_diff/atlas/
72
+ --no-render skip auto-render after a mutation
73
+ --no-open for open/diff: skip launching the browser
74
+ --out <dir> for diff: override viewer output directory
75
+
76
+ Examples:
77
+ apltk architecture feature add --slug register --title "User registration" --story "..."
78
+ apltk architecture submodule add --feature register --slug api --kind api --role "HTTP endpoint"
79
+ apltk architecture function add --feature register --submodule api --name handlePost --side network --purpose "..."
80
+ apltk architecture --spec docs/plans/2026-05-11/add-2fa submodule set --feature register --slug api --role "..."
81
+ apltk architecture validate
82
+ apltk architecture diff
83
+ `;
84
+
85
+ function openInBrowser(filePath) {
86
+ const platform = process.platform;
87
+ let command;
88
+ let args;
89
+ if (platform === 'darwin') { command = 'open'; args = [filePath]; }
90
+ else if (platform === 'win32') { command = 'cmd'; args = ['/c', 'start', '""', filePath]; }
91
+ else { command = 'xdg-open'; args = [filePath]; }
92
+ try {
93
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
94
+ child.on('error', () => {});
95
+ child.unref();
96
+ } catch (_e) { /* best effort */ }
97
+ }
98
+
99
+ function findProjectRoot(startDir) {
100
+ let dir = path.resolve(startDir);
101
+ while (true) {
102
+ if (fs.existsSync(path.join(dir, ATLAS_INDEX_REL))) return dir;
103
+ if (fs.existsSync(path.join(dir, ATLAS_REL, ATLAS_DIRNAME, stateLib.INDEX_FILE))) return dir;
104
+ const parent = path.dirname(dir);
105
+ if (parent === dir) return null;
106
+ dir = parent;
107
+ }
108
+ }
109
+
110
+ function splitList(value) {
111
+ if (value == null) return [];
112
+ return String(value).split(',').map((s) => s.trim()).filter(Boolean);
113
+ }
114
+
115
+ function parseFlags(args) {
116
+ const positional = [];
117
+ const flags = Object.create(null);
118
+ while (args.length > 0) {
119
+ const token = args.shift();
120
+ if (token === '--') { positional.push(...args); break; }
121
+ if (token.startsWith('--')) {
122
+ const eq = token.indexOf('=');
123
+ let name;
124
+ let value;
125
+ if (eq !== -1) { name = token.slice(2, eq); value = token.slice(eq + 1); }
126
+ else {
127
+ name = token.slice(2);
128
+ const nextIsValue = args.length > 0 && !args[0].startsWith('--');
129
+ const booleanFlags = new Set(['no-render', 'no-open', 'help', 'force']);
130
+ if (booleanFlags.has(name) || !nextIsValue) value = true;
131
+ else value = args.shift();
132
+ }
133
+ if (flags[name] !== undefined) {
134
+ flags[name] = Array.isArray(flags[name]) ? [...flags[name], value] : [flags[name], value];
135
+ } else {
136
+ flags[name] = value;
137
+ }
138
+ } else if (token === '-h') {
139
+ flags.help = true;
140
+ } else {
141
+ positional.push(token);
142
+ }
143
+ }
144
+ return { positional, flags };
145
+ }
146
+
147
+ function requireFlag(flags, name) {
148
+ if (flags[name] === undefined || flags[name] === null || flags[name] === true) {
149
+ throw new Error(`Missing required flag --${name}`);
150
+ }
151
+ return flags[name];
152
+ }
153
+
154
+ function resolveProjectRoot(flags) {
155
+ if (flags.project) return path.resolve(String(flags.project));
156
+ const root = findProjectRoot(process.cwd());
157
+ if (!root) throw new Error('Could not find resources/project-architecture/. Pass --project <root> or run inside the project.');
158
+ return root;
159
+ }
160
+
161
+ function specOverlayDir(projectRoot, specFlag) {
162
+ const specDir = path.isAbsolute(String(specFlag)) ? String(specFlag) : path.resolve(projectRoot, String(specFlag));
163
+ return { specDir, overlayDir: path.join(specDir, DIFF_DIRNAME, ATLAS_DIRNAME), htmlOutDir: path.join(specDir, DIFF_DIRNAME) };
164
+ }
165
+
166
+ function baseAtlasDir(projectRoot) {
167
+ return path.join(projectRoot, ATLAS_REL, ATLAS_DIRNAME);
168
+ }
169
+
170
+ function baseHtmlOutDir(projectRoot) {
171
+ return path.join(projectRoot, ATLAS_REL);
172
+ }
173
+
174
+ function loadResolvedState(projectRoot, flags) {
175
+ const base = stateLib.load(baseAtlasDir(projectRoot));
176
+ if (!flags.spec) return { base, merged: base, overlay: null };
177
+ const { overlayDir } = specOverlayDir(projectRoot, flags.spec);
178
+ const overlay = stateLib.loadOverlay(overlayDir);
179
+ const merged = stateLib.mergeOverlay(base, overlay);
180
+ return { base, merged, overlay };
181
+ }
182
+
183
+ function findFeature(state, slug) {
184
+ return (state.features || []).find((f) => f.slug === slug);
185
+ }
186
+
187
+ function findSubmodule(feature, slug) {
188
+ return ((feature && feature.submodules) || []).find((s) => s.slug === slug);
189
+ }
190
+
191
+ function ensureBaseAtlasDir(projectRoot) {
192
+ const dir = baseAtlasDir(projectRoot);
193
+ fs.mkdirSync(dir, { recursive: true });
194
+ }
195
+
196
+ async function performMutation(projectRoot, flags, action, args, mutate) {
197
+ const isSpec = Boolean(flags.spec);
198
+ const base = stateLib.load(baseAtlasDir(projectRoot));
199
+ let overlay = null;
200
+ let merged = base;
201
+ let touchedFeatureSlugs = new Set();
202
+
203
+ if (isSpec) {
204
+ const { overlayDir } = specOverlayDir(projectRoot, flags.spec);
205
+ overlay = stateLib.loadOverlay(overlayDir);
206
+ merged = stateLib.mergeOverlay(base, overlay);
207
+ const before = JSON.parse(JSON.stringify({ base, overlay }));
208
+ const result = mutate(merged, base, overlay) || {};
209
+ if (result.touchedFeatures) for (const slug of result.touchedFeatures) touchedFeatureSlugs.add(slug);
210
+ stateLib.writeUndoSnapshot(overlayDir, before);
211
+ syncOverlayFromMerged({ base, overlay, merged, touchedFeatureSlugs, removalsHint: result.removalsHint });
212
+ stateLib.saveOverlay(overlayDir, overlay);
213
+ stateLib.appendHistory(overlayDir, { action, args, mode: 'spec' });
214
+ } else {
215
+ ensureBaseAtlasDir(projectRoot);
216
+ const before = JSON.parse(JSON.stringify({ base }));
217
+ const result = mutate(base, base, null) || {};
218
+ if (result.touchedFeatures) for (const slug of result.touchedFeatures) touchedFeatureSlugs.add(slug);
219
+ stateLib.writeUndoSnapshot(baseAtlasDir(projectRoot), before);
220
+ stateLib.save(baseAtlasDir(projectRoot), base);
221
+ stateLib.appendHistory(baseAtlasDir(projectRoot), { action, args, mode: 'base' });
222
+ }
223
+
224
+ if (!flags['no-render']) {
225
+ await runRender({ projectRoot, flags });
226
+ }
227
+ }
228
+
229
+ function syncOverlayFromMerged({ base, overlay, merged, touchedFeatureSlugs, removalsHint }) {
230
+ // Compare merged top-level fields against base; if they differ, sync into overlay.
231
+ if (JSON.stringify(merged.meta || {}) !== JSON.stringify(base.meta || {})) overlay.meta = merged.meta;
232
+ if (JSON.stringify(merged.actors || []) !== JSON.stringify(base.actors || [])) overlay.actors = merged.actors || [];
233
+ if (JSON.stringify(merged.edges || []) !== JSON.stringify(base.edges || [])) overlay.edges = merged.edges || [];
234
+
235
+ const baseOrder = (base.features || []).map((f) => f.slug);
236
+ const mergedOrder = (merged.features || []).map((f) => f.slug);
237
+ if (JSON.stringify(baseOrder) !== JSON.stringify(mergedOrder)) {
238
+ overlay.featureOrder = mergedOrder;
239
+ }
240
+
241
+ // Persist touched (or simply differing) features into overlay.features
242
+ const baseFeatureMap = new Map((base.features || []).map((f) => [f.slug, f]));
243
+ const mergedFeatureMap = new Map((merged.features || []).map((f) => [f.slug, f]));
244
+ for (const slug of touchedFeatureSlugs) {
245
+ if (!mergedFeatureMap.has(slug)) continue;
246
+ const baseFeat = baseFeatureMap.get(slug);
247
+ const mergedFeat = mergedFeatureMap.get(slug);
248
+ if (!baseFeat || JSON.stringify(baseFeat) !== JSON.stringify(mergedFeat)) {
249
+ overlay.features[slug] = mergedFeat;
250
+ }
251
+ }
252
+ // Apply hinted removals
253
+ if (removalsHint) {
254
+ if (Array.isArray(removalsHint.features)) {
255
+ const seen = new Set(overlay.removed.features || []);
256
+ for (const slug of removalsHint.features) seen.add(slug);
257
+ overlay.removed.features = [...seen];
258
+ // also drop from overlay.features if present
259
+ for (const slug of removalsHint.features) delete overlay.features[slug];
260
+ }
261
+ if (Array.isArray(removalsHint.submodules)) {
262
+ const seen = new Map();
263
+ for (const item of overlay.removed.submodules || []) seen.set(`${item.feature}::${item.submodule}`, item);
264
+ for (const item of removalsHint.submodules) seen.set(`${item.feature}::${item.submodule}`, item);
265
+ overlay.removed.submodules = [...seen.values()];
266
+ }
267
+ }
268
+ }
269
+
270
+ async function runRender({ projectRoot, flags }) {
271
+ if (flags.spec) {
272
+ const { overlayDir, htmlOutDir } = specOverlayDir(projectRoot, flags.spec);
273
+ const base = stateLib.load(baseAtlasDir(projectRoot));
274
+ const overlay = stateLib.loadOverlay(overlayDir);
275
+ const merged = stateLib.mergeOverlay(base, overlay);
276
+ const diff = stateLib.diffPages(base, merged);
277
+ const scope = renderLib.scopeFromDiff(diff);
278
+ const removedPaths = renderLib.removedPagePathsFromDiff(diff);
279
+ fs.mkdirSync(htmlOutDir, { recursive: true });
280
+ return renderLib.renderAll({ outDir: htmlOutDir, state: merged, scope, removedPaths });
281
+ }
282
+ const state = stateLib.load(baseAtlasDir(projectRoot));
283
+ return renderLib.renderAll({ outDir: baseHtmlOutDir(projectRoot), state });
284
+ }
285
+
286
+ // ---- mutation helpers ---------------------------------------------------
287
+
288
+ function ensureFeature(state, slug, init) {
289
+ let feature = findFeature(state, slug);
290
+ if (!feature) {
291
+ feature = { slug, title: slug, story: '', dependsOn: [], submodules: [], edges: [], ...init };
292
+ state.features = state.features || [];
293
+ state.features.push(feature);
294
+ } else if (init) {
295
+ Object.assign(feature, init);
296
+ }
297
+ return feature;
298
+ }
299
+
300
+ function removeFeature(state, slug) {
301
+ if (!state.features) return false;
302
+ const before = state.features.length;
303
+ state.features = state.features.filter((f) => f.slug !== slug);
304
+ // also drop cross-feature edges that reference this slug
305
+ state.edges = (state.edges || []).filter((e) => !endpointReferences(e.from, slug) && !endpointReferences(e.to, slug));
306
+ return state.features.length < before;
307
+ }
308
+
309
+ function endpointReferences(endpoint, slug) {
310
+ if (!endpoint || typeof endpoint === 'string') return false;
311
+ return endpoint.feature === slug;
312
+ }
313
+
314
+ function ensureSubmodule(feature, slug, init) {
315
+ let sub = findSubmodule(feature, slug);
316
+ if (!sub) {
317
+ sub = { slug, kind: 'service', role: '', functions: [], variables: [], dataflow: [], errors: [], ...init };
318
+ feature.submodules = feature.submodules || [];
319
+ feature.submodules.push(sub);
320
+ } else if (init) {
321
+ Object.assign(sub, init);
322
+ }
323
+ return sub;
324
+ }
325
+
326
+ function removeSubmodule(feature, slug) {
327
+ if (!feature.submodules) return false;
328
+ const before = feature.submodules.length;
329
+ feature.submodules = feature.submodules.filter((s) => s.slug !== slug);
330
+ feature.edges = (feature.edges || []).filter((e) => {
331
+ const f = typeof e.from === 'string' ? e.from : e.from && e.from.submodule;
332
+ const t = typeof e.to === 'string' ? e.to : e.to && e.to.submodule;
333
+ return f !== slug && t !== slug;
334
+ });
335
+ return feature.submodules.length < before;
336
+ }
337
+
338
+ function parseEndpoint(value) {
339
+ // accepts "feature" or "feature/submodule"
340
+ const [feat, sub] = String(value).split('/').map((s) => s && s.trim()).filter(Boolean).concat([undefined])
341
+ .slice(0, 2);
342
+ if (!feat) throw new Error(`Invalid endpoint: ${value}`);
343
+ return sub ? { feature: feat, submodule: sub } : { feature: feat };
344
+ }
345
+
346
+ function isIntraFeatureEdge(from, to) {
347
+ return from && to && from.feature && to.feature && from.feature === to.feature && from.submodule && to.submodule;
348
+ }
349
+
350
+ // ---- verb dispatch ------------------------------------------------------
351
+
352
+ async function verbFeature(action, flags, projectRoot) {
353
+ const slug = String(requireFlag(flags, 'slug'));
354
+ if (action === 'add' || action === 'set') {
355
+ const init = {};
356
+ if (flags.title !== undefined) init.title = String(flags.title);
357
+ if (flags.story !== undefined) init.story = String(flags.story);
358
+ if (flags['depends-on'] !== undefined) init.dependsOn = splitList(flags['depends-on']);
359
+ return performMutation(projectRoot, flags, `feature ${action}`, { slug, ...init }, (state) => {
360
+ ensureFeature(state, slug, init);
361
+ return { touchedFeatures: new Set([slug]) };
362
+ });
363
+ }
364
+ if (action === 'remove') {
365
+ return performMutation(projectRoot, flags, 'feature remove', { slug }, (state) => {
366
+ removeFeature(state, slug);
367
+ return { removalsHint: { features: [slug] } };
368
+ });
369
+ }
370
+ throw new Error(`Unknown feature subverb: ${action}`);
371
+ }
372
+
373
+ async function verbSubmodule(action, flags, projectRoot) {
374
+ const featureSlug = String(requireFlag(flags, 'feature'));
375
+ const slug = String(requireFlag(flags, 'slug'));
376
+ if (action === 'add' || action === 'set') {
377
+ const init = {};
378
+ if (flags.kind !== undefined) init.kind = String(flags.kind);
379
+ if (flags.role !== undefined) init.role = String(flags.role);
380
+ return performMutation(projectRoot, flags, `submodule ${action}`, { feature: featureSlug, slug, ...init }, (state) => {
381
+ const feature = ensureFeature(state, featureSlug);
382
+ ensureSubmodule(feature, slug, init);
383
+ return { touchedFeatures: new Set([featureSlug]) };
384
+ });
385
+ }
386
+ if (action === 'remove') {
387
+ return performMutation(projectRoot, flags, 'submodule remove', { feature: featureSlug, slug }, (state) => {
388
+ const feature = findFeature(state, featureSlug);
389
+ if (feature) removeSubmodule(feature, slug);
390
+ return { touchedFeatures: new Set([featureSlug]), removalsHint: { submodules: [{ feature: featureSlug, submodule: slug }] } };
391
+ });
392
+ }
393
+ throw new Error(`Unknown submodule subverb: ${action}`);
394
+ }
395
+
396
+ async function verbFunction(action, flags, projectRoot) {
397
+ const featureSlug = String(requireFlag(flags, 'feature'));
398
+ const subSlug = String(requireFlag(flags, 'submodule'));
399
+ const name = String(requireFlag(flags, 'name'));
400
+ return performMutation(projectRoot, flags, `function ${action}`, { feature: featureSlug, submodule: subSlug, name }, (state) => {
401
+ const feature = ensureFeature(state, featureSlug);
402
+ const sub = ensureSubmodule(feature, subSlug);
403
+ if (action === 'add') {
404
+ sub.functions = (sub.functions || []).filter((f) => f.name !== name);
405
+ const fn = { name };
406
+ if (flags.in !== undefined) fn.in = String(flags.in);
407
+ if (flags.out !== undefined) fn.out = String(flags.out);
408
+ if (flags.side !== undefined) fn.side = String(flags.side);
409
+ if (flags.purpose !== undefined) fn.purpose = String(flags.purpose);
410
+ sub.functions.push(fn);
411
+ } else if (action === 'remove') {
412
+ sub.functions = (sub.functions || []).filter((f) => f.name !== name);
413
+ } else {
414
+ throw new Error(`Unknown function subverb: ${action}`);
415
+ }
416
+ return { touchedFeatures: new Set([featureSlug]) };
417
+ });
418
+ }
419
+
420
+ async function verbVariable(action, flags, projectRoot) {
421
+ const featureSlug = String(requireFlag(flags, 'feature'));
422
+ const subSlug = String(requireFlag(flags, 'submodule'));
423
+ const name = String(requireFlag(flags, 'name'));
424
+ return performMutation(projectRoot, flags, `variable ${action}`, { feature: featureSlug, submodule: subSlug, name }, (state) => {
425
+ const feature = ensureFeature(state, featureSlug);
426
+ const sub = ensureSubmodule(feature, subSlug);
427
+ if (action === 'add') {
428
+ sub.variables = (sub.variables || []).filter((v) => v.name !== name);
429
+ const v = { name };
430
+ if (flags.type !== undefined) v.type = String(flags.type);
431
+ if (flags.scope !== undefined) v.scope = String(flags.scope);
432
+ if (flags.purpose !== undefined) v.purpose = String(flags.purpose);
433
+ sub.variables.push(v);
434
+ } else if (action === 'remove') {
435
+ sub.variables = (sub.variables || []).filter((v) => v.name !== name);
436
+ } else {
437
+ throw new Error(`Unknown variable subverb: ${action}`);
438
+ }
439
+ return { touchedFeatures: new Set([featureSlug]) };
440
+ });
441
+ }
442
+
443
+ async function verbDataflow(action, flags, projectRoot) {
444
+ const featureSlug = String(requireFlag(flags, 'feature'));
445
+ const subSlug = String(requireFlag(flags, 'submodule'));
446
+ return performMutation(projectRoot, flags, `dataflow ${action}`, { feature: featureSlug, submodule: subSlug, step: flags.step, at: flags.at }, (state) => {
447
+ const feature = ensureFeature(state, featureSlug);
448
+ const sub = ensureSubmodule(feature, subSlug);
449
+ sub.dataflow = sub.dataflow || [];
450
+ if (action === 'add') {
451
+ const step = String(requireFlag(flags, 'step'));
452
+ const atRaw = flags.at;
453
+ if (atRaw !== undefined) {
454
+ const at = Number(atRaw);
455
+ if (!Number.isFinite(at) || at < 0) throw new Error('--at must be a non-negative integer');
456
+ sub.dataflow.splice(at, 0, step);
457
+ } else {
458
+ sub.dataflow.push(step);
459
+ }
460
+ } else if (action === 'remove') {
461
+ if (flags.at !== undefined) {
462
+ const at = Number(flags.at);
463
+ if (!Number.isFinite(at) || at < 0 || at >= sub.dataflow.length) throw new Error('--at out of range');
464
+ sub.dataflow.splice(at, 1);
465
+ } else {
466
+ const step = String(requireFlag(flags, 'step'));
467
+ sub.dataflow = sub.dataflow.filter((s) => s !== step);
468
+ }
469
+ } else if (action === 'reorder') {
470
+ const from = Number(requireFlag(flags, 'from'));
471
+ const to = Number(requireFlag(flags, 'to'));
472
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || to < 0 || from >= sub.dataflow.length || to >= sub.dataflow.length) {
473
+ throw new Error('--from / --to out of range');
474
+ }
475
+ const [moved] = sub.dataflow.splice(from, 1);
476
+ sub.dataflow.splice(to, 0, moved);
477
+ } else {
478
+ throw new Error(`Unknown dataflow subverb: ${action}`);
479
+ }
480
+ return { touchedFeatures: new Set([featureSlug]) };
481
+ });
482
+ }
483
+
484
+ async function verbError(action, flags, projectRoot) {
485
+ const featureSlug = String(requireFlag(flags, 'feature'));
486
+ const subSlug = String(requireFlag(flags, 'submodule'));
487
+ const name = String(requireFlag(flags, 'name'));
488
+ return performMutation(projectRoot, flags, `error ${action}`, { feature: featureSlug, submodule: subSlug, name }, (state) => {
489
+ const feature = ensureFeature(state, featureSlug);
490
+ const sub = ensureSubmodule(feature, subSlug);
491
+ if (action === 'add') {
492
+ sub.errors = (sub.errors || []).filter((e) => e.name !== name);
493
+ const err = { name };
494
+ if (flags.when !== undefined) err.when = String(flags.when);
495
+ if (flags.means !== undefined) err.means = String(flags.means);
496
+ sub.errors.push(err);
497
+ } else if (action === 'remove') {
498
+ sub.errors = (sub.errors || []).filter((e) => e.name !== name);
499
+ } else {
500
+ throw new Error(`Unknown error subverb: ${action}`);
501
+ }
502
+ return { touchedFeatures: new Set([featureSlug]) };
503
+ });
504
+ }
505
+
506
+ async function verbEdge(action, flags, projectRoot) {
507
+ const from = parseEndpoint(requireFlag(flags, 'from'));
508
+ const to = parseEndpoint(requireFlag(flags, 'to'));
509
+ return performMutation(projectRoot, flags, `edge ${action}`, { from, to, kind: flags.kind, label: flags.label, id: flags.id }, (state) => {
510
+ if (action === 'add') {
511
+ const edge = {
512
+ id: flags.id ? String(flags.id) : undefined,
513
+ from,
514
+ to,
515
+ kind: flags.kind ? String(flags.kind) : 'call',
516
+ label: flags.label !== undefined ? String(flags.label) : '',
517
+ };
518
+ if (!edge.id) edge.id = `e-${Math.random().toString(36).slice(2, 8)}`;
519
+ const intra = isIntraFeatureEdge(from, to);
520
+ if (intra) {
521
+ const feature = ensureFeature(state, from.feature);
522
+ feature.edges = feature.edges || [];
523
+ feature.edges = feature.edges.filter((e) => e.id !== edge.id);
524
+ feature.edges.push({
525
+ id: edge.id,
526
+ from: from.submodule,
527
+ to: to.submodule,
528
+ kind: edge.kind,
529
+ label: edge.label,
530
+ });
531
+ return { touchedFeatures: new Set([from.feature]) };
532
+ }
533
+ state.edges = state.edges || [];
534
+ state.edges = state.edges.filter((e) => e.id !== edge.id);
535
+ state.edges.push(edge);
536
+ return { touchedFeatures: new Set([from.feature, to.feature]) };
537
+ }
538
+ if (action === 'remove') {
539
+ const id = flags.id ? String(flags.id) : null;
540
+ const intra = isIntraFeatureEdge(from, to);
541
+ if (intra) {
542
+ const feature = findFeature(state, from.feature);
543
+ if (feature) {
544
+ feature.edges = (feature.edges || []).filter((e) => {
545
+ if (id && e.id === id) return false;
546
+ const f = typeof e.from === 'string' ? e.from : e.from && e.from.submodule;
547
+ const t = typeof e.to === 'string' ? e.to : e.to && e.to.submodule;
548
+ return !(f === from.submodule && t === to.submodule);
549
+ });
550
+ return { touchedFeatures: new Set([from.feature]) };
551
+ }
552
+ return { touchedFeatures: new Set([from.feature]) };
553
+ }
554
+ state.edges = (state.edges || []).filter((e) => {
555
+ if (id && e.id === id) return false;
556
+ return !(endpointEquals(e.from, from) && endpointEquals(e.to, to));
557
+ });
558
+ return { touchedFeatures: new Set([from.feature, to.feature]) };
559
+ }
560
+ throw new Error(`Unknown edge subverb: ${action}`);
561
+ });
562
+ }
563
+
564
+ function endpointEquals(a, b) {
565
+ if (typeof a === 'string' || typeof b === 'string') return false;
566
+ if (!a || !b) return false;
567
+ return a.feature === b.feature && (a.submodule || null) === (b.submodule || null);
568
+ }
569
+
570
+ async function verbMeta(action, flags, projectRoot) {
571
+ if (action !== 'set') throw new Error(`Unknown meta subverb: ${action}`);
572
+ const update = {};
573
+ if (flags.title !== undefined) update.title = String(flags.title);
574
+ if (flags.summary !== undefined) update.summary = String(flags.summary);
575
+ return performMutation(projectRoot, flags, 'meta set', update, (state) => {
576
+ state.meta = { ...state.meta, ...update };
577
+ });
578
+ }
579
+
580
+ async function verbActor(action, flags, projectRoot) {
581
+ const id = String(requireFlag(flags, 'id'));
582
+ return performMutation(projectRoot, flags, `actor ${action}`, { id, label: flags.label }, (state) => {
583
+ state.actors = state.actors || [];
584
+ if (action === 'add') {
585
+ state.actors = state.actors.filter((a) => a.id !== id);
586
+ state.actors.push({ id, label: flags.label !== undefined ? String(flags.label) : id });
587
+ } else if (action === 'remove') {
588
+ state.actors = state.actors.filter((a) => a.id !== id);
589
+ } else {
590
+ throw new Error(`Unknown actor subverb: ${action}`);
591
+ }
592
+ });
593
+ }
594
+
595
+ async function verbValidate(flags, projectRoot, io) {
596
+ const { merged } = loadResolvedState(projectRoot, flags);
597
+ const errors = schema.validate(merged);
598
+ if (errors.length === 0) {
599
+ io.stdout.write('atlas: OK\n');
600
+ return 0;
601
+ }
602
+ for (const err of errors) io.stderr.write(`${err}\n`);
603
+ return 1;
604
+ }
605
+
606
+ async function verbUndo(flags, projectRoot, io) {
607
+ const dir = flags.spec ? specOverlayDir(projectRoot, flags.spec).overlayDir : baseAtlasDir(projectRoot);
608
+ const snapshot = stateLib.readUndoSnapshot(dir);
609
+ if (!snapshot) {
610
+ io.stderr.write('No undo snapshot found.\n');
611
+ return 1;
612
+ }
613
+ if (flags.spec) {
614
+ const { overlayDir } = specOverlayDir(projectRoot, flags.spec);
615
+ stateLib.saveOverlay(overlayDir, snapshot.overlay);
616
+ stateLib.appendHistory(overlayDir, { action: 'undo', mode: 'spec' });
617
+ } else {
618
+ stateLib.save(baseAtlasDir(projectRoot), snapshot.base);
619
+ stateLib.appendHistory(baseAtlasDir(projectRoot), { action: 'undo', mode: 'base' });
620
+ }
621
+ stateLib.clearUndoSnapshot(dir);
622
+ if (!flags['no-render']) await runRender({ projectRoot, flags });
623
+ io.stdout.write('atlas: undo applied\n');
624
+ return 0;
625
+ }
626
+
627
+ async function verbOpen(flags, projectRoot, io) {
628
+ const atlas = path.join(projectRoot, ATLAS_INDEX_REL);
629
+ if (!fs.existsSync(atlas)) {
630
+ // Try to render fresh if state exists
631
+ const baseDir = baseAtlasDir(projectRoot);
632
+ if (fs.existsSync(path.join(baseDir, stateLib.INDEX_FILE))) {
633
+ await runRender({ projectRoot, flags: { ...flags, spec: undefined } });
634
+ }
635
+ }
636
+ if (!fs.existsSync(atlas)) {
637
+ io.stderr.write(`Atlas not found: ${atlas}\n`);
638
+ return 1;
639
+ }
640
+ io.stdout.write(`${atlas}\n`);
641
+ if (!flags['no-open']) openInBrowser(atlas);
642
+ return 0;
643
+ }
644
+
645
+ async function verbDiff(flags, projectRoot, io) {
646
+ const outDir = flags.out ? path.resolve(String(flags.out)) : path.join(projectRoot, DEFAULT_DIFF_OUT_REL);
647
+ fs.mkdirSync(outDir, { recursive: true });
648
+
649
+ const plansRoot = path.join(projectRoot, PLANS_REL);
650
+ const diffDirs = walkArchitectureDiffDirs(plansRoot);
651
+ const resourcesRoot = path.join(projectRoot, ATLAS_REL);
652
+ const changes = [];
653
+
654
+ for (const diffDir of diffDirs) {
655
+ const specDir = path.dirname(diffDir);
656
+ const specLabel = path.relative(projectRoot, specDir);
657
+ for (const after of walkAfterStateHtml(diffDir)) {
658
+ const beforeAbs = path.join(resourcesRoot, after.rel);
659
+ const beforeExists = fs.existsSync(beforeAbs);
660
+ changes.push({
661
+ kind: beforeExists ? 'modified' : 'added',
662
+ rel: after.rel,
663
+ spec: specLabel,
664
+ beforePath: beforeExists ? path.relative(projectRoot, beforeAbs) : null,
665
+ afterPath: path.relative(projectRoot, after.abs),
666
+ });
667
+ }
668
+ for (const removedRel of readRemovedManifest(diffDir)) {
669
+ const beforeAbs = path.join(resourcesRoot, removedRel);
670
+ if (!fs.existsSync(beforeAbs)) continue;
671
+ changes.push({
672
+ kind: 'removed',
673
+ rel: removedRel,
674
+ spec: specLabel,
675
+ beforePath: path.relative(projectRoot, beforeAbs),
676
+ afterPath: null,
677
+ });
678
+ }
679
+ }
680
+
681
+ changes.sort((a, b) => {
682
+ if (a.spec !== b.spec) return a.spec.localeCompare(b.spec);
683
+ if (a.kind !== b.kind) return a.kind.localeCompare(b.kind);
684
+ return a.rel.localeCompare(b.rel);
685
+ });
686
+
687
+ const html = renderDiffViewer({ changes, projectRoot, outDir });
688
+ const indexPath = path.join(outDir, 'index.html');
689
+ fs.writeFileSync(indexPath, html, 'utf8');
690
+ io.stdout.write(`${indexPath}\n`);
691
+ 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`);
692
+ if (!flags['no-open']) openInBrowser(indexPath);
693
+ return 0;
694
+ }
695
+
696
+ function walkArchitectureDiffDirs(plansRoot) {
697
+ const result = [];
698
+ if (!fs.existsSync(plansRoot)) return result;
699
+ function recurse(dir) {
700
+ let entries;
701
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
702
+ for (const entry of entries) {
703
+ if (!entry.isDirectory()) continue;
704
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
705
+ const full = path.join(dir, entry.name);
706
+ if (entry.name === DIFF_DIRNAME) { result.push(full); continue; }
707
+ recurse(full);
708
+ }
709
+ }
710
+ recurse(plansRoot);
711
+ return result;
712
+ }
713
+
714
+ function walkAfterStateHtml(diffDir) {
715
+ const out = [];
716
+ function recurse(dir, relParts) {
717
+ let entries;
718
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
719
+ for (const entry of entries) {
720
+ if (entry.name === 'assets') continue;
721
+ if (entry.name === ATLAS_DIRNAME) continue;
722
+ if (entry.name === REMOVED_TXT) continue;
723
+ if (entry.name.startsWith('.')) continue;
724
+ const full = path.join(dir, entry.name);
725
+ const nextRel = [...relParts, entry.name];
726
+ if (entry.isDirectory()) recurse(full, nextRel);
727
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
728
+ out.push({ abs: full, rel: nextRel.join('/') });
729
+ }
730
+ }
731
+ }
732
+ recurse(diffDir, []);
733
+ return out;
734
+ }
735
+
736
+ function readRemovedManifest(diffDir) {
737
+ const file = path.join(diffDir, REMOVED_TXT);
738
+ if (!fs.existsSync(file)) return [];
739
+ return fs.readFileSync(file, 'utf8')
740
+ .split(/\r?\n/)
741
+ .map((l) => l.trim())
742
+ .filter((l) => l && !l.startsWith('#'));
743
+ }
744
+
745
+ function htmlEscape(value) {
746
+ return String(value)
747
+ .replace(/&/g, '&amp;')
748
+ .replace(/</g, '&lt;')
749
+ .replace(/>/g, '&gt;')
750
+ .replace(/"/g, '&quot;')
751
+ .replace(/'/g, '&#39;');
752
+ }
753
+
754
+ function toViewerRel(outDir, projectRoot, projectRelPath) {
755
+ if (!projectRelPath) return null;
756
+ const absolute = path.resolve(projectRoot, projectRelPath);
757
+ const rel = path.relative(outDir, absolute);
758
+ return rel.split(path.sep).join('/');
759
+ }
760
+
761
+ function renderDiffViewer({ changes, projectRoot, outDir }) {
762
+ const pages = changes.map((change) => ({
763
+ kind: change.kind,
764
+ rel: change.rel,
765
+ spec: change.spec,
766
+ beforeSrc: toViewerRel(outDir, projectRoot, change.beforePath),
767
+ afterSrc: toViewerRel(outDir, projectRoot, change.afterPath),
768
+ }));
769
+ const summary = {
770
+ total: pages.length,
771
+ modified: pages.filter((p) => p.kind === 'modified').length,
772
+ added: pages.filter((p) => p.kind === 'added').length,
773
+ removed: pages.filter((p) => p.kind === 'removed').length,
774
+ projectRoot,
775
+ };
776
+ const payload = JSON.stringify({ pages, summary });
777
+
778
+ return `<!DOCTYPE html>
779
+ <html lang="en" data-atlas="diff-viewer">
780
+ <head>
781
+ <meta charset="utf-8">
782
+ <title>Architecture diff — ${htmlEscape(path.basename(projectRoot))}</title>
783
+ <style>
784
+ :root { color-scheme: light dark; --bg: #0f172a; --panel: #1e293b; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --added: #4ade80; --removed: #f87171; --modified: #facc15; }
785
+ * { box-sizing: border-box; }
786
+ html, body { height: 100%; margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
787
+ body { display: flex; flex-direction: column; min-height: 100vh; }
788
+ header { padding: 12px 20px; background: var(--panel); border-bottom: 1px solid #334155; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
789
+ header .title { font-size: 14px; color: var(--muted); }
790
+ header .title strong { color: var(--text); }
791
+ header .summary { display: flex; gap: 12px; font-size: 12px; color: var(--muted); }
792
+ header .summary span.count { font-weight: 600; }
793
+ header .summary .modified { color: var(--modified); }
794
+ header .summary .added { color: var(--added); }
795
+ header .summary .removed { color: var(--removed); }
796
+ main { flex: 1; display: flex; flex-direction: column; }
797
+ .meta { padding: 10px 20px; background: var(--bg); border-bottom: 1px solid #334155; display: flex; flex-wrap: wrap; gap: 16px; align-items: center; justify-content: space-between; font-size: 13px; }
798
+ .meta .left { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
799
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid currentColor; }
800
+ .badge.modified { color: var(--modified); } .badge.added { color: var(--added); } .badge.removed { color: var(--removed); }
801
+ .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); }
802
+ .spec { color: var(--muted); font-size: 12px; }
803
+ .nav { display: flex; align-items: center; gap: 8px; }
804
+ .nav button { background: transparent; color: var(--text); border: 1px solid #475569; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
805
+ .nav button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
806
+ .nav button:disabled { opacity: 0.4; cursor: not-allowed; }
807
+ .nav .counter { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 72px; text-align: center; }
808
+ .frames { flex: 1; display: grid; gap: 1px; background: #334155; padding: 1px; min-height: 0; }
809
+ .frames.split { grid-template-columns: 1fr 1fr; }
810
+ .frames.single { grid-template-columns: 1fr; }
811
+ .pane { background: #ffffff; display: flex; flex-direction: column; min-height: 0; }
812
+ .pane h2 { margin: 0; padding: 8px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; background: #f1f5f9; color: #1e293b; border-bottom: 1px solid #cbd5f5; display: flex; align-items: center; gap: 8px; }
813
+ .pane h2 .side-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: #cbd5f5; color: #1e293b; }
814
+ .pane h2.before .side-badge { background: #fee2e2; color: #991b1b; }
815
+ .pane h2.after .side-badge { background: #dcfce7; color: #166534; }
816
+ .pane iframe { flex: 1; width: 100%; border: 0; background: #ffffff; }
817
+ .empty { display: flex; align-items: center; justify-content: center; padding: 32px; font-size: 14px; color: var(--muted); }
818
+ footer { padding: 8px 20px; background: var(--panel); border-top: 1px solid #334155; font-size: 12px; color: var(--muted); }
819
+ footer kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #0f172a; padding: 1px 6px; border-radius: 4px; border: 1px solid #475569; }
820
+ </style>
821
+ </head>
822
+ <body>
823
+ <header>
824
+ <div class="title">Apollo Toolkit · <strong>architecture diff</strong> · ${htmlEscape(path.basename(projectRoot))}</div>
825
+ <div class="summary">
826
+ <span><span class="count">${summary.total}</span> change<span>${summary.total === 1 ? '' : 's'}</span></span>
827
+ <span class="modified"><span class="count">${summary.modified}</span> modified</span>
828
+ <span class="added"><span class="count">${summary.added}</span> added</span>
829
+ <span class="removed"><span class="count">${summary.removed}</span> removed</span>
830
+ </div>
831
+ </header>
832
+ <main>
833
+ <div class="meta">
834
+ <div class="left">
835
+ <span id="badge" class="badge modified">modified</span>
836
+ <span class="path" id="path">—</span>
837
+ <span class="spec" id="spec">—</span>
838
+ </div>
839
+ <div class="nav">
840
+ <button id="prev" type="button" aria-label="Previous change">← Prev</button>
841
+ <span class="counter" id="counter">0 / 0</span>
842
+ <button id="next" type="button" aria-label="Next change">Next →</button>
843
+ </div>
844
+ </div>
845
+ <div class="frames" id="frames">
846
+ <div class="empty" id="empty">No architecture diffs found under docs/plans/**/architecture_diff/.</div>
847
+ </div>
848
+ </main>
849
+ <footer>
850
+ Navigate with <kbd>←</kbd> / <kbd>→</kbd> or the buttons above. Each page pairs the current atlas (left) with the proposed-after HTML (right).
851
+ </footer>
852
+ <script id="__diff_payload" type="application/json">${payload.replace(/</g, '\\u003c')}</script>
853
+ <script>
854
+ (function () {
855
+ const data = JSON.parse(document.getElementById('__diff_payload').textContent);
856
+ const pages = data.pages || [];
857
+ const framesEl = document.getElementById('frames');
858
+ const emptyEl = document.getElementById('empty');
859
+ const badgeEl = document.getElementById('badge');
860
+ const pathEl = document.getElementById('path');
861
+ const specEl = document.getElementById('spec');
862
+ const counterEl = document.getElementById('counter');
863
+ const prevBtn = document.getElementById('prev');
864
+ const nextBtn = document.getElementById('next');
865
+ if (pages.length === 0) { counterEl.textContent = '0 / 0'; prevBtn.disabled = true; nextBtn.disabled = true; return; }
866
+ let index = 0;
867
+ function render() {
868
+ const page = pages[index];
869
+ badgeEl.className = 'badge ' + page.kind;
870
+ badgeEl.textContent = page.kind;
871
+ pathEl.textContent = page.rel;
872
+ specEl.textContent = page.spec;
873
+ counterEl.textContent = (index + 1) + ' / ' + pages.length;
874
+ prevBtn.disabled = index === 0;
875
+ nextBtn.disabled = index === pages.length - 1;
876
+ framesEl.innerHTML = '';
877
+ if (page.kind === 'modified') {
878
+ framesEl.className = 'frames split';
879
+ framesEl.appendChild(buildPane('Before', page.beforeSrc, 'before'));
880
+ framesEl.appendChild(buildPane('After', page.afterSrc, 'after'));
881
+ } else if (page.kind === 'added') {
882
+ framesEl.className = 'frames single';
883
+ framesEl.appendChild(buildPane('After (new)', page.afterSrc, 'after'));
884
+ } else if (page.kind === 'removed') {
885
+ framesEl.className = 'frames single';
886
+ framesEl.appendChild(buildPane('Before (removed)', page.beforeSrc, 'before'));
887
+ }
888
+ }
889
+ function buildPane(label, src, side) {
890
+ const pane = document.createElement('div');
891
+ pane.className = 'pane';
892
+ const heading = document.createElement('h2');
893
+ heading.className = side;
894
+ const sideBadge = document.createElement('span');
895
+ sideBadge.className = 'side-badge';
896
+ sideBadge.textContent = side;
897
+ heading.appendChild(sideBadge);
898
+ heading.appendChild(document.createTextNode(' ' + label));
899
+ pane.appendChild(heading);
900
+ const frame = document.createElement('iframe');
901
+ frame.src = src;
902
+ frame.loading = 'lazy';
903
+ frame.title = label;
904
+ pane.appendChild(frame);
905
+ return pane;
906
+ }
907
+ prevBtn.addEventListener('click', () => { if (index > 0) { index--; render(); } });
908
+ nextBtn.addEventListener('click', () => { if (index < pages.length - 1) { index++; render(); } });
909
+ document.addEventListener('keydown', (event) => {
910
+ if (event.key === 'ArrowLeft') prevBtn.click();
911
+ else if (event.key === 'ArrowRight') nextBtn.click();
912
+ });
913
+ emptyEl.remove();
914
+ render();
915
+ })();
916
+ </script>
917
+ </body>
918
+ </html>
919
+ `;
920
+ }
921
+
922
+ async function dispatch(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
923
+ const args = [...argv];
924
+ let verb = 'open';
925
+ if (args.length > 0 && !args[0].startsWith('-')) {
926
+ verb = args.shift();
927
+ }
928
+ let subverb = null;
929
+ const multiVerbs = new Set(['feature', 'submodule', 'function', 'variable', 'dataflow', 'error', 'edge', 'meta', 'actor']);
930
+ if (multiVerbs.has(verb) && args.length > 0 && !args[0].startsWith('-')) {
931
+ subverb = args.shift();
932
+ }
933
+ const { flags } = parseFlags(args);
934
+
935
+ if (verb === 'help' || verb === '--help' || verb === '-h' || flags.help) {
936
+ io.stdout.write(`${USAGE}\n`);
937
+ return 0;
938
+ }
939
+
940
+ let projectRoot;
941
+ try {
942
+ projectRoot = resolveProjectRoot(flags);
943
+ } catch (e) {
944
+ io.stderr.write(`${e.message}\n\n${USAGE}\n`);
945
+ return 1;
946
+ }
947
+
948
+ try {
949
+ switch (verb) {
950
+ case 'open': return await verbOpen(flags, projectRoot, io);
951
+ case 'diff': return await verbDiff(flags, projectRoot, io);
952
+ case 'render':
953
+ await runRender({ projectRoot, flags });
954
+ io.stdout.write(`atlas: rendered\n`);
955
+ return 0;
956
+ case 'validate': return await verbValidate(flags, projectRoot, io);
957
+ case 'undo': return await verbUndo(flags, projectRoot, io);
958
+ case 'feature': await verbFeature(subverb, flags, projectRoot); break;
959
+ case 'submodule': await verbSubmodule(subverb, flags, projectRoot); break;
960
+ case 'function': await verbFunction(subverb, flags, projectRoot); break;
961
+ case 'variable': await verbVariable(subverb, flags, projectRoot); break;
962
+ case 'dataflow': await verbDataflow(subverb, flags, projectRoot); break;
963
+ case 'error': await verbError(subverb, flags, projectRoot); break;
964
+ case 'edge': await verbEdge(subverb, flags, projectRoot); break;
965
+ case 'meta': await verbMeta(subverb, flags, projectRoot); break;
966
+ case 'actor': await verbActor(subverb, flags, projectRoot); break;
967
+ default:
968
+ io.stderr.write(`Unknown verb: ${verb}\n\n${USAGE}\n`);
969
+ return 1;
970
+ }
971
+ io.stdout.write(`atlas: ${verb}${subverb ? ` ${subverb}` : ''} applied\n`);
972
+ return 0;
973
+ } catch (e) {
974
+ io.stderr.write(`${e.message}\n`);
975
+ return 1;
976
+ }
977
+ }
978
+
979
+ module.exports = {
980
+ USAGE,
981
+ dispatch,
982
+ parseFlags,
983
+ findProjectRoot,
984
+ resolveProjectRoot,
985
+ loadResolvedState,
986
+ baseAtlasDir,
987
+ baseHtmlOutDir,
988
+ specOverlayDir,
989
+ runRender,
990
+ walkArchitectureDiffDirs,
991
+ walkAfterStateHtml,
992
+ readRemovedManifest,
993
+ renderDiffViewer,
994
+ toViewerRel,
995
+ };