superdev-cli 0.3.0 → 0.4.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.
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Superdev - tell it what you want to build, and it keeps the product map, documentation, decisions and status in step while specialist providers do the specialist work.",
8
- "version": "0.3.0",
8
+ "version": "0.4.0",
9
9
  "homepage": "https://github.com/superdev-ai/superdev"
10
10
  },
11
11
  "plugins": [
@@ -13,7 +13,7 @@
13
13
  "name": "superdev",
14
14
  "source": "./",
15
15
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence - plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
16
- "version": "0.3.0",
16
+ "version": "0.4.0",
17
17
  "author": {
18
18
  "name": "Rahul Retnan"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -16,6 +16,6 @@
16
16
  "homepage": "https://github.com/superdev-ai/superdev",
17
17
  "repository": "https://github.com/superdev-ai/superdev",
18
18
  "requires": {
19
- "cli": "0.3.0"
19
+ "cli": "0.4.0"
20
20
  }
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard.",
5
5
  "author": {
6
6
  "name": "Rahul Retnan"
@@ -22,6 +22,6 @@
22
22
  "homepage": "https://github.com/superdev-ai/superdev",
23
23
  "repository": "https://github.com/superdev-ai/superdev",
24
24
  "requires": {
25
- "cli": "0.3.0"
25
+ "cli": "0.4.0"
26
26
  }
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Local-first product-building control system for humans and coding agents.",
6
6
  "author": "Rahul Retnan",
package/src/cli.mjs CHANGED
@@ -137,6 +137,7 @@ Working
137
137
  test-plan run <id> Run the plan and record what it produced
138
138
  test-plan record <id> Record a plan carried out by hand
139
139
  verify Re-run the checks the recorded evidence stands on
140
+ evidence supersede <id> Retire a record that no longer applies, with the reason
140
141
  task cancel <id> Stop work that should not continue, with a reason
141
142
  task complete <id> Finish a task once its verification passes
142
143
  task block <id> Record why a task cannot move
@@ -2034,9 +2035,18 @@ async function cmdTaskEvidence(ctx) {
2034
2035
  }
2035
2036
  const { attachEvidence } = await lifecycle();
2036
2037
  const task = await attachEvidence(ctx.root, id, evidence);
2038
+ const also = (task.alsoProving ?? []).filter((e) => e.id !== task.evidence.id);
2037
2039
  return {
2038
2040
  data: { applied: true, task },
2039
- text: `${task.evidence.id} recorded against ${task.id}: ${summary}`,
2041
+ text: R.stitch([
2042
+ `${task.evidence.id} recorded against ${task.id}: ${summary}`,
2043
+ // Two current records for one criterion is legitimate, and it is also how a
2044
+ // correction looks. Saying so is what stops the older one being forgotten
2045
+ // until verify starts failing on a command that has moved.
2046
+ also.length
2047
+ ? R.wrap(`${countWord(also.length, "record")} already ${also.length === 1 ? "proves" : "prove"} ${evidence.acceptanceCriterionId}: ${also.map((e) => e.id).join(", ")}. If this replaces one rather than adding to it, retire it with superdev evidence supersede ${also[0].id} --reason "<why>" --apply.`)
2048
+ : null,
2049
+ ]),
2040
2050
  };
2041
2051
  }
2042
2052
 
@@ -2207,6 +2217,38 @@ async function cmdTaskBlock(ctx) {
2207
2217
  * duplicate is superseded and says which task replaced it, which is the same
2208
2218
  * answer for the reader and a better one for the record.
2209
2219
  */
2220
+ /**
2221
+ * Retire one evidence record that no longer applies.
2222
+ *
2223
+ * `evidence` rather than `task evidence supersede`, because the resolver reads two
2224
+ * words and the third would be taken as a task identifier. The record is the
2225
+ * subject here anyway, not the task.
2226
+ */
2227
+ async function cmdEvidenceSupersede(ctx) {
2228
+ const id = requireWord(ctx.words, 2, 'Say which record: superdev evidence supersede <EV-id> --reason "<why it no longer applies>".');
2229
+ const reason = String(requireFlag(ctx.flags, "reason", "Superseding evidence needs a reason, because the record already said this was observed."));
2230
+ if (!ctx.apply) {
2231
+ return planned({ id, reason }, "supersede it", R.stitch([
2232
+ R.wrap(`Would supersede ${id}: ${reason}`),
2233
+ R.wrap("The record and its reason stay in history. It leaves the verification tally, and any acceptance criterion resting on it falls back to whatever else is current, or to unmet."),
2234
+ ]));
2235
+ }
2236
+ const { supersedeEvidence } = await lifecycle();
2237
+ const out = await supersedeEvidence(ctx.root, id, { reason, actor: ctx.actor });
2238
+ return {
2239
+ data: out,
2240
+ text: R.stitch([
2241
+ `${id} is superseded and no longer counted.`,
2242
+ out.command ? R.wrap(`Its command was: ${out.command}`) : null,
2243
+ out.criterion
2244
+ ? R.wrap(out.criterion.restingOn
2245
+ ? `${out.criterion.id} is still met, now resting on ${out.criterion.restingOn}.`
2246
+ : `${out.criterion.id} is ${R.status(out.criterion.status)}, because nothing current proves it now.`)
2247
+ : null,
2248
+ ]),
2249
+ };
2250
+ }
2251
+
2210
2252
  async function cmdTaskMerge(ctx) {
2211
2253
  const duplicate = requireWord(ctx.words, 2, "Say which task is the duplicate: superdev task merge <duplicate-id> --into <TASK-id>.");
2212
2254
  const survivor = String(requireFlag(ctx.flags, "into", "Say which task keeps the work: --into <TASK-id>."));
@@ -3164,6 +3206,7 @@ const COMMANDS = {
3164
3206
  "task cancel": cmdTaskCancel,
3165
3207
  "task complete": cmdTaskComplete,
3166
3208
  "task block": cmdTaskBlock,
3209
+ "evidence supersede": cmdEvidenceSupersede,
3167
3210
  "task merge": cmdTaskMerge,
3168
3211
  "task reopen": cmdTaskReopen,
3169
3212
  "docs generate": cmdDocsGenerate,
@@ -3240,7 +3283,7 @@ const COMMANDS = {
3240
3283
  "category restore": cmdCategoryRestore,
3241
3284
  };
3242
3285
 
3243
- const GROUPS = new Set(["db", "task", "docs", "memory", "question", "decision", "category", "feature",
3286
+ const GROUPS = new Set(["db", "task", "docs", "memory", "question", "decision", "category", "feature", "evidence",
3244
3287
  "module", "goal", "milestone", "workflow", "architecture", "schema", "api", "integration", "change", "assumption", "cloud"]);
3245
3288
 
3246
3289
  function resolveCommand(words) {
@@ -491,10 +491,22 @@ async function postToolUse(root, payload) {
491
491
  // A command that looked like a write and changed nothing is not work.
492
492
  if (!changed.length) return EMPTY;
493
493
  const moved = movedSince(root, changed, readTouched(root).attributed);
494
- fresh = moved.length > 0;
494
+ // A generated document that Superdev just rewrote is not the product changing.
495
+ //
496
+ // `docs generate` rewrites its own projection of its own database, so running it
497
+ // after finishing a task produced a marker saying the product had changed while
498
+ // no task was claimed. That is the same mistake as counting the runtime
499
+ // directory, and it blocked a release: doctor raised its only high-severity
500
+ // warning about Superdev regenerating its own files.
501
+ //
502
+ // Decided from the documents table rather than from a path prefix, because a
503
+ // file somebody wrote by hand under the same directory is not a generated
504
+ // document and should still count.
505
+ const product = await notGenerated(root, moved);
506
+ fresh = product.length > 0;
495
507
  markTouched(root, changed);
496
508
  attribute(root, moved);
497
- file = file ?? changed[0];
509
+ file = product[0]?.[0] ?? file ?? changed[0];
498
510
  }
499
511
  markTouched(root, [file]);
500
512
  await flushTouched(root, { force: false });
@@ -601,6 +613,26 @@ export function movedSince(root, candidates, attributed) {
601
613
  return moved;
602
614
  }
603
615
 
616
+ /**
617
+ * The paths among these that are not registered generated documents.
618
+ *
619
+ * Reads the documents table, which is the only authority on what Superdev wrote:
620
+ * a hand-authored file sitting in the same directory is not a generated document,
621
+ * and a hand edit to one that is becomes a proposal through its own flow rather
622
+ * than passing through here. A database that cannot be read leaves every path in,
623
+ * because failing to notice real work is the worse mistake.
624
+ */
625
+ export async function notGenerated(root, moved) {
626
+ if (!moved.length) return moved;
627
+ try {
628
+ const rows = await query(root, (db) => db.all("SELECT path FROM documents"));
629
+ const generated = new Set(rows.map((r) => r.path));
630
+ return moved.filter(([path]) => !generated.has(path));
631
+ } catch {
632
+ return moved;
633
+ }
634
+ }
635
+
604
636
  /** Record that these paths are accounted for, at the state they are in now. */
605
637
  function attribute(root, moved) {
606
638
  if (!moved.length) return;
@@ -821,6 +821,21 @@ export async function attachEvidence(root, taskId, evidence = {}) {
821
821
 
822
822
  return mutate(root, async (db) => {
823
823
  const task = await taskOr404(db, taskId);
824
+ // What already proves this criterion, read before the new row is written.
825
+ //
826
+ // Two current records for one criterion is legitimate: two different checks can
827
+ // both prove one thing. It is also how a correction is made, and the second row
828
+ // silently joining the first is how a project ends up with one record failing
829
+ // forever because its command moved. So the caller is told, and told what to do
830
+ // about it, rather than the product guessing which of the two was meant.
831
+ const alreadyProving = acceptanceCriterionId
832
+ ? await db.all(
833
+ `SELECT id, check_command FROM verification_evidence
834
+ WHERE acceptance_criterion_id = ? AND status = 'current' AND result = 'pass'
835
+ ORDER BY recorded_at, id`,
836
+ acceptanceCriterionId)
837
+ : [];
838
+
824
839
  if (acceptanceCriterionId && result === "pass") {
825
840
  // Fresh proof of a criterion retires the stale proof it replaces. Marking
826
841
  // evidence stale is verify saying the check stopped passing, and the
@@ -906,6 +921,9 @@ export async function attachEvidence(root, taskId, evidence = {}) {
906
921
 
907
922
  const task_after = await db.get("SELECT * FROM tasks WHERE id = ?", taskId);
908
923
  task_after.evidence = row;
924
+ // Carried out so the surface can say it. A correction that looks identical to a
925
+ // second independent proof is how one record ends up failing forever.
926
+ task_after.alsoProving = alreadyProving;
909
927
  return task_after;
910
928
  });
911
929
  }
@@ -960,3 +978,73 @@ export async function findTaskForWork(root, { description, featureId = null, lim
960
978
  return best.row;
961
979
  });
962
980
  }
981
+
982
+ /**
983
+ * Retire one evidence record that no longer applies.
984
+ *
985
+ * A check whose script moved keeps failing forever, and the row cannot be
986
+ * corrected: `task evidence` only appends. Under the old verify allowlist such a
987
+ * row was refused and merely counted as unrunnable, so it was invisible; once
988
+ * containment let the path through, verify ran it, the file was gone, and it
989
+ * failed on every run of a healthy project. `decision supersede` and
990
+ * `memory supersede` have existed all along; this is the same shape for the one
991
+ * record type that is the whole basis of a completion claim.
992
+ *
993
+ * Nothing is deleted. What was observed and when is the point of keeping it, and
994
+ * the reason it stopped applying is worth as much as the observation was.
995
+ */
996
+ export async function supersedeEvidence(root, evidenceId, { reason, actor = "superdev" } = {}) {
997
+ if (!reason) {
998
+ throw new TaskError(E.REASON_REQUIRED,
999
+ "Superseding evidence needs a reason, because the record already said this was observed.");
1000
+ }
1001
+ return mutate(root, async (db) => {
1002
+ const row = await db.get("SELECT * FROM verification_evidence WHERE id = ?", evidenceId);
1003
+ if (!row) {
1004
+ throw new TaskError(E.NOT_FOUND,
1005
+ `There is no evidence ${evidenceId}. superdev verify --json names every record it ran, and task show lists what a task carries.`);
1006
+ }
1007
+ if (row.status === "superseded") {
1008
+ throw new TaskError(E.INVALID_TRANSITION, `${evidenceId} is already superseded.`);
1009
+ }
1010
+
1011
+ await db.run("UPDATE verification_evidence SET status = 'superseded' WHERE id = ?", evidenceId);
1012
+
1013
+ // The criterion this was the proof for now rests on whatever else is current,
1014
+ // and on nothing if there is nothing else. Leaving it met on retired proof
1015
+ // would be the same defect as a green check that never ran.
1016
+ let criterion = null;
1017
+ if (row.acceptance_criterion_id) {
1018
+ const replacement = await db.get(
1019
+ `SELECT id FROM verification_evidence
1020
+ WHERE acceptance_criterion_id = ? AND status = 'current' AND result = 'pass' AND id <> ?
1021
+ ORDER BY recorded_at DESC, id DESC LIMIT 1`,
1022
+ row.acceptance_criterion_id, evidenceId,
1023
+ );
1024
+ const before = await db.get(
1025
+ "SELECT * FROM feature_acceptance_criteria WHERE id = ?", row.acceptance_criterion_id);
1026
+ if (before && before.evidence_id === evidenceId) {
1027
+ await db.run(
1028
+ "UPDATE feature_acceptance_criteria SET status = ?, evidence_id = ? WHERE id = ?",
1029
+ replacement ? "met" : "unmet", replacement?.id ?? null, row.acceptance_criterion_id,
1030
+ );
1031
+ }
1032
+ criterion = {
1033
+ id: row.acceptance_criterion_id,
1034
+ status: before?.evidence_id === evidenceId ? (replacement ? "met" : "unmet") : before?.status ?? null,
1035
+ restingOn: replacement?.id ?? null,
1036
+ };
1037
+ }
1038
+
1039
+ await recordActivity(db, row.project_id, {
1040
+ type: "verification_attached",
1041
+ actor,
1042
+ taskId: row.task_id,
1043
+ featureId: row.feature_id,
1044
+ summary: `Evidence ${evidenceId} superseded: ${reason}`,
1045
+ metadata: { evidenceId, reason, criterion: row.acceptance_criterion_id ?? null },
1046
+ });
1047
+
1048
+ return { evidenceId, was: row.status, summary: row.summary, command: row.check_command ?? null, criterion };
1049
+ });
1050
+ }