@topogram/cli 0.3.65 → 0.3.66

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 (67) hide show
  1. package/package.json +1 -1
  2. package/src/adoption/plan/index.js +21 -8
  3. package/src/adoption/reporting.js +1 -1
  4. package/src/agent-brief.js +7 -21
  5. package/src/agent-ops/query-builders/change-risk/review-packets.js +2 -2
  6. package/src/agent-ops/query-builders/common.js +2 -2
  7. package/src/agent-ops/query-builders/multi-agent.js +1 -1
  8. package/src/agent-ops/query-builders/workflow-presets-core.js +3 -2
  9. package/src/archive/jsonl.js +2 -2
  10. package/src/archive/resolver-bridge.js +1 -1
  11. package/src/archive/unarchive.js +2 -1
  12. package/src/catalog/copy.js +11 -6
  13. package/src/catalog/provenance.js +2 -1
  14. package/src/cli/command-parsers/project.js +3 -0
  15. package/src/cli/command-parsers/shared.js +1 -1
  16. package/src/cli/commands/agent.js +2 -2
  17. package/src/cli/commands/check.js +3 -3
  18. package/src/cli/commands/doctor.js +2 -9
  19. package/src/cli/commands/generator-policy/runner.js +1 -1
  20. package/src/cli/commands/import/help.js +2 -2
  21. package/src/cli/commands/import/paths.js +3 -11
  22. package/src/cli/commands/import/plan.js +9 -1
  23. package/src/cli/commands/import/refresh.js +7 -6
  24. package/src/cli/commands/import/workspace.js +8 -5
  25. package/src/cli/commands/migrate.js +153 -0
  26. package/src/cli/commands/query/definitions.js +10 -10
  27. package/src/cli/commands/query/workspace.js +2 -6
  28. package/src/cli/commands/source.js +3 -12
  29. package/src/cli/commands/template/check.js +6 -5
  30. package/src/cli/commands/template-runner.js +6 -6
  31. package/src/cli/commands/trust.js +1 -1
  32. package/src/cli/commands/workflow.js +6 -1
  33. package/src/cli/dispatcher.js +6 -1
  34. package/src/cli/help.js +15 -14
  35. package/src/cli/migration-guidance.js +1 -1
  36. package/src/cli/output-safety.js +2 -1
  37. package/src/cli/path-normalization.js +3 -13
  38. package/src/generator/context/domain-page.js +1 -1
  39. package/src/generator/context/shared/maintained-boundary.js +2 -2
  40. package/src/generator/context/shared/metrics.js +2 -2
  41. package/src/generator/context/task-mode.js +2 -2
  42. package/src/generator/sdlc/doc-page.js +1 -1
  43. package/src/generator/surfaces/databases/lifecycle-shared.js +1 -1
  44. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +1 -1
  45. package/src/import/core/context.js +5 -7
  46. package/src/import/core/runner/candidates.js +123 -3
  47. package/src/import/core/runner/reports.js +4 -3
  48. package/src/import/core/runner/ui-drafts.js +58 -2
  49. package/src/new-project/constants.js +1 -1
  50. package/src/new-project/create.js +9 -2
  51. package/src/new-project/project-files.js +16 -13
  52. package/src/new-project/template-resolution.js +6 -4
  53. package/src/new-project/template-snapshots.js +19 -7
  54. package/src/new-project/template-updates.js +1 -1
  55. package/src/project-config/index.js +27 -0
  56. package/src/sdlc/adopt.js +6 -5
  57. package/src/sdlc/paths.js +3 -5
  58. package/src/sdlc/scaffold.js +2 -1
  59. package/src/workflows/reconcile/adoption-plan/build.js +7 -3
  60. package/src/workflows/reconcile/adoption-plan/outputs.js +12 -2
  61. package/src/workflows/reconcile/adoption-plan/paths.js +1 -1
  62. package/src/workflows/reconcile/candidate-model.js +18 -2
  63. package/src/workflows/reconcile/impacts/adoption-plan.js +6 -2
  64. package/src/workflows/reconcile/impacts/indexes.js +5 -1
  65. package/src/workflows/reconcile/renderers.js +41 -6
  66. package/src/workflows/shared.js +5 -11
  67. package/src/workspace-paths.js +328 -0
