superdev-cli 0.2.1 → 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.2.1",
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.2.1",
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.2.1",
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.2.1"
19
+ "cli": "0.4.0"
20
20
  }
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdev",
3
- "version": "0.2.1",
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.2.1"
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.2.1",
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",
@@ -317,6 +317,10 @@ export function renderTaskDetail(detail) {
317
317
  ["Priority", status(task.priority ?? "normal")],
318
318
  ["Feature", feature ? `${feature.id} ${feature.name}` : task.feature_id],
319
319
  ["Parent", task.parent_task_id],
320
+ // A superseded task that does not say what replaced it leaves anybody who
321
+ // found the old identifier, in a commit message or a branch name, with
322
+ // nowhere to go.
323
+ ["Merged into", task.superseded_by],
320
324
  ["Estimate", task.estimate],
321
325
  ["Due", task.due_at ? shortDate(task.due_at) : null],
322
326
  ["Blocked because", task.block_reason],
package/src/cli.mjs CHANGED
@@ -137,11 +137,13 @@ 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
143
144
  task unblock <id> Put a blocked task back where it was
144
145
  task reopen <id> Reopen finished work, with a reason
146
+ task merge <id> Fold a duplicate into the task that keeps the work
145
147
  derive [feature] Turn accepted specifications into tasks
146
148
 
147
149
  Knowledge
@@ -1238,7 +1240,13 @@ async function cmdTaskUnblock(ctx) {
1238
1240
  }
1239
1241
  const { unblockTask } = await lifecycle();
1240
1242
  const task = await unblockTask(ctx.root, id, { actor: ctx.actor, to, note: ctx.flags.note ?? null });
1241
- return { data: { applied: true, task }, text: `${task.id} is ${R.status(task.status)} again.` };
1243
+ return {
1244
+ data: { applied: true, task },
1245
+ text: R.stitch([
1246
+ `${task.id} is ${R.status(task.status)} again.`,
1247
+ task.unclaimed ? R.wrap(task.unclaimed) : null,
1248
+ ]),
1249
+ };
1242
1250
  }
1243
1251
 
