@deksden-com/dd-flow-cli 0.4.2 → 0.6.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 (53) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/README.md +2 -2
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/help.js +16 -4
  5. package/dist/cli/run-cli.js +42 -17
  6. package/dist/domain/flow-contract.js +82 -3
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/domain/validation.js +56 -28
  9. package/dist/protocol/local-files.js +1 -16
  10. package/dist/schemas/code-stage-report.schema.json +2 -2
  11. package/dist/schemas/flow-contract.schema.json +152 -94
  12. package/dist/schemas/flow-run.schema.json +129 -23
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  15. package/dist/schemas/merge-stage-report.schema.json +2 -2
  16. package/dist/schemas/plan-stage-report.schema.json +38 -335
  17. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  18. package/dist/schemas/protocol-plan.schema.json +197 -0
  19. package/dist/schemas/release-impact.schema.json +9 -5
  20. package/dist/schemas/session-usage.schema.json +16 -0
  21. package/dist/schemas/stage-finish-input.schema.json +20 -0
  22. package/dist/schemas/stage-prompt.schema.json +35 -0
  23. package/dist/schemas/stage-report.schema.json +20 -0
  24. package/dist/schemas/stage-start-response.schema.json +30 -0
  25. package/dist/schemas/timeline-event.schema.json +29 -0
  26. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  27. package/dist/services/branch-context.js +9 -4
  28. package/dist/services/cleanup.js +77 -0
  29. package/dist/services/dashboard.js +51 -26
  30. package/dist/services/engines.js +84 -18
  31. package/dist/services/hooks.js +101 -250
  32. package/dist/services/memory-permissions.js +77 -69
  33. package/dist/services/migrations.js +1 -1
  34. package/dist/services/plan-runtime.js +124 -0
  35. package/dist/services/plans.js +22 -84
  36. package/dist/services/projects.js +2 -1
  37. package/dist/services/prompts.js +26 -21
  38. package/dist/services/protocols.js +29 -25
  39. package/dist/services/run-projection.js +93 -13
  40. package/dist/services/runs.js +128 -61
  41. package/dist/services/schema-validation.js +168 -7
  42. package/dist/services/sessions.js +97 -73
  43. package/dist/services/stage-lifecycle.js +737 -0
  44. package/dist/services/tooling.js +285 -0
  45. package/dist/services/usage.js +183 -30
  46. package/dist/services/version-status.js +1 -1
  47. package/dist/services/worktrees.js +88 -39
  48. package/dist/storage/database.js +72 -30
  49. package/dist/storage/paths.js +0 -9
  50. package/package.json +14 -13
  51. package/tools/worktrunk-manifest.json +34 -0
  52. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  53. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -8,7 +8,60 @@ import { appendAudit } from "./audit.js";
8
8
  import { cancelLaneWaitersForWorker } from "./lanes.js";
9
9
  import { checkpointSessionUsage } from "./usage.js";
10
10
  import { refreshRunSessionProjection } from "./run-projection.js";
11
+ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
11
12
  import { appendFlowRunTimelineEvent } from "./runs.js";
