@deksden-com/dd-flow-cli 0.9.0-beta.142 → 0.9.0-beta.144

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.
@@ -178,8 +178,26 @@ export async function startDaemon(options) {
178
178
  export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); };
179
179
 
180
180
  export class Runtime {
181
- constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map((state.sessions ?? []).map(x => [x.provider_session_id, x])); this.active = null; this.persisting = Promise.resolve(); }
181
+ constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map();
182
+ for (const record of state.sessions ?? []) {
183
+ if (typeof record?.provider_session_id !== "string" || !record.provider_session_id || this.sessions.has(record.provider_session_id))
184
+ throw new DaemonError("session_identity_mismatch", "OpenCode retained Session identity is invalid or duplicated");
185
+ this.sessions.set(record.provider_session_id, record);
186
+ }
187
+ this.active = null; this.persisting = Promise.resolve(); }
182
188
  persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.catch(() => {}).then(() => writeJson(this.paths.state, snapshot)); return this.persisting; }
189
+ retainSession(info, root) {
190
+ const id = info.id, parent = info.fork ? null : info.parentID ?? null, retained = this.sessions.get(id);
191
+ if (typeof id !== "string" || !id || typeof info.directory !== "string" || !path.isAbsolute(info.directory)
192
+ || retained && ((retained.parent_provider_session_id ?? null) !== parent
193
+ || retained.adapter_session_id && retained.adapter_session_id !== id
194
+ || retained.root_provider_session_id && retained.root_provider_session_id !== root))
195
+ throw new DaemonError("session_identity_mismatch", "OpenCode retained Session identity changed");
196
+ // Cwd is observed routing authority, not ownership identity: a moved owned
197
+ // Session must remain observable/cancellable. requireSettled fences dispatch.
198
+ const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: root, cwd: info.directory };
199
+ this.sessions.set(id, record); return record;
200
+ }
183
201
  async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
184
202
  async childTree(id, seen = new Set()) {
185
203
  if (seen.has(id)) throw new DaemonError("session_identity_mismatch", "OpenCode native topology contains a cycle");
@@ -195,13 +213,11 @@ export class Runtime {
195
213
  const parent = info.fork ? null : info.parentID ?? null, existing = this.sessions.get(id);
196
214
  if (info.id !== id || !existing && (!parent || !this.sessions.has(parent) || info.fork)
197
215
  || existing && (existing.parent_provider_session_id ?? null) !== parent) throw new DaemonError("foreign_session", "OpenCode Session is not in the retained owned tree");
198
- const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent,
199
- root_provider_session_id: parent ? this.sessions.get(parent)?.root_provider_session_id ?? parent : existing?.root_provider_session_id ?? id, cwd: info.directory };
200
- this.sessions.set(id, record);
216
+ const record = this.retainSession(info, parent ? this.sessions.get(parent)?.root_provider_session_id ?? parent : existing?.root_provider_session_id ?? id);
201
217
  for (const child of children) {
202
218
  const retained = this.sessions.get(child.id);
203
219
  if (child.fork || retained && retained.parent_provider_session_id !== child.parentID) throw new DaemonError("session_identity_mismatch", "OpenCode child ancestry changed");
204
- this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: child.parentID, root_provider_session_id: record.root_provider_session_id, cwd: child.directory });
220
+ this.retainSession(child, record.root_provider_session_id);
205
221
  }
206
222
  const pending = new Map();
207
223
  for (const session of [id, ...children.map(child => child.id)]) {
@@ -287,6 +303,7 @@ export class Runtime {
287
303
  if (seen.has(id)) return [];
288
304
  const native = await this.client.inspect(id), retained = this.sessions.get(id);
289
305
  if (native.id !== id || (retained.parent_provider_session_id ?? null) !== (native.fork ? null : native.parentID ?? null)) throw new DaemonError("session_identity_mismatch", "OpenCode cancellation Session ancestry changed");
306
+ this.retainSession(native, retained.root_provider_session_id ?? id);
290
307
  seen.add(id);
291
308
  const errors = []; let children = [];
292
309
  try { children = await this.client.children(id); } catch (error) { errors.push({ session_id: id, phase: "children", ...errorPayload(error) }); }
@@ -294,7 +311,7 @@ export class Runtime {
294
311
  if (child.parentID !== id || child.fork || this.sessions.has(child.id) && this.sessions.get(child.id).parent_provider_session_id !== id) {
295
312
  errors.push({ session_id: child.id, phase: "identity", code: "session_identity_mismatch" }); continue;
296
313
  }
297
- this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: id, root_provider_session_id: this.sessions.get(id)?.root_provider_session_id ?? id, cwd: child.directory });
314
+ this.retainSession(child, this.sessions.get(id)?.root_provider_session_id ?? id);
298
315
  try { errors.push(...await this.cancelTree(child.id, seen)); }
299
316
  catch (error) { errors.push({ session_id: child.id, phase: "cancel_child", ...errorPayload(error) }); }
300
317
  }
