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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- 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 };
|