1244
1252
  /**
@@ -2027,9 +2035,18 @@ async function cmdTaskEvidence(ctx) {
2027
2035
  }
2028
2036
  const { attachEvidence } = await lifecycle();
2029
2037
  const task = await attachEvidence(ctx.root, id, evidence);
2038
+ const also = (task.alsoProving ?? []).filter((e) => e.id !== task.evidence.id);
2030
2039
  return {
2031
2040
  data: { applied: true, task },
2032
- 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
+ ]),
2033
2050
  };
2034
2051
  }
2035
2052
 
@@ -2191,6 +2208,93 @@ async function cmdTaskBlock(ctx) {
2191
2208
  return { data: { applied: true, task }, text: `${task.id} is Blocked. The reason is on the record.` };
2192
2209
  }
2193
2210
 
2211
+ /**
2212
+ * Fold a duplicate task into the one that keeps the work.
2213
+ *
2214
+ * There is no delete. A task carries why it existed and what proved it, and
2215
+ * history here is append only, so a deleted task would take its evidence with it
2216
+ * and leave a commit message pointing at an identifier nobody can look up. The
2217
+ * duplicate is superseded and says which task replaced it, which is the same
2218
+ * answer for the reader and a better one for the record.
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
+
2252
+ async function cmdTaskMerge(ctx) {
2253
+ const duplicate = requireWord(ctx.words, 2, "Say which task is the duplicate: superdev task merge <duplicate-id> --into <TASK-id>.");
2254
+ const survivor = String(requireFlag(ctx.flags, "into", "Say which task keeps the work: --into <TASK-id>."));
2255
+ const { planMerge, mergeTasks } = await import("./tasks/merge.mjs");
2256
+
2257
+ if (!ctx.apply) {
2258
+ const plan = await planMerge(ctx.root, duplicate, survivor);
2259
+ const rows = Object.entries(plan.moving)
2260
+ .filter(([, n]) => n > 0)
2261
+ .map(([kind, n]) => [MERGE_LABEL[kind] ?? kind, String(n)]);
2262
+ return planned(plan, "merge them", R.stitch([
2263
+ R.wrap(`Would fold ${plan.duplicate.id} ${plan.duplicate.name} into ${plan.survivor.id} ${plan.survivor.name}.`),
2264
+ rows.length ? R.block("What would move", R.table(["What", "How many"], rows)) : R.wrap("Nothing is recorded against it yet, so only its status would change."),
2265
+ plan.releasing ? R.wrap("Its claim would be released rather than moved, because an assignment names who took the work.") : null,
2266
+ plan.staying.history
2267
+ ? R.wrap(`${countWord(plan.staying.history, "activity event")} would stay on ${plan.duplicate.id}, because history records a moment and cannot be moved to another record without saying something untrue about the past.`)
2268
+ : null,
2269
+ R.wrap(`${plan.duplicate.id} would become Superseded and point at ${plan.survivor.id}. Nothing is deleted.`),
2270
+ ]));
2271
+ }
2272
+
2273
+ const out = await mergeTasks(ctx.root, duplicate, survivor, {
2274
+ actor: ctx.actor, reason: ctx.flags.reason ? String(ctx.flags.reason) : null,
2275
+ });
2276
+ const rows = Object.entries(out.moved).filter(([, n]) => n > 0);
2277
+ return {
2278
+ data: out,
2279
+ text: R.stitch([
2280
+ `${out.duplicate.id} is merged into ${out.survivor.id}.`,
2281
+ rows.length
2282
+ ? R.block("Moved", R.table(["What", "How many"], rows.map(([k, n]) => [MERGE_LABEL[k] ?? k, String(n)])))
2283
+ : null,
2284
+ R.wrap(`${out.duplicate.id} is ${R.status(out.duplicate.status)} and points at ${out.survivor.id}, so anyone who finds the old identifier is told where the work went.`),
2285
+ ]),
2286
+ };
2287
+ }
2288
+
2289
+ const MERGE_LABEL = {
2290
+ evidence: "Evidence",
2291
+ contractLinks: "Contract links (copied, not moved)",
2292
+ dependencies: "Dependencies",
2293
+ memories: "Memory entries",
2294
+ changes: "Recorded changes",
2295
+ children: "Child tasks",
2296
+ };
2297
+
2194
2298
  async function cmdTaskReopen(ctx) {
2195
2299
  const id = requireWord(ctx.words, 2, "Say which task to reopen: superdev task reopen <id>.");
2196
2300
  const reason = requireFlag(ctx.flags, "reason", "Reopening finished work needs a reason, because the record already said it was done.");
@@ -2201,7 +2305,15 @@ async function cmdTaskReopen(ctx) {
2201
2305
  }
2202
2306
  const { reopenTask } = await lifecycle();
2203
2307
  const task = await reopenTask(ctx.root, id, { reason, to, actor: ctx.actor });
2204
- return { data: { applied: true, task }, text: `${task.id} is open again and is ${R.status(task.status)}.` };
2308
+ return {
2309
+ data: { applied: true, task },
2310
+ // A task nobody holds lands Ready, and the reason is said rather than left
2311
+ // for the reader to notice by comparing this against what they expected.
2312
+ text: R.stitch([
2313
+ `${task.id} is open again and is ${R.status(task.status)}.`,
2314
+ task.unclaimed ? R.wrap(task.unclaimed) : null,
2315
+ ]),
2316
+ };
2205
2317
  }
2206
2318
 
2207
2319
  // ---------------------------------------------------------------------- derive
@@ -3094,6 +3206,8 @@ const COMMANDS = {
3094
3206
  "task cancel": cmdTaskCancel,
3095
3207
  "task complete": cmdTaskComplete,
3096
3208
  "task block": cmdTaskBlock,
3209
+ "evidence supersede": cmdEvidenceSupersede,
3210
+ "task merge": cmdTaskMerge,
3097
3211
  "task reopen": cmdTaskReopen,
3098
3212
  "docs generate": cmdDocsGenerate,
3099
3213
  "docs diff": cmdDocsDiff,
@@ -3169,7 +3283,7 @@ const COMMANDS = {
3169
3283
  "category restore": cmdCategoryRestore,
3170
3284
  };
3171
3285
 
3172
- 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",
3173
3287
  "module", "goal", "milestone", "workflow", "architecture", "schema", "api", "integration", "change", "assumption", "cloud"]);
3174
3288
 
3175
3289
  function resolveCommand(words) {
@@ -0,0 +1,19 @@
1
+ -- Which task replaced this one.
2
+ --
3
+ -- Two records for the same work split its evidence and its progress: each looks
4
+ -- half finished, the feature they serve can never show either as done, and nothing
5
+ -- says they are the same work. Deleting one is not the answer, because a task
6
+ -- carries the reasons it existed and the evidence recorded against it, and history
7
+ -- here is append only by design.
8
+ --
9
+ -- So a duplicate is superseded, and this is where it says by what. Without the
10
+ -- column the relationship could only live in prose in an activity event, which
11
+ -- nothing can follow: somebody who finds the old identifier in a commit message or
12
+ -- a branch name has to be told where the work went.
13
+ --
14
+ -- memory_entries has carried a superseded_by since the first migration for exactly
15
+ -- this reason. Tasks needed the same thing and did not have it.
16
+
17
+ ALTER TABLE tasks ADD COLUMN superseded_by TEXT REFERENCES tasks(id);
18
+
19
+ CREATE INDEX tasks_superseded_by ON tasks(superseded_by) WHERE superseded_by IS NOT NULL;
@@ -22,7 +22,7 @@ import { dirname, join, posix } from "node:path";
22
22
  import { query, mutate, create, recordActivity, currentProject, json } from "../db/store.mjs";
23
23
  import { assertStorable } from "../model/screening.mjs";
24
24
  import { slugify } from "../model/ids.mjs";
25
- import { EDGE_CASE_CATEGORIES, MODULE_STEPS } from "../model/vocabulary.mjs";
25
+ import { EDGE_CASE_CATEGORIES, MODULE_STEPS, AMBIENT_EVENTS } from "../model/vocabulary.mjs";
26
26
  import * as T from "./templates.mjs";
27
27
 
28
28
  export const MARKER = "superdev:generated";
@@ -145,8 +145,9 @@ async function snapshot(db, projectId) {
145
145
  db,
146
146
  `SELECT sequence, event_type, summary, actor_label, created_at FROM activity_events
147
147
  WHERE project_id = ? AND event_type NOT LIKE 'documentation_%'
148
+ AND event_type NOT IN (${AMBIENT_EVENTS.map(() => "?").join(", ")})
148
149
  ORDER BY sequence DESC LIMIT 200`,
149
- projectId,
150
+ projectId, ...AMBIENT_EVENTS,
150
151
  );
151
152
 
152
153
  index(s);
@@ -1173,7 +1173,7 @@ export function changelog(data) {
1173
1173
  return doc(
1174
1174
  REGENERATED,
1175
1175
  `# ${text(data.project.name, "Project")} - Changelog`,
1176
- "Specification and decision changes, newest first, taken from the append-only activity log. Task and session traffic stays in the control center.",
1176
+ "Specification, decision and task changes, newest first, taken from the append-only activity log. File and session traffic stays in the control center, because a note that files moved is not a change to anything described here.",
1177
1177
  table(
1178
1178
  ["#", "Date", "Change", "Actor"],
1179
1179
  data.entries.map((e) => [e.sequence, e.date, e.summary, e.actor]),
@@ -13,6 +13,32 @@ export const OPEN_TASK_STATUSES = TASK_STATUSES.filter(
13
13
 
14
14
  export const TERMINAL_TASK_STATUSES = ["complete", "cancelled", "superseded"];
15
15
 
16
+ /**
17
+ * Events that describe the working tree or a session, rather than a record.
18
+ *
19
+ * Generated documents are a projection of the database, so what makes one stale is
20
+ * a change to a record it renders. A `code_changed` event is a note that files
21
+ * moved; it changes nothing any document projects. Freshness counted it anyway, so
22
+ * every commit marked all 303 documents stale, and since the release gate runs
23
+ * doctor after the commit, the gate could never pass: regenerating cleared it, the
24
+ * next commit brought it back.
25
+ *
26
+ * The changelog had the matching half of the same confusion. It renders every event
27
+ * that is not a documentation event, while its own description says "Task and
28
+ * session traffic stays in the control center". The description was right about
29
+ * what belongs there and the query did not implement it.
30
+ *
31
+ * Held here because two modules have to agree about it: the one that decides what a
32
+ * document renders, and the one that decides when a document is out of date.
33
+ */
34
+ export const AMBIENT_EVENTS = [
35
+ "code_changed",
36
+ "untracked_work",
37
+ "session_started",
38
+ "session_ended",
39
+ "session_compacted",
40
+ ];
41
+
16
42
  /** Human-facing label. Title Case, plain language, no internal vocabulary. */