@@ -5,6 +5,15 @@
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "summary", "findings"],
7
7
  "properties": {
8
+ "accepted_intent_acknowledgement": {
9
+ "type": "object", "additionalProperties": false,
10
+ "required": ["reviewed_fingerprint", "current_fingerprint", "clarification_refs"],
11
+ "properties": {
12
+ "reviewed_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
13
+ "current_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
14
+ "clarification_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^HITL-[A-Za-z0-9._-]+$"}}
15
+ }
16
+ },
8
17
  "schema_id": {"const": "dd-flow/code-review-decision@3"},
9
18
  "summary": {"type": "string", "minLength": 1},
10
19
  "findings": {
@@ -39,6 +39,7 @@
39
39
  }
40
40
  },
41
41
  "verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
42
+ "accepted_intent_contract": {"const": "dd-flow/accepted-intent@1"},
42
43
  "final_check_coverage_contract": {"const": "dd-flow/final-check-coverage@1"},
43
44
  "status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
44
45
  "verdict": {"type": ["string", "null"]},
@@ -5,6 +5,15 @@
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "outcome", "summary", "finding_decisions", "correction"],
7
7
  "properties": {
8
+ "accepted_intent_acknowledgement": {
9
+ "type": "object", "additionalProperties": false,
10
+ "required": ["reviewed_fingerprint", "current_fingerprint", "clarification_refs"],
11
+ "properties": {
12
+ "reviewed_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
13
+ "current_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
14
+ "clarification_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^HITL-[A-Za-z0-9._-]+$"}}
15
+ }
16
+ },
8
17
  "schema_id": {"const": "dd-flow/plan-review-decision@4"},
9
18
  "outcome": {"enum": ["accepted", "failed", "cancelled"]},
10
19
  "summary": {"type": "string", "minLength": 1},
