@topogram/cli 0.3.72 → 0.3.74

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 (84) hide show
  1. package/README.md +24 -195
  2. package/package.json +1 -1
  3. package/src/adoption/plan/index.js +2 -1
  4. package/src/agent-brief.js +46 -2
  5. package/src/archive/archive.js +1 -1
  6. package/src/archive/jsonl.js +18 -8
  7. package/src/archive/resolver-bridge.js +34 -1
  8. package/src/archive/schema.js +1 -1
  9. package/src/archive/unarchive.js +26 -0
  10. package/src/cli/command-parsers/sdlc.js +66 -0
  11. package/src/cli/commands/import/help.js +1 -0
  12. package/src/cli/commands/import/plan.js +9 -0
  13. package/src/cli/commands/import/workspace.js +3 -0
  14. package/src/cli/commands/query/definitions.js +11 -10
  15. package/src/cli/commands/query/workspace.js +23 -2
  16. package/src/cli/commands/release-rollout.js +191 -10
  17. package/src/cli/commands/release-shared.js +51 -2
  18. package/src/cli/commands/release.js +16 -3
  19. package/src/cli/commands/sdlc.js +213 -5
  20. package/src/cli/dispatcher.js +8 -0
  21. package/src/cli/help.js +15 -3
  22. package/src/cli/options.js +1 -0
  23. package/src/generator/context/shared/domain-sdlc.js +27 -0
  24. package/src/generator/context/shared/relationships.js +2 -1
  25. package/src/generator/context/shared/types.d.ts +1 -0
  26. package/src/generator/context/shared.d.ts +2 -0
  27. package/src/generator/context/shared.js +2 -0
  28. package/src/generator/context/slice/core.js +3 -0
  29. package/src/generator/context/slice/sdlc.js +57 -2
  30. package/src/generator/context/task-mode.js +7 -0
  31. package/src/generator/sdlc/board.js +2 -0
  32. package/src/generator/sdlc/traceability-matrix.js +5 -1
  33. package/src/import/core/context.js +1 -1
  34. package/src/import/core/contracts.js +3 -3
  35. package/src/import/core/registry.js +3 -0
  36. package/src/import/core/runner/candidates.js +7 -0
  37. package/src/import/core/runner/reports.js +9 -1
  38. package/src/import/core/runner/tracks.js +3 -0
  39. package/src/import/extractors/cli/generic.js +340 -0
  40. package/src/new-project/project-files.js +10 -3
  41. package/src/resolver/enrich/task.js +3 -1
  42. package/src/resolver/index.js +6 -0
  43. package/src/resolver/normalize.js +31 -0
  44. package/src/resolver/projections-cli.js +158 -0
  45. package/src/sdlc/adopt.js +4 -1
  46. package/src/sdlc/check.js +24 -2
  47. package/src/sdlc/complete.js +47 -0
  48. package/src/sdlc/dod/index.js +2 -0
  49. package/src/sdlc/dod/plan.js +15 -0
  50. package/src/sdlc/dod/task.js +7 -3
  51. package/src/sdlc/explain.js +53 -1
  52. package/src/sdlc/gate.js +352 -0
  53. package/src/sdlc/history.d.ts +7 -0
  54. package/src/sdlc/history.js +50 -5
  55. package/src/sdlc/link.js +172 -0
  56. package/src/sdlc/paths.d.ts +4 -0
  57. package/src/sdlc/paths.js +8 -0
  58. package/src/sdlc/plan-steps.js +71 -0
  59. package/src/sdlc/plan.js +245 -0
  60. package/src/sdlc/policy.js +249 -0
  61. package/src/sdlc/prep.js +186 -0
  62. package/src/sdlc/scaffold.js +4 -2
  63. package/src/sdlc/status-filter.js +2 -0
  64. package/src/sdlc/transitions/index.js +3 -0
  65. package/src/sdlc/transitions/plan.js +32 -0
  66. package/src/validator/common.js +25 -4
  67. package/src/validator/index.js +10 -0
  68. package/src/validator/kinds.d.ts +7 -0
  69. package/src/validator/kinds.js +32 -0
  70. package/src/validator/per-kind/plan.js +128 -0
  71. package/src/validator/per-kind/task.js +19 -0
  72. package/src/validator/projections/cli.js +267 -0
  73. package/src/validator.d.ts +1 -0
  74. package/src/workflows/import-app/shared.js +1 -1
  75. package/src/workflows/reconcile/adoption-plan/build.js +3 -1
  76. package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
  77. package/src/workflows/reconcile/bundle-core/index.js +3 -0
  78. package/src/workflows/reconcile/candidate-model.js +15 -0
  79. package/src/workflows/reconcile/gap-report.js +4 -2
  80. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  81. package/src/workflows/reconcile/renderers.js +82 -0
  82. package/src/workflows/reconcile/summary.js +4 -0
  83. package/src/workflows/reconcile/workflow.js +2 -1
  84. package/src/workspace-paths.js +26 -2
