@deksden-com/dd-flow-cli 0.9.0-beta.31 → 0.9.0-beta.32

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.32
4
+
5
+ ### Patch Changes
6
+
7
+ - Reject reviewer results without per-aspect evidence when the Work is finished, and distinguish missing evidence from an aspect-coverage mismatch in legacy stage validation.
8
+
3
9
  ## 0.9.0-beta.31
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.31",
4
- "cli_commit": "f21c8317e762f978272536faff6822921dffeff5",
5
- "built_at": "2026-09-07T16:20:23.716Z",
3
+ "cli_version": "0.9.0-beta.32",
4
+ "cli_commit": "2d5d953e65e996d9f57d1d4edec97a730c578acb",
5
+ "built_at": "2026-09-07T18:27:14.770Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.6",
8
8
  "commit": "c6fc50cb3b5526ea0162ee4454d1ee36f17be2da",
@@ -8,7 +8,7 @@
8
8
  "schema_id": {"const": "dd-flow/code-review-result@1"},
9
9
  "verdict": {"enum": ["pass", "findings", "blocked"]},
10
10
  "summary": {"type": "string", "minLength": 1},
11
- "aspects": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["aspect_id", "verdict", "summary", "evidence_refs"], "properties": {"aspect_id": {"type": "string", "minLength": 1}, "verdict": {"enum": ["pass", "findings", "blocked"]}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "items": {"type": "string"}}}}},
11
+ "aspects": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["aspect_id", "verdict", "summary", "evidence_refs"], "properties": {"aspect_id": {"type": "string", "minLength": 1}, "verdict": {"enum": ["pass", "findings", "blocked"]}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}}},
12
12
  "findings": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["finding_id", "aspect_id", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs"], "properties": {"finding_id": {"type": "string", "pattern": "^FIND-[0-9]{3}$"}, "aspect_id": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "obligation_refs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}}}
13
13
  }
14
14
  }
@@ -20,7 +20,7 @@
20
20
  "aspect_id": {"type": "string", "minLength": 1},
21
21
  "verdict": {"enum": ["pass", "watch", "needs_changes", "blocked"]},
22
22
  "summary": {"type": "string", "minLength": 1},
23
- "evidence_refs": {"type": "array", "items": {"type": "string", "minLength": 1}},
23
+ "evidence_refs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
24
24
  "findings": {
25
25
  "type": "array",
26
26
  "items": {
@@ -31,7 +31,7 @@
31
31
  "finding_id": {"type": "string", "pattern": "^FIND-[0-9]{3}$"},
32
32
  "severity": {"enum": ["blocker", "high", "medium", "low", "info"]},
33
33
  "summary": {"type": "string", "minLength": 1},
34
- "evidence_refs": {"type": "array", "items": {"type": "string", "minLength": 1}}
34
+ "evidence_refs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}
35
35
  }
36
36
  }
37
37
  }
@@ -8,6 +8,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { registerProject, requireProjectByRoot } from "./projects.js";
9
9
  import { getFlowRunStatus, startFlowRun } from "./runs.js";
10
10
  import { readVnextFlowDefinition } from "./vnext-specify.js";