@@ -9,6 +9,7 @@ import {
9
9
  validateGeneratorManifest
10
10
  } from "../generator/registry.js";
11
11
  import { validateProjectGeneratorPolicy } from "../generator-policy.js";
12
+ import { DEFAULT_WORKSPACE_PATH, normalizeWorkspaceConfigPath } from "../workspace-paths.js";
12
13
 
13
14
  /**
14
15
  * @typedef {Object} GeneratorBinding
@@ -32,6 +33,7 @@ import { validateProjectGeneratorPolicy } from "../generator-policy.js";
32
33
  /**
33
34
  * @typedef {Object} ProjectConfig
34
35
  * @property {string} version
36
+ * @property {string} [workspace]
35
37
  * @property {Record<string, { path: string, ownership: "generated"|"maintained" }>} outputs
36
38
  * @property {{ runtimes: RuntimeTopologyRuntime[] }} topology
37
39
  * @property {{ id?: string, module?: string, export?: string, implementation_module?: string, implementation_export?: string }} [implementation]
@@ -206,6 +208,7 @@ export function defaultProjectConfigForGraph(graph, implementation = null) {
206
208
 
207
209
  return {
208
210
  version: "0.1",
211
+ workspace: DEFAULT_WORKSPACE_PATH,
209
212
  implementation: implementation?.exampleId
210
213
  ? {
211
214
  id: implementation.exampleId
@@ -223,6 +226,29 @@ export function defaultProjectConfigForGraph(graph, implementation = null) {
223
226
  };
224
227
  }
225
228
 
229
+ /**
230
+ * @param {ValidationError[]} errors
231
+ * @param {any} config
232
+ * @returns {void}
233
+ */
234
+ function validateWorkspaceConfig(errors, config) {
235
+ if (Object.prototype.hasOwnProperty.call(config, "workspaces")) {
236
+ pushError(errors, "topogram.project.json workspaces[] is not supported yet; use workspace instead");
237
+ }
238
+ if (!Object.prototype.hasOwnProperty.call(config, "workspace")) {
239
+ return;
240
+ }
241
+ if (typeof config.workspace !== "string") {
242
+ pushError(errors, "topogram.project.json workspace must be a non-empty relative path");
243
+ return;
244
+ }
245
+ try {
246
+ normalizeWorkspaceConfigPath(config.workspace);
247
+ } catch (error) {
248
+ pushError(errors, error instanceof Error ? error.message : String(error));
249
+ }
250
+ }
251
+
226
252
  /**
227
253
  * @param {string} root
228
254
  * @returns {ProjectConfigInfo|null}
@@ -448,6 +474,7 @@ export function validateProjectConfig(config, graph = null, options = {}) {
448
474
  if (typeof config.version !== "string" || config.version.length === 0) {
449
475
  pushError(errors, "topogram.project.json version must be a non-empty string");
450
476
  }
477
+ validateWorkspaceConfig(errors, config);
451
478
  validateOutputConfig(errors, config);
452
479
  if (config.topology?.components != null) {
453
480
  pushError(errors, `topogram.project.json ${renameDiagnostic("'topology.components'", "'topology.runtimes'", `"topology": { "runtimes": [] }`)}`);
package/src/sdlc/adopt.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
8
8
  import path from "node:path";
9
+ import { DEFAULT_TOPO_FOLDER_NAME, resolveTopoRoot } from "../workspace-paths.js";
9
10
 
10
11
  const SDLC_FOLDERS = [
11
12
  "pitches",
@@ -17,7 +18,7 @@ const SDLC_FOLDERS = [
17
18
  ];
18
19
 
19
20
  function ensureFolder(root, name) {
20
- const dir = path.join(root, "topogram", name);
21
+ const dir = path.join(resolveTopoRoot(root), name);
21
22
  if (!existsSync(dir)) {
22
23
  mkdirSync(dir, { recursive: true });
23
24
  return { name, created: true };
@@ -29,8 +30,8 @@ function scanPressure(root) {
29
30
  // Pressure scan: count statements per kind so the operator knows the
30
31
  // workspace's current shape. We do not attempt to backfill — that's the
31
32
  // operator's job after they pick a starting point.
32
- const tg = path.join(root, "topogram");
33
- if (!existsSync(tg)) return { error: `No 'topogram/' directory found at ${root}` };
33
+ const tg = resolveTopoRoot(root);
34
+ if (!existsSync(tg)) return { error: `No '${DEFAULT_TOPO_FOLDER_NAME}/' workspace folder found at ${root}` };
34
35
  let totalFiles = 0;
35
36
  function walk(dir) {
36
37
  for (const entry of readdirSync(dir)) {
@@ -50,8 +51,8 @@ function scanPressure(root) {
50
51
 
51
52
  export function sdlcAdopt(workspaceRoot) {
52
53
  const root = path.resolve(workspaceRoot);
53
- if (!existsSync(path.join(root, "topogram"))) {
54
- return { ok: false, error: `No 'topogram/' directory at ${root}; run 'topogram new' first` };
54
+ if (!existsSync(resolveTopoRoot(root))) {
55
+ return { ok: false, error: `No '${DEFAULT_TOPO_FOLDER_NAME}/' workspace folder at ${root}; run 'topogram new' first` };
55
56
  }
56
57
  const folders = SDLC_FOLDERS.map((name) => ensureFolder(root, name));
57
58
  const pressure = scanPressure(root);
package/src/sdlc/paths.js CHANGED
@@ -1,11 +1,9 @@
1
- import path from "node:path";
1
+ import { resolveTopoRoot, resolveWorkspaceContext } from "../workspace-paths.js";
2
2
 
3
3
  export function topogramRootForSdlc(inputPath) {
4
- const absolute = path.resolve(inputPath);
5
- return path.basename(absolute) === "topogram" ? absolute : path.join(absolute, "topogram");
4
+ return resolveTopoRoot(inputPath);
6
5
  }
7
6
 
8
7
  export function projectRootForSdlc(inputPath) {
9
- const absolute = path.resolve(inputPath);
10
- return path.basename(absolute) === "topogram" ? path.dirname(absolute) : absolute;
8
+ return resolveWorkspaceContext(inputPath).projectRoot;
11
9
  }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
+ import { resolveTopoRoot } from "../workspace-paths.js";
6
7
 
7
8
  const TEMPLATES = {
8
9
  pitch: (slug) => `pitch pitch_${slug} {
@@ -73,7 +74,7 @@ export function scaffoldNew(workspaceRoot, kind, slug) {
73
74
  if (!slug || !/^[a-z][a-z0-9_]*$/.test(slug)) {
74
75
  return { ok: false, error: `Invalid slug '${slug}' — must match /^[a-z][a-z0-9_]*$/` };
75
76
  }
76
- const targetDir = path.join(workspaceRoot, "topogram", `${kind === "acceptance_criterion" ? "acceptance_criteria" : kind + "s"}`);
77
+ const targetDir = path.join(resolveTopoRoot(workspaceRoot), `${kind === "acceptance_criterion" ? "acceptance_criteria" : kind + "s"}`);
77
78
  if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
78
79
  const targetFile = path.join(targetDir, `${slug}.tg`);
79
80
  if (existsSync(targetFile)) {
@@ -82,8 +82,12 @@ export function buildAdoptionPlan(bundles) {
82
82
  target: step.target || null,
83
83
  confidence: step.confidence || null,
84
84
  inference_summary: step.inference_summary || null,
85
+ source_kind: step.source_kind || null,
86
+ widget_id: step.widget_id || null,
87
+ event_name: step.event_name || null,
85
88
  related_docs: step.related_docs || [],
86
89
  related_capabilities: step.related_capabilities || [],
90
+ related_shapes: step.related_shapes || [],
87
91
  permission: step.permission || null,
88
92
  claim: step.claim || null,
89
93
  claim_value: Object.prototype.hasOwnProperty.call(step, "claim_value") ? step.claim_value : null,
@@ -95,7 +99,7 @@ export function buildAdoptionPlan(bundles) {
95
99
  canonical_path:
96
100
  step.action === "skip_duplicate_shape"
97
101
  ? (step.target ? canonicalDisplayPathForItem("shape", step.target) : null)
98
- : (step.canonical_rel_path ? `topogram/${step.canonical_rel_path}` : canonicalDisplayPathForItem(itemKind, step.item)),
102
+ : (step.canonical_rel_path ? `topo/${step.canonical_rel_path}` : canonicalDisplayPathForItem(itemKind, step.item)),
99
103
  canonical_rel_path: step.canonical_rel_path || canonicalRelativePathForItem(itemKind, step.item),
100
104
  reason: reasonForAdoptionItem(step),
101
105
  recommendation: recommendationForAdoptionItem(step),
@@ -155,7 +159,7 @@ export function buildAdoptionPlan(bundles) {
155
159
  target: patch.doc_id,
156
160
  status: "pending",
157
161
  source_path: `candidates/reconcile/model/bundles/${bundle.slug}/${patch.patch_rel_path}`,
158
- canonical_path: patch.canonical_rel_path ? `topogram/${patch.canonical_rel_path}` : null,
162
+ canonical_path: patch.canonical_rel_path ? `topo/${patch.canonical_rel_path}` : null,
159
163
  canonical_rel_path: patch.canonical_rel_path || null,
160
164
  reason: `Apply suggested related actor/role links to \`${patch.doc_id}\`.`,
161
165
  recommendation: recommendationForAdoptionItem({ action: "apply_doc_link_patch", target: patch.doc_id }),
@@ -182,7 +186,7 @@ export function buildAdoptionPlan(bundles) {
182
186
  status: "pending",
183
187
  confidence: patch.imported_confidence || "low",
184
188
  source_path: `candidates/reconcile/model/bundles/${bundle.slug}/${patch.patch_rel_path}`,
185
- canonical_path: patch.canonical_rel_path ? `topogram/${patch.canonical_rel_path}` : null,
189
+ canonical_path: patch.canonical_rel_path ? `topo/${patch.canonical_rel_path}` : null,
186
190
  canonical_rel_path: patch.canonical_rel_path || null,
187
191
  reason: `Apply suggested safe metadata updates to \`${patch.doc_id}\`.`,
188
192
  recommendation: recommendationForAdoptionItem({ action: "apply_doc_metadata_patch", target: patch.doc_id }),
@@ -33,8 +33,18 @@ export function buildCanonicalAdoptionOutputs(paths, candidateFiles, planItems,
33
33
  .filter((/** @type {any} */ item) => item?.kind === "capability")