@@ -47,6 +47,7 @@ import { reactNativeScreensExtractor } from "../extractors/ui/react-native-scree
47
47
  import { reactRouterUiExtractor } from "../extractors/ui/react-router.js";
48
48
  import { svelteKitUiExtractor } from "../extractors/ui/sveltekit.js";
49
49
  import { backendOnlyUiExtractor } from "../extractors/ui/backend-only.js";
50
+ import { genericCliExtractor } from "../extractors/cli/generic.js";
50
51
  import { genericWorkflowExtractor } from "../extractors/workflows/generic.js";
51
52
  import { genericVerificationExtractor } from "../extractors/verification/generic.js";
52
53
  import { authSessionEnricher } from "../enrichers/auth-session.js";
@@ -60,6 +61,7 @@ export const extractorRegistry = {
60
61
  db: [prismaExtractor, djangoModelsExtractor, efCoreExtractor, roomExtractor, swiftDataExtractor, dotnetModelsExtractor, flutterEntitiesExtractor, reactNativeEntitiesExtractor, railsSchemaExtractor, liquibaseExtractor, myBatisXmlExtractor, jpaExtractor, drizzleExtractor, sqlExtractor, snapshotExtractor],
61
62
  api: [openApiExtractor, openApiCodeExtractor, graphQlSdlExtractor, graphQlCodeFirstExtractor, trpcExtractor, aspNetCoreExtractor, retrofitExtractor, swiftWebApiExtractor, flutterDioExtractor, reactNativeRepositoryExtractor, fastifyExtractor, expressExtractor, djangoRoutesExtractor, railsRoutesExtractor, micronautExtractor, jaxRsExtractor, springWebExtractor, nextRouteExtractor, genericRouteFallbackExtractor, nextServerActionExtractor, nextAuthExtractor],
62
63
  ui: [nextAppRouterUiExtractor, nextPagesRouterUiExtractor, androidComposeUiExtractor, blazorUiExtractor, razorPagesUiExtractor, swiftUiExtractor, uiKitExtractor, mauiXamlUiExtractor, flutterScreensUiExtractor, reactNativeScreensExtractor, reactRouterUiExtractor, svelteKitUiExtractor, backendOnlyUiExtractor],
64
+ cli: [genericCliExtractor],
63
65
  workflows: [genericWorkflowExtractor],
64
66
  verification: [genericVerificationExtractor]
65
67
  };
@@ -68,6 +70,7 @@ export const enricherRegistry = {
68
70
  db: [railsModelEnricher],
69
71
  api: [djangoRestEnricher, railsControllerEnricher, authSessionEnricher],
70
72
  ui: [],
73
+ cli: [],
71
74
  workflows: [workflowTargetStateEnricher, docLinkingEnricher],
72
75
  verification: []
73
76
  };
@@ -288,6 +288,13 @@ export function normalizeCandidatesForTrack(track, candidates) {
288
288
  stacks: [...new Set(candidates.stacks || [])].sort()
289
289
  };
290
290
  }
