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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli/render.mjs +4 -0
- package/src/cli.mjs +118 -4
- package/src/db/migrations/014_task_superseded_by.sql +19 -0
- package/src/docs/render.mjs +3 -2
- package/src/docs/templates.mjs +1 -1
- package/src/model/vocabulary.mjs +26 -0
- package/src/progress/index.mjs +13 -5
- package/src/runtime/hooks.mjs +34 -2
- package/src/tasks/lifecycle.mjs +208 -33
- package/src/tasks/merge.mjs +250 -0
- package/src/verify/index.mjs +86 -5
|
@@ -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.
|
|
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.
|
|
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
|
+
"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.
|
|
19
|
+
"cli": "0.4.0"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "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.
|
|
25
|
+
"cli": "0.4.0"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/package.json
CHANGED
package/src/cli/render.mjs
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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;
|
package/src/docs/render.mjs
CHANGED
|
@@ -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);
|
package/src/docs/templates.mjs
CHANGED
|
@@ -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
|
|
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]),
|
package/src/model/vocabulary.mjs
CHANGED
|
@@ -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",
|
package/src/progress/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/runtime/hooks.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/tasks/lifecycle.mjs
CHANGED
|
@@ -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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/src/verify/index.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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);
|