34
34
  .map((/** @type {any} */ item) => item.bundle)
35
35
  );
36
+ const widgetShapeIdSet = new Set(
37
+ [...selectedSet]
38
+ .map((/** @type {any} */ key) => itemMap.get(key))
39
+ .filter((/** @type {any} */ item) => item?.kind === "widget")
40
+ .flatMap((/** @type {any} */ item) => item.related_shapes || [])
41
+ );
36
42
  for (const item of planItems) {
37
- if (item.kind === "shape" && item.status !== "skipped" && capabilityBundleSet.has(item.bundle)) {
43
+ if (
44
+ item.kind === "shape" &&
45
+ item.status !== "skipped" &&
46
+ (capabilityBundleSet.has(item.bundle) || widgetShapeIdSet.has(item.item))
47
+ ) {
38
48
  selectedSet.add(adoptionItemKey(item));
39
49
  }
40
50
  }
@@ -131,7 +141,7 @@ export function buildPromotedCanonicalItems(planItems, selectedItems, writtenCan
131
141
  track: item.track || null,
132
142
  source_path: item.source_path || null,
133
143
  canonical_rel_path: String(item.canonical_rel_path).replaceAll(path.sep, "/"),
134
- canonical_path: item.canonical_path || `topogram/${String(item.canonical_rel_path).replaceAll(path.sep, "/")}`,
144
+ canonical_path: item.canonical_path || `topo/${String(item.canonical_rel_path).replaceAll(path.sep, "/")}`,
135
145
  suggested_action: item.suggested_action || null
136
146
  }))