291
+ if (track === "cli") {
292
+ return {
293
+ commands: dedupeCandidateRecords(candidates.commands || [], (/** @type {any} */ record) => record.command_id || record.id_hint),
294
+ capabilities: dedupeCandidateRecords(candidates.capabilities || [], idHint),
295
+ surfaces: dedupeCandidateRecords(candidates.surfaces || [], idHint)
296
+ };
297
+ }
291
298
  if (track === "verification") {
292
299
  return {
293
300
  verifications: dedupeCandidateRecords(candidates.verifications || [], idHint),
@@ -29,6 +29,14 @@ export function reportMarkdown(track, candidates) {
29
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`
30
30
  );
31
31
  }
32
+ if (track === "cli") {
33
+ const commandLines = (candidates.commands || []).map((/** @type {any} */ command) =>
34
+ `- \`${command.command_id || command.id_hint}\` usage \`${command.usage || "unknown"}\` effects ${(command.effects || []).map((/** @type {any} */ effect) => `\`${effect}\``).join(", ") || "_none_"}`
35
+ );
36
+ return ensureTrailingNewline(
37
+ `# CLI Import Report\n\n- Commands: ${candidates.commands.length}\n- Capabilities: ${candidates.capabilities.length}\n- CLI surfaces: ${candidates.surfaces.length}\n\n## Command Candidates\n\n${commandLines.length ? commandLines.join("\n") : "- none"}\n`
38
+ );
39
+ }
32
40
  if (track === "verification") {
33
41
  return ensureTrailingNewline(
34
42
  `# Verification Import Report\n\n- Verifications: ${candidates.verifications.length}\n- Scenarios: ${candidates.scenarios.length}\n- Frameworks: ${candidates.frameworks.length ? candidates.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.scripts.length}\n`
@@ -46,6 +54,6 @@ export function reportMarkdown(track, candidates) {
46
54
  */
47
55
  export function appReportMarkdown(candidates, tracks) {
48
56
  return ensureTrailingNewline(
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`
57
+ `# 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## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\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`
50
58
  );
51
59
  }
@@ -104,6 +104,9 @@ function initialCandidatesForTrack(track) {
104
104
  if (track === "ui") {
105
105
  return { screens: [], routes: [], actions: [], stacks: [] };
106
106
  }
107
+ if (track === "cli") {
108
+ return { commands: [], capabilities: [], surfaces: [] };
109
+ }
107
110
  if (track === "verification") {
108
111
  return { verifications: [], scenarios: [], frameworks: [], scripts: [] };
109
112
  }
@@ -0,0 +1,340 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ findImportFiles,
5
+ idHintify,
6
+ makeCandidateRecord,
7
+ normalizeImportRelativePath,
8
+ readJsonIfExists,
9
+ readTextIfExists,
10
+ titleCase
11
+ } from "../../core/shared.js";
12
+
13
+ const CLI_SOURCE_PATTERN = /(^|\/)(bin|cli|command|commands|parser|help)(\/|[-_.A-Za-z0-9]*\.(?:js|mjs|cjs|ts))$/i;
14
+ const JS_SOURCE_PATTERN = /\.(?:js|mjs|cjs|ts)$/i;
15
+
16
+ /**
17
+ * @param {string} value
18
+ * @returns {string}
19
+ */
20
+ function normalizePath(value) {
21
+ return value.replaceAll("\\", "/");
22
+ }
23
+
24
+ /**
25
+ * @param {string} commandId
26
+ * @returns {string}
27
+ */
28
+ function capabilityIdForCommand(commandId) {
29
+ return `cap_${idHintify(commandId)}`;
30
+ }
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {string[]}
35
+ */
36
+ function extractUsageLines(text) {
37
+ const lines = [];
38
+ for (const rawLine of text.split(/\r?\n/)) {
39
+ const line = rawLine
40
+ .replace(/^\s*(?:console\.log\(|print\(|echo\s+)?["'`]*/, "")
41
+ .replace(/["'`),;]*\s*$/, "")
42
+ .trim();
43
+ if (!line) continue;
44
+ const usageMatch = line.match(/(?:^|\b)Usage:\s*(.+)$/i);
45
+ if (usageMatch?.[1]) {
46
+ lines.push(usageMatch[1].trim());
47
+ continue;
48
+ }
49
+ if (/^[a-zA-Z][\w.-]+(?:\s+[a-zA-Z][\w:-]+)+(?:\s|$)/.test(line) && /(?:--[a-zA-Z][\w:-]*|<[^>]+>|\[[^\]]+\])/.test(line)) {
50
+ lines.push(line);
51
+ }
52
+ }
53
+ return [...new Set(lines)].sort();
54
+ }
55
+
56
+ /**
57
+ * @param {string} usage
58
+ * @param {Set<string>} binNames
59
+ * @returns {{ id: string, commandPath: string[] } | null}
60
+ */
61
+ function commandIdFromUsage(usage, binNames) {
62
+ const tokens = usage.split(/\s+/).filter(Boolean);
63
+ if (tokens.length === 0) {
64
+ return null;
65
+ }
66
+ const firstCommandToken = binNames.has(tokens[0]) ? 1 : 0;
67
+ const commandPath = [];
68
+ for (let index = firstCommandToken; index < tokens.length; index += 1) {
69
+ const token = tokens[index];
70
+ if (!token || token.startsWith("-") || token.startsWith("[") || token.startsWith("<")) {
71
+ break;
72
+ }
73
+ commandPath.push(token.replace(/[^a-zA-Z0-9:_-]/g, ""));
74
+ }
75
+ if (commandPath.length === 0) {
76
+ return null;
77
+ }
78
+ return {
79
+ id: idHintify(commandPath.join("_")),
80
+ commandPath
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @param {string} usage
86
+ * @param {string} commandId
87
+ * @returns {any[]}
88
+ */
89
+ function optionsFromUsage(usage, commandId) {
90
+ const options = [];
91
+ const optionPattern = /--([a-zA-Z][\w:-]*)(?:\s+(<[^>]+>|\[[^\]]+\]))?/g;
92
+ for (const match of usage.matchAll(optionPattern)) {
93
+ const optionName = idHintify(match[1]);
94
+ options.push({
95
+ command_id: commandId,
96
+ name: optionName,
97
+ flag: `--${match[1]}`,
98
+ type: match[2] ? "string" : "boolean",
99
+ required: false,
100
+ values: [],
101
+ description: null
102
+ });
103
+ }
104
+ return options;
105
+ }
106
+
107
+ /**
108
+ * @param {string} commandId
109
+ * @param {string} usage
110
+ * @param {string} sourceText
111
+ * @returns {string[]}
112
+ */
113
+ function effectsForCommand(commandId, usage, sourceText) {
114
+ const text = `${commandId} ${usage}`.toLowerCase();
115
+ const effects = new Set();
116
+ if (/\b(check|list|show|help|doctor|status|brief|explain|query|emit)\b/.test(text)) {
117
+ effects.add("read_only");
118
+ }
119
+ if (/\b(import|adopt|new|generate|refresh|update|write|create|copy)\b/.test(text)) {
120
+ effects.add("writes_workspace");
121
+ effects.add("filesystem");
122
+ }
123
+ if (/\b(fetch|https?:\/\/|github|gh_token|github_token|network|catalog)\b/.test(text)) {
124
+ effects.add("network");
125
+ }
126
+ if (/\b(npm|package|install|publish|pack)\b/.test(text)) {
127
+ effects.add("package_install");
128
+ }
129
+ if (/\b(git|worktree|commit|push|pull|checkout|branch)\b/.test(text)) {
130
+ effects.add("git");
131
+ }
132
+ if (effects.size === 0) {
133
+ effects.add("read_only");
134
+ }
135
+ return [...effects].sort();
136
+ }
137
+
138
+ /**
139
+ * @param {string} commandId
140
+ * @param {any[]} options
141
+ * @returns {any[]}
142
+ */
143
+ function outputsForCommand(commandId, options) {
144
+ const hasJson = options.some((option) => option.command_id === commandId && option.name === "json");
145
+ return [
146
+ ...(hasJson ? [{ command_id: commandId, format: "json", schema_id: null, description: "Machine-readable JSON output" }] : []),
147
+ { command_id: commandId, format: "human", schema_id: null, description: "Human-readable terminal output" }
148
+ ];
149
+ }
150
+
151
+ /**
152
+ * @param {any[]} records
153
+ * @param {(record: any) => string} keyFn
154
+ * @returns {any[]}
155
+ */
156
+ function dedupeRecords(records, keyFn) {
157
+ const seen = new Set();
158
+ const deduped = [];
159
+ for (const record of records) {
160
+ const key = keyFn(record);
161
+ if (seen.has(key)) continue;
162
+ seen.add(key);
163
+ deduped.push(record);
164
+ }
165
+ return deduped;
166
+ }
167
+
168
+ /**
169
+ * @param {any} context
170
+ * @returns {{ packageFiles: string[], sourceFiles: string[] }}
171
+ */
172
+ function discoverCliSources(context) {
173
+ const packageFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => /package\.json$/i.test(filePath));
174
+ const sourceFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => {
175
+ const normalized = normalizePath(filePath);
176
+ return JS_SOURCE_PATTERN.test(normalized) && (
177
+ CLI_SOURCE_PATTERN.test(normalized) ||
178
+ normalized.includes("/src/cli/") ||
179
+ normalized.includes("/commands/")
180
+ );
181
+ });
182
+ return { packageFiles, sourceFiles };
183
+ }
184
+
185
+ export const genericCliExtractor = {
186
+ id: "cli.generic",
187
+ track: "cli",
188
+ /** @param {any} context */
189
+ detect(context) {
190
+ const { packageFiles, sourceFiles } = discoverCliSources(context);
191
+ const hasBin = packageFiles.some((filePath) => {
192
+ const pkg = readJsonIfExists(filePath);
193
+ return Boolean(pkg?.bin);
194
+ });
195
+ const score = (hasBin ? 70 : 0) + Math.min(30, sourceFiles.length * 5);
196
+ return {
197
+ score,
198
+ reasons: [
199
+ hasBin ? "package.json declares a CLI bin" : null,
200
+ sourceFiles.length ? `${sourceFiles.length} CLI-like source files found` : null
201
+ ].filter(Boolean)
202
+ };
203
+ },
204
+ /** @param {any} context */
205
+ extract(context) {
206
+ const { packageFiles, sourceFiles } = discoverCliSources(context);
207
+ const findings = [];
208
+ const commands = [];
209
+ const options = [];
210
+ const outputs = [];
211
+ const effects = [];
212
+ const examples = [];
213
+ const capabilities = [];
214
+ const binNames = new Set();
215
+ const provenance = [];
216
+
217
+ for (const packagePath of packageFiles) {
218
+ const pkg = readJsonIfExists(packagePath);
219
+ if (!pkg) continue;
220
+ const relPath = normalizeImportRelativePath(context.paths, packagePath);
221
+ const bin = pkg.bin;
222
+ if (typeof bin === "string") {
223
+ binNames.add(pkg.name ? String(pkg.name).split("/").pop() : "cli");
224
+ } else if (bin && typeof bin === "object") {
225
+ for (const name of Object.keys(bin)) {
226
+ binNames.add(name);
227
+ }
228
+ }
229
+ for (const [name, command] of Object.entries(pkg.scripts || {})) {
230
+ if (/^(cli|bin|start|check|test|verify)(:|$)/.test(name) || /\b(node|tsx|ts-node)\b.+\b(cli|bin)\b/i.test(String(command))) {
231
+ findings.push({
232
+ kind: "cli_script",
233
+ name,
234
+ command,
235
+ source: relPath
236
+ });
237
+ }
238
+ }
239
+ provenance.push(`${relPath}#bin`);
240
+ }
241
+
242
+ for (const sourcePath of sourceFiles) {
243
+ const sourceText = readTextIfExists(sourcePath) || "";
244
+ const relPath = normalizeImportRelativePath(context.paths, sourcePath);
245
+ const usageLines = extractUsageLines(sourceText);
246
+ if (usageLines.length === 0) {
247
+ continue;
248
+ }
249
+ findings.push({
250
+ kind: "cli_usage",
251
+ source: relPath,
252
+ usages: usageLines
253
+ });
254
+ for (const usage of usageLines) {
255
+ const command = commandIdFromUsage(usage, binNames);
256
+ if (!command) continue;
257
+ const commandOptions = optionsFromUsage(usage, command.id);
258
+ const commandEffects = effectsForCommand(command.id, usage, sourceText);
259
+ const capabilityId = capabilityIdForCommand(command.id);
260
+ const commandProvenance = [`${relPath}#${usage}`];
261
+ commands.push(makeCandidateRecord({
262
+ kind: "cli_command",
263
+ idHint: `cmd_${command.id}`,
264
+ label: titleCase(command.commandPath.join(" ")),
265
+ confidence: "medium",
266
+ sourceKind: "cli_usage",
267
+ sourceOfTruth: "candidate",
268
+ provenance: commandProvenance,
269
+ track: "cli",
270
+ command_id: command.id,
271
+ command_path: command.commandPath,
272
+ capability_id: capabilityId,
273
+ usage,
274
+ mode: commandEffects.includes("writes_workspace") ? "writes_workspace" : commandEffects[0],
275
+ options: commandOptions.map((option) => option.name),
276
+ effects: commandEffects
277
+ }));
278
+ capabilities.push(makeCandidateRecord({
279
+ kind: "capability",
280
+ idHint: capabilityId,
281
+ label: titleCase(command.commandPath.join(" ")),
282
+ confidence: "medium",
283
+ sourceKind: "cli_command",
284
+ sourceOfTruth: "candidate",
285
+ provenance: commandProvenance,
286
+ track: "cli",
287
+ command_id: command.id
288
+ }));
289
+ options.push(...commandOptions.map((option) => ({
290
+ ...option,
291
+ provenance: commandProvenance
292
+ })));
293
+ outputs.push(...outputsForCommand(command.id, commandOptions).map((output) => ({
294
+ ...output,
295
+ provenance: commandProvenance
296
+ })));
297
+ effects.push(...commandEffects.map((effect) => ({
298
+ command_id: command.id,
299
+ effect,
300
+ target: effect === "read_only" ? "workspace" : null,
301
+ provenance: commandProvenance
302
+ })));
303
+ examples.push({
304
+ command_id: command.id,
305
+ example: usage,
306
+ provenance: commandProvenance
307
+ });
308
+ }
309
+ }
310
+
311
+ const surfaceCommands = dedupeRecords(commands, (record) => record.command_id);
312
+ const surfaces = surfaceCommands.length > 0
313
+ ? [makeCandidateRecord({
314
+ kind: "cli_surface",
315
+ idHint: "proj_cli_surface",
316
+ label: "CLI Surface",
317
+ confidence: "medium",
318
+ sourceKind: "cli_usage",
319
+ sourceOfTruth: "candidate",
320
+ provenance,
321
+ track: "cli",
322
+ commands: surfaceCommands.map((record) => record.command_id),
323
+ command_records: surfaceCommands,
324
+ options: dedupeRecords(options, (record) => `${record.command_id}:${record.name}`),
325
+ outputs: dedupeRecords(outputs, (record) => `${record.command_id}:${record.format}`),
326
+ effects: dedupeRecords(effects, (record) => `${record.command_id}:${record.effect}`),
327
+ examples: dedupeRecords(examples, (record) => `${record.command_id}:${record.example}`)
328
+ })]
329
+ : [];
330
+
331
+ return {
332
+ findings,
333
+ candidates: {
334
+ commands: surfaceCommands,
335
+ capabilities: dedupeRecords(capabilities, (record) => record.id_hint),
336
+ surfaces
337
+ }
338
+ };
339
+ }
340
+ };
@@ -285,9 +285,10 @@ Start here before editing this Topogram project.
285
285
  1. \`AGENTS.md\`
286
286
  2. \`README.md\`
287
287
  3. \`topogram.project.json\`
288
- 4. \`topogram.template-policy.json\`
289
- 5. \`topogram.generator-policy.json\`
290
- ${hasImplementation ? "6. `.topogram-template-trust.json`\n7. `implementation/`\n8. Focused `topogram query ...` output\n" : "6. Focused `topogram query ...` output\n"}
288
+ 4. \`topogram.sdlc-policy.json\`, if this project has adopted SDLC enforcement
289
+ 5. \`topogram.template-policy.json\`
290
+ 6. \`topogram.generator-policy.json\`
291
+ ${hasImplementation ? "7. `.topogram-template-trust.json`\n8. `implementation/`\n9. Focused `topogram query ...` output\n" : "7. Focused `topogram query ...` output\n"}
291
292
  Machine-readable source:
292
293
 
293
294
  \`\`\`bash
@@ -310,6 +311,8 @@ npm run doctor
310
311
  npm run source:status
311
312
  npm run template:explain
312
313
  npm run generator:policy:check
314
+ topogram sdlc policy explain --json
315
+ topogram sdlc explain <task-or-bug-id> --json
313
316
  ${hasImplementation ? "npm run trust:status\n" : ""}npm run check
314
317
  npm run query:list
315
318
  npm run query:show -- widget-behavior
@@ -318,6 +321,10 @@ npm run query:show -- widget-behavior
318
321
  ## Edit Rules
319
322
 
320
323
  - Edit \`topo/**\` and \`topogram.project.json\` first.
324
+ - If \`topogram.sdlc-policy.json\` exists, protected changes need an SDLC item, a \`topo/sdlc/**\` SDLC record update, or an allowed exemption.
325
+ - Adopted SDLC records default to \`topo/sdlc/**\`; custom folders can still validate, but agents should look there first.
326
+ - Status, history, archives, trust hashes, provenance, generated sentinels, and release/rollout state are command-owned. Use Topogram commands instead of hand-editing those sidecars.
327
+ - Plans are optional support records. Agents may edit plan text directly, but should use \`topogram sdlc plan step ... --write\` for step status changes.
321
328
  - Review policy files before editing \`topogram.template-policy.json\` or \`topogram.generator-policy.json\`.
322
329
  - Do not make lasting edits under generated-owned \`app/**\`; use \`npm run generate\` to replace generated output.
323
330
  - If an output is changed to maintained ownership, agents may edit that app code directly after reading focused query packets.
@@ -4,6 +4,7 @@
4
4
  // Back-link arrays:
5
5
  // blockingMe — tasks whose `blocks` references this task (reciprocal of blocked_by)
6
6
  // blockedByMe — tasks whose `blocked_by` references this task (reciprocal of blocks)
7
+ // plans — implementation plans attached to this task
7
8
  //
8
9
  // Note: we deliberately compute *both* directions even though `blocks` and
9
10
  // `blocked_by` are reciprocal, because authors only write one side. The
@@ -13,6 +14,7 @@
13
14
  export function enrichTask(task, index) {
14
15
  return {
15
16
  blockingMe: (index.tasksThatBlockTarget.get(task.id) || []).slice().sort(),
16
- blockedByMe: (index.tasksBlockedByTarget.get(task.id) || []).slice().sort()
17
+ blockedByMe: (index.tasksBlockedByTarget.get(task.id) || []).slice().sort(),
18
+ plans: (index.plansByTask.get(task.id) || []).slice().sort()
17
19
  };
18
20
  }
@@ -114,6 +114,7 @@ export function resolveWorkspace(workspaceAst) {
114
114
  pitches: [],
115
115
  requirements: [],
116
116
  tasks: [],
117
+ plans: [],
117
118
  bugs: [],
118
119
  documents: []
119
120
  });
@@ -130,6 +131,7 @@ export function resolveWorkspace(workspaceAst) {
130
131
  pitch: "pitches",
131
132
  requirement: "requirements",
132
133
  task: "tasks",
134
+ plan: "plans",
133
135
  bug: "bugs"
134
136
  };
135
137
  for (const statement of resolvedStatements) {
@@ -175,6 +177,7 @@ export function resolveWorkspace(workspaceAst) {
175
177
  rulesByFromRequirement: new Map(),
176
178
  tasksThatBlockTarget: new Map(),
177
179
  tasksBlockedByTarget: new Map(),
180
+ plansByTask: new Map(),
178
181
  affectedByPitches: new Map(),
179
182
  affectedByRequirements: new Map(),
180
183
  affectedByTasks: new Map(),
@@ -227,6 +230,9 @@ export function resolveWorkspace(workspaceAst) {
227
230
  pushIndexFromList(sdlcIndex.tasksThatBlockTarget, statement.blocks, statement.id);
228
231
  pushIndexFromList(sdlcIndex.tasksBlockedByTarget, statement.blockedBy, statement.id);
229
232
  break;
233
+ case "plan":
234
+ pushIndex(sdlcIndex.plansByTask, statement.task?.id, statement.id);
235
+ break;
230
236
  case "bug":
231
237
  pushIndexFromList(sdlcIndex.affectedByBugs, statement.affects, statement.id);
232
238
  pushIndexFromList(sdlcIndex.rulesViolatedByBug, statement.violates, statement.id);
@@ -43,6 +43,13 @@ import {
43
43
  parseProjectionHttpResponsesBlock,
44
44
  parseProjectionHttpStatusBlock
45
45
  } from "./projections-api.js";
46
+ import {
47
+ parseProjectionCliCommandsBlock,
48
+ parseProjectionCliEffectsBlock,
49
+ parseProjectionCliExamplesBlock,
50
+ parseProjectionCliOptionsBlock,
51
+ parseProjectionCliOutputsBlock
52
+ } from "./projections-cli.js";
46
53
  import {
47
54
  parseProjectionUiActionsBlock,
48
55
  parseProjectionUiAppShellBlock,
@@ -67,6 +74,7 @@ import {
67
74
  parseProjectionDbTablesBlock,
68
75
  parseProjectionGeneratorDefaultsBlock
69
76
  } from "./projections-db.js";
77
+ import { parsePlanSteps } from "../sdlc/plan-steps.js";
70
78
 
71
79
  export function normalizeStatement(statement, registry) {
72
80
  const fieldMap = collectFieldMap(statement);
@@ -209,6 +217,11 @@ export function normalizeStatement(statement, registry) {
209
217
  httpDownload: parseProjectionHttpDownloadBlock(statement, registry),
210
218
  httpAuthz: parseProjectionHttpAuthzBlock(statement, registry),
211
219
  httpCallbacks: parseProjectionHttpCallbacksBlock(statement, registry),
220
+ commands: parseProjectionCliCommandsBlock(statement, registry),
221
+ commandOptions: parseProjectionCliOptionsBlock(statement),
222
+ commandOutputs: parseProjectionCliOutputsBlock(statement, registry),
223
+ commandEffects: parseProjectionCliEffectsBlock(statement),
224
+ commandExamples: parseProjectionCliExamplesBlock(statement),
212
225
  uiScreens: parseProjectionUiScreensBlock(statement, registry),
213
226
  screens: parseProjectionUiScreensBlock(statement, registry),
214
227
  uiCollections: parseProjectionUiCollectionsBlock(statement),
@@ -343,9 +356,11 @@ export function normalizeStatement(statement, registry) {
343
356
  ...base,
344
357
  priority: symbolValue(getFieldValue(statement, "priority")),
345
358
  workType: symbolValue(getFieldValue(statement, "work_type")),
359
+ disposition: symbolValue(getFieldValue(statement, "disposition")),
346
360
  affects: resolveReferenceList(registry, getFieldValue(statement, "affects")),
347
361
  satisfies: resolveReferenceList(registry, getFieldValue(statement, "satisfies")),
348
362
  acceptanceRefs: resolveReferenceList(registry, getFieldValue(statement, "acceptance_refs")),
363
+ verificationRefs: resolveReferenceList(registry, getFieldValue(statement, "verification_refs")),
349
364
  blocks: resolveReferenceList(registry, getFieldValue(statement, "blocks")),
350
365
  blockedBy: resolveReferenceList(registry, getFieldValue(statement, "blocked_by")),
351
366
  claimedBy: resolveReferenceList(registry, getFieldValue(statement, "claimed_by")),
@@ -356,6 +371,22 @@ export function normalizeStatement(statement, registry) {
356
371
  updated: stringValue(getFieldValue(statement, "updated")),
357
372
  resolvedDomain: resolveDomainTag(statement, registry)
358
373
  };
374
+ case "plan":
375
+ return {
376
+ ...base,
377
+ task: getFieldValue(statement, "task")
378
+ ? {
379
+ id: symbolValue(getFieldValue(statement, "task")),
380
+ target: toRef(resolveReference(registry, symbolValue(getFieldValue(statement, "task"))))
381
+ }
382
+ : null,
383
+ priority: symbolValue(getFieldValue(statement, "priority")),
384
+ notes: stringValue(getFieldValue(statement, "notes")),
385
+ outcome: stringValue(getFieldValue(statement, "outcome")),
386
+ steps: parsePlanSteps(getFieldValue(statement, "steps")),
387
+ updated: stringValue(getFieldValue(statement, "updated")),
388
+ resolvedDomain: resolveDomainTag(statement, registry)
389
+ };
359
390
  case "bug":
360
391
  return {
361
392
  ...base,