@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,9 +1,9 @@
1
1
  import crypto from "node:crypto";
2
- import fs from "node:fs";
3
2
  import { continuationPolicies, flowKinds } from "../domain/contracts.js";
4
3
  import { AppError } from "../shared/errors.js";
5
- import { commandOption, commandPosition, parseLifecycleCommand } from "./lifecycle-command.js";
4
+ import { hookSessionIdentity } from "./hooks.js";
6
5
  import { parseJsonObject } from "../shared/json.js";
6
+ import { prepareTextFile } from "../cli/input-preparation.js";
7
7
  import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { appendAudit } from "./audit.js";
9
9
  import { cancelLaneWaitersForWorker } from "./lanes.js";
@@ -11,6 +11,18 @@ import { checkpointSessionUsage } from "./usage.js";
11
11
  import { refreshRunSessionProjection } from "./run-projection.js";
12
12
  import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
13
13
  import { appendFlowRunTimelineEvent } from "./runs.js";
14
+ import { publicSessionIdentity, storageSessionId } from "./session-identity.js";
15
+ export function publicFlowSession(context, session) {
16
+ const identity = publicSessionIdentity(session);
17
+ const parent = session.parent_session_id
18
+ ? context.db.get("SELECT harness, provider_session_id, session_id FROM sessions WHERE project_id = ? AND session_id = ?", [session.project_id, session.parent_session_id])
19
+ : null;
20
+ const publicFields = { ...session };
21
+ delete publicFields.session_id;
22
+ delete publicFields.provider_session_id;
23
+ delete publicFields.parent_session_id;
24
+ return { ...publicFields, harness_id: identity.harness_id, session_id: identity.session_id, parent_session: parent ? publicSessionIdentity(parent) : null };
25
+ }
14
26
  export function recordFlowSessionObservation(context, input) {
15
27
  const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
16
28
  if (duplicate)
@@ -18,8 +30,8 @@ export function recordFlowSessionObservation(context, input) {
18
30
  const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
19
31
  WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
20
32
  const now = context.now();
21
- const runId = input.runId ?? null;
22
- const protocolId = input.protocolId ?? null;
33
+ const runId = input.runId === undefined ? open?.run_id ?? null : input.runId;
34
+ const protocolId = input.protocolId === undefined ? open?.protocol_id ?? null : input.protocolId;
23
35
  if (open && open.run_id === runId && open.protocol_id === protocolId) {
24
36
  return { changed: false, segment_id: open.id };
25
37
  }
@@ -30,27 +42,6 @@ export function recordFlowSessionObservation(context, input) {
30
42
  VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
31
43
  return { changed: true, segment_id: Number(result.lastInsertRowid) };
32
44
  }
33
- export function bindObservedFlowSession(context, project, payload, sessionId) {
34
- if (resolveProjectRoot(payload.project_root) !== project.root) {
35
- throw new AppError("project_mismatch", "Observed session payload project_root does not match hook cwd project", 1, {
36
- expected: project.root,
37
- actual: payload.project_root
38
- });
39
- }
40
- const session = upsertFlowSession(context, project, payload, sessionId);
41
- if (session.session_kind !== "orchestrator") {
42
- const now = context.now();
43
- for (const unit of payload.coverage_units ?? []) {
44
- if (!unit.job_id)
45
- continue;
46
- context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
47
- WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, now, now, project.id, session.run_id ?? "", unit.job_id]);
48
- }
49
- }
50
- if (session.run_id)
51
- refreshRunSessionProjection(context, project.id, session.run_id);
52
- return session;
53
- }
54
45
  export function registerFlowJob(context, input) {
55
46
  const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
56
47
  const now = context.now();
@@ -64,9 +55,33 @@ export function flowJobsForRun(context, projectId, runId) {
64
55
  return context.db.all("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY plan_item_id", [projectId, runId]);
65
56
  }
66
57
  export function registerFlowSession(context, input) {
67
- const payload = decodeFlowSessionPayload(input);
58
+ const payload = input.preparedPayload ?? prepareFlowSessionPayload(input);
68
59
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
69
- const session = upsertFlowSession(context, project, payload, input.sessionId);
60
+ if (payload.run_id && !context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ?", [project.id, payload.run_id])) {
61
+ throw new AppError("not_found", `Run is not registered: ${payload.run_id}`, 1, { run_id: payload.run_id, effect: "no_effect" });
62
+ }
63
+ const identity = input.hookEventId ? hookSessionIdentity(context, project.id, input.hookEventId) : undefined;
64
+ if (!identity && input.sessionId && payload.provider_session_id && payload.provider_session_id !== input.sessionId) {
65
+ throw new AppError("validation", "--session-id conflicts with payload provider_session_id", 2);
66
+ }
67
+ const session = upsertFlowSession(context, project, identity ? {
68
+ ...payload, harness: identity.harness, provider_session_id: identity.providerSessionId,
69
+ parent_session_id: identity.parentSessionId, agent_id: identity.agentId,
70
+ provider: identity.provider, model: identity.model, reasoning: identity.reasoning,
71
+ mode: identity.mode, agent_type: identity.agentType
72
+ } : { ...payload, ...(input.sessionId ? { provider_session_id: input.sessionId } : {}) }, identity?.sessionId ?? input.sessionId);
73
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId: session.session_id,
74
+ runId: session.run_id, protocolId: session.protocol_id, cwd: session.cwd,
75
+ eventKey: `session-register-${crypto.randomUUID()}` });
76
+ // Logical Work/job binding belongs to the successful CLI registration, never
77
+ // to observation of model-supplied payload by a pre-tool hook.
78
+ if (session.session_kind !== "orchestrator")
79
+ for (const unit of payload.coverage_units ?? []) {
80
+ if (!unit.job_id)
81
+ continue;
82
+ context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
83
+ WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, context.now(), context.now(), project.id, session.run_id ?? "", unit.job_id]);
84
+ }
70
85
  if (session.run_id)
