@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122

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 (38) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  6. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
  7. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
  8. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  9. package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
  10. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  11. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  12. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  13. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  14. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  15. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  16. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  17. package/dist/runtime/context.js +1 -1
  18. package/dist/schemas/flow-run.schema.json +1 -0
  19. package/dist/services/code-checks.js +40 -0
  20. package/dist/services/codex-hook-delivery.js +6 -2
  21. package/dist/services/hooks.js +51 -59
  22. package/dist/services/lifecycle-command.js +1 -383
  23. package/dist/services/lifecycle-contract.js +1 -30
  24. package/dist/services/lifecycle-invocations.js +34 -13
  25. package/dist/services/managed-processes.js +42 -6
  26. package/dist/services/run-control.js +7 -1
  27. package/dist/services/runs.js +60 -47
  28. package/dist/services/runtime-scope-stop.js +23 -2
  29. package/dist/services/stage-report-renderer.js +19 -0
  30. package/dist/services/verification-matrix.js +325 -0
  31. package/dist/services/vnext-code-review.js +25 -8
  32. package/dist/services/vnext-code.js +38 -11
  33. package/dist/services/vnext-merge.js +30 -7
  34. package/dist/services/vnext-plan-review.js +22 -6
  35. package/dist/services/vnext-plan.js +45 -17
  36. package/dist/services/work-registry.js +13 -1
  37. package/dist/storage/database.js +43 -9
  38. package/package.json +4 -1
@@ -481,8 +481,8 @@ function codexNativeParentId(payload, childId) {
481
481
  return null;
482
482
  }
483
483
  }
