superdev-cli 0.1.0

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 (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,544 @@
1
+ // Everything a person reads in the terminal is built here, so `cli.mjs` stays
2
+ // about dispatch and this file stays about wording.
3
+ //
4
+ // Three rules the whole file obeys:
5
+ // - eighty columns, because reflowed output in a narrow terminal is unreadable;
6
+ // - a number always says what it counts, so a bare "12" never appears;
7
+ // - a record with no completion contract prints Not measurable, never 0 percent.
8
+ //
9
+ // Nothing here reads the database. Every function takes plain data, which is
10
+ // what makes the same values renderable by the control center without a second
11
+ // implementation of the wording.
12
+
13
+ import { titleCase } from "../model/vocabulary.mjs";
14
+
15
+ export const WIDTH = 80;
16
+
17
+ // ------------------------------------------------------------------ primitives
18
+
19
+ export const clip = (text, width) => {
20
+ const value = String(text ?? "");
21
+ if (width < 4) return value.slice(0, Math.max(0, width));
22
+ return value.length <= width ? value : `${value.slice(0, width - 3)}...`;
23
+ };
24
+
25
+ /** Greedy wrap. Long unbreakable tokens are left to overflow rather than cut. */
26
+ export function wrap(text, width = WIDTH, indent = "") {
27
+ const words = String(text ?? "").split(/\s+/).filter(Boolean);
28
+ if (!words.length) return "";
29
+ const limit = Math.max(24, width - indent.length);
30
+ const lines = [];
31
+ let line = "";
32
+ for (const word of words) {
33
+ if (!line) line = word;
34
+ else if (line.length + 1 + word.length <= limit) line += ` ${word}`;
35
+ else {
36
+ lines.push(indent + line);
37
+ line = word;
38
+ }
39
+ }
40
+ lines.push(indent + line);
41
+ return lines.join("\n");
42
+ }
43
+
44
+ export const heading = (text) => `${text}\n${"-".repeat(Math.min(WIDTH, String(text).length))}`;
45
+
46
+ /** A bullet list with a hanging indent, so a wrapped item stays readable. */
47
+ export const bullets = (items, indent = " ") => {
48
+ const live = items.filter((item) => item !== null && item !== undefined && item !== "");
49
+ if (!live.length) return null;
50
+ return live
51
+ .map((item) => {
52
+ const body = wrap(`- ${item}`, WIDTH, `${indent} `);
53
+ return indent + body.slice(indent.length + 2);
54
+ })
55
+ .join("\n");
56
+ };
57
+
58
+ /**
59
+ * An aligned table. One column absorbs the overflow when the natural widths do
60
+ * not fit, because a table that wraps is worse than a table that truncates.
61
+ */
62
+ export function table(headers, rows, { flex = headers.length - 1, width: limit = WIDTH } = {}) {
63
+ if (!rows.length) return null;
64
+ const body = rows.map((row) => headers.map((_, i) => String(row[i] ?? "")));
65
+ const widths = headers.map((header, i) =>
66
+ Math.max(String(header).length, ...body.map((row) => row[i].length), 1),
67
+ );
68
+ // Shrink the flex column first, then whichever is widest, until the row fits.
69
+ // Trimming one column to nothing while another sprawls is how a table stops
70
+ // being readable at exactly the moment it has something to say.
71
+ const gap = 2;
72
+ const MIN = 8;
73
+ const width = () => widths.reduce((n, w) => n + w, 0) + gap * (headers.length - 1);
74
+ while (width() > limit) {
75
+ const widest = widths.indexOf(Math.max(...widths));
76
+ const target = widths[flex] > MIN + 4 ? flex : widest;
77
+ if (widths[target] <= MIN) break;
78
+ widths[target] -= 1;
79
+ }
80
+
81
+ const line = (cells) =>
82
+ cells
83
+ .map((cell, i) => (i === cells.length - 1 ? clip(cell, widths[i]) : clip(cell, widths[i]).padEnd(widths[i])))
84
+ .join(" ".repeat(gap))
85
+ .trimEnd();
86
+ return [line(headers.map(String)), ...body.map(line)].join("\n");
87
+ }
88
+
89
+ /** Key and value in two aligned columns, for short definition lists. */
90
+ export function pairs(entries, indent = " ") {
91
+ const live = entries.filter(([, value]) => value !== null && value !== undefined && value !== "");
92
+ if (!live.length) return null;
93
+ const width = Math.max(...live.map(([key]) => String(key).length));
94
+ return live
95
+ .map(([key, value]) => {
96
+ const label = String(key).padEnd(width);
97
+ const text = String(value);
98
+ const room = WIDTH - indent.length - width - 2;
99
+ if (text.length <= room && !text.includes("\n")) return `${indent}${label} ${text}`;
100
+ const body = wrap(text, WIDTH, `${indent}${" ".repeat(width + 2)}`);
101
+ return `${indent}${label} ${body.trimStart()}`;
102
+ })
103
+ .join("\n");
104
+ }
105
+
106
+ // An empty string is a deliberate blank line; null and undefined are absent
107
+ // sections. Keeping the two apart is what lets a caller compose spacing.
108
+ const stitch = (parts) => parts.filter((part) => part !== null && part !== undefined).join("\n");
109
+
110
+ const block = (title, body) => (body ? `${heading(title)}\n${body}` : null);
111
+
112
+ export const status = (value) => titleCase(value);
113
+
114
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
115
+
116
+ const shortDate = (iso) => (iso ? String(iso).replace("T", " ").replace(/\.\d+Z?$/, "").replace("Z", "") : "never");
117
+
118
+ /**
119
+ * The one place a percentage is turned into words. Progress with no completion
120
+ * contract is not zero, it is unmeasurable, and saying so is the point.
121
+ */
122
+ export function completion(progress) {
123
+ if (!progress) return "Not measurable";
124
+ if (!progress.measurable || progress.percent === null || progress.percent === undefined) {
125
+ return "Not measurable";
126
+ }
127
+ return `${progress.percent} percent (${progress.completed} of ${progress.total} tracked items done)`;
128
+ }
129
+
130
+ const componentRows = (components = []) =>
131
+ components
132
+ .filter((c) => c.applies)
133
+ .map((c) => [c.name, `${c.done} of ${c.total}`]);
134
+
135
+ // A line like "Acceptance criteria: 3 of 7" is already in the component table
136
+ // directly above it, so repeating it as prose is noise rather than honesty.
137
+ const restated = (line, components) =>
138
+ components.some((c) => String(line).startsWith(`${c.name}: `));
139
+
140
+ function progressBlock(progress) {
141
+ if (!progress) return "";
142
+ const components = progress.components ?? [];
143
+ const rows = componentRows(components);
144
+ const counts = (progress.whatCounts ?? []).filter((line) => !restated(line, components));
145
+ const remains = (progress.whatRemains ?? []).filter((line) => !restated(line, components));
146
+ return stitch([
147
+ `Overall: ${completion(progress)}`,
148
+ rows.length ? pairs(rows) : null,
149
+ counts.length ? `What counts\n${bullets(counts)}` : null,
150
+ remains.length ? `What is left\n${bullets(remains)}` : null,
151
+ ]);
152
+ }
153
+
154
+ // --------------------------------------------------------------------- status
155
+
156
+ /**
157
+ * The status report. Progress, freshness, one next action and the warnings, in
158
+ * that order, because "how far along" is worthless without "as of when".
159
+ */
160
+ export function renderStatus({ project, progress, freshness, next, warnings = [] } = {}) {
161
+ const header = project
162
+ ? stitch([
163
+ `${project.name} (${project.id})`,
164
+ pairs([
165
+ ["Status", status(project.status)],
166
+ ["Revision", progress?.sourceRevision ?? freshness?.revision ?? 0],
167
+ ["Reported at", shortDate(progress?.generatedAt ?? freshness?.generatedAt)],
168
+ ], ""),
169
+ ])
170
+ : "No project has been initialized in this directory yet.";
171
+
172
+ return stitch([
173
+ header,
174
+ "",
175
+ block("Progress", progressBlock(progress)),
176
+ "",
177
+ block("Freshness", freshnessBlock(freshness)),
178
+ "",
179
+ block("Next", nextBlock(next)),
180
+ "",
181
+ block(`Alignment warnings (${warnings.length})`, warningTable(warnings)),
182
+ ]).replace(/\n{3,}/g, "\n\n");
183
+ }
184
+
185
+ function freshnessBlock(freshness) {
186
+ if (!freshness) return "";
187
+ const sessions = freshness.sessions ?? [];
188
+ const quiet = sessions.filter((s) => s.stale).length;
189
+ return stitch([
190
+ pairs([
191
+ ["Last activity", `${shortDate(freshness.lastEventAt)} at sequence ${freshness.revision}`],
192
+ ["Last summary", freshness.lastEventSummary],
193
+ ["Documentation", freshness.lastDocumentationGeneratedAt
194
+ ? `built ${shortDate(freshness.lastDocumentationGeneratedAt)}, ${plural(freshness.documentsBehindRevision ?? 0, "file")} behind`
195
+ : "never generated"],
196
+ ["Sessions", `${plural(sessions.length, "active session")}, ${quiet} gone quiet`],
197
+ ["Open conflicts", freshness.openSyncConflicts || null],
198
+ ]),
199
+ freshness.stale
200
+ ? `This picture is out of date:\n${bullets(freshness.reasons ?? [])}`
201
+ : "Nothing suggests this picture is out of date.",
202
+ ]);
203
+ }
204
+
205
+ function nextBlock(next) {
206
+ if (!next) return "";
207
+ return stitch([
208
+ wrap(next.title, WIDTH, ""),
209
+ next.detail ? wrap(next.detail, WIDTH, " ") : null,
210
+ next.remedy ? wrap(next.remedy, WIDTH, " ") : null,
211
+ next.alsoWaiting ? ` ${plural(next.alsoWaiting, "other item")} also waiting.` : null,
212
+ ]);
213
+ }
214
+
215
+ const SEVERITY = { high: "High", medium: "Medium", low: "Low" };
216
+
217
+ /** How many warning rows a summary prints before it starts folding them. */
218
+ const WARNING_ROWS = 12;
219
+
220
+ /**
221
+ * Warnings for a summary, folded so the terminal stays readable.
222
+ *
223
+ * A real project reached 360 alignment warnings, 326 of them the same sentence
224
+ * with a different identifier, and `status` printed every one. A status report
225
+ * that fills the scrollback is a status report nobody reads, so identical
226
+ * shapes are grouped and counted, and the full list stays one command away.
227
+ */
228
+ function warningTable(warnings) {
229
+ if (!warnings.length) return "Nothing is out of alignment.";
230
+
231
+ // Group by the wording with identifiers removed, so "AC-0001 is met with no
232
+ // current evidence" and "AC-0002 is met..." collapse into one counted row.
233
+ const groups = new Map();
234
+ for (const w of warnings) {
235
+ const shape = String(w.title ?? "").replace(/\b[A-Z]{2,10}-\d{3,}\b/g, "").replace(/\s+/g, " ").trim();
236
+ const key = `${w.severity}\u0000${w.code ?? shape}\u0000${shape}`;
237
+ const group = groups.get(key) ?? { severity: w.severity, shape, members: [] };
238
+ group.members.push(w);
239
+ groups.set(key, group);
240
+ }
241
+
242
+ const rows = [];
243
+ for (const g of [...groups.values()].slice(0, WARNING_ROWS)) {
244
+ const first = g.members[0];
245
+ if (g.members.length === 1) {
246
+ rows.push([SEVERITY[g.severity] ?? status(g.severity), first.recordId ?? "", first.title ?? ""]);
247
+ continue;
248
+ }
249
+ const ids = g.members.map((m) => m.recordId).filter(Boolean);
250
+ rows.push([
251
+ SEVERITY[g.severity] ?? status(g.severity),
252
+ `${ids.length} records`,
253
+ `${g.shape || first.title} (${ids.slice(0, 3).join(", ")}${ids.length > 3 ? ", and more" : ""})`,
254
+ ]);
255
+ }
256
+
257
+ const shown = [...groups.values()].slice(0, WARNING_ROWS).reduce((n, g) => n + g.members.length, 0);
258
+ const rest = warnings.length - shown;
259
+ const note = rest > 0
260
+ ? `\n${rest} further warning${rest === 1 ? "" : "s"} not shown. Run superdev doctor for the full list with remediation.`
261
+ : "";
262
+ return table(["Severity", "Record", "What is wrong"], rows) + note;
263
+ }
264
+
265
+ /**
266
+ * The same warnings at length. `status` shows the table because it is a summary;
267
+ * `doctor` shows this, because a health report that names a problem without the
268
+ * thing to do about it has made the reader's job harder, not easier.
269
+ */
270
+ function findingList(warnings) {
271
+ if (!warnings.length) return null;
272
+ return warnings
273
+ .map((w) => stitch([
274
+ `${(SEVERITY[w.severity] ?? status(w.severity)).padEnd(6)} ${w.title}`,
275
+ w.detail ? wrap(w.detail, WIDTH, " ") : null,
276
+ w.remedy ? wrap(`Do: ${w.remedy}`, WIDTH, " ") : null,
277
+ ]))
278
+ .join("\n");
279
+ }
280
+
281
+ // ---------------------------------------------------------------------- tasks
282
+
283
+ /** The task list. Statuses are Title Case; ids come after the plain words. */
284
+ export function renderTaskList(tasks, { title = "Tasks" } = {}) {
285
+ if (!tasks.length) return stitch([heading(title), "No task matches that filter."]);
286
+ const rows = tasks.map((t) => [
287
+ t.id,
288
+ status(t.status),
289
+ status(t.priority ?? "normal"),
290
+ t.feature_id ?? "",
291
+ t.name ?? "",
292
+ ]);
293
+ return stitch([
294
+ heading(`${title} (${tasks.length})`),
295
+ table(["Id", "Status", "Priority", "Feature", "Name"], rows),
296
+ ]);
297
+ }
298
+
299
+ const listOf = (values) => (Array.isArray(values) ? values : []).map((v) =>
300
+ typeof v === "string" ? v : v?.text ?? v?.criterion ?? JSON.stringify(v));
301
+
302
+ /** One task in full: what it is for, what it implements, and who holds it. */
303
+ export function renderTaskDetail(detail) {
304
+ const {
305
+ task, feature = null, links = [], dependencies = [], subtasks = [],
306
+ assignment = null, evidence = [], history = [],
307
+ } = detail;
308
+
309
+ const holder = assignment
310
+ ? `Held since ${shortDate(assignment.assigned_at)} by ${assignment.holder ?? "an unnamed session"}`
311
+ : "Nobody has claimed it.";
312
+
313
+ return stitch([
314
+ `${task.id} ${task.name}`,
315
+ pairs([
316
+ ["Status", status(task.status)],
317
+ ["Priority", status(task.priority ?? "normal")],
318
+ ["Feature", feature ? `${feature.id} ${feature.name}` : task.feature_id],
319
+ ["Parent", task.parent_task_id],
320
+ ["Estimate", task.estimate],
321
+ ["Due", task.due_at ? shortDate(task.due_at) : null],
322
+ ["Blocked because", task.block_reason],
323
+ ["Enabling work for", task.enabling ? task.enabled_feature_id : null],
324
+ ], ""),
325
+ "",
326
+ block("Why it is needed", task.why_needed ? wrap(task.why_needed, WIDTH, " ") : ""),
327
+ block("Expected outcome", task.expected_outcome ? wrap(task.expected_outcome, WIDTH, " ") : ""),
328
+ block("Description", task.description ? wrap(task.description, WIDTH, " ") : ""),
329
+ block("Completion criteria", bullets(listOf(detail.completionCriteria))),
330
+ block("Verification required", bullets(listOf(detail.verificationRequirements))),
331
+ block("Implements", links.length
332
+ ? bullets(links.map((l) => `${status(l.target_type)} ${l.target_id} (${l.relationship})`))
333
+ : " Nothing yet. A task cannot leave draft until it implements something."),
334
+ block("Depends on", dependencies.length
335
+ ? bullets(dependencies.map((d) => `${d.id} ${status(d.status)} ${d.name ?? ""}`.trim()))
336
+ : ""),
337
+ block("Subtasks", subtasks.length
338
+ ? table(["Id", "Status", "Name"], subtasks.map((s) => [s.id, status(s.status), s.name]))
339
+ : ""),
340
+ block("Claimed", holder),
341
+ block("Verification recorded", evidence.length
342
+ ? table(["Result", "Recorded", "What was observed"],
343
+ evidence.map((e) => [status(e.result), shortDate(e.recorded_at), e.summary]))
344
+ : " None recorded."),
345
+ block("History", history.length
346
+ ? table(["When", "From", "To"],
347
+ history.map((h) => [shortDate(h.created_at), status(h.from_status), status(h.to_status)]))
348
+ : ""),
349
+ ]).replace(/\n{3,}/g, "\n\n");
350
+ }
351
+
352
+ // ------------------------------------------------------------------ readiness
353
+
354
+ const AREA_STATES = ["specified", "awaiting_decision", "deferred", "not_applicable"];
355
+
356
+ /** The production-readiness checklist. Silent gaps are named, not counted. */
357
+ export function renderReadiness(report) {
358
+ const counts = report.areaCounts ?? {};
359
+ const unresolved = (report.areas ?? []).filter(
360
+ (a) => a.state === "awaiting_decision" || a.state === "deferred",
361
+ );
362
+ const modules = (report.modules ?? []).filter((m) => m.applicable > 0);
363
+ const open = report.questions?.open ?? [];
364
+ const deferred = report.questions?.deferred ?? [];
365
+
366
+ return stitch([
367
+ heading("Production readiness"),
368
+ progressBlock(report),
369
+ "",
370
+ block("Capability areas", stitch([
371
+ pairs(AREA_STATES.map((s) => [status(s), counts[s] ?? 0])),
372
+ "",
373
+ unresolved.length
374
+ ? bullets(unresolved.map((a) =>
375
+ `${a.area}: ${status(a.state)}${a.silentGap ? " with no owner and no question raised" : ""}`))
376
+ : " Every area has been answered or recorded as not applicable.",
377
+ ])),
378
+ "",
379
+ block("Module completeness", modules.length
380
+ ? modules.map((m) => stitch([
381
+ `${m.id} ${m.name}: ${m.filled} of ${m.applicable} applicable steps filled`,
382
+ m.openSteps.length ? wrap(`Still open: ${m.openSteps.map((s) => s.name).join(", ")}`, WIDTH, " ") : null,
383
+ ])).join("\n")
384
+ : " No module has a completeness checklist yet."),
385
+ "",
386
+ block(`Open questions (${open.length})`, open.length
387
+ ? open.map((q) => stitch([
388
+ `${q.id} ${q.question}`,
389
+ wrap(`Why it matters: ${q.whyItMatters}`, WIDTH, " "),
390
+ q.recommendation ? wrap(`Recommended: ${q.recommendation}`, WIDTH, " ") : null,
391
+ ])).join("\n")
392
+ : " Nothing is waiting on an answer."),
393
+ deferred.length
394
+ ? block(`Deferred questions (${deferred.length})`,
395
+ bullets(deferred.map((q) => `${q.id} ${q.question}${q.deferralReason ? ` (deferred: ${q.deferralReason})` : ""}`)))
396
+ : "",
397
+ "",
398
+ block("Documents waiting on a decision", (report.pendingDocumentProposals ?? []).length
399
+ ? bullets(report.pendingDocumentProposals.map((d) => `${d.path} (${d.id})`))
400
+ : " None."),
401
+ ]).replace(/\n{3,}/g, "\n\n");
402
+ }
403
+
404
+ // ----------------------------------------------------------------------- diff
405
+
406
+ // A touched section arrives as {heading, added, removed}; older callers pass a
407
+ // plain heading. Both are named the same way here.
408
+ const sectionName = (section) =>
409
+ typeof section === "string" ? section : section?.heading ?? "the preamble";
410
+
411
+ /** One document proposal: what changed, where, and the diff itself. */
412
+ export function renderDiff(diff) {
413
+ return stitch([
414
+ diff.path,
415
+ pairs([
416
+ ["Status", status(diff.status)],
417
+ ["Changed", `${plural(diff.added ?? 0, "line")} added, ${diff.removed ?? 0} removed`],
418
+ ["Sections", (diff.sections ?? []).map(sectionName).join(", ") || null],
419
+ ], ""),
420
+ "",
421
+ diff.diff ? String(diff.diff).trimEnd() : " The file matches the database.",
422
+ ]);
423
+ }
424
+
425
+ /** The list of files whose text and records have parted company. */
426
+ export function renderProposals(report) {
427
+ const proposals = report.proposals ?? [];
428
+ if (!proposals.length) {
429
+ return `Every generated document matches the database. ${plural(report.scanned ?? 0, "file")} checked.`;
430
+ }
431
+ return stitch([
432
+ heading(`Documents waiting on a decision (${proposals.length})`),
433
+ table(["Path", "Why"], proposals.map((p) => [p.path, status(p.status)]), { flex: 0 }),
434
+ "",
435
+ wrap("Run docs diff with a path to read the change, then docs accept to take it into the database or docs reject to write the generated version back.", WIDTH, ""),
436
+ ]);
437
+ }
438
+
439
+ // --------------------------------------------------------------------- doctor
440
+
441
+ /**
442
+ * The health report. Every check states its verdict in one word and its reason
443
+ * in one line, so the failing one is findable without reading the rest.
444
+ */
445
+ export function renderDoctor({ checks = [], findings = [] } = {}) {
446
+ const rows = checks.map((c) => [c.name, c.ok ? "Pass" : "Problem", c.detail ?? ""]);
447
+ const bad = checks.filter((c) => !c.ok).length;
448
+ return stitch([
449
+ heading("Doctor"),
450
+ table(["Check", "Verdict", "Detail"], rows),
451
+ "",
452
+ bad
453
+ ? `${plural(bad, "check")} found a problem.`
454
+ : "Every check passed.",
455
+ findings.length ? "" : null,
456
+ findings.length ? block(`Findings (${findings.length})`, findingList(findings)) : null,
457
+ ]);
458
+ }
459
+
460
+ // -------------------------------------------------------------------- generic
461
+
462
+ const SENTENCE = (key) =>
463
+ String(key)
464
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
465
+ .replace(/[_-]+/g, " ")
466
+ .replace(/^./, (c) => c.toUpperCase());
467
+
468
+ // Keys whose values are vocabulary rather than prose. A raw "manual_edit_pending"
469
+ // in otherwise plain output is internal vocabulary leaking into a person's face.
470
+ const VOCABULARY = new Set(["status", "state", "kind", "severity", "result", "verdict"]);
471
+
472
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
473
+
474
+ const scalar = (value, key = null) => {
475
+ if (value === null || value === undefined) return "none";
476
+ if (typeof value === "boolean") return value ? "yes" : "no";
477
+ if (typeof value === "string" && ISO.test(value)) return shortDate(value);
478
+ if (key && VOCABULARY.has(String(key).toLowerCase()) && typeof value === "string") {
479
+ return status(value);
480
+ }
481
+ return String(value);
482
+ };
483
+
484
+ const isScalar = (value) => value === null || value === undefined || typeof value !== "object";
485
+
486
+ /**
487
+ * A readable outline for a shape this file does not know in advance, which is
488
+ * what plan objects and service reports are. Bounded depth: past four levels the
489
+ * reader wants the JSON output, not more indentation.
490
+ */
491
+ export function outline(value, indent = "", depth = 0) {
492
+ if (isScalar(value)) return wrap(scalar(value), WIDTH, indent);
493
+ if (depth >= 4) return `${indent}(nested detail, use --json to read it)`;
494
+
495
+ if (Array.isArray(value)) {
496
+ if (!value.length) return `${indent}none`;
497
+ // A list of same-shaped flat records is a table. Printing it as one nested
498
+ // outline per row is how a plan with sixteen entries becomes unreadable.
499
+ const uniform =
500
+ value.length > 1 &&
501
+ value.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)
502
+ && Object.values(entry).every(isScalar));
503
+ if (uniform) {
504
+ const keys = [...new Set(value.flatMap((entry) => Object.keys(entry)))];
505
+ if (keys.length <= 4) {
506
+ const rows = table(
507
+ keys.map(SENTENCE),
508
+ value.map((entry) => keys.map((k) => scalar(entry[k], k))),
509
+ { width: WIDTH - indent.length },
510
+ );
511
+ if (rows) return rows.split("\n").map((row) => indent + row).join("\n");
512
+ }
513
+ }
514
+ return value
515
+ .map((entry) =>
516
+ isScalar(entry)
517
+ ? wrap(`- ${scalar(entry)}`, WIDTH, indent)
518
+ : `${indent}-\n${outline(entry, `${indent} `, depth + 1)}`,
519
+ )
520
+ .join("\n");
521
+ }
522
+
523
+ const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined && !(Array.isArray(v) && !v.length));
524
+ if (!entries.length) return `${indent}none`;
525
+ const flat = entries.filter(([, v]) => isScalar(v));
526
+ const deep = entries.filter(([, v]) => !isScalar(v));
527
+ return stitch([
528
+ flat.length ? pairs(flat.map(([k, v]) => [SENTENCE(k), scalar(v, k)]), indent) : null,
529
+ ...deep.map(([k, v]) => `${indent}${SENTENCE(k)}:\n${outline(v, `${indent} `, depth + 1)}`),
530
+ ]);
531
+ }
532
+
533
+ /** A named block of unknown data, used by the commands whose shape is a plan. */
534
+ export const renderReport = (title, value) => stitch([heading(title), outline(value)]);
535
+
536
+ /**
537
+ * The dry-run footer. It exists once so that no command can forget to say that
538
+ * nothing happened, which is the failure a plan-by-default surface exists to
539
+ * prevent.
540
+ */
541
+ export const dryRunNote = (what) =>
542
+ `\nNothing has changed. Re-run with --apply to ${what}.`;
543
+
544
+ export { stitch, block, plural, shortDate };