superdev-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// The task lifecycle, brief sections 7.2, 7.4 and 12.
|
|
2
|
+
//
|
|
3
|
+
// Task tracking is an execution contract, not a status report someone remembers
|
|
4
|
+
// to write. Every function here is one transaction: it checks what the brief
|
|
5
|
+
// says must be true, moves the record, and leaves history behind. Nothing moves
|
|
6
|
+
// status through a plain update, so no transition can happen unrecorded.
|
|
7
|
+
//
|
|
8
|
+
// Where the database already enforces a rule (one active assignment, no parent
|
|
9
|
+
// completing over open children, no task leaving draft without a contract), this
|
|
10
|
+
// module catches the refusal and turns it into a sentence a person can act on.
|
|
11
|
+
|
|
12
|
+
import { create, mutate, patch, query, recordActivity, setStatus, json } from "../db/store.mjs";
|
|
13
|
+
import { count } from "../model/vocabulary.mjs";
|
|
14
|
+
import { TARGET_TABLE } from "./derive.mjs";
|
|
15
|
+
import { leaseExpiry, leaseHolds } from "../cloud/merge.mjs";
|
|
16
|
+
|
|
17
|
+
export const E = {
|
|
18
|
+
NOT_FOUND: "E_NOT_FOUND",
|
|
19
|
+
INVALID_TRANSITION: "E_INVALID_TRANSITION",
|
|
20
|
+
ALREADY_CLAIMED: "E_ALREADY_CLAIMED",
|
|
21
|
+
NOT_CLAIMED: "E_NOT_CLAIMED",
|
|
22
|
+
OPEN_SUBTASKS: "E_OPEN_SUBTASKS",
|
|
23
|
+
EVIDENCE_MISSING: "E_EVIDENCE_MISSING",
|
|
24
|
+
EVIDENCE_FAILING: "E_EVIDENCE_FAILING",
|
|
25
|
+
ACCEPTANCE_UNMET: "E_ACCEPTANCE_UNMET",
|
|
26
|
+
TEST_PLAN_UNSATISFIED: "E_TEST_PLAN_UNSATISFIED",
|
|
27
|
+
WITHOUT_CONTRACT: "E_TASK_WITHOUT_CONTRACT",
|
|
28
|
+
ENABLING_WITHOUT_TARGET: "E_ENABLING_WITHOUT_TARGET",
|
|
29
|
+
UNKNOWN_TARGET: "E_UNKNOWN_TARGET",
|
|
30
|
+
STATUS_VIA_TRANSITION: "E_STATUS_VIA_TRANSITION",
|
|
31
|
+
REASON_REQUIRED: "E_REASON_REQUIRED",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class TaskError extends Error {
|
|
35
|
+
constructor(code, message, detail) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "TaskError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
if (detail !== undefined) this.detail = detail;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Brief 7.4. What may follow what, so nothing arrives at complete sideways. */
|
|
44
|
+
const ALLOWED = {
|
|
45
|
+
// Brief 12.2 goes draft, link, claim, In Progress in one sitting, so a draft
|
|
46
|
+
// may start directly. The database still refuses it if it implements nothing.
|
|
47
|
+
draft: ["ready", "in_progress", "cancelled"],
|
|
48
|
+
ready: ["draft", "in_progress", "blocked", "paused", "cancelled"],
|
|
49
|
+
in_progress: ["in_review", "verifying", "blocked", "paused", "complete", "cancelled"],
|
|
50
|
+
in_review: ["in_progress", "verifying", "blocked", "complete", "cancelled"],
|
|
51
|
+
verifying: ["in_progress", "in_review", "blocked", "complete", "cancelled"],
|
|
52
|
+
blocked: ["ready", "in_progress", "paused", "cancelled"],
|
|
53
|
+
paused: ["ready", "in_progress", "blocked", "cancelled"],
|
|
54
|
+
complete: ["in_progress"],
|
|
55
|
+
cancelled: ["ready", "draft"],
|
|
56
|
+
superseded: ["ready"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const OPEN = (status) => !["complete", "cancelled", "superseded"].includes(status);
|
|
60
|
+
|
|
61
|
+
const nowIso = () => new Date().toISOString();
|
|
62
|
+
|
|
63
|
+
async function taskOr404(db, taskId) {
|
|
64
|
+
const task = await db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
65
|
+
if (!task) throw new TaskError(E.NOT_FOUND, `Task ${taskId} does not exist. Check the id or list the tasks for the feature.`);
|
|
66
|
+
return task;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Turn a trigger refusal into the reason a person can do something about. */
|
|
70
|
+
function explain(err, task, to) {
|
|
71
|
+
const text = String(err?.message ?? "");
|
|
72
|
+
if (/E_OPEN_SUBTASKS/.test(text)) {
|
|
73
|
+
return new TaskError(E.OPEN_SUBTASKS,
|
|
74
|
+
`${task.id} still has subtasks that are not finished. Complete or cancel them first, then complete ${task.id}.`);
|
|
75
|
+
}
|
|
76
|
+
if (/E_TASK_WITHOUT_CONTRACT/.test(text)) {
|
|
77
|
+
return new TaskError(E.WITHOUT_CONTRACT,
|
|
78
|
+
`${task.id} cannot move to ${to} because it implements nothing. Link it to a workflow step, action, operation, entity, migration, integration, requirement, document or decision, or mark it as enabling work with the feature it unblocks.`);
|
|
79
|
+
}
|
|
80
|
+
if (/E_ENABLING_WITHOUT_TARGET/.test(text)) {
|
|
81
|
+
return new TaskError(E.ENABLING_WITHOUT_TARGET,
|
|
82
|
+
`${task.id} is marked as enabling work but does not say which feature it unblocks or why. Set the enabled feature and a plain-language rationale, then move it again.`);
|
|
83
|
+
}
|
|
84
|
+
return err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function move(db, task, to, opts = {}) {
|
|
88
|
+
if (task.status === to) return task;
|
|
89
|
+
if (!(ALLOWED[task.status] ?? []).includes(to)) {
|
|
90
|
+
throw new TaskError(E.INVALID_TRANSITION,
|
|
91
|
+
`${task.id} is ${task.status.replace(/_/g, " ")} and cannot move straight to ${to.replace(/_/g, " ")}. Allowed from here: ${(ALLOWED[task.status] ?? []).join(", ") || "nothing"}.`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return await setStatus(db, "task", task.id, to, { projectId: task.project_id, ...opts });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw explain(err, task, to);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const activeAssignment = (db, taskId) =>
|
|
101
|
+
db.get("SELECT * FROM task_assignments WHERE task_id = ? AND active = 1", taskId);
|
|
102
|
+
|
|
103
|
+
/** Who holds a task, in words rather than ids, for the already-claimed message. */
|
|
104
|
+
async function holderOf(db, assignment) {
|
|
105
|
+
const developer = assignment.developer_id
|
|
106
|
+
? await db.get("SELECT display_name FROM developers WHERE id = ?", assignment.developer_id)
|
|
107
|
+
: null;
|
|
108
|
+
const agent = assignment.agent_id
|
|
109
|
+
? await db.get("SELECT harness, model_label FROM agents WHERE id = ?", assignment.agent_id)
|
|
110
|
+
: null;
|
|
111
|
+
const parts = [
|
|
112
|
+
developer?.display_name,
|
|
113
|
+
agent ? `${agent.harness}${agent.model_label ? ` (${agent.model_label})` : ""}` : null,
|
|
114
|
+
].filter(Boolean);
|
|
115
|
+
return parts.length ? parts.join(" via ") : "an unnamed session";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function release(db, task, actor, note) {
|
|
119
|
+
const assignment = await activeAssignment(db, task.id);
|
|
120
|
+
if (!assignment) return null;
|
|
121
|
+
// task_assignments carries no version column, so versionedUpdate cannot address
|
|
122
|
+
// it; the activity event is written here instead of being skipped.
|
|
123
|
+
await db.run("UPDATE task_assignments SET active = 0, released_at = ? WHERE id = ?", nowIso(), assignment.id);
|
|
124
|
+
await recordActivity(db, task.project_id, {
|
|
125
|
+
type: "task_released",
|
|
126
|
+
actor,
|
|
127
|
+
taskId: task.id,
|
|
128
|
+
featureId: task.feature_id,
|
|
129
|
+
summary: note ?? `Assignment on ${task.id} released.`,
|
|
130
|
+
metadata: { assignment: assignment.id },
|
|
131
|
+
});
|
|
132
|
+
return assignment;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ------------------------------------------------------------------- writing
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a task. It lands in draft, gains its contract links, and only then may
|
|
139
|
+
* be asked for a further status, because the database refuses a task that leaves
|
|
140
|
+
* draft implementing nothing.
|
|
141
|
+
*/
|
|
142
|
+
export async function createTask(root, input = {}) {
|
|
143
|
+
const {
|
|
144
|
+
featureId, name, description = null, expectedOutcome = null, whyNeeded = null,
|
|
145
|
+
completionCriteria = [], verificationRequirements = [], affectedBoundaries = [],
|
|
146
|
+
priority = "normal", risk = null, category = null, estimate = null, dueAt = null,
|
|
147
|
+
parentTaskId = null, enabling = false, enabledFeatureId = null, enablingRationale = null,
|
|
148
|
+
links = [], dependsOn = [], status = "draft", actor = "superdev", sessionId = null,
|
|
149
|
+
} = input;
|
|
150
|
+
|
|
151
|
+
if (!featureId) {
|
|
152
|
+
throw new TaskError(E.NOT_FOUND, "A task must belong to exactly one feature. Pass the feature id, or specify the feature first if it does not exist yet.");
|
|
153
|
+
}
|
|
154
|
+
if (!name) throw new TaskError(E.REASON_REQUIRED, "A task needs a name that states the outcome.");
|
|
155
|
+
|
|
156
|
+
return mutate(root, async (db) => {
|
|
157
|
+
const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
|
|
158
|
+
if (!feature) throw new TaskError(E.NOT_FOUND, `Feature ${featureId} does not exist. Create or accept the feature specification before creating work against it.`);
|
|
159
|
+
if (enabling && (!enabledFeatureId || !enablingRationale)) {
|
|
160
|
+
throw new TaskError(E.ENABLING_WITHOUT_TARGET,
|
|
161
|
+
"Enabling work must name the feature it unblocks and say, in plain language, why that feature is blocked without it.");
|
|
162
|
+
}
|
|
163
|
+
const categoryId = category
|
|
164
|
+
? (await db.get("SELECT id FROM task_categories WHERE project_id = ? AND name = ?", feature.project_id, category))?.id ?? null
|
|
165
|
+
: null;
|
|
166
|
+
|
|
167
|
+
const row = await create(db, "task", {
|
|
168
|
+
project_id: feature.project_id,
|
|
169
|
+
feature_id: feature.id,
|
|
170
|
+
parent_task_id: parentTaskId,
|
|
171
|
+
category_id: categoryId,
|
|
172
|
+
name,
|
|
173
|
+
description,
|
|
174
|
+
expected_outcome: expectedOutcome,
|
|
175
|
+
why_needed: whyNeeded,
|
|
176
|
+
completion_criteria_json: JSON.stringify(completionCriteria),
|
|
177
|
+
verification_requirements_json: JSON.stringify(verificationRequirements),
|
|
178
|
+
affected_boundaries_json: JSON.stringify(affectedBoundaries),
|
|
179
|
+
priority,
|
|
180
|
+
risk,
|
|
181
|
+
estimate,
|
|
182
|
+
due_at: dueAt,
|
|
183
|
+
enabling: enabling ? 1 : 0,
|
|
184
|
+
enabled_feature_id: enabling ? enabledFeatureId : null,
|
|
185
|
+
enabling_rationale: enabling ? enablingRationale : null,
|
|
186
|
+
}, { projectId: feature.project_id, actor, sessionId, activityType: "task_created" });
|
|
187
|
+
|
|
188
|
+
for (const l of links) await attachLink(db, row.id, l);
|
|
189
|
+
for (const dep of dependsOn) {
|
|
190
|
+
if (dep === row.id) continue;
|
|
191
|
+
await db.run(
|
|
192
|
+
"INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id, dependency_type) VALUES (?,?,?)",
|
|
193
|
+
row.id, typeof dep === "string" ? dep : dep.taskId, typeof dep === "string" ? "blocks" : dep.type ?? "blocks",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (status !== "draft") return move(db, row, status, { actor, sessionId });
|
|
197
|
+
return row;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Edit the description of the work. Status never moves through here. */
|
|
202
|
+
export async function updateTask(root, taskId, values = {}, { actor = "superdev", expectedVersion = null } = {}) {
|
|
203
|
+
if (values.status) {
|
|
204
|
+
throw new TaskError(E.STATUS_VIA_TRANSITION,
|
|
205
|
+
"Status moves through the lifecycle functions so it always leaves history. Use startTask, blockTask, completeTask and the rest.");
|
|
206
|
+
}
|
|
207
|
+
return mutate(root, async (db) => {
|
|
208
|
+
const task = await taskOr404(db, taskId);
|
|
209
|
+
const row = { ...values };
|
|
210
|
+
for (const [from, to] of [
|
|
211
|
+
["expectedOutcome", "expected_outcome"], ["whyNeeded", "why_needed"], ["dueAt", "due_at"],
|
|
212
|
+
["parentTaskId", "parent_task_id"], ["categoryId", "category_id"],
|
|
213
|
+
["enabledFeatureId", "enabled_feature_id"], ["enablingRationale", "enabling_rationale"],
|
|
214
|
+
]) {
|
|
215
|
+
if (from in row) { row[to] = row[from]; delete row[from]; }
|
|
216
|
+
}
|
|
217
|
+
for (const [key, column] of [
|
|
218
|
+
["completionCriteria", "completion_criteria_json"],
|
|
219
|
+
["verificationRequirements", "verification_requirements_json"],
|
|
220
|
+
["affectedBoundaries", "affected_boundaries_json"],
|
|
221
|
+
]) {
|
|
222
|
+
if (key in row) { row[column] = JSON.stringify(row[key]); delete row[key]; }
|
|
223
|
+
}
|
|
224
|
+
if ("enabling" in row) row.enabling = row.enabling ? 1 : 0;
|
|
225
|
+
return patch(db, "task", taskId, expectedVersion ?? task.version, row, {
|
|
226
|
+
projectId: task.project_id, actor, activityType: "task_updated",
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function attachLink(db, taskId, l) {
|
|
232
|
+
const targetType = l.targetType ?? l.target_type;
|
|
233
|
+
const targetId = l.targetId ?? l.target_id;
|
|
234
|
+
const table = TARGET_TABLE[targetType];
|
|
235
|
+
if (!table) {
|
|
236
|
+
throw new TaskError(E.UNKNOWN_TARGET,
|
|
237
|
+
`${targetType} is not something a task can implement. Use one of: ${Object.keys(TARGET_TABLE).join(", ")}.`);
|
|
238
|
+
}
|
|
239
|
+
const target = await db.get(`SELECT id FROM ${table} WHERE id = ?`, targetId);
|
|
240
|
+
if (!target) throw new TaskError(E.NOT_FOUND, `${targetType} ${targetId} does not exist, so a task cannot claim to implement it.`);
|
|
241
|
+
await db.run(
|
|
242
|
+
"INSERT OR IGNORE INTO task_contract_links (task_id, target_type, target_id, relationship) VALUES (?,?,?,?)",
|
|
243
|
+
taskId, targetType, targetId, l.relationship ?? "implements",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Point a task at the contract it implements. Brief 7.2. */
|
|
248
|
+
export async function linkContract(root, taskId, link, { actor = "superdev" } = {}) {
|
|
249
|
+
return mutate(root, async (db) => {
|
|
250
|
+
const task = await taskOr404(db, taskId);
|
|
251
|
+
await attachLink(db, taskId, link);
|
|
252
|
+
await recordActivity(db, task.project_id, {
|
|
253
|
+
type: "task_updated",
|
|
254
|
+
actor,
|
|
255
|
+
taskId,
|
|
256
|
+
featureId: task.feature_id,
|
|
257
|
+
summary: `${taskId} now implements ${link.targetType ?? link.target_type} ${link.targetId ?? link.target_id}.`,
|
|
258
|
+
metadata: { link },
|
|
259
|
+
});
|
|
260
|
+
return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A subtask exists to make execution clearer, so it carries the parent's feature. */
|
|
265
|
+
export async function addSubtask(root, parentTaskId, input = {}) {
|
|
266
|
+
const parent = await query(root, (db) => db.get("SELECT * FROM tasks WHERE id = ?", parentTaskId));
|
|
267
|
+
if (!parent) throw new TaskError(E.NOT_FOUND, `Task ${parentTaskId} does not exist, so nothing can hang off it.`);
|
|
268
|
+
return createTask(root, {
|
|
269
|
+
...input,
|
|
270
|
+
featureId: input.featureId ?? parent.feature_id,
|
|
271
|
+
parentTaskId,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------- assignment
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Claim a task for a developer, agent, branch and session. The database keeps a
|
|
279
|
+
* partial unique index over active assignments, so a second claim is impossible
|
|
280
|
+
* rather than merely unlikely; this reports who holds it instead of a constraint.
|
|
281
|
+
*/
|
|
282
|
+
export async function claimTask(root, taskId, who = {}) {
|
|
283
|
+
let { developerId = null, agentId = null, branchId = null, sessionId = null, actor = "superdev" } = who;
|
|
284
|
+
|
|
285
|
+
// A claim whose whole purpose is recording who holds the task is worthless
|
|
286
|
+
// with three nulls in it, so identity is resolved here rather than left to
|
|
287
|
+
// each caller to remember. It runs BEFORE mutate opens: resolveIdentity shells
|
|
288
|
+
// out to git, and subprocess work inside a write transaction holds the
|
|
289
|
+
// exclusive lock across it.
|
|
290
|
+
if (!developerId && !agentId) {
|
|
291
|
+
try {
|
|
292
|
+
const { resolveIdentity, detectHarness } = await import("../runtime/identity.mjs");
|
|
293
|
+
const identity = await resolveIdentity(root, {
|
|
294
|
+
harness: detectHarness(process.env).harness,
|
|
295
|
+
displayName: who.displayName ?? (actor !== "superdev" ? actor : undefined),
|
|
296
|
+
});
|
|
297
|
+
developerId = developerId ?? identity.developer?.id ?? null;
|
|
298
|
+
agentId = agentId ?? identity.agent?.id ?? null;
|
|
299
|
+
branchId = branchId ?? identity.branch?.id ?? null;
|
|
300
|
+
} catch {
|
|
301
|
+
// An unresolvable identity must not block the claim: the task still gets
|
|
302
|
+
// held, and the assignment simply says so rather than naming someone.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return mutate(root, async (db) => {
|
|
307
|
+
const task = await taskOr404(db, taskId);
|
|
308
|
+
if (!OPEN(task.status)) {
|
|
309
|
+
throw new TaskError(E.INVALID_TRANSITION, `${taskId} is ${task.status} and is not open work. Reopen it before claiming it.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// These are identifiers, and a person naturally passes a name. Checking here
|
|
313
|
+
// turns "FOREIGN KEY constraint failed" into a sentence that says which one
|
|
314
|
+
// was not found and how to see the real ones.
|
|
315
|
+
for (const [label, id, table, listing] of [
|
|
316
|
+
["developer", developerId, "developers", "superdev services"],
|
|
317
|
+
["agent", agentId, "agents", "superdev services"],
|
|
318
|
+
["branch", branchId, "branches", "superdev status"],
|
|
319
|
+
["session", sessionId, "work_sessions", "superdev services"],
|
|
320
|
+
]) {
|
|
321
|
+
if (!id) continue;
|
|
322
|
+
const found = await db.get(`SELECT id FROM ${table} WHERE id = ?`, id);
|
|
323
|
+
if (!found) {
|
|
324
|
+
throw new TaskError(
|
|
325
|
+
E.NOT_FOUND,
|
|
326
|
+
`There is no ${label} ${id} in this project. That flag takes the ${label}'s identifier, not their name; ${listing} lists the real ones. Leaving it off lets Superdev work out who is claiming.`,
|
|
327
|
+
{ [`${label}Id`]: id },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const held = await activeAssignment(db, taskId);
|
|
332
|
+
if (held) {
|
|
333
|
+
// A lease from another machine names an alias rather than a person,
|
|
334
|
+
// because section 18 forbids disclosing who works where. Saying which
|
|
335
|
+
// alias holds it and until when is enough to act on, and an expired lease
|
|
336
|
+
// is not a claim: a machine that crashed must not hold a task forever.
|
|
337
|
+
if (held.lease_holder && held.origin_peer && leaseHolds(held)) {
|
|
338
|
+
throw new TaskError(E.ALREADY_CLAIMED,
|
|
339
|
+
`${taskId} is held by ${held.lease_holder} on another machine${
|
|
340
|
+
held.lease_expires_at ? `, until ${held.lease_expires_at}` : ""
|
|
341
|
+
}. Their lease has to lapse or be released there before this machine can take it.`,
|
|
342
|
+
{ leaseHolder: held.lease_holder, expiresAt: held.lease_expires_at });
|
|
343
|
+
}
|
|
344
|
+
if (held.lease_holder && held.origin_peer && !leaseHolds(held)) {
|
|
345
|
+
// Lapsed. Clearing it here is what makes expiry mean anything.
|
|
346
|
+
await db.run("UPDATE task_assignments SET active = 0, released_at = ? WHERE id = ?",
|
|
347
|
+
nowIso(), held.id);
|
|
348
|
+
} else {
|
|
349
|
+
throw new TaskError(E.ALREADY_CLAIMED,
|
|
350
|
+
`${taskId} is already claimed by ${await holderOf(db, held)} since ${held.assigned_at}. Ask them to release it, or pick up another task.`,
|
|
351
|
+
{ assignmentId: held.id, developerId: held.developer_id, agentId: held.agent_id });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A lease is a statement to somebody else. With no peer configured there is
|
|
356
|
+
// nobody to hear it, and the local unique index is already the whole answer.
|
|
357
|
+
const peer = await db.get("SELECT alias FROM sync_peers WHERE status = 'connected' LIMIT 1")
|
|
358
|
+
.catch(() => null);
|
|
359
|
+
try {
|
|
360
|
+
await create(db, "task_assignment", {
|
|
361
|
+
task_id: taskId,
|
|
362
|
+
developer_id: developerId,
|
|
363
|
+
agent_id: agentId,
|
|
364
|
+
branch_id: branchId,
|
|
365
|
+
session_id: sessionId,
|
|
366
|
+
assigned_at: nowIso(),
|
|
367
|
+
active: 1,
|
|
368
|
+
lease_holder: peer?.alias ?? null,
|
|
369
|
+
lease_expires_at: peer ? leaseExpiry(nowIso()) : null,
|
|
370
|
+
}, { projectId: task.project_id, actor, sessionId, taskId, activityType: "task_claimed",
|
|
371
|
+
activitySummary: `${taskId} claimed.` });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
// Only a uniqueness failure means someone else got the claim first. The
|
|
374
|
+
// test used to accept any message containing "constraint", so a foreign
|
|
375
|
+
// key violation, which is an unknown developer, agent or branch, was
|
|
376
|
+
// reported as a phantom competing session and the person was told to pick
|
|
377
|
+
// up another task. Rechecking the assignment table is what settles it: a
|
|
378
|
+
// real lost race leaves a holder behind, and nothing else does.
|
|
379
|
+
const unique = /UNIQUE/i.test(String(err?.message ?? ""));
|
|
380
|
+
const now = unique ? await activeAssignment(db, taskId) : null;
|
|
381
|
+
if (!now) throw err;
|
|
382
|
+
throw new TaskError(E.ALREADY_CLAIMED,
|
|
383
|
+
`${taskId} was claimed by ${await holderOf(db, now)} a moment before this claim. Pick up another task.`);
|
|
384
|
+
}
|
|
385
|
+
return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Hand a task back. The task keeps its status; only the claim ends. */
|
|
390
|
+
export async function releaseTask(root, taskId, { actor = "superdev", reason = null } = {}) {
|
|
391
|
+
return mutate(root, async (db) => {
|
|
392
|
+
const task = await taskOr404(db, taskId);
|
|
393
|
+
const released = await release(db, task, actor, reason ? `${taskId} released: ${reason}` : null);
|
|
394
|
+
if (!released) throw new TaskError(E.NOT_CLAIMED, `${taskId} is not claimed by anyone, so there is nothing to release.`);
|
|
395
|
+
return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------- transitions
|
|
400
|
+
|
|
401
|
+
export async function startTask(root, taskId, { actor = "superdev", sessionId = null, note = null } = {}) {
|
|
402
|
+
return mutate(root, async (db) => {
|
|
403
|
+
const task = await taskOr404(db, taskId);
|
|
404
|
+
const blockers = await db.all(
|
|
405
|
+
`SELECT d.depends_on_task_id AS id, t.name, t.status FROM task_dependencies d
|
|
406
|
+
JOIN tasks t ON t.id = d.depends_on_task_id
|
|
407
|
+
WHERE d.task_id = ? AND d.dependency_type = 'blocks'
|
|
408
|
+
AND t.status NOT IN ('complete','cancelled','superseded')`,
|
|
409
|
+
taskId,
|
|
410
|
+
);
|
|
411
|
+
if (blockers.length) {
|
|
412
|
+
// Not a refusal: the person may know something the graph does not. It is
|
|
413
|
+
// recorded so the decision to start anyway is visible afterwards.
|
|
414
|
+
await recordActivity(db, task.project_id, {
|
|
415
|
+
type: "task_updated",
|
|
416
|
+
actor, sessionId, taskId, featureId: task.feature_id,
|
|
417
|
+
summary: `${taskId} started while ${blockers.map((b) => b.id).join(", ")} are still open.`,
|
|
418
|
+
metadata: { blockers: blockers.map((b) => ({ id: b.id, status: b.status })) },
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return move(db, task, "in_progress", { actor, sessionId, note });
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function blockTask(root, taskId, { reason, actor = "superdev", sessionId = null } = {}) {
|
|
426
|
+
// Captured before the move, so the reason is remembered even if the
|
|
427
|
+
// transition is refused: knowing what blocked someone is useful either way.
|
|
428
|
+
await rememberMoment(root, {
|
|
429
|
+
type: "task_blocked",
|
|
430
|
+
subjectId: taskId,
|
|
431
|
+
title: `${taskId} blocked`,
|
|
432
|
+
content: String(reason ?? "No reason was given."),
|
|
433
|
+
sourceRef: taskId,
|
|
434
|
+
taskId,
|
|
435
|
+
sessionId,
|
|
436
|
+
links: [{ type: "task", id: taskId }],
|
|
437
|
+
});
|
|
438
|
+
if (!reason) throw new TaskError(E.REASON_REQUIRED, "A blocked task needs the reason it is blocked, in plain language, so the next person can unblock it.");
|
|
439
|
+
return mutate(root, async (db) => {
|
|
440
|
+
const task = await taskOr404(db, taskId);
|
|
441
|
+
return move(db, task, "blocked", { actor, sessionId, reason, note: reason });
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Return to whatever the task was doing before it blocked, not to a guess. */
|
|
446
|
+
export async function unblockTask(root, taskId, { actor = "superdev", note = null, to = null } = {}) {
|
|
447
|
+
return mutate(root, async (db) => {
|
|
448
|
+
const task = await taskOr404(db, taskId);
|
|
449
|
+
if (task.status !== "blocked") {
|
|
450
|
+
throw new TaskError(E.INVALID_TRANSITION, `${taskId} is ${task.status}, not blocked, so there is nothing to unblock.`);
|
|
451
|
+
}
|
|
452
|
+
const previous = await db.get(
|
|
453
|
+
`SELECT from_status FROM status_history
|
|
454
|
+
WHERE record_type = 'task' AND record_id = ? AND to_status = 'blocked'
|
|
455
|
+
ORDER BY sequence DESC LIMIT 1`,
|
|
456
|
+
taskId,
|
|
457
|
+
);
|
|
458
|
+
const target = to ?? (["in_progress", "in_review", "verifying"].includes(previous?.from_status) ? previous.from_status : "ready");
|
|
459
|
+
return move(db, task, target, { actor, note, activityType: "task_unblocked" });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function submitForReview(root, taskId, { actor = "superdev", note = null } = {}) {
|
|
464
|
+
return mutate(root, async (db) => move(db, await taskOr404(db, taskId), "in_review", { actor, note }));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function verifyTask(root, taskId, { actor = "superdev", note = null } = {}) {
|
|
468
|
+
return mutate(root, async (db) => move(db, await taskOr404(db, taskId), "verifying", { actor, note }));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------- completion
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The accepted test plans that cover a task, and whether each has been run.
|
|
475
|
+
*
|
|
476
|
+
* Section 9.3 requires that the product tests defined by the accepted test plan
|
|
477
|
+
* pass before a task completes. Which plan covers which task is a question of
|
|
478
|
+
* scope: a plan written for a feature covers that feature's work, a plan
|
|
479
|
+
* written for a module covers everything inside it, and a plan with no scope at
|
|
480
|
+
* all is the product's own, which covers everything.
|
|
481
|
+
*
|
|
482
|
+
* A plan is satisfied by a passing run recorded anywhere in the project, not by
|
|
483
|
+
* one per task. Requiring every task to run the whole product suite again would
|
|
484
|
+
* be theatre: the run either passed or it did not, and re-running it for the
|
|
485
|
+
* next task is what `superdev verify` is for. A run whose re-check has since
|
|
486
|
+
* failed stops counting, which is the point of recording the command.
|
|
487
|
+
*/
|
|
488
|
+
export async function testPlansInScope(db, task) {
|
|
489
|
+
const feature = task.feature_id
|
|
490
|
+
? await db.get("SELECT id, module_id FROM features WHERE id = ?", task.feature_id)
|
|
491
|
+
: null;
|
|
492
|
+
const plans = await db.all(
|
|
493
|
+
`SELECT p.id, p.name, p.strategy, p.how_to_run, p.passing_condition, p.status,
|
|
494
|
+
p.feature_id, p.module_id, p.workflow_id,
|
|
495
|
+
(SELECT COUNT(*) FROM verification_evidence e
|
|
496
|
+
WHERE e.test_plan_id = p.id AND e.status = 'current' AND e.result = 'pass'
|
|
497
|
+
AND (e.last_check_result IS NULL OR e.last_check_result = 'pass')) AS passing_runs
|
|
498
|
+
FROM test_plans p
|
|
499
|
+
WHERE p.status = 'accepted'
|
|
500
|
+
AND (
|
|
501
|
+
(p.feature_id IS NULL AND p.module_id IS NULL AND p.workflow_id IS NULL)
|
|
502
|
+
OR (p.feature_id IS NOT NULL AND p.feature_id = ?)
|
|
503
|
+
OR (p.module_id IS NOT NULL AND p.module_id = ?)
|
|
504
|
+
)
|
|
505
|
+
ORDER BY p.id`,
|
|
506
|
+
task.feature_id ?? null,
|
|
507
|
+
feature?.module_id ?? null,
|
|
508
|
+
);
|
|
509
|
+
return plans.map((p) => ({ ...p, satisfied: Number(p.passing_runs) > 0 }));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function completionBlockers(db, task) {
|
|
513
|
+
const problems = [];
|
|
514
|
+
const open = await db.all(
|
|
515
|
+
`SELECT id, name, status FROM tasks
|
|
516
|
+
WHERE parent_task_id = ? AND status NOT IN ('complete','cancelled','superseded')`,
|
|
517
|
+
task.id,
|
|
518
|
+
);
|
|
519
|
+
if (open.length) {
|
|
520
|
+
problems.push(new TaskError(E.OPEN_SUBTASKS,
|
|
521
|
+
`${task.id} has ${open.length} subtask${open.length === 1 ? "" : "s"} still open: ${open.map((t) => `${t.id} (${t.status})`).join(", ")}. Complete or cancel them first.`,
|
|
522
|
+
{ subtasks: open }));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const evidence = await db.all(
|
|
526
|
+
"SELECT * FROM verification_evidence WHERE task_id = ? AND status = 'current'", task.id,
|
|
527
|
+
);
|
|
528
|
+
const required = json(task.verification_requirements_json, []);
|
|
529
|
+
const failing = evidence.filter((e) => e.result === "fail");
|
|
530
|
+
if (failing.length) {
|
|
531
|
+
problems.push(new TaskError(E.EVIDENCE_FAILING,
|
|
532
|
+
`${task.id} has failing verification evidence: ${failing.map((e) => e.summary).join("; ")}. Fix the work or record why the evidence no longer applies.`,
|
|
533
|
+
{ evidence: failing }));
|
|
534
|
+
}
|
|
535
|
+
// Every stated requirement needs its own passing evidence, not one row
|
|
536
|
+
// standing in for all of them, and work that states no requirement still has
|
|
537
|
+
// to show something. The previous rule let a task with three requirements
|
|
538
|
+
// complete on one passing row, while listing all three in the refusal as
|
|
539
|
+
// though each had been checked, and let a task with no stated requirement
|
|
540
|
+
// complete on no evidence at all.
|
|
541
|
+
const passing = evidence.filter((e) => e.result === "pass");
|
|
542
|
+
if (required.length && passing.length < required.length) {
|
|
543
|
+
problems.push(new TaskError(E.EVIDENCE_MISSING,
|
|
544
|
+
`${task.id} states ${count(required.length, "verification requirement")} and carries ${count(passing.length, "passing result")}: ${required.join("; ")}. Run the rest and attach the evidence.`,
|
|
545
|
+
{ required, passing: passing.length }));
|
|
546
|
+
}
|
|
547
|
+
if (!required.length && !passing.length) {
|
|
548
|
+
problems.push(new TaskError(E.EVIDENCE_MISSING,
|
|
549
|
+
`${task.id} states no verification requirement and carries no evidence, so nothing shows it is done. Attach a result, or say what verifying it means with task update --verify.`,
|
|
550
|
+
{ required: [] }));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const criteria = await db.all(
|
|
554
|
+
`SELECT ac.id, ac.criterion, ac.status FROM task_contract_links l
|
|
555
|
+
JOIN feature_acceptance_criteria ac ON ac.id = l.target_id
|
|
556
|
+
WHERE l.task_id = ? AND l.target_type = 'acceptance_criterion'`,
|
|
557
|
+
task.id,
|
|
558
|
+
);
|
|
559
|
+
const unmet = criteria.filter((c) => c.status === "unmet");
|
|
560
|
+
if (unmet.length) {
|
|
561
|
+
problems.push(new TaskError(E.ACCEPTANCE_UNMET,
|
|
562
|
+
`${task.id} verifies acceptance criteria that are still unmet: ${unmet.map((c) => `${c.id} (${c.criterion})`).join("; ")}. Attach a passing result to each with task evidence ${task.id} --criterion ${unmet[0].id}, or waive it with feature waive ${unmet[0].id} --reason.`,
|
|
563
|
+
{ criteria: unmet }));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const unrun = (await testPlansInScope(db, task)).filter((p) => !p.satisfied);
|
|
567
|
+
if (unrun.length) {
|
|
568
|
+
problems.push(new TaskError(E.TEST_PLAN_UNSATISFIED,
|
|
569
|
+
`${task.id} is covered by ${count(unrun.length, "accepted test plan")} with no passing run: ${
|
|
570
|
+
unrun.map((p) => `${p.id} (${p.name}), run ${p.how_to_run}`).join("; ")
|
|
571
|
+
}. Run it and attach the result with task evidence --plan.`,
|
|
572
|
+
{ plans: unrun.map((p) => ({ id: p.id, name: p.name, howToRun: p.how_to_run })) }));
|
|
573
|
+
}
|
|
574
|
+
return problems;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Complete a task only when it is actually finished: verification evidence is
|
|
579
|
+
* present and passing, every acceptance criterion it verifies is met, and no
|
|
580
|
+
* subtask is still open. The assignment is released as part of the same
|
|
581
|
+
* transaction, so a completed task is never left held by nobody.
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* Remember what a lifecycle moment showed.
|
|
585
|
+
*
|
|
586
|
+
* Imported lazily and never awaited for correctness: section 15.6 asks for
|
|
587
|
+
* capture at these moments, and P-008 says memory is recall rather than
|
|
588
|
+
* authority, so a failure to remember must never fail the work that happened.
|
|
589
|
+
*/
|
|
590
|
+
async function rememberMoment(root, event) {
|
|
591
|
+
try {
|
|
592
|
+
const { capture } = await import("../memory/capture.mjs");
|
|
593
|
+
await capture(root, event);
|
|
594
|
+
} catch { /* recall is not authority, and its absence is not a failure */ }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function completeTask(root, taskId, { actor = "superdev", sessionId = null, note = null } = {}) {
|
|
598
|
+
return mutate(root, async (db) => {
|
|
599
|
+
const task = await taskOr404(db, taskId);
|
|
600
|
+
const problems = await completionBlockers(db, task);
|
|
601
|
+
if (problems.length) {
|
|
602
|
+
if (problems.length === 1) throw problems[0];
|
|
603
|
+
const first = problems[0];
|
|
604
|
+
throw new TaskError(first.code, problems.map((p) => p.message).join(" "), { problems: problems.map((p) => p.code) });
|
|
605
|
+
}
|
|
606
|
+
const row = await move(db, task, "complete", { actor, sessionId, note });
|
|
607
|
+
await release(db, task, actor, `${taskId} completed, assignment released.`);
|
|
608
|
+
return { row, task };
|
|
609
|
+
}).then(async ({ row, task }) => {
|
|
610
|
+
// A feature whose last task just finished is finished, and saying so is a
|
|
611
|
+
// derivation rather than a claim: completionReadiness reads the criteria
|
|
612
|
+
// and the tasks and decides. It runs after the commit and never throws
|
|
613
|
+
// outward, because a task genuinely completed must not be undone by the
|
|
614
|
+
// feature above it not being ready.
|
|
615
|
+
if (task.feature_id) {
|
|
616
|
+
try {
|
|
617
|
+
const { completeFeature } = await import("../features/acceptance.mjs");
|
|
618
|
+
await completeFeature(root, task.feature_id, { actor, apply: true });
|
|
619
|
+
} catch {
|
|
620
|
+
// Not finished yet, which is the normal case and not a failure.
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return { row, task };
|
|
624
|
+
}).then(async ({ row, task }) => {
|
|
625
|
+
// Remembered after the transaction commits, so nothing is recalled that did
|
|
626
|
+
// not actually land. Section 15.6 names task completion as a moment worth
|
|
627
|
+
// capturing, and completion is confirmed rather than inferred because the
|
|
628
|
+
// gate only allows it once evidence passed.
|
|
629
|
+
await rememberMoment(root, {
|
|
630
|
+
type: "task_completed",
|
|
631
|
+
subjectId: taskId,
|
|
632
|
+
title: `${taskId} complete: ${task.name ?? ""}`.trim().slice(0, 160),
|
|
633
|
+
content: `${task.name ?? taskId} completed. ${task.expected_outcome ?? "No outcome was recorded."}`.trim(),
|
|
634
|
+
confirmed: true,
|
|
635
|
+
sourceRef: taskId,
|
|
636
|
+
taskId,
|
|
637
|
+
featureId: task.feature_id ?? null,
|
|
638
|
+
sessionId,
|
|
639
|
+
links: [
|
|
640
|
+
{ type: "task", id: taskId },
|
|
641
|
+
task.feature_id ? { type: "feature", id: task.feature_id } : null,
|
|
642
|
+
].filter(Boolean),
|
|
643
|
+
});
|
|
644
|
+
return row;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Reopening is a recorded decision, never a quiet edit of a finished record. */
|
|
649
|
+
export async function reopenTask(root, taskId, { reason, actor = "superdev", to = null } = {}) {
|
|
650
|
+
if (!reason) throw new TaskError(E.REASON_REQUIRED, "Reopening a finished task needs a reason, because the record already said it was done.");
|
|
651
|
+
return mutate(root, async (db) => {
|
|
652
|
+
const task = await taskOr404(db, taskId);
|
|
653
|
+
if (OPEN(task.status)) {
|
|
654
|
+
throw new TaskError(E.INVALID_TRANSITION, `${taskId} is ${task.status} and already open. There is nothing to reopen.`);
|
|
655
|
+
}
|
|
656
|
+
const target = to ?? (task.status === "complete" ? "in_progress" : "ready");
|
|
657
|
+
const cleared = await patch(db, "task", taskId, task.version, {
|
|
658
|
+
completed_at: null,
|
|
659
|
+
cancelled_at: null,
|
|
660
|
+
}, { projectId: task.project_id, actor, activityType: "task_reopened",
|
|
661
|
+
activitySummary: `${taskId} reopened: ${reason}` });
|
|
662
|
+
return move(db, cleared, target, { actor, note: reason, activityType: "task_reopened" });
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function cancelTask(root, taskId, { reason, actor = "superdev" } = {}) {
|
|
667
|
+
if (!reason) throw new TaskError(E.REASON_REQUIRED, "Cancelling a task needs a reason, so the next person does not derive it again by accident.");
|
|
668
|
+
return mutate(root, async (db) => {
|
|
669
|
+
const task = await taskOr404(db, taskId);
|
|
670
|
+
const row = await move(db, task, "cancelled", { actor, note: reason });
|
|
671
|
+
await release(db, task, actor, `${taskId} cancelled, assignment released.`);
|
|
672
|
+
return row;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ------------------------------------------------------------------ evidence
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Record what was actually observed. Passing evidence against an acceptance
|
|
680
|
+
* criterion marks that criterion met, which is the only way it is ever met.
|
|
681
|
+
*/
|
|
682
|
+
export async function attachEvidence(root, taskId, evidence = {}) {
|
|
683
|
+
const {
|
|
684
|
+
evidenceType = "manual_check", summary, reference = null, result = "pass",
|
|
685
|
+
acceptanceCriterionId = null, contentHash = null, recordedBy = null,
|
|
686
|
+
actor = "superdev", sessionId = null, checkCommand = null, testPlanId = null,
|
|
687
|
+
} = evidence;
|
|
688
|
+
if (!summary) throw new TaskError(E.REASON_REQUIRED, "Evidence needs a one-line summary of what was observed, not just a result.");
|
|
689
|
+
|
|
690
|
+
return mutate(root, async (db) => {
|
|
691
|
+
const task = await taskOr404(db, taskId);
|
|
692
|
+
if (acceptanceCriterionId && result === "pass") {
|
|
693
|
+
// Fresh proof of a criterion retires the stale proof it replaces. Marking
|
|
694
|
+
// evidence stale is verify saying the check stopped passing, and the
|
|
695
|
+
// remedy it prints is to run the check again and record fresh evidence.
|
|
696
|
+
// Doing exactly that used to leave the stale row warning forever, so the
|
|
697
|
+
// product told a reader to do something that did not clear the warning.
|
|
698
|
+
await db.run(
|
|
699
|
+
`UPDATE verification_evidence SET status = 'superseded'
|
|
700
|
+
WHERE acceptance_criterion_id = ? AND status = 'stale'`,
|
|
701
|
+
acceptanceCriterionId,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
if (testPlanId) {
|
|
705
|
+
const plan = await db.get("SELECT id, status FROM test_plans WHERE id = ?", testPlanId);
|
|
706
|
+
if (!plan) throw new TaskError(E.UNKNOWN_TARGET, `There is no test plan ${testPlanId}. Run superdev test-plan list to see them.`);
|
|
707
|
+
if (plan.status !== "accepted") {
|
|
708
|
+
throw new TaskError(E.UNKNOWN_TARGET,
|
|
709
|
+
`${testPlanId} is ${plan.status}, not accepted, so a run of it does not satisfy anything yet.`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const row = await create(db, "verification_evidence", {
|
|
713
|
+
project_id: task.project_id,
|
|
714
|
+
task_id: taskId,
|
|
715
|
+
feature_id: task.feature_id,
|
|
716
|
+
acceptance_criterion_id: acceptanceCriterionId,
|
|
717
|
+
evidence_type: evidenceType,
|
|
718
|
+
summary,
|
|
719
|
+
reference,
|
|
720
|
+
result,
|
|
721
|
+
content_hash: contentHash,
|
|
722
|
+
// The command that proved it, so the claim can be checked again rather
|
|
723
|
+
// than believed forever. Null is honest for a check nobody can automate.
|
|
724
|
+
check_command: checkCommand,
|
|
725
|
+
// Which agreed verification strategy this was a run of. Section 9.3 makes
|
|
726
|
+
// the accepted test plan a completion condition, and a plan nothing can
|
|
727
|
+
// point at is a condition nothing can satisfy.
|
|
728
|
+
test_plan_id: testPlanId,
|
|
729
|
+
recorded_by: recordedBy ?? actor,
|
|
730
|
+
recorded_at: nowIso(),
|
|
731
|
+
status: "current",
|
|
732
|
+
}, {
|
|
733
|
+
projectId: task.project_id, actor, sessionId, taskId,
|
|
734
|
+
activityType: "verification_attached",
|
|
735
|
+
activitySummary: `${result === "pass" ? "Passing" : result === "fail" ? "Failing" : "Inconclusive"} evidence recorded for ${taskId}: ${summary}`,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// feature_acceptance_criteria carries no version column, so versionedUpdate
|
|
739
|
+
// cannot address it. The activity event is written here rather than lost.
|
|
740
|
+
//
|
|
741
|
+
// Passing and failing are separate branches on purpose. Nesting the failure
|
|
742
|
+
// case inside the passing case made it unreachable, so a later failure never
|
|
743
|
+
// retracted the claim and the acceptance component and the evidence
|
|
744
|
+
// component of the same report disagreed.
|
|
745
|
+
if (acceptanceCriterionId && result === "pass") {
|
|
746
|
+
const before = await db.get("SELECT * FROM feature_acceptance_criteria WHERE id = ?", acceptanceCriterionId);
|
|
747
|
+
if (before && before.status !== "met") {
|
|
748
|
+
await db.run(
|
|
749
|
+
"UPDATE feature_acceptance_criteria SET status = 'met', evidence_id = ? WHERE id = ?",
|
|
750
|
+
row.id, acceptanceCriterionId,
|
|
751
|
+
);
|
|
752
|
+
await recordActivity(db, task.project_id, {
|
|
753
|
+
type: "specification_changed",
|
|
754
|
+
actor, sessionId, taskId, featureId: task.feature_id,
|
|
755
|
+
summary: `Acceptance criterion ${acceptanceCriterionId} is met, evidenced by ${row.id}.`,
|
|
756
|
+
metadata: { criterion: acceptanceCriterionId, evidence: row.id },
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
} else if (acceptanceCriterionId && result === "fail") {
|
|
760
|
+
const before = await db.get("SELECT * FROM feature_acceptance_criteria WHERE id = ?", acceptanceCriterionId);
|
|
761
|
+
if (before && before.status === "met") {
|
|
762
|
+
await db.run(
|
|
763
|
+
"UPDATE feature_acceptance_criteria SET status = 'unmet', evidence_id = NULL WHERE id = ?",
|
|
764
|
+
acceptanceCriterionId,
|
|
765
|
+
);
|
|
766
|
+
await recordActivity(db, task.project_id, {
|
|
767
|
+
type: "specification_changed",
|
|
768
|
+
actor, sessionId, taskId, featureId: task.feature_id,
|
|
769
|
+
summary: `Acceptance criterion ${acceptanceCriterionId} is no longer met: ${row.id} records a failure.`,
|
|
770
|
+
metadata: { criterion: acceptanceCriterionId, evidence: row.id },
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const task_after = await db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
776
|
+
task_after.evidence = row;
|
|
777
|
+
return task_after;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// -------------------------------------------------------------------- search
|
|
782
|
+
|
|
783
|
+
const STOP = new Set([
|
|
784
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "you", "your", "our",
|
|
785
|
+
"add", "make", "use", "can", "should", "must", "when", "then", "than", "there",
|
|
786
|
+
"task", "work", "please", "need", "needs", "want", "have", "has", "was", "were",
|
|
787
|
+
"are", "not", "but", "all", "any", "its", "it", "a", "an", "of", "to", "in", "on",
|
|
788
|
+
]);
|
|
789
|
+
|
|
790
|
+
const tokenize = (text) => [...new Set(
|
|
791
|
+
String(text ?? "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)),
|
|
792
|
+
)];
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Brief 12.2 step one: before creating work, look for the task that already
|
|
796
|
+
* covers it. Scoring is token overlap over open tasks, which is enough for the
|
|
797
|
+
* few hundred tasks a project holds.
|
|
798
|
+
*
|
|
799
|
+
* ponytail: no full text index and no vector index in this engine, so this is a
|
|
800
|
+
* bounded scan. Ceiling is a few thousand tasks; the upgrade path is the same
|
|
801
|
+
* bounded scan over stored embeddings that memory recall already needs.
|
|
802
|
+
*/
|
|
803
|
+
export async function findTaskForWork(root, { description, featureId = null, limit = 5 } = {}) {
|
|
804
|
+
const wanted = tokenize(description);
|
|
805
|
+
if (!wanted.length) return null;
|
|
806
|
+
return query(root, async (db) => {
|
|
807
|
+
const rows = await db.all(
|
|
808
|
+
`SELECT * FROM tasks
|
|
809
|
+
WHERE status NOT IN ('cancelled','superseded')
|
|
810
|
+
${featureId ? "AND feature_id = ?" : ""}
|
|
811
|
+
ORDER BY (status = 'complete'), id`,
|
|
812
|
+
...(featureId ? [featureId] : []),
|
|
813
|
+
);
|
|
814
|
+
const scored = [];
|
|
815
|
+
for (const row of rows) {
|
|
816
|
+
const hay = tokenize(`${row.name} ${row.description ?? ""} ${row.expected_outcome ?? ""} ${row.why_needed ?? ""}`);
|
|
817
|
+
if (!hay.length) continue;
|
|
818
|
+
const hit = wanted.filter((w) => hay.includes(w));
|
|
819
|
+
const score = hit.length / wanted.length;
|
|
820
|
+
if (score >= 0.34) scored.push({ row, score, matched: hit });
|
|
821
|
+
}
|
|
822
|
+
scored.sort((a, b) => b.score - a.score || a.row.id.localeCompare(b.row.id));
|
|
823
|
+
if (!scored.length) return null;
|
|
824
|
+
const best = scored[0];
|
|
825
|
+
best.row.score = Number(best.score.toFixed(3));
|
|
826
|
+
best.row.matched_terms = best.matched;
|
|
827
|
+
best.row.alternatives = scored.slice(1, limit).map((s) => ({ id: s.row.id, name: s.row.name, score: Number(s.score.toFixed(3)) }));
|
|
828
|
+
return best.row;
|
|
829
|
+
});
|
|
830
|
+
}
|