@deksden-com/dd-flow-cli 0.7.0 → 0.8.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Ajv } from "ajv/dist/ajv.js";
6
+ import { Ajv2020 } from "ajv/dist/2020.js";
6
7
  import { normalizeFlowContract } from "../domain/flow-contract.js";
7
8
  import { AppError } from "../shared/errors.js";
8
9
  import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
@@ -96,8 +97,24 @@ export function validateSchema(options) {
96
97
  const data = readJson(filePath, "input file");
97
98
  const schemaResolution = resolveSchema(options);
98
99
  const schema = readJson(schemaResolution.path, "schema");
99
- const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
100
- const validate = ajv.compile(schema);
100
+ // Ajv's default export only carries the draft-07 meta-schema. New stage
101
+ // contracts may deliberately use draft 2020-12, so choose the validator
102
+ // from the declared schema rather than treating that declaration as data.
103
+ const schemaVersion = asRecord(schema) ? objectValue(asRecord(schema), "$schema") : undefined;
104
+ const ajv = schemaVersion === "https://json-schema.org/draft/2020-12/schema"
105
+ ? new Ajv2020({ allErrors: true, strict: false, validateFormats: false })
106
+ : new Ajv({ allErrors: true, strict: false, validateFormats: false });
107
+ let validate;
108
+ try {
109
+ validate = ajv.compile(schema);
110
+ }
111
+ catch (error) {
112
+ throw new AppError("schema_registry_invalid", "Schema cannot be compiled by the selected JSON Schema validator", 1, {
113
+ schema: schemaResolution,
114
+ declared_draft: schemaVersion ?? null,
115
+ cause: error instanceof Error ? error.message : String(error)
116
+ });
117
+ }
101
118
  const valid = validate(data);
102
119
  const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
103
120
  const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
@@ -169,7 +186,8 @@ function resolveRunBoundSchema(options) {
169
186
  const data = readJson(path.resolve(options.file), "input file");
170
187
  const root = asRecord(data);
171
188
  const run = root ? asRecord(objectValue(root, "run")) : undefined;
172
- const runId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
189
+ const inferredRunId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
190
+ const runId = options.runId ?? inferredRunId;
173
191
  if (typeof runId !== "string")
174
192
  return null;
175
193
  const projectRoot = path.resolve(options.projectRoot);
@@ -141,7 +141,7 @@ export function stopMergeWorker(context, input) {
141
141
  return { ok: true, stopped_sessions: sessions.length, lock_release, waiter_cancellation };
142
142
  }
143
143
  export function stoppedMergeWorkerState(context, projectId, workerId) {
144
- const latest = context.db.get(`SELECT status, stop_reason FROM flow_sessions
144
+ const latest = context.db.get(`SELECT status, stop_reason FROM sessions
145
145
  WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
146
146
  ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
147
147
  return {
@@ -151,21 +151,21 @@ export function stoppedMergeWorkerState(context, projectId, workerId) {
151
151
  };
152
152
  }
153
153
  export function activeFlowSessionsForProject(context, projectId) {
154
- return context.db.all(`SELECT * FROM flow_sessions
154
+ return context.db.all(`SELECT * FROM sessions
155
155
  WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
156
156
  ORDER BY updated_at DESC`, [projectId]);
157
157
  }
158
158
  export function flowSessionsForProject(context, projectId, filter = {}) {
159
159
  if (filter.sessionId) {
160
- return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
160
+ return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
161
161
  }
162
162
  if (filter.workerId) {
163
- return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
163
+ return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
164
164
  }
165
- return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
165
+ return context.db.all("SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
166
166
  }
167
167
  export function flowSessionById(context, projectId, sessionId) {
168
- return context.db.get("SELECT * FROM flow_sessions WHERE project_id = ? AND session_id = ?", [
168
+ return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND session_id = ?", [
169
169
  projectId,
170
170
  sessionId
171
171
  ]);
@@ -177,7 +177,7 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
177
177
  }
178
178
  const actionHash = hashString(actionKey);
179
179
  const nextCount = session.last_action_hash === actionHash ? session.continuation_count + 1 : 1;
180
- context.db.run(`UPDATE flow_sessions
180
+ context.db.run(`UPDATE sessions
181
181
  SET continuation_count = ?, last_action_hash = ?, updated_at = ?
182
182
  WHERE project_id = ? AND session_id = ?`, [nextCount, actionHash, context.now(), projectId, sessionId]);
183
183
  if (session.run_id)
@@ -200,7 +200,8 @@ export function flowSessionPayloadFromRegisterCommand(command) {
200
200
  function flowSessionPayloadFromStageStartCommand(command) {
201
201
  const projectRoot = optionFromCommand(command, "project-root");
202
202
  const stage = optionFromCommand(command, "stage");
203
- const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
203
+ const rawRun = command.match(/\bdd-flow\s+stage\s+start\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+)/)?.[1];
204
+ const run = rawRun ? shellArgument(rawRun) : undefined;
204
205
  if (!projectRoot || !stage || !run || run.startsWith("--"))
205
206
  return undefined;
206
207
  return {
@@ -248,6 +249,14 @@ function normalizeFlowSessionPayload(payload) {
248
249
  const sessionId = stringField(payload, "session_id", false);
249
250
  return {
250
251
  ...(sessionId ? { session_id: sessionId } : {}),
252
+ harness: stringField(payload, "harness", false) ?? "codex-desktop",
253
+ provider_session_id: stringField(payload, "provider_session_id", false) ?? null,
254
+ agent_id: stringField(payload, "agent_id", false) ?? null,
255
+ provider: stringField(payload, "provider", false) ?? null,
256
+ model: stringField(payload, "model", false) ?? null,
257
+ reasoning: stringField(payload, "reasoning", false) ?? null,
258
+ mode: stringField(payload, "mode", false) ?? null,
259
+ agent_type: stringField(payload, "agent_type", false) ?? null,
251
260
  project_root: projectRoot,
252
261
  flow_kind: flowKind,
253
262
  run_id: stringField(payload, "run_id", false) ?? null,
@@ -272,17 +281,28 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
272
281
  const now = context.now();
273
282
  const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
274
283
  const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
275
- context.db.run(`INSERT INTO flow_sessions
276
- (session_id, project_id, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
284
+ context.db.run(`INSERT INTO sessions
285
+ (session_id, project_id, harness, provider_session_id, agent_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
277
286
  continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason,
278
287
  transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at)
279
288
  VALUES (
280
- ?, ?, ?, ?, 'active',
289
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active',
281
290
  ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL,
282
291
  ?, ?, ?, ?, ?, ?, NULL
283
292
  )
284
293
  ON CONFLICT(session_id, project_id) DO UPDATE SET
285
294
  project_root = excluded.project_root,
295
+ harness = CASE
296
+ WHEN sessions.harness <> 'codex-desktop' AND excluded.harness = 'codex-desktop' THEN sessions.harness
297
+ ELSE excluded.harness
298
+ END,
299
+ provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id),
300
+ agent_id = COALESCE(excluded.agent_id, agent_id),
301
+ provider = COALESCE(excluded.provider, provider),
302
+ model = COALESCE(excluded.model, model),
303
+ reasoning = COALESCE(excluded.reasoning, reasoning),
304
+ mode = COALESCE(excluded.mode, mode),
305
+ agent_type = COALESCE(excluded.agent_type, agent_type),
286
306
  flow_kind = excluded.flow_kind,
287
307
  status = 'active',
288
308
  run_id = excluded.run_id,
@@ -306,6 +326,14 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
306
326
  stopped_at = NULL`, [
307
327
  sessionId,
308
328
  project.id,
329
+ payload.harness ?? "codex-desktop",
330
+ payload.provider_session_id ?? null,
331
+ payload.agent_id ?? null,
332
+ payload.provider ?? null,
333
+ payload.model ?? null,
334
+ payload.reasoning ?? null,
335
+ payload.mode ?? null,
336
+ payload.agent_type ?? null,
309
337
  project.root,
310
338
  payload.flow_kind,
311
339
  payload.run_id ?? null,
@@ -338,7 +366,7 @@ function requireFlowSession(context, projectId, sessionId) {
338
366
  }
339
367
  function markSessionStopped(context, project, session, reason) {
340
368
  const now = context.now();
341
- context.db.run(`UPDATE flow_sessions
369
+ context.db.run(`UPDATE sessions
342
370
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
343
371
  WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
344
372
  if (session.run_id && session.session_kind !== "orchestrator") {
@@ -399,6 +427,17 @@ function optionFromCommand(command, key) {
399
427
  const match = command.match(pattern);
400
428
  return match?.[1] ?? match?.[2] ?? match?.[3];
401
429
  }
430
+ function shellArgument(raw) {
431
+ if (raw.startsWith('"')) {
432
+ try {
433
+ return JSON.parse(raw);
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ }
439
+ return raw.startsWith("'") ? raw.slice(1, -1) : raw;
440
+ }
402
441
  function sanitizeValue(value) {
403
442
  if (Array.isArray(value)) {
404
443
  return value.map(sanitizeValue);
@@ -0,0 +1,57 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { resolveProjectRoot } from "../storage/paths.js";
3
+ import { requireProjectByRoot } from "./projects.js";
4
+ import { blockFlowRunStage, unblockFlowRunStage } from "./runs.js";
5
+ import { flowCommand } from "./stage-pause.js";
6
+ import { refreshRunWorkProjection } from "./work-registry.js";
7
+ export function blockStageForRuntime(context, input) {
8
+ const projectRoot = resolveProjectRoot(input.projectRoot);
9
+ const project = requireProjectByRoot(context, projectRoot);
10
+ const run = requireRun(context, project.id, input.runId);
11
+ const work = requireWork(context, project.id, run.id, input.workId);
12
+ if (work.status !== "running")
13
+ throw new AppError("invalid_work_state", "Only a running Work can be blocked", 1, { work_id: work.work_id, status: work.status });
14
+ if (!input.code.trim() || !input.summary.trim())
15
+ throw new AppError("validation", "stage block requires a code and summary", 2);
16
+ const state = blockFlowRunStage(context, { ...input, projectRoot });
17
+ const now = context.now();
18
+ context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
19
+ refreshRunWorkProjection(context, project.id, work.run_id);
20
+ const resumeCommand = `${flowCommand(context)} stage unblock ${work.run_id} --stage ${input.stage} --work ${work.work_id} --project-root ${JSON.stringify(projectRoot)} --json`;
21
+ return {
22
+ ok: true,
23
+ outcome: "blocked",
24
+ run_id: work.run_id,
25
+ stage: input.stage,
26
+ work_id: work.work_id,
27
+ blocker: { kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable },
28
+ next_action: "repair_external_blocker_then_unblock_same_stage",
29
+ unblock_command: resumeCommand,
30
+ state
31
+ };
32
+ }
33
+ export function unblockStageAfterRuntimeRepair(context, input) {
34
+ const projectRoot = resolveProjectRoot(input.projectRoot);
35
+ const project = requireProjectByRoot(context, projectRoot);
36
+ const run = requireRun(context, project.id, input.runId);
37
+ const work = requireWork(context, project.id, run.id, input.workId);
38
+ if (work.status !== "paused")
39
+ throw new AppError("invalid_work_state", "stage unblock requires its paused Work", 1, { work_id: work.work_id, status: work.status });
40
+ const state = unblockFlowRunStage(context, { projectRoot, runId: work.run_id, stage: input.stage, workId: work.work_id });
41
+ const now = context.now();
42
+ context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
43
+ refreshRunWorkProjection(context, project.id, work.run_id);
44
+ return { ok: true, outcome: "resumed", run_id: work.run_id, stage: input.stage, work_id: work.work_id, next_action: `continue_${input.stage}`, state };
45
+ }
46
+ function requireWork(context, projectId, runId, workId) {
47
+ const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
48
+ if (!work)
49
+ throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
50
+ return work;
51
+ }
52
+ function requireRun(context, projectId, runId) {
53
+ const rows = context.db.all("SELECT id, short_id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
54
+ if (rows.length !== 1)
55
+ throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: runId });
56
+ return rows[0];
57
+ }
@@ -0,0 +1,90 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { parseJsonObject } from "../shared/json.js";
6
+ export function loadExternalStageContext(input) {
7
+ const sourcePath = path.resolve(input.file);
8
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
9
+ throw new AppError("not_found", "Stage context file is missing", 2, { context_file: sourcePath });
10
+ const bytes = fs.readFileSync(sourcePath);
11
+ const materializedSha256 = sha256(bytes);
12
+ if (materializedSha256 !== input.expectedSha256)
13
+ throw new AppError("context_checksum_mismatch", "Stage context checksum does not match --context-sha256", 2, { context_file: sourcePath, expected_sha256: input.expectedSha256, actual_sha256: materializedSha256 });
14
+ const value = parseJsonObject(bytes.toString("utf8"), "stage context");
15
+ validateContext(value, input.stage);
16
+ return { value, sourcePath, materializedSha256 };
17
+ }
18
+ /** Installs exactly one immutable external context for a stage attempt. */
19
+ export function installExternalStageContext(input) {
20
+ fs.mkdirSync(input.stageRoot, { recursive: true });
21
+ const destination = path.join(input.stageRoot, "stage-context.json");
22
+ if (fs.existsSync(destination)) {
23
+ const existing = sha256(fs.readFileSync(destination));
24
+ if (existing !== input.loaded.materializedSha256)
25
+ throw new AppError("stage_context_replacement", "A different stage context is already installed for this Stage", 2, { stage_context: destination, expected_sha256: input.loaded.materializedSha256, actual_sha256: existing });
26
+ }
27
+ else {
28
+ // The receipt is the hash of the runner-materialized bytes, not of a
29
+ // convenient reserialization. Preserve those exact bytes as evidence.
30
+ const temporary = `${destination}.${process.pid}.tmp`;
31
+ fs.copyFileSync(input.loaded.sourcePath, temporary);
32
+ fs.renameSync(temporary, destination);
33
+ }
34
+ return {
35
+ path: destination,
36
+ materialized_sha256: input.loaded.materializedSha256,
37
+ semantic_package_sha256: input.loaded.value.semantic_package_sha256 ?? null,
38
+ context_slice_sha256: input.loaded.value.context_slice_sha256 ?? null
39
+ };
40
+ }
41
+ /** Install the runner slice and make it visible in the agent packet before a
42
+ * caller performs its lifecycle transition. */
43
+ export function applyExternalStageContext(input) {
44
+ if (!input.loaded)
45
+ return undefined;
46
+ const installed = installExternalStageContext({ stageRoot: input.stageRoot, loaded: input.loaded });
47
+ const marker = "<external_stage_context>";
48
+ const current = fs.existsSync(input.promptPath) ? fs.readFileSync(input.promptPath, "utf8") : "";
49
+ if (!current.includes(marker)) {
50
+ fs.writeFileSync(input.promptPath, `${renderExternalStageContext({ loaded: input.loaded, installed })}\n\n${current}`);
51
+ }
52
+ return installed;
53
+ }
54
+ export function renderExternalStageContext(input) {
55
+ const value = input.loaded.value;
56
+ const sources = value.sources ?? [];
57
+ const taskInput = value.task_input ?? [];
58
+ const decisions = value.accepted_decisions ?? [];
59
+ return [
60
+ "<external_stage_context>",
61
+ `- materialized context: ${String(input.installed.path)}`,
62
+ `- materialized SHA-256: ${input.loaded.materializedSha256}`,
63
+ `- semantic package SHA-256: ${value.semantic_package_sha256 ?? "not supplied"}`,
64
+ `- context slice SHA-256: ${value.context_slice_sha256 ?? "not supplied"}`,
65
+ "</external_stage_context>", "",
66
+ "<objective>", value.objective, "</objective>", "",
67
+ "<required_inputs>",
68
+ ...(taskInput.length ? taskInput.map((item) => `- ${item.role}: ${item.path}`) : ["- No separate task-input file is declared."]),
69
+ "</required_inputs>", "",
70
+ "<project_context>",
71
+ ...(sources.length ? sources.map((item) => `- ${item.role}: ${item.path}${item.required === false ? " (optional)" : ""} — ${item.reason}`) : ["- No extra project sources are declared."]),
72
+ "</project_context>", "",
73
+ "<accepted_decisions>",
74
+ ...(decisions.length ? decisions.map((item) => `- ${typeof item === "string" ? item : `${item.id ? `${item.id}: ` : ""}${item.statement ?? ""}`}`) : ["- No additional accepted decisions."]),
75
+ "</accepted_decisions>"
76
+ ].join("\n");
77
+ }
78
+ function validateContext(value, expectedStage) {
79
+ if (value.schema_id !== "dd-eval/stage-context@1")
80
+ throw new AppError("validation", "Stage context must use dd-eval/stage-context@1", 2);
81
+ if (value.stage !== expectedStage)
82
+ throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
83
+ if (!value.objective?.trim())
84
+ throw new AppError("validation", "Stage context objective is required", 2);
85
+ for (const source of value.sources ?? []) {
86
+ if (!source.role?.trim() || !source.path?.trim() || !source.reason?.trim())
87
+ throw new AppError("validation", "Every stage context source needs role, path and reason", 2);
88
+ }
89
+ }
90
+ function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }