@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90

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 (143) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +304 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1409 -382
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/command-context.js +80 -0
  63. package/dist/services/config.js +11 -8
  64. package/dist/services/continuation-outcome.js +20 -0
  65. package/dist/services/controller-fanout.js +132 -0
  66. package/dist/services/dashboard.js +103 -31
  67. package/dist/services/engines.js +121 -76
  68. package/dist/services/eval-snapshots.js +750 -73
  69. package/dist/services/execution-policy.js +170 -0
  70. package/dist/services/external-work-launch.js +110 -0
  71. package/dist/services/harness-adapter.js +147 -24
  72. package/dist/services/harness-config.js +2 -0
  73. package/dist/services/hooks.js +587 -299
  74. package/dist/services/lanes.js +61 -53
  75. package/dist/services/lifecycle-command.js +118 -7
  76. package/dist/services/lifecycle-invocations.js +1055 -0
  77. package/dist/services/managed-daemon-binding.js +38 -0
  78. package/dist/services/managed-processes.js +283 -27
  79. package/dist/services/merge-queue.js +179 -104
  80. package/dist/services/merge-server.js +73 -28
  81. package/dist/services/migrations.js +14 -9
  82. package/dist/services/native-daemon-history.js +49 -0
  83. package/dist/services/native-session-control.js +39 -0
  84. package/dist/services/plan-runtime.js +19 -8
  85. package/dist/services/plans.js +28 -25
  86. package/dist/services/projects.js +18 -5
  87. package/dist/services/prompts.js +23 -16
  88. package/dist/services/protocols.js +64 -20
  89. package/dist/services/recovery-observation-budget.js +61 -0
  90. package/dist/services/recovery-snapshot-database.js +107 -0
  91. package/dist/services/repair-intents.js +40 -0
  92. package/dist/services/run-control-receipt.js +56 -0
  93. package/dist/services/run-control-worker.js +363 -0
  94. package/dist/services/run-control.js +876 -0
  95. package/dist/services/run-controller-adapter.js +222 -0
  96. package/dist/services/run-controller-capture.js +134 -0
  97. package/dist/services/run-controller-process.js +189 -0
  98. package/dist/services/run-controller-recovery.js +222 -0
  99. package/dist/services/run-controller-state.js +36 -0
  100. package/dist/services/run-controller.js +789 -0
  101. package/dist/services/run-engine-bindings.js +1 -1
  102. package/dist/services/run-fork.js +138 -0
  103. package/dist/services/run-observations.js +112 -0
  104. package/dist/services/run-recovery-runtime.js +69 -0
  105. package/dist/services/run-recovery.js +343 -0
  106. package/dist/services/runs.js +368 -74
  107. package/dist/services/runtime-budget.js +434 -0
  108. package/dist/services/runtime-command.js +43 -0
  109. package/dist/services/runtime-scope-capture.js +50 -0
  110. package/dist/services/runtime-scope-control.js +450 -0
  111. package/dist/services/runtime-scope-resume.js +565 -0
  112. package/dist/services/runtime-scope-stop.js +99 -0
  113. package/dist/services/runtime-scope-worker.js +243 -0
  114. package/dist/services/runtime-service.js +97 -0
  115. package/dist/services/schema-validation.js +10 -8
  116. package/dist/services/sessions.js +35 -69
  117. package/dist/services/stage-blocker.js +17 -7
  118. package/dist/services/stage-context.js +44 -18
  119. package/dist/services/stage-lifecycle.js +99 -102
  120. package/dist/services/stage-pause.js +84 -55
  121. package/dist/services/stage-work-graph.js +35 -0
  122. package/dist/services/usage.js +37 -93
  123. package/dist/services/vnext-code-review.js +195 -88
  124. package/dist/services/vnext-code.js +285 -252
  125. package/dist/services/vnext-execution-profile.js +5 -3
  126. package/dist/services/vnext-fanout.js +160 -17
  127. package/dist/services/vnext-merge.js +291 -104
  128. package/dist/services/vnext-plan-review.js +139 -81
  129. package/dist/services/vnext-plan.js +138 -69
  130. package/dist/services/vnext-protocolize.js +129 -56
  131. package/dist/services/vnext-specify.js +88 -65
  132. package/dist/services/work-registry.js +716 -168
  133. package/dist/services/workspace-bootstrap.js +76 -0
  134. package/dist/services/worktrees.js +85 -35
  135. package/dist/shared/entity-references.js +8 -0
  136. package/dist/shared/errors.js +12 -0
  137. package/dist/storage/database.js +459 -32
  138. package/dist/storage/paths.js +17 -4
  139. package/dist/storage/work-references.js +23 -0
  140. package/dist/storage/writer-contract.js +77 -0
  141. package/dist/storage/writer-migration.js +102 -0
  142. package/package.json +24 -13
  143. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,6 +1,4 @@