@@ -0,0 +1,127 @@
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 { assertPathWithin } from "../storage/paths.js";
6
+ import { acceptStagePacket, prepareStagePacket, resolveStagePublicationPath } from "./stage-report-renderer.js";
7
+ export const acceptedIntentContract = "dd-flow/accepted-intent@1";
8
+ const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
9
+ export function acceptedIntentSourceRole(role) { return role === "accepted_intake" || role.startsWith("accepted_hitl:"); }
10
+ export function acceptedIntentReviewFingerprint(sources) {
11
+ const inputs = sources?.filter(source => acceptedIntentSourceRole(source.role)).map(({ role, sha256 }) => ({ role, sha256 })).sort((a, b) => a.role < b.role ? -1 : a.role > b.role ? 1 : 0);
12
+ return inputs?.length ? digest(Buffer.from(JSON.stringify(inputs))) : null;
13
+ }
14
+ /** An independent review covers its retained inputs only. An own-stage user
15
+ * clarification may use the existing coordinator correction, explicitly named
16
+ * as such; it never retroactively expands the independent review's coverage. */
17
+ export function validateAcceptedIntentReview(context, scope, reviewedSources, contextFingerprint, acknowledgement) {
18
+ const reviewed = acceptedIntentReviewFingerprint(reviewedSources);
19
+ if (!reviewed)
20
+ return null; // Historical source contract has no new authority.
21
+ if (contextFingerprint !== reviewed)
22
+ throw new AppError("accepted_intent_review_stale", "Review context does not bind the retained accepted inputs", 1, { stage: scope.stage });
23
+ const currentSources = prepareAcceptedIntentSources(context, { ...scope, stageDirectory: "intake", marked: true }).sources;
24
+ const current = acceptedIntentReviewFingerprint(currentSources);
25
+ const reviewedEvent = Math.max(0, ...(reviewedSources ?? []).filter(source => source.role.startsWith("accepted_hitl:")).map(source => Number(source.role.split(":")[1])));
26
+ const rows = context.db.all("SELECT id, payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND id > ? ORDER BY id LIMIT 256", [scope.projectId, scope.runId, reviewedEvent]);
27
+ const additions = rows.map(row => ({ id: row.id, ...JSON.parse(row.payload_json) }));
28
+ const refs = additions.map(answer => answer.pause_id);
29
+ const evidence = { reviewed_fingerprint: reviewed, current_fingerprint: current, independent_coverage: "reviewed_sources_only", coordinator_clarification_refs: refs,
30
+ source_event_id: Math.max(reviewedEvent, ...additions.map(answer => answer.id)) };
31
+ if (current === reviewed) {
32
+ if (acknowledgement && (acknowledgement.reviewed_fingerprint !== reviewed || acknowledgement.current_fingerprint !== current || acknowledgement.clarification_refs.length))
33
+ throw new AppError("accepted_intent_review_ack_required", "Accepted-input acknowledgement differs from the retained review", 2, { ...evidence, phase: "prepare", effect: "no_effect", recoverable: true });
34
+ return evidence;
35
+ }
36
+ if (!additions.length || additions.some(answer => answer.stage !== scope.stage))
37
+ throw new AppError("accepted_intent_review_stale", "Independent review cannot cover newly accepted upstream decisions; use a supported upstream revision", 1, { ...evidence, stage: scope.stage });
38
+ if (!acknowledgement || acknowledgement.reviewed_fingerprint !== reviewed || acknowledgement.current_fingerprint !== current || JSON.stringify(acknowledgement.clarification_refs) !== JSON.stringify(refs)) {
39
+ if (scope.decisionFrozen)
40
+ throw new AppError("accepted_intent_review_stale", "New answer cannot alter an already frozen review decision; use a supported upstream revision", 1, evidence);
41
+ throw new AppError("accepted_intent_review_ack_required", "Own-stage clarification requires an explicit coordinator acknowledgement, not reuse of old independent coverage", 2, { ...evidence, phase: "prepare", effect: "no_effect", recoverable: true, accepted_intent_acknowledgement: { reviewed_fingerprint: reviewed, current_fingerprint: current, clarification_refs: refs }, instruction: "Apply/classify this clarification through the existing coordinator correction/repair route; include the exact acknowledgement in the decision. Independent review covers only the earlier retained inputs." });
42
+ }
43
+ return evidence;
44
+ }
45
+ export function assertAcceptedIntentReviewCurrent(context, scope, evidence) {
46
+ if (!evidence)
47
+ return;
48
+ const row = context.db.get("SELECT MAX(id) AS id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ?", [scope.projectId, scope.runId]);
49
+ if ((row?.id ?? 0) !== evidence.source_event_id)
50
+ throw new AppError("accepted_intent_review_stale", "Accepted answers changed during review settlement", 1);
51
+ }
52
+ export function assertDeliveredHitlReferences(context, scope, references) {
53
+ if (!references.length)
54
+ return;
55
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [scope.projectId, scope.runId]);
56
+ if (JSON.parse(run.index_json).accepted_intent_contract !== acceptedIntentContract)
57
+ return;
58
+ for (const reference of new Set(references)) {
59
+ const answered = context.db.get("SELECT id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.pause_id') = ? LIMIT 1", [scope.projectId, scope.runId, reference]);
60
+ if (!answered)
61
+ throw new AppError("validation", "Obligation amendment must reference an actually delivered answer in this RUN", 2, { hitl_ref: reference });
62
+ }
63
+ }
64
+ /** Retain only inputs actually delivered to this RUN, never agent recommendations
65
+ * or hidden evaluation expectations. Reuse the existing publication custody. */
66
+ export function retainAcceptedIntake(context, scope) {
67
+ if (!scope.bytes.length || scope.bytes.length > 1024 * 1024)
68
+ throw new AppError("accepted_intent_source_invalid", "Accepted intake exceeds the bounded source contract", 2);
69
+ const prepared = prepareStagePacket(scope.file, scope.bytes);
70
+ context.db.writeTransaction(() => {
71
+ acceptStagePacket(context, { ...scope, stage: "specify", prepared });
72
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [scope.projectId, scope.runId]);
73
+ const index = JSON.parse(row.index_json);
74
+ index.accepted_intent_contract = acceptedIntentContract;
75
+ context.db.run("UPDATE runs SET index_json = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), scope.projectId, scope.runId]);
76
+ });
77
+ }
78
+ export function prepareAcceptedIntentSources(context, input) {
79
+ if (!input.marked)
80
+ return { sources: [], packets: [] };
81
+ const fail = (reason) => { throw new AppError("accepted_intent_source_gap", "Original accepted input is unavailable; SPECIFY is not a substitute", 1, { reason, run_id: input.runId }); };
82
+ const rows = context.db.all("SELECT id, payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id LIMIT 256", [input.projectId, input.runId]);
83
+ const entries = [{ role: "accepted_intake", file: path.join(input.home, "intake/discussion.md") }, ...rows.flatMap(row => {
84
+ const value = JSON.parse(row.payload_json);
85
+ const identity = `accepted_hitl:${String(row.id).padStart(12, "0")}:${value.pause_id}`;
86
+ return [{ role: `${identity}:0-question`, file: path.join(path.dirname(value.answer_path), "question.md") }, { role: `${identity}:1-answer`, file: value.answer_path }];
87
+ })];
88
+ if (entries.length > 256)
89
+ fail("source_count_exceeds_bound");
90
+ let total = 0;
91
+ const retained = entries.map(entry => {
92
+ const file = resolveStagePublicationPath(context, input.projectId, input.runId, entry.file);
93
+ const relative = path.relative(input.home, file);
94
+ if (path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`) || relative === "..")
95
+ fail("source_outside_run");
96
+ const suffix = `/${relative.split(path.sep).join("/")}`;
97
+ const packetRows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_packet' AND json_extract(payload_json, '$.run_id') = ? AND substr(json_extract(payload_json, '$.file'), -length(?)) = ? LIMIT 33", [input.projectId, input.runId, suffix, suffix]);
98
+ if (packetRows.length > 32 || packetRows.some(row => Buffer.byteLength(row.payload_json) > 2 * 1024 * 1024))
99
+ fail(`unbounded_custody:${relative}`);
100
+ const candidates = packetRows.map(row => ({ value: JSON.parse(row.payload_json) })).filter(packet => resolveStagePublicationPath(context, input.projectId, input.runId, packet.value.file) === file);
101
+ if (!candidates.length)
102
+ fail(`missing_custody:${relative}`);
103
+ const bytes = Buffer.from(candidates[0].value.base64, "base64"), sha256 = digest(bytes);
104
+ total += bytes.length;
105
+ if (!bytes.length || bytes.length > 1024 * 1024 || total > 16 * 1024 * 1024 || candidates.some(packet => packet.value.sha256 !== sha256 || !Buffer.from(packet.value.base64, "base64").equals(bytes) || Buffer.from(packet.value.base64, "base64").toString("base64") !== packet.value.base64))
106
+ fail(`invalid_custody:${relative}`);
107
+ // An unavailable or replaced published source is an explicit evidence gap,
108
+ // rather than silent use of a later summary or arbitrary local file.
109
+ if (!fs.existsSync(file) || !fs.lstatSync(file).isFile() || fs.statSync(file).size !== bytes.length)
110
+ fail(`source_unavailable:${relative}`);
111
+ const actual = path.relative(fs.realpathSync(input.home), fs.realpathSync(file));
112
+ if (path.isAbsolute(actual) || actual === ".." || actual.startsWith(`..${path.sep}`) || !fs.readFileSync(file).equals(bytes))
113
+ fail(`source_unavailable:${relative}`);
114
+ const snapshot = `${input.stageDirectory}/verification/sources/${sha256}/accepted-input.md`;
115
+ const destination = path.join(input.home, snapshot);
116
+ try {
117
+ assertPathWithin(input.home, destination, "accepted_source_snapshot");
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof AppError) || error.code !== "validation")
121
+ throw error;
122
+ throw new AppError("verification_matrix_path_invalid", "Accepted source snapshot escapes its RUN", 1, { path: snapshot });
123
+ }
124
+ return { source: { role: entry.role, root: "run", path: snapshot, sha256 }, packet: prepareStagePacket(destination, bytes) };
125
+ });
126
+ return { sources: retained.map(item => item.source), packets: [...new Map(retained.map(item => [item.packet.file, item.packet])).values()] };
127
+ }
@@ -224,6 +224,8 @@ export function readRunnerEvidence(root, contract, binding) {
224
224
  }
225
225
  export function runnerEvidencePass(evidence) { return Boolean(evidence?.completed && !evidence.observation_unknown && evidence.counts.failed === 0 && evidence.failures.length === 0); }
226
226
  export function checkRepairDisposition(receipt) {
227
+ if (receipt.fingerprint_scope != null && receipt.fingerprint_scope !== "check_inputs")
228
+ return "tooling_blocker";
227
229
  if (receipt.status === "running" || receipt.status === "aborted" || receipt.runner_evidence?.observation_unknown)
228
230
  return "settlement_required";
229
231
  if (receipt.status === "passed")