17
43
  export const LABEL = {
18
44
  draft: "Draft",
@@ -15,7 +15,7 @@
15
15
  // project root, so a caller composes several of these inside one read.
16
16
 
17
17
  import { json, DbError } from "../db/store.mjs";
18
- import { agrees, count } from "../model/vocabulary.mjs";
18
+ import { agrees, count, AMBIENT_EVENTS } from "../model/vocabulary.mjs";
19
19
 
20
20
  const DAY_MS = 86_400_000;
21
21
 
@@ -715,12 +715,20 @@ export async function freshness(db, projectId) {
715
715
  // it rendered at and only then records its own documentation_generated event,
716
716
  // so comparing against the raw maximum sequence would report every document as
717
717
  // stale the instant it was produced, and no run could ever clear it.
718
+ // Documentation events are excluded because a render is not a content change,
719
+ // and ambient events because a note that files moved or a session started
720
+ // changes no record any document projects. Counting those made every commit
721
+ // mark all of them stale, which the release gate then refused, and regenerating
722
+ // only cleared it until the next commit.
723
+ const IGNORED = [
724
+ "documentation_generated", "documentation_proposal_raised",
725
+ "documentation_proposal_resolved", "documentation_possibly_stale",
726
+ ...AMBIENT_EVENTS,
727
+ ];
718
728
  const contentRevision = (await db.get(
719
729
  `SELECT max(sequence) AS s FROM activity_events
720
- WHERE project_id = ? AND event_type NOT IN
721
- ('documentation_generated','documentation_proposal_raised',
722
- 'documentation_proposal_resolved','documentation_possibly_stale')`,
723
- projectId,
730
+ WHERE project_id = ? AND event_type NOT IN (${IGNORED.map(() => "?").join(", ")})`,
731
+ projectId, ...IGNORED,
724
732
  ))?.s ?? 0;
725
733
  const live = documents.filter((d) => d.sync_status !== "retired");
726
734
  const behind = live.filter((d) => (d.database_revision ?? 0) < contentRevision);
@@ -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;
@@ -295,19 +295,10 @@ export async function claimTask(root, taskId, who = {}) {
295
295
  // out to git, and subprocess work inside a write transaction holds the
296
296
  // exclusive lock across it.
297
297
  if (!developerId && !agentId) {
298
- try {
299
- const { resolveIdentity, detectHarness } = await import("../runtime/identity.mjs");
300
- const identity = await resolveIdentity(root, {
301
- harness: detectHarness(process.env).harness,
302
- displayName: who.displayName ?? (actor !== "superdev" ? actor : undefined),
303
- });
304
- developerId = developerId ?? identity.developer?.id ?? null;
305
- agentId = agentId ?? identity.agent?.id ?? null;
306
- branchId = branchId ?? identity.branch?.id ?? null;
307
- } catch {
308
- // An unresolvable identity must not block the claim: the task still gets
309
- // held, and the assignment simply says so rather than naming someone.
310
- }
298
+ const identity = await whoIsActing(root, { actor, displayName: who.displayName });
299
+ developerId = developerId ?? identity.developerId;
300
+ agentId = agentId ?? identity.agentId;
301
+ branchId = branchId ?? identity.branchId;
311
302
  }
312
303
 
313
304
  return mutate(root, async (db) => {
@@ -361,21 +352,11 @@ export async function claimTask(root, taskId, who = {}) {
361
352
 
362
353
  // A lease is a statement to somebody else. With no peer configured there is
363
354
  // nobody to hear it, and the local unique index is already the whole answer.
364
- const peer = await db.get("SELECT alias FROM sync_peers WHERE status = 'connected' LIMIT 1")
365
- .catch(() => null);
366
355
  try {
367
- await create(db, "task_assignment", {
368
- task_id: taskId,
369
- developer_id: developerId,
370
- agent_id: agentId,
371
- branch_id: branchId,
372
- session_id: sessionId,
373
- assigned_at: nowIso(),
374
- active: 1,
375
- lease_holder: peer?.alias ?? null,
376
- lease_expires_at: peer ? leaseExpiry(nowIso()) : null,
377
- }, { projectId: task.project_id, actor, sessionId, taskId, activityType: "task_claimed",
378
- activitySummary: `${taskId} claimed.` });
356
+ await assign(db, task, {
357
+ developerId, agentId, branchId, sessionId, actor,
358
+ summary: `${taskId} claimed.`,
359
+ });
379
360
  } catch (err) {
380
361
  // Only a uniqueness failure means someone else got the claim first. The
381
362
  // test used to accept any message containing "constraint", so a foreign
@@ -403,8 +384,6 @@ export async function claimTask(root, taskId, who = {}) {
403
384
  //
404
385
  // Written here rather than in each caller, because the control centre claims
405
386
  // tasks too and the field has to mean the same thing whichever surface moved it.
406
- await pointSessionAt(db, task.project_id, sessionId, taskId);
407
-
408
387
  return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
409
388
  });
410
389
  }
@@ -489,6 +468,7 @@ export async function blockTask(root, taskId, { reason, actor = "superdev", sess
489
468
 
490
469
  /** Return to whatever the task was doing before it blocked, not to a guess. */
491
470
  export async function unblockTask(root, taskId, { actor = "superdev", note = null, to = null } = {}) {
471
+ const identity = await whoIsActing(root, { actor });
492
472
  return mutate(root, async (db) => {
493
473
  const task = await taskOr404(db, taskId);
494
474
  if (task.status !== "blocked") {
@@ -500,8 +480,10 @@ export async function unblockTask(root, taskId, { actor = "superdev", note = nul
500
480
  ORDER BY sequence DESC LIMIT 1`,
501
481
  taskId,
502
482
  );
503
- const target = to ?? (["in_progress", "in_review", "verifying"].includes(previous?.from_status) ? previous.from_status : "ready");
504
- return move(db, task, target, { actor, note, activityType: "task_unblocked" });
483
+ const wanted = to ?? (ACTIVE.has(previous?.from_status) ? previous.from_status : "ready");
484
+ const owned = await ownedTarget(db, task, wanted, { actor, identity });
485
+ const moved = await move(db, task, owned.target, { actor, note, activityType: "task_unblocked" });
486
+ return owned.note ? { ...moved, unclaimed: owned.note } : moved;
505
487
  });
506
488
  }
