@topogram/cli 0.3.72 → 0.3.73

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 (81) 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/sdlc.js +213 -5
  17. package/src/cli/dispatcher.js +8 -0
  18. package/src/cli/help.js +14 -2
  19. package/src/cli/options.js +1 -0
  20. package/src/generator/context/shared/domain-sdlc.js +27 -0
  21. package/src/generator/context/shared/relationships.js +2 -1
  22. package/src/generator/context/shared/types.d.ts +1 -0
  23. package/src/generator/context/shared.d.ts +2 -0
  24. package/src/generator/context/shared.js +2 -0
  25. package/src/generator/context/slice/core.js +3 -0
  26. package/src/generator/context/slice/sdlc.js +57 -2
  27. package/src/generator/context/task-mode.js +7 -0
  28. package/src/generator/sdlc/board.js +2 -0
  29. package/src/generator/sdlc/traceability-matrix.js +5 -1
  30. package/src/import/core/context.js +1 -1
  31. package/src/import/core/contracts.js +3 -3
  32. package/src/import/core/registry.js +3 -0
  33. package/src/import/core/runner/candidates.js +7 -0
  34. package/src/import/core/runner/reports.js +9 -1
  35. package/src/import/core/runner/tracks.js +3 -0
  36. package/src/import/extractors/cli/generic.js +340 -0
  37. package/src/new-project/project-files.js +10 -3
  38. package/src/resolver/enrich/task.js +3 -1
  39. package/src/resolver/index.js +6 -0
  40. package/src/resolver/normalize.js +31 -0
  41. package/src/resolver/projections-cli.js +158 -0
  42. package/src/sdlc/adopt.js +4 -1
  43. package/src/sdlc/check.js +24 -2
  44. package/src/sdlc/complete.js +47 -0
  45. package/src/sdlc/dod/index.js +2 -0
  46. package/src/sdlc/dod/plan.js +15 -0
  47. package/src/sdlc/dod/task.js +7 -3
  48. package/src/sdlc/explain.js +53 -1
  49. package/src/sdlc/gate.js +352 -0
  50. package/src/sdlc/history.d.ts +7 -0
  51. package/src/sdlc/history.js +50 -5
  52. package/src/sdlc/link.js +172 -0
  53. package/src/sdlc/paths.d.ts +4 -0
  54. package/src/sdlc/paths.js +8 -0
  55. package/src/sdlc/plan-steps.js +71 -0
  56. package/src/sdlc/plan.js +245 -0
  57. package/src/sdlc/policy.js +249 -0
  58. package/src/sdlc/prep.js +186 -0
  59. package/src/sdlc/scaffold.js +4 -2
  60. package/src/sdlc/status-filter.js +2 -0
  61. package/src/sdlc/transitions/index.js +3 -0
  62. package/src/sdlc/transitions/plan.js +32 -0
  63. package/src/validator/common.js +25 -4
  64. package/src/validator/index.js +10 -0
  65. package/src/validator/kinds.d.ts +7 -0
  66. package/src/validator/kinds.js +32 -0
  67. package/src/validator/per-kind/plan.js +128 -0
  68. package/src/validator/per-kind/task.js +19 -0
  69. package/src/validator/projections/cli.js +267 -0
  70. package/src/validator.d.ts +1 -0
  71. package/src/workflows/import-app/shared.js +1 -1
  72. package/src/workflows/reconcile/adoption-plan/build.js +3 -1
  73. package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
  74. package/src/workflows/reconcile/bundle-core/index.js +3 -0
  75. package/src/workflows/reconcile/candidate-model.js +15 -0
  76. package/src/workflows/reconcile/gap-report.js +4 -2
  77. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  78. package/src/workflows/reconcile/renderers.js +82 -0
  79. package/src/workflows/reconcile/summary.js +4 -0
  80. package/src/workflows/reconcile/workflow.js +2 -1
  81. package/src/workspace-paths.js +26 -2
