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
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,2774 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The command surface. Brief section 14.1.
|
|
3
|
+
//
|
|
4
|
+
// Two decisions shape the whole file. Modules are imported where they are used
|
|
5
|
+
// rather than at the top, so that `--help` and a usage error never pay to open a
|
|
6
|
+
// database driver, and one broken module cannot take every other command down
|
|
7
|
+
// with it. And every handler returns data and text rather than printing, so
|
|
8
|
+
// --json is one branch in one place instead of thirty scattered ones.
|
|
9
|
+
//
|
|
10
|
+
// Exit codes are part of the contract: 0 success, 1 a finding or a refusal,
|
|
11
|
+
// 2 a usage error. A finding is a real answer, so `doctor` and `docs diff`
|
|
12
|
+
// return 1 when they find something rather than pretending nothing is wrong.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import * as R from "./cli/render.mjs";
|
|
18
|
+
|
|
19
|
+
const nowIso = () => new Date().toISOString();
|
|
20
|
+
|
|
21
|
+
class UsageError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "UsageError";
|
|
25
|
+
this.code = "E_USAGE";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class Refusal extends Error {
|
|
30
|
+
constructor(message, code = "E_REFUSED") {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "Refusal";
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------- argument parsing
|
|
38
|
+
|
|
39
|
+
// Flags that carry no value. Everything else must be given one, so that
|
|
40
|
+
// `--reason --apply` fails loudly instead of silently recording "true" as the
|
|
41
|
+
// reason a task is blocked.
|
|
42
|
+
const BOOLEAN = new Set(["apply", "json", "help", "all", "enabling", "end", "reports", "partial", "adopt", "dryRun", "resolve", "version", "noUpdateCheck", "updateCheck"]);
|
|
43
|
+
|
|
44
|
+
const camel = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const words = [];
|
|
48
|
+
const flags = {};
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === "--") {
|
|
52
|
+
words.push(...argv.slice(i + 1));
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (arg === "-h") {
|
|
56
|
+
flags.help = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!arg.startsWith("--")) {
|
|
60
|
+
words.push(arg);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const eq = arg.indexOf("=");
|
|
64
|
+
const raw = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
65
|
+
const name = camel(raw);
|
|
66
|
+
let value = eq === -1 ? null : arg.slice(eq + 1);
|
|
67
|
+
if (value === null) {
|
|
68
|
+
if (BOOLEAN.has(name)) value = true;
|
|
69
|
+
else {
|
|
70
|
+
const next = argv[i + 1];
|
|
71
|
+
if (next === undefined || next.startsWith("--")) {
|
|
72
|
+
throw new UsageError(`--${raw} needs a value. Write --${raw} <value>.`);
|
|
73
|
+
}
|
|
74
|
+
value = next;
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flags[name] = name in flags ? [].concat(flags[name], value) : value;
|
|
79
|
+
}
|
|
80
|
+
return { words, flags };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const asList = (value) => (value === undefined ? [] : [].concat(value).map(String));
|
|
84
|
+
|
|
85
|
+
function requireFlag(flags, name, what) {
|
|
86
|
+
const value = flags[camel(name)];
|
|
87
|
+
if (value === undefined || value === true) throw new UsageError(`${what} Pass --${name} <value>.`);
|
|
88
|
+
return String(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requireWord(words, index, what) {
|
|
92
|
+
const value = words[index];
|
|
93
|
+
if (!value) throw new UsageError(what);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------------------------------ help
|
|
98
|
+
|
|
99
|
+
const HELP = `superdev, a local-first control system for building products.
|
|
100
|
+
|
|
101
|
+
Usage
|
|
102
|
+
superdev <command> [options]
|
|
103
|
+
|
|
104
|
+
Getting started
|
|
105
|
+
init Plan a new project from an idea, a brief or a folder
|
|
106
|
+
adopt Take on a codebase that already exists
|
|
107
|
+
plan The shape of the work, read only
|
|
108
|
+
status Where the project is, how fresh that is, what is next
|
|
109
|
+
readiness The production-readiness checklist, gap by gap
|
|
110
|
+
resume Everything the next session needs to carry on
|
|
111
|
+
doctor Health of the database, the docs and the record map
|
|
112
|
+
|
|
113
|
+
Working
|
|
114
|
+
task list Open tasks, oldest first
|
|
115
|
+
task show <id> One task in full
|
|
116
|
+
task create Create a task against a feature
|
|
117
|
+
task update <id> Edit a task, or point it at what it implements
|
|
118
|
+
task claim <id> Take a task for this developer, agent and branch
|
|
119
|
+
task start <id> Move a task to In Progress
|
|
120
|
+
task release <id> Hand a task back
|
|
121
|
+
task evidence <id> Record what verifying it actually showed
|
|
122
|
+
test-plan list How each feature and the product itself is verified
|
|
123
|
+
test-plan show <id> One test plan, how to run it and what has run
|
|
124
|
+
test-plan run <id> Run the plan and record what it produced
|
|
125
|
+
test-plan record <id> Record a plan carried out by hand
|
|
126
|
+
verify Re-run the checks the recorded evidence stands on
|
|
127
|
+
task cancel <id> Stop work that should not continue, with a reason
|
|
128
|
+
task complete <id> Finish a task once its verification passes
|
|
129
|
+
task block <id> Record why a task cannot move
|
|
130
|
+
task unblock <id> Put a blocked task back where it was
|
|
131
|
+
task reopen <id> Reopen finished work, with a reason
|
|
132
|
+
derive [feature] Turn accepted specifications into tasks
|
|
133
|
+
|
|
134
|
+
Knowledge
|
|
135
|
+
docs generate Write the Markdown projection of the database
|
|
136
|
+
Add --reports for the summary, status and drift reports
|
|
137
|
+
docs diff [path] What a hand edit changed
|
|
138
|
+
docs accept <path> Take a hand edit into the database
|
|
139
|
+
docs reject <path> Put the generated version back
|
|
140
|
+
memory search <text> Recall what earlier sessions recorded
|
|
141
|
+
memory show <id> One memory with its provenance and verification
|
|
142
|
+
memory verify <id> Check a memory against the current record
|
|
143
|
+
memory consolidate Merge duplicates, mark contradictions, rebuild the index
|
|
144
|
+
memory supersede <id> Replace a memory that no longer holds
|
|
145
|
+
memory status What memory holds, and what retrieval can and cannot do
|
|
146
|
+
memory benchmark Measure retrieval against what section 15.12 requires
|
|
147
|
+
question list Open questions, oldest first
|
|
148
|
+
change record Record what moved in accepted scope, and why
|
|
149
|
+
change list What has changed, newest first
|
|
150
|
+
change show <id> One change and the records it moved
|
|
151
|
+
assumption record Record a reversible answer and its review trigger
|
|
152
|
+
assumption list Assumptions, the ones still holding first
|
|
153
|
+
assumption resolve <id> Say what an assumption turned out to be
|
|
154
|
+
question answer <id> Answer an open question
|
|
155
|
+
Product map
|
|
156
|
+
module list Modules and how many features each owns
|
|
157
|
+
module show <id> One module, its features and the contracts it owns
|
|
158
|
+
goal list Goals and whether their success criteria are met
|
|
159
|
+
goal show <id> One goal, how it is measured, what serves it
|
|
160
|
+
milestone list Delivery checkpoints and what is scheduled into them
|
|
161
|
+
milestone show <id> One milestone, its exit conditions and its features
|
|
162
|
+
feature list Every feature, its depth and its criteria
|
|
163
|
+
feature show <id> One feature and the whole contract under it
|
|
164
|
+
workflow list Workflows and how many steps each has
|
|
165
|
+
workflow show <id> One workflow, step by step
|
|
166
|
+
architecture show Runtime pieces, how they connect, integrations
|
|
167
|
+
schema show [entity] The data model, or one entity's fields
|
|
168
|
+
api show Operations and the services that group them
|
|
169
|
+
integration list Integrations and what happens when one is absent
|
|
170
|
+
|
|
171
|
+
Knowledge
|
|
172
|
+
decision record Record a decision, with what it governs
|
|
173
|
+
decision supersede Replace a decision that no longer holds
|
|
174
|
+
decision list Decisions and what they still govern
|
|
175
|
+
feature specify <id> Write the specification its depth requires
|
|
176
|
+
feature waive <id> Set an acceptance criterion aside, with the reason
|
|
177
|
+
feature depth Read what a depth requires, or set one
|
|
178
|
+
feature accept Accept a feature, refused while its depth is unmet
|
|
179
|
+
category list Task categories, what they mean, and how many use them
|
|
180
|
+
category add Add a category of your own
|
|
181
|
+
category rename Rename one
|
|
182
|
+
category describe Say what a category means in this project
|
|
183
|
+
category retire Take a category off the pickable list, keeping history
|
|
184
|
+
category restore Put a retired category back
|
|
185
|
+
|
|
186
|
+
Service and data
|
|
187
|
+
ui Open the control center
|
|
188
|
+
start / stop Run or halt the local service
|
|
189
|
+
restart / services Restart it, or list what is running
|
|
190
|
+
export / import Move a project between machines
|
|
191
|
+
settings What Superdev checks on its own, and how to stop it
|
|
192
|
+
db status Schema version, integrity and row counts
|
|
193
|
+
db migrate Apply pending schema migrations
|
|
194
|
+
db backup Snapshot the database
|
|
195
|
+
db restore <file> Replace the database with a snapshot
|
|
196
|
+
|
|
197
|
+
Options
|
|
198
|
+
--root <path> Project directory. Defaults to the working directory
|
|
199
|
+
--apply Actually do it. Without this every command that would
|
|
200
|
+
change something prints the plan and changes nothing
|
|
201
|
+
--json Machine-readable output, and nothing else on stdout
|
|
202
|
+
--out <path> Write this command's output to a file
|
|
203
|
+
--actor <name> Who to record as responsible. Defaults to superdev
|
|
204
|
+
--help This text
|
|
205
|
+
|
|
206
|
+
Exit codes
|
|
207
|
+
0 it worked, 1 something was found or refused, 2 the command was misused`;
|
|
208
|
+
|
|
209
|
+
// ------------------------------------------------------------------- utilities
|
|
210
|
+
|
|
211
|
+
const store = () => import("./db/store.mjs");
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Turn "the database file is not there" into the one sentence that says what to
|
|
215
|
+
* do about it. Commands reach the database by many routes: some open it here,
|
|
216
|
+
* some through a module that opens it for them. Rather than guard each route,
|
|
217
|
+
* the translation happens once, around the handler, so a command added later
|
|
218
|
+
* cannot reintroduce a driver message about WAL coordination paths.
|
|
219
|
+
*/
|
|
220
|
+
async function withFriendlyMissingProject(ctx, fn) {
|
|
221
|
+
const { paths } = await store();
|
|
222
|
+
const missing = !existsSync(paths(ctx.root).db);
|
|
223
|
+
try {
|
|
224
|
+
return await fn();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (missing && /failed to open database|entity not found/i.test(String(err?.message ?? ""))) {
|
|
227
|
+
throw new Refusal(
|
|
228
|
+
"This directory has no Superdev project yet. Run init to plan one, or adopt to take on a codebase that already exists.",
|
|
229
|
+
"E_NO_PROJECT",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Read helper that refuses politely when the project was never initialized. */
|
|
237
|
+
async function withProject(root, fn) {
|
|
238
|
+
const { query, currentProject, paths } = await store();
|
|
239
|
+
// The file check comes first. Opening a database that was never created fails
|
|
240
|
+
// inside the engine with a driver message about WAL coordination paths, which
|
|
241
|
+
// is not an answer anyone can act on.
|
|
242
|
+
if (!existsSync(paths(root).db)) {
|
|
243
|
+
throw new Refusal(
|
|
244
|
+
"This directory has no Superdev project yet. Run init to plan one, or adopt to take on a codebase that already exists.",
|
|
245
|
+
"E_NO_PROJECT",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return query(root, async (db) => {
|
|
249
|
+
const project = await currentProject(db);
|
|
250
|
+
if (!project) {
|
|
251
|
+
throw new Refusal(
|
|
252
|
+
"This directory has no Superdev project yet. Run init to plan one, or adopt to take on a codebase that already exists.",
|
|
253
|
+
"E_NO_PROJECT",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return fn(db, project);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The standard shape of a command that did nothing because --apply was absent. */
|
|
261
|
+
const planned = (plan, what, text) => ({
|
|
262
|
+
data: { applied: false, plan },
|
|
263
|
+
text: `${text}${R.dryRunNote(what)}`,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const countWord = R.plural;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A path the reader can act on. Inside the project it is relative, because an
|
|
270
|
+
* absolute machine path is wider than the terminal and says nothing extra.
|
|
271
|
+
*/
|
|
272
|
+
function here(root, path) {
|
|
273
|
+
if (!path) return path;
|
|
274
|
+
const rel = relative(root, String(path));
|
|
275
|
+
return rel && !rel.startsWith("..") ? rel : String(path);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Shorten every project path inside a report whose shape this file does not
|
|
280
|
+
* know. Reports from init, adopt, resume and the service carry absolute paths
|
|
281
|
+
* that are wider than the terminal and tell the reader nothing.
|
|
282
|
+
*/
|
|
283
|
+
function localize(value, root, depth = 0) {
|
|
284
|
+
if (value === root) return "this directory";
|
|
285
|
+
if (typeof value === "string") return value.startsWith(`${root}/`) ? here(root, value) : value;
|
|
286
|
+
if (!value || typeof value !== "object" || depth > 6) return value;
|
|
287
|
+
if (Array.isArray(value)) return value.map((entry) => localize(entry, root, depth + 1));
|
|
288
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, localize(v, root, depth + 1)]));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Values nobody needs to read and everybody would paste into a transcript. The
|
|
292
|
+
// machine-readable output still carries them, because the control center needs
|
|
293
|
+
// the instance token to talk to the service at all.
|
|
294
|
+
const SECRET_KEY = /token|secret|password|key$/i;
|
|
295
|
+
|
|
296
|
+
const maskSecrets = (value, depth = 0) => {
|
|
297
|
+
if (!value || typeof value !== "object" || depth > 6) return value;
|
|
298
|
+
if (Array.isArray(value)) return value.map((entry) => maskSecrets(entry, depth + 1));
|
|
299
|
+
return Object.fromEntries(
|
|
300
|
+
Object.entries(value).map(([k, v]) => [
|
|
301
|
+
k,
|
|
302
|
+
SECRET_KEY.test(k) && typeof v === "string" ? "held locally, not shown" : maskSecrets(v, depth + 1),
|
|
303
|
+
]),
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A report whose shape belongs to another module, rendered as an outline. */
|
|
308
|
+
const report = (ctx, title, value) => R.renderReport(title, maskSecrets(localize(value, ctx.root)));
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------------------------ init, adopt
|
|
311
|
+
|
|
312
|
+
async function cmdInit(ctx) {
|
|
313
|
+
const { planInit, applyInit } = await import("./init/index.mjs");
|
|
314
|
+
// --brief and --notes used to be passed through under their own names, which
|
|
315
|
+
// nothing downstream reads, so a brief file was silently discarded and init
|
|
316
|
+
// still exited 0 reporting success. They map onto the two inputs that do
|
|
317
|
+
// exist: a source file is ingested as evidence, and notes become the project
|
|
318
|
+
// statement.
|
|
319
|
+
const brief = ctx.flags.brief ? String(ctx.flags.brief) : null;
|
|
320
|
+
if (brief && !existsSync(resolve(ctx.root, brief)) && !existsSync(brief)) {
|
|
321
|
+
throw new UsageError(`There is no file at ${brief}. --brief names a file to read the project from.`);
|
|
322
|
+
}
|
|
323
|
+
const options = {
|
|
324
|
+
idea: ctx.flags.idea ?? null,
|
|
325
|
+
sources: brief ? [brief] : [],
|
|
326
|
+
statement: ctx.flags.notes ?? null,
|
|
327
|
+
name: ctx.flags.name ?? null,
|
|
328
|
+
actor: ctx.actor,
|
|
329
|
+
// Existing documents normally mean adopt, because initializing over them
|
|
330
|
+
// would create a second source of truth. This says they are input rather
|
|
331
|
+
// than a projection, which is the case when initializing from a
|
|
332
|
+
// requirements document. Without it there was no way to say so, and the
|
|
333
|
+
// refusal named an internal function nobody could run.
|
|
334
|
+
adopt: Boolean(ctx.flags.adopt),
|
|
335
|
+
};
|
|
336
|
+
if (!ctx.apply) {
|
|
337
|
+
const plan = await planInit(ctx.root, options);
|
|
338
|
+
return planned(plan, "create the project", report(ctx, "Initialization plan", plan));
|
|
339
|
+
}
|
|
340
|
+
const result = await applyInit(ctx.root, options);
|
|
341
|
+
return { data: { applied: true, result }, text: report(ctx, "Project initialized", result) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function cmdAdopt(ctx) {
|
|
345
|
+
const { adoptProject } = await import("./init/index.mjs");
|
|
346
|
+
const result = await adoptProject(ctx.root, { apply: ctx.apply, actor: ctx.actor });
|
|
347
|
+
if (!ctx.apply) {
|
|
348
|
+
return planned(result, "adopt this codebase", report(ctx, "Adoption plan", result));
|
|
349
|
+
}
|
|
350
|
+
return { data: { applied: true, result }, text: report(ctx, "Codebase adopted", result) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ------------------------------------------------------------------------ plan
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Read only on purpose. It says what the work looks like and what deriving
|
|
357
|
+
* would create; `derive --apply` is the command that creates it. Two commands
|
|
358
|
+
* that both write the same tasks would be one command too many.
|
|
359
|
+
*/
|
|
360
|
+
async function cmdPlan(ctx) {
|
|
361
|
+
const { projectProgress, nextAction } = await import("./progress/index.mjs");
|
|
362
|
+
const { derivationDelta } = await import("./tasks/derive.mjs");
|
|
363
|
+
|
|
364
|
+
const view = await withProject(ctx.root, async (db, project) => {
|
|
365
|
+
const progress = await projectProgress(db, project.id);
|
|
366
|
+
const next = await nextAction(db, project.id);
|
|
367
|
+
const milestones = await db.all(
|
|
368
|
+
"SELECT id, name, status, target_date FROM milestones WHERE project_id = ? ORDER BY sequence, id",
|
|
369
|
+
project.id,
|
|
370
|
+
);
|
|
371
|
+
const modules = await db.all(
|
|
372
|
+
"SELECT id, name, status FROM modules WHERE project_id = ? ORDER BY sequence, id",
|
|
373
|
+
project.id,
|
|
374
|
+
);
|
|
375
|
+
const features = await db.all(
|
|
376
|
+
"SELECT id, name, status, module_id FROM features WHERE project_id = ? ORDER BY module_id, id",
|
|
377
|
+
project.id,
|
|
378
|
+
);
|
|
379
|
+
return { project, progress, next, milestones, modules, features };
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const delta = await derivationDelta(ctx.root, ctx.words[1] ?? null);
|
|
383
|
+
const wouldCreate = delta.created?.length ?? delta.created ?? 0;
|
|
384
|
+
|
|
385
|
+
const text = R.stitch([
|
|
386
|
+
`${view.project.name} (${view.project.id})`,
|
|
387
|
+
"",
|
|
388
|
+
R.block("Progress", `Overall: ${R.completion(view.progress)}`),
|
|
389
|
+
"",
|
|
390
|
+
R.block("Milestones", view.milestones.length
|
|
391
|
+
? R.table(["Id", "Status", "Target", "Name"],
|
|
392
|
+
view.milestones.map((m) => [m.id, R.status(m.status), m.target_date ?? "", m.name]))
|
|
393
|
+
: " None recorded."),
|
|
394
|
+
"",
|
|
395
|
+
R.block("Modules and features", view.modules.length
|
|
396
|
+
? view.modules.map((m) => R.stitch([
|
|
397
|
+
`${m.id} ${m.name} (${R.status(m.status)})`,
|
|
398
|
+
R.bullets(view.features.filter((f) => f.module_id === m.id)
|
|
399
|
+
.map((f) => `${f.id} ${f.name} (${R.status(f.status)})`), " "),
|
|
400
|
+
])).join("\n")
|
|
401
|
+
: " None recorded."),
|
|
402
|
+
"",
|
|
403
|
+
R.block("Deriving would", typeof wouldCreate === "number"
|
|
404
|
+
? ` create ${countWord(wouldCreate, "task")} from accepted specifications. Run derive --apply to do it.`
|
|
405
|
+
: " make no change."),
|
|
406
|
+
"",
|
|
407
|
+
R.block("Next", view.next ? `${view.next.title}\n${R.wrap(view.next.remedy ?? "", R.WIDTH, " ")}` : ""),
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
return { data: { ...view, derivation: delta }, text };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------- status
|
|
414
|
+
|
|
415
|
+
async function cmdStatus(ctx) {
|
|
416
|
+
const { projectProgress, freshness, nextAction, alignmentWarnings } =
|
|
417
|
+
await import("./progress/index.mjs");
|
|
418
|
+
|
|
419
|
+
const view = await withProject(ctx.root, async (db, project) => ({
|
|
420
|
+
project,
|
|
421
|
+
progress: await projectProgress(db, project.id),
|
|
422
|
+
freshness: await freshness(db, project.id),
|
|
423
|
+
next: await nextAction(db, project.id),
|
|
424
|
+
warnings: await alignmentWarnings(db, project.id),
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
return { data: view, text: R.renderStatus(view) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function cmdReadiness(ctx) {
|
|
431
|
+
const { readiness } = await import("./progress/index.mjs");
|
|
432
|
+
const report = await withProject(ctx.root, (db, project) => readiness(db, project.id));
|
|
433
|
+
return { data: report, text: R.renderReadiness(report) };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------- resume
|
|
437
|
+
|
|
438
|
+
async function cmdResume(ctx) {
|
|
439
|
+
if (ctx.flags.end) {
|
|
440
|
+
const { endSession, activeSession } = await import("./runtime/session.mjs");
|
|
441
|
+
if (!ctx.apply) {
|
|
442
|
+
const sessionId = ctx.flags.session ?? null;
|
|
443
|
+
return planned({ sessionId }, "end the session",
|
|
444
|
+
R.wrap(`Would end ${sessionId ? `session ${sessionId}` : "the active session"} and write its outcome.`));
|
|
445
|
+
}
|
|
446
|
+
// endSession takes the id positionally. Passing the options object in that
|
|
447
|
+
// slot meant every --end ever run reported "Session [object Object] does not
|
|
448
|
+
// exist", so this path has never worked. And --session is optional, so the
|
|
449
|
+
// null case has to resolve the live session rather than hand down a null.
|
|
450
|
+
const sessionId = ctx.flags.session ? String(ctx.flags.session) : (await activeSession(ctx.root))?.id ?? null;
|
|
451
|
+
if (!sessionId) {
|
|
452
|
+
throw new Refusal(
|
|
453
|
+
"There is no active session to end. Pass --session <SES-id> to name one.",
|
|
454
|
+
"E_NO_ACTIVE_SESSION",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const result = await endSession(ctx.root, sessionId, { actor: ctx.actor, note: ctx.flags.note ?? null });
|
|
458
|
+
return { data: { applied: true, result }, text: report(ctx, "Session ended", result) };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let started = null;
|
|
462
|
+
if (ctx.apply) {
|
|
463
|
+
const { startSession } = await import("./runtime/session.mjs");
|
|
464
|
+
started = await startSession(ctx.root, {
|
|
465
|
+
actor: ctx.actor,
|
|
466
|
+
objective: ctx.flags.objective ?? null,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const { resumeContext } = await import("./runtime/resume.mjs");
|
|
471
|
+
const context = await resumeContext(ctx.root, {
|
|
472
|
+
sessionId: ctx.flags.session ?? started?.id ?? started?.session?.id ?? null,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const text = R.stitch([
|
|
476
|
+
started ? "A work session was started for this run." : null,
|
|
477
|
+
report(ctx, "Where to carry on", context),
|
|
478
|
+
ctx.apply ? null : "\nNo session was started. Re-run with --apply to open one.",
|
|
479
|
+
]);
|
|
480
|
+
return { data: { applied: Boolean(started), session: started, context }, text };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------- doctor
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* One place that answers "is anything wrong". Every check is a sentence with a
|
|
487
|
+
* verdict, and a failing check makes the command exit 1 so a hook can act on it.
|
|
488
|
+
*/
|
|
489
|
+
async function cmdDoctor(ctx) {
|
|
490
|
+
const { paths } = await store();
|
|
491
|
+
const { inspect } = await import("./db/migrate.mjs");
|
|
492
|
+
const { integrityCheck } = await import("./db/maintenance.mjs");
|
|
493
|
+
const { alignmentWarnings, freshness } = await import("./progress/index.mjs");
|
|
494
|
+
const { detectProposals } = await import("./docs/proposals.mjs");
|
|
495
|
+
|
|
496
|
+
const dbFile = paths(ctx.root).db;
|
|
497
|
+
const checks = [];
|
|
498
|
+
|
|
499
|
+
// First, because nothing below it can run without this. A Claude Code
|
|
500
|
+
// marketplace install copies the plugin into its own cache, and node_modules
|
|
501
|
+
// is git-ignored, so the copy arrives with no engine and every other command
|
|
502
|
+
// fails at the import with a stack trace instead of a sentence. Doctor exists
|
|
503
|
+
// to turn exactly that into an instruction.
|
|
504
|
+
// Reaching this line proves it: the engine is a static import inside
|
|
505
|
+
// src/db/connect.mjs, which store() above already pulled in, so a missing
|
|
506
|
+
// engine never gets here. It is reported anyway because "the engine is fine"
|
|
507
|
+
// is one of the things a person runs doctor to be told. The failing case is
|
|
508
|
+
// handled once, in run(), for every command rather than only this one.
|
|
509
|
+
checks.push({
|
|
510
|
+
name: "Storage engine",
|
|
511
|
+
ok: true,
|
|
512
|
+
detail: "Installed and loaded",
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const migrations = await inspect(dbFile);
|
|
516
|
+
|
|
517
|
+
checks.push({
|
|
518
|
+
name: "Database",
|
|
519
|
+
ok: migrations.databaseExists,
|
|
520
|
+
detail: migrations.databaseExists
|
|
521
|
+
? `Schema version ${migrations.version} of ${migrations.latest}, ${countWord(migrations.pending.length, "migration")} pending`
|
|
522
|
+
: "No database in this directory yet. Run init.",
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!migrations.databaseExists) {
|
|
526
|
+
return { data: { ok: false, checks, migrations, findings: [] }, text: R.renderDoctor({ checks }), exit: 1 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (migrations.drift.length) {
|
|
530
|
+
checks.push({
|
|
531
|
+
name: "Migration history",
|
|
532
|
+
ok: false,
|
|
533
|
+
detail: migrations.drift.map((d) => `${d.version}: ${d.problem}`).join("; "),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const integrity = await integrityCheck(ctx.root);
|
|
538
|
+
checks.push({
|
|
539
|
+
name: "Integrity",
|
|
540
|
+
ok: integrity.ok,
|
|
541
|
+
detail: integrity.ok
|
|
542
|
+
? "No page damage and no dangling references"
|
|
543
|
+
: `${integrity.integrity.filter((line) => line !== "ok").join("; ") || ""} ${countWord(integrity.foreignKeys.length, "dangling reference")}`.trim(),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const proposals = await detectProposals(ctx.root, { apply: false });
|
|
547
|
+
checks.push({
|
|
548
|
+
name: "Documentation",
|
|
549
|
+
ok: proposals.proposals.length === 0,
|
|
550
|
+
detail: proposals.proposals.length
|
|
551
|
+
? `${countWord(proposals.proposals.length, "file")} waiting on a decision`
|
|
552
|
+
: proposals.scanned
|
|
553
|
+
? `${countWord(proposals.scanned, "generated file")} match the database`
|
|
554
|
+
: "Nothing has been generated yet",
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const view = await withProject(ctx.root, async (db, project) => ({
|
|
558
|
+
warnings: await alignmentWarnings(db, project.id),
|
|
559
|
+
freshness: await freshness(db, project.id),
|
|
560
|
+
}));
|
|
561
|
+
|
|
562
|
+
const high = view.warnings.filter((w) => w.severity === "high").length;
|
|
563
|
+
checks.push({
|
|
564
|
+
name: "Alignment",
|
|
565
|
+
ok: view.warnings.length === 0,
|
|
566
|
+
detail: view.warnings.length
|
|
567
|
+
? `${countWord(view.warnings.length, "warning")}, ${high} of them high`
|
|
568
|
+
: "Every record maps to something that declares it",
|
|
569
|
+
});
|
|
570
|
+
checks.push({
|
|
571
|
+
name: "Freshness",
|
|
572
|
+
ok: !view.freshness.stale,
|
|
573
|
+
detail: view.freshness.stale ? view.freshness.reasons[0] : "Nothing is out of date",
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Provider readiness, checked and written down.
|
|
577
|
+
//
|
|
578
|
+
// providerReadiness() in the runtime hooks reads .superdev/runtime/providers.json
|
|
579
|
+
// and its own comment says doctor writes it. Nothing ever did, the file never
|
|
580
|
+
// existed, and so the readiness line could not fire and doctor reported nothing
|
|
581
|
+
// about providers at all, while the debug skill told agents to check readiness
|
|
582
|
+
// here. Detection ran once during init and was never kept.
|
|
583
|
+
//
|
|
584
|
+
// Nothing is installed. An absent provider stays absent until the owner asks
|
|
585
|
+
// for it; this only records what is present.
|
|
586
|
+
const providers = await recordProviderReadiness(ctx.root, paths);
|
|
587
|
+
if (providers) checks.push(providers);
|
|
588
|
+
|
|
589
|
+
// Evidence that nothing can re-run, reported without re-running anything:
|
|
590
|
+
// doctor stays a fast read, and superdev verify is where checks actually run.
|
|
591
|
+
const evidence = await withProject(ctx.root, (db) => db.get(
|
|
592
|
+
`SELECT COUNT(*) AS total,
|
|
593
|
+
SUM(CASE WHEN check_command IS NULL OR TRIM(check_command) = '' THEN 1 ELSE 0 END) AS manual,
|
|
594
|
+
SUM(CASE WHEN last_check_result = 'fail' THEN 1 ELSE 0 END) AS failing
|
|
595
|
+
FROM verification_evidence WHERE status = 'current'`));
|
|
596
|
+
checks.push({
|
|
597
|
+
name: "Evidence",
|
|
598
|
+
ok: (evidence?.failing ?? 0) === 0,
|
|
599
|
+
detail: evidence?.total
|
|
600
|
+
? `${evidence.total - evidence.manual} of ${evidence.total} can be re-run${evidence.failing ? `, ${evidence.failing} last failed` : ""}. Run superdev verify.`
|
|
601
|
+
: "No evidence recorded yet",
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const ok = checks.every((c) => c.ok);
|
|
605
|
+
return {
|
|
606
|
+
data: { ok, checks, migrations, integrity: { ok: integrity.ok, counts: integrity.counts }, findings: view.warnings },
|
|
607
|
+
text: R.renderDoctor({ checks, findings: view.warnings }),
|
|
608
|
+
exit: ok ? 0 : 1,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Detect the installed providers and write the report the session hook reads.
|
|
614
|
+
*
|
|
615
|
+
* Ids and states only. A provider's path or version detail names one machine,
|
|
616
|
+
* and this file is read at session start where that would leak into a prompt.
|
|
617
|
+
*/
|
|
618
|
+
async function recordProviderReadiness(root, paths) {
|
|
619
|
+
try {
|
|
620
|
+
const { detectAll } = await import("../scripts/providers/detect.mjs");
|
|
621
|
+
const report = detectAll({ rootReal: resolve(root) });
|
|
622
|
+
const rows = (report?.providers ?? []).map((p) => ({ id: p.id, state: p.state }));
|
|
623
|
+
const ready = rows.filter((p) => p.state === "available-and-ready");
|
|
624
|
+
const notReady = rows.filter((p) => p.state !== "available-and-ready" && p.state !== "not-applicable");
|
|
625
|
+
|
|
626
|
+
const dir = paths(root).runtime;
|
|
627
|
+
mkdirSync(dir, { recursive: true });
|
|
628
|
+
writeFileSync(
|
|
629
|
+
join(dir, "providers.json"),
|
|
630
|
+
JSON.stringify({ checkedAt: new Date().toISOString(), providers: rows }, null, 2) + "\n",
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
name: "Providers",
|
|
635
|
+
// Not being installed is not a fault. The check exists to say which
|
|
636
|
+
// specialist passes will not run, not to demand that they can.
|
|
637
|
+
ok: true,
|
|
638
|
+
detail: rows.length
|
|
639
|
+
? `${ready.length} of ${rows.length} ready${notReady.length ? `, not ready: ${notReady.map((p) => p.id).join(", ")}` : ""}`
|
|
640
|
+
: "No provider was detected",
|
|
641
|
+
};
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return { name: "Providers", ok: true, detail: `Readiness could not be determined: ${String(error.message).slice(0, 80)}` };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// --------------------------------------------------------------------- service
|
|
648
|
+
|
|
649
|
+
const service = () => import("./service/manage.mjs");
|
|
650
|
+
|
|
651
|
+
// The service reports a state, not a boolean. Both spellings are accepted so a
|
|
652
|
+
// later rename of one field cannot silently make `ui` claim nothing is running.
|
|
653
|
+
const isRunning = (report) => report?.state === "running" || report?.running === true;
|
|
654
|
+
|
|
655
|
+
async function cmdUi(ctx) {
|
|
656
|
+
const { serviceStatus, startService } = await service();
|
|
657
|
+
const current = await serviceStatus(ctx.root);
|
|
658
|
+
if (isRunning(current)) {
|
|
659
|
+
return {
|
|
660
|
+
data: { applied: false, service: current },
|
|
661
|
+
text: R.stitch([
|
|
662
|
+
"The control center is already running.",
|
|
663
|
+
R.pairs([["Address", current.url ?? `http://127.0.0.1:${current.port ?? ""}`], ["Process", current.pid]], ""),
|
|
664
|
+
]),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (!ctx.apply) {
|
|
668
|
+
return planned(current, "start the control center",
|
|
669
|
+
R.wrap("The control center is not running. Starting it opens one local process for this project and listens on the loopback address only."));
|
|
670
|
+
}
|
|
671
|
+
const started = await startService(ctx.root, { actor: ctx.actor });
|
|
672
|
+
return { data: { applied: true, service: started }, text: report(ctx, "Control center started", started) };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function cmdStart(ctx) {
|
|
676
|
+
const { startService, serviceStatus } = await service();
|
|
677
|
+
if (!ctx.apply) {
|
|
678
|
+
return planned(await serviceStatus(ctx.root), "start the local service",
|
|
679
|
+
"Starting the service opens one local process for this project.");
|
|
680
|
+
}
|
|
681
|
+
const started = await startService(ctx.root, { actor: ctx.actor });
|
|
682
|
+
return { data: { applied: true, service: started }, text: report(ctx, "Service started", started) };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function cmdStop(ctx) {
|
|
686
|
+
const { stopService, serviceStatus } = await service();
|
|
687
|
+
if (!ctx.apply) {
|
|
688
|
+
return planned(await serviceStatus(ctx.root), "stop the local service",
|
|
689
|
+
"Stopping the service ends the local process. Nothing recorded is lost.");
|
|
690
|
+
}
|
|
691
|
+
const stopped = await stopService(ctx.root, { actor: ctx.actor });
|
|
692
|
+
return { data: { applied: true, service: stopped }, text: report(ctx, "Service stopped", stopped) };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function cmdRestart(ctx) {
|
|
696
|
+
const { restartService, serviceStatus } = await service();
|
|
697
|
+
if (!ctx.apply) {
|
|
698
|
+
return planned(await serviceStatus(ctx.root), "restart the local service",
|
|
699
|
+
"Restarting stops the local process and starts a fresh one.");
|
|
700
|
+
}
|
|
701
|
+
const result = await restartService(ctx.root, { actor: ctx.actor });
|
|
702
|
+
return { data: { applied: true, service: result }, text: report(ctx, "Service restarted", result) };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function cmdServices(ctx) {
|
|
706
|
+
const { serviceStatus } = await service();
|
|
707
|
+
const state = await serviceStatus(ctx.root);
|
|
708
|
+
return { data: state, text: report(ctx, "Local service", state) };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ------------------------------------------------------------- export, import
|
|
712
|
+
|
|
713
|
+
async function cmdExport(ctx) {
|
|
714
|
+
const { exportProject } = await import("./db/maintenance.mjs");
|
|
715
|
+
const out = ctx.flags.out ? resolve(String(ctx.flags.out)) : null;
|
|
716
|
+
if (!ctx.apply) {
|
|
717
|
+
return planned({ out }, "write the export",
|
|
718
|
+
R.wrap(`Would write a portable snapshot of every record${out ? ` to ${here(ctx.root, out)}` : " into .superdev/exports"}.`));
|
|
719
|
+
}
|
|
720
|
+
const result = await exportProject(ctx.root, { out });
|
|
721
|
+
return {
|
|
722
|
+
data: { applied: true, ...result },
|
|
723
|
+
text: R.stitch([
|
|
724
|
+
R.wrap(`Wrote ${countWord(result.rows, "row")} to ${here(ctx.root, result.path)}.`),
|
|
725
|
+
R.table(["Table", "Rows"], result.tables.filter((t) => t.rows).map((t) => [t.table, String(t.rows)]), { flex: 0 }),
|
|
726
|
+
]),
|
|
727
|
+
// The export file is the output, so --out must not be reused as a text sink.
|
|
728
|
+
consumedOut: true,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function cmdImport(ctx) {
|
|
733
|
+
const { importProject } = await import("./db/maintenance.mjs");
|
|
734
|
+
const file = requireWord(ctx.words, 1, "Say which export file to read: superdev import <file>.");
|
|
735
|
+
const result = await importProject(ctx.root, resolve(file), { apply: ctx.apply });
|
|
736
|
+
if (!result.applied) {
|
|
737
|
+
return planned(result.plan, "load these records",
|
|
738
|
+
report(ctx, `Import plan for ${result.plan.project?.id ?? "this export"}`, result.plan));
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
data: result,
|
|
742
|
+
text: R.wrap(`Loaded ${countWord(result.inserted, "new row")} of ${result.rows} in the export. Rows already present were left alone.`),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ------------------------------------------------------------------- database
|
|
747
|
+
|
|
748
|
+
async function cmdDbStatus(ctx) {
|
|
749
|
+
const { paths } = await store();
|
|
750
|
+
const { inspect } = await import("./db/migrate.mjs");
|
|
751
|
+
const { integrityCheck } = await import("./db/maintenance.mjs");
|
|
752
|
+
const dbFile = paths(ctx.root).db;
|
|
753
|
+
const migrations = await inspect(dbFile);
|
|
754
|
+
if (!migrations.databaseExists) {
|
|
755
|
+
return {
|
|
756
|
+
data: { migrations, integrity: null },
|
|
757
|
+
text: "This directory has no Superdev database yet. Run init to create one.",
|
|
758
|
+
exit: 1,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
const integrity = await integrityCheck(ctx.root);
|
|
762
|
+
const counted = Object.entries(integrity.counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
|
|
763
|
+
return {
|
|
764
|
+
data: { migrations, integrity },
|
|
765
|
+
text: R.stitch([
|
|
766
|
+
R.heading("Database"),
|
|
767
|
+
R.pairs([
|
|
768
|
+
// Relative on purpose: an absolute machine path is longer than the
|
|
769
|
+
// terminal and tells the reader nothing they did not already know.
|
|
770
|
+
["Location", here(ctx.root, dbFile)],
|
|
771
|
+
["Schema version", `${migrations.version} of ${migrations.latest}`],
|
|
772
|
+
["Pending migrations", migrations.pending.length],
|
|
773
|
+
["Integrity", integrity.ok ? "Sound" : "Damaged, run doctor"],
|
|
774
|
+
["Drift", migrations.drift.length ? migrations.drift.map((d) => d.problem).join("; ") : "None"],
|
|
775
|
+
]),
|
|
776
|
+
"",
|
|
777
|
+
R.block("Rows", R.table(["Table", "Rows"], counted.map(([t, n]) => [t, String(n)]), { flex: 0 })),
|
|
778
|
+
]),
|
|
779
|
+
exit: integrity.ok && !migrations.drift.length ? 0 : 1,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function cmdDbMigrate(ctx) {
|
|
784
|
+
const { paths } = await store();
|
|
785
|
+
const { migrate } = await import("./db/migrate.mjs");
|
|
786
|
+
// A running control centre holds read connections opened against the current
|
|
787
|
+
// schema, and they are pinned to the snapshot they opened on. Changing the
|
|
788
|
+
// schema underneath them is how a live interface starts answering with
|
|
789
|
+
// columns that no longer mean what it thinks. Same refusal as db restore.
|
|
790
|
+
if (ctx.apply) {
|
|
791
|
+
const { assertNoLiveService } = await import("./service/manage.mjs");
|
|
792
|
+
await assertNoLiveService(ctx.root);
|
|
793
|
+
}
|
|
794
|
+
const result = await migrate(paths(ctx.root).db, { apply: ctx.apply });
|
|
795
|
+
if (!ctx.apply) {
|
|
796
|
+
if (!result.pending.length) return { data: result, text: "The database schema is already up to date." };
|
|
797
|
+
return planned(result, "apply them", R.stitch([
|
|
798
|
+
`${countWord(result.pending.length, "migration")} would run, taking the schema from ${result.from} to ${result.to}.`,
|
|
799
|
+
R.table(["Version", "File", "Statements"],
|
|
800
|
+
result.pending.map((m) => [String(m.version), m.name, String(m.statements)]), { flex: 1 }),
|
|
801
|
+
"\nThe database is copied aside before anything runs.",
|
|
802
|
+
]));
|
|
803
|
+
}
|
|
804
|
+
if (!result.applied.length) return { data: result, text: "The database schema was already up to date." };
|
|
805
|
+
return {
|
|
806
|
+
data: result,
|
|
807
|
+
text: R.stitch([
|
|
808
|
+
`Applied ${countWord(result.applied.length, "migration")}. The schema is now at version ${result.to}.`,
|
|
809
|
+
result.backup ? R.wrap(`The previous database was copied to ${here(ctx.root, result.backup)}.`) : null,
|
|
810
|
+
]),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function cmdDbBackup(ctx) {
|
|
815
|
+
const { backup, listBackups, KEEP_BACKUPS } = await import("./db/maintenance.mjs");
|
|
816
|
+
const label = ctx.flags.label ? String(ctx.flags.label) : "manual";
|
|
817
|
+
if (!ctx.apply) {
|
|
818
|
+
const existing = await listBackups(ctx.root);
|
|
819
|
+
return planned({ label, existing: existing.length }, "take the snapshot", R.stitch([
|
|
820
|
+
`Would write a complete snapshot labelled ${label} into .superdev/backups.`,
|
|
821
|
+
`${countWord(existing.length, "backup")} already there. The newest ${KEEP_BACKUPS} are kept.`,
|
|
822
|
+
]));
|
|
823
|
+
}
|
|
824
|
+
const result = await backup(ctx.root, label);
|
|
825
|
+
const kept = await listBackups(ctx.root);
|
|
826
|
+
return {
|
|
827
|
+
data: { applied: true, ...result, backups: kept.length },
|
|
828
|
+
text: R.stitch([
|
|
829
|
+
R.wrap(`Wrote ${here(ctx.root, result.path)} (${result.bytes} bytes).`),
|
|
830
|
+
R.table(["When", "Size", "Name"], kept.map((b) => [R.shortDate(b.at), String(b.bytes), b.name])),
|
|
831
|
+
]),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function cmdDbRestore(ctx) {
|
|
836
|
+
const { restore, listBackups } = await import("./db/maintenance.mjs");
|
|
837
|
+
let file = ctx.words[2];
|
|
838
|
+
if (!file) {
|
|
839
|
+
const kept = await listBackups(ctx.root);
|
|
840
|
+
if (!kept.length) throw new Refusal("There is no backup to restore from.", "E_NO_BACKUP");
|
|
841
|
+
throw new UsageError(
|
|
842
|
+
`Say which backup to restore: superdev db restore <file>. The newest is ${here(ctx.root, kept[0].path)}.`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
// Passed through as written. A bare name is the name every command prints,
|
|
846
|
+
// and restore resolves it against the backups directory; resolving it here
|
|
847
|
+
// against the working directory turned the printed name into a file that
|
|
848
|
+
// does not exist.
|
|
849
|
+
const result = await restore(ctx.root, String(file), { apply: ctx.apply });
|
|
850
|
+
if (!result.applied) {
|
|
851
|
+
return planned(result.plan, "replace the database", R.stitch([
|
|
852
|
+
R.wrap(`Would replace the project database with ${here(ctx.root, result.plan.from)} (${result.plan.bytes} bytes).`),
|
|
853
|
+
R.wrap(result.plan.backsUpCurrent
|
|
854
|
+
? "The current database is snapshotted first, so restoring the wrong file is itself recoverable."
|
|
855
|
+
: "There is no database here yet, so nothing is being replaced."),
|
|
856
|
+
]));
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
data: result,
|
|
860
|
+
text: R.stitch([
|
|
861
|
+
R.wrap(`Restored the database from ${here(ctx.root, result.plan.from)}.`),
|
|
862
|
+
result.safetyBackup
|
|
863
|
+
? R.wrap(`The database that was there is kept at ${here(ctx.root, result.safetyBackup.path)}.`)
|
|
864
|
+
: null,
|
|
865
|
+
]),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ----------------------------------------------------------------------- tasks
|
|
870
|
+
|
|
871
|
+
const lifecycle = () => import("./tasks/lifecycle.mjs");
|
|
872
|
+
|
|
873
|
+
const OPEN_ONLY = ["draft", "ready", "in_progress", "in_review", "verifying", "blocked", "paused"];
|
|
874
|
+
|
|
875
|
+
async function cmdTaskList(ctx) {
|
|
876
|
+
const { query } = await store();
|
|
877
|
+
const statuses = ctx.flags.status ? asList(ctx.flags.status).flatMap((s) => s.split(",")) : null;
|
|
878
|
+
const feature = ctx.flags.feature ? String(ctx.flags.feature) : null;
|
|
879
|
+
const limit = Number(ctx.flags.limit ?? 200);
|
|
880
|
+
|
|
881
|
+
const tasks = await query(ctx.root, async (db) => {
|
|
882
|
+
const where = [];
|
|
883
|
+
const params = [];
|
|
884
|
+
if (feature) {
|
|
885
|
+
where.push("feature_id = ?");
|
|
886
|
+
params.push(feature);
|
|
887
|
+
}
|
|
888
|
+
const wanted = statuses ?? (ctx.flags.all ? null : OPEN_ONLY);
|
|
889
|
+
if (wanted) {
|
|
890
|
+
where.push(`status IN (${wanted.map(() => "?").join(",")})`);
|
|
891
|
+
params.push(...wanted);
|
|
892
|
+
}
|
|
893
|
+
return db.all(
|
|
894
|
+
`SELECT * FROM tasks ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
895
|
+
ORDER BY sequence, id LIMIT ${Math.max(1, Math.min(limit, 1000))}`,
|
|
896
|
+
...params,
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const title = ctx.flags.all ? "Every task" : "Open tasks";
|
|
901
|
+
return { data: { tasks }, text: R.renderTaskList(tasks, { title }) };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async function cmdTaskShow(ctx) {
|
|
905
|
+
const { query, json } = await store();
|
|
906
|
+
const id = requireWord(ctx.words, 2, "Say which task to show: superdev task show <id>.");
|
|
907
|
+
|
|
908
|
+
const detail = await query(ctx.root, async (db) => {
|
|
909
|
+
const task = await db.get("SELECT * FROM tasks WHERE id = ?", id);
|
|
910
|
+
if (!task) throw new Refusal(`There is no task ${id}. Run task list to see what exists.`, "E_NOT_FOUND");
|
|
911
|
+
const assignment = await db.get(
|
|
912
|
+
"SELECT * FROM task_assignments WHERE task_id = ? AND active = 1", id,
|
|
913
|
+
);
|
|
914
|
+
let holder = null;
|
|
915
|
+
if (assignment?.developer_id) {
|
|
916
|
+
holder = (await db.get("SELECT display_name FROM developers WHERE id = ?", assignment.developer_id))?.display_name ?? null;
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
task,
|
|
920
|
+
feature: await db.get("SELECT id, name FROM features WHERE id = ?", task.feature_id),
|
|
921
|
+
links: await db.all("SELECT * FROM task_contract_links WHERE task_id = ? ORDER BY target_type, target_id", id),
|
|
922
|
+
dependencies: await db.all(
|
|
923
|
+
`SELECT t.id, t.name, t.status FROM task_dependencies d
|
|
924
|
+
JOIN tasks t ON t.id = d.depends_on_task_id
|
|
925
|
+
WHERE d.task_id = ? ORDER BY t.id`, id,
|
|
926
|
+
),
|
|
927
|
+
subtasks: await db.all("SELECT id, name, status FROM tasks WHERE parent_task_id = ? ORDER BY sequence, id", id),
|
|
928
|
+
assignment: assignment ? { ...assignment, holder } : null,
|
|
929
|
+
evidence: await db.all(
|
|
930
|
+
"SELECT * FROM verification_evidence WHERE task_id = ? ORDER BY recorded_at DESC LIMIT 10", id,
|
|
931
|
+
),
|
|
932
|
+
history: await db.all(
|
|
933
|
+
`SELECT * FROM status_history WHERE record_type = 'task' AND record_id = ?
|
|
934
|
+
ORDER BY sequence DESC LIMIT 10`, id,
|
|
935
|
+
),
|
|
936
|
+
completionCriteria: json(task.completion_criteria_json, []),
|
|
937
|
+
verificationRequirements: json(task.verification_requirements_json, []),
|
|
938
|
+
};
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
return { data: detail, text: R.renderTaskDetail(detail) };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const parseLink = (value) => {
|
|
945
|
+
const [targetType, targetId] = String(value).split(":");
|
|
946
|
+
if (!targetType || !targetId) {
|
|
947
|
+
throw new UsageError(`--link takes type:id, for example --link acceptance_criterion:AC-0003. Got ${value}.`);
|
|
948
|
+
}
|
|
949
|
+
return { targetType, targetId };
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
async function cmdTaskCreate(ctx) {
|
|
953
|
+
const input = {
|
|
954
|
+
featureId: requireFlag(ctx.flags, "feature", "A task belongs to exactly one feature."),
|
|
955
|
+
name: requireFlag(ctx.flags, "name", "A task needs a name that states the outcome."),
|
|
956
|
+
description: ctx.flags.description ?? null,
|
|
957
|
+
expectedOutcome: ctx.flags.outcome ?? null,
|
|
958
|
+
whyNeeded: ctx.flags.why ?? null,
|
|
959
|
+
completionCriteria: asList(ctx.flags.criterion),
|
|
960
|
+
verificationRequirements: asList(ctx.flags.verify),
|
|
961
|
+
affectedBoundaries: asList(ctx.flags.boundary),
|
|
962
|
+
priority: ctx.flags.priority ? String(ctx.flags.priority) : "normal",
|
|
963
|
+
risk: ctx.flags.risk ?? null,
|
|
964
|
+
category: ctx.flags.category ?? null,
|
|
965
|
+
estimate: ctx.flags.estimate ?? null,
|
|
966
|
+
dueAt: ctx.flags.due ?? null,
|
|
967
|
+
parentTaskId: ctx.flags.parent ?? null,
|
|
968
|
+
enabling: Boolean(ctx.flags.enabling),
|
|
969
|
+
enabledFeatureId: ctx.flags.enabledFeature ?? null,
|
|
970
|
+
enablingRationale: ctx.flags.rationale ?? null,
|
|
971
|
+
links: asList(ctx.flags.link).map(parseLink),
|
|
972
|
+
dependsOn: asList(ctx.flags.dependsOn),
|
|
973
|
+
status: ctx.flags.status ? String(ctx.flags.status) : "draft",
|
|
974
|
+
actor: ctx.actor,
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
if (!ctx.apply) {
|
|
978
|
+
return planned(input, "create it", R.stitch([
|
|
979
|
+
`Would create a task against ${input.featureId}.`,
|
|
980
|
+
R.pairs([
|
|
981
|
+
["Name", input.name],
|
|
982
|
+
["Status", R.status(input.status)],
|
|
983
|
+
["Priority", R.status(input.priority)],
|
|
984
|
+
["Expected outcome", input.expectedOutcome],
|
|
985
|
+
["Implements", input.links.map((l) => `${l.targetType} ${l.targetId}`).join(", ") || "nothing yet"],
|
|
986
|
+
]),
|
|
987
|
+
]));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const { createTask } = await lifecycle();
|
|
991
|
+
const task = await createTask(ctx.root, input);
|
|
992
|
+
return {
|
|
993
|
+
data: { applied: true, task },
|
|
994
|
+
text: R.wrap(`Created ${task.id} against ${task.feature_id}. It is ${R.status(task.status)}.`),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const TASK_FIELDS = [
|
|
999
|
+
["name", "name"], ["description", "description"], ["outcome", "expectedOutcome"],
|
|
1000
|
+
["why", "whyNeeded"], ["priority", "priority"], ["risk", "risk"],
|
|
1001
|
+
["estimate", "estimate"], ["due", "dueAt"], ["parent", "parentTaskId"],
|
|
1002
|
+
["rationale", "enablingRationale"], ["enabledFeature", "enabledFeatureId"],
|
|
1003
|
+
];
|
|
1004
|
+
|
|
1005
|
+
async function cmdTaskUpdate(ctx) {
|
|
1006
|
+
const id = requireWord(ctx.words, 2, "Say which task to update: superdev task update <id>.");
|
|
1007
|
+
const values = {};
|
|
1008
|
+
for (const [flag, field] of TASK_FIELDS) {
|
|
1009
|
+
if (ctx.flags[flag] !== undefined) values[field] = String(ctx.flags[flag]);
|
|
1010
|
+
}
|
|
1011
|
+
if (ctx.flags.criterion !== undefined) values.completionCriteria = asList(ctx.flags.criterion);
|
|
1012
|
+
if (ctx.flags.verify !== undefined) values.verificationRequirements = asList(ctx.flags.verify);
|
|
1013
|
+
if (ctx.flags.boundary !== undefined) values.affectedBoundaries = asList(ctx.flags.boundary);
|
|
1014
|
+
if (ctx.flags.status !== undefined) {
|
|
1015
|
+
throw new UsageError(
|
|
1016
|
+
"Status moves through its own commands so the change always leaves history. Use task claim, task block, task complete or task reopen.",
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
// A task that implements nothing cannot leave draft, and until now the only
|
|
1020
|
+
// moment it could be given a contract was the moment it was created. A task
|
|
1021
|
+
// created without one was therefore stuck forever, with the refusal naming a
|
|
1022
|
+
// link no command could add. The engine always had linkContract; nothing
|
|
1023
|
+
// reached it.
|
|
1024
|
+
const links = asList(ctx.flags.link).map(parseLink);
|
|
1025
|
+
|
|
1026
|
+
if (!Object.keys(values).length && !links.length) {
|
|
1027
|
+
throw new UsageError("Nothing to update. Pass at least one of --name, --description, --outcome, --why, --priority, --risk, --estimate, --due, --criterion, --verify or --link.");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (!ctx.apply) {
|
|
1031
|
+
return planned({ id, values, links }, "save it", R.stitch([
|
|
1032
|
+
`Would update ${id}.`,
|
|
1033
|
+
Object.keys(values).length
|
|
1034
|
+
? R.pairs(Object.entries(values).map(([k, v]) => [k, Array.isArray(v) ? v.join("; ") : v]))
|
|
1035
|
+
: null,
|
|
1036
|
+
links.length ? R.wrap(`It would implement ${links.map((l) => `${l.targetType} ${l.targetId}`).join(", ")}.`) : null,
|
|
1037
|
+
]));
|
|
1038
|
+
}
|
|
1039
|
+
const { updateTask, linkContract } = await lifecycle();
|
|
1040
|
+
let task = null;
|
|
1041
|
+
if (Object.keys(values).length) task = await updateTask(ctx.root, id, values, { actor: ctx.actor });
|
|
1042
|
+
for (const link of links) task = await linkContract(ctx.root, id, link, { actor: ctx.actor });
|
|
1043
|
+
return {
|
|
1044
|
+
data: { applied: true, task, links },
|
|
1045
|
+
text: links.length
|
|
1046
|
+
? `Updated ${id}. It now implements ${links.map((l) => `${l.targetType} ${l.targetId}`).join(", ")}.`
|
|
1047
|
+
: `Updated ${task.id}.`,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function cmdTaskClaim(ctx) {
|
|
1052
|
+
const id = requireWord(ctx.words, 2, "Say which task to claim: superdev task claim <id>.");
|
|
1053
|
+
const who = {
|
|
1054
|
+
developerId: ctx.flags.developer ?? null,
|
|
1055
|
+
agentId: ctx.flags.agent ?? null,
|
|
1056
|
+
branchId: ctx.flags.branch ?? null,
|
|
1057
|
+
sessionId: ctx.flags.session ?? null,
|
|
1058
|
+
actor: ctx.actor,
|
|
1059
|
+
};
|
|
1060
|
+
if (!ctx.apply) {
|
|
1061
|
+
return planned({ id, ...who }, "claim it",
|
|
1062
|
+
R.wrap(`Would claim ${id} for ${ctx.actor}. A task can be held by one session at a time.`));
|
|
1063
|
+
}
|
|
1064
|
+
const { claimTask } = await lifecycle();
|
|
1065
|
+
const task = await claimTask(ctx.root, id, who);
|
|
1066
|
+
return { data: { applied: true, task }, text: `${task.id} is now claimed. It is ${R.status(task.status)}.` };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function cmdTaskRelease(ctx) {
|
|
1070
|
+
const id = requireWord(ctx.words, 2, "Say which task to release: superdev task release <id>.");
|
|
1071
|
+
const reason = ctx.flags.reason ? String(ctx.flags.reason) : null;
|
|
1072
|
+
if (!ctx.apply) {
|
|
1073
|
+
return planned({ id, reason }, "release it",
|
|
1074
|
+
R.wrap(`Would hand ${id} back. Its status does not change, only the claim ends.`));
|
|
1075
|
+
}
|
|
1076
|
+
const { releaseTask } = await lifecycle();
|
|
1077
|
+
const task = await releaseTask(ctx.root, id, { actor: ctx.actor, reason });
|
|
1078
|
+
return {
|
|
1079
|
+
data: { applied: true, task },
|
|
1080
|
+
text: R.wrap(`${task.id} is free to be claimed again. It is still ${R.status(task.status)}.`),
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Not in the brief's list, but a task that can be blocked and never started or
|
|
1086
|
+
* unblocked is a dead end: `reopen` refuses work that is still open, so without
|
|
1087
|
+
* these two the listed commands cannot get a task back out of blocked.
|
|
1088
|
+
*/
|
|
1089
|
+
async function cmdTaskStart(ctx) {
|
|
1090
|
+
const id = requireWord(ctx.words, 2, "Say which task to start: superdev task start <id>.");
|
|
1091
|
+
if (!ctx.apply) {
|
|
1092
|
+
return planned({ id }, "start it",
|
|
1093
|
+
R.wrap(`Would move ${id} to In Progress. Anything still blocking it is recorded rather than refused.`));
|
|
1094
|
+
}
|
|
1095
|
+
const { startTask } = await lifecycle();
|
|
1096
|
+
const task = await startTask(ctx.root, id, {
|
|
1097
|
+
actor: ctx.actor, sessionId: ctx.flags.session ?? null, note: ctx.flags.note ?? null,
|
|
1098
|
+
});
|
|
1099
|
+
return { data: { applied: true, task }, text: `${task.id} is In Progress.` };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function cmdTaskUnblock(ctx) {
|
|
1103
|
+
const id = requireWord(ctx.words, 2, "Say which task to unblock: superdev task unblock <id>.");
|
|
1104
|
+
const to = ctx.flags.to ? String(ctx.flags.to) : null;
|
|
1105
|
+
if (!ctx.apply) {
|
|
1106
|
+
return planned({ id, to }, "unblock it",
|
|
1107
|
+
R.wrap(`Would move ${id} back to ${to ? R.status(to) : "whatever it was doing before it blocked"}.`));
|
|
1108
|
+
}
|
|
1109
|
+
const { unblockTask } = await lifecycle();
|
|
1110
|
+
const task = await unblockTask(ctx.root, id, { actor: ctx.actor, to, note: ctx.flags.note ?? null });
|
|
1111
|
+
return { data: { applied: true, task }, text: `${task.id} is ${R.status(task.status)} again.` };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Record what verifying the task actually showed.
|
|
1116
|
+
*
|
|
1117
|
+
* Completion is refused without this, so before it existed no task could be
|
|
1118
|
+
* finished through the product's own interface: the engine could record
|
|
1119
|
+
* evidence but nothing reachable called it.
|
|
1120
|
+
*
|
|
1121
|
+
* A result is never assumed. --result defaults to pass because that is the
|
|
1122
|
+
* common case, but a failing or inconclusive run is recorded just as readily,
|
|
1123
|
+
* and a failure retracts any acceptance criterion it had been the proof for.
|
|
1124
|
+
*/
|
|
1125
|
+
/**
|
|
1126
|
+
* Record a decision.
|
|
1127
|
+
*
|
|
1128
|
+
* Nothing could write one before this: the table, its transitions, its
|
|
1129
|
+
* supersession chain and every report built on them existed, and the only way a
|
|
1130
|
+
* row ever appeared was a script reading the ADR files.
|
|
1131
|
+
*/
|
|
1132
|
+
// ------------------------------------------------------------- product map
|
|
1133
|
+
//
|
|
1134
|
+
// Section 12.4 names fifteen read commands and none existed, so a terminal
|
|
1135
|
+
// session could write the product model and never read it back.
|
|
1136
|
+
|
|
1137
|
+
const productMap = () => import("./cli/product-map.mjs");
|
|
1138
|
+
const said = (result) => ({ data: result.data, text: result.text });
|
|
1139
|
+
|
|
1140
|
+
async function cmdModuleList(ctx) { return said(await (await productMap()).moduleList(ctx.root)); }
|
|
1141
|
+
async function cmdModuleShow(ctx) {
|
|
1142
|
+
const id = requireWord(ctx.words, 2, "Say which module: superdev module show <MOD-id>.");
|
|
1143
|
+
return said(await (await productMap()).moduleShow(ctx.root, id));
|
|
1144
|
+
}
|
|
1145
|
+
async function cmdGoalList(ctx) { return said(await (await productMap()).goalList(ctx.root)); }
|
|
1146
|
+
async function cmdGoalShow(ctx) {
|
|
1147
|
+
const id = requireWord(ctx.words, 2, "Say which goal: superdev goal show <GOAL-id>.");
|
|
1148
|
+
return said(await (await productMap()).goalShow(ctx.root, id));
|
|
1149
|
+
}
|
|
1150
|
+
async function cmdMilestoneList(ctx) { return said(await (await productMap()).milestoneList(ctx.root)); }
|
|
1151
|
+
async function cmdMilestoneShow(ctx) {
|
|
1152
|
+
const id = requireWord(ctx.words, 2, "Say which milestone: superdev milestone show <MS-id>.");
|
|
1153
|
+
return said(await (await productMap()).milestoneShow(ctx.root, id));
|
|
1154
|
+
}
|
|
1155
|
+
async function cmdFeatureList(ctx) {
|
|
1156
|
+
return said(await (await productMap()).featureList(ctx.root, {
|
|
1157
|
+
module: ctx.flags.module ? String(ctx.flags.module) : null,
|
|
1158
|
+
status: ctx.flags.status ? String(ctx.flags.status) : null,
|
|
1159
|
+
}));
|
|
1160
|
+
}
|
|
1161
|
+
async function cmdFeatureShow(ctx) {
|
|
1162
|
+
const id = requireWord(ctx.words, 2, "Say which feature: superdev feature show <FEAT-id>.");
|
|
1163
|
+
return said(await (await productMap()).featureShow(ctx.root, id));
|
|
1164
|
+
}
|
|
1165
|
+
async function cmdWorkflowList(ctx) { return said(await (await productMap()).workflowList(ctx.root)); }
|
|
1166
|
+
async function cmdWorkflowShow(ctx) {
|
|
1167
|
+
const id = requireWord(ctx.words, 2, "Say which workflow: superdev workflow show <WF-id>.");
|
|
1168
|
+
return said(await (await productMap()).workflowShow(ctx.root, id));
|
|
1169
|
+
}
|
|
1170
|
+
async function cmdArchitectureShow(ctx) { return said(await (await productMap()).architectureShow(ctx.root)); }
|
|
1171
|
+
async function cmdSchemaShow(ctx) {
|
|
1172
|
+
return said(await (await productMap()).schemaShow(ctx.root, { entity: ctx.words[2] ?? null }));
|
|
1173
|
+
}
|
|
1174
|
+
async function cmdApiShow(ctx) { return said(await (await productMap()).apiShow(ctx.root)); }
|
|
1175
|
+
async function cmdIntegrationList(ctx) { return said(await (await productMap()).integrationList(ctx.root)); }
|
|
1176
|
+
|
|
1177
|
+
// ------------------------------------------------------------------- memory
|
|
1178
|
+
|
|
1179
|
+
async function cmdMemoryShow(ctx) {
|
|
1180
|
+
const id = requireWord(ctx.words, 2, "Say which memory: superdev memory show <MEM-id>.");
|
|
1181
|
+
const { query } = await store();
|
|
1182
|
+
const found = await query(ctx.root, async (db) => {
|
|
1183
|
+
const entry = await db.get("SELECT * FROM memory_entries WHERE id = ?", id);
|
|
1184
|
+
if (!entry) return null;
|
|
1185
|
+
return {
|
|
1186
|
+
entry,
|
|
1187
|
+
links: await db.all("SELECT target_type, target_id, relationship FROM memory_links WHERE memory_id = ?", id),
|
|
1188
|
+
};
|
|
1189
|
+
});
|
|
1190
|
+
if (!found) throw new UsageError(`There is no memory ${id}.`);
|
|
1191
|
+
const { entry, links } = found;
|
|
1192
|
+
return {
|
|
1193
|
+
data: found,
|
|
1194
|
+
text: R.stitch([
|
|
1195
|
+
R.heading(`${entry.title ?? entry.id} ${entry.id}`),
|
|
1196
|
+
R.pairs([
|
|
1197
|
+
["Kind", R.status(entry.kind)],
|
|
1198
|
+
["How well known", R.status(entry.epistemic_status)],
|
|
1199
|
+
["Recorded", entry.created_at],
|
|
1200
|
+
["Source", entry.source_ref ?? "Not recorded"],
|
|
1201
|
+
["Superseded by", entry.superseded_by ?? "Still current"],
|
|
1202
|
+
]),
|
|
1203
|
+
"",
|
|
1204
|
+
R.wrap(entry.content ?? ""),
|
|
1205
|
+
links.length ? R.block("It concerns", R.bullets(links.map((l) => `${l.target_type} ${l.target_id} (${l.relationship})`))) : null,
|
|
1206
|
+
"",
|
|
1207
|
+
R.wrap("Memory is recall, not authority. Check it against the current specification, decisions and evidence before acting on it."),
|
|
1208
|
+
]),
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function cmdMemoryVerify(ctx) {
|
|
1213
|
+
const id = requireWord(ctx.words, 2, "Say which memory: superdev memory verify <MEM-id>.");
|
|
1214
|
+
const { verifyRecall } = await import("./memory/index.mjs");
|
|
1215
|
+
const report = await verifyRecall(ctx.root, id);
|
|
1216
|
+
return { data: report, text: R.renderReport(`Verification of ${id}`, report) };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function cmdMemoryConsolidate(ctx) {
|
|
1220
|
+
const { consolidate } = await import("./memory/consolidate.mjs");
|
|
1221
|
+
const report = await consolidate(ctx.root, { apply: ctx.apply });
|
|
1222
|
+
const lines = [
|
|
1223
|
+
R.heading("Memory consolidation"),
|
|
1224
|
+
R.pairs([
|
|
1225
|
+
["Live memories", String(report.live)],
|
|
1226
|
+
["Duplicates merged", String(report.duplicatesMerged)],
|
|
1227
|
+
["Contradictions found", String(report.contradictionsFound)],
|
|
1228
|
+
["Noise discarded", String(report.noiseDiscarded)],
|
|
1229
|
+
["Search terms rebuilt", String(report.retrievalTermsRebuilt)],
|
|
1230
|
+
["Dangling links removed", String(report.danglingLinksRemoved)],
|
|
1231
|
+
]),
|
|
1232
|
+
];
|
|
1233
|
+
if (report.contradictions.length) {
|
|
1234
|
+
lines.push("", R.block("Statements that contradict each other", R.bullets(
|
|
1235
|
+
report.contradictions.map((c) => `${c.earlier} against ${c.later}, both about ${c.about}`))));
|
|
1236
|
+
lines.push(R.wrap("Both are kept. The earlier one is marked contradicted so recall warns rather than picking a side."));
|
|
1237
|
+
}
|
|
1238
|
+
lines.push("", R.wrap(ctx.apply ? "Applied." : "Nothing has changed. Re-run with --apply to consolidate."));
|
|
1239
|
+
return { data: report, text: R.stitch(lines) };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function cmdMemorySupersede(ctx) {
|
|
1243
|
+
const id = requireWord(ctx.words, 2, "Say which memory is replaced: superdev memory supersede <MEM-id>.");
|
|
1244
|
+
const by = requireFlag(ctx.flags, "by", "Say which memory replaces it: --by <MEM-id>.");
|
|
1245
|
+
if (!ctx.apply) {
|
|
1246
|
+
return planned({ id, by }, "record it", R.wrap(`Would mark ${id} superseded by ${by}. Both are kept: a memory that was believed and turned out wrong is worth knowing.`));
|
|
1247
|
+
}
|
|
1248
|
+
const { supersede } = await import("./memory/index.mjs");
|
|
1249
|
+
const result = await supersede(ctx.root, id, String(by), { actor: ctx.actor });
|
|
1250
|
+
return { data: { applied: true, result }, text: `${id} is superseded by ${by}.` };
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Measure retrieval quality.
|
|
1255
|
+
*
|
|
1256
|
+
* Section 15.12 lists eleven measurements required before Claude Mem can be
|
|
1257
|
+
* dropped, and none had ever been taken, so DEC-TBD-002 could not be answered
|
|
1258
|
+
* even in principle. This measures and does not decide: what counts as good
|
|
1259
|
+
* enough is the owner's call.
|
|
1260
|
+
*/
|
|
1261
|
+
async function cmdMemoryBenchmark(ctx) {
|
|
1262
|
+
const { benchmark } = await import("./memory/benchmark.mjs");
|
|
1263
|
+
const r = await benchmark(ctx.root, { limit: ctx.flags.limit ? Number(ctx.flags.limit) : 40 });
|
|
1264
|
+
if (!r.measurable) {
|
|
1265
|
+
return { data: r, text: R.stitch([R.heading("Retrieval benchmark"), R.wrap(r.why)]) };
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
data: r,
|
|
1269
|
+
text: R.stitch([
|
|
1270
|
+
R.heading("Retrieval benchmark"),
|
|
1271
|
+
R.wrap(`${r.corpus.queries} questions drawn from ${r.corpus.entries} memories. Each question is the most distinctive word in one memory's title, and that memory is the answer it should find.`),
|
|
1272
|
+
"",
|
|
1273
|
+
R.pairs([
|
|
1274
|
+
["Recall", `${r.recall.value} (${r.recall.says})`],
|
|
1275
|
+
["Precision", String(r.precision.value)],
|
|
1276
|
+
["Noise", String(r.noise.value)],
|
|
1277
|
+
["Ranking", `${r.ranking.value} mean reciprocal rank`],
|
|
1278
|
+
["Token reduction", String(r.tokenReduction.value)],
|
|
1279
|
+
["Latency", `${r.latencyMs.median} ms median, ${r.latencyMs.worst} ms worst`],
|
|
1280
|
+
["Index size", `${r.storageGrowth.searchTerms} terms over ${r.storageGrowth.entries} memories`],
|
|
1281
|
+
["Superseded", String(r.staleDetection.superseded)],
|
|
1282
|
+
["Contradictions marked", String(r.contradictionDetection.marked)],
|
|
1283
|
+
]),
|
|
1284
|
+
"",
|
|
1285
|
+
R.block("Not measurable by a query", R.bullets([
|
|
1286
|
+
r.resumeAccuracy.says,
|
|
1287
|
+
r.handoffAccuracy.says,
|
|
1288
|
+
])),
|
|
1289
|
+
R.wrap("These are the measurements section 15.12 asks for. What threshold is good enough is DEC-TBD-002 and stays open."),
|
|
1290
|
+
]),
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function cmdMemoryStatus(ctx) {
|
|
1295
|
+
const { memoryStatus } = await import("./memory/consolidate.mjs");
|
|
1296
|
+
const s = await memoryStatus(ctx.root);
|
|
1297
|
+
return {
|
|
1298
|
+
data: s,
|
|
1299
|
+
text: R.stitch([
|
|
1300
|
+
R.heading("Memory"),
|
|
1301
|
+
R.pairs([
|
|
1302
|
+
["Held", String(s.total)],
|
|
1303
|
+
["Current", String(s.live)],
|
|
1304
|
+
["Superseded", String(s.superseded)],
|
|
1305
|
+
["Findable by search", `${s.indexed} of ${s.live}`],
|
|
1306
|
+
["Links to records", String(s.links)],
|
|
1307
|
+
]),
|
|
1308
|
+
s.byKind.length ? R.block("By kind", R.bullets(s.byKind.map((k) => `${k.n} ${k.kind}`))) : null,
|
|
1309
|
+
s.byStatus.length ? R.block("By how well known", R.bullets(s.byStatus.map((k) => `${k.n} ${k.epistemic_status}`))) : null,
|
|
1310
|
+
"",
|
|
1311
|
+
R.block("How retrieval works", R.bullets(s.retrieval.stages)),
|
|
1312
|
+
R.wrap(`Semantic retrieval: ${s.retrieval.semantic}`),
|
|
1313
|
+
s.notIndexedForSearch > 0
|
|
1314
|
+
? R.wrap(`${s.notIndexedForSearch} memories carry no search terms and cannot be found lexically. Run superdev memory consolidate --apply to index them.`)
|
|
1315
|
+
: null,
|
|
1316
|
+
]),
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// --------------------------------------------------------- questions, changes
|
|
1321
|
+
|
|
1322
|
+
async function cmdQuestionList(ctx) {
|
|
1323
|
+
const { query, json } = await store();
|
|
1324
|
+
const rows = await query(ctx.root, (db) => db.all(
|
|
1325
|
+
`SELECT * FROM questions ${ctx.flags.all ? "" : "WHERE status = 'open'"} ORDER BY created_at`));
|
|
1326
|
+
if (!rows.length) {
|
|
1327
|
+
return { data: { questions: [] }, text: R.wrap(ctx.flags.all ? "No question has been recorded." : "No question is open.") };
|
|
1328
|
+
}
|
|
1329
|
+
return {
|
|
1330
|
+
data: { questions: rows },
|
|
1331
|
+
text: R.stitch([
|
|
1332
|
+
R.heading(`Questions (${rows.length})`),
|
|
1333
|
+
// Section 8.4 says a material question carries a recommendation and the
|
|
1334
|
+
// tradeoffs. Storing them and not showing them leaves the reader doing
|
|
1335
|
+
// the research the question was supposed to have done for them.
|
|
1336
|
+
rows.map((q) => {
|
|
1337
|
+
const alternatives = json(q.alternatives_json, []);
|
|
1338
|
+
return R.stitch([
|
|
1339
|
+
`${q.id} [${R.status(q.status)}]`,
|
|
1340
|
+
R.wrap(q.question, R.WIDTH, " "),
|
|
1341
|
+
R.wrap(`Why it matters: ${q.why_it_matters}`, R.WIDTH, " "),
|
|
1342
|
+
q.recommendation ? R.wrap(`Recommended: ${q.recommendation}`, R.WIDTH, " ") : null,
|
|
1343
|
+
alternatives.length
|
|
1344
|
+
? R.stitch([" Options:", R.bullets(alternatives.map((a) => String(a)), " ")])
|
|
1345
|
+
: null,
|
|
1346
|
+
q.answer ? R.wrap(`Answered: ${q.answer}`, R.WIDTH, " ") : null,
|
|
1347
|
+
]);
|
|
1348
|
+
}).join("\n\n"),
|
|
1349
|
+
]),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function cmdChangeRecord(ctx) {
|
|
1354
|
+
const summary = requireFlag(ctx.flags, "summary", "Say what moved: --summary <what changed>.");
|
|
1355
|
+
const reason = requireFlag(ctx.flags, "reason", "Say why. Without it nobody can tell later whether the product was steered or drifted.");
|
|
1356
|
+
const targets = asList(ctx.flags.target);
|
|
1357
|
+
const input = {
|
|
1358
|
+
summary, reason, targets,
|
|
1359
|
+
changeType: ctx.flags.type ? String(ctx.flags.type) : "scope_changed",
|
|
1360
|
+
decisionId: ctx.flags.decision ?? null,
|
|
1361
|
+
taskId: ctx.flags.task ?? null,
|
|
1362
|
+
requestedBy: ctx.flags.requestedBy ?? null,
|
|
1363
|
+
actor: ctx.actor,
|
|
1364
|
+
sessionId: ctx.flags.session ?? null,
|
|
1365
|
+
};
|
|
1366
|
+
if (!ctx.apply) {
|
|
1367
|
+
return planned(input, "record it", R.stitch([
|
|
1368
|
+
`Would record: ${summary}`,
|
|
1369
|
+
R.pairs([["Because", reason], ["Moves", targets.join(", ") || "nothing named yet"]]),
|
|
1370
|
+
]));
|
|
1371
|
+
}
|
|
1372
|
+
const { recordChange } = await import("./product/changes.mjs");
|
|
1373
|
+
const row = await recordChange(ctx.root, input);
|
|
1374
|
+
return { data: { applied: true, change: row }, text: `${row.id} recorded: ${summary}` };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function cmdChangeList(ctx) {
|
|
1378
|
+
const { listChanges } = await import("./product/changes.mjs");
|
|
1379
|
+
const rows = await listChanges(ctx.root, { limit: ctx.flags.limit ? Number(ctx.flags.limit) : 50 });
|
|
1380
|
+
if (!rows.length) return { data: { changes: [] }, text: R.wrap("No change to accepted scope has been recorded.") };
|
|
1381
|
+
return {
|
|
1382
|
+
data: { changes: rows },
|
|
1383
|
+
text: R.stitch([
|
|
1384
|
+
R.heading(`Changes (${rows.length})`),
|
|
1385
|
+
rows.map((c) => R.stitch([
|
|
1386
|
+
`${c.id} ${R.shortDate(c.created_at)} ${R.status(c.change_type)}`,
|
|
1387
|
+
R.wrap(c.summary, R.WIDTH, " "),
|
|
1388
|
+
R.wrap(`Because: ${c.reason}`, R.WIDTH, " "),
|
|
1389
|
+
R.wrap(`Moved: ${c.targets.map((t) => `${t.target_type} ${t.target_id}`).join(", ")}`, R.WIDTH, " "),
|
|
1390
|
+
])).join("\n\n"),
|
|
1391
|
+
]),
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
async function cmdChangeShow(ctx) {
|
|
1396
|
+
const id = requireWord(ctx.words, 2, "Say which change: superdev change show <CHG-id>.");
|
|
1397
|
+
const { showChange } = await import("./product/changes.mjs");
|
|
1398
|
+
const c = await showChange(ctx.root, id);
|
|
1399
|
+
return {
|
|
1400
|
+
data: c,
|
|
1401
|
+
text: R.stitch([
|
|
1402
|
+
R.heading(`${c.summary} ${c.id}`),
|
|
1403
|
+
R.pairs([
|
|
1404
|
+
["Kind", R.status(c.change_type)],
|
|
1405
|
+
["Recorded", c.created_at],
|
|
1406
|
+
["Because", c.reason],
|
|
1407
|
+
["Decided by", c.decided_by ?? "Not recorded"],
|
|
1408
|
+
["Under decision", c.decision ? `${c.decision.id} ${c.decision.title}` : "None"],
|
|
1409
|
+
]),
|
|
1410
|
+
"",
|
|
1411
|
+
R.block("Records it moved", R.bullets(c.targets.map((t) =>
|
|
1412
|
+
`${t.target_type} ${t.target_id}${t.what_changed ? `: ${t.what_changed}` : ""}`))),
|
|
1413
|
+
]),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// -------------------------------------------------------------- assumptions
|
|
1418
|
+
|
|
1419
|
+
async function cmdAssumptionRecord(ctx) {
|
|
1420
|
+
const statement = requireFlag(ctx.flags, "statement", "Say what is being assumed: --statement <what>.");
|
|
1421
|
+
const why = requireFlag(ctx.flags, "why", "Say why this is assumed rather than decided: --why <reason>.");
|
|
1422
|
+
const trigger = requireFlag(ctx.flags, "trigger", "Say what would make this worth revisiting: --trigger <what>. Without one it is never reviewed.");
|
|
1423
|
+
const input = {
|
|
1424
|
+
statement, whyAssumed: why, reviewTrigger: trigger,
|
|
1425
|
+
consequenceIfWrong: ctx.flags.consequence ?? null,
|
|
1426
|
+
scopeType: ctx.flags.scopeType ?? null,
|
|
1427
|
+
scopeId: ctx.flags.scopeId ?? null,
|
|
1428
|
+
questionId: ctx.flags.question ?? null,
|
|
1429
|
+
actor: ctx.actor,
|
|
1430
|
+
};
|
|
1431
|
+
if (!ctx.apply) {
|
|
1432
|
+
return planned(input, "record it", R.stitch([
|
|
1433
|
+
`Would assume: ${statement}`,
|
|
1434
|
+
R.pairs([["Because", why], ["Revisit when", trigger]]),
|
|
1435
|
+
]));
|
|
1436
|
+
}
|
|
1437
|
+
const { recordAssumption } = await import("./product/assumptions.mjs");
|
|
1438
|
+
const row = await recordAssumption(ctx.root, input);
|
|
1439
|
+
return { data: { applied: true, assumption: row }, text: `${row.id} recorded: ${statement}` };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function cmdAssumptionList(ctx) {
|
|
1443
|
+
const { listAssumptions } = await import("./product/assumptions.mjs");
|
|
1444
|
+
const rows = await listAssumptions(ctx.root, { status: ctx.flags.status ? String(ctx.flags.status) : null });
|
|
1445
|
+
if (!rows.length) return { data: { assumptions: [] }, text: R.wrap("No assumption has been recorded.") };
|
|
1446
|
+
return {
|
|
1447
|
+
data: { assumptions: rows },
|
|
1448
|
+
text: R.stitch([
|
|
1449
|
+
R.heading(`Assumptions (${rows.length})`),
|
|
1450
|
+
rows.map((a) => R.stitch([
|
|
1451
|
+
`${a.id} [${R.status(a.status)}]`,
|
|
1452
|
+
R.wrap(a.statement, R.WIDTH, " "),
|
|
1453
|
+
R.wrap(`Assumed because: ${a.why_assumed}`, R.WIDTH, " "),
|
|
1454
|
+
R.wrap(`Revisit when: ${a.review_trigger}`, R.WIDTH, " "),
|
|
1455
|
+
a.resolution ? R.wrap(`Turned out: ${a.resolution}`, R.WIDTH, " ") : null,
|
|
1456
|
+
])).join("\n\n"),
|
|
1457
|
+
]),
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function cmdAssumptionResolve(ctx) {
|
|
1462
|
+
const id = requireWord(ctx.words, 2, "Say which assumption: superdev assumption resolve <ASM-id>.");
|
|
1463
|
+
const to = requireFlag(ctx.flags, "to", "Say what it turned out to be: --to confirmed, overturned or expired.");
|
|
1464
|
+
const resolution = requireFlag(ctx.flags, "resolution", "Say what the answer actually is.");
|
|
1465
|
+
if (!ctx.apply) {
|
|
1466
|
+
return planned({ id, to, resolution }, "record it", R.wrap(`Would mark ${id} ${to}: ${resolution}`));
|
|
1467
|
+
}
|
|
1468
|
+
const { resolveAssumption } = await import("./product/assumptions.mjs");
|
|
1469
|
+
const row = await resolveAssumption(ctx.root, id, { to: String(to), resolution: String(resolution), actor: ctx.actor });
|
|
1470
|
+
return { data: { applied: true, assumption: row }, text: `${id} is ${to}.` };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// -------------------------------------------------------------------- cloud
|
|
1474
|
+
//
|
|
1475
|
+
// Section 12.9 lists these four commands and says cloud synchronization is not
|
|
1476
|
+
// required for the local plugin to function. They refused for as long as
|
|
1477
|
+
// DEC-TBD-006, 007 and 008 were open, which was right: a merge policy invented
|
|
1478
|
+
// by whoever wrote the code is a policy nobody agreed to.
|
|
1479
|
+
//
|
|
1480
|
+
// Those decisions are recorded now, so these do what the decisions say and
|
|
1481
|
+
// nothing beyond them. Local stays authoritative, nothing leaves unencrypted,
|
|
1482
|
+
// nothing leaves that DEC-TBD-007 keeps local, and the only transport is a
|
|
1483
|
+
// directory on this machine. Nothing here reaches the network.
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Say if a newer version exists, then look again if it is time to.
|
|
1487
|
+
*
|
|
1488
|
+
* The notice comes from what the previous check wrote, so this never delays a
|
|
1489
|
+
* command. The refresh runs in a detached child that this process does not
|
|
1490
|
+
* wait for, because an unawaited fetch still holds the event loop open and made
|
|
1491
|
+
* the first command of the day pay for the round trip. Whatever the child
|
|
1492
|
+
* learns is read by the next run.
|
|
1493
|
+
*/
|
|
1494
|
+
async function announceUpdates(ctx) {
|
|
1495
|
+
if (ctx.json || ctx.flags.out) return;
|
|
1496
|
+
try {
|
|
1497
|
+
const { pendingNotice, startRefresh, checkingEnabled } = await import("./runtime/version.mjs");
|
|
1498
|
+
if (!checkingEnabled(ctx.root)) return;
|
|
1499
|
+
const notice = pendingNotice(ctx.root);
|
|
1500
|
+
if (notice) process.stderr.write(`\n${notice}\n`);
|
|
1501
|
+
// A detached child, so this process exits without waiting for a round trip.
|
|
1502
|
+
startRefresh(ctx.root, { pluginVersion: pluginVersionOf() });
|
|
1503
|
+
} catch {
|
|
1504
|
+
// An update courtesy must never be the reason a command looks broken.
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** The installed plugin's own version, when this is running as one. */
|
|
1509
|
+
function pluginVersionOf() {
|
|
1510
|
+
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
1511
|
+
if (!root) return null;
|
|
1512
|
+
try {
|
|
1513
|
+
return JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version ?? null;
|
|
1514
|
+
} catch {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Report and change what Superdev checks on its own.
|
|
1521
|
+
*
|
|
1522
|
+
* There is exactly one such thing, and it is the only outbound request this
|
|
1523
|
+
* product makes, so it gets a command rather than a buried configuration file.
|
|
1524
|
+
* Somebody who wants it off should be able to find how in one place.
|
|
1525
|
+
*/
|
|
1526
|
+
/**
|
|
1527
|
+
* Run a lifecycle hook. Called by the plugin's hooks.json, not by people.
|
|
1528
|
+
*
|
|
1529
|
+
* This exists so the plugin needs no executable code of its own. Before it, the
|
|
1530
|
+
* hooks ran `node "${CLAUDE_PLUGIN_ROOT}/src/runtime/hooks.mjs"`, which meant
|
|
1531
|
+
* the plugin had to ship the whole runtime and its native storage engine, and a
|
|
1532
|
+
* plugin distributed by git arrived without the engine and could not run at all.
|
|
1533
|
+
*
|
|
1534
|
+
* Now the plugin ships skills, a hooks manifest and metadata, all of it text.
|
|
1535
|
+
* The executable half comes from the CLI, which npm installs properly.
|
|
1536
|
+
*
|
|
1537
|
+
* It delegates to the hook runner's own entry point rather than reimplementing
|
|
1538
|
+
* it, because that entry point carries things worth keeping: the payload is read
|
|
1539
|
+
* from stdin the way the harness sends it, the work is raced against a time
|
|
1540
|
+
* budget so a slow database cannot hold the harness open, the response is
|
|
1541
|
+
* written in the protocol's shape rather than Superdev's, and the process exits
|
|
1542
|
+
* hard so an interrupted write rolls back instead of half committing.
|
|
1543
|
+
*/
|
|
1544
|
+
async function cmdHook(ctx) {
|
|
1545
|
+
const event = requireWord(ctx.words, 1, "Say which lifecycle event: superdev hook <event>.");
|
|
1546
|
+
const { main } = await import("./runtime/hooks.mjs");
|
|
1547
|
+
// main reads argv[2] as the event, reads stdin itself, emits, and exits.
|
|
1548
|
+
await main([process.execPath, "hook", event]);
|
|
1549
|
+
return { data: {}, text: "", consumedOut: true, exit: 0 };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
async function cmdSettings(ctx) {
|
|
1553
|
+
const { checkingEnabled, setChecking, self, pendingNotice } = await import("./runtime/version.mjs");
|
|
1554
|
+
const me = self();
|
|
1555
|
+
|
|
1556
|
+
const wanted = ctx.flags.noUpdateCheck ? false : ctx.flags.updateCheck ? true : null;
|
|
1557
|
+
if (wanted !== null) {
|
|
1558
|
+
if (!ctx.apply) {
|
|
1559
|
+
return planned({ updateCheck: wanted }, wanted ? "turn it on" : "turn it off",
|
|
1560
|
+
R.wrap(wanted
|
|
1561
|
+
? "Would turn the update check back on. It asks the npm registry and the plugin manifest for their latest versions, at most once a day, and never blocks a command."
|
|
1562
|
+
: "Would turn the update check off. Nothing would leave this machine at all after that, and you would find out about new versions yourself."));
|
|
1563
|
+
}
|
|
1564
|
+
setChecking(ctx.root, wanted);
|
|
1565
|
+
return {
|
|
1566
|
+
data: { updateCheck: wanted },
|
|
1567
|
+
text: R.wrap(wanted
|
|
1568
|
+
? "Update checking is on. At most one request a day, after a command has already answered, and it fails silently."
|
|
1569
|
+
: "Update checking is off. Superdev now makes no outbound request of any kind."),
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const on = checkingEnabled(ctx.root);
|
|
1574
|
+
return {
|
|
1575
|
+
data: { version: me.version, package: me.name, updateCheck: on },
|
|
1576
|
+
text: R.stitch([
|
|
1577
|
+
R.heading("Settings"),
|
|
1578
|
+
R.pairs([
|
|
1579
|
+
["Version", `${me.name} ${me.version}`],
|
|
1580
|
+
["Update check", on ? "On" : "Off"],
|
|
1581
|
+
]),
|
|
1582
|
+
R.wrap(on
|
|
1583
|
+
? "The update check is the only outbound request Superdev makes. It asks the npm registry for the CLI's latest version and reads the plugin manifest from the repository, at most once a day, after a command has already produced its output. It fails silently and never blocks. Turn it off with superdev settings --no-update-check --apply, or set SUPERDEV_NO_UPDATE_CHECK in the environment."
|
|
1584
|
+
: "Nothing leaves this machine. Turn the check back on with superdev settings --update-check --apply."),
|
|
1585
|
+
pendingNotice(ctx.root) ?? null,
|
|
1586
|
+
]),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async function cmdCloudStatus(ctx) {
|
|
1591
|
+
const { status } = await import("./cloud/sync.mjs");
|
|
1592
|
+
const { count } = await import("./model/vocabulary.mjs");
|
|
1593
|
+
const state = await status(ctx.root);
|
|
1594
|
+
|
|
1595
|
+
if (!state.connected && !state.location) {
|
|
1596
|
+
return {
|
|
1597
|
+
data: state,
|
|
1598
|
+
text: R.stitch([
|
|
1599
|
+
R.heading("No remote is configured"),
|
|
1600
|
+
R.wrap(state.why),
|
|
1601
|
+
R.wrap(`Connect one with superdev cloud connect <directory>. ${count(state.shared, "table")} would be shared and ${count(state.withheld, "table")} never leave this machine.`),
|
|
1602
|
+
]),
|
|
1603
|
+
exit: 0,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
return {
|
|
1608
|
+
data: state,
|
|
1609
|
+
text: R.stitch([
|
|
1610
|
+
R.heading(`Remote ${state.alias}`),
|
|
1611
|
+
R.pairs([
|
|
1612
|
+
["Transport", state.transport],
|
|
1613
|
+
["Location", state.location],
|
|
1614
|
+
["Reachable", state.reachable ? "Yes" : "No, so a sync would have nothing to talk to"],
|
|
1615
|
+
["Key fingerprint", state.keyFingerprint],
|
|
1616
|
+
["Last synced", state.lastSyncedAt ? R.shortDate(state.lastSyncedAt) : "Never"],
|
|
1617
|
+
["Records tracked", String(state.trackedRecords)],
|
|
1618
|
+
["Open conflicts", String(state.conflicts.length)],
|
|
1619
|
+
]),
|
|
1620
|
+
state.conflicts.length
|
|
1621
|
+
? R.block(`Conflicts waiting (${state.conflicts.length})`, R.bullets(state.conflicts.map(
|
|
1622
|
+
(c) => `${c.id} ${c.recordType} ${c.recordId}, found ${R.shortDate(c.detectedAt)}`)))
|
|
1623
|
+
: null,
|
|
1624
|
+
state.leases.length
|
|
1625
|
+
? R.block(`Leases (${state.leases.length})`, R.bullets(state.leases.map(
|
|
1626
|
+
(l) => `${l.task_id} held by ${l.lease_holder}${l.lease_expires_at ? ` until ${R.shortDate(l.lease_expires_at)}` : ""}`)))
|
|
1627
|
+
: null,
|
|
1628
|
+
R.wrap(`${count(state.shared, "table")} are shared. ${count(state.withheld, "table")} never leave this machine, including memory, the activity trail, sessions and every identity.`),
|
|
1629
|
+
]),
|
|
1630
|
+
exit: 0,
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
async function cmdCloudConnect(ctx) {
|
|
1635
|
+
const location = ctx.words[2] ?? ctx.flags.location;
|
|
1636
|
+
const { connect } = await import("./cloud/sync.mjs");
|
|
1637
|
+
const out = await connect(ctx.root, {
|
|
1638
|
+
location: location ? String(location) : null,
|
|
1639
|
+
alias: ctx.flags.alias ? String(ctx.flags.alias) : null,
|
|
1640
|
+
transport: ctx.flags.transport ? String(ctx.flags.transport) : "directory",
|
|
1641
|
+
apply: ctx.apply,
|
|
1642
|
+
});
|
|
1643
|
+
if (!out.applied) {
|
|
1644
|
+
return planned(out.plan, "connect", R.stitch([
|
|
1645
|
+
R.wrap(`Would connect this project to the ${out.plan.transport} at ${out.plan.location}.`),
|
|
1646
|
+
R.wrap(out.plan.createsKey
|
|
1647
|
+
? "An encryption key would be created for this project and kept here. It is never transmitted, and a remote copy is unreadable without it."
|
|
1648
|
+
: "This project already has an encryption key, which would be reused."),
|
|
1649
|
+
R.wrap("Connecting sends nothing. The first sync is a separate command."),
|
|
1650
|
+
]));
|
|
1651
|
+
}
|
|
1652
|
+
return {
|
|
1653
|
+
data: out,
|
|
1654
|
+
text: R.stitch([
|
|
1655
|
+
R.wrap(`Connected as ${out.alias} to the ${out.transport} at ${out.location}.`),
|
|
1656
|
+
R.wrap(`${out.createdKey ? "An encryption key was created" : "The existing encryption key is used"}, fingerprint ${out.keyFingerprint}. It stays on this machine and a remote copy cannot be read without it.`),
|
|
1657
|
+
R.wrap("Nothing has been sent. Run superdev sync --dry-run to see what would go, and superdev sync --apply to send it."),
|
|
1658
|
+
]),
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* One synchronization, or a preview of it.
|
|
1664
|
+
*
|
|
1665
|
+
* The preview and the run share every line that decides anything, so what is
|
|
1666
|
+
* described is what would happen. --resolve settles conflicts instead.
|
|
1667
|
+
*/
|
|
1668
|
+
async function cmdSync(ctx) {
|
|
1669
|
+
const { synchronize, openConflicts, resolveConflict } = await import("./cloud/sync.mjs");
|
|
1670
|
+
const { count } = await import("./model/vocabulary.mjs");
|
|
1671
|
+
|
|
1672
|
+
if (ctx.flags.resolve !== undefined) {
|
|
1673
|
+
const which = typeof ctx.flags.resolve === "string" && ctx.flags.resolve !== "true"
|
|
1674
|
+
? String(ctx.flags.resolve)
|
|
1675
|
+
: ctx.words[1] && ctx.words[1].startsWith("CONF") ? ctx.words[1] : null;
|
|
1676
|
+
const waiting = await openConflicts(ctx.root);
|
|
1677
|
+
if (!waiting.length) {
|
|
1678
|
+
return { data: { conflicts: [] }, text: R.wrap("No conflict is waiting. Nothing to settle.") };
|
|
1679
|
+
}
|
|
1680
|
+
if (!which) {
|
|
1681
|
+
return {
|
|
1682
|
+
data: { conflicts: waiting },
|
|
1683
|
+
text: R.stitch([
|
|
1684
|
+
R.heading(`Conflicts waiting (${waiting.length})`),
|
|
1685
|
+
waiting.map((c) => R.stitch([
|
|
1686
|
+
`${c.id} ${c.record_type} ${c.record_id}`,
|
|
1687
|
+
R.wrap(`Both copies changed it since they last agreed. Settle it with superdev sync --resolve ${c.id} --keep local, --keep remote, or --keep merged.`, R.WIDTH, " "),
|
|
1688
|
+
])).join("\n\n"),
|
|
1689
|
+
]),
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
const choice = ctx.flags.keep ? String(ctx.flags.keep) : "local";
|
|
1693
|
+
const out = await resolveConflict(ctx.root, which, { choice, apply: ctx.apply, actor: ctx.actor });
|
|
1694
|
+
if (!out.applied) {
|
|
1695
|
+
return planned(out.plan, "settle it", R.stitch([
|
|
1696
|
+
R.wrap(`Would settle ${which} on ${out.plan.recordType} ${out.plan.recordId} by keeping ${choice}.`),
|
|
1697
|
+
out.plan.fields.length ? R.wrap(`The copies disagree on: ${out.plan.fields.join(", ")}.`) : null,
|
|
1698
|
+
]));
|
|
1699
|
+
}
|
|
1700
|
+
return {
|
|
1701
|
+
data: out,
|
|
1702
|
+
text: R.wrap(`${which} settled by keeping ${choice}. The settled value is now what both sides agree on, so the next sync will not raise it again.`),
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const out = await synchronize(ctx.root, { apply: ctx.apply });
|
|
1707
|
+
const body = R.stitch([
|
|
1708
|
+
R.pairs([
|
|
1709
|
+
["Remote", `${out.peer} at ${out.location}`],
|
|
1710
|
+
["Other copies found", String(out.peersFound)],
|
|
1711
|
+
["Coming in", String(out.incoming)],
|
|
1712
|
+
["Going out", String(out.outgoing)],
|
|
1713
|
+
["Conflicts", String(out.conflicts)],
|
|
1714
|
+
]),
|
|
1715
|
+
out.leases.length
|
|
1716
|
+
? R.block(`Held elsewhere (${out.leases.length})`, R.bullets(out.leases.map(
|
|
1717
|
+
(l) => `${l.taskId} by ${l.holder}${l.expiresAt ? ` until ${R.shortDate(l.expiresAt)}` : ""}`)))
|
|
1718
|
+
: null,
|
|
1719
|
+
out.unreadable.length
|
|
1720
|
+
? R.block("Could not be read", R.bullets(out.unreadable))
|
|
1721
|
+
: null,
|
|
1722
|
+
R.wrap(`${count(out.withheldTables, "table")} were withheld, including memory, the activity trail, sessions and every identity. Nothing left this machine unencrypted.`),
|
|
1723
|
+
]);
|
|
1724
|
+
|
|
1725
|
+
if (!out.applied) {
|
|
1726
|
+
return planned(out, "synchronize", R.stitch([
|
|
1727
|
+
R.heading("What a sync would do"),
|
|
1728
|
+
body,
|
|
1729
|
+
out.conflicts
|
|
1730
|
+
? R.wrap(`${count(out.conflicts, "record")} changed in both copies since they last agreed. A sync records each as a conflict and leaves the local value alone; settle them with superdev sync --resolve.`)
|
|
1731
|
+
: null,
|
|
1732
|
+
]));
|
|
1733
|
+
}
|
|
1734
|
+
return {
|
|
1735
|
+
data: out,
|
|
1736
|
+
text: R.stitch([
|
|
1737
|
+
R.heading("Synchronized"),
|
|
1738
|
+
body,
|
|
1739
|
+
R.wrap(`${count(out.taken ?? 0, "record")} taken in. ${
|
|
1740
|
+
out.conflicts
|
|
1741
|
+
? `${count(out.conflicts, "conflict")} recorded and left for you: the local value stands until you settle it with superdev sync --resolve.`
|
|
1742
|
+
: "No conflict was found."
|
|
1743
|
+
}`),
|
|
1744
|
+
]),
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
async function cmdDecisionRecord(ctx) {
|
|
1750
|
+
const title = requireFlag(ctx.flags, "title", "A decision needs a title, in plain language, so it can be found again.");
|
|
1751
|
+
const decision = requireFlag(ctx.flags, "decision", "Say what was decided. A title alone records that a choice happened, not what it was.");
|
|
1752
|
+
const input = {
|
|
1753
|
+
title, decision,
|
|
1754
|
+
context: ctx.flags.context ?? null,
|
|
1755
|
+
rationale: ctx.flags.rationale ?? null,
|
|
1756
|
+
verification: ctx.flags.verification ?? null,
|
|
1757
|
+
scopeType: ctx.flags.scopeType ?? null,
|
|
1758
|
+
scopeId: ctx.flags.scopeId ?? null,
|
|
1759
|
+
status: ctx.flags.status ? String(ctx.flags.status) : "proposed",
|
|
1760
|
+
acceptedBy: ctx.flags.acceptedBy ?? null,
|
|
1761
|
+
expiresAt: ctx.flags.expires ?? null,
|
|
1762
|
+
governs: asList(ctx.flags.governs),
|
|
1763
|
+
evidence: asList(ctx.flags.evidence),
|
|
1764
|
+
criteria: asList(ctx.flags.criterion),
|
|
1765
|
+
options: asList(ctx.flags.option),
|
|
1766
|
+
risks: asList(ctx.flags.risk),
|
|
1767
|
+
enforcement: asList(ctx.flags.enforcement),
|
|
1768
|
+
revisitTriggers: asList(ctx.flags.revisit),
|
|
1769
|
+
actor: ctx.actor,
|
|
1770
|
+
sessionId: ctx.flags.session ?? null,
|
|
1771
|
+
};
|
|
1772
|
+
if (!ctx.apply) {
|
|
1773
|
+
return planned(input, "record it", R.stitch([
|
|
1774
|
+
`Would record "${title}" as ${R.status(input.status)}.`,
|
|
1775
|
+
R.pairs([["Decided", decision], ["Governs", input.governs.join(", ") || "nothing yet"]]),
|
|
1776
|
+
]));
|
|
1777
|
+
}
|
|
1778
|
+
const { recordDecision } = await import("./decisions/record.mjs");
|
|
1779
|
+
const row = await recordDecision(ctx.root, input);
|
|
1780
|
+
return { data: { applied: true, decision: row }, text: `${row.id} recorded: ${title}` };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** Replace a decision that no longer holds, in one transaction. */
|
|
1784
|
+
async function cmdDecisionSupersede(ctx) {
|
|
1785
|
+
const id = requireWord(ctx.words, 2, "Say which decision is being replaced: superdev decision supersede <id>.");
|
|
1786
|
+
const title = requireFlag(ctx.flags, "title", "The replacement needs a title.");
|
|
1787
|
+
const decision = requireFlag(ctx.flags, "decision", "Say what is decided instead.");
|
|
1788
|
+
const partial = Boolean(ctx.flags.partial);
|
|
1789
|
+
const scopeDelta = ctx.flags.scopeDelta ? String(ctx.flags.scopeDelta) : "";
|
|
1790
|
+
if (partial && !scopeDelta) {
|
|
1791
|
+
throw new UsageError("A partial supersession has to say which part stops applying. Pass --scopeDelta <what no longer holds>.");
|
|
1792
|
+
}
|
|
1793
|
+
const input = {
|
|
1794
|
+
title, decision, partial, scopeDelta,
|
|
1795
|
+
context: ctx.flags.context ?? null,
|
|
1796
|
+
rationale: ctx.flags.rationale ?? null,
|
|
1797
|
+
governs: asList(ctx.flags.governs),
|
|
1798
|
+
actor: ctx.actor,
|
|
1799
|
+
};
|
|
1800
|
+
if (!ctx.apply) {
|
|
1801
|
+
return planned({ supersedes: id, ...input }, "record it", R.stitch([
|
|
1802
|
+
`Would record "${title}" and mark ${id} ${partial ? "partially superseded" : "superseded"}.`,
|
|
1803
|
+
partial ? R.wrap(`What stops applying: ${scopeDelta}`) : null,
|
|
1804
|
+
]));
|
|
1805
|
+
}
|
|
1806
|
+
const { supersedeDecision } = await import("./decisions/record.mjs");
|
|
1807
|
+
const row = await supersedeDecision(ctx.root, id, input);
|
|
1808
|
+
return {
|
|
1809
|
+
data: { applied: true, decision: row },
|
|
1810
|
+
text: `${row.id} replaces ${id}, which is now ${partial ? "partially superseded" : "superseded"}.`,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Re-run the checks recorded evidence stands on.
|
|
1816
|
+
*
|
|
1817
|
+
* Without --apply nothing changes, which is the mode worth reaching for: it
|
|
1818
|
+
* answers whether what the project believes is still true, without altering the
|
|
1819
|
+
* answer.
|
|
1820
|
+
*/
|
|
1821
|
+
async function cmdVerify(ctx) {
|
|
1822
|
+
const { verifyEvidence } = await import("./verify/index.mjs");
|
|
1823
|
+
const report = await verifyEvidence(ctx.root, {
|
|
1824
|
+
apply: ctx.apply,
|
|
1825
|
+
taskId: ctx.flags.task ? String(ctx.flags.task) : null,
|
|
1826
|
+
limit: ctx.flags.limit ? Number(ctx.flags.limit) : null,
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
const lines = [
|
|
1830
|
+
R.heading("Verification"),
|
|
1831
|
+
R.pairs([
|
|
1832
|
+
["Evidence in force", String(report.evidenceCurrent)],
|
|
1833
|
+
["Carries a command", String(report.withCommand)],
|
|
1834
|
+
["Checked by hand only", String(report.manualOnly)],
|
|
1835
|
+
["Re-ran", String(report.checked)],
|
|
1836
|
+
["Still passing", String(report.stillPassing)],
|
|
1837
|
+
["No longer passing", String(report.noLongerPassing)],
|
|
1838
|
+
["Could not run", String(report.couldNotRun)],
|
|
1839
|
+
]),
|
|
1840
|
+
];
|
|
1841
|
+
if (report.failing.length) {
|
|
1842
|
+
lines.push("", R.block("No longer passing", R.bullets(report.failing.map((f) =>
|
|
1843
|
+
`${f.task ?? f.evidence} (${R.status(f.taskStatus ?? "unknown")}): ${f.command}. ${f.detail}`))));
|
|
1844
|
+
}
|
|
1845
|
+
if (report.unrunnable.length) {
|
|
1846
|
+
lines.push("", R.block("Could not run", R.bullets(report.unrunnable.slice(0, 8).map((f) =>
|
|
1847
|
+
`${f.task ?? f.evidence}: ${f.why}`))));
|
|
1848
|
+
}
|
|
1849
|
+
lines.push("", R.wrap(ctx.apply
|
|
1850
|
+
? `${report.noLongerPassing} piece(s) of evidence marked stale. The tasks that stood on them are named above and are not reopened automatically.`
|
|
1851
|
+
: "Nothing has changed. Re-run with --apply to mark failing evidence stale."));
|
|
1852
|
+
|
|
1853
|
+
return { data: report, text: R.stitch(lines), exit: report.noLongerPassing > 0 ? 1 : 0 };
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
async function cmdTaskEvidence(ctx) {
|
|
1857
|
+
const id = requireWord(ctx.words, 2, "Say which task was verified: superdev task evidence <id>.");
|
|
1858
|
+
const summary = requireFlag(ctx.flags, "summary",
|
|
1859
|
+
"Evidence needs a one-line summary of what was actually observed, not just a result.");
|
|
1860
|
+
const result = ctx.flags.result ? String(ctx.flags.result) : "pass";
|
|
1861
|
+
if (!["pass", "fail", "inconclusive"].includes(result)) {
|
|
1862
|
+
throw new UsageError(`A result is pass, fail or inconclusive, not ${result}.`);
|
|
1863
|
+
}
|
|
1864
|
+
const evidence = {
|
|
1865
|
+
summary,
|
|
1866
|
+
result,
|
|
1867
|
+
evidenceType: ctx.flags.type ? String(ctx.flags.type) : "manual_check",
|
|
1868
|
+
reference: ctx.flags.reference ? String(ctx.flags.reference) : null,
|
|
1869
|
+
acceptanceCriterionId: ctx.flags.criterion ? String(ctx.flags.criterion) : null,
|
|
1870
|
+
checkCommand: ctx.flags.command ? String(ctx.flags.command) : null,
|
|
1871
|
+
testPlanId: ctx.flags.plan ? String(ctx.flags.plan) : null,
|
|
1872
|
+
actor: ctx.actor,
|
|
1873
|
+
sessionId: ctx.flags.session ?? null,
|
|
1874
|
+
};
|
|
1875
|
+
if (!ctx.apply) {
|
|
1876
|
+
return planned({ id, ...evidence }, "record it", R.stitch([
|
|
1877
|
+
`Would record ${result === "pass" ? "passing" : result === "fail" ? "failing" : "inconclusive"} evidence for ${id}: ${summary}`,
|
|
1878
|
+
evidence.acceptanceCriterionId
|
|
1879
|
+
? R.wrap(`It is attached to acceptance criterion ${evidence.acceptanceCriterionId}, which ${result === "pass" ? "this marks met" : "this leaves unmet"}.`)
|
|
1880
|
+
: null,
|
|
1881
|
+
evidence.testPlanId
|
|
1882
|
+
? R.wrap(`It is recorded as a run of test plan ${evidence.testPlanId}${result === "pass" ? ", which satisfies it for the work it covers." : ", which leaves it unsatisfied."}`)
|
|
1883
|
+
: null,
|
|
1884
|
+
]));
|
|
1885
|
+
}
|
|
1886
|
+
const { attachEvidence } = await lifecycle();
|
|
1887
|
+
const task = await attachEvidence(ctx.root, id, evidence);
|
|
1888
|
+
return {
|
|
1889
|
+
data: { applied: true, task },
|
|
1890
|
+
text: `${task.evidence.id} recorded against ${task.id}: ${summary}`,
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* How the product is verified, and whether that verification has been run.
|
|
1896
|
+
*
|
|
1897
|
+
* Section 9.3 makes the accepted test plan a completion condition, so the
|
|
1898
|
+
* question a reader has is never only what the plan says: it is whether anyone
|
|
1899
|
+
* has run it since. Both are answered here, because a plan without its last
|
|
1900
|
+
* result is a promise rather than a report.
|
|
1901
|
+
*/
|
|
1902
|
+
async function cmdTestPlanList(ctx) {
|
|
1903
|
+
const { listPlans } = await import("./product/test-plans.mjs");
|
|
1904
|
+
const { count } = await import("./model/vocabulary.mjs");
|
|
1905
|
+
const plans = await listPlans(ctx.root);
|
|
1906
|
+
if (!plans.length) {
|
|
1907
|
+
return { data: { plans: [] }, text: R.wrap("No test plan has been recorded. A feature without one cannot say what proving it means.") };
|
|
1908
|
+
}
|
|
1909
|
+
const unrun = plans.filter((p) => p.status === "accepted" && !p.satisfied);
|
|
1910
|
+
return {
|
|
1911
|
+
data: { plans },
|
|
1912
|
+
text: R.stitch([
|
|
1913
|
+
R.heading(`Test plans (${plans.length})`),
|
|
1914
|
+
plans.map((p) => R.stitch([
|
|
1915
|
+
`${p.id} [${R.status(p.status)}] ${p.name}`,
|
|
1916
|
+
R.wrap(`Covers: ${p.feature_id ?? p.workflow_id ?? p.module_id ?? "the whole product"}`, R.WIDTH, " "),
|
|
1917
|
+
R.wrap(`Run: ${p.how_to_run}`, R.WIDTH, " "),
|
|
1918
|
+
R.wrap(`Passes when: ${p.passing_condition}`, R.WIDTH, " "),
|
|
1919
|
+
R.wrap(p.satisfied
|
|
1920
|
+
? `${count(p.passing_runs, "passing run")} recorded.`
|
|
1921
|
+
: `No passing run recorded, so any task it covers cannot complete. ${
|
|
1922
|
+
p.runnable ? `Run it with superdev test-plan run ${p.id} --apply.` : `It cannot run unattended, so record what you saw with superdev test-plan record ${p.id}.`}`,
|
|
1923
|
+
R.WIDTH, " "),
|
|
1924
|
+
])).join("\n\n"),
|
|
1925
|
+
unrun.length
|
|
1926
|
+
? R.wrap(`${count(unrun.length, "accepted plan")} without a passing run. Until each has one, no task it covers can complete.`)
|
|
1927
|
+
: R.wrap("Every accepted plan carries a passing run."),
|
|
1928
|
+
]),
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function cmdTestPlanShow(ctx) {
|
|
1933
|
+
const id = requireWord(ctx.words, 2, "Say which test plan: superdev test-plan show <TP-id>.");
|
|
1934
|
+
const { showPlan } = await import("./product/test-plans.mjs");
|
|
1935
|
+
const { plan: p, runs } = await showPlan(ctx.root, id);
|
|
1936
|
+
return {
|
|
1937
|
+
data: { plan: p, runs },
|
|
1938
|
+
text: R.stitch([
|
|
1939
|
+
R.heading(`${p.name} ${p.id}`),
|
|
1940
|
+
R.pairs([
|
|
1941
|
+
["Status", R.status(p.status)],
|
|
1942
|
+
["Covers", p.feature_id ?? p.workflow_id ?? p.module_id ?? "The whole product"],
|
|
1943
|
+
["Run", p.how_to_run],
|
|
1944
|
+
["Passes when", p.passing_condition],
|
|
1945
|
+
["Runs unattended", p.runnable ? "Yes" : `No. ${p.why_not_runnable}`],
|
|
1946
|
+
]),
|
|
1947
|
+
R.heading("Strategy"),
|
|
1948
|
+
R.wrap(p.strategy),
|
|
1949
|
+
R.heading("Runs recorded"),
|
|
1950
|
+
runs.length
|
|
1951
|
+
? R.bullets(runs.map((r) =>
|
|
1952
|
+
`${R.shortDate(r.recorded_at)} ${R.status(r.last_check_result ?? r.result)} ${r.summary}${r.task_id ? ` (${r.task_id})` : ""}`))
|
|
1953
|
+
: R.wrap("None. Until this plan is run, every task it covers is refused at completion."),
|
|
1954
|
+
]),
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** Run the plan's own command and record whatever it produced. */
|
|
1959
|
+
async function cmdTestPlanRun(ctx) {
|
|
1960
|
+
const id = requireWord(ctx.words, 2, "Say which test plan to run: superdev test-plan run <TP-id>.");
|
|
1961
|
+
const { runPlan } = await import("./product/test-plans.mjs");
|
|
1962
|
+
const out = await runPlan(ctx.root, id, {
|
|
1963
|
+
actor: ctx.actor,
|
|
1964
|
+
sessionId: ctx.flags.session ?? null,
|
|
1965
|
+
taskId: ctx.flags.task ? String(ctx.flags.task) : null,
|
|
1966
|
+
apply: ctx.apply,
|
|
1967
|
+
});
|
|
1968
|
+
const headline = `${id} ${out.result === "pass" ? "passed" : out.result === "fail" ? "failed" : "was inconclusive"}: ${out.detail || "no output"}`;
|
|
1969
|
+
if (!out.applied) {
|
|
1970
|
+
return planned({ id, ...out, plan: undefined }, "record the result", R.stitch([
|
|
1971
|
+
R.wrap(headline),
|
|
1972
|
+
R.wrap("The command was run. Nothing has been recorded yet."),
|
|
1973
|
+
]));
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
data: { applied: true, id, result: out.result, evidence: out.evidence?.id ?? null },
|
|
1977
|
+
text: R.stitch([
|
|
1978
|
+
R.wrap(headline),
|
|
1979
|
+
R.wrap(`Recorded as ${out.evidence?.id ?? "evidence"}.${out.result === "pass" ? "" : " A failing run does not satisfy the plan, so the tasks it covers stay blocked."}`),
|
|
1980
|
+
]),
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/** Record a plan that was carried out rather than run. */
|
|
1985
|
+
async function cmdTestPlanRecord(ctx) {
|
|
1986
|
+
const id = requireWord(ctx.words, 2, "Say which test plan was carried out: superdev test-plan record <TP-id>.");
|
|
1987
|
+
const summary = requireFlag(ctx.flags, "summary",
|
|
1988
|
+
"Say what was actually observed when the plan was carried out, not only whether it passed.");
|
|
1989
|
+
const { recordPlanRun } = await import("./product/test-plans.mjs");
|
|
1990
|
+
const out = await recordPlanRun(ctx.root, id, {
|
|
1991
|
+
summary,
|
|
1992
|
+
result: ctx.flags.result ? String(ctx.flags.result) : "pass",
|
|
1993
|
+
reference: ctx.flags.reference ? String(ctx.flags.reference) : null,
|
|
1994
|
+
taskId: ctx.flags.task ? String(ctx.flags.task) : null,
|
|
1995
|
+
actor: ctx.actor,
|
|
1996
|
+
sessionId: ctx.flags.session ?? null,
|
|
1997
|
+
apply: ctx.apply,
|
|
1998
|
+
});
|
|
1999
|
+
if (!out.applied) {
|
|
2000
|
+
return planned({ id, result: out.result, summary }, "record it",
|
|
2001
|
+
R.wrap(`Would record ${out.result === "pass" ? "a passing" : out.result === "fail" ? "a failing" : "an inconclusive"} run of ${id}: ${summary}`));
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
data: { applied: true, id, evidence: out.evidence?.id ?? null },
|
|
2005
|
+
text: R.wrap(`${out.evidence?.id ?? "The run"} recorded against ${id}: ${summary}`),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Stop work that should not continue.
|
|
2011
|
+
*
|
|
2012
|
+
* Cancelling is a status move like any other, so it leaves history and takes a
|
|
2013
|
+
* reason: a cancelled task with no reason is indistinguishable from one that
|
|
2014
|
+
* was quietly dropped.
|
|
2015
|
+
*/
|
|
2016
|
+
async function cmdTaskCancel(ctx) {
|
|
2017
|
+
const id = requireWord(ctx.words, 2, "Say which task to cancel: superdev task cancel <id>.");
|
|
2018
|
+
const reason = requireFlag(ctx.flags, "reason",
|
|
2019
|
+
"A cancelled task needs the reason, in plain language, so nobody re-derives it by accident.");
|
|
2020
|
+
if (!ctx.apply) {
|
|
2021
|
+
return planned({ id, reason }, "cancel it", R.wrap(`Would cancel ${id} because: ${reason}`));
|
|
2022
|
+
}
|
|
2023
|
+
const { cancelTask } = await lifecycle();
|
|
2024
|
+
const task = await cancelTask(ctx.root, id, { actor: ctx.actor, reason });
|
|
2025
|
+
return { data: { applied: true, task }, text: `${task.id} is cancelled.` };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function cmdTaskComplete(ctx) {
|
|
2029
|
+
const id = requireWord(ctx.words, 2, "Say which task to complete: superdev task complete <id>.");
|
|
2030
|
+
if (!ctx.apply) {
|
|
2031
|
+
return planned({ id }, "complete it", R.stitch([
|
|
2032
|
+
`Would complete ${id}.`,
|
|
2033
|
+
R.wrap("It is refused unless its verification evidence passes, the acceptance criteria it verifies are met, and no subtask is still open."),
|
|
2034
|
+
]));
|
|
2035
|
+
}
|
|
2036
|
+
const { completeTask } = await lifecycle();
|
|
2037
|
+
const task = await completeTask(ctx.root, id, { actor: ctx.actor, note: ctx.flags.note ?? null });
|
|
2038
|
+
return { data: { applied: true, task }, text: `${task.id} is complete and its claim was released.` };
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
async function cmdTaskBlock(ctx) {
|
|
2042
|
+
const id = requireWord(ctx.words, 2, "Say which task is blocked: superdev task block <id>.");
|
|
2043
|
+
const reason = requireFlag(ctx.flags, "reason", "A blocked task needs the reason, in plain language, so the next person can unblock it.");
|
|
2044
|
+
if (!ctx.apply) {
|
|
2045
|
+
return planned({ id, reason }, "record it", R.wrap(`Would mark ${id} blocked because: ${reason}`));
|
|
2046
|
+
}
|
|
2047
|
+
const { blockTask } = await lifecycle();
|
|
2048
|
+
const task = await blockTask(ctx.root, id, { reason, actor: ctx.actor });
|
|
2049
|
+
return { data: { applied: true, task }, text: `${task.id} is Blocked. The reason is on the record.` };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
async function cmdTaskReopen(ctx) {
|
|
2053
|
+
const id = requireWord(ctx.words, 2, "Say which task to reopen: superdev task reopen <id>.");
|
|
2054
|
+
const reason = requireFlag(ctx.flags, "reason", "Reopening finished work needs a reason, because the record already said it was done.");
|
|
2055
|
+
const to = ctx.flags.to ? String(ctx.flags.to) : null;
|
|
2056
|
+
if (!ctx.apply) {
|
|
2057
|
+
return planned({ id, reason, to }, "reopen it",
|
|
2058
|
+
R.wrap(`Would reopen ${id}${to ? ` as ${R.status(to)}` : ""} because: ${reason}`));
|
|
2059
|
+
}
|
|
2060
|
+
const { reopenTask } = await lifecycle();
|
|
2061
|
+
const task = await reopenTask(ctx.root, id, { reason, to, actor: ctx.actor });
|
|
2062
|
+
return { data: { applied: true, task }, text: `${task.id} is open again and is ${R.status(task.status)}.` };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// ---------------------------------------------------------------------- derive
|
|
2066
|
+
|
|
2067
|
+
async function cmdDerive(ctx) {
|
|
2068
|
+
const { deriveTasks, deriveAll } = await import("./tasks/derive.mjs");
|
|
2069
|
+
// The feature is positional. Written as --feature it swallows the identifier
|
|
2070
|
+
// as the flag's value, leaving no positional, and deriving one feature
|
|
2071
|
+
// silently becomes deriving every accepted feature: fifty-two tasks where one
|
|
2072
|
+
// was meant. Refusing is the only safe reading of that.
|
|
2073
|
+
if (ctx.flags.feature !== undefined) {
|
|
2074
|
+
throw new UsageError(
|
|
2075
|
+
`The feature is positional here: superdev derive ${ctx.flags.feature}. Written as --feature it is read as a value and every accepted feature is derived instead.`,
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
const featureId = ctx.words[1] ?? null;
|
|
2079
|
+
const result = featureId
|
|
2080
|
+
? await deriveTasks(ctx.root, featureId, { apply: ctx.apply, actor: ctx.actor })
|
|
2081
|
+
: await deriveAll(ctx.root, { apply: ctx.apply, actor: ctx.actor });
|
|
2082
|
+
|
|
2083
|
+
const created = result.created?.length ?? result.created ?? 0;
|
|
2084
|
+
const updated = result.updated?.length ?? result.updated ?? 0;
|
|
2085
|
+
const superseded = result.superseded?.length ?? result.superseded ?? 0;
|
|
2086
|
+
const summary = `${countWord(created, "task")} to create, ${updated} to update, ${superseded} to supersede`;
|
|
2087
|
+
|
|
2088
|
+
if (!ctx.apply) {
|
|
2089
|
+
return planned(result, "create them", R.stitch([
|
|
2090
|
+
featureId ? `Deriving ${featureId}: ${summary}.` : `Deriving every accepted feature: ${summary}.`,
|
|
2091
|
+
Array.isArray(result.created) && result.created.length
|
|
2092
|
+
? R.bullets(result.created.map((c) => c.name ?? c.item?.name ?? c.id ?? "a task"))
|
|
2093
|
+
: null,
|
|
2094
|
+
]));
|
|
2095
|
+
}
|
|
2096
|
+
return {
|
|
2097
|
+
data: result,
|
|
2098
|
+
text: `Derivation finished: ${created} created, ${updated} updated, ${superseded} superseded.`,
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// ------------------------------------------------------------------------ docs
|
|
2103
|
+
|
|
2104
|
+
async function cmdDocsGenerate(ctx) {
|
|
2105
|
+
const { generate } = await import("./docs/render.mjs");
|
|
2106
|
+
const only = ctx.flags.only ? String(ctx.flags.only) : null;
|
|
2107
|
+
const result = await generate(ctx.root, { apply: ctx.apply, only , includeReports: Boolean(ctx.flags.reports) });
|
|
2108
|
+
const summary = R.stitch([
|
|
2109
|
+
`${countWord(result.written.length, "file")} to write, ${result.unchanged.length} already correct, ${result.proposals.length} held back by a hand edit.`,
|
|
2110
|
+
result.written.length ? R.bullets(result.written) : null,
|
|
2111
|
+
result.proposals.length
|
|
2112
|
+
? R.block("Held back", R.bullets(result.proposals.map((p) => `${p.path} was edited by hand`)))
|
|
2113
|
+
: null,
|
|
2114
|
+
result.skipped.length
|
|
2115
|
+
? R.block("No longer applicable", R.bullets(result.skipped.map((s) => `${s.path} (${s.reason})`)))
|
|
2116
|
+
: null,
|
|
2117
|
+
]);
|
|
2118
|
+
if (!ctx.apply) return planned(result, "write them", summary);
|
|
2119
|
+
return {
|
|
2120
|
+
data: result,
|
|
2121
|
+
text: R.stitch([
|
|
2122
|
+
`Wrote ${countWord(result.written.length, "file")}. ${countWord(result.unchanged.length, "file")} ${result.unchanged.length === 1 ? "was" : "were"} already correct.`,
|
|
2123
|
+
result.proposals.length
|
|
2124
|
+
? R.wrap(`${countWord(result.proposals.length, "file")} ${result.proposals.length === 1 ? "was" : "were"} left alone because someone edited ${result.proposals.length === 1 ? "it" : "them"}. Run docs diff to see what changed.`)
|
|
2125
|
+
: null,
|
|
2126
|
+
]),
|
|
2127
|
+
exit: result.proposals.length ? 1 : 0,
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
async function cmdDocsDiff(ctx) {
|
|
2132
|
+
const { detectProposals, diffProposal } = await import("./docs/proposals.mjs");
|
|
2133
|
+
const path = ctx.words[2] ?? null;
|
|
2134
|
+
if (!path) {
|
|
2135
|
+
const report = await detectProposals(ctx.root, { apply: false });
|
|
2136
|
+
return {
|
|
2137
|
+
data: report,
|
|
2138
|
+
text: R.renderProposals(report),
|
|
2139
|
+
exit: report.proposals.length ? 1 : 0,
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
const diff = await diffProposal(ctx.root, path);
|
|
2143
|
+
const differs = diff.added > 0 || diff.removed > 0;
|
|
2144
|
+
return { data: diff, text: R.renderDiff(diff), exit: differs ? 1 : 0 };
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// An unmapped edit is {section, reason}. Both matter: the person needs to know
|
|
2148
|
+
// which part of the file has nowhere to go and why.
|
|
2149
|
+
const unmappedLine = (entry) =>
|
|
2150
|
+
typeof entry === "string" ? entry : `${entry.section ?? entry.heading ?? "the preamble"}: ${entry.reason ?? "nothing in the database holds this"}`;
|
|
2151
|
+
|
|
2152
|
+
async function cmdDocsAccept(ctx) {
|
|
2153
|
+
const { acceptProposal } = await import("./docs/proposals.mjs");
|
|
2154
|
+
const path = requireWord(ctx.words, 2, "Say which document to accept: superdev docs accept <path>.");
|
|
2155
|
+
const result = await acceptProposal(ctx.root, path, { apply: ctx.apply, actor: ctx.actor });
|
|
2156
|
+
if (!ctx.apply) {
|
|
2157
|
+
return planned(result, "write the edit into the database", R.stitch([
|
|
2158
|
+
R.wrap(`Accepting ${result.path} would take the hand-edited text into the records it came from.`),
|
|
2159
|
+
result.message ? R.wrap(result.message, R.WIDTH, " ") : null,
|
|
2160
|
+
result.unmapped?.length
|
|
2161
|
+
? R.block("Nothing to map these onto", R.bullets(result.unmapped.map(unmappedLine)))
|
|
2162
|
+
: null,
|
|
2163
|
+
]));
|
|
2164
|
+
}
|
|
2165
|
+
return {
|
|
2166
|
+
data: result,
|
|
2167
|
+
text: R.stitch([
|
|
2168
|
+
R.wrap(result.resolved
|
|
2169
|
+
? `Accepted the edits to ${result.path} into the database.`
|
|
2170
|
+
: `Part of ${result.path} has nowhere to go in the database, so the proposal is still open.`),
|
|
2171
|
+
result.message ? R.wrap(result.message, R.WIDTH, " ") : null,
|
|
2172
|
+
]),
|
|
2173
|
+
exit: result.resolved ? 0 : 1,
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
async function cmdDocsReject(ctx) {
|
|
2178
|
+
const { rejectProposal } = await import("./docs/proposals.mjs");
|
|
2179
|
+
const path = requireWord(ctx.words, 2, "Say which document to reject: superdev docs reject <path>.");
|
|
2180
|
+
if (!ctx.apply) {
|
|
2181
|
+
return planned({ path }, "put the generated version back", R.stitch([
|
|
2182
|
+
R.wrap(`Rejecting ${path} writes the generated version back over the file.`),
|
|
2183
|
+
R.wrap("The discarded text is recorded first, so a rejection can be read back afterwards."),
|
|
2184
|
+
]));
|
|
2185
|
+
}
|
|
2186
|
+
const result = await rejectProposal(ctx.root, path, { apply: true, actor: ctx.actor });
|
|
2187
|
+
return { data: result, text: R.wrap(`Wrote the generated version of ${result.path ?? path} back.`) };
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// ---------------------------------------------------------------------- memory
|
|
2191
|
+
|
|
2192
|
+
async function cmdMemorySearch(ctx) {
|
|
2193
|
+
const { recall } = await import("./memory/index.mjs");
|
|
2194
|
+
const text = ctx.words.slice(2).join(" ") || (ctx.flags.text ? String(ctx.flags.text) : "");
|
|
2195
|
+
if (!text) throw new UsageError("Say what to look for: superdev memory search <text>.");
|
|
2196
|
+
const entries = await recall(ctx.root, {
|
|
2197
|
+
text,
|
|
2198
|
+
limit: Number(ctx.flags.limit ?? 10),
|
|
2199
|
+
kinds: ctx.flags.kind ? asList(ctx.flags.kind) : undefined,
|
|
2200
|
+
taskId: ctx.flags.task ?? undefined,
|
|
2201
|
+
featureId: ctx.flags.feature ?? undefined,
|
|
2202
|
+
});
|
|
2203
|
+
if (!entries.length) {
|
|
2204
|
+
return { data: { entries: [] }, text: `Nothing recorded matches "${text}".` };
|
|
2205
|
+
}
|
|
2206
|
+
return {
|
|
2207
|
+
data: { entries },
|
|
2208
|
+
text: R.stitch([
|
|
2209
|
+
R.heading(`Recalled ${countWord(entries.length, "entry", "entries")}`),
|
|
2210
|
+
entries.map((e) => R.stitch([
|
|
2211
|
+
`${e.id} ${R.status(e.kind)} ${R.shortDate(e.createdAt ?? e.created_at)}`,
|
|
2212
|
+
R.wrap(e.summary ?? e.content ?? "", R.WIDTH, " "),
|
|
2213
|
+
])).join("\n"),
|
|
2214
|
+
"",
|
|
2215
|
+
R.wrap("Recall is a memory, not an authority. Check anything load bearing against the records before acting on it.", R.WIDTH, ""),
|
|
2216
|
+
]),
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// -------------------------------------------------------- questions, decisions
|
|
2221
|
+
|
|
2222
|
+
async function cmdQuestionAnswer(ctx) {
|
|
2223
|
+
const id = requireWord(ctx.words, 2, "Say which question to answer: superdev question answer <id> --answer <text>.");
|
|
2224
|
+
const answer = ctx.flags.answer !== undefined
|
|
2225
|
+
? String(ctx.flags.answer)
|
|
2226
|
+
: ctx.words.slice(3).join(" ");
|
|
2227
|
+
if (!answer) throw new UsageError("An answer needs text. Pass --answer <text>.");
|
|
2228
|
+
|
|
2229
|
+
const { query, mutate, patch, setStatus } = await store();
|
|
2230
|
+
const question = await query(ctx.root, (db) => db.get("SELECT * FROM questions WHERE id = ?", id));
|
|
2231
|
+
if (!question) throw new Refusal(`There is no question ${id}.`, "E_NOT_FOUND");
|
|
2232
|
+
if (question.status === "answered") {
|
|
2233
|
+
throw new Refusal(`${id} was already answered: ${question.answer}`, "E_ALREADY_ANSWERED");
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
if (!ctx.apply) {
|
|
2237
|
+
return planned({ id, answer }, "record it", R.stitch([
|
|
2238
|
+
`Would answer ${id}: ${question.question}`,
|
|
2239
|
+
R.wrap(`Answer: ${answer}`, R.WIDTH, " "),
|
|
2240
|
+
question.recommendation ? R.wrap(`The recommendation on file was: ${question.recommendation}`, R.WIDTH, " ") : null,
|
|
2241
|
+
]));
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const row = await mutate(ctx.root, async (db) => {
|
|
2245
|
+
const current = await db.get("SELECT * FROM questions WHERE id = ?", id);
|
|
2246
|
+
await patch(db, "question", id, current.version, {
|
|
2247
|
+
answer,
|
|
2248
|
+
answered_by: ctx.actor,
|
|
2249
|
+
answered_at: nowIso(),
|
|
2250
|
+
}, { projectId: current.project_id, actor: ctx.actor, activitySummary: `Answered ${id}: ${question.question}` });
|
|
2251
|
+
return setStatus(db, "question", id, "answered", {
|
|
2252
|
+
projectId: current.project_id, actor: ctx.actor, note: answer,
|
|
2253
|
+
});
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
return { data: { applied: true, question: row }, text: `${id} is answered.` };
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async function cmdDecisionList(ctx) {
|
|
2260
|
+
const { json } = await store();
|
|
2261
|
+
const decisions = await withProject(ctx.root, async (db, project) => {
|
|
2262
|
+
const rows = await db.all(
|
|
2263
|
+
`SELECT * FROM decisions WHERE project_id = ?
|
|
2264
|
+
${ctx.flags.all ? "" : "AND status NOT IN ('rejected','superseded','deprecated')"}
|
|
2265
|
+
ORDER BY created_at DESC`,
|
|
2266
|
+
project.id,
|
|
2267
|
+
);
|
|
2268
|
+
// What a decision governs is the question this command exists to answer, and
|
|
2269
|
+
// it lived only in the control centre's own read model. Reading a decision
|
|
2270
|
+
// without its links says which decisions exist, never which one binds the
|
|
2271
|
+
// module in front of you.
|
|
2272
|
+
for (const d of rows) {
|
|
2273
|
+
d.links = await db.all(
|
|
2274
|
+
"SELECT target_type, target_id, relationship, scope_note FROM decision_links WHERE decision_id = ?",
|
|
2275
|
+
d.id,
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
return rows;
|
|
2279
|
+
});
|
|
2280
|
+
if (!decisions.length) {
|
|
2281
|
+
return { data: { decisions: [] }, text: "No decision has been recorded yet." };
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
data: { decisions },
|
|
2285
|
+
text: R.stitch([
|
|
2286
|
+
R.heading(`Decisions (${decisions.length})`),
|
|
2287
|
+
R.table(["Id", "Status", "Expires", "Title"],
|
|
2288
|
+
decisions.map((d) => [d.id, R.status(d.status), d.expires_at ? R.shortDate(d.expires_at) : "", d.title])),
|
|
2289
|
+
"",
|
|
2290
|
+
R.block("What they govern", R.bullets(decisions.slice(0, 8).map((d) => {
|
|
2291
|
+
const binds = (d.links ?? []).filter((l) => l.relationship === "governs").map((l) => l.target_id);
|
|
2292
|
+
return `${d.id}: ${binds.length ? `binds ${binds.join(", ")}. ` : ""}${d.decision ?? d.title}`;
|
|
2293
|
+
}))),
|
|
2294
|
+
]),
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// -------------------------------------------------------------------- dispatch
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
// ---------------------------------------------------------------- categories
|
|
2302
|
+
|
|
2303
|
+
async function cmdCategoryList(ctx) {
|
|
2304
|
+
const { listCategories } = await import("./tasks/categories.mjs");
|
|
2305
|
+
const rows = await listCategories(ctx.root);
|
|
2306
|
+
if (!rows.length) {
|
|
2307
|
+
return { data: { categories: [] }, text: "This project has no task categories yet. Run superdev init, or add one with superdev category add." };
|
|
2308
|
+
}
|
|
2309
|
+
const active = rows.filter((c) => c.active);
|
|
2310
|
+
const retired = rows.filter((c) => !c.active);
|
|
2311
|
+
return {
|
|
2312
|
+
data: { categories: rows },
|
|
2313
|
+
text: R.stitch([
|
|
2314
|
+
R.heading(`Task categories (${active.length} in use${retired.length ? `, ${retired.length} retired` : ""})`),
|
|
2315
|
+
R.table(["Id", "Category", "Origin", "Tasks", "Means"],
|
|
2316
|
+
active.map((c) => [c.id, c.name, c.system ? "seeded" : "yours", String(c.task_count), c.description ?? ""])),
|
|
2317
|
+
retired.length ? "" : null,
|
|
2318
|
+
retired.length ? R.block("Retired", R.bullets(retired.map((c) =>
|
|
2319
|
+
`${c.name} (${c.id}), still carried by ${c.task_count} task(s). Restore with superdev category restore ${c.id}.`))) : null,
|
|
2320
|
+
].filter((x) => x !== null)),
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
async function cmdCategoryAdd(ctx) {
|
|
2325
|
+
const name = ctx.words.slice(2).join(" ");
|
|
2326
|
+
if (!name) throw new UsageError("Say what to call it: superdev category add <name> [--description <text>]");
|
|
2327
|
+
const { createCategory } = await import("./tasks/categories.mjs");
|
|
2328
|
+
if (!ctx.apply) {
|
|
2329
|
+
return { data: { plan: { name } }, text: `Would add the task category ${name}. Re-run with --apply to create it.` };
|
|
2330
|
+
}
|
|
2331
|
+
const row = await createCategory(ctx.root, { name, description: ctx.flags.description ?? null, actor: ctx.actor });
|
|
2332
|
+
return { data: { category: row }, text: `Added the task category ${row.name} (${row.id}).` };
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
async function cmdCategoryRename(ctx) {
|
|
2336
|
+
const id = requireWord(ctx.words, 2, "Say which one and what to call it: superdev category rename <id> <new name>.");
|
|
2337
|
+
const name = ctx.words.slice(3).join(" ");
|
|
2338
|
+
if (!name) throw new UsageError("Say what to call it: superdev category rename <id> <new name>.");
|
|
2339
|
+
const { updateCategory } = await import("./tasks/categories.mjs");
|
|
2340
|
+
if (!ctx.apply) {
|
|
2341
|
+
return { data: { plan: { id, name } }, text: `Would rename ${id} to ${name}. Re-run with --apply.` };
|
|
2342
|
+
}
|
|
2343
|
+
const row = await updateCategory(ctx.root, id, { name, description: ctx.flags.description, actor: ctx.actor });
|
|
2344
|
+
return { data: { category: row }, text: `${row.id} is now ${row.name}.` };
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
async function cmdCategoryDescribe(ctx) {
|
|
2348
|
+
const id = requireWord(ctx.words, 2, "Say which one: superdev category describe <id> <what it means>.");
|
|
2349
|
+
const description = ctx.words.slice(3).join(" ");
|
|
2350
|
+
if (!description) throw new UsageError("Say what it means: superdev category describe <id> <what it means>.");
|
|
2351
|
+
const { updateCategory } = await import("./tasks/categories.mjs");
|
|
2352
|
+
if (!ctx.apply) {
|
|
2353
|
+
return { data: { plan: { id, description } }, text: `Would describe ${id} as: ${description}. Re-run with --apply.` };
|
|
2354
|
+
}
|
|
2355
|
+
const row = await updateCategory(ctx.root, id, { description, actor: ctx.actor });
|
|
2356
|
+
return { data: { category: row }, text: `${row.name} now reads: ${row.description}` };
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
async function cmdCategoryRetire(ctx) {
|
|
2360
|
+
const id = requireWord(ctx.words, 2, "Say which one: superdev category retire <id>.");
|
|
2361
|
+
const { setCategoryActive } = await import("./tasks/categories.mjs");
|
|
2362
|
+
if (!ctx.apply) {
|
|
2363
|
+
return { data: { plan: { id } }, text: `Would retire ${id}. Tasks already filed under it keep it. Re-run with --apply.` };
|
|
2364
|
+
}
|
|
2365
|
+
const row = await setCategoryActive(ctx.root, id, false, ctx.actor);
|
|
2366
|
+
return { data: { category: row }, text: `${row.name} is retired. It is off the pickable list; tasks that already carry it are untouched.` };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
async function cmdCategoryRestore(ctx) {
|
|
2370
|
+
const id = requireWord(ctx.words, 2, "Say which one: superdev category restore <id>.");
|
|
2371
|
+
const { setCategoryActive } = await import("./tasks/categories.mjs");
|
|
2372
|
+
if (!ctx.apply) {
|
|
2373
|
+
return { data: { plan: { id } }, text: `Would restore ${id}. Re-run with --apply.` };
|
|
2374
|
+
}
|
|
2375
|
+
const row = await setCategoryActive(ctx.root, id, true, ctx.actor);
|
|
2376
|
+
return { data: { category: row }, text: `${row.name} is available again.` };
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
// ------------------------------------------------------------ feature depth
|
|
2381
|
+
|
|
2382
|
+
async function cmdFeatureAccept(ctx) {
|
|
2383
|
+
const id = requireWord(ctx.words, 2, "Say which feature: superdev feature accept <id>.");
|
|
2384
|
+
const { acceptFeature } = await import("./features/acceptance.mjs");
|
|
2385
|
+
const report = await acceptFeature(ctx.root, id, { apply: ctx.apply, actor: ctx.actor });
|
|
2386
|
+
return {
|
|
2387
|
+
data: report,
|
|
2388
|
+
text: report.applied
|
|
2389
|
+
? `${report.name} is accepted at ${report.depth} depth, with all ${report.required} requirements recorded.`
|
|
2390
|
+
: R.stitch([
|
|
2391
|
+
R.heading(`${report.name} is ready to accept at ${report.depth} depth`),
|
|
2392
|
+
R.table(["Requirement", "State"], report.components.map((c) => [c.says, c.met ? "recorded" : "missing"])),
|
|
2393
|
+
"",
|
|
2394
|
+
"Re-run with --apply to accept it.",
|
|
2395
|
+
]),
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* Write the specification the depth gate asks for.
|
|
2401
|
+
*
|
|
2402
|
+
* Section 12.4 names no command for this and the depth gate names a database
|
|
2403
|
+
* record for every gap, so the two only meet if something can write one. Until
|
|
2404
|
+
* this existed, `feature depth` reported six missing covers and no command
|
|
2405
|
+
* could close any of them.
|
|
2406
|
+
*/
|
|
2407
|
+
/** Set an acceptance criterion aside, with the reason on the record. */
|
|
2408
|
+
async function cmdFeatureWaive(ctx) {
|
|
2409
|
+
const id = requireWord(ctx.words, 2, "Say which acceptance criterion: superdev feature waive <AC-id>.");
|
|
2410
|
+
const reason = requireFlag(ctx.flags, "reason",
|
|
2411
|
+
"Say why this criterion is being set aside. A waiver without a reason reads the same as forgetting.");
|
|
2412
|
+
const { waiveCriterion } = await import("./features/specify.mjs");
|
|
2413
|
+
const out = await waiveCriterion(ctx.root, id, {
|
|
2414
|
+
reason, actor: ctx.actor, sessionId: ctx.flags.session ?? null, apply: ctx.apply,
|
|
2415
|
+
});
|
|
2416
|
+
if (!out.applied) {
|
|
2417
|
+
return planned(out, "waive it",
|
|
2418
|
+
R.wrap(`Would waive ${id} on ${out.feature ?? "its feature"}: ${out.criterion}. Because: ${out.reason}`));
|
|
2419
|
+
}
|
|
2420
|
+
return {
|
|
2421
|
+
data: out,
|
|
2422
|
+
text: R.wrap(`${id} is waived on ${out.feature}: ${out.criterion}. The reason is on the record, and tasks verifying it are no longer held by it.`),
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
async function cmdFeatureSpecify(ctx) {
|
|
2427
|
+
const id = requireWord(ctx.words, 2, "Say which feature to specify: superdev feature specify <FEATURE-id>.");
|
|
2428
|
+
const { specifyFeature } = await import("./features/specify.mjs");
|
|
2429
|
+
const { EDGE_CASE_CATEGORIES } = await import("./model/vocabulary.mjs");
|
|
2430
|
+
|
|
2431
|
+
// --edge takes category:behavior, and a behavior beginning with "not
|
|
2432
|
+
// applicable" records the category as deliberately out rather than unwritten.
|
|
2433
|
+
const edgeCases = asList(ctx.flags.edge).map((value) => {
|
|
2434
|
+
const text = String(value);
|
|
2435
|
+
const at = text.indexOf(":");
|
|
2436
|
+
if (at < 1) {
|
|
2437
|
+
throw new UsageError(`--edge takes category:behavior, for example --edge empty_states:"The list says no notes yet." Categories: ${EDGE_CASE_CATEGORIES.join(", ")}.`);
|
|
2438
|
+
}
|
|
2439
|
+
const behavior = text.slice(at + 1).trim();
|
|
2440
|
+
return {
|
|
2441
|
+
category: text.slice(0, at).trim(),
|
|
2442
|
+
behavior,
|
|
2443
|
+
applicability: /^not[ _]applicable\b/i.test(behavior) ? "not_applicable" : "applicable",
|
|
2444
|
+
};
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
// --criterion takes the criterion, optionally with how it is verified after a
|
|
2448
|
+
// double bar, because the depth gate asks for both at standard depth.
|
|
2449
|
+
const criteria = asList(ctx.flags.criterion).map((value) => {
|
|
2450
|
+
const [criterion, verification] = String(value).split("||").map((x) => x.trim());
|
|
2451
|
+
return { criterion, verification: verification || null };
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
const input = {
|
|
2455
|
+
purpose: ctx.flags.purpose ? String(ctx.flags.purpose) : null,
|
|
2456
|
+
userStatement: ctx.flags.user ? String(ctx.flags.user) : null,
|
|
2457
|
+
scopeIn: asList(ctx.flags.in).map(String),
|
|
2458
|
+
scopeOut: asList(ctx.flags.out).map(String),
|
|
2459
|
+
flow: asList(ctx.flags.flow).map(String),
|
|
2460
|
+
criteria,
|
|
2461
|
+
edgeCases,
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
const out = await specifyFeature(ctx.root, id, input, {
|
|
2465
|
+
actor: ctx.actor, sessionId: ctx.flags.session ?? null, apply: ctx.apply,
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
const written = [
|
|
2469
|
+
out.purpose ? "its purpose" : null,
|
|
2470
|
+
out.userStatement ? "who wants it" : null,
|
|
2471
|
+
out.scopeIn ? `${out.scopeIn} in scope` : null,
|
|
2472
|
+
out.scopeOut ? `${out.scopeOut} out of scope` : null,
|
|
2473
|
+
out.flow ? `a ${out.flow} step flow` : null,
|
|
2474
|
+
out.criteria ? `${out.criteria} acceptance criteria` : null,
|
|
2475
|
+
out.edgeCases ? `${out.edgeCases} edge cases` : null,
|
|
2476
|
+
].filter(Boolean).join(", ");
|
|
2477
|
+
|
|
2478
|
+
if (!out.applied) {
|
|
2479
|
+
return planned({ id, ...input }, "write it", R.wrap(`Would record for ${id}: ${written}.`));
|
|
2480
|
+
}
|
|
2481
|
+
const { depthReadiness } = await import("./features/acceptance.mjs");
|
|
2482
|
+
const { query } = await store();
|
|
2483
|
+
const report = await query(ctx.root, (db) => depthReadiness(db, id));
|
|
2484
|
+
return {
|
|
2485
|
+
data: { applied: true, ...out, readiness: report },
|
|
2486
|
+
text: R.stitch([
|
|
2487
|
+
R.wrap(`Recorded for ${id}: ${written}.`),
|
|
2488
|
+
R.wrap(report.acceptable
|
|
2489
|
+
? `${id} now carries everything ${report.depth} depth promises. Accept it with superdev feature accept ${id} --apply.`
|
|
2490
|
+
: `${id} still lacks ${report.missing.map((m) => m.says).join(", ")}.`),
|
|
2491
|
+
]),
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
async function cmdFeatureDepth(ctx) {
|
|
2496
|
+
const { depthReadiness, depthGaps } = await import("./features/acceptance.mjs");
|
|
2497
|
+
const id = ctx.words[2];
|
|
2498
|
+
// Section 12.4 specifies `feature depth <FEATURE-id> <depth>`, which sets it.
|
|
2499
|
+
// Only the report existed, so the depth a feature declared could be read and
|
|
2500
|
+
// never changed, and a feature drafted at one depth was stuck at it.
|
|
2501
|
+
const wanted = ctx.words[3];
|
|
2502
|
+
if (id && wanted) {
|
|
2503
|
+
const { setDepth } = await import("./features/specify.mjs");
|
|
2504
|
+
const out = await setDepth(ctx.root, id, wanted, { actor: ctx.actor, apply: ctx.apply });
|
|
2505
|
+
if (out.unchanged) return { data: out, text: R.wrap(`${id} is already at ${wanted} depth.`) };
|
|
2506
|
+
if (!out.applied) {
|
|
2507
|
+
return planned(out, "change the depth",
|
|
2508
|
+
R.wrap(`Would move ${id} from ${out.from} to ${wanted} depth, which changes what it must carry before it can be accepted.`));
|
|
2509
|
+
}
|
|
2510
|
+
return { data: out, text: R.wrap(`${id} is now ${wanted} depth, was ${out.from}. Run superdev feature depth ${id} to see what that now requires.`) };
|
|
2511
|
+
}
|
|
2512
|
+
if (id) {
|
|
2513
|
+
const { query } = await store();
|
|
2514
|
+
const report = await query(ctx.root, (db) => depthReadiness(db, id));
|
|
2515
|
+
return {
|
|
2516
|
+
data: report,
|
|
2517
|
+
text: R.stitch([
|
|
2518
|
+
R.heading(`${report.name}, declared ${report.depth} depth`),
|
|
2519
|
+
R.pairs([["Recorded", `${report.met} of ${report.required}`]]),
|
|
2520
|
+
"",
|
|
2521
|
+
R.table(["Requirement", "State", "What closes it"],
|
|
2522
|
+
report.components.map((c) => [c.says, c.met ? "recorded" : "missing", c.met ? "" : c.fix])),
|
|
2523
|
+
]),
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
const gaps = await depthGaps(ctx.root);
|
|
2527
|
+
if (!gaps.length) {
|
|
2528
|
+
return { data: { gaps: [] }, text: "Every accepted feature carries what its declared depth promises." };
|
|
2529
|
+
}
|
|
2530
|
+
return {
|
|
2531
|
+
data: { gaps },
|
|
2532
|
+
text: R.stitch([
|
|
2533
|
+
R.heading(`Accepted features thinner than they claim (${gaps.length})`),
|
|
2534
|
+
R.table(["Feature", "Depth", "Recorded", "Missing"],
|
|
2535
|
+
gaps.map((g) => [g.name, g.depth, `${g.met} of ${g.required}`, g.missing.map((m) => m.says).join("; ")])),
|
|
2536
|
+
"",
|
|
2537
|
+
"Record what is missing, or lower the depth so the record matches the feature.",
|
|
2538
|
+
]),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const COMMANDS = {
|
|
2543
|
+
init: cmdInit,
|
|
2544
|
+
adopt: cmdAdopt,
|
|
2545
|
+
plan: cmdPlan,
|
|
2546
|
+
status: cmdStatus,
|
|
2547
|
+
readiness: cmdReadiness,
|
|
2548
|
+
resume: cmdResume,
|
|
2549
|
+
doctor: cmdDoctor,
|
|
2550
|
+
ui: cmdUi,
|
|
2551
|
+
start: cmdStart,
|
|
2552
|
+
stop: cmdStop,
|
|
2553
|
+
restart: cmdRestart,
|
|
2554
|
+
services: cmdServices,
|
|
2555
|
+
export: cmdExport,
|
|
2556
|
+
import: cmdImport,
|
|
2557
|
+
derive: cmdDerive,
|
|
2558
|
+
"db status": cmdDbStatus,
|
|
2559
|
+
"db migrate": cmdDbMigrate,
|
|
2560
|
+
"db backup": cmdDbBackup,
|
|
2561
|
+
"db restore": cmdDbRestore,
|
|
2562
|
+
"task list": cmdTaskList,
|
|
2563
|
+
"task show": cmdTaskShow,
|
|
2564
|
+
"task create": cmdTaskCreate,
|
|
2565
|
+
"task update": cmdTaskUpdate,
|
|
2566
|
+
"task claim": cmdTaskClaim,
|
|
2567
|
+
"task start": cmdTaskStart,
|
|
2568
|
+
"task unblock": cmdTaskUnblock,
|
|
2569
|
+
"task release": cmdTaskRelease,
|
|
2570
|
+
verify: cmdVerify,
|
|
2571
|
+
"task evidence": cmdTaskEvidence,
|
|
2572
|
+
"task cancel": cmdTaskCancel,
|
|
2573
|
+
"task complete": cmdTaskComplete,
|
|
2574
|
+
"task block": cmdTaskBlock,
|
|
2575
|
+
"task reopen": cmdTaskReopen,
|
|
2576
|
+
"docs generate": cmdDocsGenerate,
|
|
2577
|
+
"docs diff": cmdDocsDiff,
|
|
2578
|
+
"docs accept": cmdDocsAccept,
|
|
2579
|
+
"docs reject": cmdDocsReject,
|
|
2580
|
+
"memory search": cmdMemorySearch,
|
|
2581
|
+
"question answer": cmdQuestionAnswer,
|
|
2582
|
+
settings: cmdSettings,
|
|
2583
|
+
hook: cmdHook,
|
|
2584
|
+
"cloud status": cmdCloudStatus,
|
|
2585
|
+
"cloud connect": cmdCloudConnect,
|
|
2586
|
+
sync: cmdSync,
|
|
2587
|
+
"module list": cmdModuleList,
|
|
2588
|
+
"module show": cmdModuleShow,
|
|
2589
|
+
"goal list": cmdGoalList,
|
|
2590
|
+
"goal show": cmdGoalShow,
|
|
2591
|
+
"milestone list": cmdMilestoneList,
|
|
2592
|
+
"milestone show": cmdMilestoneShow,
|
|
2593
|
+
"feature list": cmdFeatureList,
|
|
2594
|
+
"feature show": cmdFeatureShow,
|
|
2595
|
+
"workflow list": cmdWorkflowList,
|
|
2596
|
+
"workflow show": cmdWorkflowShow,
|
|
2597
|
+
"architecture show": cmdArchitectureShow,
|
|
2598
|
+
"schema show": cmdSchemaShow,
|
|
2599
|
+
"api show": cmdApiShow,
|
|
2600
|
+
"integration list": cmdIntegrationList,
|
|
2601
|
+
"memory show": cmdMemoryShow,
|
|
2602
|
+
"memory verify": cmdMemoryVerify,
|
|
2603
|
+
"memory consolidate": cmdMemoryConsolidate,
|
|
2604
|
+
"memory supersede": cmdMemorySupersede,
|
|
2605
|
+
"memory status": cmdMemoryStatus,
|
|
2606
|
+
"memory benchmark": cmdMemoryBenchmark,
|
|
2607
|
+
"question list": cmdQuestionList,
|
|
2608
|
+
"change record": cmdChangeRecord,
|
|
2609
|
+
"test-plan list": cmdTestPlanList,
|
|
2610
|
+
"test-plan show": cmdTestPlanShow,
|
|
2611
|
+
"test-plan run": cmdTestPlanRun,
|
|
2612
|
+
"test-plan record": cmdTestPlanRecord,
|
|
2613
|
+
"change list": cmdChangeList,
|
|
2614
|
+
"change show": cmdChangeShow,
|
|
2615
|
+
"assumption record": cmdAssumptionRecord,
|
|
2616
|
+
"assumption list": cmdAssumptionList,
|
|
2617
|
+
"assumption resolve": cmdAssumptionResolve,
|
|
2618
|
+
"decision record": cmdDecisionRecord,
|
|
2619
|
+
"decision supersede": cmdDecisionSupersede,
|
|
2620
|
+
"decision list": cmdDecisionList,
|
|
2621
|
+
"feature accept": cmdFeatureAccept,
|
|
2622
|
+
"feature depth": cmdFeatureDepth,
|
|
2623
|
+
"feature specify": cmdFeatureSpecify,
|
|
2624
|
+
"feature waive": cmdFeatureWaive,
|
|
2625
|
+
"category list": cmdCategoryList,
|
|
2626
|
+
"category add": cmdCategoryAdd,
|
|
2627
|
+
"category rename": cmdCategoryRename,
|
|
2628
|
+
"category describe": cmdCategoryDescribe,
|
|
2629
|
+
"category retire": cmdCategoryRetire,
|
|
2630
|
+
"category restore": cmdCategoryRestore,
|
|
2631
|
+
};
|
|
2632
|
+
|
|
2633
|
+
const GROUPS = new Set(["db", "task", "docs", "memory", "question", "decision", "category", "feature",
|
|
2634
|
+
"module", "goal", "milestone", "workflow", "architecture", "schema", "api", "integration", "change", "assumption", "cloud"]);
|
|
2635
|
+
|
|
2636
|
+
function resolveCommand(words) {
|
|
2637
|
+
if (!words.length) return null;
|
|
2638
|
+
const two = `${words[0]} ${words[1] ?? ""}`.trim();
|
|
2639
|
+
if (COMMANDS[two]) return { name: two, handler: COMMANDS[two] };
|
|
2640
|
+
if (GROUPS.has(words[0])) {
|
|
2641
|
+
const known = Object.keys(COMMANDS)
|
|
2642
|
+
.filter((k) => k.startsWith(`${words[0]} `))
|
|
2643
|
+
.map((k) => k.split(" ")[1]);
|
|
2644
|
+
throw new UsageError(
|
|
2645
|
+
words[1]
|
|
2646
|
+
? `There is no ${two} command. ${words[0]} takes: ${known.join(", ")}.`
|
|
2647
|
+
: `${words[0]} needs a subcommand: ${known.join(", ")}.`,
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
if (COMMANDS[words[0]]) return { name: words[0], handler: COMMANDS[words[0]] };
|
|
2651
|
+
return null;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// Piping into head or less closes the pipe early. That is the reader's choice,
|
|
2655
|
+
// not a failure of the command. Registered once, because run() is callable more
|
|
2656
|
+
// than once in a process.
|
|
2657
|
+
let pipeGuarded = false;
|
|
2658
|
+
function guardPipe() {
|
|
2659
|
+
if (pipeGuarded) return;
|
|
2660
|
+
pipeGuarded = true;
|
|
2661
|
+
process.stdout.on("error", (err) => {
|
|
2662
|
+
if (err?.code !== "EPIPE") throw err;
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/** Write output where it was asked for. `--out` is a file, otherwise stdout. */
|
|
2667
|
+
function emit(text, out) {
|
|
2668
|
+
if (!out) {
|
|
2669
|
+
guardPipe();
|
|
2670
|
+
process.stdout.write(`${text}\n`);
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
const file = resolve(String(out));
|
|
2674
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2675
|
+
writeFileSync(file, `${text}\n`, "utf8");
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
2679
|
+
let parsed;
|
|
2680
|
+
try {
|
|
2681
|
+
parsed = parseArgs(argv);
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
process.stderr.write(`${err.message}\n`);
|
|
2684
|
+
return 2;
|
|
2685
|
+
}
|
|
2686
|
+
const { words, flags } = parsed;
|
|
2687
|
+
|
|
2688
|
+
if (flags.version) {
|
|
2689
|
+
const { self } = await import("./runtime/version.mjs");
|
|
2690
|
+
const me = self();
|
|
2691
|
+
process.stdout.write(flags.json
|
|
2692
|
+
? `${JSON.stringify({ ok: true, command: "version", data: me }, null, 2)}\n`
|
|
2693
|
+
: `${me.name} ${me.version}\n`);
|
|
2694
|
+
return 0;
|
|
2695
|
+
}
|
|
2696
|
+
if (flags.help) {
|
|
2697
|
+
process.stdout.write(`${HELP}\n`);
|
|
2698
|
+
return 0;
|
|
2699
|
+
}
|
|
2700
|
+
if (!words.length) {
|
|
2701
|
+
// Nothing was asked for, which is a misuse rather than an answer, so the
|
|
2702
|
+
// help goes to stdout and the exit code still says the command was wrong.
|
|
2703
|
+
process.stdout.write(`${HELP}\n`);
|
|
2704
|
+
return 2;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
let resolved;
|
|
2708
|
+
try {
|
|
2709
|
+
resolved = resolveCommand(words);
|
|
2710
|
+
} catch (err) {
|
|
2711
|
+
process.stderr.write(`${err.message}\n`);
|
|
2712
|
+
return 2;
|
|
2713
|
+
}
|
|
2714
|
+
if (!resolved) {
|
|
2715
|
+
process.stderr.write(`There is no ${words.join(" ")} command. Run superdev --help for the list.\n`);
|
|
2716
|
+
return 2;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
const ctx = {
|
|
2720
|
+
root: resolve(String(flags.root ?? process.cwd())),
|
|
2721
|
+
apply: Boolean(flags.apply),
|
|
2722
|
+
json: Boolean(flags.json),
|
|
2723
|
+
actor: flags.actor ? String(flags.actor) : "superdev",
|
|
2724
|
+
flags,
|
|
2725
|
+
words,
|
|
2726
|
+
command: resolved.name,
|
|
2727
|
+
};
|
|
2728
|
+
|
|
2729
|
+
try {
|
|
2730
|
+
const result = await withFriendlyMissingProject(ctx, () => resolved.handler(ctx));
|
|
2731
|
+
const out = result.consumedOut ? null : flags.out;
|
|
2732
|
+
if (ctx.json) {
|
|
2733
|
+
emit(JSON.stringify({ ok: true, command: resolved.name, data: result.data ?? null }, null, 2), out);
|
|
2734
|
+
} else {
|
|
2735
|
+
emit(String(result.text ?? "").replace(/\n{3,}/g, "\n\n").trimEnd(), out);
|
|
2736
|
+
// After the answer, never before it, and never in JSON: a machine reading
|
|
2737
|
+
// --json is parsing a contract, and a courtesy about versions is not part
|
|
2738
|
+
// of it. This reads a file the last check wrote, so it cannot wait.
|
|
2739
|
+
await announceUpdates(ctx);
|
|
2740
|
+
}
|
|
2741
|
+
return result.exit ?? 0;
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
const usage = err instanceof UsageError || err?.code === "E_USAGE";
|
|
2744
|
+
// The engine is a static import inside src/db/connect.mjs, so when it is
|
|
2745
|
+
// absent every command dies at module load with a resolver message naming a
|
|
2746
|
+
// package the reader never asked for. This is the one place that sees all of
|
|
2747
|
+
// them. The case is common rather than exotic: a Claude Code marketplace
|
|
2748
|
+
// install copies the plugin into its own cache, and node_modules is
|
|
2749
|
+
// git-ignored, so the copy arrives with no engine at all.
|
|
2750
|
+
if (/Cannot find (package|module) '@tursodatabase\/database'/.test(String(err?.message ?? ""))) {
|
|
2751
|
+
err = new Refusal(
|
|
2752
|
+
"Superdev's storage engine is not installed, so it cannot open a database. " +
|
|
2753
|
+
"Run npm install in the Superdev plugin directory, the one holding its package.json, " +
|
|
2754
|
+
"then run this again. If Superdev was installed from a marketplace, that copy lives in " +
|
|
2755
|
+
"the harness's plugin cache and needs the install run there.",
|
|
2756
|
+
"E_ENGINE_MISSING",
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
const payload = {
|
|
2760
|
+
ok: false,
|
|
2761
|
+
command: resolved.name,
|
|
2762
|
+
error: { code: err?.code ?? "E_FAILED", message: err?.message ?? String(err) },
|
|
2763
|
+
};
|
|
2764
|
+
if (ctx.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
2765
|
+
else process.stderr.write(`${R.wrap(err?.message ?? String(err), R.WIDTH, "")}\n`);
|
|
2766
|
+
return usage ? 2 : 1;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// Only dispatch when this file is the program. Imported (by a hook, or by a
|
|
2771
|
+
// test of the parser) it stays inert.
|
|
2772
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
2773
|
+
process.exitCode = await run();
|
|
2774
|
+
}
|