@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
@@ -40,6 +40,15 @@ export const BROWNFIELD_BROAD_ADOPT_SELECTORS = [
40
40
  },
41
41
  { selector: "workflows", kind: "track", label: "workflows", matches: (/** @type {AnyRecord} */ item) => item.track === "workflows" || item.kind === "decision" },
42
42
  { selector: "verification", kind: "kind", label: "verification", matches: (/** @type {AnyRecord} */ item) => item.kind === "verification" },
43
+ {
44
+ selector: "cli",
45
+ kind: "track",
46
+ label: "CLI surfaces",
47
+ matches: (/** @type {AnyRecord} */ item) =>
48
+ item.bundle === "cli" ||
49
+ item.track === "cli" ||
50
+ item.suggested_action === "promote_cli_surface"
51
+ },
43
52
  {
44
53
  selector: "ui",
45
54
  kind: "track",
@@ -116,6 +116,9 @@ export function importCandidateCounts(summary) {
116
116
  uiRoutes: candidates.ui?.routes?.length || 0,
117
117
  uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
118
118
  uiShapes: candidates.ui?.shapes?.length || 0,
119
+ cliCommands: candidates.cli?.commands?.length || 0,
120
+ cliCapabilities: candidates.cli?.capabilities?.length || 0,
121
+ cliSurfaces: candidates.cli?.surfaces?.length || 0,
119
122
  workflows: candidates.workflows?.workflows?.length || 0,
120
123
  verifications: candidates.verification?.verifications?.length || 0
121
124
  };
@@ -32,21 +32,22 @@
32
32
  * @returns {QueryDefinition[]}
33
33
  */
34
34
  export function queryDefinitions() {
35
+ const contextSelectors = ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document", "from-topogram"];
35
36
  return [
36
37
  {
37
38
  name: "slice",
38
39
  purpose: "Give an agent the smallest graph slice needed to reason about one selected semantic surface.",
39
40
  description: "Return a focused semantic context slice for one selected surface.",
40
- selectors: ["capability", "workflow", "projection", "widget", "entity", "journey", "domain"],
41
+ selectors: ["capability", "workflow", "projection", "widget", "entity", "journey", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"],
41
42
  args: ["[path]", "[selectors]", "[--json]"],
42
43
  output: "context_slice",
43
- example: "topogram query slice ./topo --widget widget_data_grid"
44
+ example: "topogram query slice ./topo --task task_implement_audit_writer"
44
45
  },
45
46
  {
46
47
  name: "verification-targets",
47
48
  purpose: "Map a selected change or mode to the smallest verification set worth running.",
48
49
  description: "Return the smallest verification target set for a mode, selector, or diff.",
49
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "from-topogram"],
50
+ selectors: contextSelectors.filter((selector) => selector !== "surface" && selector !== "domain" && selector !== "pitch" && selector !== "requirement" && selector !== "acceptance" && selector !== "plan" && selector !== "bug" && selector !== "document"),
50
51
  args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
51
52
  output: "verification_targets",
52
53
  example: "topogram query verification-targets ./topo --widget widget_data_grid"
@@ -64,7 +65,7 @@ export function queryDefinitions() {
64
65
  name: "change-plan",
65
66
  purpose: "Summarize what a selected change affects before code or Topogram edits start.",
66
67
  description: "Return the semantic change plan, generator targets, risk, and alignment recommendations.",
67
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "from-topogram"],
68
+ selectors: contextSelectors,
68
69
  args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
69
70
  output: "change_plan_query",
70
71
  example: "topogram query change-plan ./topo --widget widget_data_grid"
@@ -73,7 +74,7 @@ export function queryDefinitions() {
73
74
  name: "review-packet",
74
75
  purpose: "Bundle the context a human or agent needs to review a selected semantic change.",
75
76
  description: "Return the review packet for a selected change or diff.",
76
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "from-topogram"],
77
+ selectors: contextSelectors,
77
78
  args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
78
79
  output: "review_packet_query",
79
80
  example: "topogram query review-packet ./topo --widget widget_data_grid"
@@ -82,7 +83,7 @@ export function queryDefinitions() {
82
83
  name: "resolved-workflow-context",
83
84
  purpose: "Resolve workflow guidance and artifact load order for a selected mode or change.",
84
85
  description: "Return resolved workflow guidance, artifact load order, preset policy, and recommended artifact queries.",
85
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "provider", "preset", "from-topogram"],
86
+ selectors: [...contextSelectors.filter((selector) => selector !== "document"), "provider", "preset"],
86
87
  args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
87
88
  output: "resolved_workflow_context_query",
88
89
  example: "topogram query resolved-workflow-context ./topo --mode modeling --widget widget_data_grid --json"
@@ -91,7 +92,7 @@ export function queryDefinitions() {
91
92
  name: "single-agent-plan",
92
93
  purpose: "Give one coding agent a bounded plan, artifact set, and write guidance.",
93
94
  description: "Return a single-agent operating plan for a mode and optional selector.",
94
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "from-topogram"],
95
+ selectors: contextSelectors.filter((selector) => selector !== "document"),
95
96
  args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
96
97
  output: "single_agent_plan_query",
97
98
  example: "topogram query single-agent-plan ./topo --mode modeling --widget widget_data_grid --json"
@@ -100,7 +101,7 @@ export function queryDefinitions() {
100
101
  name: "risk-summary",
101
102
  purpose: "Surface behavioral, ownership, and verification risks for a selected change.",
102
103
  description: "Return the risk summary for a selected change, mode, or diff.",
103
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "from-topogram"],
104
+ selectors: contextSelectors,
104
105
  args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
105
106
  output: "risk_summary_query",
106
107
  example: "topogram query risk-summary ./topo --widget widget_data_grid"
@@ -109,7 +110,7 @@ export function queryDefinitions() {
109
110
  name: "proceed-decision",
110
111
  purpose: "Tell a human or agent whether enough context and proof exist to proceed.",
111
112
  description: "Return a proceed/no-go decision for the current selected work.",
112
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "from-topogram"],
113
+ selectors: contextSelectors,
113
114
  args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
114
115
  output: "proceed_decision_query",
115
116
  example: "topogram query proceed-decision ./topo --mode verification"
@@ -118,7 +119,7 @@ export function queryDefinitions() {
118
119
  name: "write-scope",
119
120
  purpose: "Define where an agent may edit for a selected semantic surface.",
120
121
  description: "Return safe edit boundaries for a selected mode or semantic surface.",
121
- selectors: ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "from-topogram"],
122
+ selectors: contextSelectors.filter((selector) => selector !== "surface" && selector !== "document"),
122
123
  args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
123
124
  output: "write_scope_query",
124
125
  example: "topogram query write-scope ./topo --widget widget_data_grid"
@@ -74,6 +74,13 @@ export function importAdoptOnlyRequested(options = {}) {
74
74
  options.journeyId ||
75
75
  options.surfaceId ||
76
76
  options.domainId ||
77
+ options.pitchId ||
78
+ options.requirementId ||
79
+ options.acceptanceId ||
80
+ options.taskId ||
81
+ options.planId ||
82
+ options.bugId ||
83
+ options.documentId ||
77
84
  options.fromTopogramPath
78
85
  );
79
86
  }
@@ -202,7 +209,14 @@ export function hasSelectors(options) {
202
209
  options.entityId ||
203
210
  options.journeyId ||
204
211
  options.surfaceId ||
205
- options.domainId
212
+ options.domainId ||
213
+ options.pitchId ||
214
+ options.requirementId ||
215
+ options.acceptanceId ||
216
+ options.taskId ||
217
+ options.planId ||
218
+ options.bugId ||
219
+ options.documentId
206
220
  );
207
221
  }
208
222
 
@@ -228,7 +242,14 @@ export function selectorOptions(options) {
228
242
  entityId: options.entityId,
229
243
  journeyId: options.journeyId,
230
244
  surfaceId: options.surfaceId,
231
- domainId: options.domainId
245
+ domainId: options.domainId,
246
+ pitchId: options.pitchId,
247
+ requirementId: options.requirementId,
248
+ acceptanceId: options.acceptanceId,
249
+ taskId: options.taskId,
250
+ planId: options.planId,
251
+ bugId: options.bugId,
252
+ documentId: options.documentId
232
253
  };
233
254
  }
234
255
 
@@ -16,6 +16,7 @@ import {
16
16
  expectedConsumerWorkflowName,
17
17
  hasStagedGitChanges,
18
18
  inspectConsumerCi,
19
+ inspectGitUpstreamAhead,
19
20
  inspectGitWorktreeClean,
20
21
  messageFromError,
21
22
  runGit,
@@ -24,12 +25,101 @@ import {
24
25
 
25
26
  /**
26
27
  * @typedef {Record<string, any>} AnyRecord
28
+ * @typedef {{ consumer?: string, step: string, status: "start"|"ok"|"skip"|"waiting"|"error", message: string, elapsedMs?: number, [key: string]: any }} ReleaseRollProgress
27
29
  */
28
30
 
31
+ /**
32
+ * @param {{ onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} options
33
+ * @param {ReleaseRollProgress} event
34
+ * @returns {void}
35
+ */
36
+ function notifyProgress(options, event) {
37
+ if (typeof options.onProgress === "function") {
38
+ options.onProgress(event);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {AnyRecord} item
44
+ * @param {{ onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} options
45
+ * @param {ReleaseRollProgress["step"]} step
46
+ * @param {ReleaseRollProgress["status"]} status
47
+ * @param {string} message
48
+ * @param {AnyRecord} [detail]
49
+ * @returns {ReleaseRollProgress}
50
+ */
51
+ function recordProgress(item, options, step, status, message, detail = {}) {
52
+ /** @type {ReleaseRollProgress} */
53
+ const event = {
54
+ consumer: item.name,
55
+ step,
56
+ status,
57
+ message
58
+ };
59
+ if (typeof detail.elapsedMs === "number") {
60
+ event.elapsedMs = detail.elapsedMs;
61
+ }
62
+ if (typeof detail.headSha === "string") {
63
+ event.headSha = detail.headSha;
64
+ }
65
+ if (typeof detail.expectedWorkflow === "string") {
66
+ event.expectedWorkflow = detail.expectedWorkflow;
67
+ }
68
+ if (detail.run && typeof detail.run === "object") {
69
+ event.run = {
70
+ workflowName: detail.run.workflowName || null,
71
+ status: detail.run.status || null,
72
+ conclusion: detail.run.conclusion || null,
73
+ headSha: detail.run.headSha || null,
74
+ url: detail.run.url || null
75
+ };
76
+ }
77
+ if (options.captureProgress !== true) {
78
+ notifyProgress(options, event);
79
+ return event;
80
+ }
81
+ if (!Array.isArray(item.progress)) {
82
+ item.progress = [];
83
+ }
84
+ item.progress.push(event);
85
+ notifyProgress(options, event);
86
+ return event;
87
+ }
88
+
89
+ /**
90
+ * @param {Array<AnyRecord>} consumers
91
+ * @param {{ version: string, push: boolean, watch: boolean, noWatch?: boolean }} options
92
+ * @returns {AnyRecord}
93
+ */
94
+ function buildRecoverySummary(consumers, options) {
95
+ /**
96
+ * @param {(consumer: AnyRecord) => boolean} predicate
97
+ * @returns {string[]}
98
+ */
99
+ const namesFor = (predicate) => consumers.filter(predicate).map((consumer) => String(consumer.name));
100
+ return {
101
+ version: options.version,
102
+ alreadyCurrent: namesFor((consumer) => consumer.alreadyCurrent === true),
103
+ alreadyPushed: namesFor((consumer) => consumer.alreadyPushed === true),
104
+ updated: namesFor((consumer) => consumer.updated === true),
105
+ committed: namesFor((consumer) => consumer.committed === true),
106
+ pushed: namesFor((consumer) => consumer.pushed === true),
107
+ recoveredPushes: namesFor((consumer) => consumer.recoveredPush === true),
108
+ needsAttention: namesFor((consumer) => (
109
+ /** @type {Array<AnyRecord>} */ (consumer.diagnostics || [])
110
+ ).some((diagnostic) => diagnostic.severity === "error")),
111
+ resumeCommand: `topogram release roll-consumers ${options.version}${options.push ? "" : " --no-push"}${options.watch ? " --watch" : options.noWatch ? " --no-watch" : ""}`,
112
+ asyncVerificationCommand: "topogram release status --strict",
113
+ watchGuidance: options.watch
114
+ ? "If CI waiting is too slow or interrupted, rerun roll-consumers with --no-watch, then verify with release status --strict."
115
+ : "This rollout did not wait for CI. Verify consumers after workflows finish with release status --strict."
116
+ };
117
+ }
118
+
29
119
  /**
30
120
  * @param {string} requested
31
- * @param {{ cwd?: string, push?: boolean, watch?: boolean }} [options]
32
- * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, watched: boolean, consumers: Array<AnyRecord>, diagnostics: Array<AnyRecord>, errors: string[] }}
121
+ * @param {{ cwd?: string, push?: boolean, watch?: boolean, noWatch?: boolean, onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} [options]
122
+ * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, watched: boolean, consumers: Array<AnyRecord>, diagnostics: Array<AnyRecord>, errors: string[], recovery: AnyRecord|null }}
33
123
  */
34
124
  export function buildReleaseRollConsumersPayload(requested, options = {}) {
35
125
  const cwd = options.cwd || process.cwd();
@@ -44,7 +134,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
44
134
  severity: "error",
45
135
  message: "`topogram release roll-consumers --watch` requires pushing consumer commits.",
46
136
  path: "release roll-consumers",
47
- suggestedFix: "Remove --no-push or run without --watch and verify consumer CI separately."
137
+ suggestedFix: "Remove --no-push or use --no-watch and verify consumer CI separately."
48
138
  });
49
139
  return {
50
140
  ok: false,
@@ -55,7 +145,8 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
55
145
  watched: watch,
56
146
  consumers: [],
57
147
  diagnostics,
58
- errors: diagnostics.map((diagnostic) => diagnostic.message)
148
+ errors: diagnostics.map((diagnostic) => diagnostic.message),
149
+ recovery: null
59
150
  };
60
151
  }
61
152
  const version = requestedLatest
@@ -79,9 +170,14 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
79
170
  commit: null,
80
171
  update: null,
81
172
  ci: null,
173
+ alreadyCurrent: false,
174
+ alreadyPushed: false,
175
+ recoveredPush: false,
176
+ upstreamAhead: null,
82
177
  diagnostics: []
83
178
  };
84
179
  consumers.push(item);
180
+ recordProgress(item, options, "inspect", "start", `${consumer.name}: inspecting repository and package metadata.`);
85
181
  if (!consumer.root || !fs.existsSync(consumer.root)) {
86
182
  item.diagnostics.push({
87
183
  code: "release_consumer_repo_missing",
@@ -91,6 +187,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
91
187
  suggestedFix: `Clone ${consumer.name} beside the topogram repo, then rerun roll-consumers.`
92
188
  });
93
189
  diagnostics.push(...item.diagnostics);
190
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: repository not found.`);
94
191
  continue;
95
192
  }
96
193
  const packagePath = path.join(consumer.root, "package.json");
@@ -103,6 +200,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
103
200
  suggestedFix: "Only package-backed first-party consumers can be rolled by this command."
104
201
  });
105
202
  diagnostics.push(...item.diagnostics);
203
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: package.json missing.`);
106
204
  continue;
107
205
  }
108
206
  const clean = inspectGitWorktreeClean(consumer.root);
@@ -115,11 +213,15 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
115
213
  suggestedFix: "Commit, stash, or discard unrelated consumer changes before rolling the CLI version."
116
214
  });
