@topogram/cli 0.3.62 → 0.3.64

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 (121) hide show
  1. package/package.json +1 -1
  2. package/src/adoption/plan.d.ts +6 -0
  3. package/src/adoption/reporting.d.ts +10 -0
  4. package/src/adoption/review-groups.d.ts +6 -0
  5. package/src/agent-brief.d.ts +3 -0
  6. package/src/agent-brief.js +495 -0
  7. package/src/agent-ops/query-builders.d.ts +26 -0
  8. package/src/archive/archive.d.ts +2 -0
  9. package/src/archive/compact.d.ts +1 -0
  10. package/src/archive/unarchive.d.ts +1 -0
  11. package/src/catalog.d.ts +10 -0
  12. package/src/catalog.js +62 -66
  13. package/src/cli/catalog-alias.d.ts +1 -0
  14. package/src/cli/command-parser.js +38 -0
  15. package/src/cli/command-parsers/core.js +102 -0
  16. package/src/cli/command-parsers/generator.js +39 -0
  17. package/src/cli/command-parsers/import.js +44 -0
  18. package/src/cli/command-parsers/legacy-workflow.js +21 -0
  19. package/src/cli/command-parsers/project.js +47 -0
  20. package/src/cli/command-parsers/sdlc.js +47 -0
  21. package/src/cli/command-parsers/shared.js +51 -0
  22. package/src/cli/command-parsers/template.js +48 -0
  23. package/src/cli/commands/agent.js +47 -0
  24. package/src/cli/commands/catalog.js +617 -0
  25. package/src/cli/commands/check.js +268 -0
  26. package/src/cli/commands/doctor.js +268 -0
  27. package/src/cli/commands/emit.js +149 -0
  28. package/src/cli/commands/generate.js +96 -0
  29. package/src/cli/commands/generator-policy.js +785 -0
  30. package/src/cli/commands/generator.js +443 -0
  31. package/src/cli/commands/import-runner.js +157 -0
  32. package/src/cli/commands/import.js +1734 -0
  33. package/src/cli/commands/inspect.js +55 -0
  34. package/src/cli/commands/new.js +94 -0
  35. package/src/cli/commands/package.js +815 -0
  36. package/src/cli/commands/query.js +1302 -0
  37. package/src/cli/commands/release-rollout.js +257 -0
  38. package/src/cli/commands/release-shared.js +528 -0
  39. package/src/cli/commands/release-status.js +429 -0
  40. package/src/cli/commands/release.js +107 -0
  41. package/src/cli/commands/sdlc.js +168 -0
  42. package/src/cli/commands/setup.js +76 -0
  43. package/src/cli/commands/source.js +291 -0
  44. package/src/cli/commands/template-runner.js +198 -0
  45. package/src/cli/commands/template.js +2145 -0
  46. package/src/cli/commands/trust.js +219 -0
  47. package/src/cli/commands/version.js +40 -0
  48. package/src/cli/commands/widget.js +168 -0
  49. package/src/cli/commands/workflow.js +63 -0
  50. package/src/cli/dispatcher.js +392 -0
  51. package/src/cli/help-dispatch.js +188 -0
  52. package/src/cli/help.js +296 -0
  53. package/src/cli/migration-guidance.js +59 -0
  54. package/src/cli/options.js +96 -0
  55. package/src/cli/output-safety.js +107 -0
  56. package/src/cli/path-normalization.js +29 -0
  57. package/src/cli.js +47 -11711
  58. package/src/example-implementation.d.ts +2 -0
  59. package/src/format.d.ts +1 -0
  60. package/src/generator/check.d.ts +1 -0
  61. package/src/generator/context/bundle.d.ts +1 -0
  62. package/src/generator/context/shared.d.ts +2 -0
  63. package/src/generator/native/parity-bundle.js +2 -1
  64. package/src/generator/surfaces/web/html-escape.js +22 -0
  65. package/src/generator/surfaces/web/react.js +10 -8
  66. package/src/generator/surfaces/web/sveltekit.js +7 -5
  67. package/src/generator/surfaces/web/vanilla.js +8 -4
  68. package/src/generator.d.ts +2 -0
  69. package/src/github-client.js +520 -0
  70. package/src/import/core/shared.js +20 -62
  71. package/src/import/extractors/api/flutter-dio.js +4 -8
  72. package/src/import/extractors/api/react-native-repository.js +4 -8
  73. package/src/import/index.d.ts +4 -0
  74. package/src/import/provenance.d.ts +4 -0
  75. package/src/new-project.js +100 -11
  76. package/src/npm-safety.js +79 -0
  77. package/src/parser.d.ts +1 -0
  78. package/src/path-helpers.d.ts +1 -0
  79. package/src/path-helpers.js +20 -0
  80. package/src/project-config.js +1 -0
  81. package/src/reconcile/docs.d.ts +8 -0
  82. package/src/reconcile/journeys.d.ts +1 -0
  83. package/src/resolver.d.ts +1 -0
  84. package/src/runtime-support.js +29 -0
  85. package/src/sdlc/adopt.d.ts +1 -0
  86. package/src/sdlc/check.d.ts +1 -0
  87. package/src/sdlc/explain.d.ts +1 -0
  88. package/src/sdlc/release.d.ts +1 -0
  89. package/src/sdlc/scaffold.d.ts +1 -0
  90. package/src/sdlc/transition.d.ts +1 -0
  91. package/src/text-helpers.d.ts +6 -0
  92. package/src/text-helpers.js +245 -0
  93. package/src/topogram-config.js +306 -0
  94. package/src/validator.d.ts +2 -0
  95. package/src/workflows/adoption/index.js +26 -0
  96. package/src/workflows/docs-generate.js +262 -0
  97. package/src/workflows/docs-scan.js +703 -0
  98. package/src/workflows/docs.js +15 -0
  99. package/src/workflows/import-app/api.js +799 -0
  100. package/src/workflows/import-app/db.js +538 -0
  101. package/src/workflows/import-app/index.js +30 -0
  102. package/src/workflows/import-app/shared.js +218 -0
  103. package/src/workflows/import-app/ui.js +443 -0
  104. package/src/workflows/import-app/workflow.js +159 -0
  105. package/src/workflows/reconcile/adoption-plan.js +742 -0
  106. package/src/workflows/reconcile/auth.js +692 -0
  107. package/src/workflows/reconcile/bundle-core.js +600 -0
  108. package/src/workflows/reconcile/bundle-shared.js +75 -0
  109. package/src/workflows/reconcile/candidate-model.js +477 -0
  110. package/src/workflows/reconcile/canonical-surface.js +264 -0
  111. package/src/workflows/reconcile/gap-report.js +333 -0
  112. package/src/workflows/reconcile/ids.js +6 -0
  113. package/src/workflows/reconcile/impacts.js +625 -0
  114. package/src/workflows/reconcile/index.js +7 -0
  115. package/src/workflows/reconcile/renderers.js +461 -0
  116. package/src/workflows/reconcile/summary.js +90 -0
  117. package/src/workflows/reconcile/workflow.js +309 -0
  118. package/src/workflows/shared.js +189 -0
  119. package/src/workflows/types.d.ts +93 -0
  120. package/src/workflows.d.ts +1 -0
  121. package/src/workflows.js +10 -7652
