superdev-cli 0.1.0 → 0.1.2

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.
@@ -0,0 +1,584 @@
1
+ // Adding to the product map after it has been initialized.
2
+ //
3
+ // Every product record could only be created during `init`. After that a project
4
+ // was frozen: no command added a goal, a success criterion, a milestone, an exit
5
+ // condition, a module or a feature, and nothing could move a feature to a
6
+ // different module or milestone.
7
+ //
8
+ // The consequence was not obvious from the command list, because the read side
9
+ // was complete. `goal list` showed goals and `goal show` showed one in full, so
10
+ // the surface looked finished while nothing could write to it. A goal recorded
11
+ // with no success criteria can never be met, and progress counts goal criteria,
12
+ // so a project could carry a goal that permanently reported nothing.
13
+ //
14
+ // The evidence for this being real rather than theoretical is in this project's
15
+ // own activity trail: 2531 specification events by a self-hosting script against
16
+ // 104 by the product's own commands. Its product map was written around the
17
+ // product, because there was no way to write it through the product.
18
+ //
19
+ // Nothing here invents structure. Each function writes one record with the
20
+ // fields its table requires, refuses what the schema would refuse with a sentence
21
+ // instead of a constraint violation, and leaves history behind like every other
22
+ // write.
23
+
24
+ import { create, mutate, patch, query, recordActivity, setStatus, json } from "../db/store.mjs";
25
+ import { slugify, uniqueSlug } from "../model/ids.mjs";
26
+ import { sanitizeExternal } from "../model/screening.mjs";
27
+
28
+ export const E = {
29
+ NOT_FOUND: "E_NOT_FOUND",
30
+ EXISTS: "E_ALREADY_EXISTS",
31
+ REQUIRED: "E_FIELD_REQUIRED",
32
+ NOT_EDITABLE: "E_NOT_EDITABLE",
33
+ };
34
+
35
+ export class AuthoringError extends Error {
36
+ constructor(code, message, detail) {
37
+ super(message);
38
+ this.name = "AuthoringError";
39
+ this.code = code;
40
+ if (detail !== undefined) this.detail = detail;
41
+ }
42
+ }
43
+
44
+ const clean = (value, max = 600) => {
45
+ const text = sanitizeExternal(String(value ?? "")).replace(/\s+/g, " ").trim();
46
+ return text ? text.slice(0, max) : null;
47
+ };
48
+
49
+ const nowIso = () => new Date().toISOString();
50
+
51
+ /** The next sequence for a table scoped to something, so ordering stays stable. */
52
+ async function nextSequence(db, table, column, id) {
53
+ const row = await db.get(`SELECT MAX(sequence) AS n FROM ${table} WHERE ${column} = ?`, id);
54
+ return Number(row?.n ?? -1) + 1;
55
+ }
56
+
57
+ // -------------------------------------------------------------------- goals
58
+
59
+ /**
60
+ * Record a goal: a lasting outcome, not a feature.
61
+ *
62
+ * A goal with no success criteria is counted as unmeasurable rather than as
63
+ * met, which is why this reports what to add next rather than treating the goal
64
+ * as finished business.
65
+ */
66
+ export async function recordGoal(root, { name, why = null, description = null, actor = "superdev", apply = false } = {}) {
67
+ if (!clean(name)) {
68
+ throw new AuthoringError(E.REQUIRED, "A goal needs a name that states the outcome, not the work.");
69
+ }
70
+ const plan = { name: clean(name, 200), why: clean(why), description: clean(description, 1000) };
71
+ if (!apply) return { applied: false, ...plan };
72
+
73
+ return mutate(root, async (db) => {
74
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
75
+ if (!project) throw new AuthoringError(E.NOT_FOUND, "There is no project here yet. Run superdev init first.");
76
+ const existing = await db.get(
77
+ "SELECT id FROM goals WHERE project_id = ? AND name = ?", project.id, plan.name);
78
+ if (existing) {
79
+ throw new AuthoringError(E.EXISTS, `${existing.id} is already called that. Show it with superdev goal show ${existing.id}.`);
80
+ }
81
+ const row = await create(db, "goal", {
82
+ project_id: project.id,
83
+ name: plan.name,
84
+ description: plan.description,
85
+ why_it_matters: plan.why,
86
+ status: "draft",
87
+ sequence: await nextSequence(db, "goals", "project_id", project.id),
88
+ }, {
89
+ projectId: project.id, actor, activityType: "scope_changed",
90
+ activitySummary: clean(`Goal recorded: ${plan.name}`, 200),
91
+ });
92
+ return { applied: true, goal: row, criteria: 0 };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Add a success criterion to a goal, with how it is measured.
98
+ *
99
+ * The measurement is the point. "People find things faster" cannot be met by
100
+ * anything; "the last handover is on screen within ten seconds" can, and the
101
+ * difference decides whether progress means anything.
102
+ */
103
+ export async function addGoalCriterion(root, goalId, { criterion, measurement = null, target = null, actor = "superdev", apply = false } = {}) {
104
+ if (!clean(criterion)) {
105
+ throw new AuthoringError(E.REQUIRED, "Say what has to be true. A criterion nobody can check is not one.");
106
+ }
107
+ const plan = {
108
+ goalId,
109
+ criterion: clean(criterion, 500),
110
+ measurement: clean(measurement, 300),
111
+ target: clean(target, 200),
112
+ };
113
+ if (!apply) return { applied: false, ...plan };
114
+
115
+ return mutate(root, async (db) => {
116
+ const goal = await db.get("SELECT id, project_id, name FROM goals WHERE id = ?", goalId);
117
+ if (!goal) throw new AuthoringError(E.NOT_FOUND, `There is no goal ${goalId}. List them with superdev goal list.`);
118
+ const row = await create(db, "goal_success_criterion", {
119
+ goal_id: goalId,
120
+ criterion: plan.criterion,
121
+ measurement_method: plan.measurement,
122
+ target: plan.target,
123
+ status: "unmet",
124
+ sequence: await nextSequence(db, "goal_success_criteria", "goal_id", goalId),
125
+ }, {
126
+ projectId: goal.project_id, actor, activityType: "scope_changed",
127
+ activitySummary: clean(`Success criterion recorded for ${goal.name}`, 200),
128
+ });
129
+ const count = await db.get(
130
+ "SELECT COUNT(*) AS n FROM goal_success_criteria WHERE goal_id = ?", goalId);
131
+ return { applied: true, criterion: row, goal, total: Number(count.n) };
132
+ });
133
+ }
134
+
135
+ // --------------------------------------------------------------- milestones
136
+
137
+ /** Record a delivery stage. Its exit conditions are what make it reachable. */
138
+ export async function recordMilestone(root, { name, outcome = null, targetDate = null, actor = "superdev", apply = false } = {}) {
139
+ if (!clean(name)) {
140
+ throw new AuthoringError(E.REQUIRED, "A milestone needs a name that says what is delivered by it.");
141
+ }
142
+ const plan = { name: clean(name, 200), outcome: clean(outcome, 1000), targetDate: clean(targetDate, 40) };
143
+ if (!apply) return { applied: false, ...plan };
144
+
145
+ return mutate(root, async (db) => {
146
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
147
+ if (!project) throw new AuthoringError(E.NOT_FOUND, "There is no project here yet. Run superdev init first.");
148
+ const existing = await db.get(
149
+ "SELECT id FROM milestones WHERE project_id = ? AND name = ?", project.id, plan.name);
150
+ if (existing) {
151
+ throw new AuthoringError(E.EXISTS, `${existing.id} is already called that. Show it with superdev milestone show ${existing.id}.`);
152
+ }
153
+ const row = await create(db, "milestone", {
154
+ project_id: project.id,
155
+ name: plan.name,
156
+ outcome: plan.outcome,
157
+ entry_conditions_json: JSON.stringify([]),
158
+ exit_conditions_json: JSON.stringify([]),
159
+ target_date: plan.targetDate,
160
+ status: "planned",
161
+ sequence: await nextSequence(db, "milestones", "project_id", project.id),
162
+ }, {
163
+ projectId: project.id, actor, activityType: "scope_changed",
164
+ activitySummary: clean(`Milestone recorded: ${plan.name}`, 200),
165
+ });
166
+ return { applied: true, milestone: row };
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Add an exit condition to a milestone.
172
+ *
173
+ * Stored as an object rather than a string, because a condition that cannot
174
+ * carry a verdict can never be met, and a milestone whose conditions can never
175
+ * be met is unreachable by construction. That was true of every milestone here
176
+ * once, and it is the shape this refuses to recreate.
177
+ */
178
+ export async function addMilestoneCondition(root, milestoneId, { condition, check = null, entry = false, actor = "superdev", apply = false } = {}) {
179
+ if (!clean(condition)) {
180
+ throw new AuthoringError(E.REQUIRED, "Say what has to hold before this milestone is reached.");
181
+ }
182
+ const column = entry ? "entry_conditions_json" : "exit_conditions_json";
183
+ const kind = entry ? "Entry" : "Exit";
184
+ const plan = { milestoneId, condition: clean(condition, 500), check: clean(check, 200), entry };
185
+ if (!apply) return { applied: false, ...plan };
186
+
187
+ return mutate(root, async (db) => {
188
+ const milestone = await db.get(
189
+ `SELECT id, project_id, name, ${column} AS conditions, version FROM milestones WHERE id = ?`, milestoneId);
190
+ if (!milestone) {
191
+ throw new AuthoringError(E.NOT_FOUND, `There is no milestone ${milestoneId}. List them with superdev milestone list.`);
192
+ }
193
+ const conditions = json(milestone.conditions, []).map((c) =>
194
+ typeof c === "string" ? { condition: c, met: false, reading: null, check: null } : c);
195
+ if (conditions.some((c) => c.condition === plan.condition)) {
196
+ throw new AuthoringError(E.EXISTS, `${milestoneId} already carries that condition.`);
197
+ }
198
+ conditions.push({ condition: plan.condition, met: false, reading: null, check: plan.check });
199
+ await patch(db, "milestone", milestoneId, milestone.version, {
200
+ [column]: JSON.stringify(conditions),
201
+ }, {
202
+ projectId: milestone.project_id, actor, activityType: "scope_changed",
203
+ activitySummary: clean(`${kind} condition recorded for ${milestone.name}`, 200),
204
+ });
205
+ return { applied: true, milestoneId, total: conditions.length, condition: plan.condition, entry };
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Rename a milestone, restate what it delivers, or move its date.
211
+ *
212
+ * A milestone init could not name was created as "First outcome", and nothing
213
+ * could rename it, so a placeholder became permanent on every project that had
214
+ * goals and no stated delivery stages.
215
+ */
216
+ export async function updateMilestone(root, milestoneId, { name = null, outcome = null, targetDate = null, actor = "superdev", apply = false } = {}) {
217
+ const changes = {};
218
+ if (clean(name)) changes.name = clean(name, 200);
219
+ if (clean(outcome)) changes.outcome = clean(outcome, 1000);
220
+ if (clean(targetDate)) changes.target_date = clean(targetDate, 40);
221
+ if (!Object.keys(changes).length) {
222
+ throw new AuthoringError(E.REQUIRED, "Say what to change: --name, --outcome or --target.");
223
+ }
224
+ const plan = { milestoneId, changes };
225
+ if (!apply) return { applied: false, ...plan };
226
+
227
+ return mutate(root, async (db) => {
228
+ const milestone = await db.get("SELECT id, project_id, name, version FROM milestones WHERE id = ?", milestoneId);
229
+ if (!milestone) {
230
+ throw new AuthoringError(E.NOT_FOUND, `There is no milestone ${milestoneId}. List them with superdev milestone list.`);
231
+ }
232
+ if (changes.name && changes.name !== milestone.name) {
233
+ const clash = await db.get(
234
+ "SELECT id FROM milestones WHERE project_id = ? AND name = ? AND id <> ?",
235
+ milestone.project_id, changes.name, milestoneId);
236
+ if (clash) throw new AuthoringError(E.EXISTS, `${clash.id} is already called that.`);
237
+ }
238
+ await patch(db, "milestone", milestoneId, milestone.version, changes, {
239
+ projectId: milestone.project_id, actor, activityType: "scope_changed",
240
+ activitySummary: clean(changes.name && changes.name !== milestone.name
241
+ ? `Milestone ${milestone.name} renamed to ${changes.name}`
242
+ : `Milestone ${milestone.name} updated`, 200),
243
+ });
244
+ return { applied: true, milestoneId, was: milestone.name, changes };
245
+ });
246
+ }
247
+
248
+ // ------------------------------------------------------------------ modules
249
+
250
+ /** Record a module: a slice of the product that owns some of it. */
251
+ export async function recordModule(root, { name, purpose = null, actor = "superdev", apply = false } = {}) {
252
+ if (!clean(name)) throw new AuthoringError(E.REQUIRED, "A module needs a name.");
253
+ const plan = { name: clean(name, 200), purpose: clean(purpose, 1000) };
254
+ if (!apply) return { applied: false, ...plan };
255
+
256
+ return mutate(root, async (db) => {
257
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
258
+ if (!project) throw new AuthoringError(E.NOT_FOUND, "There is no project here yet. Run superdev init first.");
259
+ const existing = await db.get(
260
+ "SELECT id FROM modules WHERE project_id = ? AND name = ?", project.id, plan.name);
261
+ if (existing) {
262
+ throw new AuthoringError(E.EXISTS, `${existing.id} is already called that.`);
263
+ }
264
+ const row = await create(db, "module", {
265
+ project_id: project.id,
266
+ name: plan.name,
267
+ slug: await uniqueSlug(db, "modules", project.id, plan.name, "module"),
268
+ purpose: plan.purpose,
269
+ primary_users_json: JSON.stringify([]),
270
+ owns_json: JSON.stringify([]),
271
+ consumes_json: JSON.stringify([]),
272
+ status: "planned",
273
+ sequence: await nextSequence(db, "modules", "project_id", project.id),
274
+ }, {
275
+ projectId: project.id, actor, activityType: "scope_changed",
276
+ activitySummary: clean(`Module recorded: ${plan.name}`, 200),
277
+ });
278
+ return { applied: true, module: row };
279
+ });
280
+ }
281
+
282
+ // ----------------------------------------------------------------- features
283
+
284
+ /**
285
+ * Add a feature to a module.
286
+ *
287
+ * It lands in draft carrying only what was given, because the depth gate decides
288
+ * when it is ready and `feature specify` is what fills it. Creating one already
289
+ * acceptable would route around the gate.
290
+ */
291
+ export async function createFeature(root, { moduleId, name, purpose = null, milestoneId = null, actor = "superdev", apply = false } = {}) {
292
+ if (!clean(name)) throw new AuthoringError(E.REQUIRED, "A feature needs a name that states what somebody can do.");
293
+ if (!moduleId) {
294
+ throw new AuthoringError(E.REQUIRED,
295
+ "A feature belongs to exactly one module. Pass --module, and see them with superdev module list.");
296
+ }
297
+ const plan = { moduleId, milestoneId, name: clean(name, 200), purpose: clean(purpose, 1000) };
298
+ if (!apply) return { applied: false, ...plan };
299
+
300
+ return mutate(root, async (db) => {
301
+ const module = await db.get("SELECT id, project_id, name FROM modules WHERE id = ?", moduleId);
302
+ if (!module) {
303
+ throw new AuthoringError(E.NOT_FOUND, `There is no module ${moduleId}. List them with superdev module list.`);
304
+ }
305
+ if (plan.milestoneId) {
306
+ const milestone = await db.get("SELECT id FROM milestones WHERE id = ?", plan.milestoneId);
307
+ if (!milestone) {
308
+ throw new AuthoringError(E.NOT_FOUND, `There is no milestone ${plan.milestoneId}.`);
309
+ }
310
+ }
311
+ const existing = await db.get(
312
+ "SELECT id FROM features WHERE project_id = ? AND name = ?", module.project_id, plan.name);
313
+ if (existing) {
314
+ throw new AuthoringError(E.EXISTS, `${existing.id} is already called that. Extend it with superdev feature specify ${existing.id}.`);
315
+ }
316
+ const row = await create(db, "feature", {
317
+ project_id: module.project_id,
318
+ module_id: moduleId,
319
+ milestone_id: plan.milestoneId,
320
+ name: plan.name,
321
+ slug: await uniqueSlug(db, "features", module.project_id, plan.name, "feature"),
322
+ purpose: plan.purpose,
323
+ spec_depth: "microspec",
324
+ risk_level: "R1",
325
+ scope_in_json: JSON.stringify([]),
326
+ scope_out_json: JSON.stringify([]),
327
+ status: "draft",
328
+ created_at: nowIso(),
329
+ updated_at: nowIso(),
330
+ }, {
331
+ projectId: module.project_id, actor, activityType: "specification_changed",
332
+ activitySummary: clean(`Feature drafted in ${module.name}: ${plan.name}`, 200),
333
+ });
334
+ return { applied: true, feature: row, module };
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Move a feature to a different module or milestone.
340
+ *
341
+ * The reason this exists is that initialization guesses. A brief with one
342
+ * heading puts every feature under one module, and a brief with several pairs
343
+ * each feature with whichever module its wording resembled. Neither guess is
344
+ * always right, and until now neither could be corrected through the product.
345
+ */
346
+ export async function moveFeature(root, featureId, { moduleId = null, milestoneId = null, actor = "superdev", apply = false } = {}) {
347
+ if (!moduleId && !milestoneId) {
348
+ throw new AuthoringError(E.REQUIRED, "Say where it is going: --module, --milestone, or both.");
349
+ }
350
+ const before = await query(root, (db) =>
351
+ db.get(`SELECT f.*, m.name AS module_name, s.name AS milestone_name
352
+ FROM features f
353
+ LEFT JOIN modules m ON m.id = f.module_id
354
+ LEFT JOIN milestones s ON s.id = f.milestone_id
355
+ WHERE f.id = ?`, featureId));
356
+ if (!before) throw new AuthoringError(E.NOT_FOUND, `There is no feature ${featureId}.`);
357
+
358
+ const plan = {
359
+ featureId,
360
+ name: before.name,
361
+ fromModule: before.module_name,
362
+ fromMilestone: before.milestone_name,
363
+ moduleId,
364
+ milestoneId,
365
+ };
366
+ if (!apply) return { applied: false, ...plan };
367
+
368
+ return mutate(root, async (db) => {
369
+ const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
370
+ const fields = {};
371
+ if (moduleId) {
372
+ const module = await db.get("SELECT id, name FROM modules WHERE id = ?", moduleId);
373
+ if (!module) throw new AuthoringError(E.NOT_FOUND, `There is no module ${moduleId}.`);
374
+ fields.module_id = moduleId;
375
+ plan.toModule = module.name;
376
+ }
377
+ if (milestoneId) {
378
+ const milestone = await db.get("SELECT id, name FROM milestones WHERE id = ?", milestoneId);
379
+ if (!milestone) throw new AuthoringError(E.NOT_FOUND, `There is no milestone ${milestoneId}.`);
380
+ fields.milestone_id = milestoneId;
381
+ plan.toMilestone = milestone.name;
382
+ }
383
+ await patch(db, "feature", featureId, feature.version, fields, {
384
+ projectId: feature.project_id, actor, activityType: "specification_changed",
385
+ activitySummary: clean(
386
+ `${featureId} moved${plan.toModule ? ` to module ${plan.toModule}` : ""}${plan.toMilestone ? ` into ${plan.toMilestone}` : ""}`, 200),
387
+ });
388
+ return { applied: true, ...plan };
389
+ });
390
+ }
391
+
392
+ /** Take a goal or a milestone out of scope without deleting its history. */
393
+ export async function retire(root, recordId, { reason, actor = "superdev", apply = false } = {}) {
394
+ if (!clean(reason)) {
395
+ throw new AuthoringError(E.REQUIRED, "Say why it is being retired. Without a reason nobody can tell later whether it was decided or forgotten.");
396
+ }
397
+ const kind = recordId?.startsWith("GOAL-") ? "goal" : recordId?.startsWith("MS-") ? "milestone" : null;
398
+ if (!kind) {
399
+ throw new AuthoringError(E.NOT_EDITABLE, `${recordId} is not a goal or a milestone. Retiring is for those.`);
400
+ }
401
+ const table = kind === "goal" ? "goals" : "milestones";
402
+ const row = await query(root, (db) => db.get(`SELECT id, project_id, name, status FROM ${table} WHERE id = ?`, recordId));
403
+ if (!row) throw new AuthoringError(E.NOT_FOUND, `There is no ${kind} ${recordId}.`);
404
+ if (!apply) return { applied: false, recordId, kind, name: row.name, reason: clean(reason) };
405
+
406
+ return mutate(root, async (db) => {
407
+ await setStatus(db, kind, recordId, "retired", {
408
+ projectId: row.project_id, actor, note: clean(reason),
409
+ activityType: "scope_changed",
410
+ activitySummary: clean(`${row.name} retired: ${reason}`, 200),
411
+ });
412
+ return { applied: true, recordId, kind, name: row.name, reason: clean(reason) };
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Say which goal a feature advances, or stop saying it.
418
+ *
419
+ * feature_goals has existed since the first migration, the alignment report has
420
+ * always warned that a feature serving no goal is scope nobody can justify when
421
+ * scope is cut, and nothing could write the link. The warning named a problem
422
+ * with no remedy, which teaches a reader to ignore warnings.
423
+ */
424
+ export async function linkFeatureGoal(root, featureId, goalId, { remove = false, actor = "superdev", apply = false } = {}) {
425
+ const found = await query(root, async (db) => ({
426
+ feature: await db.get("SELECT id, project_id, name FROM features WHERE id = ?", featureId),
427
+ goal: await db.get("SELECT id, name, status FROM goals WHERE id = ?", goalId),
428
+ linked: await db.get(
429
+ "SELECT 1 AS yes FROM feature_goals WHERE feature_id = ? AND goal_id = ?", featureId, goalId),
430
+ }));
431
+ if (!found.feature) throw new AuthoringError(E.NOT_FOUND, `There is no feature ${featureId}.`);
432
+ if (!found.goal) throw new AuthoringError(E.NOT_FOUND, `There is no goal ${goalId}. List them with superdev goal list.`);
433
+ if (!remove && found.goal.status === "retired") {
434
+ throw new AuthoringError(E.NOT_EDITABLE,
435
+ `${goalId} is retired, so work pointed at it would count toward nothing. Record a current goal, or reopen that one.`);
436
+ }
437
+ if (!remove && found.linked) {
438
+ return { applied: false, unchanged: true, featureId, goalId, name: found.feature.name, goal: found.goal.name };
439
+ }
440
+ const plan = { featureId, goalId, name: found.feature.name, goal: found.goal.name, remove };
441
+ if (!apply) return { applied: false, ...plan };
442
+
443
+ return mutate(root, async (db) => {
444
+ if (remove) {
445
+ await db.run("DELETE FROM feature_goals WHERE feature_id = ? AND goal_id = ?", featureId, goalId);
446
+ } else {
447
+ await db.run(
448
+ "INSERT OR IGNORE INTO feature_goals (feature_id, goal_id) VALUES (?, ?)", featureId, goalId);
449
+ }
450
+ await recordActivity(db, found.feature.project_id, {
451
+ type: "specification_changed",
452
+ actor,
453
+ featureId,
454
+ summary: clean(remove
455
+ ? `${featureId} no longer serves ${found.goal.name}`
456
+ : `${featureId} now serves ${found.goal.name}`, 200),
457
+ metadata: { featureId, goalId, removed: remove },
458
+ });
459
+ return { applied: true, ...plan };
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Rename a module, or restate what it owns.
465
+ *
466
+ * A module's name reaches the filesystem as the directory its documentation is
467
+ * written to, so a bad one from a brief parse was permanent in the record and in
468
+ * committed paths. Existing documents keep their old directory until the next
469
+ * generation, which is why the result says so.
470
+ */
471
+ export async function renameModule(root, moduleId, { name = null, purpose = null, actor = "superdev", apply = false } = {}) {
472
+ const changes = {};
473
+ if (clean(name)) changes.name = clean(name, 200);
474
+ if (clean(purpose)) changes.purpose = clean(purpose, 1000);
475
+ if (!Object.keys(changes).length) {
476
+ throw new AuthoringError(E.REQUIRED, "Say what to change: --name or --purpose.");
477
+ }
478
+ const plan = { moduleId, changes };
479
+ if (!apply) return { applied: false, ...plan };
480
+
481
+ return mutate(root, async (db) => {
482
+ const module = await db.get("SELECT id, project_id, name, version FROM modules WHERE id = ?", moduleId);
483
+ if (!module) {
484
+ throw new AuthoringError(E.NOT_FOUND, `There is no module ${moduleId}. List them with superdev module list.`);
485
+ }
486
+ if (changes.name && changes.name !== module.name) {
487
+ const clash = await db.get(
488
+ "SELECT id FROM modules WHERE project_id = ? AND name = ? AND id <> ?",
489
+ module.project_id, changes.name, moduleId);
490
+ if (clash) throw new AuthoringError(E.EXISTS, `${clash.id} is already called that.`);
491
+ }
492
+ await patch(db, "module", moduleId, module.version, changes, {
493
+ projectId: module.project_id, actor, activityType: "scope_changed",
494
+ activitySummary: clean(changes.name && changes.name !== module.name
495
+ ? `Module ${module.name} renamed to ${changes.name}`
496
+ : `Module ${module.name} updated`, 200),
497
+ });
498
+ return { applied: true, moduleId, was: module.name, changes };
499
+ });
500
+ }
501
+
502
+ // ------------------------------------------------------------------- scope
503
+
504
+ const DIRECTIONS = new Set(["in", "out", "non_goal"]);
505
+
506
+ const SAID = { in: "in scope", out: "out of scope", non_goal: "a non-goal" };
507
+
508
+ /**
509
+ * Record what the product deliberately does or does not do.
510
+ *
511
+ * project_scope_items was written by nothing. The product document reads it, the
512
+ * requirement template asks the reader to fill it in, and the generated
513
+ * foundations told them "None declared. An empty list here is a gap, not a
514
+ * statement" even when their brief had declared several, because the parse landed
515
+ * in a discovery item and stopped there.
516
+ *
517
+ * A non-goal is the only place where "we decided not to" is distinguishable from
518
+ * "nobody thought of it", which is why it gets a command of its own rather than
519
+ * being left to the one-shot parse.
520
+ */
521
+ export async function recordScopeItem(root, { statement, direction = "non_goal", why = null, horizon = null, actor = "superdev", apply = false } = {}) {
522
+ if (!clean(statement)) {
523
+ throw new AuthoringError(E.REQUIRED, "Say what is in or out. A scope line nobody can read decides nothing.");
524
+ }
525
+ if (!DIRECTIONS.has(direction)) {
526
+ throw new AuthoringError(E.REQUIRED, `A scope item is in, out or non_goal, not ${direction}.`);
527
+ }
528
+ const plan = {
529
+ statement: clean(statement, 500),
530
+ direction,
531
+ why: clean(why, 500),
532
+ horizon: clean(horizon, 200),
533
+ };
534
+ if (!apply) return { applied: false, ...plan };
535
+
536
+ return mutate(root, async (db) => {
537
+ const project = await db.get("SELECT id FROM projects LIMIT 1");
538
+ if (!project) throw new AuthoringError(E.NOT_FOUND, "There is no project here yet. Run superdev init first.");
539
+ const existing = await db.get(
540
+ "SELECT id, direction FROM project_scope_items WHERE project_id = ? AND statement = ?",
541
+ project.id, plan.statement);
542
+ if (existing) {
543
+ throw new AuthoringError(E.EXISTS,
544
+ `${existing.id} already says that, as ${SAID[existing.direction] ?? existing.direction}.`);
545
+ }
546
+ const row = await create(db, "project_scope_item", {
547
+ project_id: project.id,
548
+ direction: plan.direction,
549
+ statement: plan.statement,
550
+ why: plan.why,
551
+ horizon: plan.horizon,
552
+ sequence: await nextSequence(db, "project_scope_items", "project_id", project.id),
553
+ }, {
554
+ projectId: project.id, actor, activityType: "scope_changed",
555
+ activitySummary: clean(`Recorded as ${SAID[plan.direction]}: ${plan.statement}`, 200),
556
+ });
557
+ return { applied: true, item: row, said: SAID[plan.direction] };
558
+ });
559
+ }
560
+
561
+ /** Take a scope line back out, when it turns out not to have been decided. */
562
+ export async function removeScopeItem(root, itemId, { actor = "superdev", apply = false } = {}) {
563
+ const found = await query(root, (db) =>
564
+ db.get("SELECT * FROM project_scope_items WHERE id = ?", itemId));
565
+ if (!found) throw new AuthoringError(E.NOT_FOUND, `There is no scope item ${itemId}. List them with superdev scope list.`);
566
+ if (!apply) return { applied: false, itemId, statement: found.statement, said: SAID[found.direction] };
567
+
568
+ return mutate(root, async (db) => {
569
+ await db.run("DELETE FROM project_scope_items WHERE id = ?", itemId);
570
+ await recordActivity(db, found.project_id, {
571
+ type: "scope_changed",
572
+ actor,
573
+ summary: clean(`No longer recorded as ${SAID[found.direction]}: ${found.statement}`, 200),
574
+ metadata: { itemId, direction: found.direction },
575
+ });
576
+ return { applied: true, itemId, statement: found.statement, said: SAID[found.direction] };
577
+ });
578
+ }
579
+
580
+ /** What is in, what is out, and what is deliberately neither. */
581
+ export async function scopeList(root) {
582
+ return query(root, (db) => db.all(
583
+ "SELECT * FROM project_scope_items ORDER BY direction, sequence, id"));
584
+ }