@deksden-com/dd-flow-cli 0.8.0 → 0.9.0-beta.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.
@@ -7,6 +7,7 @@ import { AppError } from "../shared/errors.js";
7
7
  import { parseJsonObject } from "../shared/json.js";
8
8
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
+ import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
10
11
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
12
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
12
13
  const defaultProfileName = "default";
@@ -272,47 +273,27 @@ export function handleCodexHook(context, input) {
272
273
  const command = lifecycleCommandFromPayload(payload);
273
274
  if (!command)
274
275
  return { ok: true, observed: false, reason: "non_bash_tool" };
275
- const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
276
- const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
277
- const lifecycleShell = analyzeLifecycleShellCommand(command);
276
+ const lifecycle = lifecycleFacts(command);
277
+ if (!lifecycle)
278
+ return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
278
279
  // Resume legitimately receives an answer through stdin. It is matched by
279
280
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
280
- if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
281
+ if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
281
282
  return {
282
283
  ok: false,
283
284
  observed: false,
284
285
  reason: "compound_lifecycle_command",
285
286
  message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
286
- standalone_command: lifecycleShell.standaloneCommand,
287
+ standalone_command: lifecycle.invocation.command,
287
288
  hookSpecificOutput: {
288
289
  hookEventName: "PreToolUse",
289
290
  permissionDecision: "deny",
290
- permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycleShell.standaloneCommand}`
291
+ permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
291
292
  }
292
293
  };
293
294
  }
294
- const flowPayload = flowSessionPayloadFromRegisterCommand(command);
295
- const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
296
- const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
297
- const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
298
- if (!flowPayload && !stageStart && !stageResume && !workStart)
299
- return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
300
- const commandProjectRoot = optionFromCommand(command, "project-root");
301
- const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
302
- const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
303
- const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
304
- const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
305
- const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
306
- const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
307
- const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
308
- const matchKey = bootstrapStageStart && commandProjectRoot ? bootstrapMatchKey({
309
- projectRoot: commandProjectRoot,
310
- subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
311
- intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
312
- ...(contextSha256 ? { contextSha256 } : {})
313
- }) : stageStart && stageRunId && stageName && commandProjectRoot ? stageStartMatchKey(stageRunId, stageName, commandProjectRoot, contextSha256)
314
- : stageResume && resumeRunId && resumeStageName && resumeWorkId && commandProjectRoot ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, commandProjectRoot)
315
- : workStart && workId && commandProjectRoot ? workStartMatchKey(workId, commandProjectRoot) : null;
295
+ const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
296
+ const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
316
297
  // A bootstrap stage is the first stateful command for a new materialized project.
317
298
  // Its PreToolUse event arrives before stage start can register that project.
318
299
  if (bootstrapStageStart && commandProjectRoot) {
@@ -393,22 +374,15 @@ export function handleZcodeEvent(context, input) {
393
374
  if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
394
375
  return { ok: true, observed: false, reason: "non_bash_tool" };
395
376
  }
396
- const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
397
- const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
398
- const lifecycleShell = analyzeLifecycleShellCommand(command, true);
399
- if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
377
+ const lifecycle = lifecycleFacts(command);
378
+ if (!lifecycle)
379
+ return { ok: true, observed: false, reason: "event_not_participating" };
380
+ if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
400
381
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
401
- standalone_command: lifecycleShell.standaloneCommand
382
+ standalone_command: lifecycle.invocation.command
402
383
  });
403
384
  }
404
- const flowPayload = flowSessionPayloadFromRegisterCommand(command);
405
- const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
406
- const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
407
- const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
408
- if (!flowPayload && !stageStart && !stageResume && !workStart) {
409
- return { ok: true, observed: false, reason: "event_not_participating" };
410
- }
411
- const commandProjectRoot = optionFromCommand(command, "project-root");
385
+ const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
412
386
  const expectedRoot = resolveProjectRoot(input.projectRoot);
413
387
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
414
388
  throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
@@ -416,21 +390,7 @@ export function handleZcodeEvent(context, input) {
416
390
  actual: commandProjectRoot
417
391
  });
418
392
  }
419
- const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
420
- const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
421
- const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
422
- const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
423
- const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
424
- const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
425
- const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
426
- const matchKey = bootstrapStageStart ? bootstrapMatchKey({
427
- projectRoot: expectedRoot,
428
- subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
429
- intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
430
- ...(contextSha256 ? { contextSha256 } : {})
431
- }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
432
- : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
433
- : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
393
+ const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
434
394
  if (bootstrapStageStart)
435
395
  registerProject(context, { root: expectedRoot });
436
396
  const project = requireProjectByRoot(context, expectedRoot);
@@ -522,42 +482,22 @@ export function handleGrokEvent(context, input) {
522
482
  if (!command || (toolName && !["Bash", "bash", "run_terminal_command", "RunTerminalCommand"].includes(toolName))) {
523
483
  return { ok: true, observed: false, reason: "non_bash_tool" };
524
484
  }
525
- const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
526
- const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
527
- const lifecycleShell = analyzeLifecycleShellCommand(command, true);
528
- if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
485
+ const lifecycle = lifecycleFacts(command);
486
+ if (!lifecycle)
487
+ return { ok: true, observed: false, reason: "event_not_participating" };
488
+ if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
529
489
  throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
530
- standalone_command: lifecycleShell.standaloneCommand
490
+ standalone_command: lifecycle.invocation.command
531
491
  });
532
492
  }
533
- const flowPayload = flowSessionPayloadFromRegisterCommand(command);
534
- const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
535
- const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
536
- const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
537
- if (!flowPayload && !stageStart && !stageResume && !workStart)
538
- return { ok: true, observed: false, reason: "event_not_participating" };
539
- const commandProjectRoot = optionFromCommand(command, "project-root");
493
+ const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
540
494
  const expectedRoot = resolveProjectRoot(input.projectRoot);
541
495
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
542
496
  throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
543
497
  expected: expectedRoot, actual: commandProjectRoot
544
498
  });
545
499
  }
546
- const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
547
- const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
548
- const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
549
- const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
550
- const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
551
- const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
552
- const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
553
- const matchKey = bootstrapStageStart ? bootstrapMatchKey({
554
- projectRoot: expectedRoot,
555
- subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
556
- intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
557
- ...(contextSha256 ? { contextSha256 } : {})
558
- }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
559
- : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
560
- : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
500
+ const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
561
501
  if (bootstrapStageStart)
562
502
  registerProject(context, { root: expectedRoot });
563
503
  const project = requireProjectByRoot(context, expectedRoot);
@@ -642,38 +582,17 @@ export function handleOpenCodeEvent(context, input) {
642
582
  if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
643
583
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
644
584
  }
645
- const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
646
- const lifecycleShell = analyzeLifecycleShellCommand(command, true);
647
- const agyBootstrapHeredoc = agy && containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
648
- const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
649
- if (lifecycleShell.kind === "compound" && !stageResume && !agyBootstrapHeredoc && !stagePauseHeredoc) {
650
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycleShell.standaloneCommand });
651
- }
652
- const flowPayload = flowSessionPayloadFromRegisterCommand(command);
653
- const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
654
- const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
655
- const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
656
- if (!flowPayload && !stageStart && !stageResume && !workStart)
585
+ const lifecycle = lifecycleFacts(command);
586
+ if (!lifecycle)
657
587
  return { ok: true, observed: false, reason: "event_not_participating" };
658
- const commandProjectRoot = optionFromCommand(command, "project-root");
588
+ if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
589
+ throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
590
+ }
591
+ const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
659
592
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
660
593
  throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
661
594
  }
662
- const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
663
- const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
664
- const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
665
- const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
666
- const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
667
- const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
668
- const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
669
- const matchKey = bootstrapStageStart ? bootstrapMatchKey({
670
- projectRoot: expectedRoot,
671
- subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
672
- intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
673
- ...(contextSha256 ? { contextSha256 } : {})
674
- }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
675
- : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
676
- : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
595
+ const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
677
596
  if (bootstrapStageStart)
678
597
  registerProject(context, { root: expectedRoot });
679
598
  const project = requireProjectByRoot(context, expectedRoot);
@@ -721,22 +640,17 @@ export function handleAgyEvent(context, input) {
721
640
  };
722
641
  return handleOpenCodeEvent(context, { projectRoot: input.projectRoot, stdin: JSON.stringify(translated) });
723
642
  }
724
- /**
725
- * A Desktop hook inherits the app environment, not an inline `VAR=value`
726
- * assignment made by the pending Bash tool call. Find the explicit
727
- * DD_FLOW_HOME assignment for the dd-flow invocation, including a safe stdin
728
- * pipeline such as `cat <<EOF | CODEX_HOME=... DD_FLOW_HOME=... dd-flow`.
729
- * Other shell syntax is deliberately not evaluated here.
730
- */
643
+ /** A Desktop hook must open the same isolated state as the actual lifecycle invocation. */
731
644
  export function isolatedDdFlowHomeFromHookStdin(stdin) {
732
645
  try {
733
646
  const payload = parseJsonObject(stdin || "{}", "codex hook stdin");
734
647
  const command = lifecycleCommandFromPayload(payload);
735
- const direct = command?.match(/(?:^|[|;\r\n]\s*)(?:env\s+)?(?:(?!DD_FLOW_HOME=)[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s|;]+)\s+)*DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1\s+dd-flow\b/);
736
- // An exported home applies to the following pipeline command; this is the
737
- // other form emitted by generated commands that send stdin through a pipe.
738
- const exported = command?.match(/(?:^|[;\r\n]\s*)export\s+DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1(?=\s*(?:[;\r\n]|$))/);
739
- const home = direct?.[2] ?? exported?.[2];
648
+ if (!command)
649
+ return null;
650
+ const analysis = parseLifecycleCommand(command);
651
+ if (analysis.kind === "none")
652
+ return null;
653
+ const home = analysis.invocation.env.DD_FLOW_HOME;
740
654
  return home && path.isAbsolute(home) ? path.resolve(home) : null;
741
655
  }
742
656
  catch {
@@ -750,51 +664,13 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
750
664
  */
751
665
  function lifecycleCommandFromPayload(payload) {
752
666
  const command = commandFromPayload(payload);
753
- if (!command)
754
- return undefined;
755
- const wrapped = command.trim().match(/^(?:\S*\/)?bash\s+-c\s+"((?:\\.|[^"\\])*)"\s*$/);
756
- return wrapped ? wrapped[1].replace(/\\([\\"])/g, "$1") : command;
667
+ return command ? unwrapShellCommand(command) : undefined;
757
668
  }
758
669
  function commandWithHookEvent(command, eventKey) {
759
- if (/(?:^|\s)--hook-event-id(?:=|\s)/.test(command))
670
+ const analysis = parseLifecycleCommand(command);
671
+ if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
760
672
  return command;
761
- // Heredoc payloads are opaque bytes from the user. Appending a flag to the
762
- // whole Bash command changes those bytes and corrupts `stage resume` input.
763
- if (containsUnquotedHeredoc(command))
764
- return command;
765
- if (/^\s*dd-flow\s+session\s+register\b/.test(command) && !/[;|&\n\r]|\$\(/.test(command))
766
- return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
767
- const lifecycle = analyzeLifecycleShellCommand(command, true);
768
- if (lifecycle.kind !== "standalone")
769
- return command;
770
- return `${lifecycle.standaloneCommand} --hook-event-id ${JSON.stringify(eventKey)}`;
771
- }
772
- function containsUnquotedHeredoc(command) {
773
- let quote = null;
774
- let escaped = false;
775
- for (let index = 0; index < command.length - 1; index += 1) {
776
- const char = command[index];
777
- if (escaped) {
778
- escaped = false;
779
- continue;
780
- }
781
- if (char === "\\" && quote !== "'") {
782
- escaped = true;
783
- continue;
784
- }
785
- if (quote) {
786
- if (char === quote)
787
- quote = null;
788
- continue;
789
- }
790
- if (char === "'" || char === '"') {
791
- quote = char;
792
- continue;
793
- }
794
- if (char === "<" && command[index + 1] === "<")
795
- return true;
796
- }
797
- return false;
673
+ return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
798
674
  }
799
675
  /**
800
676
  * Recognise the small shell surface used by generated dd-flow lifecycle
@@ -802,57 +678,52 @@ function containsUnquotedHeredoc(command) {
802
678
  * is rejected so a hook event can never bind the wrong command.
803
679
  */
804
680
  export function analyzeLifecycleShellCommand(command, includeSessionRegister = false) {
805
- const segments = [];
806
- let start = 0;
807
- let quote = null;
808
- let escaped = false;
809
- let compound = false;
810
- for (let index = 0; index < command.length; index += 1) {
811
- const char = command[index];
812
- if (escaped) {
813
- escaped = false;
814
- continue;
815
- }
816
- if (char === "\\" && quote !== "'") {
817
- escaped = true;
818
- continue;
819
- }
820
- if (quote) {
821
- if (char === quote)
822
- quote = null;
823
- continue;
824
- }
825
- if (char === "'" || char === '"') {
826
- quote = char;
827
- continue;
828
- }
829
- const pair = command.slice(index, index + 2);
830
- if (pair === "$(" || pair === "&&" || pair === "||") {
831
- compound = true;
832
- segments.push(command.slice(start, index).trim());
833
- index += 1;
834
- start = index + 1;
835
- continue;
836
- }
837
- if (char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r" || char === "(") {
838
- compound = true;
839
- segments.push(command.slice(start, index).trim());
840
- start = index + 1;
841
- }
842
- }
843
- segments.push(command.slice(start).trim());
844
- // `stage resume` intentionally accepts the user's complete answer on stdin.
845
- // We recognise it for observability but never rewrite a compound invocation.
846
- const lifecyclePattern = includeSessionRegister
847
- ? /\bdd-flow\s+(?:session\s+register|stage\s+(?:start|resume)|work\s+start)\b/
848
- : /\bdd-flow\s+(?:stage\s+(?:start|resume)|work\s+start)\b/;
849
- const lifecycle = segments.filter((segment) => lifecyclePattern.test(segment));
850
- if (!lifecycle.length)
681
+ const analysis = parseLifecycleCommand(command);
682
+ if (analysis.kind === "none" || (!includeSessionRegister && analysis.invocation.operation === "session_register"))
851
683
  return { kind: "none" };
852
- const standaloneCommand = lifecycle[0];
853
- return compound || lifecycle.length !== 1
854
- ? { kind: "compound", standaloneCommand }
855
- : { kind: "standalone", standaloneCommand };
684
+ return analysis.kind === "compound"
685
+ ? { kind: "compound", standaloneCommand: analysis.invocation.command }
686
+ : { kind: "standalone", standaloneCommand: command.trim() };
687
+ }
688
+ function lifecycleFacts(command) {
689
+ const analysis = parseLifecycleCommand(command);
690
+ if (analysis.kind === "none")
691
+ return null;
692
+ const invocation = analysis.invocation;
693
+ const stageStart = invocation.operation === "stage_start";
694
+ const stageResume = invocation.operation === "stage_resume";
695
+ const workStart = invocation.operation === "work_start";
696
+ return {
697
+ analysis,
698
+ invocation,
699
+ flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
700
+ stageStart,
701
+ stageResume,
702
+ workStart,
703
+ bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
704
+ commandProjectRoot: commandOption(invocation, "project-root"),
705
+ stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
706
+ runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
707
+ workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
708
+ contextSha256: commandOption(invocation, "context-sha256")
709
+ };
710
+ }
711
+ function lifecycleMatchKey(facts, projectRoot) {
712
+ const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
713
+ if (bootstrapStageStart)
714
+ return bootstrapMatchKey({
715
+ projectRoot,
716
+ subject: commandOption(invocation, "subject") ?? commandOption(invocation, "slug") ?? "specify",
717
+ intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
718
+ ...(contextSha256 ? { contextSha256 } : {})
719
+ });
720
+ if (stageStart && runId && stageName)
721
+ return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
722
+ if (stageResume && runId && stageName && workId)
723
+ return stageResumeMatchKey(runId, stageName, workId, projectRoot);
724
+ if (workStart && workId)
725
+ return workStartMatchKey(workId, projectRoot);
726
+ return null;
856
727
  }
857
728
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
858
729
  export function sessionIdForHookEvent(context, projectId, eventKey) {
@@ -956,7 +827,7 @@ export function assertStageStartHookEvent(context, input) {
956
827
  const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
957
828
  const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
958
829
  if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
959
- throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey });
830
+ throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
960
831
  }
961
832
  return hookSessionIdentity(context, input.projectId, input.eventKey);
962
833
  }
@@ -1435,27 +1306,6 @@ function projectForHook(context, explicitRoot, cwd) {
1435
1306
  })
1436
1307
  .sort((left, right) => right.root.length - left.root.length)[0];
1437
1308
  }
1438
- function optionFromCommand(command, name) {
1439
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1440
- const match = command.match(new RegExp(`(?:^|\\s)--${escaped}(?:=|\\s+)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
1441
- return match?.[1] ? shellArgument(match[1]) : undefined;
1442
- }
1443
- function positionalFromCommand(command, prefix) {
1444
- const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1445
- const match = command.match(new RegExp(`\\b${escaped}\\s+("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
1446
- return match?.[1] ? shellArgument(match[1]) : undefined;
1447
- }
1448
- function shellArgument(raw) {
1449
- if (raw.startsWith('"')) {
1450
- try {
1451
- return JSON.parse(raw);
1452
- }
1453
- catch {
1454
- return undefined;
1455
- }
1456
- }
1457
- return raw.startsWith("'") ? raw.slice(1, -1) : raw;
1458
- }
1459
1309
  function hookEventKey(payload, eventName, toolName, command) {
1460
1310
  const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
1461
1311
  if (explicit)
@@ -42,10 +42,27 @@ export function previewNextEntityId(context, input) {
42
42
  }
43
43
  /** Work ids are globally allocated because work_id is the database primary key. */
44
44
  export function nextWorkId(context, _projectId, slug) {
45
+ return nextWorkIds(context, _projectId, [slug])[0];
46
+ }
47
+ /** Allocate one collision-free operator id for each member of an atomic Work batch. */
48
+ export function nextWorkIds(context, _projectId, slugs) {
49
+ if (!slugs.length)
50
+ return [];
45
51
  const used = new Set();
46
52
  for (const row of context.db.all("SELECT work_id AS id FROM works WHERE work_id LIKE ?", ["WRK-%"]))
47
53
  addSequence(used, row.id, "WRK");
48
- return formatFullId("WRK", Math.max(0, ...used) + 1, normalizeSlug(slug));
54
+ const first = Math.max(0, ...used) + 1;
55
+ return slugs.map((slug, index) => formatFullId("WRK", first + index, normalizeSlug(slug)));
56
+ }
57
+ /** Merge requests are global runtime entities, just like Work. */
58
+ export function nextMergeRequestId(context) {
59
+ const used = new Set();
60
+ for (const row of context.db.all("SELECT merge_request_id AS id FROM merge_requests WHERE merge_request_id LIKE ?", ["MRG-%"])) {
61
+ const match = /^MRG-(\d+)$/.exec(row.id);
62
+ if (match)
63
+ used.add(Number(match[1]));
64
+ }
65
+ return `MRG-${String(Math.max(0, ...used) + 1).padStart(3, "0")}`;
49
66
  }
50
67
  function parseEntityKind(value) {
51
68
  const normalized = value.toLowerCase();