13
+ export function recordFlowSessionObservation(context, input) {
14
+ const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
15
+ if (duplicate)
16
+ return { changed: false, segment_id: duplicate.id };
17
+ const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
18
+ WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
19
+ const now = context.now();
20
+ const runId = input.runId ?? null;
21
+ const protocolId = input.protocolId ?? null;
22
+ if (open && open.run_id === runId && open.protocol_id === protocolId) {
23
+ return { changed: false, segment_id: open.id };
24
+ }
25
+ if (open)
26
+ context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE id = ? AND ended_at IS NULL", [now, open.id]);
27
+ const result = context.db.run(`INSERT INTO flow_session_segments
28
+ (project_id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name, event_key)
29
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
30
+ return { changed: true, segment_id: Number(result.lastInsertRowid) };
31
+ }
32
+ export function bindObservedFlowSession(context, project, payload, sessionId) {
33
+ if (resolveProjectRoot(payload.project_root) !== project.root) {
34
+ throw new AppError("project_mismatch", "Observed session payload project_root does not match hook cwd project", 1, {
35
+ expected: project.root,
36
+ actual: payload.project_root
37
+ });
38
+ }
39
+ const session = upsertFlowSession(context, project, payload, sessionId);
40
+ if (session.session_kind !== "orchestrator") {
41
+ const now = context.now();
42
+ for (const unit of payload.coverage_units ?? []) {
43
+ if (!unit.job_id)
44
+ continue;
45
+ context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
46
+ 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]);
47
+ }
48
+ }
49
+ if (session.run_id)
50
+ refreshRunSessionProjection(context, project.id, session.run_id);
51
+ return session;
52
+ }
53
+ export function registerFlowJob(context, input) {
54
+ const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
55
+ const now = context.now();
56
+ context.db.run(`INSERT INTO flow_jobs (job_id, project_id, run_id, protocol_id, plan_item_id, group_id, status, worker_session_id, attempts, last_error, registered_at, updated_at, started_at, finished_at)
57
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, 0, NULL, ?, ?, NULL, NULL)
58
+ ON CONFLICT(project_id, run_id, plan_item_id) DO UPDATE SET protocol_id = excluded.protocol_id, group_id = excluded.group_id, updated_at = excluded.updated_at`, [jobId, input.projectId, input.runId, input.protocolId ?? null, input.planItemId, input.groupId ?? null, now, now]);
59
+ refreshRunSessionProjection(context, input.projectId, input.runId);
60
+ return context.db.get("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? AND plan_item_id = ?", [input.projectId, input.runId, input.planItemId]) ?? { job_id: jobId };
61
+ }
62
+ export function flowJobsForRun(context, projectId, runId) {
63
+ return context.db.all("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY plan_item_id", [projectId, runId]);
64
+ }
12
65
  export function registerFlowSession(context, input) {
13
66
  const payload = decodeFlowSessionPayload(input);
14
67
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
@@ -36,14 +89,19 @@ export function registerFlowSession(context, input) {
36
89
  }
37
90
  export function getFlowSessionStatus(context, input) {
38
91
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
92
+ const sessions = flowSessionsForProject(context, project.id, {
93
+ sessionId: input.sessionId,
94
+ workerId: input.workerId
95
+ });
39
96
  return {
40
97
  ok: true,
41
- sessions: flowSessionsForProject(context, project.id, {
42
- sessionId: input.sessionId,
43
- workerId: input.workerId
44
- })
98
+ sessions,
99
+ coverage: reconcileSessionCoverage(sessions)
45
100
  };
46
101
  }
102
+ export function reconcileSessionCoverage(sessions) {
103
+ return reconcileSessionCoverageRows(sessions);
104
+ }
47
105
  export function stopFlowSession(context, input) {
48
106
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
49
107
  const session = requireFlowSession(context, project.id, input.sessionId);
@@ -126,67 +184,11 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
126
184
  refreshRunSessionProjection(context, projectId, session.run_id);
127
185
  return nextCount;
128
186
  }
129
- export function recordPendingFlowSessionBinding(context, project, input) {
130
- const payload = flowSessionPayloadFromRegisterCommand(input.command);
131
- if (!payload) {
132
- return { recorded: false };
133
- }
134
- const payloadRoot = resolveProjectRoot(payload.project_root);
135
- if (payloadRoot !== project.root) {
136
- throw new AppError("project_mismatch", "Session register payload project_root does not match hook project", 1, {
137
- expected: project.root,
138
- actual: payloadRoot
139
- });
140
- }
141
- const now = context.now();
142
- context.db.run(`INSERT INTO pending_flow_session_bindings
143
- (session_id, project_id, payload_json, cwd, transcript_path, turn_id, status, created_at, updated_at)
144
- VALUES (?, ?, ?, ?, ?, ?, 'observed', ?, ?)
145
- ON CONFLICT(session_id, project_id) DO UPDATE SET
146
- payload_json = excluded.payload_json,
147
- cwd = COALESCE(excluded.cwd, cwd),
148
- transcript_path = COALESCE(excluded.transcript_path, transcript_path),
149
- turn_id = excluded.turn_id,
150
- status = 'observed',
151
- updated_at = excluded.updated_at`, [
152
- input.sessionId,
153
- project.id,
154
- JSON.stringify(sanitizeSessionPayload(payload)),
155
- input.cwd ?? payload.cwd ?? null,
156
- input.transcriptPath ?? payload.transcript_path ?? null,
157
- input.turnId ?? null,
158
- now,
159
- now
160
- ]);
161
- return { recorded: true, payload };
162
- }
163
- export function confirmPendingFlowSessionBinding(context, project, input) {
164
- const pending = context.db.get("SELECT * FROM pending_flow_session_bindings WHERE project_id = ? AND session_id = ? AND status = 'observed'", [project.id, input.sessionId]);
165
- if (!pending) {
166
- return undefined;
167
- }
168
- const payload = normalizeFlowSessionPayload(JSON.parse(pending.payload_json));
169
- const session = upsertFlowSession(context, project, {
170
- ...payload,
171
- cwd: pending.cwd ?? payload.cwd ?? null,
172
- transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
173
- }, input.sessionId);
174
- if (session.run_id)
175
- refreshRunSessionProjection(context, project.id, session.run_id);
176
- context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
177
- WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
178
- appendAudit(context, {
179
- projectId: project.id,
180
- eventType: "flow_session.bound",
181
- payload: { project_id: project.id, session_id: input.sessionId, flow_kind: session.flow_kind },
182
- ...(session.protocol_id ? { protocolId: session.protocol_id } : {})
183
- });
184
- return session;
185
- }
186
187
  export function flowSessionPayloadFromRegisterCommand(command) {
187
- if (!/\bdd-flow\s+session\s+register\b/.test(command)) {
188
+ if (/\bdd-flow\s+stage\s+start\b/.test(command))
189
+ return flowSessionPayloadFromStageStartCommand(command);
190
+ if (!/\bdd-flow\s+session\s+register\b/.test(command))
188
191
  return undefined;
189
- }
190
192
  const payloadBase64 = optionFromCommand(command, "payload-base64");
191
193
  const payloadJson = optionFromCommand(command, "payload-json");
192
194
  const payloadFile = optionFromCommand(command, "payload-file");
@@ -195,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
195
197
  }
196
198
  return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
197
199
  }
200
+ function flowSessionPayloadFromStageStartCommand(command) {
201
+ const projectRoot = optionFromCommand(command, "project-root");
202
+ const stage = optionFromCommand(command, "stage");
203
+ const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
204
+ if (!projectRoot || !stage || !run || run.startsWith("--"))
205
+ return undefined;
206
+ return {
207
+ project_root: projectRoot,
208
+ flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
209
+ run_id: run,
210
+ protocol_id: null,
211
+ worker_id: null,
212
+ workspace_path: null,
213
+ continuation_policy: "go_router",
214
+ session_kind: "orchestrator",
215
+ current_stage: stage,
216
+ coverage_units: []
217
+ };
218
+ }
198
219
  function decodeFlowSessionPayload(input) {
199
220
  if (input.payloadFile) {
200
221
  return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
@@ -320,6 +341,10 @@ function markSessionStopped(context, project, session, reason) {
320
341
  context.db.run(`UPDATE flow_sessions
321
342
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
322
343
  WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
344
+ if (session.run_id && session.session_kind !== "orchestrator") {
345
+ context.db.run(`UPDATE flow_jobs SET status = 'pending', worker_session_id = NULL, last_error = ?, updated_at = ?
346
+ WHERE project_id = ? AND run_id = ? AND worker_session_id = ? AND status = 'running'`, [reason, now, project.id, session.run_id, session.session_id]);
347
+ }
323
348
  if (session.run_id)
324
349
  appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_stopped", session_id: session.session_id });
325
350
  if (session.run_id)
@@ -374,13 +399,6 @@ function optionFromCommand(command, key) {
374
399
  const match = command.match(pattern);
375
400
  return match?.[1] ?? match?.[2] ?? match?.[3];
376
401
  }
377
- function sanitizeSessionPayload(payload) {
378
- return {
379
- ...payload,
380
- next_action: redactString(payload.next_action ?? null),
381
- metadata: sanitizeValue(payload.metadata ?? {})
382
- };
383
- }
384
402
  function sanitizeValue(value) {
385
403
  if (Array.isArray(value)) {
386
404
  return value.map(sanitizeValue);
@@ -427,9 +445,8 @@ function sessionKind(payload) {
427
445
  function normalizeCoverageUnits(value) {
428
446
  if (value === undefined || value === null)
429
447
  return [];
430
- if (!Array.isArray(value)) {
448
+ if (!Array.isArray(value))
431
449
  throw new AppError("validation", "coverage_units must be an array", 2);
432
- }
433
450
  return value.map((item, index) => {
434
451
  if (!item || typeof item !== "object" || Array.isArray(item)) {
435
452
  throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
@@ -438,7 +455,14 @@ function normalizeCoverageUnits(value) {
438
455
  if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
439
456
  throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
440
457
  }
441
- const optional = (key) => object[key] === undefined || object[key] === null ? null : typeof object[key] === "string" ? object[key] : (() => { throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2); })();
458
+ const optional = (key) => {
459
+ const field = object[key];
460
+ if (field === undefined || field === null)
461
+ return null;
462
+ if (typeof field === "string")
463
+ return field;
464
+ throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
465
+ };
442
466
  return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
443
467
  });
444
468
  }