137
147
  .sort((/** @type {any} */ a, /** @type {any} */ b) =>
@@ -29,7 +29,7 @@ export function canonicalRelativePathForItem(kind, item) {
29
29
  /** @param {string} kind @param {string} item @returns {any} */
30
30
  export function canonicalDisplayPathForItem(kind, item) {
31
31
  const relativePath = canonicalRelativePathForItem(kind, item);
32
- return relativePath ? `topogram/${relativePath}` : null;
32
+ return relativePath ? `topo/${relativePath}` : null;
33
33
  }
34
34
 
35
35
  /** @param {CandidateBundle} bundle @param {string} kind @param {WorkflowRecord} item @returns {any} */
@@ -82,8 +82,10 @@ export function bestContextBundleForCandidate(bundles, candidate) {
82
82
  export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
83
83
  const dbCandidates = appImport.candidates.db || { entities: [], enums: [] };
84
84
  const apiCandidates = appImport.candidates.api || { capabilities: [] };
85
- const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], widgets: [] };
85
+ const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], widgets: [], shapes: [] };
86
86
  const uiWidgetCandidates = uiCandidates.widgets || uiCandidates.components || [];
87
+ const uiShapeCandidates = uiCandidates.shapes || [];
88
+ const uiShapeCandidatesById = new Map(uiShapeCandidates.map((/** @type {any} */ shape) => [shape.id || shape.id_hint, shape]));
87
89
  const workflowCandidates = appImport.candidates.workflows || { workflows: [], workflow_states: [], workflow_transitions: [] };