@@ -0,0 +1,1734 @@
1
+ // @ts-check
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { buildOutputFiles } from "../../generator.js";
9
+ import { stableStringify } from "../../format.js";
10
+ import { parsePath } from "../../parser.js";
11
+ import { resolveWorkspace } from "../../resolver.js";
12
+ import { formatValidationErrors, validateWorkspace } from "../../validator.js";
13
+ import { runWorkflow } from "../../workflows.js";
14
+ import {
15
+ buildTopogramImportStatus,
16
+ collectImportSourceFileRecords,
17
+ TOPOGRAM_IMPORT_FILE,
18
+ writeTopogramImportRecord
19
+ } from "../../import/provenance.js";
20
+ import {
21
+ loadProjectConfig,
22
+ validateProjectConfig,
23
+ validateProjectOutputOwnership
24
+ } from "../../project-config.js";
25
+ import { validateProjectImplementationTrust } from "../../template-trust.js";
26
+ import { shellCommandArg } from "./catalog.js";
27
+ import { CLI_PACKAGE_NAME, readInstalledCliPackageVersion } from "./package.js";
28
+
29
+ const TOPOGRAM_IMPORT_ADOPTIONS_FILE = ".topogram-import-adoptions.jsonl";
30
+
31
+ /**
32
+ * @typedef {Record<string, any>} AnyRecord
33
+ */
34
+
35
+ /**
36
+ * @param {string} inputPath
37
+ * @returns {string}
38
+ */
39
+ function normalizeTopogramPath(inputPath) {
40
+ const absolute = path.resolve(inputPath);
41
+ if (path.basename(absolute) === "topogram") {
42
+ return absolute;
43
+ }
44
+ const candidate = path.join(absolute, "topogram");
45
+ return fs.existsSync(candidate) ? candidate : absolute;
46
+ }
47
+
48
+ /**
49
+ * @param {string} inputPath
50
+ * @returns {string}
51
+ */
52
+ function normalizeProjectRoot(inputPath) {
53
+ const absolute = path.resolve(inputPath);
54
+ if (path.basename(absolute) === "topogram") {
55
+ return path.dirname(absolute);
56
+ }
57
+ return absolute;
58
+ }
59
+
60
+ /**
61
+ * @param {...{ ok: boolean, errors?: any[] }|null|undefined} results
62
+ * @returns {{ ok: boolean, errors: any[] }}
63
+ */
64
+ function combineProjectValidationResults(...results) {
65
+ const errors = [];
66
+ for (const result of results) {
67
+ errors.push(...(result?.errors || []));
68
+ }
69
+ return {
70
+ ok: errors.length === 0,
71
+ errors
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {AnyRecord} component
77
+ * @returns {{ uses_api: string|null, uses_database: string|null }}
78
+ */
79
+ function topologyComponentReferences(component) {
80
+ return {
81
+ uses_api: component.uses_api || null,
82
+ uses_database: component.uses_database || null
83
+ };
84
+ }
85
+
86
+ /**
87
+ * @param {AnyRecord} component
88
+ * @returns {any}
89
+ */
90
+ function topologyComponentPort(component) {
91
+ return Object.prototype.hasOwnProperty.call(component, "port") ? component.port : null;
92
+ }
93
+
94
+ /**
95
+ * @param {AnyRecord|null|undefined} config
96
+ * @returns {{ outputs: any[], runtimes: any[], edges: any[] }}
97
+ */
98
+ function summarizeProjectTopology(config) {
99
+ const outputs = Object.entries(config?.outputs || {})
100
+ .map(([name, output]) => ({
101
+ name,
102
+ path: output?.path || null,
103
+ ownership: output?.ownership || null
104
+ }))
105
+ .sort((left, right) => left.name.localeCompare(right.name));
106
+ const runtimes = (config?.topology?.runtimes || [])
107
+ .map((/** @type {AnyRecord} */ component) => ({
108
+ id: component.id,
109
+ kind: component.kind,
110
+ projection: component.projection,
111
+ generator: {
112
+ id: component.generator?.id || null,
113
+ version: component.generator?.version || null
114
+ },
115
+ port: topologyComponentPort(component),
116
+ references: topologyComponentReferences(component)
117
+ }))
118
+ .sort((/** @type {AnyRecord} */ left, /** @type {AnyRecord} */ right) => left.id.localeCompare(right.id));
119
+ const edges = runtimes.flatMap((/** @type {AnyRecord} */ component) => {
120
+ const references = [];
121
+ if (component.references.uses_api) {
122
+ references.push({
123
+ from: component.id,
124
+ to: component.references.uses_api,
125
+ type: "calls_api"
126
+ });
127
+ }
128
+ if (component.references.uses_database) {
129
+ references.push({
130
+ from: component.id,
131
+ to: component.references.uses_database,
132
+ type: "uses_database"
133
+ });
134
+ }
135
+ return references;
136
+ }).sort((/** @type {AnyRecord} */ left, /** @type {AnyRecord} */ right) => `${left.from}:${left.type}:${left.to}`.localeCompare(`${right.from}:${right.type}:${right.to}`));
137
+ return {
138
+ outputs,
139
+ runtimes,
140
+ edges
141
+ };
142
+ }
143
+
144
+ /**
145
+ * @param {AnyRecord|null|undefined} topology
146
+ * @returns {AnyRecord|null}
147
+ */
148
+ function publicProjectTopology(topology) {
149
+ if (!topology || typeof topology !== "object") {
150
+ return topology || null;
151
+ }
152
+ return {
153
+ ...Object.fromEntries(Object.entries(topology).filter(([key]) => key !== "components")),
154
+ runtimes: topology.runtimes || []
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {{ inputPath: string, ast: AnyRecord, resolved: AnyRecord, projectConfigInfo: AnyRecord|null, projectValidation: { ok: boolean, errors: any[] } }} input
160
+ * @returns {AnyRecord}
161
+ */
162
+ function checkSummaryPayload({ inputPath, ast, resolved, projectConfigInfo, projectValidation }) {
163
+ const statementCount = ast.files.flatMap((/** @type {{ statements: any[] }} */ file) => file.statements).length;
164
+ const projectInfo = projectConfigInfo || {
165
+ configPath: null,
166
+ compatibility: false,
167
+ config: { topology: null }
168
+ };
169
+ const resolvedTopology = summarizeProjectTopology(projectInfo.config);
170
+ return {
171
+ ok: resolved.ok && projectValidation.ok,
172
+ inputPath,
173
+ topogram: {
174
+ files: ast.files.length,
175
+ statements: statementCount,
176
+ valid: resolved.ok
177
+ },
178
+ project: {
179
+ configPath: projectInfo.configPath,
180
+ compatibility: Boolean(projectInfo.compatibility),
181
+ valid: projectValidation.ok,
182
+ topology: publicProjectTopology(projectInfo.config.topology),
183
+ resolvedTopology
184
+ },
185
+ errors: [
186
+ ...(resolved.ok ? [] : resolved.validation.errors.map((/** @type {AnyRecord} */ error) => ({
187
+ source: "topogram",
188
+ message: error.message,
189
+ loc: error.loc
190
+ }))),
191
+ ...projectValidation.errors.map((error) => ({
192
+ source: "project",
193
+ message: error.message,
194
+ loc: error.loc
195
+ }))
196
+ ]
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @param {string} filePath
202
+ * @returns {{ sha256: string, size: number }}
203
+ */
204
+ function projectFileHash(filePath) {
205
+ const bytes = fs.readFileSync(filePath);
206
+ return {
207
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
208
+ size: bytes.length
209
+ };
210
+ }
211
+
212
+ export function printImportHelp() {
213
+ console.log("Usage: topogram import <app-path> --out <target> [--from <track[,track]>] [--json]");
214
+ console.log(" or: topogram import refresh [path] [--from <app-path>] [--dry-run] [--json]");
215
+ console.log(" or: topogram import diff [path] [--json]");
216
+ console.log(" or: topogram import check [path] [--json]");
217
+ console.log(" or: topogram import plan [path] [--json]");
218
+ console.log(" or: topogram import adopt --list [path] [--json]");
219
+ console.log(" or: topogram import adopt <selector> [path] [--dry-run|--write] [--force --reason <text>] [--json]");
220
+ console.log(" or: topogram import status [path] [--json]");
221
+ console.log(" or: topogram import history [path] [--verify] [--json]");
222
+ console.log("");
223
+ console.log("Creates an editable Topogram workspace from a brownfield app without modifying the app.");
224
+ console.log("");
225
+ console.log("Behavior:");
226
+ console.log(" - writes raw import candidates under topogram/candidates/app");
227
+ console.log(" - writes reconcile proposal bundles under topogram/candidates/reconcile");
228
+ console.log(" - writes topogram.project.json with maintained ownership and no generated stack binding");
229
+ console.log(` - writes ${TOPOGRAM_IMPORT_FILE} with source file hashes from import time`);
230
+ console.log(" - imported Topogram artifacts are project-owned after creation");
231
+ console.log(" - refresh rewrites only candidate/reconcile artifacts and source provenance");
232
+ console.log(" - adoption previews never write canonical Topogram files unless --write is passed");
233
+ console.log(" - adoption writes refuse dirty brownfield source provenance unless --force is passed");
234
+ console.log(` - adoption writes append audit receipts to ${TOPOGRAM_IMPORT_ADOPTIONS_FILE}`);
235
+ console.log(" - forced adoption writes require --reason <text>");
236
+ console.log("");
237
+ console.log("Examples:");
238
+ console.log(" topogram import ./existing-app --out ./imported-topogram");
239
+ console.log(" topogram import ./existing-app --out ./imported-topogram --from db,api,ui");
240
+ console.log(" topogram import diff ./imported-topogram");
241
+ console.log(" topogram import refresh ./imported-topogram --from ./existing-app --dry-run");
242
+ console.log(" topogram import refresh ./imported-topogram --from ./existing-app");
243
+ console.log(" topogram import check ./imported-topogram");
244
+ console.log(" topogram import plan ./imported-topogram");
245
+ console.log(" topogram import adopt --list ./imported-topogram");
246
+ console.log(" topogram import adopt bundle:task ./imported-topogram --dry-run");
247
+ console.log(" topogram import adopt bundle:task ./imported-topogram --write");
248
+ console.log(" topogram import adopt bundle:task ./imported-topogram --write --force --reason \"Reviewed source drift\"");
249
+ console.log(" topogram import status ./imported-topogram");
250
+ console.log(" topogram import history ./imported-topogram");
251
+ console.log(" topogram import history ./imported-topogram --verify");
252
+ console.log(" topogram import check --json");
253
+ }
254
+
255
+ /**
256
+ * @param {string} targetPath
257
+ * @returns {void}
258
+ */
259
+ function ensureEmptyImportTarget(targetPath) {
260
+ if (!fs.existsSync(targetPath)) {
261
+ fs.mkdirSync(targetPath, { recursive: true });
262
+ return;
263
+ }
264
+ if (!fs.statSync(targetPath).isDirectory()) {
265
+ throw new Error(`Cannot import into non-directory path '${targetPath}'.`);
266
+ }
267
+ const entries = fs.readdirSync(targetPath).filter((/** @type {string} */ entry) => entry !== ".DS_Store");
268
+ if (entries.length > 0) {
269
+ throw new Error(`Refusing to import into non-empty directory '${targetPath}'.`);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * @param {string} outDir
275
+ * @param {Record<string, any>} files
276
+ * @returns {string[]}
277
+ */
278
+ function writeRelativeFiles(outDir, files) {
279
+ const written = [];
280
+ for (const [relativePath, contents] of Object.entries(files || {})) {
281
+ const normalizedRelativePath = relativePath.replaceAll(path.sep, "/");
282
+ const destination = path.join(outDir, normalizedRelativePath);
283
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
284
+ fs.writeFileSync(destination, typeof contents === "string" ? contents : `${stableStringify(contents)}\n`, "utf8");
285
+ written.push(normalizedRelativePath);
286
+ }
287
+ return written.sort((a, b) => a.localeCompare(b));
288
+ }
289
+
290
+ /**
291
+ * @returns {Record<string, any>}
292
+ */
293
+ function importedProjectConfig() {
294
+ return {
295
+ version: "0.1",
296
+ outputs: {
297
+ maintained_app: {
298
+ path: "./app",
299
+ ownership: "maintained"
300
+ }
301
+ },
302
+ topology: {
303
+ runtimes: []
304
+ }
305
+ };
306
+ }
307
+
308
+ /**
309
+ * @param {string} sourceRoot
310
+ * @param {string} targetRoot
311
+ * @param {ReturnType<typeof runWorkflow>["summary"]} importSummary
312
+ * @returns {string}
313
+ */
314
+ function importedWorkspaceReadme(sourceRoot, targetRoot, importSummary) {
315
+ return [
316
+ "# Imported Topogram Workspace",
317
+ "",
318
+ "This workspace was created from a brownfield app import.",
319
+ "",
320
+ `- Imported source: \`${sourceRoot}\``,
321
+ `- Target workspace: \`${targetRoot}\``,
322
+ `- Tracks: ${(importSummary.tracks || []).join(", ") || "none"}`,
323
+ `- Provenance: \`${TOPOGRAM_IMPORT_FILE}\``,
324
+ "",
325
+ "Imported Topogram artifacts are project-owned after creation. Edit them directly, promote candidates deliberately, and run `topogram check` before generation or maintained-app work.",
326
+ "",
327
+ "Useful commands:",
328
+ "",
329
+ "```sh",
330
+ "topogram import check",
331
+ "topogram check",
332
+ "topogram query import-plan ./topogram",
333
+ "```",
334
+ ""
335
+ ].join("\n");
336
+ }
337
+
338
+ /**
339
+ * @param {Record<string, any>} summary
340
+ * @returns {Record<string, number>}
341
+ */
342
+ function importCandidateCounts(summary) {
343
+ const candidates = summary.candidates || {};
344
+ return {
345
+ dbEntities: candidates.db?.entities?.length || 0,
346
+ dbEnums: candidates.db?.enums?.length || 0,
347
+ apiCapabilities: candidates.api?.capabilities?.length || 0,
348
+ apiRoutes: candidates.api?.routes?.length || 0,
349
+ uiScreens: candidates.ui?.screens?.length || 0,
350
+ uiRoutes: candidates.ui?.routes?.length || 0,
351
+ uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
352
+ workflows: candidates.workflows?.workflows?.length || 0,
353
+ verifications: candidates.verification?.verifications?.length || 0
354
+ };
355
+ }
356
+
357
+ /**
358
+ * @param {string} rootPath
359
+ * @returns {number}
360
+ */
361
+ function countFilesRecursive(rootPath) {
362
+ if (!fs.existsSync(rootPath)) {
363
+ return 0;
364
+ }
365
+ let count = 0;
366
+ for (const entry of fs.readdirSync(rootPath, { withFileTypes: true })) {
367
+ const childPath = path.join(rootPath, entry.name);
368
+ if (entry.isDirectory()) {
369
+ count += countFilesRecursive(childPath);
370
+ } else if (entry.isFile()) {
371
+ count += 1;
372
+ }
373
+ }
374
+ return count;
375
+ }
376
+
377
+ /**
378
+ * @param {string} projectRoot
379
+ * @returns {{ path: string, record: Record<string, any> }}
380
+ */
381
+ function readTopogramImportRecord(projectRoot) {
382
+ const importPath = path.join(normalizeProjectRoot(projectRoot), TOPOGRAM_IMPORT_FILE);
383
+ if (!fs.existsSync(importPath)) {
384
+ throw new Error(`No brownfield import provenance found at '${importPath}'. Run 'topogram import <app-path> --out <target>' first.`);
385
+ }
386
+ try {
387
+ return { path: importPath, record: JSON.parse(fs.readFileSync(importPath, "utf8")) };
388
+ } catch (error) {
389
+ throw new Error(`Invalid brownfield import provenance JSON at '${importPath}'.`);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * @param {Record<string, any>} importRecord
395
+ * @returns {string|null}
396
+ */
397
+ function importTrackValueFromRecord(importRecord) {
398
+ const tracks = Array.isArray(importRecord.import?.tracks)
399
+ ? importRecord.import.tracks.map((/** @type {any} */ track) => String(track).trim()).filter(Boolean)
400
+ : [];
401
+ return tracks.length ? [...new Set(tracks)].join(",") : null;
402
+ }
403
+
404
+ /**
405
+ * @param {string} topogramRoot
406
+ * @returns {{ rawCandidateFiles: number, reconcileFiles: number }}
407
+ */
408
+ function clearImportRefreshCandidateArtifacts(topogramRoot) {
409
+ const appCandidatesRoot = path.join(topogramRoot, "candidates", "app");
410
+ const reconcileRoot = path.join(topogramRoot, "candidates", "reconcile");
411
+ const removed = {
412
+ rawCandidateFiles: countFilesRecursive(appCandidatesRoot),
413
+ reconcileFiles: countFilesRecursive(reconcileRoot)
414
+ };
415
+ fs.rmSync(appCandidatesRoot, { recursive: true, force: true });
416
+ fs.rmSync(reconcileRoot, { recursive: true, force: true });
417
+ return removed;
418
+ }
419
+
420
+ /**
421
+ * @param {{ changed?: any[], added?: any[], removed?: any[] }} [content]
422
+ * @returns {{ changed: number, added: number, removed: number }}
423
+ */
424
+ function sourceDiffCounts(content = {}) {
425
+ return {
426
+ changed: content.changed?.length || 0,
427
+ added: content.added?.length || 0,
428
+ removed: content.removed?.length || 0
429
+ };
430
+ }
431
+
432
+ /**
433
+ * @param {string} projectRoot
434
+ * @param {AnyRecord} importRecord
435
+ * @param {string} sourceRoot
436
+ * @returns {AnyRecord}
437
+ */
438
+ function compareImportRecordToSource(projectRoot, importRecord, sourceRoot) {
439
+ const trustedFiles = Array.isArray(importRecord.files) ? importRecord.files : [];
440
+ const trustedByPath = new Map(trustedFiles.map((/** @type {AnyRecord} */ file) => [String(file.path), file]));
441
+ const currentFiles = collectImportSourceFileRecords(sourceRoot, { excludeRoots: [projectRoot] });
442
+ const currentByPath = new Map(currentFiles.map((/** @type {AnyRecord} */ file) => [file.path, file]));
443
+ /** @type {string[]} */
444
+ const changed = [];
445
+ /** @type {string[]} */
446
+ const added = [];
447
+ /** @type {string[]} */
448
+ const removed = [];
449
+ for (const [filePath, current] of currentByPath) {
450
+ const trusted = trustedByPath.get(filePath);
451
+ if (!trusted) {
452
+ added.push(filePath);
453
+ } else if (trusted.sha256 !== current.sha256 || trusted.size !== current.size) {
454
+ changed.push(filePath);
455
+ }
456
+ }
457
+ for (const filePath of trustedByPath.keys()) {
458
+ if (!currentByPath.has(filePath)) {
459
+ removed.push(filePath);
460
+ }
461
+ }
462
+ const content = {
463
+ changed: changed.sort((a, b) => a.localeCompare(b)),
464
+ added: added.sort((a, b) => a.localeCompare(b)),
465
+ removed: removed.sort((a, b) => a.localeCompare(b))
466
+ };
467
+ const counts = sourceDiffCounts(content);
468
+ const clean = counts.changed === 0 && counts.added === 0 && counts.removed === 0;
469
+ return {
470
+ ok: clean,
471
+ status: clean ? "clean" : "changed",
472
+ content,
473
+ counts,
474
+ files: currentFiles
475
+ };
476
+ }
477
+
478
+ /**
479
+ * @param {Record<string, number>} [previous]
480
+ * @param {Record<string, number>} [next]
481
+ * @returns {AnyRecord}
482
+ */
483
+ function buildCountDeltas(previous = {}, next = {}) {
484
+ const keys = [...new Set([...Object.keys(previous || {}), ...Object.keys(next || {})])].sort((a, b) => a.localeCompare(b));
485
+ /** @type {Record<string, { previous: number, next: number, delta: number }>} */
486
+ const deltas = {};
487
+ /** @type {Array<{ key: string, previous: number, next: number, delta: number }>} */
488
+ const changed = [];
489
+ for (const key of keys) {
490
+ const previousCount = Number(previous?.[key] || 0);
491
+ const nextCount = Number(next?.[key] || 0);
492
+ const delta = nextCount - previousCount;
493
+ deltas[key] = { previous: previousCount, next: nextCount, delta };
494
+ if (delta !== 0) {
495
+ changed.push({ key, previous: previousCount, next: nextCount, delta });
496
+ }
497
+ }
498
+ return {
499
+ previous,
500
+ next,
501
+ deltas,
502
+ changed
503
+ };
504
+ }
505
+
506
+ /**
507
+ * @param {AnyRecord} item
508
+ * @returns {string}
509
+ */
510
+ function adoptionSurfaceKey(item) {
511
+ return `${item?.bundle || "unbundled"}:${item?.kind || "unknown"}:${item?.item || item?.id || "unknown"}`;
512
+ }
513
+
514
+ /**
515
+ * @param {AnyRecord} item
516
+ * @returns {AnyRecord}
517
+ */
518
+ function summarizeAdoptionSurface(item) {
519
+ return {
520
+ key: adoptionSurfaceKey(item),
521
+ bundle: item?.bundle || "unbundled",
522
+ kind: item?.kind || "unknown",
523
+ item: item?.item || item?.id || "unknown",
524
+ currentState: item?.current_state || null
525
+ };
526
+ }
527
+
528
+ /**
529
+ * @param {AnyRecord[]} [currentSurfaces]
530
+ * @param {AnyRecord[]} [nextSurfaces]
531
+ * @returns {AnyRecord}
532
+ */
533
+ function summarizeAdoptionPlanDeltas(currentSurfaces = [], nextSurfaces = []) {
534
+ const currentByKey = new Map((currentSurfaces || []).map((item) => [adoptionSurfaceKey(item), item]));
535
+ const nextByKey = new Map((nextSurfaces || []).map((item) => [adoptionSurfaceKey(item), item]));
536
+ /** @type {AnyRecord[]} */
537
+ const added = [];
538
+ /** @type {AnyRecord[]} */
539
+ const removed = [];
540
+ /** @type {AnyRecord[]} */
541
+ const changed = [];
542
+ for (const [key, next] of nextByKey) {
543
+ const current = currentByKey.get(key);
544
+ if (!current) {
545
+ added.push(summarizeAdoptionSurface(next));
546
+ } else if (stableStringify(current) !== stableStringify(next)) {
547
+ changed.push({
548
+ ...summarizeAdoptionSurface(next),
549
+ previousState: current.current_state || null,
550
+ nextState: next.current_state || null
551
+ });
552
+ }
553
+ }
554
+ for (const [key, current] of currentByKey) {
555
+ if (!nextByKey.has(key)) {
556
+ removed.push(summarizeAdoptionSurface(current));
557
+ }
558
+ }
559
+ const currentByBundle = countByField(currentSurfaces, "bundle");
560
+ const nextByBundle = countByField(nextSurfaces, "bundle");
561
+ return {
562
+ added: added.sort((left, right) => left.key.localeCompare(right.key)),
563
+ removed: removed.sort((left, right) => left.key.localeCompare(right.key)),
564
+ changed: changed.sort((left, right) => left.key.localeCompare(right.key)),
565
+ byBundle: buildCountDeltas(currentByBundle, nextByBundle)
566
+ };
567
+ }
568
+
569
+ /**
570
+ * @param {string|null|undefined} fileContents
571
+ * @returns {AnyRecord[]}
572
+ */
573
+ function adoptionSurfacesFromPlanFile(fileContents) {
574
+ if (!fileContents) {
575
+ return [];
576
+ }
577
+ const parsed = JSON.parse(fileContents);
578
+ return parsed.imported_proposal_surfaces || [];
579
+ }
580
+
581
+ /**
582
+ * @param {string} projectRoot
583
+ * @param {string} topogramRoot
584
+ * @param {Record<string, any>} importFiles
585
+ * @returns {AnyRecord}
586
+ */
587
+ function buildRefreshPreviewReconcile(projectRoot, topogramRoot, importFiles) {
588
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "topogram-import-refresh-preview."));
589
+ try {
590
+ const tempProjectRoot = path.join(tempRoot, "workspace");
591
+ const tempTopogramRoot = path.join(tempProjectRoot, "topogram");
592
+ fs.mkdirSync(tempProjectRoot, { recursive: true });
593
+ fs.cpSync(topogramRoot, tempTopogramRoot, { recursive: true });
594
+ const projectConfigPath = path.join(projectRoot, "topogram.project.json");
595
+ if (fs.existsSync(projectConfigPath)) {
596
+ fs.cpSync(projectConfigPath, path.join(tempProjectRoot, "topogram.project.json"));
597
+ }
598
+ clearImportRefreshCandidateArtifacts(tempTopogramRoot);
599
+ writeRelativeFiles(tempTopogramRoot, importFiles || {});
600
+ const reconcileResult = runWorkflow("reconcile", tempProjectRoot, {});
601
+ return {
602
+ reconcileFileCount: Object.keys(reconcileResult.files || {}).length,
603
+ reconcileFilePaths: Object.keys(reconcileResult.files || {}).sort((a, b) => a.localeCompare(b)),
604
+ adoptionSurfaces: adoptionSurfacesFromPlanFile(reconcileResult.files?.["candidates/reconcile/adoption-plan.agent.json"]),
605
+ summary: reconcileResult.summary || {}
606
+ };
607
+ } finally {
608
+ fs.rmSync(tempRoot, { recursive: true, force: true });
609
+ }
610
+ }
611
+
612
+ /**
613
+ * @param {string} topogramRoot
614
+ * @returns {AnyRecord[]}
615
+ */
616
+ function readCurrentAdoptionSurfaces(topogramRoot) {
617
+ const planPath = path.join(topogramRoot, "candidates", "reconcile", "adoption-plan.agent.json");
618
+ if (!fs.existsSync(planPath)) {
619
+ return [];
620
+ }
621
+ return adoptionSurfacesFromPlanFile(fs.readFileSync(planPath, "utf8"));
622
+ }
623
+
624
+ /**
625
+ * @param {string} inputPath
626
+ * @param {{ sourcePath?: string|null }} [options]
627
+ * @returns {AnyRecord}
628
+ */
629
+ function buildBrownfieldImportRefreshAnalysis(inputPath, options = {}) {
630
+ const projectRoot = normalizeProjectRoot(inputPath);
631
+ const topogramRoot = normalizeTopogramPath(projectRoot);
632
+ if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
633
+ throw new Error(`No topogram directory found for imported workspace '${inputPath}'.`);
634
+ }
635
+
636
+ const { record: importRecord } = readTopogramImportRecord(projectRoot);
637
+ const sourcePath = options.sourcePath && !String(options.sourcePath).startsWith("-")
638
+ ? options.sourcePath
639
+ : importRecord.source?.path;
640
+ if (!sourcePath) {
641
+ throw new Error("No brownfield source path was provided or recorded. Use 'topogram import refresh <workspace> --from <app-path>'.");
642
+ }
643
+ const sourceRoot = path.resolve(sourcePath);
644
+ if (!fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
645
+ throw new Error(`Cannot refresh from missing app directory '${sourcePath}'.`);
646
+ }
647
+ if (sourceRoot === projectRoot) {
648
+ throw new Error("Refusing to refresh import from the imported Topogram workspace itself.");
649
+ }
650
+
651
+ const sourceComparison = compareImportRecordToSource(projectRoot, importRecord, sourceRoot);
652
+ const trackValue = importTrackValueFromRecord(importRecord);
653
+ const importResult = runWorkflow("import-app", sourceRoot, { from: trackValue });
654
+ const candidateCounts = importCandidateCounts(importResult.summary);
655
+ const candidateCountDeltas = buildCountDeltas(importRecord.import?.candidateCounts || {}, candidateCounts);
656
+ const removedCandidateFiles = {
657
+ rawCandidateFiles: countFilesRecursive(path.join(topogramRoot, "candidates", "app")),
658
+ reconcileFiles: countFilesRecursive(path.join(topogramRoot, "candidates", "reconcile"))
659
+ };
660
+ const previewReconcile = buildRefreshPreviewReconcile(projectRoot, topogramRoot, importResult.files || {});
661
+ const currentAdoptionSurfaces = readCurrentAdoptionSurfaces(topogramRoot);
662
+ const adoptionPlanDeltas = summarizeAdoptionPlanDeltas(currentAdoptionSurfaces, previewReconcile.adoptionSurfaces);
663
+ const receiptVerification = verifyImportAdoptionReceipts(projectRoot, readImportAdoptionReceipts(projectRoot));
664
+ const plannedFiles = [
665
+ TOPOGRAM_IMPORT_FILE,
666
+ ...Object.keys(importResult.files || {}).map((filePath) => `topogram/${filePath}`),
667
+ ...previewReconcile.reconcileFilePaths.map((/** @type {string} */ filePath) => `topogram/${filePath}`)
668
+ ].sort((a, b) => a.localeCompare(b));
669
+ const analysis = /** @type {AnyRecord} */ ({
670
+ projectRoot,
671
+ topogramRoot,
672
+ sourcePath: sourceRoot,
673
+ provenancePath: path.join(projectRoot, TOPOGRAM_IMPORT_FILE),
674
+ importedAt: importRecord.importedAt || null,
675
+ previousImportStatus: sourceComparison.status,
676
+ sourceDiff: {
677
+ status: sourceComparison.status,
678
+ counts: sourceComparison.counts,
679
+ changed: sourceComparison.content.changed,
680
+ added: sourceComparison.content.added,
681
+ removed: sourceComparison.content.removed
682
+ },
683
+ tracks: importResult.summary.tracks || [],
684
+ sourceFiles: sourceComparison.files.length,
685
+ removedCandidateFiles,
686
+ rawCandidateFiles: Object.keys(importResult.files || {}).length,
687
+ reconcileFiles: previewReconcile.reconcileFileCount,
688
+ candidateCounts,
689
+ candidateCountDeltas,
690
+ adoptionPlanDeltas,
691
+ receiptVerification,
692
+ plannedFiles
693
+ });
694
+ Object.defineProperty(analysis, "importResult", {
695
+ value: importResult,
696
+ enumerable: false
697
+ });
698
+ return analysis;
699
+ }
700
+
701
+ /**
702
+ * @param {string} sourcePath
703
+ * @param {string} targetPath
704
+ * @param {{ from?: string|null }} [options]
705
+ * @returns {{ ok: boolean, sourcePath: string, targetPath: string, topogramRoot: string, projectConfigPath: string, provenancePath: string, tracks: string[], sourceFiles: number, rawCandidateFiles: number, reconcileFiles: number, writtenFiles: string[], candidateCounts: Record<string, number>, nextCommands: string[] }}
706
+ */
707
+ export function buildBrownfieldImportWorkspacePayload(sourcePath, targetPath, options = {}) {
708
+ const sourceRoot = path.resolve(sourcePath);
709
+ const targetRoot = path.resolve(targetPath);
710
+ if (!fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
711
+ throw new Error(`Cannot import missing app directory '${sourcePath}'.`);
712
+ }
713
+ if (sourceRoot === targetRoot) {
714
+ throw new Error("Refusing to import into the same directory as the brownfield app.");
715
+ }
716
+ ensureEmptyImportTarget(targetRoot);
717
+
718
+ const topogramRoot = path.join(targetRoot, "topogram");
719
+ fs.mkdirSync(topogramRoot, { recursive: true });
720
+ const sourceFiles = collectImportSourceFileRecords(sourceRoot, { excludeRoots: [targetRoot] });
721
+ const importResult = runWorkflow("import-app", sourceRoot, { from: options.from || null });
722
+ const rawCandidateFiles = writeRelativeFiles(topogramRoot, importResult.files || {});
723
+
724
+ const projectConfigPath = path.join(targetRoot, "topogram.project.json");
725
+ fs.writeFileSync(projectConfigPath, `${stableStringify(importedProjectConfig())}\n`, "utf8");
726
+ fs.writeFileSync(path.join(targetRoot, "README.md"), importedWorkspaceReadme(sourceRoot, targetRoot, importResult.summary), "utf8");
727
+
728
+ const reconcileResult = runWorkflow("reconcile", targetRoot, {});
729
+ const reconcileFiles = writeRelativeFiles(topogramRoot, reconcileResult.files || {});
730
+ const candidateCounts = importCandidateCounts(importResult.summary);
731
+ const provenance = writeTopogramImportRecord(targetRoot, {
732
+ sourceRoot,
733
+ ignoredRoots: [targetRoot],
734
+ tracks: importResult.summary.tracks || [],
735
+ findingsCount: importResult.summary.findings_count || 0,
736
+ candidateCounts,
737
+ files: sourceFiles
738
+ });
739
+ const writtenFiles = [
740
+ "README.md",
741
+ "topogram.project.json",
742
+ TOPOGRAM_IMPORT_FILE,
743
+ ...rawCandidateFiles.map((filePath) => `topogram/${filePath}`),
744
+ ...reconcileFiles.map((filePath) => `topogram/${filePath}`)
745
+ ].sort((a, b) => a.localeCompare(b));
746
+ return {
747
+ ok: true,
748
+ sourcePath: sourceRoot,
749
+ targetPath: targetRoot,
750
+ topogramRoot,
751
+ projectConfigPath,
752
+ provenancePath: provenance.path,
753
+ tracks: importResult.summary.tracks || [],
754
+ sourceFiles: sourceFiles.length,
755
+ rawCandidateFiles: rawCandidateFiles.length,
756
+ reconcileFiles: reconcileFiles.length,
757
+ writtenFiles,
758
+ candidateCounts,
759
+ nextCommands: [
760
+ "topogram import check",
761
+ "topogram import plan",
762
+ "topogram import adopt bundle:task --dry-run",
763
+ "topogram import status",
764
+ "topogram check",
765
+ "topogram query import-plan ./topogram"
766
+ ]
767
+ };
768
+ }
769
+
770
+ /**
771
+ * @param {string} inputPath
772
+ * @param {{ sourcePath?: string|null, dryRun?: boolean }} [options]
773
+ * @returns {{ ok: boolean, dryRun: boolean, projectRoot: string, topogramRoot: string, sourcePath: string, provenancePath: string, previousImportStatus: string, currentImportStatus: string, tracks: string[], sourceFiles: number, sourceDiff: Record<string, any>, removedCandidateFiles: Record<string, number>, rawCandidateFiles: number, reconcileFiles: number, writtenFiles: string[], plannedFiles: string[], candidateCounts: Record<string, number>, candidateCountDeltas: Record<string, any>, adoptionPlanDeltas: Record<string, any>, receiptVerification: Record<string, any>, refreshMetadata: Record<string, any>|null, nextCommands: string[] }}
774
+ */
775
+ export function buildBrownfieldImportRefreshPayload(inputPath, options = {}) {
776
+ const analysis = buildBrownfieldImportRefreshAnalysis(inputPath, options);
777
+ const dryRun = Boolean(options.dryRun);
778
+ let provenancePath = analysis.provenancePath;
779
+ let currentImportStatus = dryRun ? analysis.previousImportStatus : "unknown";
780
+ /** @type {string[]} */
781
+ let writtenFiles = [];
782
+ /** @type {AnyRecord|null} */
783
+ let refreshMetadata = null;
784
+ if (!dryRun) {
785
+ const removedCandidateFiles = clearImportRefreshCandidateArtifacts(analysis.topogramRoot);
786
+ const rawCandidateFiles = writeRelativeFiles(analysis.topogramRoot, analysis.importResult.files || {});
787
+ const reconcileResult = runWorkflow("reconcile", analysis.projectRoot, {});
788
+ const reconcileFiles = writeRelativeFiles(analysis.topogramRoot, reconcileResult.files || {});
789
+ const refreshedAt = new Date().toISOString();
790
+ refreshMetadata = {
791
+ refreshedAt,
792
+ previousSourceStatus: analysis.previousImportStatus,
793
+ sourceDiffCounts: analysis.sourceDiff.counts
794
+ };
795
+ const provenance = writeTopogramImportRecord(analysis.projectRoot, {
796
+ sourceRoot: analysis.sourcePath,
797
+ ignoredRoots: [analysis.projectRoot],
798
+ importedAt: analysis.importedAt || undefined,
799
+ refreshedAt,
800
+ refresh: {
801
+ previousSourceStatus: analysis.previousImportStatus,
802
+ sourceDiffCounts: analysis.sourceDiff.counts
803
+ },
804
+ tracks: analysis.importResult.summary.tracks || [],
805
+ findingsCount: analysis.importResult.summary.findings_count || 0,
806
+ candidateCounts: analysis.candidateCounts,
807
+ files: collectImportSourceFileRecords(analysis.sourcePath, { excludeRoots: [analysis.projectRoot] })
808
+ });
809
+ provenancePath = provenance.path;
810
+ currentImportStatus = buildTopogramImportStatus(analysis.projectRoot).status;
811
+ writtenFiles = [
812
+ TOPOGRAM_IMPORT_FILE,
813
+ ...rawCandidateFiles.map((filePath) => `topogram/${filePath}`),
814
+ ...reconcileFiles.map((filePath) => `topogram/${filePath}`)
815
+ ].sort((a, b) => a.localeCompare(b));
816
+ analysis.removedCandidateFiles = removedCandidateFiles;
817
+ analysis.rawCandidateFiles = rawCandidateFiles.length;
818
+ analysis.reconcileFiles = reconcileFiles.length;
819
+ }
820
+ return {
821
+ ok: dryRun || currentImportStatus === "clean",
822
+ dryRun,
823
+ projectRoot: analysis.projectRoot,
824
+ topogramRoot: analysis.topogramRoot,
825
+ sourcePath: analysis.sourcePath,
826
+ provenancePath,
827
+ previousImportStatus: analysis.previousImportStatus,
828
+ currentImportStatus,
829
+ tracks: analysis.tracks,
830
+ sourceFiles: analysis.sourceFiles,
831
+ sourceDiff: analysis.sourceDiff,
832
+ removedCandidateFiles: analysis.removedCandidateFiles,
833
+ rawCandidateFiles: analysis.rawCandidateFiles,
834
+ reconcileFiles: analysis.reconcileFiles,
835
+ writtenFiles,
836
+ plannedFiles: analysis.plannedFiles,
837
+ candidateCounts: analysis.candidateCounts,
838
+ candidateCountDeltas: analysis.candidateCountDeltas,
839
+ adoptionPlanDeltas: analysis.adoptionPlanDeltas,
840
+ receiptVerification: analysis.receiptVerification,
841
+ refreshMetadata,
842
+ nextCommands: [
843
+ dryRun
844
+ ? `topogram import refresh ${importProjectCommandPath(analysis.projectRoot)}`
845
+ : `topogram import check ${importProjectCommandPath(analysis.projectRoot)}`,
846
+ `topogram import plan ${importProjectCommandPath(analysis.projectRoot)}`,
847
+ `topogram import status ${importProjectCommandPath(analysis.projectRoot)}`,
848
+ `topogram import history ${importProjectCommandPath(analysis.projectRoot)} --verify`
849
+ ]
850
+ };
851
+ }
852
+
853
+ /**
854
+ * @param {string} inputPath
855
+ * @param {{ sourcePath?: string|null }} [options]
856
+ * @returns {AnyRecord}
857
+ */
858
+ export function buildBrownfieldImportDiffPayload(inputPath, options = {}) {
859
+ const analysis = buildBrownfieldImportRefreshAnalysis(inputPath, options);
860
+ return {
861
+ ok: true,
862
+ projectRoot: analysis.projectRoot,
863
+ topogramRoot: analysis.topogramRoot,
864
+ sourcePath: analysis.sourcePath,
865
+ provenancePath: analysis.provenancePath,
866
+ importStatus: analysis.previousImportStatus,
867
+ sourceDiff: analysis.sourceDiff,
868
+ tracks: analysis.tracks,
869
+ sourceFiles: analysis.sourceFiles,
870
+ candidateCounts: analysis.candidateCounts,
871
+ candidateCountDeltas: analysis.candidateCountDeltas,
872
+ adoptionPlanDeltas: analysis.adoptionPlanDeltas,
873
+ receiptVerification: analysis.receiptVerification,
874
+ plannedFiles: analysis.plannedFiles,
875
+ nextCommands: [
876
+ `topogram import refresh ${importProjectCommandPath(analysis.projectRoot)} --dry-run`,
877
+ `topogram import refresh ${importProjectCommandPath(analysis.projectRoot)}`,
878
+ `topogram import plan ${importProjectCommandPath(analysis.projectRoot)}`
879
+ ]
880
+ };
881
+ }
882
+
883
+ /**
884
+ * @param {ReturnType<typeof buildBrownfieldImportWorkspacePayload>} payload
885
+ * @returns {void}
886
+ */
887
+ export function printBrownfieldImportWorkspace(payload) {
888
+ console.log(`Imported brownfield app to ${payload.targetPath}.`);
889
+ console.log(`Source: ${payload.sourcePath}`);
890
+ console.log(`Topogram: ${payload.topogramRoot}`);
891
+ console.log(`Project config: ${payload.projectConfigPath}`);
892
+ console.log(`Import provenance: ${payload.provenancePath}`);
893
+ console.log(`Tracked source files: ${payload.sourceFiles}`);
894
+ console.log(`Raw candidate files: ${payload.rawCandidateFiles}`);
895
+ console.log(`Reconcile proposal files: ${payload.reconcileFiles}`);
896
+ console.log("Imported Topogram artifacts are project-owned after creation; source hashes record the app evidence trusted at import time.");
897
+ console.log("");
898
+ console.log("Next steps:");
899
+ console.log(` cd ${shellCommandArg(path.relative(process.cwd(), payload.targetPath) || ".")}`);
900
+ for (const command of payload.nextCommands) {
901
+ console.log(` ${command}`);
902
+ }
903
+ }
904
+
905
+ /**
906
+ * @param {ReturnType<typeof buildBrownfieldImportRefreshPayload>} payload
907
+ * @returns {void}
908
+ */
909
+ export function printBrownfieldImportRefresh(payload) {
910
+ console.log(`${payload.dryRun ? "Previewed" : "Refreshed"} brownfield import candidates for ${payload.projectRoot}.`);
911
+ console.log(`Source: ${payload.sourcePath}`);
912
+ console.log(`Topogram: ${payload.topogramRoot}`);
913
+ console.log(`Import provenance: ${payload.provenancePath}`);
914
+ console.log(`Previous source status: ${payload.previousImportStatus}`);
915
+ console.log(`Current source status: ${payload.currentImportStatus}`);
916
+ console.log(`Source diff: changed=${payload.sourceDiff.counts.changed}, added=${payload.sourceDiff.counts.added}, removed=${payload.sourceDiff.counts.removed}`);
917
+ console.log(`Tracked source files: ${payload.sourceFiles}`);
918
+ console.log(`Raw candidate files: ${payload.rawCandidateFiles}`);
919
+ console.log(`Reconcile proposal files: ${payload.reconcileFiles}`);
920
+ console.log(`Replaced candidate files: ${payload.removedCandidateFiles.rawCandidateFiles + payload.removedCandidateFiles.reconcileFiles}`);
921
+ const candidateChanges = payload.candidateCountDeltas.changed || [];
922
+ console.log(`Candidate count changes: ${candidateChanges.length}`);
923
+ for (const item of candidateChanges.slice(0, 8)) {
924
+ const sign = item.delta > 0 ? "+" : "";
925
+ console.log(`- ${item.key}: ${item.previous} -> ${item.next} (${sign}${item.delta})`);
926
+ }
927
+ const adoptionDeltas = payload.adoptionPlanDeltas;
928
+ console.log(`Adoption plan changes: added=${adoptionDeltas.added.length}, removed=${adoptionDeltas.removed.length}, changed=${adoptionDeltas.changed.length}`);
929
+ console.log(`Receipt verification: ${payload.receiptVerification.status}`);
930
+ if (payload.dryRun) {
931
+ console.log("No files were written. Re-run without --dry-run to refresh candidates and source provenance.");
932
+ }
933
+ console.log("Canonical Topogram files were not overwritten. Adopt refreshed candidates explicitly after review.");
934
+ console.log("");
935
+ console.log("Next steps:");
936
+ for (const command of payload.nextCommands) {
937
+ console.log(` ${command}`);
938
+ }
939
+ }
940
+
941
+ /**
942
+ * @param {ReturnType<typeof buildBrownfieldImportDiffPayload>} payload
943
+ * @returns {void}
944
+ */
945
+ export function printBrownfieldImportDiff(payload) {
946
+ console.log(`Import diff for ${payload.projectRoot}`);
947
+ console.log(`Source: ${payload.sourcePath}`);
948
+ console.log(`Source status: ${payload.importStatus}`);
949
+ console.log(`Source diff: changed=${payload.sourceDiff.counts.changed}, added=${payload.sourceDiff.counts.added}, removed=${payload.sourceDiff.counts.removed}`);
950
+ for (const filePath of [...payload.sourceDiff.changed, ...payload.sourceDiff.added, ...payload.sourceDiff.removed].slice(0, 12)) {
951
+ const status = payload.sourceDiff.changed.includes(filePath)
952
+ ? "changed"
953
+ : payload.sourceDiff.added.includes(filePath)
954
+ ? "added"
955
+ : "removed";
956
+ console.log(`- ${filePath}: ${status}`);
957
+ }
958
+ console.log("");
959
+ console.log("Candidate count changes:");
960
+ const candidateChanges = payload.candidateCountDeltas.changed || [];
961
+ if (candidateChanges.length === 0) {
962
+ console.log("- None");
963
+ } else {
964
+ for (const item of candidateChanges) {
965
+ const sign = item.delta > 0 ? "+" : "";
966
+ console.log(`- ${item.key}: ${item.previous} -> ${item.next} (${sign}${item.delta})`);
967
+ }
968
+ }
969
+ console.log("");
970
+ console.log(`Adoption plan changes: added=${payload.adoptionPlanDeltas.added.length}, removed=${payload.adoptionPlanDeltas.removed.length}, changed=${payload.adoptionPlanDeltas.changed.length}`);
971
+ for (const item of payload.adoptionPlanDeltas.added.slice(0, 8)) {
972
+ console.log(`- added ${item.bundle}/${item.kind}/${item.item}`);
973
+ }
974
+ for (const item of payload.adoptionPlanDeltas.removed.slice(0, 8)) {
975
+ console.log(`- removed ${item.bundle}/${item.kind}/${item.item}`);
976
+ }
977
+ console.log(`Receipt verification: ${payload.receiptVerification.status}`);
978
+ const receiptSummary = payload.receiptVerification.summary;
979
+ console.log(`Adopted file audit: changed=${receiptSummary.changedFileCount}, removed=${receiptSummary.removedFileCount}, unverifiable=${receiptSummary.unverifiableFileCount}`);
980
+ console.log("");
981
+ console.log("Next steps:");
982
+ for (const command of payload.nextCommands) {
983
+ console.log(` ${command}`);
984
+ }
985
+ }
986
+
987
+ /**
988
+ * @param {string} inputPath
989
+ * @returns {ReturnType<typeof checkSummaryPayload>}
990
+ */
991
+ function buildTopogramCheckPayloadForPath(inputPath) {
992
+ const ast = parsePath(inputPath);
993
+ const resolved = resolveWorkspace(ast);
994
+ const explicitProjectConfig = loadProjectConfig(inputPath);
995
+ const projectValidation = explicitProjectConfig
996
+ ? combineProjectValidationResults(
997
+ validateProjectConfig(explicitProjectConfig.config, resolved.ok ? resolved.graph : null, { configDir: explicitProjectConfig.configDir }),
998
+ validateProjectOutputOwnership(explicitProjectConfig),
999
+ validateProjectImplementationTrust(explicitProjectConfig)
1000
+ )
1001
+ : { ok: false, errors: [{ message: "Missing topogram.project.json or compatible topogram.implementation.json", loc: null }] };
1002
+ return checkSummaryPayload({ inputPath, ast, resolved, projectConfigInfo: explicitProjectConfig, projectValidation });
1003
+ }
1004
+
1005
+ /**
1006
+ * @param {string} projectRoot
1007
+ * @returns {{ ok: boolean, projectRoot: string, import: ReturnType<typeof buildTopogramImportStatus>, topogram: ReturnType<typeof buildTopogramCheckPayloadForPath>, errors: any[] }}
1008
+ */
1009
+ export function buildBrownfieldImportCheckPayload(projectRoot) {
1010
+ const resolvedRoot = normalizeProjectRoot(projectRoot);
1011
+ const importStatus = buildTopogramImportStatus(resolvedRoot);
1012
+ const topogramCheck = buildTopogramCheckPayloadForPath(resolvedRoot);
1013
+ return {
1014
+ ok: importStatus.ok && topogramCheck.ok,
1015
+ projectRoot: resolvedRoot,
1016
+ import: importStatus,
1017
+ topogram: topogramCheck,
1018
+ errors: [
1019
+ ...(importStatus.errors || []).map((/** @type {string} */ message) => ({ source: "import", message })),
1020
+ ...(topogramCheck.errors || [])
1021
+ ]
1022
+ };
1023
+ }
1024
+
1025
+ /**
1026
+ * @param {ReturnType<typeof buildBrownfieldImportCheckPayload>} payload
1027
+ * @returns {void}
1028
+ */
1029
+ export function printBrownfieldImportCheck(payload) {
1030
+ console.log(`Topogram import check: ${payload.import.status}`);
1031
+ console.log(`Project: ${payload.projectRoot}`);
1032
+ if (payload.import.source?.source?.path) {
1033
+ console.log(`Imported source: ${payload.import.source.source.path}`);
1034
+ }
1035
+ console.log(`Provenance: ${payload.import.path}`);
1036
+ if (payload.import.source?.files) {
1037
+ console.log(`Trusted source files: ${payload.import.source.files.length}`);
1038
+ }
1039
+ if (payload.import.status === "changed") {
1040
+ console.log(`Changed source files: ${payload.import.content.changed.length}`);
1041
+ console.log(`Added source files: ${payload.import.content.added.length}`);
1042
+ console.log(`Removed source files: ${payload.import.content.removed.length}`);
1043
+ }
1044
+ console.log(`Topogram check: ${payload.topogram.ok ? "passed" : "failed"}`);
1045
+ console.log("Imported Topogram artifacts are project-owned; import check compares only the brownfield source hashes trusted at import time plus normal Topogram validity.");
1046
+ for (const diagnostic of payload.import.diagnostics || []) {
1047
+ const label = diagnostic.severity === "warning" ? "Warning" : "Error";
1048
+ console.log(`${label}: ${diagnostic.message}`);
1049
+ if (diagnostic.suggestedFix) {
1050
+ console.log(`Fix: ${diagnostic.suggestedFix}`);
1051
+ }
1052
+ }
1053
+ for (const error of payload.topogram.errors || []) {
1054
+ console.log(`Error: ${error.message}`);
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * @param {string} filePath
1060
+ * @returns {AnyRecord|null}
1061
+ */
1062
+ function readJsonIfExists(filePath) {
1063
+ if (!fs.existsSync(filePath)) {
1064
+ return null;
1065
+ }
1066
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
1067
+ }
1068
+
1069
+ /**
1070
+ * @param {string} projectRoot
1071
+ * @returns {string}
1072
+ */
1073
+ function importAdoptionsPath(projectRoot) {
1074
+ return path.join(normalizeProjectRoot(projectRoot), TOPOGRAM_IMPORT_ADOPTIONS_FILE);
1075
+ }
1076
+
1077
+ /**
1078
+ * @param {string} projectRoot
1079
+ * @returns {AnyRecord[]}
1080
+ */
1081
+ function readImportAdoptionReceipts(projectRoot) {
1082
+ const historyPath = importAdoptionsPath(projectRoot);
1083
+ if (!fs.existsSync(historyPath)) {
1084
+ return [];
1085
+ }
1086
+ return fs.readFileSync(historyPath, "utf8")
1087
+ .split(/\r?\n/)
1088
+ .map((/** @type {string} */ line) => line.trim())
1089
+ .filter(Boolean)
1090
+ .map((/** @type {string} */ line, /** @type {number} */ index) => {
1091
+ try {
1092
+ return JSON.parse(line);
1093
+ } catch (error) {
1094
+ throw new Error(`Invalid import adoption receipt JSON at ${historyPath}:${index + 1}.`);
1095
+ }
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * @param {string} projectRoot
1101
+ * @param {AnyRecord} receipt
1102
+ * @returns {string}
1103
+ */
1104
+ function appendImportAdoptionReceipt(projectRoot, receipt) {
1105
+ const historyPath = importAdoptionsPath(projectRoot);
1106
+ fs.appendFileSync(historyPath, `${JSON.stringify(receipt)}\n`, "utf8");
1107
+ return historyPath;
1108
+ }
1109
+
1110
+ /**
1111
+ * @param {AnyRecord[]} items
1112
+ * @param {string} fieldName
1113
+ * @returns {Record<string, number>}
1114
+ */
1115
+ function countByField(items, fieldName) {
1116
+ /** @type {Record<string, number>} */
1117
+ const counts = {};
1118
+ for (const item of items || []) {
1119
+ const key = item?.[fieldName] || "unknown";
1120
+ counts[key] = (counts[key] || 0) + 1;
1121
+ }
1122
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
1123
+ }
1124
+
1125
+ /**
1126
+ * @param {string} projectRoot
1127
+ * @returns {string}
1128
+ */
1129
+ function importProjectCommandPath(projectRoot) {
1130
+ return shellCommandArg(path.relative(process.cwd(), projectRoot) || ".");
1131
+ }
1132
+
1133
+ /**
1134
+ * @param {string} projectRoot
1135
+ * @param {string} selector
1136
+ * @param {boolean} [write]
1137
+ * @returns {string}
1138
+ */
1139
+ function importAdoptCommand(projectRoot, selector, write = false) {
1140
+ return `topogram import adopt ${selector} ${importProjectCommandPath(projectRoot)} ${write ? "--write" : "--dry-run"}`;
1141
+ }
1142
+
1143
+ const BROWNFIELD_BROAD_ADOPT_SELECTORS = [
1144
+ {
1145
+ selector: "from-plan",
1146
+ kind: "plan",
1147
+ label: "approved or pending plan items",
1148
+ matches: (/** @type {AnyRecord} */ item) => item.current_state === "stage" || item.current_state === "accept"
1149
+ },
1150
+ { selector: "actors", kind: "kind", label: "actors", matches: (/** @type {AnyRecord} */ item) => item.kind === "actor" },
1151
+ { selector: "roles", kind: "kind", label: "roles", matches: (/** @type {AnyRecord} */ item) => item.kind === "role" },
1152
+ { selector: "enums", kind: "kind", label: "enums", matches: (/** @type {AnyRecord} */ item) => item.kind === "enum" },
1153
+ { selector: "shapes", kind: "kind", label: "shapes", matches: (/** @type {AnyRecord} */ item) => item.kind === "shape" },
1154
+ { selector: "entities", kind: "kind", label: "entities", matches: (/** @type {AnyRecord} */ item) => item.kind === "entity" },
1155
+ { selector: "capabilities", kind: "kind", label: "capabilities", matches: (/** @type {AnyRecord} */ item) => item.kind === "capability" },
1156
+ { selector: "widgets", kind: "kind", label: "widgets", matches: (/** @type {AnyRecord} */ item) => item.kind === "widget" },
1157
+ { selector: "docs", kind: "track", label: "docs", matches: (/** @type {AnyRecord} */ item) => item.track === "docs" },
1158
+ {
1159
+ selector: "journeys",
1160
+ kind: "track",
1161
+ label: "journey docs",
1162
+ matches: (/** @type {AnyRecord} */ item) => item.track === "docs" && String(item.canonical_rel_path || "").startsWith("docs/journeys/")
1163
+ },
1164
+ { selector: "workflows", kind: "track", label: "workflows", matches: (/** @type {AnyRecord} */ item) => item.track === "workflows" || item.kind === "decision" },
1165
+ { selector: "verification", kind: "kind", label: "verification", matches: (/** @type {AnyRecord} */ item) => item.kind === "verification" },
1166
+ { selector: "ui", kind: "track", label: "UI reports and widgets", matches: (/** @type {AnyRecord} */ item) => item.track === "ui" }
1167
+ ];
1168
+
1169
+ /**
1170
+ * @param {string} inputPath
1171
+ * @returns {AnyRecord}
1172
+ */
1173
+ function readImportAdoptionArtifacts(inputPath) {
1174
+ const projectRoot = normalizeProjectRoot(inputPath);
1175
+ const topogramRoot = normalizeTopogramPath(inputPath);
1176
+ const reconcileRoot = path.join(topogramRoot, "candidates", "reconcile");
1177
+ const paths = {
1178
+ reconcileRoot,
1179
+ adoptionPlanAgent: path.join(reconcileRoot, "adoption-plan.agent.json"),
1180
+ adoptionPlan: path.join(reconcileRoot, "adoption-plan.json"),
1181
+ adoptionStatus: path.join(reconcileRoot, "adoption-status.json"),
1182
+ reconcileReport: path.join(reconcileRoot, "report.json")
1183
+ };
1184
+ if (!fs.existsSync(paths.adoptionPlanAgent)) {
1185
+ throw new Error(`No import adoption plan found under '${reconcileRoot}'. Run 'topogram import <app-path> --out <target>' first.`);
1186
+ }
1187
+ return {
1188
+ projectRoot,
1189
+ topogramRoot,
1190
+ paths,
1191
+ adoptionPlan: JSON.parse(fs.readFileSync(paths.adoptionPlanAgent, "utf8")),
1192
+ adoptionStatus: readJsonIfExists(paths.adoptionStatus),
1193
+ reconcileReport: readJsonIfExists(paths.reconcileReport)
1194
+ };
1195
+ }
1196
+
1197
+ /**
1198
+ * @param {string} projectRoot
1199
+ * @param {AnyRecord} adoptionPlan
1200
+ * @returns {AnyRecord[]}
1201
+ */
1202
+ function buildBrownfieldBroadAdoptSelectors(projectRoot, adoptionPlan) {
1203
+ const surfaces = /** @type {AnyRecord[]} */ (adoptionPlan.imported_proposal_surfaces || []);
1204
+ return BROWNFIELD_BROAD_ADOPT_SELECTORS.map((definition) => {
1205
+ const items = surfaces.filter(definition.matches);
1206
+ const pendingItems = items.filter((/** @type {AnyRecord} */ item) => !["accept", "accepted", "applied"].includes(item.current_state));
1207
+ const appliedItems = items.filter((/** @type {AnyRecord} */ item) => ["accept", "accepted", "applied"].includes(item.current_state));
1208
+ const blockedItems = items.filter((/** @type {AnyRecord} */ item) => item.human_review_required);
1209
+ return {
1210
+ selector: definition.selector,
1211
+ kind: definition.kind,
1212
+ label: definition.label,
1213
+ itemCount: items.length,
1214
+ pendingItemCount: pendingItems.length,
1215
+ appliedItemCount: appliedItems.length,
1216
+ blockedItemCount: blockedItems.length,
1217
+ previewCommand: importAdoptCommand(projectRoot, definition.selector, false),
1218
+ writeCommand: importAdoptCommand(projectRoot, definition.selector, true)
1219
+ };
1220
+ }).filter((selector) => selector.itemCount > 0);
1221
+ }
1222
+
1223
+ /**
1224
+ * @param {AnyRecord} adoptionPlan
1225
+ * @param {AnyRecord} adoptionStatus
1226
+ * @param {string} projectRoot
1227
+ * @returns {AnyRecord}
1228
+ */
1229
+ function summarizeImportAdoption(adoptionPlan, adoptionStatus, projectRoot) {
1230
+ const surfaces = adoptionPlan.imported_proposal_surfaces || [];
1231
+ /** @type {string[]} */
1232
+ const slugs = [];
1233
+ /** @type {Map<string, AnyRecord[]>} */
1234
+ const surfaceMap = new Map();
1235
+ for (const surface of surfaces) {
1236
+ const slug = surface.bundle || "unbundled";
1237
+ if (!surfaceMap.has(slug)) {
1238
+ surfaceMap.set(slug, []);
1239
+ slugs.push(slug);
1240
+ }
1241
+ surfaceMap.get(slug)?.push(surface);
1242
+ }
1243
+ for (const item of /** @type {AnyRecord[]} */ (adoptionStatus?.bundle_priorities || [])) {
1244
+ if (item?.bundle && !surfaceMap.has(item.bundle)) {
1245
+ surfaceMap.set(item.bundle, []);
1246
+ slugs.push(item.bundle);
1247
+ }
1248
+ }
1249
+ const blockersByBundle = new Map((/** @type {AnyRecord[]} */ (adoptionStatus?.bundle_blockers || [])).map((item) => [item.bundle, item]));
1250
+ const prioritiesByBundle = new Map((/** @type {AnyRecord[]} */ (adoptionStatus?.bundle_priorities || [])).map((item) => [item.bundle, item]));
1251
+ const bundles = slugs.sort((left, right) => left.localeCompare(right)).map((slug) => {
1252
+ const bundleSurfaces = surfaceMap.get(slug) || [];
1253
+ const blocker = blockersByBundle.get(slug) || null;
1254
+ const priority = prioritiesByBundle.get(slug) || null;
1255
+ const pendingItems = blocker?.pending_items || bundleSurfaces
1256
+ .filter((/** @type {AnyRecord} */ item) => !["accept", "accepted", "applied"].includes(item.current_state))
1257
+ .map((/** @type {AnyRecord} */ item) => item.item);
1258
+ const appliedItems = blocker?.applied_items || [];
1259
+ const blockedItems = blocker?.blocked_items || [];
1260
+ return {
1261
+ bundle: slug,
1262
+ itemCount: bundleSurfaces.length,
1263
+ pendingItemCount: pendingItems.length,
1264
+ appliedItemCount: appliedItems.length,
1265
+ blockedItemCount: blockedItems.length,
1266
+ humanReviewRequiredCount: bundleSurfaces.filter((/** @type {AnyRecord} */ item) => item.human_review_required).length,
1267
+ kindCounts: countByField(bundleSurfaces, "kind"),
1268
+ complete: Boolean(priority?.is_complete) || (pendingItems.length === 0 && blockedItems.length === 0 && appliedItems.length > 0),
1269
+ evidenceScore: priority?.evidence_score || 0,
1270
+ why: priority?.operator_summary?.whyThisBundle || null,
1271
+ nextCommand: importAdoptCommand(projectRoot, `bundle:${slug}`, false)
1272
+ };
1273
+ });
1274
+ const nextBundle = bundles.find((bundle) => !bundle.complete && bundle.pendingItemCount > 0) || bundles.find((bundle) => !bundle.complete) || bundles[0] || null;
1275
+ const blockedCount = bundles.reduce((total, bundle) => total + bundle.blockedItemCount, 0);
1276
+ const pendingCount = bundles.reduce((total, bundle) => total + bundle.pendingItemCount, 0);
1277
+ const appliedCount = adoptionStatus?.applied_item_count ?? bundles.reduce((total, bundle) => total + bundle.appliedItemCount, 0);
1278
+ return {
1279
+ summary: {
1280
+ bundleCount: bundles.length,
1281
+ proposalItemCount: surfaces.length,
1282
+ pendingItemCount: pendingCount,
1283
+ appliedItemCount: appliedCount,
1284
+ blockedItemCount: blockedCount,
1285
+ requiresHumanReviewCount: (adoptionPlan.requires_human_review || []).length || surfaces.filter((/** @type {AnyRecord} */ item) => item.human_review_required).length
1286
+ },
1287
+ bundles,
1288
+ risks: [
1289
+ ...(blockedCount > 0 ? [`${blockedCount} adoption item(s) are blocked.`] : []),
1290
+ ...(((adoptionPlan.requires_human_review || []).length || surfaces.some((/** @type {AnyRecord} */ item) => item.human_review_required))
1291
+ ? ["Imported proposal items require human review before adoption."]
1292
+ : [])
1293
+ ],
1294
+ nextCommand: nextBundle ? nextBundle.nextCommand : `topogram import status ${importProjectCommandPath(projectRoot)}`
1295
+ };
1296
+ }
1297
+
1298
+ /**
1299
+ * @param {string} inputPath
1300
+ * @returns {AnyRecord}
1301
+ */
1302
+ export function buildBrownfieldImportPlanPayload(inputPath) {
1303
+ const artifacts = readImportAdoptionArtifacts(inputPath);
1304
+ const adoptionStatus = runWorkflow("adoption-status", artifacts.projectRoot).summary || artifacts.adoptionStatus || {};
1305
+ const adoption = summarizeImportAdoption(artifacts.adoptionPlan, adoptionStatus, artifacts.projectRoot);
1306
+ return {
1307
+ ok: true,
1308
+ projectRoot: artifacts.projectRoot,
1309
+ topogramRoot: artifacts.topogramRoot,
1310
+ artifacts: {
1311
+ adoptionPlan: artifacts.paths.adoptionPlanAgent,
1312
+ adoptionStatus: artifacts.paths.adoptionStatus,
1313
+ reconcileReport: artifacts.paths.reconcileReport
1314
+ },
1315
+ ...adoption,
1316
+ commands: {
1317
+ check: `topogram import check ${importProjectCommandPath(artifacts.projectRoot)}`,
1318
+ status: `topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`,
1319
+ next: adoption.nextCommand
1320
+ }
1321
+ };
1322
+ }
1323
+
1324
+ /**
1325
+ * @param {AnyRecord} payload
1326
+ * @returns {void}
1327
+ */
1328
+ export function printBrownfieldImportPlan(payload) {
1329
+ console.log(`Import adoption plan for ${payload.projectRoot}`);
1330
+ console.log(`Proposal items: ${payload.summary.proposalItemCount}`);
1331
+ console.log(`Bundles: ${payload.summary.bundleCount}`);
1332
+ for (const bundle of payload.bundles) {
1333
+ console.log(`- ${bundle.bundle}: ${bundle.itemCount} item(s), ${bundle.pendingItemCount} pending, ${bundle.appliedItemCount} applied`);
1334
+ if (bundle.why) {
1335
+ console.log(` ${bundle.why}`);
1336
+ }
1337
+ console.log(` Preview: ${bundle.nextCommand}`);
1338
+ }
1339
+ if (payload.risks.length > 0) {
1340
+ console.log("Risks:");
1341
+ for (const risk of payload.risks) {
1342
+ console.log(`- ${risk}`);
1343
+ }
1344
+ }
1345
+ console.log("");
1346
+ console.log(`Next: ${payload.nextCommand}`);
1347
+ }
1348
+
1349
+ /**
1350
+ * @param {string} inputPath
1351
+ * @returns {AnyRecord}
1352
+ */
1353
+ export function buildBrownfieldImportAdoptListPayload(inputPath) {
1354
+ const artifacts = readImportAdoptionArtifacts(inputPath);
1355
+ const plan = buildBrownfieldImportPlanPayload(inputPath);
1356
+ const selectors = plan.bundles.map((/** @type {AnyRecord} */ bundle) => ({
1357
+ selector: `bundle:${bundle.bundle}`,
1358
+ kind: "bundle",
1359
+ bundle: bundle.bundle,
1360
+ itemCount: bundle.itemCount,
1361
+ pendingItemCount: bundle.pendingItemCount,
1362
+ appliedItemCount: bundle.appliedItemCount,
1363
+ blockedItemCount: bundle.blockedItemCount,
1364
+ complete: bundle.complete,
1365
+ previewCommand: importAdoptCommand(plan.projectRoot, `bundle:${bundle.bundle}`, false),
1366
+ writeCommand: importAdoptCommand(plan.projectRoot, `bundle:${bundle.bundle}`, true)
1367
+ }));
1368
+ const broadSelectors = buildBrownfieldBroadAdoptSelectors(plan.projectRoot, artifacts.adoptionPlan);
1369
+ return {
1370
+ ok: true,
1371
+ projectRoot: plan.projectRoot,
1372
+ topogramRoot: plan.topogramRoot,
1373
+ selectorCount: selectors.length,
1374
+ selectors,
1375
+ broadSelectorCount: broadSelectors.length,
1376
+ broadSelectors,
1377
+ nextCommand: selectors.find((/** @type {AnyRecord} */ selector) => !selector.complete)?.previewCommand || plan.commands.status
1378
+ };
1379
+ }
1380
+
1381
+ /**
1382
+ * @param {AnyRecord} payload
1383
+ * @returns {void}
1384
+ */
1385
+ export function printBrownfieldImportAdoptList(payload) {
1386
+ console.log(`Import adoption selectors for ${payload.projectRoot}`);
1387
+ if (payload.selectors.length === 0) {
1388
+ console.log("No adoption selectors are available. Run `topogram import plan` to inspect reconcile artifacts.");
1389
+ return;
1390
+ }
1391
+ for (const selector of payload.selectors) {
1392
+ console.log(`- ${selector.selector}: ${selector.itemCount} item(s), ${selector.pendingItemCount} pending, ${selector.appliedItemCount} applied`);
1393
+ console.log(` Preview: ${selector.previewCommand}`);
1394
+ console.log(` Write: ${selector.writeCommand}`);
1395
+ }
1396
+ if (payload.broadSelectors.length > 0) {
1397
+ console.log("");
1398
+ console.log("Broad selectors:");
1399
+ for (const selector of payload.broadSelectors) {
1400
+ console.log(`- ${selector.selector}: ${selector.itemCount} ${selector.label}`);
1401
+ console.log(` Preview: ${selector.previewCommand}`);
1402
+ console.log(` Write: ${selector.writeCommand}`);
1403
+ }
1404
+ }
1405
+ console.log("");
1406
+ console.log(`Next: ${payload.nextCommand}`);
1407
+ }
1408
+
1409
+ /**
1410
+ * @param {string} outputRoot
1411
+ * @param {string[]} writtenFiles
1412
+ * @returns {AnyRecord[]}
1413
+ */
1414
+ function writtenFileHashesForReceipt(outputRoot, writtenFiles) {
1415
+ return (writtenFiles || []).map((relativePath) => {
1416
+ const filePath = path.join(outputRoot, relativePath);
1417
+ const hash = fs.existsSync(filePath) ? projectFileHash(filePath) : null;
1418
+ return {
1419
+ path: relativePath,
1420
+ sha256: hash?.sha256 || null,
1421
+ size: hash?.size || null
1422
+ };
1423
+ });
1424
+ }
1425
+
1426
+ /**
1427
+ * @param {{ artifacts: AnyRecord, selector: string, options: AnyRecord, importStatus: AnyRecord, summary: AnyRecord, writtenFiles: string[], outputRoot: string }} input
1428
+ * @returns {AnyRecord}
1429
+ */
1430
+ function buildImportAdoptionReceipt({ artifacts, selector, options, importStatus, summary, writtenFiles, outputRoot }) {
1431
+ return {
1432
+ type: "topogram_import_adoption_receipt",
1433
+ version: "0.1",
1434
+ timestamp: new Date().toISOString(),
1435
+ cli: {
1436
+ packageName: CLI_PACKAGE_NAME,
1437
+ version: readInstalledCliPackageVersion()
1438
+ },
1439
+ projectRoot: artifacts.projectRoot,
1440
+ topogramRoot: artifacts.topogramRoot,
1441
+ selector,
1442
+ mode: "write",
1443
+ dryRun: false,
1444
+ forced: Boolean(options.force),
1445
+ reason: options.reason || null,
1446
+ sourceProvenance: {
1447
+ ok: importStatus.ok,
1448
+ status: importStatus.status,
1449
+ path: importStatus.path || null,
1450
+ changed: importStatus.content?.changed || [],
1451
+ added: importStatus.content?.added || [],
1452
+ removed: importStatus.content?.removed || []
1453
+ },
1454
+ promotedCanonicalItems: (summary.promoted_canonical_items || []).map((/** @type {AnyRecord} */ item) => ({
1455
+ bundle: item.bundle || null,
1456
+ kind: item.kind || null,
1457
+ item: item.item || null,
1458
+ canonicalRelPath: item.canonical_rel_path || null,
1459
+ sourcePath: item.source_path || null,
1460
+ changeType: item.change_type || null
1461
+ })),
1462
+ writtenFiles,
1463
+ writtenFileHashes: writtenFileHashesForReceipt(outputRoot, writtenFiles),
1464
+ outputRoot
1465
+ };
1466
+ }
1467
+
1468
+ /**
1469
+ * @param {string} selector
1470
+ * @param {string} inputPath
1471
+ * @param {{ write?: boolean, dryRun?: boolean, force?: boolean, reason?: string|null, refreshAdopted?: boolean }} [options]
1472
+ * @returns {AnyRecord}
1473
+ */
1474
+ export function buildBrownfieldImportAdoptPayload(selector, inputPath, options = {}) {
1475
+ if (!selector) {
1476
+ throw new Error("Missing required <selector>. Example: topogram import adopt bundle:task --dry-run");
1477
+ }
1478
+ if (options.write && options.dryRun) {
1479
+ throw new Error("Use either --dry-run or --write, not both.");
1480
+ }
1481
+ if (options.write && options.force && !options.reason) {
1482
+ throw new Error("Forced import adoption writes require --reason <text>.");
1483
+ }
1484
+ const artifacts = readImportAdoptionArtifacts(inputPath);
1485
+ const importStatus = buildTopogramImportStatus(artifacts.projectRoot);
1486
+ if (options.write && !options.force && !importStatus.ok) {
1487
+ throw new Error(`Refusing to write import adoption because brownfield source provenance is ${importStatus.status}. Run 'topogram import check ${importProjectCommandPath(artifacts.projectRoot)}', review the changed source evidence, rerun import, or pass --force --reason <text> after review.`);
1488
+ }
1489
+ const result = runWorkflow("reconcile", artifacts.projectRoot, {
1490
+ adopt: selector,
1491
+ write: Boolean(options.write),
1492
+ refreshAdopted: Boolean(options.refreshAdopted)
1493
+ });
1494
+ const outputRoot = path.resolve(result.defaultOutDir || artifacts.topogramRoot);
1495
+ const writtenFiles = options.write ? writeRelativeFiles(outputRoot, result.files || {}) : [];
1496
+ const summary = result.summary || {};
1497
+ const receipt = options.write
1498
+ ? buildImportAdoptionReceipt({ artifacts, selector, options, importStatus, summary, writtenFiles, outputRoot })
1499
+ : null;
1500
+ const receiptPath = receipt ? appendImportAdoptionReceipt(artifacts.projectRoot, receipt) : null;
1501
+ return {
1502
+ ok: true,
1503
+ projectRoot: artifacts.projectRoot,
1504
+ topogramRoot: artifacts.topogramRoot,
1505
+ selector,
1506
+ dryRun: !options.write,
1507
+ write: Boolean(options.write),
1508
+ forced: Boolean(options.force),
1509
+ reason: options.reason || null,
1510
+ outputRoot,
1511
+ promotedCanonicalItemCount: (summary.promoted_canonical_items || []).length,
1512
+ promotedCanonicalItems: summary.promoted_canonical_items || [],
1513
+ writtenFiles,
1514
+ receipt,
1515
+ receiptPath,
1516
+ adoption: summary,
1517
+ import: importStatus,
1518
+ warnings: options.write && options.force && !importStatus.ok
1519
+ ? [`Brownfield source provenance is ${importStatus.status}; adoption write was forced with reason: ${options.reason}.`]
1520
+ : [],
1521
+ nextCommands: options.write
1522
+ ? [
1523
+ `topogram import history ${importProjectCommandPath(artifacts.projectRoot)}`,
1524
+ `topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`,
1525
+ `topogram check ${importProjectCommandPath(artifacts.projectRoot)}`
1526
+ ]
1527
+ : [
1528
+ importAdoptCommand(artifacts.projectRoot, selector, true),
1529
+ `topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`
1530
+ ]
1531
+ };
1532
+ }
1533
+
1534
+ /**
1535
+ * @param {AnyRecord} payload
1536
+ * @returns {void}
1537
+ */
1538
+ export function printBrownfieldImportAdopt(payload) {
1539
+ console.log(`${payload.dryRun ? "Previewed" : "Applied"} import adoption for ${payload.selector}.`);
1540
+ console.log(`Project: ${payload.projectRoot}`);
1541
+ console.log(`Promoted canonical items: ${payload.promotedCanonicalItemCount}`);
1542
+ console.log(`Written files: ${payload.writtenFiles.length}`);
1543
+ if (payload.receiptPath) {
1544
+ console.log(`Receipt: ${payload.receiptPath}`);
1545
+ }
1546
+ if (payload.dryRun) {
1547
+ console.log("No files were written. Re-run with --write to promote these candidates.");
1548
+ }
1549
+ for (const warning of payload.warnings || []) {
1550
+ console.log(`Warning: ${warning}`);
1551
+ }
1552
+ console.log("");
1553
+ console.log("Next steps:");
1554
+ for (const command of payload.nextCommands) {
1555
+ console.log(` ${command}`);
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * @param {string} inputPath
1561
+ * @returns {AnyRecord}
1562
+ */
1563
+ export function buildBrownfieldImportStatusPayload(inputPath) {
1564
+ const artifacts = readImportAdoptionArtifacts(inputPath);
1565
+ const importCheck = buildBrownfieldImportCheckPayload(artifacts.projectRoot);
1566
+ const adoptionStatus = runWorkflow("adoption-status", artifacts.projectRoot).summary || artifacts.adoptionStatus || {};
1567
+ const adoption = summarizeImportAdoption(artifacts.adoptionPlan, adoptionStatus, artifacts.projectRoot);
1568
+ const history = buildBrownfieldImportHistoryPayload(artifacts.projectRoot);
1569
+ return {
1570
+ ok: importCheck.ok,
1571
+ projectRoot: artifacts.projectRoot,
1572
+ topogramRoot: artifacts.topogramRoot,
1573
+ import: importCheck.import,
1574
+ topogram: importCheck.topogram,
1575
+ adoption: {
1576
+ status: adoptionStatus,
1577
+ summary: adoption.summary,
1578
+ bundles: adoption.bundles,
1579
+ risks: adoption.risks,
1580
+ nextCommand: adoption.nextCommand,
1581
+ history: history.summary
1582
+ },
1583
+ errors: importCheck.errors
1584
+ };
1585
+ }
1586
+
1587
+ /**
1588
+ * @param {AnyRecord} payload
1589
+ * @returns {void}
1590
+ */
1591
+ export function printBrownfieldImportStatus(payload) {
1592
+ console.log(`Import status: ${payload.import.status}`);
1593
+ console.log(`Topogram check: ${payload.topogram.ok ? "passed" : "failed"}`);
1594
+ console.log(`Adoption: ${payload.adoption.summary.appliedItemCount} applied, ${payload.adoption.summary.pendingItemCount} pending, ${payload.adoption.summary.blockedItemCount} blocked`);
1595
+ const next = payload.adoption.nextCommand;
1596
+ if (next) {
1597
+ console.log(`Next: ${next}`);
1598
+ }
1599
+ }
1600
+
1601
+ /**
1602
+ * @param {string} projectRoot
1603
+ * @param {AnyRecord[]} receipts
1604
+ * @returns {AnyRecord}
1605
+ */
1606
+ function verifyImportAdoptionReceipts(projectRoot, receipts) {
1607
+ const topogramRoot = normalizeTopogramPath(projectRoot);
1608
+ const files = [];
1609
+ for (const receipt of receipts || []) {
1610
+ const hashedFiles = Array.isArray(receipt.writtenFileHashes) ? receipt.writtenFileHashes : [];
1611
+ const hashedPaths = new Set(hashedFiles.map((/** @type {AnyRecord} */ item) => item.path));
1612
+ for (const item of hashedFiles) {
1613
+ const relativePath = item.path;
1614
+ const filePath = path.join(topogramRoot, relativePath);
1615
+ if (!fs.existsSync(filePath)) {
1616
+ files.push({
1617
+ receiptTimestamp: receipt.timestamp || null,
1618
+ selector: receipt.selector || null,
1619
+ path: relativePath,
1620
+ status: "removed",
1621
+ expectedSha256: item.sha256 || null,
1622
+ currentSha256: null,
1623
+ expectedSize: item.size ?? null,
1624
+ currentSize: null
1625
+ });
1626
+ continue;
1627
+ }
1628
+ const currentHash = projectFileHash(filePath);
1629
+ const matches = item.sha256 === currentHash.sha256 && item.size === currentHash.size;
1630
+ files.push({
1631
+ receiptTimestamp: receipt.timestamp || null,
1632
+ selector: receipt.selector || null,
1633
+ path: relativePath,
1634
+ status: matches ? "matched" : "changed",
1635
+ expectedSha256: item.sha256 || null,
1636
+ currentSha256: currentHash.sha256,
1637
+ expectedSize: item.size ?? null,
1638
+ currentSize: currentHash.size
1639
+ });
1640
+ }
1641
+ for (const relativePath of receipt.writtenFiles || []) {
1642
+ if (hashedPaths.has(relativePath)) {
1643
+ continue;
1644
+ }
1645
+ files.push({
1646
+ receiptTimestamp: receipt.timestamp || null,
1647
+ selector: receipt.selector || null,
1648
+ path: relativePath,
1649
+ status: "unverifiable",
1650
+ expectedSha256: null,
1651
+ currentSha256: null,
1652
+ expectedSize: null,
1653
+ currentSize: null
1654
+ });
1655
+ }
1656
+ }
1657
+ const summary = {
1658
+ checkedFileCount: files.length,
1659
+ matchedFileCount: files.filter((item) => item.status === "matched").length,
1660
+ changedFileCount: files.filter((item) => item.status === "changed").length,
1661
+ removedFileCount: files.filter((item) => item.status === "removed").length,
1662
+ unverifiableFileCount: files.filter((item) => item.status === "unverifiable").length
1663
+ };
1664
+ const status = summary.changedFileCount > 0 || summary.removedFileCount > 0
1665
+ ? "changed"
1666
+ : summary.unverifiableFileCount > 0
1667
+ ? "unverifiable"
1668
+ : "matched";
1669
+ return {
1670
+ status,
1671
+ summary,
1672
+ files,
1673
+ auditOnly: true,
1674
+ note: "History verification is audit-only. Imported/adopted Topogram files are project-owned, and edits do not make the workspace invalid."
1675
+ };
1676
+ }
1677
+
1678
+ /**
1679
+ * @param {string} inputPath
1680
+ * @param {{ verify?: boolean }} [options]
1681
+ * @returns {AnyRecord}
1682
+ */
1683
+ export function buildBrownfieldImportHistoryPayload(inputPath, options = {}) {
1684
+ const projectRoot = normalizeProjectRoot(inputPath);
1685
+ const historyPath = importAdoptionsPath(projectRoot);
1686
+ const receipts = readImportAdoptionReceipts(projectRoot);
1687
+ const forcedWrites = receipts.filter((receipt) => receipt.forced);
1688
+ const verification = options.verify ? verifyImportAdoptionReceipts(projectRoot, receipts) : null;
1689
+ return {
1690
+ ok: true,
1691
+ projectRoot,
1692
+ path: historyPath,
1693
+ exists: fs.existsSync(historyPath),
1694
+ verified: Boolean(options.verify),
1695
+ summary: {
1696
+ receiptCount: receipts.length,
1697
+ writeCount: receipts.filter((receipt) => receipt.mode === "write").length,
1698
+ forcedWriteCount: forcedWrites.length,
1699
+ lastTimestamp: receipts[receipts.length - 1]?.timestamp || null,
1700
+ lastSelector: receipts[receipts.length - 1]?.selector || null
1701
+ },
1702
+ verification,
1703
+ receipts
1704
+ };
1705
+ }
1706
+
1707
+ /**
1708
+ * @param {AnyRecord} payload
1709
+ * @returns {void}
1710
+ */
1711
+ export function printBrownfieldImportHistory(payload) {
1712
+ console.log(`Import adoption history for ${payload.projectRoot}`);
1713
+ console.log(`Receipts: ${payload.summary.receiptCount}`);
1714
+ console.log(`Forced writes: ${payload.summary.forcedWriteCount}`);
1715
+ if (!payload.exists) {
1716
+ console.log(`No history file found at ${payload.path}.`);
1717
+ return;
1718
+ }
1719
+ for (const receipt of payload.receipts) {
1720
+ const forced = receipt.forced ? " forced" : "";
1721
+ const reason = receipt.reason ? ` reason="${receipt.reason}"` : "";
1722
+ console.log(`- ${receipt.timestamp}: ${receipt.selector}${forced}, ${receipt.writtenFiles?.length || 0} file(s), source=${receipt.sourceProvenance?.status || "unknown"}${reason}`);
1723
+ }
1724
+ if (payload.verification) {
1725
+ const summary = payload.verification.summary;
1726
+ console.log("");
1727
+ console.log(`Verification: ${payload.verification.status}`);
1728
+ console.log(`Matched: ${summary.matchedFileCount}; changed: ${summary.changedFileCount}; removed: ${summary.removedFileCount}; unverifiable: ${summary.unverifiableFileCount}`);
1729
+ for (const file of payload.verification.files.filter((/** @type {AnyRecord} */ item) => item.status !== "matched")) {
1730
+ console.log(`- ${file.path}: ${file.status}`);
1731
+ }
1732
+ console.log(payload.verification.note);
1733
+ }
1734
+ }