71
86
  appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_bound", session_id: session.session_id });
72
87
  if (session.run_id)
@@ -86,7 +101,7 @@ export function registerFlowSession(context, input) {
86
101
  },
87
102
  ...(session.protocol_id ? { protocolId: session.protocol_id } : {})
88
103
  });
89
- return { ok: true, session };
104
+ return { ok: true, session: publicFlowSession(context, session) };
90
105
  }
91
106
  export function getFlowSessionStatus(context, input) {
92
107
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -96,7 +111,7 @@ export function getFlowSessionStatus(context, input) {
96
111
  });
97
112
  return {
98
113
  ok: true,
99
- sessions,
114
+ sessions: sessions.map((session) => publicFlowSession(context, session)),
100
115
  coverage: reconcileSessionCoverage(sessions)
101
116
  };
102
117
  }
@@ -111,13 +126,14 @@ export function stopFlowSession(context, input) {
111
126
  markSessionStopped(context, project, session, input.reason);
112
127
  if (session.run_id)
113
128
  refreshRunSessionProjection(context, project.id, session.run_id);
114
- return { ok: true, session: flowSessionById(context, project.id, input.sessionId) };
129
+ const updated = flowSessionById(context, project.id, input.sessionId);
130
+ return { ok: true, session: updated ? publicFlowSession(context, updated) : null };
115
131
  }
