superdev-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
// The front door: initialization, adoption, and the plan you see before either
|
|
2
|
+
// one touches anything.
|
|
3
|
+
//
|
|
4
|
+
// Two rules run through this file. Initialization is re-runnable: every step
|
|
5
|
+
// checks what is already there and reports it instead of writing over it, so
|
|
6
|
+
// running `init` twice is a report rather than a reset. And a repository that
|
|
7
|
+
// already has documentation is never initialized over, it is adopted, because
|
|
8
|
+
// the one thing this tool must never do is become a second writable copy of
|
|
9
|
+
// somebody's existing docs.
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import { ensureDatabase, mutate, paths, query, create, patch, setStatus, recordActivity } from "../db/store.mjs";
|
|
14
|
+
import { slugify, uniqueSlug } from "../model/ids.mjs";
|
|
15
|
+
import { sanitizeExternal } from "../model/screening.mjs";
|
|
16
|
+
import { CAPABILITY_AREAS, SYSTEM_TASK_CATEGORIES, count } from "../model/vocabulary.mjs";
|
|
17
|
+
import { buildConceptMap, detectProjectKind, ingestSources, inspectEvidence, titleOf } from "./discovery.mjs";
|
|
18
|
+
import {
|
|
19
|
+
MATERIAL_AREAS,
|
|
20
|
+
STACK_SLOTS,
|
|
21
|
+
materialQuestions,
|
|
22
|
+
questionCatalog,
|
|
23
|
+
seedCapabilityAreas,
|
|
24
|
+
seedModuleCompleteness,
|
|
25
|
+
seedTaskCategories,
|
|
26
|
+
} from "./questions.mjs";
|
|
27
|
+
|
|
28
|
+
export class InitError extends Error {
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InitError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Brief 8.1. The plan prints these, and `applyInit` reports against them. */
|
|
37
|
+
const STEPS = [
|
|
38
|
+
"Detect new project versus existing project",
|
|
39
|
+
"Inspect the repository before asking anything",
|
|
40
|
+
"Check specialist provider readiness",
|
|
41
|
+
"Start brainstorming when it is available",
|
|
42
|
+
"Create a discovery session and immutable source records",
|
|
43
|
+
"Build the initial concept map",
|
|
44
|
+
"Ask the smallest set of material questions",
|
|
45
|
+
"Recommend a default for every question",
|
|
46
|
+
"Accept an honest \"I do not know\" as a reversible assumption",
|
|
47
|
+
"Produce a high-level plan before creating implementation tasks",
|
|
48
|
+
"Generate draft foundations, modules, goals, milestones and feature candidates",
|
|
49
|
+
"Present the plan and material decisions for acceptance",
|
|
50
|
+
"Store accepted content in the database",
|
|
51
|
+
"Generate the Markdown documentation",
|
|
52
|
+
"Generate implementation tasks from accepted specifications",
|
|
53
|
+
"Start the local control center",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const nowIso = () => new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
const clean = (text) => {
|
|
59
|
+
if (text === null || text === undefined) return null;
|
|
60
|
+
const out = sanitizeExternal(String(text)).trim();
|
|
61
|
+
return out || null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const trim = (text, max) => {
|
|
65
|
+
const value = clean(text);
|
|
66
|
+
if (!value) return null;
|
|
67
|
+
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ------------------------------------------------------------------ planInit
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Exactly what initialization will do, before it does any of it.
|
|
74
|
+
*
|
|
75
|
+
* This writes nothing, opens no database it would have to create, and can be
|
|
76
|
+
* run on a directory that has never seen Superdev. The question wording is real
|
|
77
|
+
* rather than a count, because the whole value of confirming a plan is seeing
|
|
78
|
+
* what you are about to be asked.
|
|
79
|
+
*/
|
|
80
|
+
export async function planInit(root, opts = {}) {
|
|
81
|
+
const at = resolve(root);
|
|
82
|
+
const detection = detectProjectKind(at);
|
|
83
|
+
const evidence = inspectEvidence(at);
|
|
84
|
+
const providers = await checkProviders(at);
|
|
85
|
+
const brainstorming = brainstormingAvailability(at);
|
|
86
|
+
|
|
87
|
+
const existing = await inspectExisting(at);
|
|
88
|
+
const answered = MATERIAL_AREAS.filter((area) => evidence.answers[area]);
|
|
89
|
+
const unanswered = MATERIAL_AREAS.filter((area) => !evidence.answers[area]);
|
|
90
|
+
|
|
91
|
+
// A project that already exists knows which questions are genuinely left; a
|
|
92
|
+
// fresh directory gets the same catalog, predicted from the evidence alone.
|
|
93
|
+
const questions = existing.project
|
|
94
|
+
? await query(at, (db) => materialQuestions(db, existing.project.id))
|
|
95
|
+
: questionCatalog(unanswered);
|
|
96
|
+
|
|
97
|
+
const sources = (opts.sources ?? []).map((path) => ({
|
|
98
|
+
path,
|
|
99
|
+
action: existsSync(resolve(at, path)) ? "record as immutable evidence" : "skip, file not found",
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const warnings = [];
|
|
103
|
+
if (evidence.truncated) {
|
|
104
|
+
warnings.push("The repository is larger than the inspection limit, so the evidence below is a sample rather than a census.");
|
|
105
|
+
}
|
|
106
|
+
if (detection.route === "adopt") {
|
|
107
|
+
warnings.push(`This repository already has documentation (profile ${detection.docsProfile}). Initialization is refused here; adoptProject leaves those files exactly where they are.`);
|
|
108
|
+
}
|
|
109
|
+
if (existing.project) {
|
|
110
|
+
warnings.push(`Project ${existing.project.id} already exists. Initialization will report what is present and create only what is missing.`);
|
|
111
|
+
}
|
|
112
|
+
if (!clean(opts.idea) && !sources.length && !existing.project) {
|
|
113
|
+
warnings.push("No idea and no source material were supplied, so the concept map will consist mostly of explicit unknowns.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const seeds = {
|
|
117
|
+
capabilityAreas: existing.counts.capabilityAreas ? 0 : CAPABILITY_AREAS.length,
|
|
118
|
+
stackSlots: existing.counts.stackSlots ? 0 : STACK_SLOTS.length,
|
|
119
|
+
taskCategories: existing.counts.taskCategories ? 0 : SYSTEM_TASK_CATEGORIES.length,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const actions = STEPS.map((name, index) => planStep(index + 1, name, {
|
|
123
|
+
detection, evidence, providers, brainstorming, existing, questions, sources, seeds, opts,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
root: at,
|
|
128
|
+
at: opts.at ?? nowIso(),
|
|
129
|
+
route: detection.route,
|
|
130
|
+
kind: detection.kind,
|
|
131
|
+
detection,
|
|
132
|
+
evidence: {
|
|
133
|
+
answers: evidence.answers,
|
|
134
|
+
findings: evidence.findings,
|
|
135
|
+
context: evidence.context,
|
|
136
|
+
stacks: evidence.stacks,
|
|
137
|
+
truncated: evidence.truncated,
|
|
138
|
+
counts: evidence.counts ?? { files: 0, directories: 0 },
|
|
139
|
+
},
|
|
140
|
+
providers,
|
|
141
|
+
brainstorming,
|
|
142
|
+
existing,
|
|
143
|
+
answeredByEvidence: answered.map((area) => ({ area, ...evidence.answers[area] })),
|
|
144
|
+
questions,
|
|
145
|
+
sources,
|
|
146
|
+
creates: seeds,
|
|
147
|
+
steps: actions,
|
|
148
|
+
warnings,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function planStep(step, name, ctx) {
|
|
153
|
+
const { detection, evidence, providers, brainstorming, existing, questions, sources, seeds, opts } = ctx;
|
|
154
|
+
const done = (detail) => ({ step, name, action: "already done", detail });
|
|
155
|
+
const willDo = (detail) => ({ step, name, action: "create", detail });
|
|
156
|
+
const skip = (detail) => ({ step, name, action: "skip", detail });
|
|
157
|
+
|
|
158
|
+
switch (step) {
|
|
159
|
+
case 1: return done(`${detection.kind}, documentation profile ${detection.docsProfile}, route ${detection.route}`);
|
|
160
|
+
case 2: return done(`${count(evidence.findings.length, "finding")} over ${count(evidence.counts?.files ?? 0, "file")}; ${count(Object.keys(evidence.answers).length, "capability area")} already answered`);
|
|
161
|
+
case 3: return providers.checked
|
|
162
|
+
? done(`${count(providers.ready.length, "provider")} ready, ${providers.missing.length} not installed; nothing will be installed`)
|
|
163
|
+
: skip(providers.reason);
|
|
164
|
+
case 4: return brainstorming.available
|
|
165
|
+
? willDo("brainstorming is available and will be offered, never started silently")
|
|
166
|
+
: skip(brainstorming.reason);
|
|
167
|
+
case 5: return willDo(`${count(sources.filter((s) => s.action.startsWith("record")).length, "source file")}, plus one discovery session`);
|
|
168
|
+
case 6: return willDo(clean(opts.idea) || sources.length
|
|
169
|
+
? "concept map from the stated idea, the supplied sources and the repository evidence"
|
|
170
|
+
: "concept map from repository evidence only, with an explicit unknown for every cover nothing answers");
|
|
171
|
+
case 7: return questions.length ? willDo(`${count(questions.length, "material question")}`) : skip("the evidence already answers every material area");
|
|
172
|
+
case 8: return questions.length ? willDo("every question carries a recommended default, alternatives and its deferral consequence") : skip("no questions to recommend defaults for");
|
|
173
|
+
case 9: return done("an unknown answer is recorded as a reversible assumption and the question stays open");
|
|
174
|
+
case 10: return done("this plan");
|
|
175
|
+
case 11: return willDo("goals, modules and features from accepted concept-map candidates, plus one draft milestone when goals exist");
|
|
176
|
+
case 12: return willDo("this plan is the presentation; applyInit stores nothing until it is called");
|
|
177
|
+
case 13: return existing.project ? done(`project ${existing.project.id} exists; only missing records are created`) : willDo(`seed ${count(seeds.capabilityAreas, "capability area")}, ${count(seeds.stackSlots, "stack slot")}, ${count(seeds.taskCategories, "task category", "task categories")}`);
|
|
178
|
+
case 14: return opts.generateDocs === false ? skip("documentation generation was turned off for this run") : willDo("Markdown generated from accepted database content only");
|
|
179
|
+
case 15: return opts.deriveTasks === false ? skip("task derivation was turned off for this run") : willDo("implementation tasks derived from accepted specifications");
|
|
180
|
+
default: return typeof opts.startControlCenter === "function"
|
|
181
|
+
? willDo("the caller supplied a control-center starter and it will be invoked")
|
|
182
|
+
: skip("init does not hold a long-lived process open; start the control center separately");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What is already on disk and in the database. Read only, never creates. */
|
|
187
|
+
async function inspectExisting(root) {
|
|
188
|
+
const p = paths(root);
|
|
189
|
+
const out = {
|
|
190
|
+
database: existsSync(p.db),
|
|
191
|
+
adapter: existsSync(join(root, "talks", "project.yaml")),
|
|
192
|
+
gitignored: gitignoresSuperdev(root),
|
|
193
|
+
project: null,
|
|
194
|
+
counts: { capabilityAreas: 0, stackSlots: 0, taskCategories: 0, discoveryItems: 0, questions: 0, sources: 0, modules: 0, features: 0, goals: 0, tasks: 0, documents: 0 },
|
|
195
|
+
};
|
|
196
|
+
if (!out.database) return out;
|
|
197
|
+
|
|
198
|
+
return query(root, async (db) => {
|
|
199
|
+
const project = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
200
|
+
if (!project) return out;
|
|
201
|
+
const count = async (sql, ...params) => Number((await db.value(sql, ...params)) ?? 0);
|
|
202
|
+
out.project = project;
|
|
203
|
+
out.counts = {
|
|
204
|
+
capabilityAreas: await count("SELECT count(*) FROM capability_areas WHERE project_id = ? AND catalog = 'readiness'", project.id),
|
|
205
|
+
stackSlots: await count("SELECT count(*) FROM capability_areas WHERE project_id = ? AND catalog = 'stack_slot'", project.id),
|
|
206
|
+
taskCategories: await count("SELECT count(*) FROM task_categories WHERE project_id = ?", project.id),
|
|
207
|
+
discoveryItems: await count("SELECT count(*) FROM discovery_items WHERE project_id = ?", project.id),
|
|
208
|
+
questions: await count("SELECT count(*) FROM questions WHERE project_id = ?", project.id),
|
|
209
|
+
sources: await count("SELECT count(*) FROM source_material WHERE project_id = ?", project.id),
|
|
210
|
+
modules: await count("SELECT count(*) FROM modules WHERE project_id = ?", project.id),
|
|
211
|
+
features: await count("SELECT count(*) FROM features WHERE project_id = ?", project.id),
|
|
212
|
+
goals: await count("SELECT count(*) FROM goals WHERE project_id = ?", project.id),
|
|
213
|
+
tasks: await count("SELECT count(*) FROM tasks WHERE project_id = ?", project.id),
|
|
214
|
+
documents: await count("SELECT count(*) FROM documents WHERE project_id = ?", project.id),
|
|
215
|
+
};
|
|
216
|
+
return out;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Which specialist providers are installed. Nothing is ever installed here: an
|
|
222
|
+
* absent provider is reported as absent so the person decides, which is brief
|
|
223
|
+
* 8.1 step 3 read literally.
|
|
224
|
+
*/
|
|
225
|
+
async function checkProviders(root) {
|
|
226
|
+
try {
|
|
227
|
+
const { detectAll } = await import("../../scripts/providers/detect.mjs");
|
|
228
|
+
const report = detectAll({ rootReal: resolve(root) });
|
|
229
|
+
const rows = report?.providers ?? [];
|
|
230
|
+
return {
|
|
231
|
+
checked: true,
|
|
232
|
+
installed: false,
|
|
233
|
+
ready: rows.filter((r) => r.ready).map((r) => r.id),
|
|
234
|
+
missing: rows.filter((r) => !r.ready).map((r) => r.id),
|
|
235
|
+
harness: report?.harness ?? null,
|
|
236
|
+
reason: "nothing was installed; an absent provider stays absent until you ask for it",
|
|
237
|
+
};
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return { checked: false, ready: [], missing: [], reason: `provider readiness could not be determined: ${clean(err.message) ?? "unknown error"}` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Superpowers brainstorming, if this installation has it. Never auto-started. */
|
|
244
|
+
function brainstormingAvailability(root) {
|
|
245
|
+
const candidates = [
|
|
246
|
+
join(root, ".claude", "skills", "brainstorming"),
|
|
247
|
+
join(root, "skills", "brainstorming"),
|
|
248
|
+
];
|
|
249
|
+
const found = candidates.find((path) => existsSync(path));
|
|
250
|
+
return found
|
|
251
|
+
? { available: true, path: found.replace(resolve(root), "."), note: "Offered, never started on your behalf." }
|
|
252
|
+
: { available: false, reason: "the brainstorming skill is not installed here, so discovery proceeds from evidence and questions alone" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ----------------------------------------------------------------- applyInit
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Run brief 8.1 steps 1 to 16 and report what actually happened.
|
|
259
|
+
*
|
|
260
|
+
* Every step is guarded by an existence check, so a second run creates nothing
|
|
261
|
+
* and returns the same shape describing what was already there.
|
|
262
|
+
*/
|
|
263
|
+
export async function applyInit(root, opts = {}) {
|
|
264
|
+
const at = resolve(root);
|
|
265
|
+
const stamp = opts.at ?? nowIso();
|
|
266
|
+
const actor = clean(opts.actor) ?? "superdev";
|
|
267
|
+
const plan = await planInit(at, opts);
|
|
268
|
+
const happened = [];
|
|
269
|
+
const record = (step, outcome, detail) => happened.push({ step, name: STEPS[step - 1], outcome, detail });
|
|
270
|
+
|
|
271
|
+
if (plan.route === "adopt" && opts.adopt !== true) {
|
|
272
|
+
throw new InitError(
|
|
273
|
+
"E_EXISTING_DOCS",
|
|
274
|
+
`This repository already documents itself with the ${plan.detection.docsProfile} profile, detected at ${plan.detection.docsRoot ?? "docs"}. Initializing here would create a second source of truth for the same facts. Run superdev adopt instead, which writes only the adapter and the control layer and leaves every existing file untouched. If those documents are input rather than a projection, for example a requirements document you are initializing from, pass --adopt to say so and init will proceed.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
record(1, "done", `${plan.detection.kind}, route ${plan.detection.route}`);
|
|
279
|
+
record(2, "done", `${count(plan.evidence.findings.length, "finding")}; ${count(Object.keys(plan.evidence.answers).length, "capability area")} answered by the repository`);
|
|
280
|
+
|
|
281
|
+
// Detection already ran while building the plan; running it again would only
|
|
282
|
+
// risk reporting something different from what was confirmed.
|
|
283
|
+
const providers = plan.providers;
|
|
284
|
+
record(3, providers.checked ? "done" : "skipped", providers.checked ? `${providers.ready.length} ready, ${providers.missing.length} absent, none installed` : providers.reason);
|
|
285
|
+
record(4, plan.brainstorming.available ? "available" : "skipped", plan.brainstorming.available ? plan.brainstorming.note : plan.brainstorming.reason);
|
|
286
|
+
|
|
287
|
+
// Step 13 in schedule order: the database and the project row have to exist
|
|
288
|
+
// before anything else can hang off them.
|
|
289
|
+
const migration = await ensureDatabase(at, { apply: true });
|
|
290
|
+
const evidence = plan.evidence;
|
|
291
|
+
|
|
292
|
+
const created = await mutate(at, async (db) => {
|
|
293
|
+
const existing = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
294
|
+
const project = existing ?? await createProject(db, at, opts, stamp, actor);
|
|
295
|
+
const areas = await seedCapabilityAreas(db, project.id, { evidence, at: stamp, actor, owner: opts.owner });
|
|
296
|
+
const categories = await seedTaskCategories(db, project.id, { actor });
|
|
297
|
+
return { project, reused: Boolean(existing), areas, categories };
|
|
298
|
+
});
|
|
299
|
+
record(13, created.reused ? "already existed" : "done",
|
|
300
|
+
created.reused
|
|
301
|
+
? `project ${created.project.id} was already initialized; ${count(created.areas.areas.length, "area")} and ${count(created.categories.created.length, "category", "categories")} added`
|
|
302
|
+
: `project ${created.project.id}, ${count(created.areas.areas.length, "capability area")}, ${count(created.areas.stackSlots.length, "stack slot")}, ${created.categories.created.length} task categories`);
|
|
303
|
+
|
|
304
|
+
// Step 5. The session shells out to git, so it runs in its own transaction.
|
|
305
|
+
const started = await runQuietly(async () => {
|
|
306
|
+
const { startSession } = await import("../runtime/session.mjs");
|
|
307
|
+
return startSession(at, { objective: "Product discovery", actor, at: stamp });
|
|
308
|
+
}, "the discovery session could not be started");
|
|
309
|
+
const session = started.error ? null : started;
|
|
310
|
+
const ingested = await ingestSources(at, opts.sources ?? [], { projectId: created.project.id, actor, at: stamp });
|
|
311
|
+
record(5, session ? "done" : "partial",
|
|
312
|
+
`${session ? `session ${session.id}` : started.error}; ${count(ingested.ingested.length, "source")} recorded, ${ingested.alreadyPresent.length} already present, ${ingested.skipped.length} skipped`);
|
|
313
|
+
|
|
314
|
+
// Step 6.
|
|
315
|
+
const conceptMap = await buildConceptMap(at, {
|
|
316
|
+
idea: opts.idea,
|
|
317
|
+
sources: [...ingested.ingested, ...ingested.alreadyPresent],
|
|
318
|
+
evidence,
|
|
319
|
+
actor,
|
|
320
|
+
at: stamp,
|
|
321
|
+
});
|
|
322
|
+
record(6, "done", `${count(conceptMap.created.length, "discovery item")}, ${count(conceptMap.unknowns, "explicit unknown")}, ${conceptMap.duplicates} already present`);
|
|
323
|
+
|
|
324
|
+
// Steps 7 to 9.
|
|
325
|
+
const questions = await mutate(at, async (db) => {
|
|
326
|
+
const descriptors = await materialQuestions(db, created.project.id);
|
|
327
|
+
const rows = [];
|
|
328
|
+
for (const descriptor of descriptors) {
|
|
329
|
+
const row = await create(db, "question", {
|
|
330
|
+
project_id: created.project.id,
|
|
331
|
+
scope_type: descriptor.scopeType,
|
|
332
|
+
scope_id: descriptor.scopeId,
|
|
333
|
+
question: descriptor.question,
|
|
334
|
+
why_it_matters: descriptor.whyItMatters,
|
|
335
|
+
recommendation: descriptor.recommendation,
|
|
336
|
+
alternatives_json: JSON.stringify(descriptor.alternatives ?? []),
|
|
337
|
+
status: "open",
|
|
338
|
+
created_at: stamp,
|
|
339
|
+
}, { projectId: created.project.id, activity: false });
|
|
340
|
+
// The area points at its question so readiness can tell an owned gap from
|
|
341
|
+
// a silent one, which is the whole test in brief 8.2.
|
|
342
|
+
if (descriptor.areaId) {
|
|
343
|
+
const area = await db.get("SELECT * FROM capability_areas WHERE id = ?", descriptor.areaId);
|
|
344
|
+
if (area) {
|
|
345
|
+
await patch(db, "capability_area", area.id, area.version, { question_id: row.id }, {
|
|
346
|
+
projectId: created.project.id, activity: false, at: stamp,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
rows.push(row);
|
|
351
|
+
}
|
|
352
|
+
if (rows.length) {
|
|
353
|
+
await recordActivity(db, created.project.id, {
|
|
354
|
+
type: "specification_changed",
|
|
355
|
+
summary: `Raised ${count(rows.length, "material question")} for the project owner`,
|
|
356
|
+
actor,
|
|
357
|
+
metadata: { questions: rows.map((r) => r.id) },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return rows;
|
|
361
|
+
});
|
|
362
|
+
record(7, questions.length ? "done" : "skipped", questions.length ? `${count(questions.length, "question")} raised: ${questions.map((q) => q.id).join(", ")}` : "every material area is answered by the repository or already asked");
|
|
363
|
+
record(8, "done", "each question carries why it matters, a recommended default, alternatives and its deferral consequence");
|
|
364
|
+
record(9, "ready", "answerQuestion records an unknown answer as a reversible assumption and leaves the question open");
|
|
365
|
+
record(10, "done", "the plan returned by planInit was produced before any of this ran");
|
|
366
|
+
|
|
367
|
+
// Step 11 and 12: the draft product, only from what the concept map holds.
|
|
368
|
+
const draft = await draftProduct(at, created.project, { actor, at: stamp, milestones: opts.milestones });
|
|
369
|
+
record(11, draft.created ? "done" : "skipped", draft.detail);
|
|
370
|
+
record(12, "done", "planInit is the presentation step; calling applyInit is the acceptance");
|
|
371
|
+
|
|
372
|
+
// Step 14 and 15 run outside every transaction: both open their own.
|
|
373
|
+
// `notRun` rather than `skipped`: both generate and deriveAll return their own
|
|
374
|
+
// `skipped` collection, and an empty array is still truthy.
|
|
375
|
+
let documents = { notRun: "documentation generation was turned off for this run" };
|
|
376
|
+
if (opts.generateDocs !== false) {
|
|
377
|
+
documents = await runQuietly(async () => {
|
|
378
|
+
const { generate } = await import("../docs/render.mjs");
|
|
379
|
+
return generate(at, { apply: true });
|
|
380
|
+
}, "documentation could not be generated");
|
|
381
|
+
}
|
|
382
|
+
record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
|
|
383
|
+
|
|
384
|
+
let tasks = { notRun: "task derivation was turned off for this run" };
|
|
385
|
+
if (opts.deriveTasks !== false) {
|
|
386
|
+
tasks = await runQuietly(async () => {
|
|
387
|
+
const { deriveAll } = await import("../tasks/derive.mjs");
|
|
388
|
+
return deriveAll(at, { apply: true, actor });
|
|
389
|
+
}, "tasks could not be derived");
|
|
390
|
+
}
|
|
391
|
+
record(15, outcomeOf(tasks), tasks.error ?? tasks.notRun ?? summarize(tasks));
|
|
392
|
+
|
|
393
|
+
// Step 16. A function that returns cannot own a listening socket, so the
|
|
394
|
+
// control center is started by whoever called this, not from inside it.
|
|
395
|
+
let controlCenter = { started: false, reason: "init does not hold a process open; start the control center separately" };
|
|
396
|
+
if (typeof opts.startControlCenter === "function") {
|
|
397
|
+
const result = await runQuietly(() => opts.startControlCenter(at), "the control center could not be started");
|
|
398
|
+
controlCenter = result.error ? { started: false, reason: result.error } : { started: true, detail: result };
|
|
399
|
+
}
|
|
400
|
+
record(16, controlCenter.started ? "done" : "deferred", controlCenter.reason ?? "the supplied starter returned");
|
|
401
|
+
|
|
402
|
+
await mutate(at, (db) => recordActivity(db, created.project.id, {
|
|
403
|
+
type: "project_initialized",
|
|
404
|
+
summary: created.reused
|
|
405
|
+
? `Re-ran initialization for ${created.project.name}; existing records were reported, not replaced`
|
|
406
|
+
: `Initialized ${created.project.name}`,
|
|
407
|
+
actor,
|
|
408
|
+
metadata: { route: plan.route, questions: questions.length, discoveryItems: conceptMap.created.length, reused: created.reused },
|
|
409
|
+
}));
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
root: at,
|
|
413
|
+
project: created.project,
|
|
414
|
+
reused: created.reused,
|
|
415
|
+
migration,
|
|
416
|
+
plan,
|
|
417
|
+
// Recorded in execution order, reported in brief order, because the two
|
|
418
|
+
// differ: the database has to exist before step 5 can write to it.
|
|
419
|
+
steps: happened.slice().sort((a, b) => a.step - b.step),
|
|
420
|
+
session,
|
|
421
|
+
sources: ingested,
|
|
422
|
+
conceptMap,
|
|
423
|
+
questions,
|
|
424
|
+
capabilityAreas: created.areas,
|
|
425
|
+
taskCategories: created.categories,
|
|
426
|
+
draft,
|
|
427
|
+
documents,
|
|
428
|
+
tasks,
|
|
429
|
+
controlCenter,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function createProject(db, root, opts, at, actor) {
|
|
434
|
+
// What the owner said it is called, then what the brief calls itself, and
|
|
435
|
+
// only then the directory. A document that opens with the product's name has
|
|
436
|
+
// already answered this, and the directory it was initialized in usually has
|
|
437
|
+
// not: a brief titled "Kitchen Handover" in a folder called `journey`
|
|
438
|
+
// produced a product named Journey.
|
|
439
|
+
const name = clean(opts.name) ?? briefTitle(root, opts.sources ?? []) ?? titleFrom(basename(root));
|
|
440
|
+
const base = slugify(name, "project");
|
|
441
|
+
let slug = base;
|
|
442
|
+
for (let n = 2; await db.get("SELECT 1 FROM projects WHERE slug = ?", slug); n++) slug = `${base}-${n}`;
|
|
443
|
+
|
|
444
|
+
return create(db, "project", {
|
|
445
|
+
name,
|
|
446
|
+
slug,
|
|
447
|
+
// The idea, as stated, is the problem this exists to solve. The success
|
|
448
|
+
// statement is deliberately left empty so the material question still asks.
|
|
449
|
+
problem: trim(opts.idea, 2000),
|
|
450
|
+
statement: trim(opts.statement, 2000),
|
|
451
|
+
status: "draft",
|
|
452
|
+
working_mode: clean(opts.workingMode) ?? "guided",
|
|
453
|
+
docs_profile: clean(opts.docsProfile) ?? "talks-v1",
|
|
454
|
+
local_authority: 1,
|
|
455
|
+
created_at: at,
|
|
456
|
+
updated_at: at,
|
|
457
|
+
}, { activity: false, actor });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The first heading of the first readable brief, or nothing. */
|
|
461
|
+
function briefTitle(root, sources) {
|
|
462
|
+
for (const raw of sources) {
|
|
463
|
+
const path = isAbsolute(raw) ? raw : join(root, raw);
|
|
464
|
+
let text;
|
|
465
|
+
try {
|
|
466
|
+
text = readFileSync(path, "utf8");
|
|
467
|
+
} catch {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const title = titleOf(text);
|
|
471
|
+
// A heading long enough to be a sentence is describing the product, not
|
|
472
|
+
// naming it, so the directory remains the better answer.
|
|
473
|
+
if (title && title.length <= 60) return clean(title);
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const titleFrom = (name) =>
|
|
479
|
+
String(name).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || "Project";
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Turn accepted concept-map candidates into goals, modules and features.
|
|
483
|
+
*
|
|
484
|
+
* Only candidates are converted. Nothing is invented to fill a shape: a project
|
|
485
|
+
* with no goal candidates gets no goals, and the gap stays visible as the
|
|
486
|
+
* unknown the concept map already wrote down.
|
|
487
|
+
*/
|
|
488
|
+
async function draftProduct(root, project, { actor, at, milestones }) {
|
|
489
|
+
const outcome = await mutate(root, async (db) => {
|
|
490
|
+
const items = await db.all(
|
|
491
|
+
"SELECT * FROM discovery_items WHERE project_id = ? AND status = 'proposed' ORDER BY created_at, id",
|
|
492
|
+
project.id,
|
|
493
|
+
);
|
|
494
|
+
const moduleCandidates = items.filter((i) => i.kind === "feature_candidate" && i.statement.startsWith("Module candidate:"));
|
|
495
|
+
const featureCandidates = items.filter((i) => i.kind === "feature_candidate" && !i.statement.startsWith("Module candidate:"));
|
|
496
|
+
const goalCandidates = items.filter((i) => i.kind === "goal_candidate");
|
|
497
|
+
|
|
498
|
+
const goals = [];
|
|
499
|
+
for (const [index, item] of goalCandidates.entries()) {
|
|
500
|
+
const name = trim(item.statement, 120);
|
|
501
|
+
if (!name || await db.get("SELECT 1 FROM goals WHERE project_id = ? AND name = ?", project.id, name)) continue;
|
|
502
|
+
const goal = await create(db, "goal", {
|
|
503
|
+
project_id: project.id,
|
|
504
|
+
name,
|
|
505
|
+
description: item.statement,
|
|
506
|
+
why_it_matters: item.provenance,
|
|
507
|
+
status: "draft",
|
|
508
|
+
sequence: index,
|
|
509
|
+
}, { projectId: project.id, activity: false });
|
|
510
|
+
goals.push(goal);
|
|
511
|
+
await convert(db, item, "goal", goal.id, at);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const modules = [];
|
|
515
|
+
for (const [index, item] of moduleCandidates.entries()) {
|
|
516
|
+
const name = trim(item.statement.replace(/^Module candidate:\s*/, ""), 120);
|
|
517
|
+
if (!name) continue;
|
|
518
|
+
const module = await createModule(db, project, name, item.statement, index, at);
|
|
519
|
+
if (!module) continue;
|
|
520
|
+
modules.push(module);
|
|
521
|
+
await convert(db, item, "module", module.id, at);
|
|
522
|
+
}
|
|
523
|
+
// Features cannot exist without a module, so one home module is created
|
|
524
|
+
// when candidates exist and nothing named a module.
|
|
525
|
+
if (!modules.length && featureCandidates.length) {
|
|
526
|
+
const module = await createModule(db, project, project.name, `Everything ${project.name} does, until the parts are named.`, 0, at);
|
|
527
|
+
if (module) modules.push(module);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const features = [];
|
|
531
|
+
for (const [index, item] of featureCandidates.entries()) {
|
|
532
|
+
const name = trim(item.statement, 120);
|
|
533
|
+
if (!name || !modules.length) continue;
|
|
534
|
+
if (await db.get("SELECT 1 FROM features WHERE project_id = ? AND name = ?", project.id, name)) continue;
|
|
535
|
+
const slug = await uniqueSlug(db, "features", project.id, name, "feature");
|
|
536
|
+
const feature = await create(db, "feature", {
|
|
537
|
+
project_id: project.id,
|
|
538
|
+
module_id: modules[index % modules.length].id,
|
|
539
|
+
name,
|
|
540
|
+
slug,
|
|
541
|
+
purpose: item.statement,
|
|
542
|
+
user_statement: item.provenance,
|
|
543
|
+
spec_depth: "microspec",
|
|
544
|
+
risk_level: "R1",
|
|
545
|
+
status: "draft",
|
|
546
|
+
created_at: at,
|
|
547
|
+
updated_at: at,
|
|
548
|
+
}, { projectId: project.id, activity: false });
|
|
549
|
+
features.push(feature);
|
|
550
|
+
await convert(db, item, "feature", feature.id, at);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const created = [];
|
|
554
|
+
for (const [index, supplied] of (milestones ?? []).entries()) {
|
|
555
|
+
created.push(await createMilestone(db, project, clean(supplied.name), clean(supplied.outcome), index, at));
|
|
556
|
+
}
|
|
557
|
+
// One draft milestone when goals exist and none was supplied: a goal with
|
|
558
|
+
// nothing to reach it by is the shape this rebuild exists to avoid. It is
|
|
559
|
+
// marked draft precisely because nobody has agreed to it yet.
|
|
560
|
+
if (!created.length && goals.length) {
|
|
561
|
+
const milestone = await createMilestone(db, project, "First outcome", `The project can demonstrate: ${goals[0].name}`, 0, at);
|
|
562
|
+
if (milestone) created.push(milestone);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const module of modules) await seedModuleCompleteness(db, module.id, { at });
|
|
566
|
+
|
|
567
|
+
if (goals.length || modules.length || features.length || created.length) {
|
|
568
|
+
await recordActivity(db, project.id, {
|
|
569
|
+
type: "specification_changed",
|
|
570
|
+
summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")} and ${count(created.filter(Boolean).length, "milestone")} from the concept map`,
|
|
571
|
+
actor,
|
|
572
|
+
metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return { goals, modules, features, milestones: created.filter(Boolean) };
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const total = outcome.goals.length + outcome.modules.length + outcome.features.length;
|
|
580
|
+
return {
|
|
581
|
+
...outcome,
|
|
582
|
+
created: total > 0,
|
|
583
|
+
detail: total
|
|
584
|
+
? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}`
|
|
585
|
+
: "the concept map held no candidates to convert, so nothing was invented to fill the gap",
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function createModule(db, project, name, purpose, sequence, at) {
|
|
590
|
+
const moduleName = trim(name, 120);
|
|
591
|
+
if (!moduleName) return null;
|
|
592
|
+
if (await db.get("SELECT 1 FROM modules WHERE project_id = ? AND name = ?", project.id, moduleName)) return null;
|
|
593
|
+
return create(db, "module", {
|
|
594
|
+
project_id: project.id,
|
|
595
|
+
name: moduleName,
|
|
596
|
+
slug: await uniqueSlug(db, "modules", project.id, moduleName, "module"),
|
|
597
|
+
purpose: trim(purpose, 400),
|
|
598
|
+
status: "planned",
|
|
599
|
+
sequence,
|
|
600
|
+
created_at: at,
|
|
601
|
+
updated_at: at,
|
|
602
|
+
}, { projectId: project.id, activity: false });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function createMilestone(db, project, name, outcome, sequence, at) {
|
|
606
|
+
if (!name) return null;
|
|
607
|
+
if (await db.get("SELECT 1 FROM milestones WHERE project_id = ? AND name = ?", project.id, name)) return null;
|
|
608
|
+
return create(db, "milestone", {
|
|
609
|
+
project_id: project.id,
|
|
610
|
+
name,
|
|
611
|
+
outcome: trim(outcome, 400),
|
|
612
|
+
status: "draft",
|
|
613
|
+
sequence,
|
|
614
|
+
created_at: at,
|
|
615
|
+
updated_at: at,
|
|
616
|
+
}, { projectId: project.id, activity: false });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A converted candidate keeps its provenance and stops being proposed. */
|
|
620
|
+
async function convert(db, item, type, id, at) {
|
|
621
|
+
const moved = await setStatus(db, "discovery_item", item.id, "converted", {
|
|
622
|
+
projectId: item.project_id, activity: false, at, note: `converted to ${type} ${id}`,
|
|
623
|
+
});
|
|
624
|
+
await patch(db, "discovery_item", item.id, moved.version, {
|
|
625
|
+
converted_type: type, converted_id: id, accepted_at: at,
|
|
626
|
+
}, { projectId: item.project_id, activity: false, at });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function runQuietly(fn, message) {
|
|
630
|
+
try {
|
|
631
|
+
return await fn();
|
|
632
|
+
} catch (err) {
|
|
633
|
+
// A failure here is reported, never thrown: documentation and task
|
|
634
|
+
// derivation are downstream of a project that has already been created,
|
|
635
|
+
// and losing the whole initialization to a render bug helps nobody.
|
|
636
|
+
return { error: `${message}: ${clean(err.message) ?? "unknown error"}` };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const outcomeOf = (result) => (result.error ? "failed" : result.notRun ? "skipped" : "done");
|
|
641
|
+
|
|
642
|
+
const summarize = (result) => {
|
|
643
|
+
if (!result || typeof result !== "object") return "done";
|
|
644
|
+
const counts = Object.entries(result)
|
|
645
|
+
.filter(([, value]) => Array.isArray(value))
|
|
646
|
+
.map(([key, value]) => `${value.length} ${key}`);
|
|
647
|
+
return counts.length ? counts.join(", ") : "done";
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// -------------------------------------------------------------- adoptProject
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Adopt a repository that already documents itself.
|
|
654
|
+
*
|
|
655
|
+
* Adoption writes exactly two things: the adapter that records where the
|
|
656
|
+
* documentation already lives, and the control layer under `.superdev/`. No
|
|
657
|
+
* existing file is moved, rewritten, copied into the database, or registered as
|
|
658
|
+
* a generated document, because any of those would create a second editable
|
|
659
|
+
* copy of a fact the repository already owns.
|
|
660
|
+
*/
|
|
661
|
+
export async function adoptProject(root, { apply = false, ...opts } = {}) {
|
|
662
|
+
const at = resolve(root);
|
|
663
|
+
const stamp = opts.at ?? nowIso();
|
|
664
|
+
const actor = clean(opts.actor) ?? "superdev";
|
|
665
|
+
const detection = detectProjectKind(at);
|
|
666
|
+
const evidence = inspectEvidence(at);
|
|
667
|
+
const existing = await inspectExisting(at);
|
|
668
|
+
|
|
669
|
+
const adapterPath = join("talks", "project.yaml");
|
|
670
|
+
const name = clean(opts.name) ?? clean(detection.adapter?.project?.name) ?? titleFrom(basename(at));
|
|
671
|
+
|
|
672
|
+
const actions = [
|
|
673
|
+
{ what: "database and control layer under .superdev/", action: existing.database ? "already present" : "create" },
|
|
674
|
+
{ what: adapterPath, action: existing.adapter ? "leave exactly as it is" : "create" },
|
|
675
|
+
{ what: ".gitignore entry for .superdev/", action: existing.gitignored ? "already present" : "append one line" },
|
|
676
|
+
{ what: "project record", action: existing.project ? `already present as ${existing.project.id}` : "create" },
|
|
677
|
+
{ what: "capability areas and stack slots", action: existing.counts.capabilityAreas ? "already present" : "seed" },
|
|
678
|
+
{ what: "task categories", action: existing.counts.taskCategories ? "already present" : "seed" },
|
|
679
|
+
];
|
|
680
|
+
|
|
681
|
+
const refusals = [
|
|
682
|
+
"Existing documentation is not moved, renamed, rewritten or copied into the database.",
|
|
683
|
+
"Existing documents are not registered as generated documents, so nothing here can later be overwritten by a render.",
|
|
684
|
+
"No Markdown is generated by adoption. The repository keeps the documentation it already has.",
|
|
685
|
+
];
|
|
686
|
+
|
|
687
|
+
const plan = {
|
|
688
|
+
root: at,
|
|
689
|
+
at: stamp,
|
|
690
|
+
apply,
|
|
691
|
+
name,
|
|
692
|
+
detection,
|
|
693
|
+
evidence: { answers: evidence.answers, context: evidence.context, stacks: evidence.stacks },
|
|
694
|
+
existing,
|
|
695
|
+
actions,
|
|
696
|
+
refusals,
|
|
697
|
+
warnings: detection.docsProfile === "unknown"
|
|
698
|
+
? ["The documentation profile could not be detected, so the adapter will record it as custom and nothing will be assumed about the layout."]
|
|
699
|
+
: [],
|
|
700
|
+
};
|
|
701
|
+
if (!apply) return plan;
|
|
702
|
+
|
|
703
|
+
const migration = await ensureDatabase(at, { apply: true });
|
|
704
|
+
const profile = detection.docsProfile === "unknown" || detection.docsProfile === "none" ? "custom" : detection.docsProfile;
|
|
705
|
+
|
|
706
|
+
const result = await mutate(at, async (db) => {
|
|
707
|
+
const found = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
708
|
+
const project = found ?? await createProject(db, at, { ...opts, name, docsProfile: profile }, stamp, actor);
|
|
709
|
+
const areas = await seedCapabilityAreas(db, project.id, { evidence, at: stamp, actor, owner: opts.owner });
|
|
710
|
+
const categories = await seedTaskCategories(db, project.id, { actor });
|
|
711
|
+
await recordActivity(db, project.id, {
|
|
712
|
+
type: "project_initialized",
|
|
713
|
+
summary: found
|
|
714
|
+
? `Re-ran adoption for ${project.name}; existing records were reported, not replaced`
|
|
715
|
+
: `Adopted ${project.name} with the ${profile} documentation profile`,
|
|
716
|
+
actor,
|
|
717
|
+
metadata: { profile, docsRoot: detection.docsRoot, reused: Boolean(found) },
|
|
718
|
+
});
|
|
719
|
+
return { project, reused: Boolean(found), areas, categories };
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const wroteAdapter = existing.adapter ? false : writeAdapter(at, { name, profile, docsRoot: detection.docsRoot, at: stamp });
|
|
723
|
+
const wroteIgnore = existing.gitignored ? false : ignoreSuperdev(at);
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
...plan,
|
|
727
|
+
applied: true,
|
|
728
|
+
migration,
|
|
729
|
+
project: result.project,
|
|
730
|
+
reused: result.reused,
|
|
731
|
+
capabilityAreas: result.areas,
|
|
732
|
+
taskCategories: result.categories,
|
|
733
|
+
wrote: [
|
|
734
|
+
...(wroteAdapter ? [adapterPath] : []),
|
|
735
|
+
...(wroteIgnore ? [".gitignore"] : []),
|
|
736
|
+
".superdev/superdev.db",
|
|
737
|
+
],
|
|
738
|
+
untouched: (evidence.context ?? []).map((c) => c.evidence),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** The adapter records where documentation already lives. It never moves it. */
|
|
743
|
+
function writeAdapter(root, { name, profile, docsRoot, at }) {
|
|
744
|
+
const path = join(root, "talks", "project.yaml");
|
|
745
|
+
if (existsSync(path)) return false;
|
|
746
|
+
mkdirSync(join(root, "talks"), { recursive: true });
|
|
747
|
+
const lines = [
|
|
748
|
+
"# Superdev project record configuration",
|
|
749
|
+
"project:",
|
|
750
|
+
` name: "${String(name).replace(/"/g, "'")}"`,
|
|
751
|
+
` created: "${at}"`,
|
|
752
|
+
"docs:",
|
|
753
|
+
` profile: ${profile}`,
|
|
754
|
+
...(docsRoot ? [` canonicalRoot: ${docsRoot}`] : []),
|
|
755
|
+
" migrationStatus: none",
|
|
756
|
+
"",
|
|
757
|
+
];
|
|
758
|
+
writeFileSync(path, lines.join("\n"));
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const IGNORE_LINE = ".superdev/";
|
|
763
|
+
|
|
764
|
+
function gitignoresSuperdev(root) {
|
|
765
|
+
const path = join(root, ".gitignore");
|
|
766
|
+
if (!existsSync(path)) return false;
|
|
767
|
+
try {
|
|
768
|
+
return readFileSync(path, "utf8").split(/\r?\n/).some((line) => line.trim().replace(/\/$/, "") === ".superdev");
|
|
769
|
+
} catch {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** One appended line, never a rewrite of somebody's ignore file. */
|
|
775
|
+
function ignoreSuperdev(root) {
|
|
776
|
+
const path = join(root, ".gitignore");
|
|
777
|
+
if (!existsSync(path)) {
|
|
778
|
+
writeFileSync(path, `${IGNORE_LINE}\n`);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
const current = readFileSync(path, "utf8");
|
|
782
|
+
appendFileSync(path, `${current.endsWith("\n") ? "" : "\n"}${IGNORE_LINE}\n`);
|
|
783
|
+
return true;
|
|
784
|
+
}
|