1
1
  import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
2
  import { spawnSync } from "node:child_process";
5
3
  import { AppError } from "../shared/errors.js";
6
4
  import { appendAudit } from "./audit.js";
@@ -8,6 +6,32 @@ import { requireProjectByRoot } from "./projects.js";
8
6
  import { resolveProjectRoot } from "../storage/paths.js";
9
7
  const defaultTtlSeconds = 300;
10
8
  const defaultPollIntervalSeconds = 10;
9
+ export function prepareLaneWaitTiming(input) {
10
+ return {
11
+ timeoutSeconds: normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout"),
12
+ pollIntervalSeconds: normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval")
13
+ };
14
+ }
15
+ export function prepareLaneLock(context, input, action) {
16
+ if (typeof input.workerId !== "string" || !input.workerId.trim())
17
+ throw new AppError("validation", "Lane lock requires a nonempty worker ID", 2);
18
+ if (["acquire", "release", "wait-acquire"].includes(action) && (typeof input.reason !== "string" || !input.reason.trim()))
19
+ throw new AppError("validation", "Lane lock requires a nonempty reason", 2);
20
+ if (input.leaseToken !== undefined && (typeof input.leaseToken !== "string" || !input.leaseToken.trim()))
21
+ throw new AppError("validation", "Lease token must be nonempty text", 2);
22
+ const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
23
+ const { timeoutSeconds, pollIntervalSeconds } = prepareLaneWaitTiming(input);
24
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
25
+ const lane = normalizeLane(input.lane);
26
+ if (input.workspacePath !== undefined)
27
+ requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
28
+ else if (["acquire", "wait", "wait-acquire"].includes(action))
29
+ throw new AppError("validation", "Lane lock requires a workspace path", 2);
30
+ if (action === "acquire" || action === "wait-acquire")
31
+ assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
32
+ const lock = action === "heartbeat" || action === "release" ? requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken) : undefined;
33
+ return { project, lane, ttlSeconds, timeoutSeconds, pollIntervalSeconds, lock };
34
+ }
11
35
  export function getLaneStatus(context, input) {
12
36
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
13
37
  return {
@@ -23,10 +47,16 @@ export function expireProjectLaneLocks(context, projectId) {
23
47
  export function expireProjectLaneWaiters(context, projectId) {
24
48
  expireStaleWaiters(context, projectId);
25
49
  }
26
- export function setLaneWorkspace(context, input) {
50
+ export function prepareLaneWorkspace(context, input) {
51
+ if (input.branch !== undefined && (typeof input.branch !== "string" || !input.branch.trim()))
52
+ throw new AppError("validation", "Expected branch must be nonempty text", 2);
27
53
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
28
54
  const lane = normalizeLane(input.lane);
29
55
  const workspacePath = resolveExistingPath(input.workspacePath);
56
+ return { project, lane, workspacePath };
57
+ }
58
+ export function setLaneWorkspace(context, input) {
59
+ const { project, lane, workspacePath } = prepareLaneWorkspace(context, input);
30
60
  const now = context.now();
31
61
  const existing = laneRecord(context, project.id, lane);
32
62
  context.db.run(`INSERT INTO lanes
@@ -69,11 +99,7 @@ export function checkLaneWorkspace(context, input) {
69
99
  };
70
100
  }
71
101
  export function acquireLaneLock(context, input) {
72
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
73
- const lane = normalizeLane(input.lane);
74
- requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
75
- assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
76
- const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
102
+ const { project, lane, ttlSeconds } = prepareLaneLock(context, input, "acquire");
77
103
  expireStaleLocks(context, project.id);
78
104
  const active = activeLaneLock(context, project.id, lane);
79
105
  if (active) {
@@ -116,13 +142,8 @@ export function acquireLaneLock(context, input) {
116
142
  };
117
143
  }
118
144
  export function heartbeatLaneLock(context, input) {
119
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
120
- const lane = normalizeLane(input.lane);
121
- if (input.workspacePath) {
122
- requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
123
- }
124
- const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
125
- const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
145
+ const { project, lock: preparedLock, ttlSeconds } = prepareLaneLock(context, input, "heartbeat");
146
+ const lock = preparedLock;
126
147
  const now = context.now();
127
148
  context.db.run(`UPDATE lane_locks SET heartbeat_at = ?, expires_at = ?, updated_at = ? WHERE id = ?`, [now, expiresAt(now, ttlSeconds), now, lock.id]);
128
149
  appendAudit(context, {
@@ -133,12 +154,8 @@ export function heartbeatLaneLock(context, input) {
133
154
  return { ok: true, lock: activeLaneLock(context, project.id, lock.lane) };
134
155
  }
135
156
  export function releaseLaneLock(context, input) {
136
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
137
- const lane = normalizeLane(input.lane);
138
- if (input.workspacePath) {
139
- requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
140
- }
141
- const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
157
+ const { project, lock: preparedLock } = prepareLaneLock(context, input, "release");
158
+ const lock = preparedLock;
142
159
  const now = context.now();
143
160
  context.db.run(`UPDATE lane_locks
144
161
  SET status = 'released', released_at = ?, reason = ?, updated_at = ?
@@ -152,11 +169,7 @@ export function releaseLaneLock(context, input) {
152
169
  return { ok: true, lock: laneLockById(context, lock.id) };
153
170
  }
154
171
  export async function waitForLaneLock(context, input) {
155
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
156
- const lane = normalizeLane(input.lane);
157
- requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
158
- const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
159
- const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
172
+ const { project, lane, timeoutSeconds, pollIntervalSeconds } = prepareLaneLock(context, input, "wait");
160
173
  const start = Date.now();
161
174
  while (true) {
162
175
  expireStaleLocks(context, project.id);
@@ -186,13 +199,18 @@ export function getLaneWaiters(context, input) {
186
199
  }))
187
200
  };
188
201
  }
189
- export function cancelLaneWaiter(context, input) {
202
+ export function prepareLaneWaiterCancellation(context, input) {
203
+ if (typeof input.workerId !== "string" || !input.workerId.trim())
204
+ throw new AppError("validation", "Lane waiter requires a nonempty worker ID", 2);
205
+ if (typeof input.reason !== "string" || !input.reason.trim())
206
+ throw new AppError("validation", "lane waiter cancel requires --reason", 2);
190
207
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
191
208
  const lane = normalizeLane(input.lane);
192
209
  const reason = input.reason.trim();
193
- if (!reason) {
194
- throw new AppError("validation", "lane waiter cancel requires --reason", 2);
195
- }
210
+ return { project, lane, reason };
211
+ }
212
+ export function cancelLaneWaiter(context, input) {
213
+ const { project, lane, reason } = prepareLaneWaiterCancellation(context, input);
196
214
  expireStaleWaiters(context, project.id);
197
215
  const now = context.now();
198
216
  const waiter = activeLaneWaiterForWorker(context, project.id, lane, input.workerId);
@@ -245,13 +263,7 @@ export function expireLaneWaiterById(context, input) {
245
263
  return { ok: true, expired: result.changes === 1, waiter_id: input.waiterId };
246
264
  }
247
265
  export async function waitAcquireLaneLock(context, input) {
248
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
249
- const lane = normalizeLane(input.lane);
250
- requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
251
- assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
252
- const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
253
- const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
254
- const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
266
+ const { project, lane, timeoutSeconds, pollIntervalSeconds, ttlSeconds } = prepareLaneLock(context, input, "wait-acquire");
255
267
  const startedAt = Date.now();
256
268
  const waiterDeadline = timeoutSeconds > 0 ? new Date(startedAt + timeoutSeconds * 1000).toISOString() : null;
257
269
  while (true) {
@@ -375,10 +387,9 @@ function laneWaitersForProject(context, projectId, lane) {
375
387
  LIMIT 100`, [projectId]);
376
388
  }
377
389
  function activeLaneLock(context, projectId, lane) {
378
- expireStaleLocks(context, projectId);
379
390
  return context.db.get(`SELECT * FROM lane_locks
380
- WHERE project_id = ? AND lane = ? AND status = 'active'
381
- ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
391
+ WHERE project_id = ? AND lane = ? AND status = 'active' AND expires_at > ?
392
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane, context.now()]);
382
393
  }
383
394
  function activeLaneWaiterForWorker(context, projectId, lane, workerId) {
384
395
  return context.db.get(`SELECT * FROM lane_waiters
@@ -513,10 +524,10 @@ function ensureQueuedLaneWaiter(context, input) {
513
524
  return waiter;
514
525
  }
515
526
  function tryAcquireQueuedLaneLock(context, input) {
516
- expireStaleLocks(context, input.projectId);
517
- expireStaleWaiters(context, input.projectId);
518
- context.db.exec("BEGIN IMMEDIATE");
527
+ context.db.beginWriteTransaction();
519
528
  try {
529
+ expireStaleLocks(context, input.projectId);
530
+ expireStaleWaiters(context, input.projectId);
520
531
  const existingLock = activeLaneLock(context, input.projectId, input.lane);
521
532
  if (existingLock?.worker_id === input.workerId) {
522
533
  context.db.exec("COMMIT");
@@ -618,11 +629,7 @@ function tryAcquireQueuedLaneLock(context, input) {
618
629
  }
619
630
  }
620
631
  function resolveExistingPath(value) {
621
- const absolute = path.resolve(value);
622
- if (!fs.existsSync(absolute)) {
623
- throw new AppError("not_found", `Workspace path does not exist: ${absolute}`, 1);
624
- }
625
- return fs.realpathSync(absolute);
632
+ return resolveProjectRoot(value);
626
633
  }
627
634
  function detectBranch(workspacePath) {
628
635
  const result = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
@@ -635,7 +642,7 @@ function expiresAt(now, ttlSeconds) {
635
642
  return new Date(new Date(now).getTime() + ttlSeconds * 1000).toISOString();
636
643
  }
637
644
  function normalizeLane(value) {
638
- const normalized = value.trim().toLowerCase();
645
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
639
646
  if (!normalized || !/^[a-z0-9._-]+$/.test(normalized)) {
640
647
  throw new AppError("validation", "Lane name must contain only lowercase letters, numbers, dot, underscore, or hyphen", 2);
641
648
  }
@@ -643,15 +650,16 @@ function normalizeLane(value) {
643
650
  }
644
651
  function normalizePositiveNumber(value, fallback, label) {
645
652
  const normalized = value ?? fallback;
646
- if (!Number.isFinite(normalized) || normalized <= 0) {
647
- throw new AppError("validation", `--${label} must be a positive number`, 2);
653
+ if (!Number.isFinite(normalized) || normalized <= 0 ||
654
+ (label === "poll-interval" ? normalized * 1000 > 2 ** 31 - 1 : !Number.isFinite(new Date(Date.now() + normalized * 1000).getTime()))) {
655
+ throw new AppError("validation", `--${label} must be a positive number within the supported timer/date range`, 2);
648
656
  }
649
657
  return normalized;
650
658
  }
651
659
  function normalizeNonNegativeNumber(value, fallback, label) {
652
660
  const normalized = value ?? fallback;
653
- if (!Number.isFinite(normalized) || normalized < 0) {
654
- throw new AppError("validation", `--${label} must be zero or a positive number`, 2);
661
+ if (!Number.isFinite(normalized) || normalized < 0 || !Number.isFinite(new Date(Date.now() + normalized * 1000).getTime())) {
662
+ throw new AppError("validation", `--${label} must be zero or a positive number within the supported date range`, 2);
655
663
  }
656
664
  return normalized;
657
665
  }
@@ -1,11 +1,44 @@
1
1
  import path from "node:path";
2
2
  import { parse, quote } from "shell-quote";
3
+ /** Native hooks and the lightweight ingress must recognize the same shell. */
4
+ export function lifecycleCommandFromPayload(payload) {
5
+ const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
6
+ const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
7
+ return typeof command === "string" ? unwrapShellCommand(command) : undefined;
8
+ }
3
9
  const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
4
10
  const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
5
11
  /** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
6
12
  export function parseLifecycleCommand(command) {
7
13
  return parseLifecycleCommandInner(command, false);
8
14
  }
15
+ /** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
16
+ export function continuationCommand(command) {
17
+ let entries;
18
+ try {
19
+ entries = parse(command, name => `$${name}`);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (!entries.every(entry => typeof entry === "string"))
25
+ return null;
26
+ const words = entries;
27
+ const start = words.findIndex(word => ["stage", "work"].includes(word));
28
+ if (start < 1)
29
+ return null;
30
+ const launcher = words.slice(0, start);
31
+ while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
32
+ launcher.shift();
33
+ const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
34
+ const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
35
+ if (!direct && !node)
36
+ return null;
37
+ const args = parseCommandArgs(words.slice(start));
38
+ const route = args.positional.slice(0, 3).join(" ");
39
+ const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
40
+ return operation ? { operation, args } : null;
41
+ }
9
42
  /** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
10
43
  export function unwrapShellCommand(command) {
11
44
  let entries;
@@ -32,7 +65,7 @@ function parseLifecycleCommandInner(command, wrapped) {
32
65
  const heredoc = stripHeredocBodies(command);
33
66
  let entries;
34
67
  try {
35
- entries = parse(heredoc.command, (name) => `$${name}`);
68
+ entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
36
69
  }
37
70
  catch {
38
71
  // A command that cannot be tokenized cannot create a trusted claim. The
@@ -56,11 +89,11 @@ function parseLifecycleCommandInner(command, wrapped) {
56
89
  if (!found.length)
57
90
  return { kind: "none" };
58
91
  const first = found[0];
59
- if (found.length !== 1 || composed || first.nestedCompound) {
92
+ if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
60
93
  return {
61
94
  kind: "compound",
62
95
  invocation: { ...first.invocation, rewriteSafe: false },
63
- reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : "shell_composition"
96
+ reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
64
97
  };
65
98
  }
66
99
  return { kind: "standalone", invocation: first.invocation };
@@ -127,7 +160,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
127
160
  }
128
161
  }
129
162
  const executable = segment.words[index];
130
- if (!executable || path.basename(executable) !== "dd-flow")
163
+ if (!executable || path.basename(executable) !== "dd-flow" && executable !== "$DD_FLOW_BIN")
131
164
  return null;
132
165
  const argv = segment.words.slice(index + 1);
133
166
  const operation = lifecycleOperation(argv);
@@ -138,7 +171,7 @@ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
138
171
  executable,
139
172
  argv,
140
173
  env,
141
- args: parseCommandArgs(argv.slice(2)),
174
+ args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
142
175
  command: quote(segment.words),
143
176
  rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
144
177
  wrapped,
@@ -170,15 +203,32 @@ function wrappedInvocationFromSegment(segment) {
170
203
  return parseLifecycleCommandInner(payload, true);
171
204
  }
172
205
  function lifecycleOperation(argv) {
206
+ // The CLI handles help before dispatch, wherever the flag occurs.
207
+ if (argv.some(arg => arg === "--help" || arg === "-h"))
208
+ return null;
209
+ if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
210
+ return "recovery_accept";
173
211
  const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
174
212
  if (key === "session register")
175
213
  return "session_register";
176
214
  if (key === "stage start")
177
215
  return "stage_start";
216
+ if (key === "stage finish")
217
+ return "stage_finish";
218
+ if (key === "stage pause")
219
+ return "stage_pause";
178
220
  if (key === "stage resume")
179
221
  return "stage_resume";
180
222
  if (key === "work start")
181
223
  return "work_start";
224
+ if (key === "work finish")
225
+ return "work_finish";
226
+ if (key === "work fail")
227
+ return "work_fail";
228
+ if (key === "merge apply")
229
+ return "merge_apply";
230
+ if (key === "merge repair")
231
+ return "merge_repair";
182
232
  return null;
183
233
  }
184
234
  export function parseCommandArgs(argv) {
@@ -187,6 +237,12 @@ export function parseCommandArgs(argv) {
187
237
  for (let index = 0; index < argv.length; index += 1) {
188
238
  const value = argv[index];
189
239
  if (value?.startsWith("--")) {
240
+ const equals = value.indexOf("=");
241
+ if (equals > 2) {
242
+ const key = value.slice(2, equals);
243
+ options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]);
244
+ continue;
245
+ }
190
246
  const key = value.slice(2);
191
247
  const next = argv[index + 1];
192
248
  const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
@@ -213,6 +269,58 @@ export function commandPosition(invocation, index) {
213
269
  function assignment(word) {
214
270
  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
215
271
  }
272
+ /** Source offsets matter: shell-quote loses the difference between `2>&1`
273
+ * and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
274
+ function shellSyntaxOffsets(command) {
275
+ const offsets = [];
276
+ let quoted = null, escaped = false;
277
+ for (let i = 0; i < command.length; i++) {
278
+ const char = command[i];
279
+ if (escaped) {
280
+ escaped = false;
281
+ continue;
282
+ }
283
+ if (char === "\\" && quoted !== "'") {
284
+ escaped = true;
285
+ continue;
286
+ }
287
+ if (quoted) {
288
+ if (char === quoted)
289
+ quoted = null;
290
+ continue;
291
+ }
292
+ if (char === "'" || char === '"') {
293
+ quoted = char;
294
+ continue;
295
+ }
296
+ if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
297
+ offsets.push(i);
298
+ break;
299
+ }
300
+ if (char === "<" || char === ">")
301
+ offsets.push(i);
302
+ }
303
+ return offsets;
304
+ }
305
+ function descriptorStart(command, index) {
306
+ let start = index;
307
+ while (start > 0 && /[0-9]/.test(command[start - 1]))
308
+ start--;
309
+ return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
310
+ }
311
+ export function shellSuffixOffset(command) {
312
+ const offset = shellSyntaxOffsets(command)[0];
313
+ return offset === undefined ? command.length : descriptorStart(command, offset);
314
+ }
315
+ function withoutFileDescriptors(command) {
316
+ for (const index of shellSyntaxOffsets(command).reverse()) {
317
+ if (command[index] === "#")
318
+ continue;
319
+ const start = descriptorStart(command, index);
320
+ command = command.slice(0, start) + command.slice(index);
321
+ }
322
+ return command;
323
+ }
216
324
  function addAssignment(env, word) {
217
325
  const separator = word.indexOf("=");
218
326
  env[word.slice(0, separator)] = word.slice(separator + 1);
@@ -223,6 +331,7 @@ function stripHeredocBodies(command) {
223
331
  const pending = [];
224
332
  let active;
225
333
  let present = false;
334
+ let count = 0, quoted = true;
226
335
  for (const line of lines) {
227
336
  if (active) {
228
337
  const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
@@ -233,13 +342,15 @@ function stripHeredocBodies(command) {
233
342
  if (line.trim())
234
343
  output.push(line);
235
344
  const found = heredocDelimiters(line);
345
+ count += found.length;
346
+ quoted &&= found.every(item => item.quoted);
236
347
  if (found.length) {
237
348
  present = true;
238
349
  active = found[0];
239
350
  pending.push(...found.slice(1));
240
351
  }
241
352
  }
242
- return { command: output.join(" ; "), present };
353
+ return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
243
354
  }
244
355
  function heredocDelimiters(line) {
245
356
  const result = [];
@@ -282,7 +393,7 @@ function heredocDelimiters(line) {
282
393
  index += 1;
283
394
  const delimiter = line.slice(start, index);
284
395
  if (delimiter)
285
- result.push({ delimiter, stripTabs });
396
+ result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
286
397
  }
287
398
  return result;
288
399
  }