507
489
 
@@ -691,20 +673,125 @@ export async function completeTask(root, taskId, { actor = "superdev", sessionId
691
673
  }
692
674
 
693
675
  /** Reopening is a recorded decision, never a quiet edit of a finished record. */
676
+ /**
677
+ * Who is acting, resolved outside every transaction.
678
+ *
679
+ * resolveIdentity shells out to git, and subprocess work inside a write
680
+ * transaction holds the engine's exclusive lock across it, so this always runs
681
+ * before mutate opens. A claim recording three nulls says nothing about who holds
682
+ * the task, which is the only reason the claim exists.
683
+ */
684
+ async function whoIsActing(root, { actor, displayName } = {}) {
685
+ try {
686
+ const { resolveIdentity, detectHarness } = await import("../runtime/identity.mjs");
687
+ const identity = await resolveIdentity(root, {
688
+ harness: detectHarness(process.env).harness,
689
+ displayName: displayName ?? (actor && actor !== "superdev" ? actor : undefined),
690
+ });
691
+ return {
692
+ developerId: identity.developer?.id ?? null,
693
+ agentId: identity.agent?.id ?? null,
694
+ branchId: identity.branch?.id ?? null,
695
+ };
696
+ } catch {
697
+ // An unresolvable identity must not block the work: the claim still happens
698
+ // and simply says so rather than naming somebody.
699
+ return { developerId: null, agentId: null, branchId: null };
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Create the claim, point the session at it, and say who holds it now.
705
+ *
706
+ * Factored out of claimTask so that reopening a task can take the claim through
707
+ * exactly the same path. Two ways of claiming would be two things to keep in
708
+ * agreement, and the last three defects in this area were all one fact recorded
709
+ * in one place and read from another.
710
+ */
711
+ async function assign(db, task, { developerId, agentId, branchId, sessionId, actor, summary }) {
712
+ const peer = await db.get("SELECT alias FROM sync_peers WHERE status = 'connected' LIMIT 1")
713
+ .catch(() => null);
714
+ await create(db, "task_assignment", {
715
+ task_id: task.id,
716
+ developer_id: developerId,
717
+ agent_id: agentId,
718
+ branch_id: branchId,
719
+ session_id: sessionId,
720
+ assigned_at: nowIso(),
721
+ active: 1,
722
+ lease_holder: peer?.alias ?? null,
723
+ lease_expires_at: peer ? leaseExpiry(nowIso()) : null,
724
+ }, { projectId: task.project_id, actor, sessionId, taskId: task.id, activityType: "task_claimed",
725
+ activitySummary: summary });
726
+ await pointSessionAt(db, task.project_id, sessionId, task.id);
727
+ }
728
+
729
+ /**
730
+ * Statuses that assert somebody is working on the task right now.
731
+ *
732
+ * A task in one of these with no claim behind it is a state the record should not
733
+ * be able to reach, and it had two ways in. `task reopen` moved a completed task
734
+ * straight to in_progress, and `task unblock` restored whatever status the task
735
+ * held before it was blocked. Neither took a claim, so the session's active task
736
+ * stayed null while a task said it was in progress, and the untracked-work hook,
737
+ * which reads exactly that field, then recorded every later edit as work nobody
738
+ * had accounted for. Readiness reported it at high severity against work being
739
+ * done on a correctly specified, in-progress task.
740
+ */
741
+ const ACTIVE = new Set(["in_progress", "in_review", "verifying"]);
742
+
743
+ /**
744
+ * The status a transition may actually use, given who holds the task.
745
+ *
746
+ * An unclaimed task drops to ready rather than being claimed on somebody's
747
+ * behalf: reopening a task is a statement that the work is not finished, which is
748
+ * a different fact from somebody working on it this minute, and taking a claim
749
+ * silently would be worse than asking for one. A task the caller already holds
750
+ * keeps the active status, and the session is pointed at it either way.
751
+ */
752
+ async function ownedTarget(db, task, target, { actor, identity }) {
753
+ if (!ACTIVE.has(target)) return { target, note: null };
754
+ const held = await activeAssignment(db, task.id);
755
+ if (held) {
756
+ // Somebody already holds it, including possibly another machine. Their claim
757
+ // stands; this only makes sure the session it belongs to points at it.
758
+ await pointSessionAt(db, task.project_id, held.session_id, task.id);
759
+ return { target, note: null };
760
+ }
761
+ // Nobody holds it. Dropping to ready is not available: the status machine only
762
+ // allows complete to in_progress, and loosening a transition to work around a
763
+ // missing claim would be the wrong repair. So the claim is taken, by the actor
764
+ // who asked for this, which is an explicit act that already carries a reason.
765
+ await assign(db, task, {
766
+ developerId: identity?.developerId ?? null,
767
+ agentId: identity?.agentId ?? null,
768
+ branchId: identity?.branchId ?? null,
769
+ sessionId: null,
770
+ actor,
771
+ summary: `${task.id} claimed by reopening it.`,
772
+ });
773
+ return { target, note: `It is claimed for ${actor}, because a task in progress that no session owns is a state the record should not be able to reach.` };
774
+ }
775
+
694
776
  export async function reopenTask(root, taskId, { reason, actor = "superdev", to = null } = {}) {
695
777
  if (!reason) throw new TaskError(E.REASON_REQUIRED, "Reopening a finished task needs a reason, because the record already said it was done.");
778
+ // Before mutate, because reopening may have to take the claim and resolving who
779
+ // is acting shells out to git.
780
+ const identity = await whoIsActing(root, { actor });
696
781
  return mutate(root, async (db) => {
697
782
  const task = await taskOr404(db, taskId);
698
783
  if (OPEN(task.status)) {
699
784
  throw new TaskError(E.INVALID_TRANSITION, `${taskId} is ${task.status} and already open. There is nothing to reopen.`);
700
785
  }
701
- const target = to ?? (task.status === "complete" ? "in_progress" : "ready");
786
+ const wanted = to ?? (task.status === "complete" ? "in_progress" : "ready");
787
+ const { target, note } = await ownedTarget(db, task, wanted, { actor, identity });
702
788
  const cleared = await patch(db, "task", taskId, task.version, {
703
789
  completed_at: null,
704
790
  cancelled_at: null,
705
791
  }, { projectId: task.project_id, actor, activityType: "task_reopened",
706
792
  activitySummary: `${taskId} reopened: ${reason}` });
707
- return move(db, cleared, target, { actor, note: reason, activityType: "task_reopened" });
793
+ const moved = await move(db, cleared, target, { actor, note: reason, activityType: "task_reopened" });
794
+ return note ? { ...moved, unclaimed: note } : moved;
708
795
  });
709
796
  }
710
797
 
@@ -734,6 +821,21 @@ export async function attachEvidence(root, taskId, evidence = {}) {
734
821
 
735
822
  return mutate(root, async (db) => {
736
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
+
737
839
  if (acceptanceCriterionId && result === "pass") {
738
840
  // Fresh proof of a criterion retires the stale proof it replaces. Marking
739
841
  // evidence stale is verify saying the check stopped passing, and the
@@ -819,6 +921,9 @@ export async function attachEvidence(root, taskId, evidence = {}) {
819
921
 
820
922
  const task_after = await db.get("SELECT * FROM tasks WHERE id = ?", taskId);
821
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;
822
927
  return task_after;
823
928
  });
824
929
  }
@@ -873,3 +978,73 @@ export async function findTaskForWork(root, { description, featureId = null, lim
873
978
  return best.row;
874
979
  });
875
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
+ }
@@ -0,0 +1,250 @@
1
+ // Folding a duplicate task into the one that keeps the work.
2
+ //
3
+ // Deriving twice, or creating by hand what derivation had already created, leaves
4
+ // two records for one piece of work. Each collects some of the evidence, so each
5
+ // looks half finished, the feature they serve can never show either as done, and
6
+ // progress counts the same work twice while completing neither.
7
+ //
8
+ // Deleting one is the obvious move and the wrong one. A task carries why it
9
+ // existed, what it implements and what proved it, and history here is append only
10
+ // on purpose: a deleted task takes its evidence with it and leaves a commit
11
+ // message or a branch name pointing at an identifier nobody can look up. So
12
+ // nothing is deleted. What the duplicate owns moves to the survivor, the duplicate
13
+ // becomes superseded, and it says which task replaced it.
14
+ //
15
+ // What does not move, and why:
16
+ //
17
+ // Activity events stay where they happened. They are a record of a moment, and
18
+ // rewriting them to point elsewhere would make the log say something that is not
19
+ // true about the past.
20
+ //
21
+ // Claims stay with whoever made them. An assignment names a person, a machine
22
+ // and a branch; moving it would put somebody's name against work they did not
23
+ // take. The duplicate's claim is released instead.
24
+
25
+ import { mutate, query, recordActivity, setStatus } from "../db/store.mjs";
26
+
27
+ export const E = {
28
+ NOT_FOUND: "E_NOT_FOUND",
29
+ SAME_TASK: "E_SAME_TASK",
30
+ DIFFERENT_PROJECT: "E_DIFFERENT_PROJECT",
31
+ NOT_MERGEABLE: "E_NOT_MERGEABLE",
32
+ HELD: "E_ALREADY_CLAIMED",
33
+ };
34
+
35
+ export class MergeError extends Error {
36
+ constructor(code, message, detail) {
37
+ super(message);
38
+ this.name = "MergeError";
39
+ this.code = code;
40
+ if (detail !== undefined) this.detail = detail;
41
+ }
42
+ }
43
+
44
+ /** A task in one of these has already been closed out; merging it again says nothing. */
45
+ const CLOSED = new Set(["superseded", "cancelled"]);
46
+
47
+ /**
48
+ * What a merge would move, without moving any of it.
49
+ *
50
+ * Counted per kind rather than totalled, because "6 things would move" tells a
51
+ * reader nothing about whether they meant to move them.
52
+ */
53
+ export async function planMerge(root, duplicateId, survivorId) {
54
+ return query(root, async (db) => {
55
+ const [duplicate, survivor] = await Promise.all([
56
+ db.get("SELECT * FROM tasks WHERE id = ?", duplicateId),
57
+ db.get("SELECT * FROM tasks WHERE id = ?", survivorId),
58
+ ]);
59
+ refuseImpossible(duplicate, survivor, duplicateId, survivorId);
60
+
61
+ const held = await db.get(
62
+ "SELECT id, developer_id, agent_id FROM task_assignments WHERE task_id = ? AND active = 1",
63
+ duplicateId,
64
+ );
65
+
66
+ return {
67
+ duplicate: summary(duplicate),
68
+ survivor: summary(survivor),
69
+ moving: {
70
+ evidence: await countOf(db, "SELECT COUNT(*) AS n FROM verification_evidence WHERE task_id = ?", duplicateId),
71
+ // Counted as what the survivor would gain, not as what the duplicate has:
72
+ // a link the survivor already carries is not moved anywhere.
73
+ contractLinks: await countOf(db,
74
+ `SELECT COUNT(*) AS n FROM task_contract_links d
75
+ WHERE d.task_id = ?
76
+ AND NOT EXISTS (SELECT 1 FROM task_contract_links s
77
+ WHERE s.task_id = ? AND s.target_type = d.target_type
78
+ AND s.target_id = d.target_id AND s.relationship = d.relationship)`,
79
+ duplicateId, survivorId),
80
+ dependencies: await countOf(db,
81
+ "SELECT COUNT(*) AS n FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?", duplicateId, duplicateId),
82
+ memories: await countOf(db, "SELECT COUNT(*) AS n FROM memory_entries WHERE task_id = ?", duplicateId),
83
+ changes: await countOf(db, "SELECT COUNT(*) AS n FROM changes WHERE task_id = ?", duplicateId),
84
+ children: await countOf(db, "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?", duplicateId),
85
+ },
86
+ // A claim is released rather than moved, and the reader is told so before it
87
+ // happens rather than discovering it afterwards.
88
+ releasing: held ? held.id : null,
89
+ staying: {
90
+ history: await countOf(db, "SELECT COUNT(*) AS n FROM activity_events WHERE task_id = ?", duplicateId),
91
+ },
92
+ };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Move everything the duplicate owns onto the survivor and supersede it.
98
+ *
99
+ * Every move is `OR IGNORE` or guarded, because the survivor may already carry the
100
+ * same link, and a merge that fails halfway through on a uniqueness constraint
101
+ * would leave the work split across two tasks with no way to tell which half went
102
+ * where. The whole thing is one transaction, so it either happens or it does not.
103
+ */
104
+ export async function mergeTasks(root, duplicateId, survivorId, { actor = "superdev", reason = null } = {}) {
105
+ return mutate(root, async (db) => {
106
+ const [duplicate, survivor] = await Promise.all([
107
+ db.get("SELECT * FROM tasks WHERE id = ?", duplicateId),
108
+ db.get("SELECT * FROM tasks WHERE id = ?", survivorId),
109
+ ]);
110
+ refuseImpossible(duplicate, survivor, duplicateId, survivorId);
111
+
112
+ const moved = {};
113
+
114
+ moved.evidence = await moveRows(db,
115
+ "UPDATE verification_evidence SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
116
+
117
+ // Contract links are copied, not moved.
118
+ //
119
+ // Moving them emptied the duplicate, and a trigger refuses any task leaving
120
+ // draft without at least one contract link, so superseding it then failed and
121
+ // the whole merge rolled back. The trigger is right: a task in an active status
122
+ // that implements nothing is not a task. Copying is also the truer record,
123
+ // because the duplicate really was written to implement that criterion, and it
124
+ // is superseded rather than open so it no longer competes for the work.
125
+ moved.contractLinks = await moveRows(db,
126
+ `INSERT OR IGNORE INTO task_contract_links (task_id, target_type, target_id, relationship)
127
+ SELECT ?, target_type, target_id, relationship FROM task_contract_links WHERE task_id = ?`,
128
+ survivorId, duplicateId);
129
+
130
+ moved.dependencies = await moveRows(db,
131
+ "UPDATE OR IGNORE task_dependencies SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
132
+ moved.dependencies += await moveRows(db,
133
+ "UPDATE OR IGNORE task_dependencies SET depends_on_task_id = ? WHERE depends_on_task_id = ?", survivorId, duplicateId);
134
+ // Anything left pointed at the duplicate, and a task cannot depend on itself.
135
+ await db.run("DELETE FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?", duplicateId, duplicateId);
136
+ await db.run("DELETE FROM task_dependencies WHERE task_id = depends_on_task_id");
137
+
138
+ moved.memories = await moveRows(db,
139
+ "UPDATE memory_entries SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
140
+ moved.changes = await moveRows(db,
141
+ "UPDATE changes SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
142
+ moved.children = await moveRows(db,
143
+ "UPDATE tasks SET parent_task_id = ? WHERE parent_task_id = ?", survivorId, duplicateId);
144
+
145
+ // The claim is released, not moved: an assignment names a person, a machine and
146
+ // a branch, and moving it would put somebody's name against work they did not
147
+ // take.
148
+ const held = await db.get(
149
+ "SELECT id FROM task_assignments WHERE task_id = ? AND active = 1", duplicateId);
150
+ if (held) {
151
+ await db.run("UPDATE task_assignments SET active = 0, released_at = ? WHERE id = ?",
152
+ new Date().toISOString(), held.id);
153
+ }
154
+ await db.run(
155
+ "UPDATE work_sessions SET active_task_id = NULL WHERE project_id = ? AND active_task_id = ?",
156
+ duplicate.project_id, duplicateId);
157
+
158
+ // The pointer is written before the status moves, so nothing can observe a
159
+ // superseded task that does not say what replaced it.
160
+ await db.run("UPDATE tasks SET superseded_by = ? WHERE id = ?", survivorId, duplicateId);
161
+
162
+ const why = reason
163
+ ? `${duplicateId} merged into ${survivorId}: ${reason}`
164
+ : `${duplicateId} merged into ${survivorId} as a duplicate.`;
165
+ await setStatus(db, "task", duplicateId, "superseded", {
166
+ projectId: duplicate.project_id,
167
+ actor,
168
+ note: why,
169
+ activityType: "task_updated",
170
+ activitySummary: why,
171
+ });
172
+
173
+ await recordActivity(db, survivor.project_id, {
174
+ type: "task_updated",
175
+ actor,
176
+ taskId: survivorId,
177
+ featureId: survivor.feature_id,
178
+ summary: `${survivorId} absorbed ${duplicateId}: ${describe(moved)}`,
179
+ metadata: { mergedFrom: duplicateId, moved },
180
+ });
181
+
182
+ return {
183
+ duplicate: summary(await db.get("SELECT * FROM tasks WHERE id = ?", duplicateId)),
184
+ survivor: summary(await db.get("SELECT * FROM tasks WHERE id = ?", survivorId)),
185
+ moved,
186
+ released: held?.id ?? null,
187
+ };
188
+ });
189
+ }
190
+
191
+ // ------------------------------------------------------------------ helpers
192
+
193
+ /** Everything that makes a merge impossible, refused in one place. */
194
+ function refuseImpossible(duplicate, survivor, duplicateId, survivorId) {
195
+ if (duplicateId === survivorId) {
196
+ throw new MergeError(E.SAME_TASK, `${duplicateId} cannot be merged into itself.`);
197
+ }
198
+ if (!duplicate) throw new MergeError(E.NOT_FOUND, `There is no task ${duplicateId}.`);
199
+ if (!survivor) throw new MergeError(E.NOT_FOUND, `There is no task ${survivorId}.`);
200
+ if (duplicate.project_id !== survivor.project_id) {
201
+ throw new MergeError(E.DIFFERENT_PROJECT,
202
+ `${duplicateId} and ${survivorId} belong to different projects, so merging them would move work between records that do not share a scope.`);
203
+ }
204
+ if (duplicate.superseded_by) {
205
+ throw new MergeError(E.NOT_MERGEABLE,
206
+ `${duplicateId} was already merged into ${duplicate.superseded_by}. Merge that one instead.`);
207
+ }
208
+ if (CLOSED.has(duplicate.status)) {
209
+ throw new MergeError(E.NOT_MERGEABLE,
210
+ `${duplicateId} is ${duplicate.status}, so it has already been closed out and there is nothing to fold in.`);
211
+ }
212
+ if (CLOSED.has(survivor.status)) {
213
+ throw new MergeError(E.NOT_MERGEABLE,
214
+ `${survivorId} is ${survivor.status}, so merging into it would move the work onto a task nobody is going to finish.`);
215
+ }
216
+ }
217
+
218
+ const summary = (task) => task && ({
219
+ id: task.id,
220
+ name: task.name,
221
+ status: task.status,
222
+ featureId: task.feature_id,
223
+ supersededBy: task.superseded_by ?? null,
224
+ });
225
+
226
+ async function countOf(db, sql, ...args) {
227
+ return Number((await db.get(sql, ...args))?.n ?? 0);
228
+ }
229
+
230
+ /** How many rows a statement actually moved, whichever field the driver reports it in. */
231
+ async function moveRows(db, sql, ...args) {
232
+ const result = await db.run(sql, ...args);
233
+ return Number(result?.changes ?? result?.rowsAffected ?? 0);
234
+ }
235
+
236
+ const describe = (moved) => {
237
+ const said = Object.entries(moved)
238
+ .filter(([, n]) => n > 0)
239
+ .map(([kind, n]) => `${n} ${LABEL[kind] ?? kind}`);
240
+ return said.length ? said.join(", ") : "nothing to move";
241
+ };
242
+
243
+ const LABEL = {
244
+ evidence: "pieces of evidence",
245
+ contractLinks: "contract links copied across",
246
+ dependencies: "dependencies",
247
+ memories: "memory entries",
248
+ changes: "recorded changes",
249
+ children: "child tasks",
250
+ };
@@ -17,6 +17,8 @@
17
17
  // saying plainly that a check is manual.
18
18
 
19
19
  import { execFile } from "node:child_process";
20
+ import { realpathSync } from "node:fs";
21
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
20
22
  import { promisify } from "node:util";
21
23
 
22
24
  import { mutate, query } from "../db/store.mjs";
@@ -50,8 +52,86 @@ const run = promisify(execFile);
50
52
  * should be run by a person who has read it.
51
53
  */
52
54
 
53
- /** Scripts of this project that may be run, and nothing else. */
54
- const SCRIPT = /^(src\/cli\.mjs|scripts\/[A-Za-z0-9_\/-]+\.mjs)$/;
55
+ /**
56
+ * A script inside the project may be run. Whether it is inside is a containment
57
+ * question, not a question about its path's shape.
58
+ *
59
+ * This was `/^(src\/cli\.mjs|scripts\/[A-Za-z0-9_\/-]+\.mjs)$/`, which is
60
+ * Superdev's own entry point and Superdev's own script directory, hardcoded. Every
61
+ * other repository layout was refused however plainly the script sat inside the
62
+ * project: a workspace package, `tools/`, `bin/`, `lib/`, `apps/web/lib/`. The
63
+ * refusal even said "node may only run this project's own scripts", and the
64
+ * allowlist did not describe this project. It described Superdev.
65
+ *
66
+ * That mattered more than a wrong message. The recorded command is the whole
67
+ * mechanism by which evidence stops being a one-time opinion, so on any project
68
+ * whose scripts are not at `scripts/*.mjs` the mechanism quietly did nothing, and
69
+ * a reader was pushed into moving their files to suit the tool.
70
+ *
71
+ * The real risk was never the path's shape. It is a node option before the script
72
+ * path, which can load arbitrary code, and that rule is enforced separately and
73
+ * unchanged. What containment has to add is that the script is under the project
74
+ * root once symlinks are resolved, so a link pointing out of the tree is refused
75
+ * even though its path looks local.
76
+ */
77
+ const SCRIPT_EXTENSION = /\.(mjs|cjs|js)$/;
78
+
79
+ function outsideProject(root, script) {
80
+ // A flag in the script position is the actual risk, and it is refused on shape
81
+ // rather than on containment: `--require=./evil.js` ends in `.js` and names no
82
+ // script at all. Checking the extension first let exactly that through.
83
+ if (script.startsWith("-")) {
84
+ return "node may not be given an option before the script path, because an option can load arbitrary code";
85
+ }
86
+ if (!SCRIPT_EXTENSION.test(script)) return `not a script: ${script}`;
87
+ if (isAbsolute(script)) return `an absolute path names a place rather than something in this project: ${script}`;
88
+ if (!root) {
89
+ return `the project root is not known here, so whether ${script} is inside it cannot be decided`;
90
+ }
91
+ const base = realpathOrSelf(resolve(root));
92
+ const target = resolvedWithin(base, script);
93
+ if (target !== base && !target.startsWith(base + sep)) {
94
+ return `node may only run scripts inside this project, and ${script} resolves outside it`;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The script's real location, resolving every symlink on the way to it.
101
+ *
102
+ * realpath on the whole path answers nothing when the file does not exist yet, and
103
+ * falling back to the unresolved path let a directory symlink out of the tree
104
+ * pass: `escape/hosts.mjs`, where `escape` links to somewhere else, resolves under
105
+ * the root only because the leaf could not be resolved. So the deepest ancestor
106
+ * that does exist is resolved instead, and the rest is appended to it.
107
+ */
108
+ function resolvedWithin(base, script) {
109
+ const full = resolve(base, script);
110
+ let head = full;
111
+ const tail = [];
112
+ for (;;) {
113
+ const real = realpathSafe(head);
114
+ if (real) return tail.length ? resolve(real, ...tail.reverse()) : real;
115
+ const parent = dirname(head);
116
+ // Reached the filesystem root without finding anything that exists.
117
+ if (parent === head) return full;
118
+ tail.push(basename(head));
119
+ head = parent;
120
+ }
121
+ }
122
+
123
+ const realpathSafe = (path) => {
124
+ try {
125
+ return realpathSync(path);
126
+ } catch {
127
+ return null;
128
+ }
129
+ };
130
+
131
+ /** Symlinks resolved where possible, so a link out of the tree cannot hide. */
132
+ function realpathOrSelf(path) {
133
+ return realpathSafe(path) ?? path;
134
+ }
55
135
 
56
136
  /**
57
137
  * Flags each program may carry.
@@ -71,7 +151,7 @@ const CHANGES_STATE = /^--(apply|force|write|fix|delete|remove)\b/;
71
151
  export const E = { UNSAFE: "E_UNSAFE_CHECK" };
72
152
 
73
153
  /** Why a command will not be run here, or null when it will. */
74
- export function refuseReason(command) {
154
+ export function refuseReason(command, root = null) {
75
155
  const text = String(command ?? "").trim();
76
156
  if (!text) return "no command recorded";
77
157
  // Shell punctuation cannot do anything through execFile, but its presence
@@ -96,7 +176,8 @@ export function refuseReason(command) {
96
176
  // arbitrary code, which is the whole risk with running node at all.
97
177
  const [script, ...args] = rest;
98
178
  if (!script) return "node with no script";
99
- if (!SCRIPT.test(script)) return `node may only run this project's own scripts, not ${script}`;
179
+ const outside = outsideProject(root, script);
180
+ if (outside) return outside;
100
181
  for (const a of args) {
101
182
  if (CHANGES_STATE.test(a)) return "it would change something, and a check must not";
102
183
  }
@@ -152,7 +233,7 @@ export function tokenize(command) {
152
233
  * validator which starts finding things is how a claim stops being true.
153
234
  */
154
235
  export async function runCheck(root, command, { timeoutMs = 120000 } = {}) {
155
- const refused = refuseReason(command);
236
+ const refused = refuseReason(command, root);
156
237
  if (refused) return { result: "error", detail: refused };
157
238
 
158
239
  const [bin, ...rest] = tokenize(command);