@deksden-com/dd-flow-cli 0.9.0-beta.135 → 0.9.0-beta.137

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.
@@ -4,6 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { parse as parseYaml } from "yaml";
7
8
  import { AppError } from "../shared/errors.js";
8
9
  import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
9
10
  import { previewNextEntityId } from "./ids.js";
@@ -189,6 +190,29 @@ export async function finishVnextProtocolize(context, input) {
189
190
  const resultFile = prepareProtocolizeResultPath(requireRunHome(run), input.resultFile);
190
191
  const result = input.result ?? readResult(resultFile, { projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
191
192
  validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, data: result, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
193
+ const acceptedIndex = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]).index_json);
194
+ if (acceptedIndex.stage_runs.some(item => item.stage === stage && item.status === "done")) {
195
+ publishStageReport(context, project.id, run.id, stage, root);
196
+ const file = path.join(root, "stage-report.json");
197
+ if (!fs.existsSync(file))
198
+ throw new AppError("protocolize_report_missing", "Completed PROTOCOLIZE has no accepted report to replay", 1, { run_id: run.id });
199
+ const accepted = JSON.parse(fs.readFileSync(file, "utf8"));
200
+ const authoredSha = accepted.semantic.materialization?.authored_result?.sha256;
201
+ if (authoredSha && authoredSha !== sha256(fs.readFileSync(resultFile)))
202
+ throw new AppError("protocolize_intent_conflict", "Completed PROTOCOLIZE result differs from accepted input", 1, { run_id: run.id });
203
+ const currentStage = acceptedIndex.current_stage ?? [...acceptedIndex.stage_runs].reverse().find(item => item.status === "running" || item.status === "done")?.stage;
204
+ const activeReplay = currentStage === stage && acceptedIndex.status === "running";
205
+ const resolvedPolicy = activeReplay ? requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" }) : accepted.mechanical?.workspace_route;
206
+ if (activeReplay) {
207
+ if (acceptedIndex.verdict !== "protocolized")
208
+ advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
209
+ refreshRunWorkProjection(context, project.id, run.id);
210
+ }
211
+ const next = activeReplay ? stageSuccessor(context, { projectRoot, runId: run.id, stage: "plan", command: `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`, metadata: { cwd: run.workspace_root, session_mode: resolvedPolicy.stage_session_mode } }) : { kind: "complete", outcome: "already_completed" };
212
+ return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: accepted.semantic.acceptance, workspace_root: run.workspace_root, git_policy: resolvedPolicy, next_action: activeReplay ? "await_plan" : acceptedIndex.next_action, ...("command" in next && next.command ? { next_command: next.command } : {}), next,
213
+ artifacts: { result: resultFile, report_json: file, report_markdown: path.join(root, "stage-report.md"), report_html: path.join(root, "stage-report.html"), protocols: accepted.semantic.acceptance.map(id => path.join(run.workspace_root, ".memory-bank/protocol", id, "summary.md")) } };
214
+ }
215
+ const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
192
216
  const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
193
217
  let obligations;