117
215
  diagnostics.push(...item.diagnostics);
216
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: worktree is dirty.`);
118
217
  continue;
119
218
  }
219
+ recordProgress(item, options, "inspect", "ok", `${consumer.name}: worktree is clean.`);
120
220
  try {
221
+ recordProgress(item, options, "update", "start", `${consumer.name}: updating ${CLI_PACKAGE_NAME} to ${version} and running package checks.`);
121
222
  item.update = buildPackageUpdateCliPayload(version, { cwd: consumer.root });
122
223
  item.updated = true;
224
+ recordProgress(item, options, "update", "ok", `${consumer.name}: package update/check completed.`);
123
225
  } catch (error) {
124
226
  item.diagnostics.push({
125
227
  code: "release_consumer_update_failed",
@@ -129,10 +231,12 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
129
231
  suggestedFix: "Fix the consumer update/check failure, then rerun roll-consumers."
130
232
  });
131
233
  diagnostics.push(...item.diagnostics);
234
+ recordProgress(item, options, "update", "error", `${consumer.name}: package update/check failed.`);
132
235
  continue;
133
236
  }
134
237
  const filesToStage = ["package.json", "package-lock.json", "topogram-cli.version"]
135
238
  .filter((file) => fs.existsSync(path.join(consumer.root || "", file)));
239
+ recordProgress(item, options, "stage", "start", `${consumer.name}: staging package pin changes.`);
136
240
  const addResult = runGit(["add", ...filesToStage], consumer.root);
137
241
  if (addResult.status !== 0) {
138
242
  item.diagnostics.push(commandDiagnostic({
@@ -144,6 +248,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
144
248
  result: addResult
145
249
  }));
146
250
  diagnostics.push(...item.diagnostics);
251
+ recordProgress(item, options, "stage", "error", `${consumer.name}: staging failed.`);
147
252
  continue;
148
253
  }
149
254
  const staged = hasStagedGitChanges(consumer.root);
@@ -157,16 +262,63 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
157
262
  result: staged.result
158
263
  }));
159
264
  diagnostics.push(...item.diagnostics);
265
+ recordProgress(item, options, "stage", "error", `${consumer.name}: could not inspect staged changes.`);
160
266
  continue;
161
267
  }
162
268
  if (!staged.changed) {
269
+ item.alreadyCurrent = true;
270
+ item.commit = currentGitHead(consumer.root);
271
+ recordProgress(item, options, "stage", "skip", `${consumer.name}: already pinned to ${CLI_PACKAGE_NAME}@${version}; no commit needed.`);
272
+ if (push) {
273
+ const ahead = inspectGitUpstreamAhead(consumer.root);
274
+ item.upstreamAhead = ahead.ok ? ahead.ahead : null;
275
+ if (!ahead.ok) {
276
+ item.diagnostics.push(commandDiagnostic({
277
+ code: "release_consumer_git_upstream_unavailable",
278
+ severity: "warning",
279
+ message: `Could not inspect whether ${consumer.name} has unpushed commits.`,
280
+ path: consumer.root,
281
+ suggestedFix: "Inspect git status manually; if the consumer branch is ahead, push it before verifying CI.",
282
+ result: ahead.result
283
+ }));
284
+ diagnostics.push(...item.diagnostics.slice(-1));
285
+ } else if ((ahead.ahead || 0) > 0) {
286
+ recordProgress(item, options, "push", "start", `${consumer.name}: branch is ahead of upstream; pushing existing rollout commit.`);
287
+ const pushResult = runGit(["push", "origin", "main"], consumer.root);
288
+ if (pushResult.status !== 0) {
289
+ item.diagnostics.push(commandDiagnostic({
290
+ code: "release_consumer_git_push_failed",
291
+ severity: "error",
292
+ message: `Failed to push ${consumer.name} existing CLI update.`,
293
+ path: consumer.root,
294
+ suggestedFix: "Push the consumer update manually, then confirm its verification workflow passes.",
295
+ result: pushResult
296
+ }));
297
+ diagnostics.push(...item.diagnostics);
298
+ recordProgress(item, options, "push", "error", `${consumer.name}: push failed.`);
299
+ continue;
300
+ }
301
+ item.pushed = true;
302
+ item.recoveredPush = true;
303
+ recordProgress(item, options, "push", "ok", `${consumer.name}: pushed existing rollout commit.`);
304
+ } else {
305
+ item.alreadyPushed = true;
306
+ recordProgress(item, options, "push", "skip", `${consumer.name}: branch already matches upstream.`);
307
+ }
308
+ }
309
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", "start", `${consumer.name}: ${watch ? "watching" : "checking"} verification workflow.`);
163
310
  item.ci = watch
164
- ? waitForConsumerCi(consumer)
311
+ ? waitForConsumerCi(consumer, {
312
+ onProgress: (event) => recordProgress(item, options, "watch-ci", event.status === "ok" ? "ok" : event.status === "error" ? "error" : "waiting", event.message, event)
313
+ })
165
314
  : inspectConsumerCi(consumer, { strict: false });
166
315
  item.diagnostics.push(...item.ci.diagnostics);
167
316
  diagnostics.push(...item.ci.diagnostics);
317
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", item.ci.ok === false ? "error" : "ok", `${consumer.name}: verification ${item.ci.ok === false ? "reported issues" : "checked"}.`);
168
318
  continue;
169
319
  }
320
+ recordProgress(item, options, "stage", "ok", `${consumer.name}: staged CLI pin changes.`);
321
+ recordProgress(item, options, "commit", "start", `${consumer.name}: committing CLI rollout.`);
170
322
  const commitResult = runGit(["commit", "-m", `Update Topogram CLI to ${version}`], consumer.root);
171
323
  if (commitResult.status !== 0) {
172
324
  item.diagnostics.push(commandDiagnostic({
@@ -178,11 +330,14 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
178
330
  result: commitResult
179
331
  }));
180
332
  diagnostics.push(...item.diagnostics);
333
+ recordProgress(item, options, "commit", "error", `${consumer.name}: commit failed.`);
181
334
  continue;
182
335
  }
183
336
  item.committed = true;
184
337
  item.commit = currentGitHead(consumer.root);
338
+ recordProgress(item, options, "commit", "ok", `${consumer.name}: committed ${item.commit || "CLI rollout"}.`);
185
339
  if (push) {
340
+ recordProgress(item, options, "push", "start", `${consumer.name}: pushing rollout commit.`);
186
341
  const pushResult = runGit(["push", "origin", "main"], consumer.root);
187
342
  if (pushResult.status !== 0) {
188
343
  item.diagnostics.push(commandDiagnostic({
@@ -194,15 +349,21 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
194
349
  result: pushResult
195
350
  }));
196
351
  diagnostics.push(...item.diagnostics);
352
+ recordProgress(item, options, "push", "error", `${consumer.name}: push failed.`);
197
353
  continue;
198
354
  }
199
355
  item.pushed = true;
356
+ recordProgress(item, options, "push", "ok", `${consumer.name}: pushed rollout commit.`);
200
357
  }
358
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", "start", `${consumer.name}: ${watch ? "watching" : "checking"} verification workflow.`);
201
359
  item.ci = watch
202
- ? waitForConsumerCi(consumer)
360
+ ? waitForConsumerCi(consumer, {
361
+ onProgress: (event) => recordProgress(item, options, "watch-ci", event.status === "ok" ? "ok" : event.status === "error" ? "error" : "waiting", event.message, event)
362
+ })
203
363
  : inspectConsumerCi(consumer, { strict: false });
204
364
  item.diagnostics.push(...item.ci.diagnostics);
205
365
  diagnostics.push(...item.ci.diagnostics);
366
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", item.ci.ok === false ? "error" : "ok", `${consumer.name}: verification ${item.ci.ok === false ? "reported issues" : "checked"}.`);
206
367
  }
207
368
  const errors = diagnostics
208
369
  .filter((diagnostic) => diagnostic.severity === "error")
@@ -216,10 +377,19 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
216
377
  watched: watch,
217
378
  consumers,
218
379
  diagnostics,
219
- errors
380
+ errors,
381
+ recovery: buildRecoverySummary(consumers, { version, push, watch, noWatch: options.noWatch === true })
220
382
  };
221
383
  }
222
384
 
385
+ /**
386
+ * @param {ReleaseRollProgress} event
387
+ * @returns {void}
388
+ */
389
+ export function printReleaseRollProgress(event) {
390
+ console.error(`[roll-consumers] ${event.message}`);
391
+ }
392
+
223
393
  /**
224
394
  * @param {ReturnType<typeof buildReleaseRollConsumersPayload>} payload
225
395
  * @returns {void}
@@ -233,9 +403,11 @@ export function printReleaseRollConsumers(payload) {
233
403
  console.log(`Push: ${payload.pushed ? "enabled" : "disabled"}`);
234
404
  console.log(`Watch: ${payload.watched ? "enabled" : "disabled"}`);
235
405
  for (const consumer of payload.consumers) {
236
- const state = consumer.committed
237
- ? consumer.pushed ? "pushed" : "committed"
238
- : consumer.updated ? "updated" : "skipped";
406
+ const state = consumer.alreadyCurrent
407
+ ? consumer.pushed ? "pushed existing commit" : consumer.alreadyPushed ? "current" : "current"
408
+ : consumer.committed
409
+ ? consumer.pushed ? "pushed" : "committed"
410
+ : consumer.updated ? "updated" : "skipped";
239
411
  console.log(`- ${consumer.name}: ${state}`);
240
412
  if (consumer.update) {
241
413
  console.log(` Checks run: ${consumer.update.scriptsRun.join(", ") || "none"}`);
@@ -254,4 +426,13 @@ export function printReleaseRollConsumers(payload) {
254
426
  console.log(` ${label}: ${diagnostic.message}`);
255
427
  }
256
428
  }
429
+ if (payload.recovery) {
430
+ console.log("Recovery:");
431
+ console.log(` Resume: ${payload.recovery.resumeCommand}`);
432
+ console.log(` Verify: ${payload.recovery.asyncVerificationCommand}`);
433
+ if ((payload.recovery.needsAttention || []).length > 0) {
434
+ console.log(` Needs attention: ${payload.recovery.needsAttention.join(", ")}`);
435
+ }
436
+ console.log(` ${payload.recovery.watchGuidance}`);
437
+ }
257
438
  }
@@ -19,6 +19,7 @@ const REPO_ROOT = decodeURIComponent(new URL("../../../../", import.meta.url).pa
19
19
 
20
20
  /**
21
21
  * @typedef {Record<string, any>} AnyRecord
22
+ * @typedef {{ consumer?: string, step?: string, status?: string, message: string, elapsedMs?: number, headSha?: string|null, expectedWorkflow?: string|null, run?: AnyRecord|null }} ReleaseProgressEvent
22
23
  */
23
24
 
24
25
  /**
@@ -157,6 +158,23 @@ export function currentGitHead(cwd) {
157
158
  return result.status === 0 ? String(result.stdout || "").trim() || null : null;
158
159
  }
159
160
 
161
+ /**
162
+ * @param {string} cwd
163
+ * @returns {{ ok: boolean, ahead: number|null, result: ReturnType<typeof childProcess.spawnSync> }}
164
+ */
165
+ export function inspectGitUpstreamAhead(cwd) {
166
+ const result = runGit(["rev-list", "--count", "@{u}..HEAD"], cwd);
167
+ if (result.status !== 0) {
168
+ return { ok: false, ahead: null, result };
169
+ }
170
+ const ahead = Number.parseInt(String(result.stdout || "").trim(), 10);
171
+ return {
172
+ ok: Number.isFinite(ahead),
173
+ ahead: Number.isFinite(ahead) ? ahead : null,
174
+ result
175
+ };
176
+ }
177
+
160
178
  /**
161
179
  * @param {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string, result: ReturnType<typeof childProcess.spawnSync> }} input
162
180
  * @returns {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string }}
@@ -205,7 +223,7 @@ function positiveIntegerEnv(name, fallback) {
205
223
 
206
224
  /**
207
225
  * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
208
- * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
226
+ * @param {{ timeoutMs?: number, intervalMs?: number, onProgress?: ((event: ReleaseProgressEvent) => void)|null }} [options]
209
227
  * @returns {ReturnType<typeof inspectConsumerCi>}
210
228
  */
211
229
  export function waitForConsumerCi(consumer, options = {}) {
@@ -213,12 +231,23 @@ export function waitForConsumerCi(consumer, options = {}) {
213
231
  const intervalMs = options.intervalMs || positiveIntegerEnv("TOPOGRAM_RELEASE_WATCH_INTERVAL_MS", 5000);
214
232
  const startedAt = Date.now();
215
233
  let latest = inspectConsumerCi(consumer, { strict: false });
234
+ const notify = typeof options.onProgress === "function" ? options.onProgress : null;
216
235
  while (true) {
217
236
  const currentRun = latest.run &&
218
237
  latest.headSha &&
219
238
  latest.run?.headSha &&
220
239
  latest.run.headSha === latest.headSha;
221
240
  if (currentRun && latest.run?.status === "completed") {
241
+ notify?.({
242
+ consumer: consumer.name,
243
+ step: "watch-ci",
244
+ status: "ok",
245
+ message: `${consumer.name}: verification workflow completed on current commit.`,
246
+ elapsedMs: Date.now() - startedAt,
247
+ headSha: latest.headSha,
248
+ expectedWorkflow: latest.expectedWorkflow,
249
+ run: latest.run
250
+ });
222
251
  return inspectConsumerCi(consumer, { strict: true });
223
252
  }
224
253
  if (Date.now() - startedAt >= timeoutMs) {
@@ -228,11 +257,31 @@ export function waitForConsumerCi(consumer, options = {}) {
228
257
  severity: "error",
229
258
  message: `${consumer.name} verification workflow did not complete on the current commit before the watch timeout.`,
230
259
  path: strictLatest.run?.url || consumerGithubRepoSlug(consumer),
231
- suggestedFix: "Open the consumer workflow, fix failures if needed, then rerun release status."
260
+ suggestedFix: "Open the consumer workflow, fix failures if needed, then rerun release status. If you only need to push and verify later, rerun roll-consumers with --no-watch."
232
261
  });
233
262
  strictLatest.ok = false;
263
+ notify?.({
264
+ consumer: consumer.name,
265
+ step: "watch-ci",
266
+ status: "error",
267
+ message: `${consumer.name}: verification watch timed out; rerun with --no-watch to continue asynchronously.`,
268
+ elapsedMs: Date.now() - startedAt,
269
+ headSha: strictLatest.headSha,
270
+ expectedWorkflow: strictLatest.expectedWorkflow,
271
+ run: strictLatest.run
272
+ });
234
273
  return strictLatest;
235
274
  }
275
+ notify?.({
276
+ consumer: consumer.name,
277
+ step: "watch-ci",
278
+ status: "waiting",
279
+ message: `${consumer.name}: waiting for ${latest.expectedWorkflow || "verification workflow"} on ${latest.headSha || "HEAD"} (${Math.round((Date.now() - startedAt) / 1000)}s elapsed).`,
280
+ elapsedMs: Date.now() - startedAt,
281
+ headSha: latest.headSha,
282
+ expectedWorkflow: latest.expectedWorkflow,
283
+ run: latest.run
284
+ });
236
285
  sleepSync(intervalMs);
237
286
  latest = inspectConsumerCi(consumer, { strict: false });
238
287
  }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { stableStringify } from "../../format.js";
7
7
  import {
8
8
  buildReleaseRollConsumersPayload,
9
+ printReleaseRollProgress,
9
10
  printReleaseRollConsumers
10
11
  } from "./release-rollout.js";
11
12
  import {
@@ -19,10 +20,11 @@ import {
19
20
  */
20
21
  export function printReleaseHelp() {
21
22
  console.log("Usage: topogram release status [--json] [--strict] [--markdown|--write-report <path>]");
22
- console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch]");
23
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch|--no-watch]");
23
24
  console.log("");
24
25
  console.log("Checks the local CLI version, latest published package version, release tag, first-party consumer pins, and strict consumer CI state.");
25
26
  console.log("Rolls first-party consumers to a published CLI version, runs their checks, commits, pushes, and can wait for current workflow runs.");
27
+ console.log("Rollout progress prints to stderr in human mode; JSON output stays final-only. Use --no-watch to push and verify later with release status --strict.");
26
28
  console.log("");
27
29
  console.log("Examples:");
28
30
  console.log(" topogram release status");
@@ -88,13 +90,24 @@ export function runReleaseCommand(context) {
88
90
 
89
91
  if (command === "roll-consumers") {
90
92
  const push = !args.includes("--no-push");
93
+ const noWatch = args.includes("--no-watch");
91
94
  const watch = args.includes("--watch");
95
+ if (watch && noWatch) {
96
+ console.error("Use either --watch or --no-watch, not both.");
97
+ printReleaseHelp();
98
+ return 1;
99
+ }
92
100
  if (watch && !push) {
93
- console.error("Use either --watch or --no-push, not both.");
101
+ console.error("Use either --watch or --no-push, not both. Use --no-watch with --no-push when CI will be verified separately.");
94
102
  printReleaseHelp();
95
103
  return 1;
96
104
  }
97
- const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, { push, watch });
105
+ const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, {
106
+ push,
107
+ watch,
108
+ noWatch,
109
+ onProgress: json ? null : printReleaseRollProgress
110
+ });
98
111
  if (json) {
99
112
  console.log(stableStringify(payload));
100
113
  } else {