@deksden-com/dd-flow-cli 0.4.1 → 0.5.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/CHANGELOG.md +33 -4
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +14 -3
- package/dist/cli/run-cli.js +32 -16
- package/dist/domain/flow-contract.js +81 -2
- package/dist/domain/validation.js +56 -28
- package/dist/protocol/local-files.js +1 -16
- package/dist/schemas/code-stage-report.schema.json +2 -2
- package/dist/schemas/flow-contract.schema.json +150 -94
- package/dist/schemas/flow-run.schema.json +129 -23
- package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
- package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
- package/dist/schemas/merge-stage-report.schema.json +2 -2
- package/dist/schemas/plan-stage-report.schema.json +38 -335
- package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
- package/dist/schemas/protocol-plan.schema.json +197 -0
- package/dist/schemas/release-impact.schema.json +9 -5
- package/dist/schemas/session-usage.schema.json +16 -0
- package/dist/schemas/stage-finish-input.schema.json +20 -0
- package/dist/schemas/stage-prompt.schema.json +31 -0
- package/dist/schemas/stage-report.schema.json +20 -0
- package/dist/schemas/stage-start-response.schema.json +29 -0
- package/dist/schemas/timeline-event.schema.json +29 -0
- package/dist/schemas/worktrunk-workspace.schema.json +19 -0
- package/dist/services/branch-context.js +9 -4
- package/dist/services/dashboard.js +51 -26
- package/dist/services/engines.js +86 -19
- package/dist/services/hooks.js +80 -246
- package/dist/services/memory-permissions.js +77 -69
- package/dist/services/plan-runtime.js +124 -0
- package/dist/services/plans.js +22 -84
- package/dist/services/projects.js +2 -1
- package/dist/services/prompts.js +26 -21
- package/dist/services/protocols.js +29 -25
- package/dist/services/run-projection.js +77 -11
- package/dist/services/runs.js +95 -61
- package/dist/services/schema-validation.js +168 -7
- package/dist/services/sessions.js +132 -68
- package/dist/services/stage-lifecycle.js +572 -0
- package/dist/services/tooling.js +285 -0
- package/dist/services/usage.js +183 -30
- package/dist/services/version-status.js +1 -1
- package/dist/services/worktrees.js +88 -39
- package/dist/storage/database.js +72 -30
- package/dist/storage/paths.js +0 -9
- package/package.json +14 -13
- package/tools/worktrunk-manifest.json +34 -0
- package/dist/schemas/flow-run-index-v3.schema.json +0 -203
- package/dist/schemas/flow-run-index.schema.json +0 -175
|
@@ -6,47 +6,55 @@ import { AppError } from "../shared/errors.js";
|
|
|
6
6
|
const schemaId = "dd-flow/memorybank-permissions-preflight@1";
|
|
7
7
|
const flows = ["mb-init", "mb-upgrade", "mb-audit", "mb-fix", "mb-upgrade-review", "custom"];
|
|
8
8
|
const modes = ["read", "write", "repair", "report_only"];
|
|
9
|
-
const maxScannedFiles = 5000;
|
|
10
9
|
export function preflightMemoryPermissions(options) {
|
|
11
10
|
const flow = requireEnum(options.flow, flows, "--flow");
|
|
12
11
|
const mode = requireEnum(options.mode, modes, "--mode");
|
|
13
12
|
const projectRoot = path.resolve(options.root);
|
|
14
13
|
const memoryBank = resolveInside(projectRoot, options.memoryBank);
|
|
15
14
|
const tasks = options.tasks ? resolveInside(projectRoot, options.tasks) : path.join(projectRoot, ".tasks");
|
|
15
|
+
if (options.targets && options.targets.length === 0) {
|
|
16
|
+
throw new AppError("validation", "Exact permission preflight requires at least one target", 2);
|
|
17
|
+
}
|
|
16
18
|
const checks = [];
|
|
17
19
|
const platform = platformInfo(projectRoot);
|
|
18
20
|
checks.push(checkExistingDirectory("project-root-read", projectRoot, "read_existing_file", fs.constants.R_OK));
|
|
19
|
-
if (
|
|
21
|
+
if (options.targets) {
|
|
22
|
+
for (const [index, target] of options.targets.entries()) {
|
|
23
|
+
checks.push(...checkTarget(`exact-target-${index + 1}`, resolveInside(projectRoot, target.path), target.mode));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (flow === "mb-init" && !fs.existsSync(memoryBank)) {
|
|
20
27
|
checks.push(checkCreateDirectory("memory-bank-create", memoryBank));
|
|
21
28
|
}
|
|
22
29
|
else {
|
|
23
30
|
checks.push(checkExistingDirectory("memory-bank-read", memoryBank, "read_existing_file", fs.constants.R_OK));
|
|
24
31
|
if (mode !== "read") {
|
|
25
32
|
checks.push(checkExistingDirectory("memory-bank-write", memoryBank, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
26
|
-
checks.push(...checkWritableTree(memoryBank, "memory-bank-existing-file"));
|
|
27
33
|
checks.push(probeDirectory("memory-bank-probe", memoryBank));
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
if (!options.targets) {
|
|
37
|
+
if (mode === "write" || mode === "repair" || mode === "report_only") {
|
|
38
|
+
if (fs.existsSync(tasks)) {
|
|
39
|
+
checks.push(checkExistingDirectory("tasks-write", tasks, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
40
|
+
checks.push(probeDirectory("tasks-probe", tasks));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
checks.push(checkCreateDirectory("tasks-create", tasks));
|
|
44
|
+
}
|
|
34
45
|
}
|
|
35
46
|
else {
|
|
36
|
-
checks.push(
|
|
47
|
+
checks.push({
|
|
48
|
+
id: "tasks-read-mode-skipped",
|
|
49
|
+
severity: "info",
|
|
50
|
+
path: displayPath(projectRoot, tasks),
|
|
51
|
+
operation: "create_directory",
|
|
52
|
+
status: "skipped",
|
|
53
|
+
reason: "ok",
|
|
54
|
+
side_effect: "none"
|
|
55
|
+
});
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
|
-
else {
|
|
40
|
-
checks.push({
|
|
41
|
-
id: "tasks-read-mode-skipped",
|
|
42
|
-
severity: "info",
|
|
43
|
-
path: displayPath(projectRoot, tasks),
|
|
44
|
-
operation: "create_directory",
|
|
45
|
-
status: "skipped",
|
|
46
|
-
reason: "ok",
|
|
47
|
-
side_effect: "none"
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
58
|
const errors = checks.filter((check) => check.severity === "error" && check.status === "failed").length;
|
|
51
59
|
const warnings = checks.filter((check) => check.severity === "warning").length;
|
|
52
60
|
const ok = errors === 0;
|
|
@@ -78,7 +86,32 @@ function requireEnum(value, allowed, label) {
|
|
|
78
86
|
return value;
|
|
79
87
|
}
|
|
80
88
|
function resolveInside(projectRoot, input) {
|
|
81
|
-
|
|
89
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
90
|
+
const resolved = path.isAbsolute(input) ? path.resolve(input) : path.resolve(resolvedRoot, input);
|
|
91
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
92
|
+
throw new AppError("validation", "Permission target must remain inside project root", 2, { root: resolvedRoot, target: resolved });
|
|
93
|
+
}
|
|
94
|
+
const existingPath = nearestExistingPath(resolved);
|
|
95
|
+
if (existingPath) {
|
|
96
|
+
const realRoot = fs.realpathSync(resolvedRoot);
|
|
97
|
+
const realExisting = fs.realpathSync(existingPath);
|
|
98
|
+
const suffix = path.relative(existingPath, resolved);
|
|
99
|
+
const realResolved = path.resolve(realExisting, suffix);
|
|
100
|
+
if (realResolved !== realRoot && !realResolved.startsWith(`${realRoot}${path.sep}`)) {
|
|
101
|
+
throw new AppError("validation", "Permission target escapes project root through a symlink", 2, { root: realRoot, target: resolved });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
function nearestExistingPath(target) {
|
|
107
|
+
let current = target;
|
|
108
|
+
while (!fs.existsSync(current)) {
|
|
109
|
+
const parent = path.dirname(current);
|
|
110
|
+
if (parent === current)
|
|
111
|
+
return undefined;
|
|
112
|
+
current = parent;
|
|
113
|
+
}
|
|
114
|
+
return current;
|
|
82
115
|
}
|
|
83
116
|
function platformInfo(projectRoot) {
|
|
84
117
|
const user = safeUserInfo();
|
|
@@ -132,55 +165,26 @@ function checkCreateDirectory(id, target) {
|
|
|
132
165
|
return failedCheck(id, target, "create_directory", "not_writable", errorMessage(error), safeStat(parent));
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
|
-
function
|
|
136
|
-
if (!fs.existsSync(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
while (stack.length > 0 && scanned < maxScannedFiles) {
|
|
142
|
-
const current = stack.pop();
|
|
143
|
-
if (!current)
|
|
144
|
-
continue;
|
|
145
|
-
let entries;
|
|
146
|
-
try {
|
|
147
|
-
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
checks.push(failedCheck(`${prefix}-read-${checks.length + 1}`, current, "read_existing_file", "not_readable", errorMessage(error), safeStat(current)));
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
for (const entry of entries) {
|
|
154
|
-
if (entry.name === ".git")
|
|
155
|
-
continue;
|
|
156
|
-
const entryPath = path.join(current, entry.name);
|
|
157
|
-
scanned += 1;
|
|
158
|
-
if (entry.isDirectory()) {
|
|
159
|
-
stack.push(entryPath);
|
|
160
|
-
checks.push(checkExistingDirectory(`${prefix}-dir-${checks.length + 1}`, entryPath, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
161
|
-
}
|
|
162
|
-
else if (entry.isFile()) {
|
|
163
|
-
checks.push(checkExistingFile(`${prefix}-${checks.length + 1}`, entryPath, fs.constants.R_OK | fs.constants.W_OK));
|
|
164
|
-
}
|
|
165
|
-
if (scanned >= maxScannedFiles)
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
+
function checkTarget(id, target, mode) {
|
|
169
|
+
if (!fs.existsSync(target)) {
|
|
170
|
+
if (mode !== "write")
|
|
171
|
+
return [failedCheck(id, target, "read_existing_file", "not_found", "Path does not exist")];
|
|
172
|
+
const parentCheck = checkCreateDirectory(id, target);
|
|
173
|
+
return parentCheck.status === "passed" ? [parentCheck, probeDirectory(`${id}-probe`, path.dirname(target))] : [parentCheck];
|
|
168
174
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
status: "warning",
|
|
176
|
-
reason: "unknown",
|
|
177
|
-
fs_error: { message: `Stopped after ${maxScannedFiles} filesystem entries` },
|
|
178
|
-
side_effect: "none"
|
|
179
|
-
});
|
|
175
|
+
const stat = safeStat(target);
|
|
176
|
+
if (!stat)
|
|
177
|
+
return [failedCheck(id, target, "stat_metadata", "unknown", "Cannot stat permission target")];
|
|
178
|
+
if (stat.isDirectory()) {
|
|
179
|
+
const check = checkExistingDirectory(id, target, mode === "write" ? "create_file" : "read_existing_file", mode === "write" ? fs.constants.W_OK | fs.constants.X_OK : fs.constants.R_OK);
|
|
180
|
+
return mode === "write" && check.status === "passed" ? [check, probeDirectory(`${id}-probe`, target)] : [check];
|
|
180
181
|
}
|
|
181
|
-
|
|
182
|
+
const check = checkExistingFile(id, target, mode === "write" ? fs.constants.R_OK | fs.constants.W_OK : fs.constants.R_OK, mode === "write" ? "write_existing_file" : "read_existing_file");
|
|
183
|
+
return mode === "write" && check.status === "passed"
|
|
184
|
+
? [check, probeDirectory(`${id}-probe`, path.dirname(target))]
|
|
185
|
+
: [check];
|
|
182
186
|
}
|
|
183
|
-
function checkExistingFile(id, target, access) {
|
|
187
|
+
function checkExistingFile(id, target, access, operation = "write_existing_file") {
|
|
184
188
|
try {
|
|
185
189
|
const stat = fs.statSync(target);
|
|
186
190
|
const immutable = detectImmutableFlag(target);
|
|
@@ -189,10 +193,10 @@ function checkExistingFile(id, target, access) {
|
|
|
189
193
|
}
|
|
190
194
|
fs.accessSync(target, access);
|
|
191
195
|
const ownerWarning = ownerMismatchWarning(id, target, stat);
|
|
192
|
-
return ownerWarning ?? passedCheck(id, target,
|
|
196
|
+
return operation === "write_existing_file" ? ownerWarning ?? passedCheck(id, target, operation, stat) : passedCheck(id, target, operation, stat);
|
|
193
197
|
}
|
|
194
198
|
catch (error) {
|
|
195
|
-
return failedCheck(id, target,
|
|
199
|
+
return failedCheck(id, target, operation, accessReason(error, access), errorMessage(error), safeStat(target));
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
function ownerMismatchWarning(id, target, stat) {
|
|
@@ -211,9 +215,11 @@ function ownerMismatchWarning(id, target, stat) {
|
|
|
211
215
|
}
|
|
212
216
|
function probeDirectory(id, target) {
|
|
213
217
|
const probePath = path.join(target, `.dd-flow-permission-probe-${process.pid}-${Date.now()}`);
|
|
218
|
+
const renamedProbePath = `${probePath}.renamed`;
|
|
214
219
|
try {
|
|
215
220
|
fs.writeFileSync(probePath, "probe\n", { flag: "wx" });
|
|
216
|
-
fs.
|
|
221
|
+
fs.renameSync(probePath, renamedProbePath);
|
|
222
|
+
fs.unlinkSync(renamedProbePath);
|
|
217
223
|
return {
|
|
218
224
|
...metadata(id, target, "create_file", safeStat(target)),
|
|
219
225
|
severity: "info",
|
|
@@ -226,6 +232,8 @@ function probeDirectory(id, target) {
|
|
|
226
232
|
try {
|
|
227
233
|
if (fs.existsSync(probePath))
|
|
228
234
|
fs.unlinkSync(probePath);
|
|
235
|
+
if (fs.existsSync(renamedProbePath))
|
|
236
|
+
fs.unlinkSync(renamedProbePath);
|
|
229
237
|
}
|
|
230
238
|
catch (cleanupError) {
|
|
231
239
|
return {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { validatePlan } from "../domain/validation.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { parseJsonObject } from "../shared/json.js";
|
|
6
|
+
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
7
|
+
export function readCanonicalPlan(planPath, protocolId) {
|
|
8
|
+
if (!fs.existsSync(planPath) || !fs.statSync(planPath).isFile()) {
|
|
9
|
+
throw new AppError("not_found", `Canonical protocol plan is missing: ${planPath}`, 1, { plan_path: planPath });
|
|
10
|
+
}
|
|
11
|
+
const raw = fs.readFileSync(planPath, "utf8");
|
|
12
|
+
const value = parseJsonObject(raw, planPath);
|
|
13
|
+
const plan = validatePlan(value, protocolId);
|
|
14
|
+
const revisionValue = value.revision;
|
|
15
|
+
const revision = typeof revisionValue === "number" && Number.isInteger(revisionValue) && revisionValue > 0 ? revisionValue : 1;
|
|
16
|
+
return { plan, raw, sha256: crypto.createHash("sha256").update(raw).digest("hex"), revision };
|
|
17
|
+
}
|
|
18
|
+
export function boundCanonicalPlan(context, input) {
|
|
19
|
+
const canonical = readCanonicalPlan(input.planPath, input.protocolId);
|
|
20
|
+
const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
|
|
21
|
+
if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
|
|
22
|
+
throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
|
|
23
|
+
protocol_id: input.protocolId,
|
|
24
|
+
expected_revision: binding.plan_revision,
|
|
25
|
+
actual_revision: canonical.revision,
|
|
26
|
+
expected_sha256: binding.plan_sha256,
|
|
27
|
+
actual_sha256: canonical.sha256,
|
|
28
|
+
plan_path: input.planPath
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const now = context.now();
|
|
32
|
+
if (!binding) {
|
|
33
|
+
context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
|
|
34
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.revision, canonical.sha256, input.planPath, now, now]);
|
|
35
|
+
}
|
|
36
|
+
for (const item of canonical.plan.items) {
|
|
37
|
+
context.db.run(`INSERT INTO plan_progress
|
|
38
|
+
(project_id, protocol_id, item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required, updated_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(project_id, protocol_id, item_id) DO NOTHING`, [
|
|
41
|
+
input.projectId,
|
|
42
|
+
input.protocolId,
|
|
43
|
+
item.id,
|
|
44
|
+
canonical.revision,
|
|
45
|
+
canonical.sha256,
|
|
46
|
+
item.status,
|
|
47
|
+
item.summary,
|
|
48
|
+
JSON.stringify(item.evidence),
|
|
49
|
+
item.block_reason ?? null,
|
|
50
|
+
item.user_required ? 1 : 0,
|
|
51
|
+
now
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
refreshProtocolRuns(context, input.projectId, input.protocolId);
|
|
55
|
+
return canonical;
|
|
56
|
+
}
|
|
57
|
+
export function planWithProgress(context, input) {
|
|
58
|
+
const canonical = boundCanonicalPlan(context, input);
|
|
59
|
+
const rows = context.db.all(`SELECT item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required
|
|
60
|
+
FROM plan_progress WHERE project_id = ? AND protocol_id = ?`, [input.projectId, input.protocolId]);
|
|
61
|
+
const progress = new Map(rows.map((row) => [row.item_id, row]));
|
|
62
|
+
return {
|
|
63
|
+
canonical,
|
|
64
|
+
plan: {
|
|
65
|
+
...canonical.plan,
|
|
66
|
+
items: canonical.plan.items.map((item) => mergeProgress(item, progress.get(item.id), canonical))
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function planSummary(context, input) {
|
|
71
|
+
const { canonical, plan } = planWithProgress(context, input);
|
|
72
|
+
return {
|
|
73
|
+
plan_id: plan.plan_id,
|
|
74
|
+
revision: canonical.revision,
|
|
75
|
+
sha256: canonical.sha256,
|
|
76
|
+
total: plan.items.length,
|
|
77
|
+
done: plan.items.filter((item) => item.status === "done").length,
|
|
78
|
+
blocked: plan.items.filter((item) => item.status === "blocked").length
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function updatePlanProgress(context, input) {
|
|
82
|
+
const canonical = boundCanonicalPlan(context, input);
|
|
83
|
+
context.db.run(`UPDATE plan_progress
|
|
84
|
+
SET plan_revision = ?, plan_sha256 = ?, status = ?, summary = ?, evidence_json = ?, block_reason = ?, user_required = ?, updated_at = ?
|
|
85
|
+
WHERE project_id = ? AND protocol_id = ? AND item_id = ?`, [
|
|
86
|
+
canonical.revision,
|
|
87
|
+
canonical.sha256,
|
|
88
|
+
input.item.status,
|
|
89
|
+
input.item.summary,
|
|
90
|
+
JSON.stringify(input.item.evidence),
|
|
91
|
+
input.item.block_reason ?? null,
|
|
92
|
+
input.item.user_required ? 1 : 0,
|
|
93
|
+
context.now(),
|
|
94
|
+
input.projectId,
|
|
95
|
+
input.protocolId,
|
|
96
|
+
input.item.id
|
|
97
|
+
]);
|
|
98
|
+
refreshProtocolRuns(context, input.projectId, input.protocolId);
|
|
99
|
+
}
|
|
100
|
+
function refreshProtocolRuns(context, projectId, protocolId) {
|
|
101
|
+
for (const run of context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
|
|
102
|
+
refreshRunSessionProjection(context, projectId, run.id);
|
|
103
|
+
}
|
|
104
|
+
function mergeProgress(item, row, canonical) {
|
|
105
|
+
if (!row || row.plan_revision !== canonical.revision || row.plan_sha256 !== canonical.sha256)
|
|
106
|
+
return item;
|
|
107
|
+
let evidence = item.evidence;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(row.evidence_json);
|
|
110
|
+
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string"))
|
|
111
|
+
evidence = parsed;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
evidence = item.evidence;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...item,
|
|
118
|
+
status: row.status,
|
|
119
|
+
summary: row.summary,
|
|
120
|
+
evidence,
|
|
121
|
+
...(row.block_reason ? { block_reason: row.block_reason } : {}),
|
|
122
|
+
...(row.user_required ? { user_required: true } : {})
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/services/plans.js
CHANGED
|
@@ -1,68 +1,34 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { schemaVersion } from "../domain/contracts.js";
|
|
3
|
-
import { validatePlan } from "../domain/validation.js";
|
|
4
2
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { parseJsonObject } from "../shared/json.js";
|
|
6
|
-
import { ensureReadableFile } from "../storage/database.js";
|
|
7
3
|
import { appendAudit } from "./audit.js";
|
|
8
4
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
9
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
10
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
11
|
-
import {
|
|
12
|
-
export function setProtocolPlan(context, input) {
|
|
13
|
-
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
14
|
-
ensureReadableFile(input.file);
|
|
15
|
-
const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
|
|
16
|
-
writePlanFile(protocol.plan_path, plan);
|
|
17
|
-
context.db.run(`INSERT INTO plans (project_id, protocol_id, plan_json, updated_at)
|
|
18
|
-
VALUES (?, ?, ?, ?)
|
|
19
|
-
ON CONFLICT(project_id, protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.project_id, protocol.id, JSON.stringify(plan), context.now()]);
|
|
20
|
-
updateStatePlanSummary(context, protocol, plan);
|
|
21
|
-
appendAudit(context, {
|
|
22
|
-
protocolId: protocol.id,
|
|
23
|
-
projectId: protocol.project_id,
|
|
24
|
-
eventType: "plan.set",
|
|
25
|
-
payload: { protocol_id: protocol.id, plan_id: plan.plan_id, total: plan.items.length }
|
|
26
|
-
});
|
|
27
|
-
return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
|
|
28
|
-
}
|
|
7
|
+
import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
|
|
29
8
|
export function getPlanStatus(context, input) {
|
|
30
9
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
31
|
-
const
|
|
10
|
+
const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
|
|
32
11
|
return {
|
|
33
12
|
ok: true,
|
|
34
13
|
protocol_id: protocol.id,
|
|
35
|
-
plan:
|
|
36
|
-
|
|
14
|
+
plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
|
|
15
|
+
binding: { revision: current.canonical.revision, sha256: current.canonical.sha256, path: protocol.plan_path },
|
|
16
|
+
blocked_items: current.plan.items
|
|
37
17
|
.filter((item) => item.status === "blocked")
|
|
38
|
-
.map((item) => ({
|
|
39
|
-
|
|
40
|
-
title: item.title,
|
|
41
|
-
reason: item.block_reason ?? "",
|
|
42
|
-
user_required: item.user_required ?? false
|
|
43
|
-
})),
|
|
44
|
-
items: plan.items
|
|
18
|
+
.map((item) => ({ id: item.id, title: item.title, reason: item.block_reason ?? "", user_required: item.user_required ?? false })),
|
|
19
|
+
items: current.plan.items
|
|
45
20
|
};
|
|
46
21
|
}
|
|
47
22
|
export function startPlanItem(context, input) {
|
|
48
23
|
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
|
|
49
24
|
assertDependenciesClosed(plan, item);
|
|
50
|
-
return {
|
|
51
|
-
...item,
|
|
52
|
-
status: "in_progress",
|
|
53
|
-
summary: item.summary || "Started."
|
|
54
|
-
};
|
|
25
|
+
return { ...item, status: "in_progress", summary: item.summary || "Started." };
|
|
55
26
|
});
|
|
56
27
|
}
|
|
57
28
|
export function completePlanItem(context, input) {
|
|
58
29
|
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
|
|
59
30
|
assertDependenciesClosed(plan, item);
|
|
60
|
-
return {
|
|
61
|
-
...item,
|
|
62
|
-
status: "done",
|
|
63
|
-
summary: input.summary,
|
|
64
|
-
evidence: [...new Set([...item.evidence, ...input.evidence])]
|
|
65
|
-
};
|
|
31
|
+
return { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] };
|
|
66
32
|
});
|
|
67
33
|
}
|
|
68
34
|
export function blockPlanItem(context, input) {
|
|
@@ -83,48 +49,28 @@ export function skipPlanItem(context, input) {
|
|
|
83
49
|
}
|
|
84
50
|
function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
|
|
85
51
|
const protocol = scopedProtocol(context, projectRoot, protocolId);
|
|
86
|
-
const
|
|
87
|
-
const item = plan.items.find((candidate) => candidate.id === itemId);
|
|
88
|
-
if (!item)
|
|
52
|
+
const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
|
|
53
|
+
const item = current.plan.items.find((candidate) => candidate.id === itemId);
|
|
54
|
+
if (!item)
|
|
89
55
|
throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
|
|
56
|
+
const nextItem = transform(item, current.plan);
|
|
57
|
+
updatePlanProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path, item: nextItem });
|
|
58
|
+
if (nextItem.status === "done") {
|
|
59
|
+
context.db.run("UPDATE flow_jobs SET status = 'done', finished_at = COALESCE(finished_at, ?), updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_item_id = ?", [context.now(), context.now(), protocol.project_id, protocol.id, nextItem.id]);
|
|
90
60
|
}
|
|
91
|
-
|
|
92
|
-
...plan,
|
|
93
|
-
items: plan.items.map((candidate) => (candidate.id === itemId ? transform(candidate, plan) : candidate))
|
|
94
|
-
};
|
|
95
|
-
const validated = validatePlan(nextPlan, protocol.id);
|
|
96
|
-
writePlanFile(protocol.plan_path, validated);
|
|
97
|
-
context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ?", [
|
|
98
|
-
JSON.stringify(validated),
|
|
99
|
-
context.now(),
|
|
100
|
-
protocol.project_id,
|
|
101
|
-
protocol.id
|
|
102
|
-
]);
|
|
103
|
-
updateStatePlanSummary(context, protocol, validated);
|
|
61
|
+
updateStatePlanSummary(context, protocol);
|
|
104
62
|
appendAudit(context, {
|
|
105
63
|
protocolId: protocol.id,
|
|
106
64
|
projectId: protocol.project_id,
|
|
107
|
-
eventType: "plan.
|
|
108
|
-
payload: {
|
|
109
|
-
protocol_id: protocol.id,
|
|
110
|
-
item_id: itemId,
|
|
111
|
-
from: item.status,
|
|
112
|
-
to: validated.items.find((candidate) => candidate.id === itemId)?.status
|
|
113
|
-
}
|
|
65
|
+
eventType: "plan.progress.updated",
|
|
66
|
+
payload: { protocol_id: protocol.id, item_id: itemId, from: item.status, to: nextItem.status }
|
|
114
67
|
});
|
|
115
|
-
return { ok: true, protocol_id: protocol.id, item:
|
|
68
|
+
return { ok: true, protocol_id: protocol.id, item: nextItem, plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) };
|
|
116
69
|
}
|
|
117
70
|
function scopedProtocol(context, projectRoot, protocolId) {
|
|
118
71
|
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
119
72
|
return requireProtocol(context, protocolId, project.id);
|
|
120
73
|
}
|
|
121
|
-
function requireStoredPlan(context, projectId, protocolId) {
|
|
122
|
-
const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
123
|
-
if (!row) {
|
|
124
|
-
throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
|
|
125
|
-
}
|
|
126
|
-
return validatePlan(parseJsonObject(row.plan_json, `stored plan for ${protocolId}`), protocolId);
|
|
127
|
-
}
|
|
128
74
|
function assertDependenciesClosed(plan, item) {
|
|
129
75
|
const openDependencies = item.depends_on
|
|
130
76
|
.map((dependencyId) => plan.items.find((candidate) => candidate.id === dependencyId))
|
|
@@ -137,20 +83,12 @@ function assertDependenciesClosed(plan, item) {
|
|
|
137
83
|
});
|
|
138
84
|
}
|
|
139
85
|
}
|
|
140
|
-
function updateStatePlanSummary(context, protocol
|
|
86
|
+
function updateStatePlanSummary(context, protocol) {
|
|
141
87
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
142
88
|
persistProtocolState(context, protocol, {
|
|
143
89
|
...state,
|
|
144
90
|
schema_version: state.schema_version || schemaVersion,
|
|
145
|
-
plan:
|
|
91
|
+
plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
|
|
146
92
|
updated_at: context.now()
|
|
147
93
|
});
|
|
148
94
|
}
|
|
149
|
-
function summarizePlan(plan) {
|
|
150
|
-
return {
|
|
151
|
-
plan_id: plan.plan_id,
|
|
152
|
-
total: plan.items.length,
|
|
153
|
-
done: plan.items.filter((item) => item.status === "done").length,
|
|
154
|
-
blocked: plan.items.filter((item) => item.status === "blocked").length
|
|
155
|
-
};
|
|
156
|
-
}
|
package/dist/services/prompts.js
CHANGED
|
@@ -5,10 +5,12 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { getCliVersionReport } from "./build-info.js";
|
|
6
6
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
7
|
import { requireProtocol } from "./protocols.js";
|
|
8
|
-
import {
|
|
8
|
+
import { validatePlanItem } from "../domain/validation.js";
|
|
9
9
|
import { AppError } from "../shared/errors.js";
|
|
10
10
|
import { parseJsonObject } from "../shared/json.js";
|
|
11
11
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
12
|
+
import { planWithProgress } from "./plan-runtime.js";
|
|
13
|
+
import { registerFlowJob } from "./sessions.js";
|
|
12
14
|
const profiles = {
|
|
13
15
|
code_implementation: {
|
|
14
16
|
static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/code.md"]
|
|
@@ -37,8 +39,13 @@ export function renderWorkerPrompt(context, input) {
|
|
|
37
39
|
});
|
|
38
40
|
}
|
|
39
41
|
assertGitFacts(workspaceRoot, index.execution?.git, run.id);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
+
const planRuntime = input.taskFile ? null : planWithProgress(context, {
|
|
43
|
+
projectId: protocol.project_id,
|
|
44
|
+
protocolId: protocol.id,
|
|
45
|
+
planPath: protocol.plan_path
|
|
46
|
+
});
|
|
47
|
+
const plan = planRuntime?.plan ?? null;
|
|
48
|
+
const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run)) : null;
|
|
42
49
|
const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
|
|
43
50
|
if (!item)
|
|
44
51
|
throw new AppError("not_found", `Plan item is not found: ${input.planItemId}`, 1);
|
|
@@ -57,6 +64,13 @@ export function renderWorkerPrompt(context, input) {
|
|
|
57
64
|
const profile = profiles[input.profile];
|
|
58
65
|
if (!profile)
|
|
59
66
|
throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
|
|
67
|
+
const job = registerFlowJob(context, {
|
|
68
|
+
projectId: protocol.project_id,
|
|
69
|
+
runId: run.id,
|
|
70
|
+
protocolId: protocol.id,
|
|
71
|
+
planItemId: item.id,
|
|
72
|
+
groupId: item.execution_context.write_scope[0] ?? null
|
|
73
|
+
});
|
|
60
74
|
const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
|
|
61
75
|
const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
|
|
62
76
|
const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
|
|
@@ -86,8 +100,10 @@ export function renderWorkerPrompt(context, input) {
|
|
|
86
100
|
protocol_id: protocol.id,
|
|
87
101
|
run_id: run.id,
|
|
88
102
|
plan_id: plan?.plan_id ?? null,
|
|
103
|
+
...(planRuntime ? { plan_revision: planRuntime.canonical.revision, plan_sha256: planRuntime.canonical.sha256 } : {}),
|
|
89
104
|
plan_item_id: input.taskFile ? null : item.id,
|
|
90
105
|
task_id: input.taskFile ? item.id : null,
|
|
106
|
+
job_id: job.job_id,
|
|
91
107
|
canon_version: readCanonVersion(projectRoot),
|
|
92
108
|
renderer_version: getCliVersionReport().cli.version,
|
|
93
109
|
static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
|
|
@@ -115,13 +131,14 @@ export function renderWorkerPrompt(context, input) {
|
|
|
115
131
|
profile: input.profile,
|
|
116
132
|
plan_item_id: input.taskFile ? null : item.id,
|
|
117
133
|
task_id: input.taskFile ? item.id : null,
|
|
118
|
-
run_id: run.id
|
|
134
|
+
run_id: run.id,
|
|
135
|
+
job_id: job.job_id
|
|
119
136
|
};
|
|
120
137
|
}
|
|
121
138
|
function runHomePath(run) {
|
|
122
139
|
return run.run_home_path ?? path.dirname(run.run_index_path);
|
|
123
140
|
}
|
|
124
|
-
function readWorkerTask(taskFile, runHome
|
|
141
|
+
function readWorkerTask(taskFile, runHome) {
|
|
125
142
|
const resolved = path.resolve(taskFile);
|
|
126
143
|
assertWithin(runHome, resolved, "worker task file");
|
|
127
144
|
if (!fs.existsSync(resolved))
|
|
@@ -130,13 +147,7 @@ function readWorkerTask(taskFile, runHome, protocolId) {
|
|
|
130
147
|
if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
|
|
131
148
|
throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
|
|
132
149
|
}
|
|
133
|
-
const item =
|
|
134
|
-
schema_version: "0.1.0",
|
|
135
|
-
plan_id: "worker-task",
|
|
136
|
-
protocol_id: protocolId,
|
|
137
|
-
title: "worker task",
|
|
138
|
-
items: [{ ...task.task, depends_on: [] }]
|
|
139
|
-
}, protocolId).items[0];
|
|
150
|
+
const item = validatePlanItem({ ...task.task, depends_on: [] }, 0);
|
|
140
151
|
return { item, handoff: validateWorkerTaskHandoff(task.handoff, runHome) };
|
|
141
152
|
}
|
|
142
153
|
function validateWorkerTaskHandoff(value, runHome) {
|
|
@@ -187,14 +198,13 @@ function validateWorkerTaskHandoff(value, runHome) {
|
|
|
187
198
|
}
|
|
188
199
|
function protocolIdForRun(context, projectId, runId) {
|
|
189
200
|
const run = requireRun(context, projectId, runId);
|
|
190
|
-
|
|
191
|
-
if (subject?.type !== "protocol" || typeof subject.id !== "string") {
|
|
201
|
+
if (run.subject_type !== "protocol") {
|
|
192
202
|
throw new AppError("prompt_run_subject", "Prompt rendering requires a protocol-owned RUN", 1, { run_id: run.id });
|
|
193
203
|
}
|
|
194
|
-
return
|
|
204
|
+
return run.subject_id;
|
|
195
205
|
}
|
|
196
206
|
function requireRun(context, projectId, runId) {
|
|
197
|
-
const row = context.db.get("SELECT id, project_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
|
|
207
|
+
const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
|
|
198
208
|
if (!row)
|
|
199
209
|
throw new AppError("not_found", `Run is not found: ${runId}`, 1);
|
|
200
210
|
return row;
|
|
@@ -209,11 +219,6 @@ function requireStage(index, stageName, runId) {
|
|
|
209
219
|
}
|
|
210
220
|
return { dir: stage.dir };
|
|
211
221
|
}
|
|
212
|
-
function readPlan(planPath, protocolId) {
|
|
213
|
-
if (!fs.existsSync(planPath))
|
|
214
|
-
throw new AppError("not_found", `Protocol plan is missing: ${planPath}`, 1);
|
|
215
|
-
return validatePlan(parseJsonObject(fs.readFileSync(planPath, "utf8"), planPath), protocolId);
|
|
216
|
-
}
|
|
217
222
|
function readStaticInput(projectRoot, relativePath) {
|
|
218
223
|
checkedReference(projectRoot, undefined, relativePath, "static profile input", true);
|
|
219
224
|
const absolute = path.resolve(projectRoot, relativePath);
|