194
218
  try {
@@ -202,11 +226,11 @@ export async function finishVnextProtocolize(context, input) {
202
226
  throw error;
203
227
  throw new AppError(error.code, error.message, error.exitCode, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
204
228
  }
205
- const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
206
229
  const receipt = readWorkspaceRouteReceipt(root);
207
230
  const workspaceRoot = run.workspace_root;
208
231
  const intent = await prepareProtocolizeIntent(context, run, resultFile, result, obligations);
209
232
  publishProtocolizeIntent(context, project.id, intent);
233
+ const materialization = verifyProtocolizeMaterialization(intent, result, path.relative(requireRunHome(run), resultFile));
210
234
  const protocolIds = intent.protocol_ids;
211
235
  const changedFiles = intent.changed_files;
212
236
  for (const protocolId of protocolIds)
@@ -216,9 +240,10 @@ export async function finishVnextProtocolize(context, input) {
216
240
  ensureManagedWorktreeRecorded(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
217
241
  }
218
242
  const now = context.now();
219
- const workSession = completeWorkSession(context, work.work_id, resultFile, now);
243
+ const workSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
220
244
  const routedRun = requireRun(context, projectRoot, run.id);
221
245
  const report = stageReport(context, routedRun, resultFile, protocolIds, changedFiles, workSession?.session_id ?? null, now, resolvedPolicy);
246
+ Object.assign(report.semantic, { materialization });
222
247
  const reportJson = path.join(root, "stage-report.json");
223
248
  const reportMarkdown = path.join(root, "stage-report.md");
224
249
  const reportHtml = path.join(root, "stage-report.html");
@@ -235,6 +260,7 @@ export async function finishVnextProtocolize(context, input) {
235
260
  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") };
236
261
  const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
237
262
  withStageSettlement(context, () => {
263
+ completeWorkSession(context, work.work_id, resultFile, now);
238
264
  acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
239
265
  completeFlowRunStage(context, completion, preparedCompletion);
240
266
  });
@@ -367,6 +393,38 @@ function ensureManagedWorktreeRecorded(context, input) {
367
393
  recordManagedWorktree(context, input);
368
394
  }
369
395
  function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
396
+ export function verifyProtocolizeMaterialization(intent, result, authoredPath) {
397
+ const documents = intent.files.map(file => {
398
+ const target = path.resolve(intent.workspace_root, file.relative);
399
+ inside(path.join(intent.workspace_root, ".memory-bank"), target);
400
+ assertNoSymlinkPath(path.join(intent.workspace_root, ".memory-bank"), target);
401
+ const bytes = fs.readFileSync(target);
402
+ if (sha256(bytes) !== file.after_sha256)
403
+ throw new AppError("protocolize_materialization_invalid", "Published document differs from accepted intent", 1, { path: file.relative });
404
+ return { path: file.relative, sha256: file.after_sha256 };
405
+ });
406
+ const featurePaths = intent.changed_files.filter(file => /\/features\/FT-[^/]+\/index\.md$/.test(file));
407
+ const featurePath = result.feature.action === "not_applicable" ? null : featurePaths.length === 1 ? featurePaths[0] : null;
408
+ if (result.feature.action !== "not_applicable" && !featurePath)
409
+ throw new AppError("protocolize_materialization_invalid", "Accepted feature output is ambiguous or missing", 1);
410
+ const frontmatter = (relative) => {
411
+ const text = fs.readFileSync(path.join(intent.workspace_root, relative), "utf8");
412
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
413
+ const value = match ? parseYaml(match[1]) : null;
414
+ if (!value || typeof value !== "object" || Array.isArray(value))
415
+ throw new AppError("protocolize_materialization_invalid", "Published document lacks frontmatter", 1, { path: relative });
416
+ return value;
417
+ };
418
+ const edges = featurePath ? intent.protocol_ids.map(protocolId => ({ feature_path: featurePath, protocol_id: protocolId, protocol_path: `.memory-bank/protocol/${protocolId}/summary.md` })) : [];
419
+ for (const edge of edges) {
420
+ const protocols = frontmatter(edge.feature_path).related_protocols;
421
+ const features = frontmatter(edge.protocol_path).related_features;
422
+ if (!Array.isArray(protocols) || !protocols.includes(edge.protocol_id) || !Array.isArray(features) || !features.includes(edge.feature_path))
423
+ throw new AppError("protocolize_materialization_invalid", "Owned feature/protocol links are not reciprocal", 1, edge);
424
+ }
425
+ return { schema_id: "dd-flow/protocolize-materialization@1", run_id: intent.run_id, stage: "protocolize", action: result.feature.action, feature_path: featurePath,
426
+ protocol_ids: intent.protocol_ids, authored_result: { role: "authored_input", run_relative_path: authoredPath, sha256: intent.result_sha256 }, documents, owned_links: edges, status: "passed" };
427
+ }
370
428
  async function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
371
429
  const psetPath = psetId ? path.join(projectRoot, ".memory-bank", "protocol", "_set", `${psetId}.md`) : null;
372
430
  const created = [];
@@ -5,7 +5,7 @@ import { channel } from "node:diagnostics_channel";
5
5
  import { createRequire } from "node:module";
6
6
  import { ensureDir } from "./paths.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { executeStoreSchema, readStoreWriterContract, registerStoreWriter, STORE_WRITER_VERSION } from "./writer-contract.js";
8
+ import { executeStoreSchema, readStoreWriterContract, registerStoreWriter, registerReconciliationWriter, STORE_WRITER_VERSION } from "./writer-contract.js";
9
9
  import { assertImportAdmission } from "./import-guard.js";
10
10
  import { withStoreCleanup, rollbackStoreTransaction } from "./native-cleanup.js";
11
11
  const workContinuationStorageSql = `
@@ -354,6 +354,8 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
354
354
  }
355
355
  if (mode === "hook" && !exists)
356
356
  throw new AppError("hook_storage_unprepared", "Prepare the runtime store before starting native hooks", 1);
357
+ if (mode === "reconcile" && !exists)
358
+ throw new AppError("reconciliation_storage_unprepared", "Reconciliation cannot create a runtime store", 1);
357
359
  if (!exists || mode === "migrate")
358
360
  ensureDir(ddFlowHome);
359
361
  const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
@@ -365,7 +367,10 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
365
367
  }
366
368
  if (mode !== "read_existing") {
367
369
  try {
368
- registerStoreWriter(db);
370
+ if (mode === "reconcile")
371
+ registerReconciliationWriter(db);
372
+ else
373
+ registerStoreWriter(db);
369
374
  }
370
375
  catch (error) {
371
376
  return closeAfterOpenFailure(db, error);
@@ -379,7 +384,7 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
379
384
  }
380
385
  // Internal additive schema changes must be available to every write command.
381
386
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
382
- if (mode !== "read_existing") {
387
+ if (mode !== "read_existing" && mode !== "reconcile") {
383
388
  try {
384
389
  const initialize = () => { migrate(db); ensureRecoveryStorage(db); ensureLifecycleInvocationStorage(db); };
385
390
  if (mode !== "hook") {
@@ -402,6 +407,16 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
402
407
  }
403
408
  if (mode === "read_existing")
404
409
  db.exec("PRAGMA query_only = ON");
410
+ if (mode === "reconcile") {
411
+ try {
412
+ db.prepare("SELECT id, project_id, status, verdict, next_action, index_json, updated_at, completed_at FROM runs LIMIT 0");
413
+ db.prepare("SELECT id, root FROM projects LIMIT 0");
414
+ db.prepare("SELECT id, event_type, payload_json FROM audit_events LIMIT 0");
415
+ }
416
+ catch (error) {
417
+ return closeAfterOpenFailure(db, error);
418
+ }
419
+ }
405
420
  db.exec("PRAGMA foreign_keys = ON");
406
421
  return databaseFacade(dbPath, mode !== "read_existing", db, undefined, deadline);
407
422
  }
@@ -35,6 +35,14 @@ export function registerStoreWriter(db) {
35
35
  throw new AppError("store_writer_incompatible", "This CLI cannot write the store's required contract", 1, { required: current, supported: version });
36
36
  db.function("dd_flow_writer_version", () => version);
37
37
  }
38
+ /** Health maintenance uses only verified legacy lifecycle columns and never
39
+ * migrates schema. Keep the retained writer fence, including version 1. */
40
+ export function registerReconciliationWriter(db) {
41
+ const current = readStoreWriterContract(db);
42
+ if (current !== null && current !== 1 && current !== version)
43
+ throw new AppError("store_writer_incompatible", "Unsupported reconciliation writer contract", 1, { required: current, supported: [1, version] });
44
+ db.function("dd_flow_writer_version", () => current ?? version);
45
+ }
38
46
  export function readStoreWriterContract(db) {
39
47
  if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table))
40
48
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.135",
3
+ "version": "0.9.0-beta.137",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.23.0",