@@ -8,6 +8,7 @@ import {
8
8
  domainById,
9
9
  getJourneyDoc,
10
10
  pitchById,
11
+ planById,
11
12
  recommendedVerificationTargets,
12
13
  relatedEntitiesForDomain,
13
14
  relatedProjectionsForDomain,
@@ -21,6 +22,7 @@ import {
21
22
  summarizeDocument,
22
23
  summarizeDomain,
23
24
  summarizePitch,
25
+ summarizePlan,
24
26
  summarizeRequirement,
25
27
  summarizeStatementsByIds,
26
28
  summarizeTask,
@@ -297,10 +299,12 @@ export function taskSlice(graph, taskId) {
297
299
 
298
300
  const satisfies = (task.satisfies || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
299
301
  const acRefs = (task.acceptanceRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
302
+ const verificationRefs = (task.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
300
303
  const blockedBy = (task.blockedBy || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
301
304
  const blocks = (task.blocks || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
302
305
  const affects = (task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean).sort();
303
- const verifications = verificationIdsForTarget(graph, [taskId, ...affects, ...acRefs]);
306
+ const plans = (task.plans || []).slice().sort();
307
+ const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [taskId, ...affects, ...acRefs])])].sort();
304
308
 
305
309
  return {
306
310
  type: "context_slice",
@@ -310,15 +314,19 @@ export function taskSlice(graph, taskId) {
310
314
  depends_on: {
311
315
  satisfies,
312
316
  acceptance_refs: acRefs,
317
+ verification_refs: verificationRefs,
313
318
  blocked_by: blockedBy,
314
319
  blocks,
320
+ plans,
315
321
  affects,
316
322
  verifications
317
323
  },
318
324
  related: {
319
325
  satisfies: summarizeStatementsByIds(graph, satisfies),
320
326
  acceptance_refs: summarizeStatementsByIds(graph, acRefs),
327
+ verification_refs: summarizeStatementsByIds(graph, verificationRefs),
321
328
  blocked_by: summarizeStatementsByIds(graph, blockedBy),
329
+ plans: summarizeStatementsByIds(graph, plans),
322
330
  affects: summarizeStatementsByIds(graph, affects)
323
331
  },
324
332
  verification: summarizeStatementsByIds(graph, verifications),
@@ -331,6 +339,54 @@ export function taskSlice(graph, taskId) {
331
339
  };
332
340
  }
333
341
 
342
+ /**
343
+ * @param {import("../shared/types.d.ts").ContextGraph} graph
344
+ * @param {string} planId
345
+ * @returns {any}
346
+ */
347
+ export function planSlice(graph, planId) {
348
+ const plan = planById(graph, planId);
349
+ if (!plan) throw new Error(`No plan found with id '${planId}'`);
350
+
351
+ const taskId = plan.task?.id || null;
352
+ const task = taskId ? taskById(graph, taskId) : null;
353
+ const satisfies = task ? (task.satisfies || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
354
+ const acRefs = task ? (task.acceptanceRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
355
+ const verificationRefs = task ? (task.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
356
+ const affects = task ? (task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean).sort() : [];
357
+ const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [planId, ...(taskId ? [taskId] : []), ...affects, ...acRefs])])].sort();
358
+
359
+ return {
360
+ type: "context_slice",
361
+ version: 1,
362
+ focus: { kind: "plan", id: planId },
363
+ summary: summarizePlan(plan),
364
+ depends_on: {
365
+ task: taskId,
366
+ satisfies,
367
+ acceptance_refs: acRefs,
368
+ verification_refs: verificationRefs,
369
+ affects,
370
+ verifications
371
+ },
372
+ related: {
373
+ task: task ? [summarizeTask(task)] : [],
374
+ satisfies: summarizeStatementsByIds(graph, satisfies),
375
+ acceptance_refs: summarizeStatementsByIds(graph, acRefs),
376
+ verification_refs: summarizeStatementsByIds(graph, verificationRefs),
377
+ affects: summarizeStatementsByIds(graph, affects)
378
+ },
379
+ steps: plan.steps || [],
380
+ verification: summarizeStatementsByIds(graph, verifications),
381
+ verification_targets: recommendedVerificationTargets(graph, [planId, ...(taskId ? [taskId] : []), ...affects, ...acRefs], {
382
+ rationale: "Plan slice points at verification for the owning task and affected surfaces."
383
+ }),
384
+ write_scope: buildDefaultWriteScope(),
385
+ review_boundary: reviewBoundaryForTask(),
386
+ ownership_boundary: defaultOwnershipBoundary()
387
+ };
388
+ }
389
+
334
390
  /**
335
391
  * @param {import("../shared/types.d.ts").ContextGraph} graph
336
392
  * @param {string} bugId
@@ -414,4 +470,3 @@ export function documentSlice(graph, documentId) {
414
470
  ownership_boundary: defaultOwnershipBoundary()
415
471
  };
416
472
  }
417
-
@@ -234,6 +234,13 @@ function selectedSurface(options = {}) {
234
234
  if (options.entityId) return { kind: "entity", id: options.entityId };
235
235
  if (options.journeyId) return { kind: "journey", id: options.journeyId };
236
236
  if (options.surfaceId) return { kind: "surface", id: options.surfaceId };
237
+ if (options.pitchId) return { kind: "pitch", id: options.pitchId };
238
+ if (options.requirementId) return { kind: "requirement", id: options.requirementId };
239
+ if (options.acceptanceId) return { kind: "acceptance_criterion", id: options.acceptanceId };
240
+ if (options.taskId) return { kind: "task", id: options.taskId };
241
+ if (options.planId) return { kind: "plan", id: options.planId };
242
+ if (options.bugId) return { kind: "bug", id: options.bugId };
243
+ if (options.documentId) return { kind: "document", id: options.documentId };
237
244
  return null;
238
245
  }
239
246
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  summarizeBug,
10
+ summarizePlan,
10
11
  summarizePitch,
11
12
  summarizeRequirement,
12
13
  summarizeTask
@@ -20,6 +21,7 @@ const SUMMARIZERS = {
20
21
  pitch: summarizePitch,
21
22
  requirement: summarizeRequirement,
22
23
  task: summarizeTask,
24
+ plan: summarizePlan,
23
25
  bug: summarizeBug
24
26
  };
25
27
 
@@ -19,7 +19,11 @@ export function generateSdlcTraceabilityMatrix(graph, options = {}) {
19
19
  const pitch = pitchId ? pitchesById.get(pitchId) : null;
20
20
 
21
21
  const taskIds = (ac.tasks || []).slice().sort();
22
- const verificationIds = (ac.verifications || []).slice().sort();
22
+ const taskVerificationIds = taskIds.flatMap((id) => {
23
+ const task = tasksById.get(id);
24
+ return (task?.verificationRefs || []).map((ref) => typeof ref === "string" ? ref : ref?.id).filter(Boolean);
25
+ });
26
+ const verificationIds = [...new Set([...(ac.verifications || []), ...taskVerificationIds])].sort();
23
27
 
24
28
  const linkedBugIds = [];
25
29
  for (const bug of bugsById.values()) {
@@ -20,7 +20,7 @@ export function findNearestGitRoot(startDir) {
20
20
  }
21
21
 
22
22
  export function normalizeWorkspacePaths(inputPath) {
23
- const context = resolveWorkspaceContext(inputPath);
23
+ const context = resolveWorkspaceContext(inputPath, { ignoreAncestorConfig: true });
24
24
  const absolute = path.resolve(inputPath);
25
25
  const topogramRoot = context.topoRoot;
26
26
  const workspaceRoot = context.projectRoot;
@@ -1,17 +1,17 @@
1
- export const IMPORT_TRACKS = new Set(["db", "api", "ui", "workflows", "verification"]);
1
+ export const IMPORT_TRACKS = new Set(["db", "api", "ui", "cli", "workflows", "verification"]);
2
2
 
3
3
  /**
4
4
  * @typedef {{score:number, reasons:string[]}} DetectionResult
5
5
  * @typedef {{findings:any[], candidates:any}} ExtractResult
6
6
  * @typedef {{
7
7
  * id:string,
8
- * track:"db"|"api"|"ui"|"workflows"|"verification",
8
+ * track:"db"|"api"|"ui"|"cli"|"workflows"|"verification",
9
9
  * detect:(context:any)=>DetectionResult,
10
10
  * extract:(context:any)=>ExtractResult
11
11
  * }} ImportExtractor
12
12
  * @typedef {{
13
13
  * id:string,
14
- * track:"db"|"api"|"ui"|"workflows"|"verification"|"docs",
14
+ * track:"db"|"api"|"ui"|"cli"|"workflows"|"verification"|"docs",
15
15
  * applies:(context:any, candidates:any)=>boolean|number,
16
16
  * enrich:(context:any, candidates:any)=>any
17
17
  * }} ImportEnricher
@@ -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
  }