484
- /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
485
- * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
484
+ /** Qualified ACP admission binds public commands to an exact issued assignment.
485
+ * Its provenance is an observation, never a synchronous PreToolUse assertion. */
486
486
  export function handleZcodeEvent(context, input) {
487
487
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
488
488
  const params = objectRecord(notification.params), update = objectRecord(params.update);
@@ -494,8 +494,10 @@ export function handleZcodeEvent(context, input) {
494
494
  return { ok: true, observed: false, reason: "non_bash_tool" };
495
495
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
496
496
  const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
497
- if (parsed.kind === "none")
497
+ if (parsed.kind === "none" && !parsed.unresolved)
498
498
  return { ok: true, observed: false, reason: "acp_evidence_only" };
499
+ if (parsed.kind === "none")
500
+ throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
499
501
  const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
500
502
  const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
501
503
  return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
@@ -557,7 +559,7 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
557
559
  const observedProfile = objectRecord(ddZcode.observedProfile);
558
560
  const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
559
561
  let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
560
- const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
562
+ const eventNameForReceipt = nativePreTool ? "PreToolUse" : "ToolCallObserved";
561
563
  const persist = (scope) => {
562
564
  if (scope)
563
565
  matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
@@ -583,6 +585,24 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
583
585
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
584
586
  recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
585
587
  });
588
+ if (receipt.duplicate) {
589
+ // Keep first admission immutable. A second transport is independently
590
+ // retained corroboration, never another claim or an invented ACP cwd.
591
+ const retained = context.db.get("SELECT event_name, match_key, harness, provider_session_id, parent_session_id, daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
592
+ if (retained && retained.event_name !== eventNameForReceipt) {
593
+ if (retained.harness !== "zcode-acp" || retained.provider_session_id !== providerSessionId || retained.parent_session_id !== parentSessionId || retained.daemon_id !== daemonId) {
594
+ throw new AppError("hook_event_replay_mismatch", "Corroborating transport has different native ownership", 1, { event_key: receipt.eventKey });
595
+ }
596
+ context.db.writeTransaction(() => recordHookEvent(context, { projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
597
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? null, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "corroborating",
598
+ payload: { ...hook, command, corroborates_event_key: receipt.eventKey, admission_transport: nativePreTool ? "native_pre_tool" : "acp_observation" },
599
+ eventKey: `${receipt.eventKey}:${eventNameForReceipt}`, matchKey: null, transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd }));
600
+ }
601
+ else if (retained) {
602
+ assertHookEventReplay(context, { projectId: project.id, eventKey: receipt.eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId,
603
+ sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey: retained.match_key, cwd: observedCwd });
604
+ }
605
+ }
586
606
  const admittedInvocationId = receipt.invocationId ?? invocationId;
587
607
  if (admittedInvocationId)
588
608
  return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
@@ -706,8 +726,9 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
706
726
  if (work) {
707
727
  // PROTOCOLIZE provisions the feature checkout after its coordinator
708
728
  // Session has launched in the stable project root. Its transition Work
709
- // must keep that physical route until the Stage is complete.
710
- if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
729
+ // must keep that physical route until the Stage is complete. Child Works
730
+ // share stage membership, not the root coordinator's execution directory.
731
+ if (work.parent_work_id === null && context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
711
732
  && context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
712
733
  return projectRoot;
713
734
  const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
@@ -785,14 +806,24 @@ function handleControlledToolEvent(context, input, event, harness) {
785
806
  const auxiliary = continuationCommand(command);
786
807
  if (!lifecycle && !auxiliary)
787
808
  return { ok: true, observed: false, reason: "event_not_participating" };
809
+ // Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
810
+ // supply a route or execution authority, including a short public Stage call.
811
+ const shellRefusal = lifecycle?.analysis.supported === false;
788
812
  const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
789
813
  ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
790
- const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
791
- if (observedCwd !== expectedCwd) {
792
- throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
814
+ if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
815
+ throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { effect: "no_effect", recoverable: false });
793
816
  }
794
- const bootstrapStageStart = lifecycle?.bootstrapStageStart;
795
- let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
817
+ const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
818
+ const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd;
819
+ if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
820
+ throw new AppError(`${errorPrefix}_directory_mismatch`, "Literal shell cd route does not match the assigned lifecycle workspace", 1, { expected: expectedCwd, actual: executionRoute, actual_provenance: "shell_literal_route", effect: "no_effect", recoverable: false });
821
+ }
822
+ if (!executionRoute && observedCwd !== expectedCwd) {
823
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, effect: "no_effect", recoverable: false, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
824
+ }
825
+ const bootstrapStageStart = !shellRefusal && lifecycle?.bootstrapStageStart;
826
+ let matchKey = lifecycle && !shellRefusal ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
796
827
  if (bootstrapStageStart)
797
828
  registerProject(context, { root: expectedRoot });
798
829
  const project = requireProjectByRoot(context, expectedRoot);
@@ -810,6 +841,7 @@ function handleControlledToolEvent(context, input, event, harness) {
810
841
  const payload = {
811
842
  session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
812
843
  ...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
844
+ ...(executionRoute ? { lifecycle_execution_cwd: expectedCwd, lifecycle_execution_cwd_provenance: "literal_cd_intent_requires_cli_confirmation" } : {}),
813
845
  provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
814
846
  mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
815
847
  };
@@ -822,10 +854,10 @@ function handleControlledToolEvent(context, input, event, harness) {
822
854
  return inserted;
823
855
  };
824
856
  const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
825
- if (agy && lifecycle) {
857
+ if ((agy || shellRefusal) && lifecycle) {
826
858
  let inserted = false;
827
859
  const receipt = observeLifecycleInvocation(context, {
828
- command, harness, projectId: project.id, identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
860
+ command, harness, projectId: project.id, ...(!shellRefusal && (agy || executionRoute) ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
829
861
  recordReceipt: scope => { inserted = persist(scope); return baseKey; }
830
862
  });
831
863
  return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
@@ -908,50 +940,19 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
908
940
  */
909
941
  export function commandWithHookEvent(command, eventKey) {
910
942
  const analysis = parseLifecycleCommand(command);
911
- if (analysis.kind !== "standalone")
943
+ if (analysis.kind === "none" || analysis.supported === false)
912
944
  return command;
913
945
  if (commandHasOption(analysis.invocation, "hook-event-id")) {
914
946
  return command;
915
947
  }
916
948
  const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
917
949
  if (analysis.invocation.rewriteSafe) {
918
- const index = shellSuffixOffset(command);
919
- return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
920
- }
921
- if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
922
- const index = firstHeredoc(command);
923
- if (index >= 0)
924
- return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
950
+ const index = analysis.invocation.sourceRange?.end ?? shellSuffixOffset(command);
951
+ const suffix = command.slice(index);
952
+ return `${command.slice(0, index)} ${option}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
925
953
  }
926
954
  return command;
927
955
  }
928
- function firstHeredoc(command) {
929
- let quoteState = null;
930
- let escaped = false;
931
- for (let index = 0; index < command.length - 1; index += 1) {
932
- const char = command[index];
933
- if (escaped) {
934
- escaped = false;
935
- continue;
936
- }
937
- if (char === "\\" && quoteState !== "'") {
938
- escaped = true;
939
- continue;
940
- }
941
- if (quoteState) {
942
- if (char === quoteState)
943
- quoteState = null;
944
- continue;
945
- }
946
- if (char === "'" || char === '"') {
947
- quoteState = char;
948
- continue;
949
- }
950
- if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
951
- return index;
952
- }
953
- return -1;
954
- }
955
956
  /**
956
957
  * Recognise the small shell surface used by generated dd-flow lifecycle
957
958
  * commands. This intentionally does not evaluate Bash: ambiguous composition
@@ -967,6 +968,8 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
967
968
  }
968
969
  function lifecycleFacts(command) {
969
970
  const analysis = parseLifecycleCommand(command);
971
+ if (analysis.kind === "none" && analysis.unresolved)
972
+ throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
970
973
  if (analysis.kind === "none")
971
974
  return null;
972
975
  const invocation = analysis.invocation;
@@ -993,17 +996,6 @@ function lifecycleFacts(command) {
993
996
  contextSha256: commandOption(invocation, "context-sha256")
994
997
  };
995
998
  }
996
- // Worker completion and the two HITL commands intentionally take their
997
- // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
998
- // not a second authority. Everything else remains standalone-only.
999
- function allowsStdinLifecycleCompound(facts) {
1000
- const operation = facts.invocation.operation;
1001
- return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
1002
- && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
1003
- || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
1004
- || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
1005
- || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
1006
- }
1007
999
  function lifecycleMatchKey(facts, projectRoot, scope) {
1008
1000
  // Observe the actual argument without validating it. A mistyped project
1009
1001
  // must not leave a receipt matching a later, corrected non-rewriting call.
@@ -1,383 +1 @@
1
- import path from "node:path";
2
- import { parse, quote } from "shell-quote";
3
- import { lifecycleOperationForArgv } from "./lifecycle-contract.js";
4
- /** Native hooks and the lightweight ingress must recognize the same shell. */
5
- export function lifecycleCommandFromPayload(payload) {
6
- const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
7
- const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
8
- return typeof command === "string" ? unwrapShellCommand(command) : undefined;
9
- }
10
- const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
11
- const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
12
- /** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
13
- export function parseLifecycleCommand(command) {
14
- return parseLifecycleCommandInner(command, false);
15
- }
16
- /** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
17
- export function continuationCommand(command) {
18
- let entries;
19
- try {
20
- entries = parse(command, name => `$${name}`);
21
- }
22
- catch {
23
- return null;
24
- }
25
- if (!entries.every(entry => typeof entry === "string"))
26
- return null;
27
- const words = entries;
28
- const start = words.findIndex(word => ["stage", "work"].includes(word));
29
- if (start < 1)
30
- return null;
31
- const launcher = words.slice(0, start);
32
- while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
33
- launcher.shift();
34
- const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
35
- const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
36
- if (!direct && !node)
37
- return null;
38
- const args = parseCommandArgs(words.slice(start));
39
- const route = args.positional.slice(0, 3).join(" ");
40
- const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
41
- return operation ? { operation, args } : null;
42
- }
43
- /** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
44
- export function unwrapShellCommand(command) {
45
- let entries;
46
- try {
47
- entries = parse(command, (name) => `$${name}`);
48
- }
49
- catch {
50
- return command;
51
- }
52
- if (!entries.every((entry) => typeof entry === "string"))
53
- return command;
54
- const words = entries;
55
- let index = 0;
56
- while (index < words.length && assignment(words[index]))
57
- index += 1;
58
- const executable = path.basename(words[index] ?? "");
59
- if (!new Set(["bash", "sh", "zsh"]).has(executable))
60
- return command;
61
- if (!new Set(["-c", "-lc", "-cl"]).has(words[index + 1] ?? "") || words.length !== index + 3)
62
- return command;
63
- return words[index + 2] ?? command;
64
- }
65
- function parseLifecycleCommandInner(command, wrapped) {
66
- const heredoc = stripHeredocBodies(command);
67
- let entries;
68
- try {
69
- entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
70
- }
71
- catch {
72
- // A command that cannot be tokenized cannot create a trusted claim. The
73
- // real lifecycle CLI will consequently fail its binding requirement.
74
- return { kind: "none" };
75
- }
76
- const { segments, composed } = commandSegments(entries);
77
- const found = [];
78
- const exportedEnv = {};
79
- for (const segment of segments) {
80
- Object.assign(exportedEnv, exportedAssignments(segment));
81
- const direct = invocationFromSegment(segment, heredoc.present, wrapped, exportedEnv);
82
- if (direct) {
83
- found.push({ invocation: direct, nestedCompound: segment.dynamic });
84
- continue;
85
- }
86
- const nested = wrappedInvocationFromSegment(segment);
87
- if (nested && nested.kind !== "none")
88
- found.push({ invocation: { ...nested.invocation, heredoc: nested.invocation.heredoc || heredoc.present, rewriteSafe: false, wrapped: true }, nestedCompound: nested.kind === "compound" });
89
- }
90
- if (!found.length)
91
- return { kind: "none" };
92
- const first = found[0];
93
- if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
94
- return {
95
- kind: "compound",
96
- invocation: { ...first.invocation, rewriteSafe: false },
97
- reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
98
- };
99
- }
100
- return { kind: "standalone", invocation: first.invocation };
101
- }
102
- function commandSegments(entries) {
103
- const segments = [];
104
- let words = [];
105
- let dynamic = false;
106
- let composed = false;
107
- const flush = () => {
108
- if (words.length || dynamic)
109
- segments.push({ words, dynamic });
110
- words = [];
111
- dynamic = false;
112
- };
113
- for (let index = 0; index < entries.length; index += 1) {
114
- const entry = entries[index];
115
- if (typeof entry === "string") {
116
- words.push(entry);
117
- continue;
118
- }
119
- if ("comment" in entry)
120
- break;
121
- if (!("op" in entry) || typeof entry.op !== "string") {
122
- dynamic = true;
123
- continue;
124
- }
125
- const operator = entry.op;
126
- if (operator === "glob") {
127
- dynamic = true;
128
- words.push("pattern" in entry && typeof entry.pattern === "string" ? entry.pattern : "");
129
- continue;
130
- }
131
- if (controlOperators.has(operator)) {
132
- composed = true;
133
- flush();
134
- continue;
135
- }
136
- if (redirectionOperators.has(operator)) {
137
- const target = entries[index + 1];
138
- if (typeof target === "string" || (target && typeof target === "object" && "op" in target && target.op === "glob"))
139
- index += 1;
140
- continue;
141
- }
142
- dynamic = true;
143
- }
144
- flush();
145
- return { segments, composed };
146
- }
147
- function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
148
- let index = 0;
149
- const env = { ...inheritedEnv };
150
- while (index < segment.words.length && assignment(segment.words[index])) {
151
- addAssignment(env, segment.words[index]);
152
- index += 1;
153
- }
154
- if (path.basename(segment.words[index] ?? "") === "env") {
155
- index += 1;
156
- if (segment.words[index]?.startsWith("-"))
157
- return null;
158
- while (index < segment.words.length && assignment(segment.words[index])) {
159
- addAssignment(env, segment.words[index]);
160
- index += 1;
161
- }
162
- }
163
- let executable = segment.words[index];
164
- let nodeLauncher = false;
165
- let launcher;
166
- if (executable && path.basename(executable) === "node"
167
- && (path.basename(segment.words[index + 1] ?? "") === "cli.js" || segment.words[index + 1] === "$DD_FLOW_BIN")) {
168
- launcher = executable;
169
- executable = segment.words[++index];
170
- nodeLauncher = true;
171
- }
172
- if (!executable || path.basename(executable) !== "dd-flow" && executable !== "$DD_FLOW_BIN" && !(nodeLauncher && path.basename(executable) === "cli.js"))
173
- return null;
174
- const argv = segment.words.slice(index + 1);
175
- const operation = lifecycleOperation(argv);
176
- if (!operation)
177
- return null;
178
- return {
179
- operation,
180
- executable,
181
- ...(launcher ? { launcher } : {}),
182
- argv,
183
- env,
184
- args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
185
- command: quote(segment.words),
186
- rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
187
- wrapped,
188
- heredoc
189
- };
190
- }
191
- function exportedAssignments(segment) {
192
- const result = {};
193
- if (segment.words[0] !== "export")
194
- return result;
195
- for (const word of segment.words.slice(1)) {
196
- if (!assignment(word))
197
- return {};
198
- addAssignment(result, word);
199
- }
200
- return result;
201
- }
202
- function wrappedInvocationFromSegment(segment) {
203
- let index = 0;
204
- while (index < segment.words.length && assignment(segment.words[index]))
205
- index += 1;
206
- const executable = path.basename(segment.words[index] ?? "");
207
- if (!new Set(["bash", "sh", "zsh"]).has(executable))
208
- return null;
209
- const flag = segment.words[index + 1];
210
- const payload = segment.words[index + 2];
211
- if (!payload || !new Set(["-c", "-lc", "-cl"]).has(flag ?? "") || segment.words.length !== index + 3)
212
- return null;
213
- return parseLifecycleCommandInner(payload, true);
214
- }
215
- function lifecycleOperation(argv) {
216
- return lifecycleOperationForArgv(argv);
217
- }
218
- export function parseCommandArgs(argv) {
219
- const positional = [];
220
- const options = new Map();
221
- for (let index = 0; index < argv.length; index += 1) {
222
- const value = argv[index];
223
- if (value?.startsWith("--")) {
224
- const equals = value.indexOf("=");
225
- if (equals > 2) {
226
- const key = value.slice(2, equals);
227
- options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]);
228
- continue;
229
- }
230
- const key = value.slice(2);
231
- const next = argv[index + 1];
232
- const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
233
- options.set(key, [...(options.get(key) ?? []), optionValue]);
234
- if (optionValue !== "__dd_flow_flag__")
235
- index += 1;
236
- }
237
- else if (value)
238
- positional.push(value);
239
- }
240
- return { positional, options };
241
- }
242
- export function commandOption(invocation, name) {
243
- const values = invocation.args.options.get(name);
244
- const value = values?.[values.length - 1];
245
- return value === "__dd_flow_flag__" ? undefined : value;
246
- }
247
- export function commandHasOption(invocation, name) {
248
- return invocation.args.options.has(name);
249
- }
250
- export function commandPosition(invocation, index) {
251
- return invocation.args.positional[index];
252
- }
253
- function assignment(word) {
254
- return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
255
- }
256
- /** Source offsets matter: shell-quote loses the difference between `2>&1`
257
- * and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
258
- function shellSyntaxOffsets(command) {
259
- const offsets = [];
260
- let quoted = null, escaped = false;
261
- for (let i = 0; i < command.length; i++) {
262
- const char = command[i];
263
- if (escaped) {
264
- escaped = false;
265
- continue;
266
- }
267
- if (char === "\\" && quoted !== "'") {
268
- escaped = true;
269
- continue;
270
- }
271
- if (quoted) {
272
- if (char === quoted)
273
- quoted = null;
274
- continue;
275
- }
276
- if (char === "'" || char === '"') {
277
- quoted = char;
278
- continue;
279
- }
280
- if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
281
- offsets.push(i);
282
- break;
283
- }
284
- if (char === "<" || char === ">")
285
- offsets.push(i);
286
- }
287
- return offsets;
288
- }
289
- function descriptorStart(command, index) {
290
- let start = index;
291
- while (start > 0 && /[0-9]/.test(command[start - 1]))
292
- start--;
293
- return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
294
- }
295
- export function shellSuffixOffset(command) {
296
- const offset = shellSyntaxOffsets(command)[0];
297
- return offset === undefined ? command.length : descriptorStart(command, offset);
298
- }
299
- function withoutFileDescriptors(command) {
300
- for (const index of shellSyntaxOffsets(command).reverse()) {
301
- if (command[index] === "#")
302
- continue;
303
- const start = descriptorStart(command, index);
304
- command = command.slice(0, start) + command.slice(index);
305
- }
306
- return command;
307
- }
308
- function addAssignment(env, word) {
309
- const separator = word.indexOf("=");
310
- env[word.slice(0, separator)] = word.slice(separator + 1);
311
- }
312
- function stripHeredocBodies(command) {
313
- const lines = command.split(/\r?\n/);
314
- const output = [];
315
- const pending = [];
316
- let active;
317
- let present = false;
318
- let count = 0, quoted = true;
319
- for (const line of lines) {
320
- if (active) {
321
- const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
322
- if (candidate === active.delimiter)
323
- active = pending.shift();
324
- continue;
325
- }
326
- if (line.trim())
327
- output.push(line);
328
- const found = heredocDelimiters(line);
329
- count += found.length;
330
- quoted &&= found.every(item => item.quoted);
331
- if (found.length) {
332
- present = true;
333
- active = found[0];
334
- pending.push(...found.slice(1));
335
- }
336
- }
337
- return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
338
- }
339
- function heredocDelimiters(line) {
340
- const result = [];
341
- let quoteState = null;
342
- let escaped = false;
343
- for (let index = 0; index < line.length - 1; index += 1) {
344
- const char = line[index];
345
- if (escaped) {
346
- escaped = false;
347
- continue;
348
- }
349
- if (char === "\\" && quoteState !== "'") {
350
- escaped = true;
351
- continue;
352
- }
353
- if (quoteState) {
354
- if (char === quoteState)
355
- quoteState = null;
356
- continue;
357
- }
358
- if (char === "'" || char === '"') {
359
- quoteState = char;
360
- continue;
361
- }
362
- if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<")
363
- continue;
364
- index += 2;
365
- const stripTabs = line[index] === "-";
366
- if (stripTabs)
367
- index += 1;
368
- while (/\s/.test(line[index] ?? ""))
369
- index += 1;
370
- const delimiterQuote = line[index] === "'" || line[index] === '"' ? line[index++] : null;
371
- const start = index;
372
- if (delimiterQuote)
373
- while (index < line.length && line[index] !== delimiterQuote)
374
- index += 1;
375
- else
376
- while (index < line.length && !/[\s;&|<>]/.test(line[index]))
377
- index += 1;
378
- const delimiter = line.slice(start, index);
379
- if (delimiter)
380
- result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
381
- }
382
- return result;
383
- }
1
+ export { lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand, unwrapShellCommand, parseCommandArgs, commandOption, commandHasOption, commandPosition, shellSuffixOffset } from "../harness-runtime/lib/lifecycle-command.mjs";
@@ -1,30 +1 @@
1
- export const lifecycleRoutes = {
2
- "session register": "session_register",
3
- "stage start": "stage_start",
4
- "stage finish": "stage_finish",
5
- "stage pause": "stage_pause",
6
- "stage resume": "stage_resume",
7
- "work start": "work_start",
8
- "work finish": "work_finish",
9
- "work fail": "work_fail",
10
- "merge apply": "merge_apply",
11
- "merge repair": "merge_repair",
12
- "run recovery accept": "recovery_accept"
13
- };
14
- export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
15
- export function lifecycleOperationForArgv(argv) {
16
- if (argv.some(arg => arg === "--help" || arg === "-h"))
17
- return null;
18
- for (const [route, operation] of Object.entries(lifecycleRoutes)) {
19
- const parts = route.split(" ");
20
- if (parts.every((part, index) => argv[index] === part))
21
- return operation;
22
- }
23
- return null;
24
- }
25
- export function lifecycleRouteForOperation(operation) {
26
- const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
27
- if (!entry)
28
- throw new Error(`Unknown lifecycle operation: ${operation}`);
29
- return entry[0];
30
- }
1
+ export { lifecycleRoutes, lifecycleInternalOptions, lifecycleOperationForArgv, lifecycleRouteForOperation } from "../harness-runtime/lib/lifecycle-contract.mjs";