@topogram/cli 0.3.65 → 0.3.67

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 +38 -8
  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
@@ -338,7 +338,7 @@ function importAdoptMode(graph, options = {}) {
338
338
  write_scope: {
339
339
  safe_to_edit: ["candidates/**"],
340
340
  generator_owned: ["artifacts/**", "apps/**"],
341
- human_owned_review_required: ["topogram/**"],
341
+ human_owned_review_required: ["topo/**"],
342
342
  out_of_bounds: [".git/**", "node_modules/**"]
343
343
  },
344
344
  verification_targets: {
@@ -384,7 +384,7 @@ function diffReviewMode(graph, options = {}) {
384
384
  write_scope: {
385
385
  safe_to_edit: [],
386
386
  generator_owned: ["artifacts/**", "apps/**"],
387
- human_owned_review_required: ["topogram/**", "examples/maintained/proof-app/**"],
387
+ human_owned_review_required: ["topo/**", "examples/maintained/proof-app/**"],
388
388
  out_of_bounds: [".git/**", "node_modules/**"]
389
389
  },
390
390
  verification_targets: slice?.verification_targets || recommendedVerificationTargets(graph, [], {
@@ -47,7 +47,7 @@ export function generateSdlcDocPage(graph, options = {}) {
47
47
  type: "sdlc_doc_page",
48
48
  version: 1,
49
49
  document_id: doc.id,
50
- output_path: `topogram/docs-generated/sdlc/${doc.id}.md`,
50
+ output_path: `topo/docs-generated/sdlc/${doc.id}.md`,
51
51
  markdown
52
52
  };
53
53
  }
@@ -99,7 +99,7 @@ function renderEmptySnapshotForProjection(projection) {
99
99
 
100
100
  function renderDbLifecycleEnvExample(projection, plan) {
101
101
  const engine = plan.engine || (dbProfileForProjection(projection).startsWith("sqlite") ? "sqlite" : "postgres");
102
- const inputPath = "../../../../topogram";
102
+ const inputPath = "../../../../topo";
103
103
  if (engine === "sqlite") {
104
104
  return `DATABASE_URL=file:./var/${projection.id}.sqlite\nTOPOGRAM_INPUT_PATH=${inputPath}\n`;
105
105
  }
@@ -22,5 +22,5 @@ Configure the API base URL and optional auth token via scheme environment variab
22
22
  From `engine/`:
23
23
 
24
24
  ```bash
25
- topogram emit swiftui-app ./topogram --projection proj_ios_surface__swiftui --write --out-dir ./app/ios-swiftui
25
+ topogram emit swiftui-app ./topo --projection proj_ios_surface__swiftui --write --out-dir ./app/ios-swiftui
26
26
  ```
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  import { readJsonIfExists, readTextIfExists } from "./shared.js";
5
+ import { resolveWorkspaceContext } from "../../workspace-paths.js";
5
6
 
6
7
  export function findNearestGitRoot(startDir) {
7
8
  let currentDir = path.resolve(startDir);
@@ -19,13 +20,10 @@ export function findNearestGitRoot(startDir) {
19
20
  }
20
21
 
21
22
  export function normalizeWorkspacePaths(inputPath) {
23
+ const context = resolveWorkspaceContext(inputPath);
22
24
  const absolute = path.resolve(inputPath);
23
- const inputExists = fs.existsSync(absolute);
24
- const topogramChild = path.join(absolute, "topogram");
25
- const hasTopogramChild = fs.existsSync(topogramChild) && fs.statSync(topogramChild).isDirectory();
26
- const isTopogramDir = path.basename(absolute) === "topogram" && inputExists;
27
- const topogramRoot = isTopogramDir ? absolute : hasTopogramChild ? topogramChild : path.join(absolute, "topogram");
28
- const workspaceRoot = isTopogramDir ? path.dirname(topogramRoot) : absolute;
25
+ const topogramRoot = context.topoRoot;
26
+ const workspaceRoot = context.projectRoot;
29
27
  const repoRoot = findNearestGitRoot(workspaceRoot);
30
28
  return {
31
29
  inputRoot: absolute,
@@ -33,7 +31,7 @@ export function normalizeWorkspacePaths(inputPath) {
33
31
  workspaceRoot,
34
32
  exampleRoot: workspaceRoot,
35
33
  repoRoot,
36
- bootstrappedTopogramRoot: !fs.existsSync(topogramRoot)
34
+ bootstrappedTopogramRoot: context.bootstrappedTopoRoot
37
35
  };
38
36
  }
39
37
 
@@ -47,6 +47,119 @@ export function capabilityHintsForScreen(screen) {
47
47
  return rawHints.map(normalizeCapabilityHint).filter(Boolean);
48
48
  }
49
49
 
50
+ /**
51
+ * @param {string|null|undefined} screenId
52
+ * @returns {string}
53
+ */
54
+ function screenConceptStem(screenId) {
55
+ return String(screenId || "")
56
+ .replace(/_(list|index|table|grid|results|detail|show|create|new|edit|form)$/, "")
57
+ .replace(/^(list|show|create|edit)_/, "");
58
+ }
59
+
60
+ /**
61
+ * @param {string|null|undefined} screenId
62
+ * @param {string} eventName
63
+ * @returns {string}
64
+ */
65
+ function eventPayloadShapeId(screenId, eventName) {
66
+ const stem = screenConceptStem(screenId) || idHintify(String(screenId || "widget"));
67
+ return `shape_event_${idHintify(`${stem}_${eventName}`)}`;
68
+ }
69
+
70
+ /**
71
+ * @param {string|null|undefined} routePath
72
+ * @returns {{ name: string, field_type: string, required: boolean }[]}
73
+ */
74
+ function routeParamFields(routePath) {
75
+ const fields = [];
76
+ const pathText = String(routePath || "");
77
+ const routeParamPattern = /[:{]([A-Za-z_][A-Za-z0-9_]*)}?/g;
78
+ for (const match of pathText.matchAll(routeParamPattern)) {
79
+ fields.push({
80
+ name: idHintify(match[1]),
81
+ field_type: "string",
82
+ required: true
83
+ });
84
+ }
85
+ return fields.length > 0 ? fields : [{ name: "id", field_type: "string", required: true }];
86
+ }
87
+
88
+ /**
89
+ * @param {any} sourceScreen
90
+ * @param {any[]} screens
91
+ * @returns {any|null}
92
+ */
93
+ function matchingDetailScreen(sourceScreen, screens) {
94
+ const sourceStem = screenConceptStem(sourceScreen?.id_hint);
95
+ if (!sourceStem) {
96
+ return null;
97
+ }
98
+ return screens.find((screen) =>
99
+ screen?.id_hint !== sourceScreen?.id_hint &&
100
+ screen?.screen_kind === "detail" &&
101
+ screenConceptStem(screen.id_hint) === sourceStem
102
+ ) || null;
103
+ }
104
+
105
+ /**
106
+ * @param {any} screen
107
+ * @param {any[]} screens
108
+ * @returns {any[]}
109
+ */
110
+ function inferredEventsForWidgetScreen(screen, screens) {
111
+ const detailScreen = matchingDetailScreen(screen, screens);
112
+ if (!detailScreen) {
113
+ return [];
114
+ }
115
+ return [{
116
+ name: "row_select",
117
+ kind: "selection",
118
+ action: "navigate",
119
+ target_screen: detailScreen.id_hint,
120
+ payload_shape: eventPayloadShapeId(screen?.id_hint, "row_select"),
121
+ confidence: "medium",
122
+ evidence: detailScreen.provenance || [],
123
+ payload_fields: routeParamFields(detailScreen.route_path),
124
+ requires_payload_shape_review: true
125
+ }];
126
+ }
127
+
128
+ /**
129
+ * @param {any[]} widgets
130
+ * @returns {any[]}
131
+ */
132
+ function deriveUiWidgetEventShapeCandidates(widgets) {
133
+ return widgets.flatMap((widget) =>
134
+ (widget.inferred_events || [])
135
+ .filter((/** @type {any} */ event) => event.payload_shape)
136
+ .map((/** @type {any} */ event) => makeCandidateRecord({
137
+ kind: "shape",
138
+ idHint: event.payload_shape,
139
+ label: `${widget.label || widget.id_hint} ${event.name || "event"} payload`,
140
+ confidence: event.confidence || widget.confidence || "low",
141
+ sourceKind: "ui_widget_event",
142
+ sourceOfTruth: "candidate",
143
+ provenance: [
144
+ ...(widget.provenance || []),
145
+ ...(event.evidence || [])
146
+ ],
147
+ track: "ui",
148
+ widget_id: widget.id_hint,
149
+ event_name: event.name,
150
+ fields: event.payload_fields || [{ name: "id", field_type: "string", required: true }],
151
+ missing_decisions: [
152
+ "confirm selected row identity fields",
153
+ "confirm event payload shape name"
154
+ ],
155
+ notes: [
156
+ "Imported widget event payload shapes are review-only.",
157
+ "Adopt with the widget when the event binding is accepted."
158
+ ]
159
+ }))
160
+ );
161
+ }
162
+
50
163
  /**
51
164
  * @param {any} candidates
52
165
  * @returns {any[]}
@@ -95,6 +208,7 @@ function deriveUiWidgetCandidates(candidates) {
95
208
  const pattern = collectionPatternFromPresentations(presentations);
96
209
  const widgetStem = idHintify(`${screen.id_hint}_results`);
97
210
  const loadCapability = loadCapabilityForScreen(screen);
211
+ const inferredEvents = inferredEventsForWidgetScreen(screen, screens);
98
212
  return makeCandidateRecord({
99
213
  kind: "widget",
100
214
  idHint: `widget_${widgetStem}`,
@@ -109,10 +223,13 @@ function deriveUiWidgetCandidates(candidates) {
109
223
  data_prop: "rows",
110
224
  data_source: loadCapability,
111
225
  inferred_props: [{ name: "rows", type: "array", required: true, source: loadCapability }],
112
- inferred_events: [],
226
+ inferred_events: inferredEvents,
113
227
  inferred_region: "results",
114
228
  inferred_pattern: pattern,
115
- evidence: screen.provenance || [],
229
+ evidence: [
230
+ ...(screen.provenance || []),
231
+ ...inferredEvents.flatMap((event) => event.evidence || [])
232
+ ],
116
233
  missing_decisions: [
117
234
  "confirm widget reuse boundary",
118
235
  "confirm prop names and data source",
@@ -160,11 +277,14 @@ export function normalizeCandidatesForTrack(track, candidates) {
160
277
  if (track === "ui") {
161
278
  const explicitWidgets = uiWidgetCandidates(candidates);
162
279
  const derivedWidgets = deriveUiWidgetCandidates(candidates);
280
+ const widgets = dedupeCandidateRecords([...explicitWidgets, ...derivedWidgets], idHint);
281
+ const eventShapes = deriveUiWidgetEventShapeCandidates(widgets);
163
282
  return {
164
283
  screens: dedupeCandidateRecords(candidates.screens || [], idHint),
165
284
  routes: dedupeCandidateRecords(candidates.routes || [], idHint),
166
285
  actions: dedupeCandidateRecords(candidates.actions || [], idHint),
167
- widgets: dedupeCandidateRecords([...explicitWidgets, ...derivedWidgets], idHint),
286
+ widgets,
287
+ shapes: dedupeCandidateRecords([...(candidates.shapes || []), ...eventShapes], idHint),
168
288
  stacks: [...new Set(candidates.stacks || [])].sort()
169
289
  };
170
290
  }
@@ -21,11 +21,12 @@ export function reportMarkdown(track, candidates) {
21
21
  }
22
22
  if (track === "ui") {
23
23
  const widgets = uiWidgetCandidates(candidates);
24
+ const shapes = candidates.shapes || [];
24
25
  const widgetLines = widgets.map((widget) =>
25
- `- \`${widget.id_hint}\` confidence ${widget.confidence || "unknown"} pattern \`${widget.pattern || widget.inferred_pattern || "unknown"}\` region \`${widget.region || widget.inferred_region || "unknown"}\` evidence ${(widget.evidence || widget.provenance || []).length} missing decisions ${(widget.missing_decisions || []).length}`
26
+ `- \`${widget.id_hint}\` confidence ${widget.confidence || "unknown"} pattern \`${widget.pattern || widget.inferred_pattern || "unknown"}\` region \`${widget.region || widget.inferred_region || "unknown"}\` events ${(widget.inferred_events || []).length} evidence ${(widget.evidence || widget.provenance || []).length} missing decisions ${(widget.missing_decisions || []).length}`
26
27
  );
27
28
  return ensureTrailingNewline(
28
- `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Widgets: ${widgets.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topogram/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
29
+ `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
29
30
  );
30
31
  }
31
32
  if (track === "verification") {
@@ -45,6 +46,6 @@ export function reportMarkdown(track, candidates) {
45
46
  */
46
47
  export function appReportMarkdown(candidates, tracks) {
47
48
  return ensureTrailingNewline(
48
- `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
49
+ `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
49
50
  );
50
51
  }
@@ -49,22 +49,73 @@ function renderWidgetCandidate(widget) {
49
49
  "confirm prop names and data source",
50
50
  "confirm events and behavior"
51
51
  ];
52
+ const inferredEvents = Array.isArray(widget.inferred_events) ? widget.inferred_events : [];
53
+ const eventLines = inferredEvents
54
+ .filter((/** @type {any} */ event) => event.name && event.payload_shape)
55
+ .map((/** @type {any} */ event) => ` ${event.name} ${event.payload_shape}`);
56
+ const selectionEvent = inferredEvents.find((/** @type {any} */ event) => event.name);
57
+ const inferredEventComments = inferredEvents.length > 0
58
+ ? `${inferredEvents.map((/** @type {any} */ event) =>
59
+ ` # Inferred event: ${event.name || "event"} ${event.action || "action"} ${event.target_screen || event.target || "target"}; review payload shape ${event.payload_shape || "before adding an events block"}.`
60
+ ).join("\n")}\n`
61
+ : "";
62
+ const behaviorBlock = eventLines.length > 0
63
+ ? ` events {\n${eventLines.join("\n")}\n }\n behavior [selection]\n behaviors {\n selection mode single emits ${selectionEvent?.name || "row_select"}\n }\n`
64
+ : "";
52
65
  return `widget ${widget.id_hint} {
53
66
  # Import metadata: confidence ${widget.confidence || "unknown"}; evidence ${evidenceCount}; inferred pattern ${widget.pattern || widget.inferred_pattern || "search_results"}; inferred region ${widget.region || widget.inferred_region || "results"}.
54
67
  # Missing decisions: ${missingDecisions.join("; ")}.
68
+ ${inferredEventComments} # Event declarations are draft bindings and require payload shape review before adoption.
55
69
  name "${widget.label || widget.id_hint}"
56
70
  description "Candidate reusable widget inferred from imported UI evidence. Review props, behavior, events, and reuse before adoption."
57
71
  category collection
58
72
  props {
59
73
  ${widget.data_prop || "rows"} array required
60
74
  }
61
- patterns [${widget.pattern || "search_results"}]
75
+ ${behaviorBlock} patterns [${widget.pattern || "search_results"}]
62
76
  regions [${widget.region || "results"}]
63
77
  status proposed
64
78
  }
65
79
  `;
66
80
  }
67
81
 
82
+ /**
83
+ * @param {any} shape
84
+ * @returns {string}
85
+ */
86
+ function renderShapeCandidate(shape) {
87
+ const fields = Array.isArray(shape.fields) && shape.fields.length > 0
88
+ ? shape.fields
89
+ : [{ name: "id", field_type: "string", required: true }];
90
+ return `shape ${shape.id_hint} {
91
+ # Import metadata: confidence ${shape.confidence || "unknown"}; source ${shape.source_kind || "unknown"}.
92
+ # Missing decisions: ${(shape.missing_decisions || ["confirm event payload fields"]).join("; ")}.
93
+ name "${shape.label || shape.id_hint}"
94
+ description "Candidate event payload shape inferred from imported UI interaction evidence."
95
+ fields {
96
+ ${fields.map((/** @type {any} */ field) => {
97
+ const fieldName = typeof field === "string" ? field : field.name;
98
+ const fieldType = typeof field === "string" ? "string" : (field.field_type || field.type || "string");
99
+ const requiredness = typeof field === "string" || field.required ? "required" : "optional";
100
+ return ` ${fieldName} ${fieldType} ${requiredness}`;
101
+ }).join("\n")}
102
+ }
103
+ status proposed
104
+ }
105
+ `;
106
+ }
107
+
108
+ /**
109
+ * @param {any} widget
110
+ * @returns {string}
111
+ */
112
+ function widgetEventDirectives(widget) {
113
+ return (widget.inferred_events || [])
114
+ .filter((/** @type {any} */ event) => event.name && event.action && (event.target_screen || event.target) && event.payload_shape)
115
+ .map((/** @type {any} */ event) => ` event ${event.name} ${event.action} ${event.target_screen || event.target}`)
116
+ .join("");
117
+ }
118
+
68
119
  /**
69
120
  * @param {any[]} widgetCandidates
70
121
  * @param {Record<string, any>} allCandidates
@@ -78,7 +129,7 @@ function uiWidgetLinesForCandidates(widgetCandidates, allCandidates) {
78
129
  const dataBinding = dataSource
79
130
  ? ` data ${widget.data_prop || "rows"} from ${dataSource}`
80
131
  : "";
81
- return ` screen ${widget.screen_id} region ${widget.region} widget ${widget.id_hint}${dataBinding}`;
132
+ return ` screen ${widget.screen_id} region ${widget.region} widget ${widget.id_hint}${dataBinding}${widgetEventDirectives(widget)}`;
82
133
  });
83
134
  }
84
135
 
@@ -99,6 +150,7 @@ export function draftUiProjectionFiles(context, candidates, allCandidates = {})
99
150
  /** @type {any[]} */
100
151
  const actions = ui.actions || [];
101
152
  const widgetCandidates = [...uiWidgetCandidates(ui)].sort((a, b) => a.id_hint.localeCompare(b.id_hint));
153
+ const shapeCandidates = [...(ui.shapes || [])].sort((a, b) => String(a.id_hint || "").localeCompare(String(b.id_hint || "")));
102
154
  const shell = actions.find((entry) => entry.kind === "ui_shell")?.shell_kind || "topbar";
103
155
  const navigationPatterns = uniqueSorted(actions.filter((entry) => entry.kind === "navigation").map((entry) => entry.navigation_pattern));
104
156
  const presentations = uniqueSorted(actions.filter((entry) => entry.kind === "ui_presentation").map((entry) => entry.presentation));
@@ -308,6 +360,7 @@ ${uiWebLines.length > 0 ? ` web_hints {\n${uiWebLines.join("\n")}\n }\n\n` : "
308
360
  - Draft UI contract projection: \`candidates/app/ui/drafts/proj-ui-contract.tg\`
309
361
  - Draft web surface projection: \`candidates/app/ui/drafts/proj-web-surface.tg\`
310
362
  - Draft widget candidates: ${widgetCandidates.length}
363
+ - Draft event payload shape candidates: ${shapeCandidates.length}
311
364
  - Imported screens: ${screens.length}
312
365
  - Imported routes: ${(ui.routes || []).length}
313
366
  - Imported UI actions/presentations: ${actions.length}
@@ -330,6 +383,9 @@ ${uiWebLines.length > 0 ? ` web_hints {\n${uiWebLines.join("\n")}\n }\n\n` : "
330
383
  "candidates/app/ui/drafts/proj-web-surface.tg": ensureTrailingNewline(uiWebDraft),
331
384
  "candidates/app/ui/drafts/README.md": ensureTrailingNewline(coverage)
332
385
  };
386
+ for (const shape of shapeCandidates) {
387
+ files[`candidates/app/ui/drafts/shapes/${widgetCandidateFileName(shape)}`] = ensureTrailingNewline(renderShapeCandidate(shape));
388
+ }
333
389
  for (const widget of widgetCandidates) {
334
390
  files[`candidates/app/ui/drafts/widgets/${widgetCandidateFileName(widget)}`] = ensureTrailingNewline(renderWidgetCandidate(widget));
335
391
  }
@@ -31,7 +31,7 @@ export const SURFACE_ORDER = new Map([
31
31
  * @returns {string}
32
32
  */
33
33
  export function unsupportedTemplateSymlinkMessage(templateId, relativePath) {
34
- return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied topogram/ and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram new or topogram template check.`;
34
+ return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied workspace and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram new or topogram template check.`;
35
35
  }
36
36
 
37
37
  /**
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
 
5
5
  import { defaultGeneratorPolicy, writeGeneratorPolicy } from "../generator-policy.js";
6
6
  import { writeTemplateTrustRecord } from "../template-trust.js";
7
+ import { DEFAULT_TOPO_FOLDER_NAME } from "../workspace-paths.js";
7
8
  import { DEFAULT_TEMPLATE_NAME } from "./constants.js";
8
9
  import { writeProjectTemplateMetadata } from "./metadata.js";
9
10
  import { assertProjectOutsideEngine, copyTopogramWorkspace, ensureCreatableProjectRoot, writeAgentsGuide, writeExplainScript, writeProjectPackage, writeProjectReadme } from "./project-files.js";
@@ -52,7 +53,7 @@ export function createNewProject({
52
53
  }
53
54
 
54
55
  ensureCreatableProjectRoot(projectRoot);
55
- copyTopogramWorkspace(template.root, projectRoot);
56
+ const workspaceCopy = copyTopogramWorkspace(template.root, projectRoot);
56
57
  const projectConfig = writeProjectTemplateMetadata(projectRoot, template, templateProvenance);
57
58
  writeProjectPackage(projectRoot, engineRoot, template);
58
59
  writeExplainScript(projectRoot);
@@ -63,6 +64,12 @@ export function createNewProject({
63
64
  writeGeneratorPolicy(projectRoot, defaultGeneratorPolicy());
64
65
 
65
66
  const warnings = [];
67
+ if (workspaceCopy.legacyWorkspace) {
68
+ warnings.push(
69
+ `Template '${template.manifest.id}' still ships legacy topogram/ source. Copied it into this project as ${DEFAULT_TOPO_FOLDER_NAME}/. ` +
70
+ "This one-release package-ingress bridge will be removed after first-party packages migrate."
71
+ );
72
+ }
66
73
  if (template.manifest.includesExecutableImplementation) {
67
74
  writeTemplateTrustRecord(projectRoot, projectConfig);
68
75
  warnings.push(
@@ -76,7 +83,7 @@ export function createNewProject({
76
83
  projectRoot,
77
84
  templateName: template.manifest.id,
78
85
  template: projectConfig.template,
79
- topogramPath: path.join(projectRoot, "topogram"),
86
+ topogramPath: path.join(projectRoot, DEFAULT_TOPO_FOLDER_NAME),
80
87
  appPath: path.join(projectRoot, "app"),
81
88
  warnings
82
89
  };
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
 
6
6
  import { githubRepoSlug } from "../topogram-config.js";
7
+ import { DEFAULT_TOPO_FOLDER_NAME, DEFAULT_WORKSPACE_PATH, resolvePackageWorkspace } from "../workspace-paths.js";
7
8
  import { cliDependencyForProject, generatorDependenciesForTemplate, isSameOrInside, packageNameFromPath, writeProjectNpmConfig } from "./package-spec.js";
8
9
 
9
10
  /** @typedef {import("./types.js").CreateNewProjectOptions} CreateNewProjectOptions */
@@ -54,16 +55,17 @@ export function ensureCreatableProjectRoot(projectRoot) {
54
55
  /**
55
56
  * @param {string} templateRoot
56
57
  * @param {string} projectRoot
57
- * @returns {void}
58
+ * @returns {{ legacyWorkspace: boolean }}
58
59
  */
59
60
  export function copyTopogramWorkspace(templateRoot, projectRoot) {
60
- const topogramRoot = path.join(projectRoot, "topogram");
61
- fs.cpSync(path.join(templateRoot, "topogram"), topogramRoot, { recursive: true });
62
-
63
- fs.cpSync(
64
- path.join(templateRoot, "topogram.project.json"),
65
- path.join(projectRoot, "topogram.project.json")
66
- );
61
+ const templateWorkspace = resolvePackageWorkspace(templateRoot);
62
+ const topoRoot = path.join(projectRoot, DEFAULT_TOPO_FOLDER_NAME);
63
+ fs.cpSync(templateWorkspace.root, topoRoot, { recursive: true });
64
+
65
+ const projectConfigPath = path.join(templateRoot, "topogram.project.json");
66
+ const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf8"));
67
+ projectConfig.workspace = DEFAULT_WORKSPACE_PATH;
68
+ fs.writeFileSync(path.join(projectRoot, "topogram.project.json"), `${JSON.stringify(projectConfig, null, 2)}\n`, "utf8");
67
69
  const implementationRoot = path.join(templateRoot, "implementation");
68
70
  if (fs.existsSync(implementationRoot)) {
69
71
  fs.cpSync(
@@ -72,6 +74,7 @@ export function copyTopogramWorkspace(templateRoot, projectRoot) {
72
74
  { recursive: true }
73
75
  );
74
76
  }
77
+ return { legacyWorkspace: templateWorkspace.legacy };
75
78
  }
76
79
 
77
80
  /**
@@ -148,7 +151,7 @@ export function writeExplainScript(projectRoot) {
148
151
  Topogram app workflow
149
152
 
150
153
  1. Edit:
151
- topogram/
154
+ topo/
152
155
  topogram.project.json
153
156
 
154
157
  2. Start with project guidance:
@@ -179,8 +182,8 @@ Or run self-contained local runtime verification:
179
182
  Useful inspection:
180
183
  npm run agent:brief
181
184
  npm run check:json
182
- topogram emit ui-widget-contract ./topogram --json
183
- topogram emit widget-conformance-report ./topogram --json
185
+ topogram emit ui-widget-contract ./topo --json
186
+ topogram emit widget-conformance-report ./topo --json
184
187
  npm run doctor
185
188
  npm run source:status
186
189
  npm run source:status:remote
@@ -257,7 +260,7 @@ ${provenanceLines.join("\n")}
257
260
  ${workflowCommands.join("\n")}
258
261
  \`\`\`
259
262
 
260
- Edit \`topogram/\` and \`topogram.project.json\`, then regenerate with \`npm run generate\`.
263
+ Edit the workspace folder \`${DEFAULT_TOPO_FOLDER_NAME}/\` and \`topogram.project.json\`, then regenerate with \`npm run generate\`.
261
264
  Generated app code is written to \`app/\`.
262
265
  Use \`topogram emit <target>\` to inspect contracts, reports, snapshots, and other artifacts without regenerating the app.
263
266
  Agents should start with \`AGENTS.md\` and \`npm run agent:brief\`. The direct \`topogram agent brief --json\` command is the canonical machine-readable first-run guidance.
@@ -315,7 +318,7 @@ npm run query:show -- widget-behavior
315
318
 
316
319
  ## Edit Rules
317
320
 
318
- - Edit \`topogram/**\` and \`topogram.project.json\` first.
321
+ - Edit \`topo/**\` and \`topogram.project.json\` first.
319
322
  - Review policy files before editing \`topogram.template-policy.json\` or \`topogram.generator-policy.json\`.
320
323
  - Do not make lasting edits under generated-owned \`app/**\`; use \`npm run generate\` to replace generated output.
321
324
  - If an output is changed to maintained ownership, agents may edit that app code directly after reading focused query packets.
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
 
8
8
  import { assertSafeNpmSpec, localNpmrcEnv } from "../npm-safety.js";
9
+ import { DEFAULT_TOPO_FOLDER_NAME, LEGACY_TOPOGRAM_FOLDER_NAME, resolvePackageWorkspace } from "../workspace-paths.js";
9
10
  import { GENERATOR_LABELS, SURFACE_ORDER, TEMPLATE_MANIFEST, unsupportedTemplateSymlinkMessage } from "./constants.js";
10
11
  import { isLocalTemplateSpec, packageNameFromSpec } from "./package-spec.js";
11
12
 
@@ -106,21 +107,22 @@ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
106
107
  */
107
108
  export function validateTemplateRoot(templateRoot) {
108
109
  const manifest = readTemplateManifest(templateRoot);
109
- const topogramRoot = path.join(templateRoot, "topogram");
110
+ const workspace = resolvePackageWorkspace(templateRoot);
111
+ const topogramRoot = workspace.root;
110
112
  const projectConfigPath = path.join(templateRoot, "topogram.project.json");
111
113
  if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
112
- throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram"));
114
+ throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, workspace.legacy ? LEGACY_TOPOGRAM_FOLDER_NAME : DEFAULT_TOPO_FOLDER_NAME));
113
115
  }
114
116
  if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
115
117
  throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram.project.json"));
116
118
  }
117
119
  if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
118
- throw new Error(`Template '${manifest.id}' is missing topogram/.`);
120
+ throw new Error(`Template '${manifest.id}' is missing ${DEFAULT_TOPO_FOLDER_NAME}/.`);
119
121
  }
120
122
  if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
121
123
  throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
122
124
  }
123
- assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, "topogram", manifest.id);
125
+ assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, workspace.legacy ? LEGACY_TOPOGRAM_FOLDER_NAME : DEFAULT_TOPO_FOLDER_NAME, manifest.id);
124
126
  if (manifest.includesExecutableImplementation) {
125
127
  const implementationRoot = path.join(templateRoot, "implementation");
126
128
  if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { MAX_TEXT_DIFF_BYTES, TEMPLATE_FILES_MANIFEST } from "./constants.js";
8
8
  import { stableJsonStringify } from "./json.js";
9
9
  import { candidateProjectTemplateMetadata } from "./metadata.js";
10
+ import { DEFAULT_TOPO_FOLDER_NAME, resolvePackageWorkspace } from "../workspace-paths.js";
10
11
 
11
12
  /** @typedef {import("./types.js").CreateNewProjectOptions} CreateNewProjectOptions */
12
13
  /** @typedef {import("./types.js").TemplateUpdatePlanOptions} TemplateUpdatePlanOptions */
@@ -235,6 +236,24 @@ export function fileHash(file) {
235
236
  };
236
237
  }
237
238
 
239
+ /**
240
+ * @param {string} relativePath
241
+ * @param {{ absolutePath: string|null, content: string|null }} file
242
+ * @returns {{ sha256: string, size: number }}
243
+ */
244
+ function templateOwnedFileHash(relativePath, file) {
245
+ if (relativePath !== "topogram.project.json" || file.content !== null) {
246
+ return fileHash(file);
247
+ }
248
+ if (!file.absolutePath) {
249
+ return fileHash(file);
250
+ }
251
+ return fileHash({
252
+ absolutePath: null,
253
+ content: `${stableJsonStringify(JSON.parse(fs.readFileSync(file.absolutePath, "utf8")))}\n`
254
+ });
255
+ }
256
+
238
257
  /**
239
258
  * @param {ResolvedTemplate} template
240
259
  * @param {Record<string, any>|null} [currentProjectConfig]
@@ -242,14 +261,24 @@ export function fileHash(file) {
242
261
  */
243
262
  export function candidateTemplateFiles(template, currentProjectConfig = null) {
244
263
  const files = new Map();
245
- for (const rootName of ["topogram", "implementation"]) {
246
- const root = path.join(template.root, rootName);
247
- if (!fs.existsSync(root)) {
248
- continue;
249
- }
264
+ const templateWorkspace = resolvePackageWorkspace(template.root);
265
+ /** @type {string[]} */
266
+ const workspaceFiles = [];
267
+ collectFiles(template.root, templateWorkspace.root, workspaceFiles);
268
+ for (const sourceRelativePath of workspaceFiles) {
269
+ const workspaceRelative = path.relative(templateWorkspace.root, path.join(template.root, sourceRelativePath)).replace(/\\/g, "/");
270
+ const targetRelativePath = path.posix.join(DEFAULT_TOPO_FOLDER_NAME, workspaceRelative);
271
+ files.set(targetRelativePath, {
272
+ path: targetRelativePath,
273
+ content: null,
274
+ absolutePath: path.join(template.root, sourceRelativePath)
275
+ });
276
+ }
277
+ const implementationRoot = path.join(template.root, "implementation");
278
+ if (fs.existsSync(implementationRoot)) {
250
279
  /** @type {string[]} */
251
280
  const relativeFiles = [];
252
- collectFiles(template.root, root, relativeFiles);
281
+ collectFiles(template.root, implementationRoot, relativeFiles);
253
282
  for (const relativePath of relativeFiles) {
254
283
  files.set(relativePath, {
255
284
  path: relativePath,
@@ -259,6 +288,7 @@ export function candidateTemplateFiles(template, currentProjectConfig = null) {
259
288
  }
260
289
  }
261
290
  const candidateProjectConfig = JSON.parse(fs.readFileSync(path.join(template.root, "topogram.project.json"), "utf8"));
291
+ candidateProjectConfig.workspace = `./${DEFAULT_TOPO_FOLDER_NAME}`;
262
292
  candidateProjectConfig.template = candidateProjectTemplateMetadata(template, currentProjectConfig);
263
293
  files.set("topogram.project.json", {
264
294
  path: "topogram.project.json",
@@ -276,7 +306,7 @@ export function candidateTemplateFiles(template, currentProjectConfig = null) {
276
306
  */
277
307
  export function currentTemplateOwnedFiles(projectRoot, includeImplementation, projectConfig) {
278
308
  const files = new Map();
279
- for (const rootName of includeImplementation ? ["topogram", "implementation"] : ["topogram"]) {
309
+ for (const rootName of includeImplementation ? [DEFAULT_TOPO_FOLDER_NAME, "implementation"] : [DEFAULT_TOPO_FOLDER_NAME]) {
280
310
  const root = path.join(projectRoot, rootName);
281
311
  if (!fs.existsSync(root)) {
282
312
  continue;
@@ -323,7 +353,7 @@ export function includesTemplateImplementation(projectConfig) {
323
353
  export function currentTemplateOwnedFileHashes(projectRoot, projectConfig) {
324
354
  const files = currentTemplateOwnedFiles(projectRoot, includesTemplateImplementation(projectConfig), projectConfig);
325
355
  return new Map([...files.entries()].map(([relativePath, file]) => {
326
- const hash = fileHash(file);
356
+ const hash = templateOwnedFileHash(relativePath, file);
327
357
  return [relativePath, { path: relativePath, ...hash }];
328
358
  }));
329
359
  }
@@ -343,7 +343,7 @@ export function applyTemplateUpdateFileAction(options) {
343
343
  code: "template_file_not_current",
344
344
  message: `Cannot accept current file '${relativePath}' because it is not a current template-owned file.`,
345
345
  path: path.join(options.projectRoot, relativePath),
346
- suggestedFix: "Pass a file under topogram/, topogram.project.json, or trusted implementation/.",
346
+ suggestedFix: "Pass a file under topo/, topogram.project.json, or trusted implementation/.",
347
347
  step: "accept-current"
348
348
  }));
349
349
  return templateUpdateFileActionResult(diagnostics, null, options.action, relativePath, applied, accepted, deleted, conflicts, current);