88
90
  const verificationCandidates = appImport.candidates.verification || { verifications: [], scenarios: [], frameworks: [], scripts: [] };
89
91
  const docCandidates = appImport.candidates.docs || [];
@@ -210,6 +212,20 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
210
212
  }
211
213
  const conceptId = widgetConceptId(entry);
212
214
  const bundle = getOrCreateCandidateBundle(bundles, conceptId, bundleLabelFromConceptId(conceptId || entry.screen_id || entry.id_hint));
215
+ for (const event of entry.inferred_events || []) {
216
+ const shapeId = event.payload_shape || null;
217
+ const shape = shapeId ? uiShapeCandidatesById.get(shapeId) : null;
218
+ if (shape && !bundle.shapes.some((/** @type {any} */ candidate) => candidate.id === shapeId)) {
219
+ bundle.shapes.push({
220
+ id: shapeId,
221
+ label: shape.label || `${entry.label || entry.id_hint} ${event.name || "event"} Payload`,
222
+ fields: shape.fields || event.payload_fields || ["id"],
223
+ source_kind: shape.source_kind || "ui_widget_event",
224
+ widget_id: entry.id_hint,
225
+ event_name: event.name || null
226
+ });
227
+ }
228
+ }
213
229
  bundle.widgets.push(entry);
214
230
  }
215
231
  for (const entry of workflowCandidates.workflows || []) {
@@ -412,7 +428,7 @@ export function buildCandidateModelFiles(graph, appImport, topogramRoot) {
412
428
  files[`${bundleRoot}/entities/${entry.id_hint}.tg`] = renderCandidateEntity(entry, knownEnums);
413
429
  }
414
430
  for (const shape of bundle.shapes) {
415
- files[`${bundleRoot}/shapes/${shape.id}.tg`] = renderCandidateShape(shape.id, shape.label, shape.fields);
431
+ files[`${bundleRoot}/shapes/${shape.id}.tg`] = renderCandidateShape(shape.id, shape.label, shape.fields, shape.source_kind || null);
416
432
  }
417
433
  for (const entry of bundle.capabilities) {
418
434
  const inputShapeId = bundle.shapes.find((/** @type {any} */ shape) => shape.id === shapeIdForCapability(entry, "input")) ? shapeIdForCapability(entry, "input") : null;
@@ -65,7 +65,7 @@ export function buildBundleAdoptionPlan(bundle, canonicalShapeIndex) {
65
65
  for (const entry of bundle.shapes) {
66
66
  const signature = shapeFieldSignature(entry.fields || []);
67
67
  const duplicateTargets = canonicalShapeIndex.get(signature) || [];
68
- if (duplicateTargets.length > 0) {
68
+ if (duplicateTargets.length > 0 && entry.source_kind !== "ui_widget_event") {
69
69
  steps.push({
70
70
  action: "skip_duplicate_shape",
71
71
  item: entry.id,
@@ -76,7 +76,10 @@ export function buildBundleAdoptionPlan(bundle, canonicalShapeIndex) {
76
76
  steps.push({
77
77
  action: "promote_shape",
78
78
  item: entry.id,
79
- target: bundle.mergeHints?.canonicalEntityTarget || null
79
+ target: bundle.mergeHints?.canonicalEntityTarget || null,
80
+ source_kind: entry.source_kind || null,
81
+ widget_id: entry.widget_id || null,
82
+ event_name: entry.event_name || null
80
83
  });
81
84
  }
82
85
  for (const entry of bundle.docs) {
@@ -126,6 +129,7 @@ export function buildBundleAdoptionPlan(bundle, canonicalShapeIndex) {
126
129
  confidence: entry.confidence || "low",
127
130
  inference_summary: entry.inference_summary || null,
128
131
  related_capabilities: [entry.data_source].filter(Boolean),
132
+ related_shapes: [...new Set((entry.inferred_events || []).map((/** @type {any} */ event) => event.payload_shape).filter(Boolean))],
129
133
  source_path: `candidates/reconcile/model/bundles/${bundle.slug}/widgets/${entry.id_hint}.tg`,
130
134
  canonical_rel_path: `widgets/${dashedTopogramId(entry.id_hint)}.tg`
131
135
  });
@@ -2,7 +2,11 @@
2
2
 
3
3
  /** @param {any[]} fields @returns {any} */
4
4
  export function shapeFieldSignature(fields) {
5
- return [...new Set((fields || []).filter(Boolean))].sort().join("|");
5
+ return [...new Set((fields || [])
6
+ .map((field) => typeof field === "string" ? field : field?.name)
7
+ .filter(Boolean))]
8
+ .sort()
9
+ .join("|");
6
10
  }
7
11
 
8
12
  /** @param {ResolvedGraph} graph @returns {any} */
@@ -113,17 +113,31 @@ export function shapeIdForCapability(record, direction) {
113
113
  return direction === "input" ? `shape_input_${stem}` : `shape_output_${stem}`;
114
114
  }
115
115
 
116
- /** @param {string} shapeId @param {string} label @param {any[]} fields @returns {any} */
117
- export function renderCandidateShape(shapeId, label, fields) {
116
+ /** @param {string} shapeId @param {string} label @param {any[]} fields @param {string|null} [sourceKind] @returns {any} */
117
+ export function renderCandidateShape(shapeId, label, fields, sourceKind = null) {
118
+ /** @param {any} field @returns {{ name: string, fieldType: string, requiredness: string }} */
119
+ function normalizeField(field) {
120
+ if (typeof field === "string") {
121
+ return { name: field, fieldType: "string", requiredness: "optional" };
122
+ }
123
+ return {
124
+ name: field?.name || "id",
125
+ fieldType: field?.field_type || field?.type || "string",
126
+ requiredness: field?.required ? "required" : "optional"
127
+ };
128
+ }
118
129
  const lines = [
119
130
  `shape ${shapeId} {`,
120
131
  ` name "${label}"`,
121
- ` description "Candidate shape imported from brownfield API evidence"`,
132
+ sourceKind === "ui_widget_event"
133
+ ? ` description "Candidate event payload shape imported from brownfield UI interaction evidence"`
134
+ : ` description "Candidate shape imported from brownfield API evidence"`,
122
135
  "",
123
136
  " fields {"
124
137
  ];
125
138
  for (const field of fields) {
126
- lines.push(` ${field} string optional`);
139
+ const normalized = normalizeField(field);
140
+ lines.push(` ${normalized.name} ${normalized.fieldType} ${normalized.requiredness}`);
127
141
  }
128
142
  lines.push(" }", "", " status active", "}");
129
143
  return ensureTrailingNewline(lines.join("\n"));
@@ -236,7 +250,6 @@ export function renderCandidateUiReportDoc(screen, routes, actions) {
236
250
  source_of_truth: "imported",
237
251
  confidence: screen.confidence || "medium",
238
252
  review_required: true,
239
- related_entities: [screen.entity_id].filter(Boolean),
240
253
  provenance: screen.provenance || [],
241
254
  tags: ["import", "ui"]
242
255
  };
@@ -244,11 +257,12 @@ export function renderCandidateUiReportDoc(screen, routes, actions) {
244
257
  "Candidate UI surface imported from brownfield route evidence.",
245
258
  "",
246
259
  `Screen: \`${screen.id_hint}\` (${screen.screen_kind})`,
260
+ screen.entity_id ? `Inferred entity: \`${screen.entity_id}\`` : null,
247
261
  `Routes: ${routes.length ? routes.map((/** @type {any} */ route) => `\`${route.path}\``).join(", ") : "_none_"}`,
248
262
  `Actions: ${actions.length ? actions.map((/** @type {any} */ action) => `\`${action.capability_hint}\``).join(", ") : "_none_"}`,
249
263
  "",
250
264
  "Review this UI surface before promoting it into canonical docs or projections."
251
- ].join("\n");
265
+ ].filter(Boolean).join("\n");
252
266
  return renderMarkdownDoc(metadata, body);
253
267
  }
254
268
 
@@ -257,15 +271,36 @@ export function renderCandidateWidget(widget) {
257
271
  const propName = widget.data_prop || "rows";
258
272
  const pattern = widget.pattern || "search_results";
259
273
  const region = widget.region || "results";
274
+ const inferredEvents = Array.isArray(widget.inferred_events) ? widget.inferred_events : [];
275
+ const eventLines = inferredEvents
276
+ .filter((/** @type {any} */ event) => event.name && event.payload_shape)
277
+ .map((/** @type {any} */ event) => ` ${event.name} ${event.payload_shape}`);
278
+ const selectionEvent = inferredEvents.find((/** @type {any} */ event) => event.name);
279
+ const eventComments = inferredEvents.map((/** @type {any} */ event) =>
280
+ ` # Inferred event: ${event.name || "event"} ${event.action || "action"} ${event.target_screen || event.target || "target"}.`
281
+ );
282
+ const behaviorLines = eventLines.length > 0
283
+ ? [
284
+ " events {",
285
+ ...eventLines,
286
+ " }",
287
+ " behavior [selection]",
288
+ " behaviors {",
289
+ ` selection mode single emits ${selectionEvent?.name || "row_select"}`,
290
+ " }"
291
+ ]
292
+ : [];
260
293
  return ensureTrailingNewline(
261
294
  [
262
295
  `widget ${widget.id_hint} {`,
263
296
  ` name "${widget.label || widget.id_hint}"`,
264
297
  ' description "Candidate reusable widget inferred from imported UI evidence. Review props, behavior, events, and reuse before adoption."',
265
298
  " category collection",
299
+ ...eventComments,
266
300
  " props {",
267
301
  ` ${propName} array required`,
268
302
  " }",
303
+ ...behaviorLines,
269
304
  ` patterns [${pattern}]`,
270
305
  ` regions [${region}]`,
271
306
  " status proposed",
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  import { ensureTrailingNewline, titleCase } from "../text-helpers.js";
6
+ import { resolveWorkspaceContext } from "../workspace-paths.js";
6
7
 
7
8
  /** @param {string} startDir @returns {any} */
8
9
  export function findNearestGitRoot(startDir) {
@@ -21,17 +22,10 @@ export function findNearestGitRoot(startDir) {
21
22
 
22
23
  /** @param {string} inputPath @returns {any} */
23
24
  export function normalizeWorkspacePaths(inputPath) {
25
+ const context = resolveWorkspaceContext(inputPath);
24
26
  const absolute = path.resolve(inputPath);
25
- const inputExists = fs.existsSync(absolute);
26
- const hasTopogramChild = fs.existsSync(path.join(absolute, "topogram")) && fs.statSync(path.join(absolute, "topogram")).isDirectory();
27
- const isTopogramDir = path.basename(absolute) === "topogram" && inputExists;
28
- const bootstrapWorkspaceRoot = !isTopogramDir && !hasTopogramChild;
29
- const topogramRoot = isTopogramDir
30
- ? absolute
31
- : hasTopogramChild
32
- ? path.join(absolute, "topogram")
33
- : path.join(absolute, "topogram");
34
- const workspaceRoot = isTopogramDir ? path.dirname(topogramRoot) : absolute;
27
+ const topogramRoot = context.topoRoot;
28
+ const workspaceRoot = context.projectRoot;
35
29
  const repoRoot = findNearestGitRoot(workspaceRoot);
36
30
  return {
37
31
  inputRoot: absolute,
@@ -39,7 +33,7 @@ export function normalizeWorkspacePaths(inputPath) {
39
33
  workspaceRoot,
40
34
  exampleRoot: workspaceRoot,
41
35
  repoRoot,
42
- bootstrappedTopogramRoot: !fs.existsSync(topogramRoot)
36
+ bootstrappedTopogramRoot: context.bootstrappedTopoRoot
43
37
  };
44
38
  }
45
39