@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105
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 +6 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +23 -8
- package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/services/cleanup.js +81 -32
- package/dist/services/code-checks.js +18 -19
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/controller-fanout.js +3 -3
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/hooks.js +150 -42
- package/dist/services/lanes.js +23 -18
- package/dist/services/lifecycle-command.js +11 -2
- package/dist/services/lifecycle-invocations.js +291 -54
- package/dist/services/managed-processes.js +35 -23
- package/dist/services/merge-queue.js +71 -67
- package/dist/services/merge-server.js +3 -11
- package/dist/services/migrations.js +28 -12
- package/dist/services/plan-runtime.js +8 -4
- package/dist/services/projects.js +3 -11
- package/dist/services/protocols.js +98 -68
- package/dist/services/recovery-snapshot-database.js +43 -8
- package/dist/services/run-control-worker.js +15 -32
- package/dist/services/run-control.js +3 -17
- package/dist/services/run-controller-capture.js +58 -27
- package/dist/services/run-controller-process.js +21 -30
- package/dist/services/run-controller-recovery.js +36 -37
- package/dist/services/run-controller.js +81 -86
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +19 -64
- package/dist/services/run-recovery-runtime.js +24 -14
- package/dist/services/run-recovery.js +56 -54
- package/dist/services/runs.js +218 -160
- package/dist/services/runtime-budget.js +19 -45
- package/dist/services/runtime-scope-capture.js +8 -6
- package/dist/services/runtime-scope-control.js +49 -39
- package/dist/services/runtime-scope-resume.js +134 -122
- package/dist/services/runtime-scope-worker.js +48 -49
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/stage-context.js +6 -1
- package/dist/services/stage-lifecycle.js +1 -1
- package/dist/services/stage-pause.js +13 -29
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/usage.js +2 -9
- package/dist/services/vnext-code-review.js +10 -7
- package/dist/services/vnext-code.js +9 -17
- package/dist/services/vnext-merge.js +64 -21
- package/dist/services/vnext-plan-review.js +43 -30
- package/dist/services/vnext-plan.js +28 -24
- package/dist/services/vnext-protocolize.js +183 -20
- package/dist/services/vnext-specify.js +27 -19
- package/dist/services/work-registry.js +229 -150
- package/dist/storage/database.js +286 -56
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/writer-contract.js +3 -3
- package/dist/storage/writer-migration.js +24 -41
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +48 -23
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { AppError } from "../shared/errors.js";
|
|
6
7
|
import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
|
|
7
8
|
import { previewNextEntityId } from "./ids.js";
|
|
8
9
|
import { registerProtocol } from "./protocols.js";
|
|
9
10
|
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
-
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, prepareFlowRunStageAttachment, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
|
|
11
|
-
import { resolveStageTransition } from "./execution-policy.js";
|
|
11
|
+
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, prepareFlowRunStageAttachment, prepareFlowRunStageCompletion, withStageSettlement, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
|
|
12
|
+
import { resolveStageTransition, stageExecutionCwd } from "./execution-policy.js";
|
|
12
13
|
import { validateSchema } from "./schema-validation.js";
|
|
13
14
|
import { assertStageStartHookEvent, hookSessionIdentity } from "./hooks.js";
|
|
14
15
|
import { bindStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
|
|
@@ -17,11 +18,13 @@ import { bootstrapManagedWorktree, createManagedWorktree, prepareManagedWorktree
|
|
|
17
18
|
import { loadVnextWorkspacePolicy, requireVnextWorkspaceRoute, vnextRunBranch } from "./vnext-workspace-policy.js";
|
|
18
19
|
import { readVnextSpecifyResult } from "./vnext-specify.js";
|
|
19
20
|
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
20
|
-
import {
|
|
21
|
+
import { acceptStageReport, prepareStageReport, publishStageReport } from "./stage-report-renderer.js";
|
|
21
22
|
import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
|
|
22
23
|
import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
|
|
24
|
+
import { stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
25
|
+
import { withProjectionLock } from "../storage/projection-lock.js";
|
|
23
26
|
const stage = "protocolize";
|
|
24
|
-
function stageSessionMode(context, run) {
|
|
27
|
+
function stageSessionMode(context, run, projectRoot, targetWorkspaceRoot) {
|
|
25
28
|
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
|
|
26
29
|
const value = row ? JSON.parse(row.index_json) : null;
|
|
27
30
|
const profile = value?.execution_profile;
|
|
@@ -31,9 +34,10 @@ function stageSessionMode(context, run) {
|
|
|
31
34
|
if (profile?.settings.execution) {
|
|
32
35
|
if (!profile.agent_profiles)
|
|
33
36
|
throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
|
|
34
|
-
return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "protocolize", stage: "plan", stageSessionMode: mode, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
37
|
+
return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot, workspaceRoot: targetWorkspaceRoot, fromStage: "protocolize", stage: "plan", stageSessionMode: mode, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
35
38
|
}
|
|
36
|
-
return
|
|
39
|
+
return stageExecutionCwd({ stage: "protocolize", projectRoot, workspaceRoot: targetWorkspaceRoot })
|
|
40
|
+
=== stageExecutionCwd({ stage: "plan", projectRoot, workspaceRoot: targetWorkspaceRoot }) ? mode : "new_session";
|
|
37
41
|
}
|
|
38
42
|
export function isVnextProtocolizeRun(context, input) {
|
|
39
43
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -76,7 +80,7 @@ export function prepareVnextProtocolize(context, input, prepared = prepareVnextP
|
|
|
76
80
|
writeJson(resultPath, resultTemplate(obligations));
|
|
77
81
|
const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId: work.work_id, projectRoot });
|
|
78
82
|
const prompt = renderPrompt({ projectRoot, runId: run.id, workId: work.work_id, specifyPath, obligations, resultPath, handoff: input.handoff, catalog: projectCatalog, policy, template, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), finishCommand: managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --result-file ${JSON.stringify(resultPath)} --json`) });
|
|
79
|
-
const workspaceContract = ["<workspace_contract>", `- stage artifact directory: ${stageRoot}`, `- project workspace: ${policy.worktree_path ?? projectRoot}`, `-
|
|
83
|
+
const workspaceContract = ["<workspace_contract>", `- stage artifact directory: ${stageRoot}`, `- provisioned project workspace for later stages: ${policy.worktree_path ?? projectRoot}`, `- this PROTOCOLIZE Session remains in its launch directory: ${projectRoot}`, `Write only the named result file in the stage artifact directory; do not write project files, another RUN, another native home, branches, worktrees or Git commits. Do not change the Session directory to the newly provisioned worktree.`, "The CLI performs all protocol document materialization after the result is accepted.", "</workspace_contract>"].join("\n");
|
|
80
84
|
const promptWithWorkspaceContract = prompt.replace("</work_context>", `</work_context>\n\n${workspaceContract}`);
|
|
81
85
|
fs.writeFileSync(promptPath, promptWithWorkspaceContract);
|
|
82
86
|
// Packet publication does not itself bind a Session or open the Stage.
|
|
@@ -184,17 +188,18 @@ export function finishVnextProtocolize(context, input) {
|
|
|
184
188
|
const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
|
|
185
189
|
const obligations = effectiveObligations(result, acceptedObligations);
|
|
186
190
|
validateDelivery(projectRoot, result, obligations);
|
|
187
|
-
const protocolIds = result.delivery.members.map((member) => allocateProtocolId(context, projectRoot, member.slug));
|
|
188
|
-
const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(projectRoot, result.delivery.slug) : null;
|
|
189
191
|
const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
|
|
190
192
|
const receipt = readWorkspaceRouteReceipt(root);
|
|
191
193
|
const workspaceRoot = run.workspace_root;
|
|
192
|
-
const
|
|
194
|
+
const intent = prepareProtocolizeIntent(context, run, resultFile, result, obligations);
|
|
195
|
+
publishProtocolizeIntent(context, project.id, intent);
|
|
196
|
+
const protocolIds = intent.protocol_ids;
|
|
197
|
+
const changedFiles = intent.changed_files;
|
|
193
198
|
for (const protocolId of protocolIds)
|
|
194
199
|
registerProtocol(context, { handshakeId: protocolId, projectRoot, workspacePath: workspaceRoot });
|
|
195
200
|
if (receipt.created && receipt.bootstrap) {
|
|
196
201
|
for (const protocolId of protocolIds)
|
|
197
|
-
|
|
202
|
+
ensureManagedWorktreeRecorded(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
|
|
198
203
|
}
|
|
199
204
|
const now = context.now();
|
|
200
205
|
const workSession = completeWorkSession(context, work.work_id, resultFile, now);
|
|
@@ -203,15 +208,150 @@ export function finishVnextProtocolize(context, input) {
|
|
|
203
208
|
const reportJson = path.join(root, "stage-report.json");
|
|
204
209
|
const reportMarkdown = path.join(root, "stage-report.md");
|
|
205
210
|
const reportHtml = path.join(root, "stage-report.html");
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
const currentIndex = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]).index_json);
|
|
212
|
+
const stageDone = currentIndex.stage_runs.some(item => item.stage === stage && item.status === "done");
|
|
213
|
+
if (stageDone) {
|
|
214
|
+
publishStageReport(context, project.id, run.id, stage, root);
|
|
215
|
+
if (!fs.existsSync(reportJson))
|
|
216
|
+
throw new AppError("protocolize_report_missing", "Completed PROTOCOLIZE has no accepted report to replay", 1, { run_id: run.id, report_path: reportJson });
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const preparedReport = prepareStageReport(report);
|
|
220
|
+
validateSchema({ schemaName: "stage-report", file: reportJson, data: preparedReport.normalized, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
|
|
221
|
+
const completion = { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html", dataSha256: crypto.createHash("sha256").update(fs.readFileSync(resultFile)).digest("hex") };
|
|
222
|
+
const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
|
|
223
|
+
withStageSettlement(context, () => {
|
|
224
|
+
acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
|
|
225
|
+
completeFlowRunStage(context, completion, preparedCompletion);
|
|
226
|
+
});
|
|
227
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
|
|
228
|
+
}
|
|
229
|
+
if (currentIndex.verdict !== "protocolized")
|
|
230
|
+
advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
|
|
211
231
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
212
232
|
const nextCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`);
|
|
213
233
|
return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: protocolIds, workspace_root: workspaceRoot, git_policy: resolvedPolicy, next_action: "await_plan", next_command: nextCommand, next: { kind: "start_stage", stage: "plan", command: nextCommand, cwd: workspaceRoot, session_mode: resolvedPolicy.stage_session_mode }, artifacts: { result: resultFile, report_json: reportJson, report_markdown: reportMarkdown, report_html: reportHtml, protocols: protocolIds.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "summary.md")) } };
|
|
214
234
|
}
|
|
235
|
+
function prepareProtocolizeIntent(context, run, resultFile, result, obligations) {
|
|
236
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
|
|
237
|
+
const index = row ? JSON.parse(row.index_json) : null;
|
|
238
|
+
const attempt = index?.stage_runs?.find(item => item.stage === stage)?.attempt;
|
|
239
|
+
if (!attempt)
|
|
240
|
+
throw new AppError("protocolize_owner_missing", "PROTOCOLIZE has no owning Stage attempt", 1, { run_id: run.id });
|
|
241
|
+
const resultSha = sha256(fs.readFileSync(resultFile));
|
|
242
|
+
const retained = readProtocolizeIntent(context, run.project_id, run.id, attempt);
|
|
243
|
+
if (retained) {
|
|
244
|
+
if (retained.workspace_root !== run.workspace_root || retained.result_sha256 !== resultSha)
|
|
245
|
+
throw new AppError("protocolize_intent_conflict", "Accepted PROTOCOLIZE publication belongs to different result or workspace bytes", 1, { run_id: run.id, attempt });
|
|
246
|
+
return retained;
|
|
247
|
+
}
|
|
248
|
+
const projectRoot = run.workspace_root;
|
|
249
|
+
const protocolIds = allocateProtocolIds(context, run.project_id, projectRoot, result.delivery.members.map(member => member.slug));
|
|
250
|
+
const psetId = result.delivery.kind === "protocol_set" ? allocatePsetId(context, run.project_id, projectRoot, result.delivery.slug) : null;
|
|
251
|
+
const draft = fs.mkdtempSync(path.join(os.tmpdir(), "dd-flow-protocolize-"));
|
|
252
|
+
try {
|
|
253
|
+
// A draft must never follow a workspace symlink back into the live tree.
|
|
254
|
+
fs.cpSync(path.join(projectRoot, ".memory-bank"), path.join(draft, ".memory-bank"), { recursive: true, filter: source => !fs.lstatSync(source).isSymbolicLink() });
|
|
255
|
+
const feature = result.feature.epic_path && path.isAbsolute(result.feature.epic_path)
|
|
256
|
+
? { ...result.feature, epic_path: path.join(draft, path.relative(projectRoot, result.feature.epic_path)) }
|
|
257
|
+
: result.feature;
|
|
258
|
+
const draftResult = { ...result, feature };
|
|
259
|
+
const changedFiles = materialize(draft, run.id, requireRunHome(run), draftResult, obligations, protocolIds, psetId);
|
|
260
|
+
const files = changedFiles.map(relative => {
|
|
261
|
+
const source = path.join(projectRoot, relative), target = path.join(draft, relative);
|
|
262
|
+
const bytes = fs.readFileSync(target, "utf8");
|
|
263
|
+
return { relative, before_sha256: fs.existsSync(source) ? sha256(fs.readFileSync(source)) : null, after_sha256: sha256(bytes), bytes };
|
|
264
|
+
});
|
|
265
|
+
const candidate = { run_id: run.id, attempt, workspace_root: projectRoot, result_sha256: resultSha, protocol_ids: protocolIds, pset_id: psetId, changed_files: changedFiles, files };
|
|
266
|
+
return context.db.writeTransaction(() => {
|
|
267
|
+
const raced = readProtocolizeIntent(context, run.project_id, run.id, attempt);
|
|
268
|
+
if (raced) {
|
|
269
|
+
if (raced.workspace_root !== projectRoot || raced.result_sha256 !== resultSha)
|
|
270
|
+
throw new AppError("protocolize_intent_conflict", "A competing PROTOCOLIZE publication was accepted", 1, { run_id: run.id, attempt });
|
|
271
|
+
return raced;
|
|
272
|
+
}
|
|
273
|
+
const reserved = new Set(context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent'", [run.project_id])
|
|
274
|
+
.flatMap(row => { const prior = JSON.parse(row.payload_json); return [...prior.protocol_ids, ...(prior.pset_id ? [prior.pset_id] : [])]; }));
|
|
275
|
+
if ([...protocolIds, ...(psetId ? [psetId] : [])].some(id => reserved.has(id)))
|
|
276
|
+
throw new AppError("protocolize_id_reserved", "Another PROTOCOLIZE attempt reserved the selected document ID; retry to allocate a new one", 1, { run_id: run.id });
|
|
277
|
+
context.db.run("INSERT INTO audit_events (project_id, event_type, forced, payload_json, created_at) VALUES (?, 'flow_run.protocolize_intent', 0, ?, ?)", [run.project_id, JSON.stringify(candidate), context.now()]);
|
|
278
|
+
return candidate;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
fs.rmSync(draft, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function readProtocolizeIntent(context, projectId, runId, attempt) {
|
|
286
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent' ORDER BY id DESC", [projectId]);
|
|
287
|
+
return rows.map(row => JSON.parse(row.payload_json)).find(item => item.run_id === runId && item.attempt === attempt) ?? null;
|
|
288
|
+
}
|
|
289
|
+
function publishProtocolizeIntent(context, projectId, intent) {
|
|
290
|
+
const memory = path.join(intent.workspace_root, ".memory-bank");
|
|
291
|
+
withProjectionLock(path.join(context.ddFlowHome, "locks", `protocolize-${projectId}.publish-lock`), () => {
|
|
292
|
+
for (const file of intent.files) {
|
|
293
|
+
if (!file.relative.startsWith(".memory-bank/") || sha256(file.bytes) !== file.after_sha256)
|
|
294
|
+
throw new AppError("protocolize_intent_invalid", "Accepted PROTOCOLIZE file is invalid", 1, { relative: file.relative });
|
|
295
|
+
const target = path.resolve(intent.workspace_root, file.relative);
|
|
296
|
+
inside(memory, target);
|
|
297
|
+
assertNoSymlinkPath(memory, target);
|
|
298
|
+
const current = fs.existsSync(target) ? sha256(fs.readFileSync(target)) : null;
|
|
299
|
+
if (current !== file.before_sha256 && current !== file.after_sha256)
|
|
300
|
+
throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE target changed since publication was accepted", 1, { target });
|
|
301
|
+
}
|
|
302
|
+
for (const file of intent.files) {
|
|
303
|
+
const target = path.resolve(intent.workspace_root, file.relative);
|
|
304
|
+
if (fs.existsSync(target) && sha256(fs.readFileSync(target)) === file.after_sha256)
|
|
305
|
+
continue;
|
|
306
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
307
|
+
const temporary = `${target}.${crypto.randomUUID()}.tmp`;
|
|
308
|
+
try {
|
|
309
|
+
fs.writeFileSync(temporary, file.bytes, { flag: "wx" });
|
|
310
|
+
if (file.before_sha256 === null) {
|
|
311
|
+
try {
|
|
312
|
+
fs.linkSync(temporary, target);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (error.code !== "EEXIST" || sha256(fs.readFileSync(target)) !== file.after_sha256)
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
if (sha256(fs.readFileSync(target)) !== file.before_sha256)
|
|
321
|
+
throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE target changed during publication", 1, { target });
|
|
322
|
+
fs.renameSync(temporary, target);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
fs.rmSync(temporary, { force: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function assertNoSymlinkPath(memory, target) {
|
|
332
|
+
for (let cursor = memory;; cursor = path.join(cursor, path.relative(cursor, target).split(path.sep)[0])) {
|
|
333
|
+
try {
|
|
334
|
+
if (fs.lstatSync(cursor).isSymbolicLink())
|
|
335
|
+
throw new AppError("protocolize_publication_conflict", "PROTOCOLIZE publication cannot follow a workspace symlink", 1, { target, symlink: cursor });
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error.code !== "ENOENT")
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
if (cursor === target)
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function ensureManagedWorktreeRecorded(context, input) {
|
|
346
|
+
const existing = context.db.get("SELECT wr.feature_branch, wr.base_ref, wr.worktree_path FROM worktree_records wr JOIN protocols p ON p.project_id = wr.project_id AND p.id = wr.protocol_id WHERE p.project_root = ? AND p.id = ? AND wr.status = 'active' ORDER BY wr.created_at DESC LIMIT 1", [input.projectRoot, input.protocolId]);
|
|
347
|
+
if (existing) {
|
|
348
|
+
if (existing.feature_branch !== input.branch || existing.base_ref !== input.base || existing.worktree_path !== input.created.worktreePath)
|
|
349
|
+
throw new AppError("worktree_already_exists", "Existing managed worktree record conflicts with the accepted PROTOCOLIZE route", 1, { protocol_id: input.protocolId });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
recordManagedWorktree(context, input);
|
|
353
|
+
}
|
|
354
|
+
function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
|
|
215
355
|
function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
|
|
216
356
|
const psetPath = psetId ? path.join(projectRoot, ".memory-bank", "protocol", "_set", `${psetId}.md`) : null;
|
|
217
357
|
const created = [];
|
|
@@ -483,7 +623,7 @@ function resultTemplate(obligations = []) {
|
|
|
483
623
|
}
|
|
484
624
|
function renderPrompt(input) {
|
|
485
625
|
const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
|
|
486
|
-
return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
|
|
626
|
+
return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(input.obligations), null, 2)}\n\`\`\``, "obligation_id values and their one-per-obligation entries are prefilled by the CLI. Preserve those IDs exactly; change only member_keys when semantic ownership requires it. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, stageLifecycleInstruction, "</output_contract>", ""].join("\n");
|
|
487
627
|
}
|
|
488
628
|
function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
|
|
489
629
|
const receiptPath = path.join(stageRoot, "workspace-route.json");
|
|
@@ -492,8 +632,8 @@ function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, st
|
|
|
492
632
|
prepareOutputFile(receiptPath);
|
|
493
633
|
const config = loadVnextWorkspacePolicy(projectRoot);
|
|
494
634
|
const stableFacts = gitFacts(projectRoot);
|
|
495
|
-
const sessionMode = stageSessionMode(context, run);
|
|
496
635
|
if (config.workspace.route === "integration_branch_direct") {
|
|
636
|
+
const sessionMode = stageSessionMode(context, run, projectRoot, projectRoot);
|
|
497
637
|
const policy = { route: "integration_branch_direct", integration_branch: config.workspace.integration_branch, base_ref: stableFacts.head, feature_branch: null, worktree_path: null, reason: "Project workspace configuration explicitly selects the integration checkout.", provision_stage: "protocolize_start", stage_session_mode: sessionMode, provisioned: true, bootstrap_status: "not_applicable" };
|
|
498
638
|
return { kind: "direct", receiptPath, policy };
|
|
499
639
|
}
|
|
@@ -503,6 +643,7 @@ function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, st
|
|
|
503
643
|
const branch = vnextRunBranch(config, run);
|
|
504
644
|
const holder = path.basename(projectRoot).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
505
645
|
const worktreePath = projectFeatureWorktreePath(context.ddFlowHome, projectId, `RUN-${run.short_id}`, holder);
|
|
646
|
+
const sessionMode = stageSessionMode(context, run, projectRoot, worktreePath);
|
|
506
647
|
let create;
|
|
507
648
|
try {
|
|
508
649
|
create = prepareManagedWorktree({ projectRoot, branch, base: stableFacts.head, path: worktreePath });
|
|
@@ -545,8 +686,27 @@ function readWorkspaceRouteReceipt(root) {
|
|
|
545
686
|
}
|
|
546
687
|
function requiredPolicyValue(value, label) { if (!value)
|
|
547
688
|
throw new AppError("runtime_missing", `PROTOCOLIZE Git policy is missing ${label}`, 1); return value; }
|
|
548
|
-
function
|
|
549
|
-
|
|
689
|
+
function allocateProtocolIds(context, projectId, projectRoot, names) {
|
|
690
|
+
if (!names.length)
|
|
691
|
+
return [];
|
|
692
|
+
const first = previewNextEntityId(context, { projectRoot, type: "protocol", slug: names[0] }).entity.id;
|
|
693
|
+
const used = protocolizeReservedNumbers(context, projectId, "protocol_ids", /^PRT-(\d+)-/);
|
|
694
|
+
const sequence = Math.max(Number(/^PRT-(\d+)-/.exec(first)?.[1] ?? 1) - 1, ...used) + 1;
|
|
695
|
+
return names.map((name, index) => `PRT-${String(sequence + index).padStart(3, "0")}-${slug(name)}`);
|
|
696
|
+
}
|
|
697
|
+
function allocatePsetId(context, projectId, projectRoot, value) {
|
|
698
|
+
const root = path.join(projectRoot, ".memory-bank", "protocol", "_set");
|
|
699
|
+
const used = fs.existsSync(root) ? fs.readdirSync(root).map((file) => /^PSET-(\d+)-/.exec(file)?.[1]).filter((item) => Boolean(item)).map(Number) : [];
|
|
700
|
+
return `PSET-${String(Math.max(0, ...used, ...protocolizeReservedNumbers(context, projectId, "pset_id", /^PSET-(\d+)-/)) + 1).padStart(3, "0")}-${slug(value)}`;
|
|
701
|
+
}
|
|
702
|
+
function protocolizeReservedNumbers(context, projectId, field, pattern) {
|
|
703
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.protocolize_intent'", [projectId]);
|
|
704
|
+
return rows.flatMap(row => {
|
|
705
|
+
const value = JSON.parse(row.payload_json);
|
|
706
|
+
const ids = field === "protocol_ids" ? value.protocol_ids : value.pset_id ? [value.pset_id] : [];
|
|
707
|
+
return ids.map(id => Number(pattern.exec(id)?.[1])).filter(Number.isFinite);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
550
710
|
function catalog(projectRoot) {
|
|
551
711
|
const memory = path.join(projectRoot, ".memory-bank");
|
|
552
712
|
const candidates = [
|
|
@@ -575,6 +735,9 @@ function requireRunHome(run) { if (!run.run_root)
|
|
|
575
735
|
export function prepareProtocolizeResultPath(runHome, file) {
|
|
576
736
|
const resolved = path.resolve(file);
|
|
577
737
|
inside(path.join(runHome, "02-protocolize"), resolved);
|
|
738
|
+
const canonical = path.join(runHome, "02-protocolize", "protocolize-result.json");
|
|
739
|
+
if (resolved !== canonical)
|
|
740
|
+
throw new AppError("protocolize_result_path_invalid", "PROTOCOLIZE result must be the canonical Stage data file named by its completion receipt", 2, { result_file: resolved, expected: canonical });
|
|
578
741
|
return resolved;
|
|
579
742
|
}
|
|
580
743
|
function inside(root, file) { const relative = path.relative(root, file); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
@@ -9,16 +9,17 @@ import { ensureVnextWorkStorage } from "../storage/database.js";
|
|
|
9
9
|
import { projectHome, resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
|
|
10
10
|
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
11
11
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
12
|
-
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, prepareFlowRunStart, startFlowRun } from "./runs.js";
|
|
12
|
+
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, prepareFlowRunStageCompletion, prepareFlowRunStart, startFlowRun, withStageSettlement } from "./runs.js";
|
|
13
13
|
import { resolveStageTransition } from "./execution-policy.js";
|
|
14
14
|
import { validateSchema } from "./schema-validation.js";
|
|
15
15
|
import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
|
|
16
16
|
import { nextWorkId } from "./ids.js";
|
|
17
17
|
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
18
|
-
import {
|
|
18
|
+
import { acceptStageReport, prepareStageReport } from "./stage-report-renderer.js";
|
|
19
19
|
import { prepareError, prepareJsonSchema, prepareJsonText, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
|
|
20
20
|
import { hookSessionIdentity } from "./hooks.js";
|
|
21
21
|
import { assertRecoveryHookCurrent, assertRunMutationAllowed } from "./run-recovery.js";
|
|
22
|
+
import { stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
22
23
|
const flowId = "mb-sdlc-vnext-specify";
|
|
23
24
|
const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
|
|
24
25
|
const flowVersion = 4;
|
|
@@ -61,7 +62,7 @@ export function prepareVnextSpecifyStart(context, input) {
|
|
|
61
62
|
if (preflight.ok !== true)
|
|
62
63
|
throw new AppError("permission_preflight_failed", "SPECIFY project context is not readable", 1, { preflight });
|
|
63
64
|
const grounding = projectGrounding(projectRoot);
|
|
64
|
-
const handoff = run ? executionSnapshot(context, run.project_id, run.id) : executionSnapshotFromProfile(context, start?.executionProfile);
|
|
65
|
+
const handoff = run ? executionSnapshot(context, run.project_id, run.id, projectRoot, run.workspace_root) : executionSnapshotFromProfile(context, start?.executionProfile, projectRoot, projectRoot);
|
|
65
66
|
return { projectRoot, intake, discussion, template, startInput, start, preflight, grounding, handoff };
|
|
66
67
|
}
|
|
67
68
|
function assertSpecifyNotStarted(context, projectRoot, runId) {
|
|
@@ -187,21 +188,14 @@ export function submitVnextSpecify(context, input) {
|
|
|
187
188
|
const handoff = continuesToProtocolize ? executionSnapshotFromPath(path.join(stageRoot, "work-context.json"), work.work_id) : null;
|
|
188
189
|
const sameSessionContinuation = handoff?.stage_handoff.effective === "same_session";
|
|
189
190
|
const terminal = !continuesToProtocolize && (outcome === "specified" || outcome === "failed" || outcome === "cancelled");
|
|
190
|
-
if (terminal) {
|
|
191
|
-
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [outcome === "specified" ? "completed" : outcome, normalizedResult, now, now, work.work_id]);
|
|
192
|
-
context.db.run("UPDATE work_sessions SET status = ?, result_path = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [outcome === "specified" ? "completed" : outcome, resultFile, now, now, work.work_id]);
|
|
193
|
-
}
|
|
194
191
|
const workSession = latestWorkSession(context, work.work_id);
|
|
195
|
-
if (workSession && !sameSessionContinuation) {
|
|
196
|
-
context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ?", [resultFile, now, now, workSession.id]);
|
|
197
|
-
}
|
|
198
192
|
const reportPath = path.join(stageRoot, "stage-report.json");
|
|
199
193
|
const markdownPath = path.join(stageRoot, "stage-report.md");
|
|
200
194
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
201
195
|
const report = buildStageReport({ run, work, workSession, outcome, result, resultMarkdown: renderedMarkdown, now, stageRoot, resultFile });
|
|
202
|
-
|
|
203
|
-
validateSchema({ schemaName: "stage-report", file: reportPath, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
|
|
204
|
-
|
|
196
|
+
const preparedReport = prepareStageReport(report);
|
|
197
|
+
validateSchema({ schemaName: "stage-report", file: reportPath, data: preparedReport.normalized, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: runHome });
|
|
198
|
+
const completion = {
|
|
205
199
|
projectRoot,
|
|
206
200
|
runId: run.id,
|
|
207
201
|
stage: stageId,
|
|
@@ -209,7 +203,20 @@ export function submitVnextSpecify(context, input) {
|
|
|
209
203
|
data: "specify.json",
|
|
210
204
|
dataSchemaId: "dd-flow/specify@1",
|
|
211
205
|
report: "stage-report.md",
|
|
212
|
-
stageReport: "stage-report.html"
|
|
206
|
+
stageReport: "stage-report.html",
|
|
207
|
+
dataSha256: crypto.createHash("sha256").update(fs.readFileSync(resultFile)).digest("hex")
|
|
208
|
+
};
|
|
209
|
+
const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
|
|
210
|
+
withStageSettlement(context, () => {
|
|
211
|
+
const current = requireWork(context, work.work_id);
|
|
212
|
+
if (current.status !== "running" || current.updated_at !== work.updated_at)
|
|
213
|
+
throw new AppError("work_generation_stale", "SPECIFY Work changed during finish preparation", 1, { work_id: work.work_id });
|
|
214
|
+
if (terminal && context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running' AND updated_at = ?", [outcome === "specified" ? "completed" : outcome, normalizedResult, now, now, work.work_id, work.updated_at]).changes !== 1)
|
|
215
|
+
throw new AppError("work_generation_stale", "SPECIFY Work changed during finish preparation", 1, { work_id: work.work_id });
|
|
216
|
+
if (workSession && !sameSessionContinuation && context.db.run("UPDATE work_sessions SET status = 'completed', result_path = ?, updated_at = ?, completed_at = ? WHERE id = ? AND status = 'running' AND updated_at = ?", [resultFile, now, now, workSession.id, workSession.updated_at]).changes !== 1)
|
|
217
|
+
throw new AppError("work_generation_stale", "SPECIFY Session changed during finish preparation", 1, { work_id: work.work_id, work_session_id: workSession.id });
|
|
218
|
+
acceptStageReport(context, { projectId: project.id, runId: run.id, stage: stageId, root: stageRoot, prepared: preparedReport });
|
|
219
|
+
completeFlowRunStage(context, completion, preparedCompletion);
|
|
213
220
|
});
|
|
214
221
|
const updatedWork = requireWork(context, work.work_id);
|
|
215
222
|
const updatedWorkSession = workSession ? requireWorkSession(context, workSession.id) : null;
|
|
@@ -420,7 +427,8 @@ function renderPrompt(input) {
|
|
|
420
427
|
"```",
|
|
421
428
|
"The response tells you exactly what to ask and how to resume this same stage. Ask its user_message and stop the Turn.",
|
|
422
429
|
`Finish exactly once only after all questions are resolved. Run this as one standalone Bash command:\n\`${input.finishCommand}\`.`,
|
|
423
|
-
"Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators.
|
|
430
|
+
"Do not combine the lifecycle command with cat, skill reads, Git commands, pipes or shell operators. After success, follow only its returned next directive; a focused eval controller may explicitly stop at this stage boundary.",
|
|
431
|
+
stageLifecycleInstruction,
|
|
424
432
|
"</output_contract>",
|
|
425
433
|
""
|
|
426
434
|
].join("\n");
|
|
@@ -584,19 +592,19 @@ export function readVnextFlowDefinition(projectRoot) {
|
|
|
584
592
|
}
|
|
585
593
|
return null;
|
|
586
594
|
}
|
|
587
|
-
function executionSnapshot(context, projectId, runId) {
|
|
595
|
+
function executionSnapshot(context, projectId, runId, projectRoot, workspaceRoot) {
|
|
588
596
|
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
589
597
|
const snapshot = row ? JSON.parse(row.index_json) : null;
|
|
590
|
-
return executionSnapshotFromProfile(context, snapshot?.execution_profile, runId);
|
|
598
|
+
return executionSnapshotFromProfile(context, snapshot?.execution_profile, projectRoot, workspaceRoot, runId);
|
|
591
599
|
}
|
|
592
|
-
function executionSnapshotFromProfile(context, profile, runId) {
|
|
600
|
+
function executionSnapshotFromProfile(context, profile, projectRoot, workspaceRoot, runId) {
|
|
593
601
|
let handoff = profile?.settings.stage_session_mode;
|
|
594
602
|
if (handoff !== "same_session" && handoff !== "new_session")
|
|
595
603
|
throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
|
|
596
604
|
if (profile?.settings.execution) {
|
|
597
605
|
if (!profile.agent_profiles)
|
|
598
606
|
throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
|
|
599
|
-
handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
607
|
+
handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot, workspaceRoot, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
600
608
|
}
|
|
601
609
|
return { stage_handoff: { effective: handoff, source: "run_override" } };
|
|
602
610
|
}
|