11
+ import { assertRunWorkResultArtifacts } from "./work-registry.js";
11
12
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
12
13
  export function createEvalBootstrapSnapshot(context, input) {
13
14
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -80,6 +81,7 @@ function captureEvalRunSnapshot(context, input) {
80
81
  const project = requireProjectByRoot(context, projectRoot);
81
82
  const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
82
83
  assertDedicatedHome(context, project.id, input.runId);
84
+ assertRunWorkResultArtifacts(context, project.id, input.runId);
83
85
  const recovery = typeof input.recoveryId === "string";
84
86
  if (recovery) {
85
87
  const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
@@ -423,8 +423,11 @@ function reviewerEvidenceFailures(context, input) {
423
423
  continue;
424
424
  }
425
425
  const actual = result.aspects.map((item) => item.aspect_id);
426
- if (actual.length !== group.aspect_ids.length || new Set(actual).size !== actual.length || group.aspect_ids.some((id) => !actual.includes(id)) || result.aspects.some((item) => item.evidence_refs.length === 0))
426
+ if (actual.length !== group.aspect_ids.length || new Set(actual).size !== actual.length || group.aspect_ids.some((id) => !actual.includes(id)))
427
427
  failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_coverage_invalid", expected_aspects: group.aspect_ids, actual_aspects: actual });
428
+ const missingEvidence = result.aspects.filter((item) => item.evidence_refs.length === 0).map((item) => item.aspect_id);
429
+ if (missingEvidence.length)
430
+ failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_evidence_missing", missing_aspects: missingEvidence });
428
431
  for (const ref of result.aspects.flatMap((item) => [...item.evidence_refs, ...item.findings.flatMap((finding) => finding.evidence_refs)])) {
429
432
  try {
430
433
  assertPortableArtifactRef(ref, { workspaceRoot: input.workspaceRoot, runHome: input.runHome, runId: input.runId });
@@ -182,6 +182,17 @@ export function listWorks(context, input) {
182
182
  }) };
183
183
  }
184
184
  export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
185
+ /** DB rows are authoritative; reject snapshots that would preserve a rewritten Work receipt. */
186
+ export function assertRunWorkResultArtifacts(context, projectId, runId) {
187
+ const works = context.db.all(`SELECT w.work_id, w.result, (SELECT ws.result_path FROM work_sessions ws WHERE ws.work_id = w.work_id ORDER BY ws.created_at DESC LIMIT 1) AS result_path FROM works w WHERE w.project_id = ? AND w.run_id = ? AND w.result_schema IS NOT NULL AND w.result IS NOT NULL AND w.status = 'completed'`, [projectId, runId]);
188
+ for (const work of works) {
189
+ if (!work.result_path || !fs.existsSync(work.result_path))
190
+ throw new AppError("work_result_artifact_missing", "Completed structured Work has no retained result artifact", 1, { work_id: work.work_id, result_path: work.result_path });
191
+ const actual = fs.readFileSync(work.result_path, "utf8");
192
+ if (actual !== work.result)
193
+ throw new AppError("work_result_artifact_drift", "Completed Work result artifact no longer matches its authoritative receipt", 1, { work_id: work.work_id, result_path: work.result_path, expected_sha256: crypto.createHash("sha256").update(work.result).digest("hex"), actual_sha256: crypto.createHash("sha256").update(actual).digest("hex") });
194
+ }
195
+ }
185
196
  export function mutateWorkDeps(context, input) {
186
197
  const work = requireWork(context, input.workId);
187
198
  if (input.action === "list")
@@ -619,11 +630,11 @@ export function resultSchemaGuidance(work, runId) {
619
630
  "Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
620
631
  }
621
632
  if (schema === "dd-flow/code-review-result@1") {
622
- return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: [] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
633
+ return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/evidence"] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
623
634
  }
624
635
  if (schema !== "dd-flow/plan-review-result@1")
625
636
  return [];
626
- return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: [], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
637
+ return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/evidence"], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
627
638
  }
628
639
  export function validateCodeReviewResultIdentity(work, value) {
629
640
  const group = codeReviewGroup(work);
@@ -652,6 +663,7 @@ export function validateCodeReviewResultIdentity(work, value) {
652
663
  function validateCodeReviewResult(work, value, input) {
653
664
  validateCodeReviewResultIdentity(work, value);
654
665
  const result = value;
666
+ assertReviewAspectEvidence(work, result.aspects ?? [], "CODE");
655
667
  const findings = result.findings ?? [];
656
668
  // A reviewer is a child Work and cannot pause its coordinator-owned Stage.
657
669
  // Its structured blocked result is evidence for the coordinator, which then
@@ -681,6 +693,7 @@ export function validatePlanReviewResult(work, value, input) {
681
693
  const unexpected = ids.filter((id) => !expected.has(id));
682
694
  if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
683
695
  throw new AppError("review_evidence_invalid", "PLAN reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
696
+ assertReviewAspectEvidence(work, aspects, "PLAN");
684
697
  const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
685
698
  const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
686
699
  if (new Set(findingIds).size !== findingIds.length || invalid.length)
@@ -695,6 +708,11 @@ export function validatePlanReviewResult(work, value, input) {
695
708
  assertPortableArtifactRef(ref, input);
696
709
  }
697
710
  }
711
+ function assertReviewAspectEvidence(work, aspects, stage) {
712
+ const missing = aspects.filter((aspect) => !Array.isArray(aspect.evidence_refs) || aspect.evidence_refs.length === 0).map((aspect) => aspect.aspect_id ?? "");
713
+ if (missing.length)
714
+ throw new AppError("review_evidence_invalid", `${stage} reviewer result requires non-empty evidence_refs for every assigned aspect`, 2, { work_id: work.work_id, missing_aspects: missing });
715
+ }
698
716
  function codeReviewGroup(work) {
699
717
  const payload = parsePayload(work);
700
718
  const group = payload?.group;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.31",
3
+ "version": "0.9.0-beta.32",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {