@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.
- package/CHANGELOG.md +16 -0
- package/README.md +30 -3
- package/dist/build-info.json +4 -4
- package/dist/cli/help.js +13 -61
- package/dist/cli/run-cli.js +55 -24
- package/dist/domain/stage-catalog.js +2 -2
- package/dist/schemas/agent-profile.schema.json +17 -0
- package/dist/schemas/code-review-decision.schema.json +5 -3
- package/dist/schemas/merge-result.schema.json +15 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/code-checks.js +125 -208
- package/dist/services/eval-snapshots.js +10 -1
- package/dist/services/harness-adapter.js +59 -0
- package/dist/services/hooks.js +87 -237
- package/dist/services/ids.js +18 -1
- package/dist/services/lifecycle-command.js +288 -0
- package/dist/services/merge-server.js +124 -0
- package/dist/services/runs.js +7 -2
- package/dist/services/sessions.js +18 -27
- package/dist/services/stage-pause.js +13 -0
- package/dist/services/vnext-code-review.js +70 -16
- package/dist/services/vnext-code.js +45 -18
- package/dist/services/vnext-execution-profile.js +6 -3
- package/dist/services/vnext-merge.js +330 -0
- package/dist/services/vnext-plan.js +13 -5
- package/dist/services/vnext-specify.js +5 -2
- package/dist/services/vnext-workspace-policy.js +8 -2
- package/dist/services/work-registry.js +35 -7
- package/dist/storage/database.js +66 -0
- package/package.json +2 -1
package/dist/services/hooks.js
CHANGED
|
@@ -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
|
|
276
|
-
|
|
277
|
-
|
|
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 (
|
|
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:
|
|
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: ${
|
|
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 =
|
|
295
|
-
const
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
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:
|
|
382
|
+
standalone_command: lifecycle.invocation.command
|
|
402
383
|
});
|
|
403
384
|
}
|
|
404
|
-
const flowPayload =
|
|
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
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (
|
|
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:
|
|
490
|
+
standalone_command: lifecycle.invocation.command
|
|
531
491
|
});
|
|
532
492
|
}
|
|
533
|
-
const flowPayload =
|
|
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
|
|
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
|
|
646
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
+
const analysis = parseLifecycleCommand(command);
|
|
671
|
+
if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
|
|
760
672
|
return command;
|
|
761
|
-
|
|
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
|
|
806
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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)
|
package/dist/services/ids.js
CHANGED
|
@@ -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
|
-
|
|
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();
|