116
132
  export function syncFlowSessionUsage(context, input) {
117
133
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
118
134
  const session = requireFlowSession(context, project.id, input.sessionId);
119
135
  if (!session.run_id) {
120
- return { ok: true, session_id: session.session_id, extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
136
+ return { ok: true, session: publicSessionIdentity(session), extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
121
137
  }
122
138
  return { ok: true, snapshot: checkpointSessionUsage(context, session, { checkpoint: "manual_sync", stage: session.current_stage }) };
123
139
  }
@@ -158,7 +174,7 @@ export function activeFlowSessionsForProject(context, projectId) {
158
174
  }
159
175
  export function flowSessionsForProject(context, projectId, filter = {}) {
160
176
  if (filter.sessionId) {
161
- return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
177
+ return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?) ORDER BY updated_at DESC", [projectId, filter.sessionId, filter.sessionId]);
162
178
  }
163
179
  if (filter.workerId) {
164
180
  return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
@@ -166,10 +182,7 @@ export function flowSessionsForProject(context, projectId, filter = {}) {
166
182
  return context.db.all("SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
167
183
  }
168
184
  export function flowSessionById(context, projectId, sessionId) {
169
- return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND session_id = ?", [
170
- projectId,
171
- sessionId
172
- ]);
185
+ return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?)", [projectId, sessionId, sessionId]);
173
186
  }
174
187
  export function updateFlowSessionContinuation(context, projectId, sessionId, actionKey) {
175
188
  const session = flowSessionById(context, projectId, sessionId);
@@ -185,52 +198,15 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
185
198
  refreshRunSessionProjection(context, projectId, session.run_id);
186
199
  return nextCount;
187
200
  }
188
- export function flowSessionPayloadFromRegisterCommand(command) {
189
- const invocation = typeof command === "string" ? invocationFromCommand(command) : command;
190
- if (!invocation)
191
- return undefined;
192
- if (invocation.operation === "stage_start")
193
- return flowSessionPayloadFromStageStartCommand(invocation);
194
- if (invocation.operation !== "session_register")
195
- return undefined;
196
- const payloadBase64 = commandOption(invocation, "payload-base64");
197
- const payloadJson = commandOption(invocation, "payload-json");
198
- const payloadFile = commandOption(invocation, "payload-file");
199
- if (!payloadBase64 && !payloadJson && !payloadFile) {
200
- return undefined;
201
- }
202
- return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
203
- }
204
- function flowSessionPayloadFromStageStartCommand(invocation) {
205
- const projectRoot = commandOption(invocation, "project-root");
206
- const stage = commandOption(invocation, "stage");
207
- const run = commandPosition(invocation, 0);
208
- if (!projectRoot || !stage || !run || run.startsWith("--"))
209
- return undefined;
210
- return {
211
- project_root: projectRoot,
212
- flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
213
- run_id: run,
214
- protocol_id: null,
215
- worker_id: null,
216
- workspace_path: null,
217
- continuation_policy: "go_router",
218
- session_kind: "orchestrator",
219
- current_stage: stage,
220
- coverage_units: []
221
- };
222
- }
223
- function invocationFromCommand(command) {
224
- const analysis = parseLifecycleCommand(command);
225
- return analysis.kind === "none" ? undefined : analysis.invocation;
226
- }
227
- function decodeFlowSessionPayload(input) {
201
+ export function prepareFlowSessionPayload(input) {
202
+ if ([input.payloadFile, input.payloadBase64, input.payloadJson].filter(value => value !== undefined).length !== 1)
203
+ throw new AppError("usage", "session register requires exactly one of --payload-file, --payload-base64, or --payload-json", 2);
228
204
  if (input.payloadFile) {
229
- return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
205
+ return normalizeFlowSessionPayload(parseJsonObject(prepareTextFile(input.payloadFile, "session payload").text, "session payload"));
230
206
  }
231
207
  if (input.payloadBase64) {
232
208
  if (/\$[A-Za-z_{(]/.test(input.payloadBase64)) {
233
- throw new AppError("validation", "session register --payload-base64 must be a literal base64 value because Codex PreToolUse hooks run before shell variable expansion; use --payload-file from prompts", 2);
209
+ throw new AppError("validation", "session register received an unexpanded shell variable as --payload-base64; expand it before invoking the CLI or use --payload-file", 2);
234
210
  }
235
211
  return normalizeFlowSessionPayload(parseJsonObject(Buffer.from(input.payloadBase64, "base64").toString("utf8"), "session payload"));
236
212
  }
@@ -286,7 +262,12 @@ function normalizeFlowSessionPayload(payload) {
286
262
  }
287
263
  function upsertFlowSession(context, project, payload, forcedSessionId) {
288
264
  const now = context.now();
289
- const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
265
+ // `sessions.session_id` is a private storage key. The native ID is kept
266
+ // unchanged in provider_session_id and is always paired with its harness in
267
+ // public contracts. A caller may pass a forced key only after a trusted
268
+ // hook has already derived it from that pair.
269
+ const nativeSessionId = payload.provider_session_id ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
270
+ const sessionId = forcedSessionId ?? storageSessionId({ harness_id: payload.harness ?? "codex-desktop", session_id: nativeSessionId });
290
271
  const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
291
272
  context.db.run(`INSERT INTO sessions
292
273
  (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,
@@ -334,7 +315,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
334
315
  sessionId,
335
316
  project.id,
336
317
  payload.harness ?? "codex-desktop",
337
- payload.provider_session_id ?? null,
318
+ nativeSessionId,
338
319
  payload.agent_id ?? null,
339
320
  payload.provider ?? null,
340
321
  payload.model ?? null,
@@ -1,19 +1,24 @@
1
1
  import { AppError } from "../shared/errors.js";
2
2
  import { resolveProjectRoot } from "../storage/paths.js";
3
3
  import { requireProjectByRoot } from "./projects.js";
4
- import { blockFlowRunStage, unblockFlowRunStage } from "./runs.js";
4
+ import { blockFlowRunStage, unblockFlowRunStage, prepareFlowRunStageBlock, prepareFlowRunStageUnblock } from "./runs.js";
5
5
  import { flowCommand } from "./stage-pause.js";
6
6
  import { refreshRunWorkProjection } from "./work-registry.js";
7
- export function blockStageForRuntime(context, input) {
7
+ export function prepareStageRuntimeBlock(context, input) {
8
+ if (typeof input.code !== "string" || !input.code.trim() || typeof input.summary !== "string" || !input.summary.trim())
9
+ throw new AppError("validation", "stage block requires a code and summary", 2);
8
10
  const projectRoot = resolveProjectRoot(input.projectRoot);
9
11
  const project = requireProjectByRoot(context, projectRoot);
10
12
  const run = requireRun(context, project.id, input.runId);
11
13
  const work = requireWork(context, project.id, run.id, input.workId);
12
14
  if (work.status !== "running")
13
15
  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 });
16
+ const flow = prepareFlowRunStageBlock(context, { ...input, projectRoot });
17
+ return { projectRoot, project, work, flow };
18
+ }
19
+ export function blockStageForRuntime(context, input) {
20
+ const { projectRoot, project, work, flow } = prepareStageRuntimeBlock(context, input);
21
+ const state = blockFlowRunStage(context, { ...input, projectRoot }, flow);
17
22
  const now = context.now();
18
23
  context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
19
24
  refreshRunWorkProjection(context, project.id, work.run_id);
@@ -30,14 +35,19 @@ export function blockStageForRuntime(context, input) {
30
35
  state
31
36
  };
32
37
  }
33
- export function unblockStageAfterRuntimeRepair(context, input) {
38
+ export function prepareStageRuntimeUnblock(context, input) {
34
39
  const projectRoot = resolveProjectRoot(input.projectRoot);
35
40
  const project = requireProjectByRoot(context, projectRoot);
36
41
  const run = requireRun(context, project.id, input.runId);
37
42
  const work = requireWork(context, project.id, run.id, input.workId);
38
43
  if (work.status !== "paused")
39
44
  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 });
45
+ const flow = prepareFlowRunStageUnblock(context, { ...input, projectRoot, runId: work.run_id });
46
+ return { projectRoot, project, work, flow };
47
+ }
48
+ export function unblockStageAfterRuntimeRepair(context, input) {
49
+ const { projectRoot, project, work, flow } = prepareStageRuntimeUnblock(context, input);
50
+ const state = unblockFlowRunStage(context, { projectRoot, runId: work.run_id, stage: input.stage, workId: work.work_id }, flow);
41
51
  const now = context.now();
42
52
  context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
43
53
  refreshRunWorkProjection(context, project.id, work.run_id);
@@ -2,33 +2,42 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { parseJsonObject } from "../shared/json.js";
5
+ import { prepareFileBytes, prepareJsonText, prepareOutputFile } from "../cli/input-preparation.js";
6
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);
7
+ const { file: sourcePath, bytes } = prepareFileBytes(input.file, "Stage context");
11
8
  const materializedSha256 = sha256(bytes);
12
9
  if (materializedSha256 !== input.expectedSha256)
13
10
  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");
11
+ const value = prepareJsonText(bytes.toString("utf8"), { file: sourcePath, label: "Stage context" }).value;
15
12
  validateContext(value, input.stage);
16
- return { value, sourcePath, materializedSha256 };
13
+ return { value, sourcePath, materializedSha256, bytes };
17
14
  }
18
- /** Installs exactly one immutable external context for a stage attempt. */
19
- export function installExternalStageContext(input) {
20
- fs.mkdirSync(input.stageRoot, { recursive: true });
15
+ export function prepareExternalStageContext(input) {
16
+ const destination = externalStageContextDestination(input);
17
+ if (!fs.existsSync(destination))
18
+ prepareOutputFile(destination);
19
+ return destination;
20
+ }
21
+ function externalStageContextDestination(input) {
21
22
  const destination = path.join(input.stageRoot, "stage-context.json");
22
23
  if (fs.existsSync(destination)) {
23
24
  const existing = sha256(fs.readFileSync(destination));
24
25
  if (existing !== input.loaded.materializedSha256)
25
26
  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
  }
27
- else {
28
+ return destination;
29
+ }
30
+ /** Installs exactly one immutable external context for a stage attempt. */
31
+ export function installExternalStageContext(input) {
32
+ // Publication may follow earlier effects. Recheck immutable identity here,
33
+ // but never reclassify a late output failure as a no-effect input rejection.
34
+ const destination = externalStageContextDestination(input);
35
+ fs.mkdirSync(input.stageRoot, { recursive: true });
36
+ if (!fs.existsSync(destination)) {
28
37
  // The receipt is the hash of the runner-materialized bytes, not of a
29
38
  // convenient reserialization. Preserve those exact bytes as evidence.
30
39
  const temporary = `${destination}.${process.pid}.tmp`;
31
- fs.copyFileSync(input.loaded.sourcePath, temporary);
40
+ fs.writeFileSync(temporary, input.loaded.bytes);
32
41
  fs.renameSync(temporary, destination);
33
42
  }
34
43
  return {
@@ -76,15 +85,32 @@ export function renderExternalStageContext(input) {
76
85
  ].join("\n");
77
86
  }
78
87
  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);
88
+ const object = (item) => Boolean(item && typeof item === "object" && !Array.isArray(item));
89
+ const text = (item) => typeof item === "string" && Boolean(item.trim());
90
+ if (!object(value))
91
+ throw new AppError("validation", "Stage context must be an object", 2);
92
+ if (value.schema_id !== "dd-flow/stage-context@1" && value.schema_id !== "dd-eval/stage-context@1")
93
+ throw new AppError("validation", "Stage context must use dd-flow/stage-context@1", 2);
81
94
  if (value.stage !== expectedStage)
82
95
  throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
83
- if (!value.objective?.trim())
96
+ if (!text(value.objective))
84
97
  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);
98
+ for (const key of ["task_input", "sources", "accepted_decisions", "dynamic_roles"]) {
99
+ if (value[key] === undefined)
100
+ continue;
101
+ if (!Array.isArray(value[key]))
102
+ throw new AppError("validation", `Stage context ${key} must be an array`, 2);
103
+ for (const item of value[key]) {
104
+ const valid = key === "accepted_decisions" ? text(item) || object(item) && (text(item.id) || text(item.statement)) && [item.id, item.statement].every(field => field === undefined || text(field))
105
+ : object(item) && text(item.role) && (key === "dynamic_roles" ? item.reason === undefined || text(item.reason)
106
+ : text(item.path) && (key === "sources" ? text(item.reason) && (item.required === undefined || typeof item.required === "boolean") && (item.root === undefined || typeof item.root === "string" && ["project", "workspace", "run"].includes(item.root)) : item.sha256 === undefined || text(item.sha256)));
107
+ if (!valid)
108
+ throw new AppError("validation", `Invalid stage context ${key} item`, 2, { parameter: key });
109
+ }
110
+ }
111
+ for (const key of ["semantic_package_sha256", "context_slice_sha256"]) {
112
+ if (value[key] !== undefined && !text(value[key]))
113
+ throw new AppError("validation", `Stage context ${key} must be a nonempty string`, 2);
88
114
  }
89
115
  }
90
116
  function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }