@deksden-com/dd-flow-cli 0.9.0-beta.80 → 0.9.0-beta.81

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.81
4
+
5
+ ### Patch Changes
6
+
7
+ - a48e3de: Canonicalize short and full RUN/Work references across native hook receipts, lifecycle admission, Work settlement, and HITL pause/resume. Keep model-facing commands short while preserving scoped ownership checks and report precise receipt mismatch reasons.
8
+
3
9
  ## 0.9.0-beta.75
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.80",
4
- "cli_commit": "258058b332954cc3760d11838a3a8a55de4bb0dd",
5
- "built_at": "2026-09-19T22:32:15.901Z",
3
+ "cli_version": "0.9.0-beta.81",
4
+ "cli_commit": "0e1bd85b82868a501bc9e368e103fe288445370a",
5
+ "built_at": "2026-09-20T10:24:22.045Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
@@ -9,6 +9,7 @@ import { createContext, createRouterContext } from "../runtime/context.js";
9
9
  import { migrateStoreWriter } from "../storage/writer-migration.js";
10
10
  import { helpForArgs } from "./help.js";
11
11
  import { AppError, isAppError } from "../shared/errors.js";
12
+ import { entityReferenceVariants } from "../shared/entity-references.js";
12
13
  import { writeJson } from "../shared/json.js";
13
14
  import { archiveProject, prepareProjectArchive, getProjectStatus, migrateProjectIds, registerProject, resolveProject } from "../services/projects.js";
14
15
  import { cancelProtocol, getProtocolBlockers, getProtocolStatus, getReadyProtocols, implementProtocol, readyForMerge, registerProtocol, prepareProtocolRegistration, requireProtocol, syncProtocolFromRun, prepareProtocolTransition, transitionProtocol } from "../services/protocols.js";
@@ -1561,7 +1562,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1561
1562
  registerProject(context, { root: projectRoot });
1562
1563
  return findRecentMatchingHookEvent(context, {
1563
1564
  projectId: requireProjectByRoot(context, resolveProjectRoot(projectRoot)).id,
1564
- matchKey: stageStartMatchKey(runId, stage, projectRoot, contextSha256),
1565
+ matchKey: entityReferenceVariants(runId).map((reference) => stageStartMatchKey(reference, stage, projectRoot, contextSha256)),
1565
1566
  errorCode: "trusted_session_binding_required",
1566
1567
  operation: `vNext ${stage.toUpperCase()} start`
1567
1568
  }).eventKey;
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { parse, stringify, TomlError } from "smol-toml";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants } from "../shared/entity-references.js";
7
8
  import { parseJsonObject } from "../shared/json.js";
8
9
  import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
10
  import { appendAudit } from "./audit.js";
@@ -939,16 +940,25 @@ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
939
940
  export function workLifecycleMatchKey(operation, workId, projectRoot) {
940
941
  return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
941
942
  }
943
+ function workLifecycleMatchKeys(operation, workId, projectRoot) {
944
+ return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
945
+ }
946
+ function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
947
+ const runReferences = entityReferenceVariants(runId);
948
+ const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
949
+ return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
950
+ }
942
951
  /** Claims a Work terminal command only after its exact hook receipt has proved
943
952
  * the physical Session that issued it. Work code performs the owner check. */
944
953
  export function claimWorkLifecycleHookEvent(context, input) {
945
- const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
954
+ const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
946
955
  const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
947
- projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
956
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
948
957
  }).eventKey;
949
958
  const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
950
- if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
951
- throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
959
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
960
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
961
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
952
962
  }
953
963
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
954
964
  if (claimed.changes !== 1)
@@ -958,13 +968,14 @@ export function claimWorkLifecycleHookEvent(context, input) {
958
968
  /** Claims a lifecycle command only after its exact hook receipt has proved the
959
969
  * physical Session that issued it. Stage code performs the Work-owner check. */
960
970
  export function claimStageLifecycleHookEvent(context, input) {
961
- const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
971
+ const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
962
972
  const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
963
- projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
973
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
964
974
  }).eventKey;
965
975
  const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
966
- if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
967
- throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
976
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
977
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
978
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
968
979
  }
969
980
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
970
981
  if (claimed.changes !== 1)
@@ -1023,11 +1034,15 @@ export function claimRecentMatchingHookEvent(context, input) {
1023
1034
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
1024
1035
  export function findRecentMatchingHookEvent(context, input) {
1025
1036
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
1037
+ const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
1038
+ const placeholders = matchKeys.map(() => "?").join(", ");
1026
1039
  const find = () => context.db.all(`SELECT id, event_key FROM hook_events
1027
- WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
1040
+ WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
1041
+ AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
1042
+ AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
1028
1043
  AND harness IN ('codex-desktop', 'antigravity-cli')
1029
1044
  AND (? IS NULL OR session_id = ?)
1030
- ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
1045
+ ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
1031
1046
  let events = find();
1032
1047
  // Only the qualified non-rewriting transports may correlate the original
1033
1048
  // command: Codex Desktop and Antigravity. New harnesses fail closed by default.
@@ -1037,10 +1052,10 @@ export function findRecentMatchingHookEvent(context, input) {
1037
1052
  events = find();
1038
1053
  }
1039
1054
  if (events.length === 0 || !events[0]?.event_key) {
1040
- throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1);
1055
+ throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
1041
1056
  }
1042
1057
  if (events.length > 1) {
1043
- throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
1058
+ throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
1044
1059
  }
1045
1060
  return { id: events[0].id, eventKey: events[0].event_key };
1046
1061
  }
@@ -1065,9 +1080,9 @@ export function bootstrapMatchKey(input) {
1065
1080
  })).digest("hex");
1066
1081
  }
1067
1082
  export function claimWorkStartHookEvent(context, input) {
1068
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
1083
+ const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
1069
1084
  const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1070
- if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
1085
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
1071
1086
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
1072
1087
  }
1073
1088
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
@@ -1075,13 +1090,12 @@ export function claimWorkStartHookEvent(context, input) {
1075
1090
  throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
1076
1091
  return hookSessionIdentity(context, input.projectId, input.eventKey);
1077
1092
  }
1078
- function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
1079
1093
  /** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
1080
1094
  export function assertStageStartHookEvent(context, input) {
1081
- const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
1095
+ const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
1082
1096
  const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1083
- if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
1084
- 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 });
1097
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
1098
+ 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_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
1085
1099
  }
1086
1100
  const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
1087
1101
  assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
@@ -1115,10 +1129,10 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
1115
1129
  }
1116
1130
  /** Claims the resume command observed in the Session that received the user answer. */
1117
1131
  export function claimStageResumeHookEvent(context, input) {
1118
- const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
1119
- const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1132
+ const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
1133
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1120
1134
  const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1121
- if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
1135
+ if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
1122
1136
  throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
1123
1137
  }
1124
1138
  const identity = hookSessionIdentity(context, input.projectId, event.event_key);
@@ -4,6 +4,8 @@ import fs from "node:fs";
4
4
  import { quote } from "shell-quote";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { AppError, errorRecord } from "../shared/errors.js";
7
+ import { shortEntityReference } from "../shared/entity-references.js";
8
+ import { resolveWorkReference } from "../storage/work-references.js";
7
9
  import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
8
10
  import { requireProjectByRoot } from "./projects.js";
9
11
  import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
@@ -73,10 +75,9 @@ function normalizedInvocationValue(key, value) {
73
75
  if (runRelative)
74
76
  return `@run/${runRelative}`;
75
77
  }
76
- const entity = /^(PRJ|RUN|WRK)-\d{3,}(?:-|$)/.exec(value)?.[0]?.replace(/-$/, "");
77
- if (entity)
78
- return entity;
79
- return /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value;
78
+ if (["positional", "work", "run"].includes(key))
79
+ return shortEntityReference(value);
80
+ return key === "retry-check" ? /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value : value;
80
81
  }
81
82
  function withoutInvocationId(command, aliases) {
82
83
  const parsed = invocation(command);
@@ -88,6 +89,8 @@ function withoutInvocationId(command, aliases) {
88
89
  const option = argv[index - 1]?.replace(/^--/, "");
89
90
  if (option === "project-root")
90
91
  argv[index] = "@project";
92
+ else if (option === "work" || option === "run")
93
+ argv[index] = shortEntityReference(argv[index]);
91
94
  else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
92
95
  for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
93
96
  if (!root)
@@ -526,10 +529,14 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
526
529
  let workId;
527
530
  if (parsed.operation.startsWith("work_")) {
528
531
  const target = parsed.args.positional[0] ?? "";
529
- const works = context.db.all("SELECT work_id, run_id FROM works WHERE project_id = ? AND (work_id = ? OR (? = 1 AND work_id LIKE ?))", [project.id, target, /^WRK-\d{3,}$/.test(target) ? 1 : 0, `${target}-%`]);
530
- if (works.length !== 1 || works[0].run_id !== scope.runId)
531
- throw new AppError("invocation_scope_mismatch", "Work does not belong to the issued RUN scope", 1);
532
- workId = works[0].work_id;
532
+ try {
533
+ workId = resolveWorkReference(context, target, { projectId: project.id, runId: scope.runId }).work_id;
534
+ }
535
+ catch (error) {
536
+ if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
537
+ throw error;
538
+ throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
539
+ }
533
540
  }
534
541
  else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
535
542
  const target = parsed.args.positional[0];
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
+ import { entityReferenceVariants } from "../shared/entity-references.js";
6
7
  import { resolveProjectRoot } from "../storage/paths.js";
7
8
  import { requireProjectByRoot } from "./projects.js";
8
9
  import { appendFlowRunTimelineEvent, setFlowRunRecoveryPaused } from "./runs.js";
@@ -271,7 +272,7 @@ export function resumeRunRecovery(context, input, prepared = prepareRunRecoveryR
271
272
  export function acceptRunRecovery(context, input) {
272
273
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
273
274
  const run = requireRecoveryRun(context, project.id, input.runId);
274
- const matchKey = recoveryAcceptMatchKey(run.id, input.recoveryId, run.project_root);
275
+ const matchKey = entityReferenceVariants(run.id).map((reference) => recoveryAcceptMatchKey(reference, input.recoveryId, run.project_root));
275
276
  const eventKey = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_recovery_acceptance_required", operation: "run recovery accept" }).eventKey;
276
277
  const identity = hookSessionIdentity(context, project.id, eventKey);
277
278
  const now = context.now();
@@ -281,7 +282,7 @@ export function acceptRunRecovery(context, input) {
281
282
  const guard = recoveryGuard(context, project.id, run.id);
282
283
  const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
283
284
  const hook = context.db.get("SELECT status, match_key FROM hook_events WHERE id = ?", [identity.hookEventId]);
284
- if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || hook?.match_key !== matchKey)
285
+ if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || !hook?.match_key || !matchKey.includes(hook.match_key))
285
286
  throw new AppError("recovery_binding_mismatch", "Recovery acknowledgement does not belong to the authorized physical Session and generation", 1);
286
287
  packetReceipt = recoveryPacketReceipt(binding);
287
288
  if (binding.harness === "zcode-acp") {
@@ -4,7 +4,9 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
7
8
  import { resolveProjectRoot } from "../storage/paths.js";
9
+ import { resolveWorkReference } from "../storage/work-references.js";
8
10
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
9
11
  import { requireProjectByRoot } from "./projects.js";
10
12
  import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
@@ -96,7 +98,7 @@ export function resumeStageAfterUser(context, input) {
96
98
  const { projectRoot, project, run, work, stage, pause, workSession, evidence } = prepareStageResume(context, input);
97
99
  const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, {
98
100
  projectId: project.id,
99
- matchKey: stageResumeMatchKey(run.id, stage.stage, work.work_id, projectRoot),
101
+ matchKey: entityReferenceVariants(run.id).flatMap((runReference) => entityReferenceVariants(work.work_id).map((workReference) => stageResumeMatchKey(runReference, stage.stage, workReference, projectRoot))),
100
102
  errorCode: "trusted_stage_resume_required",
101
103
  operation: "stage resume"
102
104
  }).eventKey;
@@ -155,7 +157,7 @@ export function resumeStageAfterUser(context, input) {
155
157
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
156
158
  }
157
159
  export function stagePauseCommand(context, input) {
158
- return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
160
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
159
161
  }
160
162
  /**
161
163
  * The only shell form allowed for an agent-owned HITL pause. Supplying a
@@ -181,7 +183,7 @@ Effect on scope or acceptance:
181
183
  USER_QUESTION`;
182
184
  }
183
185
  function stageResumeCommand(context, input) {
184
- return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
186
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
185
187
  }
186
188
  export function flowCommand(context) {
187
189
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
@@ -207,10 +209,7 @@ function requireRun(context, projectId, id) {
207
209
  return rows[0];
208
210
  }
209
211
  function requireWork(context, projectId, runId, workId) {
210
- const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
211
- if (!work)
212
- throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
213
- return work;
212
+ return resolveWorkReference(context, workId, { projectId, runId });
214
213
  }
215
214
  function requireStage(run, stage, status) {
216
215
  let index;
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
+ import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
7
+ import { resolveWorkReference } from "../storage/work-references.js";
6
8
  import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
7
9
  import { validateSchema } from "./schema-validation.js";
8
10
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
@@ -41,14 +43,15 @@ export function assertStageLifecycleOwner(context, input) {
41
43
  if (!project)
42
44
  throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
43
45
  requireRun(context, project.id, input.runId);
46
+ const scopedWork = input.workId ? requireWork(context, input.workId, { projectId: project.id, runId: input.runId }) : undefined;
44
47
  let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
45
48
  FROM work_sessions ws
46
49
  JOIN works w ON w.work_id = ws.work_id
47
50
  JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
48
51
  WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
49
52
  AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
50
- ${input.workId ? "AND w.work_id = ?" : ""}
51
- ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
53
+ ${scopedWork ? "AND w.work_id = ?" : ""}
54
+ ORDER BY ws.created_at DESC LIMIT 1`, scopedWork ? [project.id, input.runId, input.stage, scopedWork.work_id] : [project.id, input.runId, input.stage]);
52
55
  if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
53
56
  const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
54
57
  const index = row ? JSON.parse(row.index_json) : null;
@@ -371,14 +374,14 @@ export function deleteWork(context, id) {
371
374
  prepareWorkDelete(context, id);
372
375
  return context.db.writeTransaction(() => { const work = prepareWorkDelete(context, id); context.db.run("DELETE FROM works WHERE work_id = ?", [work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: work.work_id }; });
373
376
  }
374
- export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
377
+ export function shortWorkId(id) { return shortEntityReference(id); }
375
378
  /**
376
379
  * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
377
380
  * Fan-out workers run in independently spawned harnesses where the adapter's
378
381
  * PATH is not inherited; using the shared lifecycle command keeps them on the
379
382
  * same captured runtime as their parent stage.
380
383
  */
381
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
384
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, `${flowCommand(context)} work start ${shortWorkId(work.work_id)} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
382
385
  export function assertWorkLaunchReady(context, id) {
383
386
  ensureWorkRegistry(context);
384
387
  const work = requireWork(context, id);
@@ -409,15 +412,7 @@ export function startWork(context, id, input) {
409
412
  const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
410
413
  if (input.recoveryId !== recoveryId)
411
414
  throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
412
- let hookEventId = input.hookEventId;
413
- if (!hookEventId) {
414
- try {
415
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
416
- }
417
- catch {
418
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
419
- }
420
- }
415
+ const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: entityReferenceVariants(work.work_id).map((reference) => workStartMatchKey(reference, run.project_root, recoveryId)), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
421
416
  const eventKey = hookEventId;
422
417
  const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
423
418
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
@@ -980,7 +975,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
980
975
  const command = flowCommand(context);
981
976
  const packet = codePacket(work);
982
977
  const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
983
- const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
978
+ const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${shortWorkId(work.work_id)} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
984
979
  const mergeWork = parsePayload(work)?.kind === "merge";
985
980
  const writeBoundary = mergeWork
986
981
  ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
@@ -1112,22 +1107,13 @@ function codeReviewGroup(work) {
1112
1107
  ? { key: value.key, aspect_ids: value.aspect_ids }
1113
1108
  : null;
1114
1109
  }
1115
- function requireWork(context, id) {
1110
+ function requireWork(context, id, explicitScope) {
1116
1111
  ensureWorkRegistry(context);
1117
1112
  const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
1118
1113
  const scope = configured ? JSON.parse(configured) : undefined;
1119
- const projectId = scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined;
1120
- const scoped = projectId && scope?.runId ? " AND project_id = ? AND run_id = ?" : "";
1121
- const params = projectId && scope?.runId ? [projectId, scope.runId] : [];
1122
- const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?${scoped}`, [id, ...params]);
1123
- if (exact)
1124
- return exact;
1125
- if (!/^WRK-\d{3,}$/.test(id))
1126
- throw new AppError("not_found", "Work is not registered in the current RUN", 1, { work_id: id });
1127
- const matches = context.db.all(`SELECT ${workColumns} FROM works WHERE work_id LIKE ?${scoped} ORDER BY work_id`, [`${id}-%`, ...params]);
1128
- if (matches.length !== 1)
1129
- throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous in the current RUN" : "Work is not registered in the current RUN", 1, { work_id: id, matches: matches.map((work) => work.work_id) });
1130
- return matches[0];
1114
+ const projectId = explicitScope?.projectId ?? (scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined);
1115
+ const runId = explicitScope?.runId ?? scope?.runId;
1116
+ return resolveWorkReference(context, id, { projectId, runId });
1131
1117
  }
1132
1118
  function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]); if (!run?.run_root)
1133
1119
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
@@ -0,0 +1,8 @@
1
+ /** Stable model-facing reference for persisted project, RUN and Work IDs. */
2
+ export function shortEntityReference(id) {
3
+ return /^(?:PRJ|RUN|WRK)-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id;
4
+ }
5
+ /** Accepted spellings of one already-resolved entity. Wrong full slugs stay distinct. */
6
+ export function entityReferenceVariants(id) {
7
+ return [...new Set([id, shortEntityReference(id)])];
8
+ }
@@ -0,0 +1,23 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ /** Read-only lookup; scope applies to exact IDs as well as short aliases. */
3
+ export function resolveWorkReference(context, id, scope = {}) {
4
+ const clauses = ["1 = 1"];
5
+ const params = [];
6
+ if (scope.projectId) {
7
+ clauses.push("project_id = ?");
8
+ params.push(scope.projectId);
9
+ }
10
+ if (scope.runId) {
11
+ clauses.push("run_id = ?");
12
+ params.push(scope.runId);
13
+ }
14
+ const where = clauses.join(" AND ");
15
+ const exact = context.db.get(`SELECT * FROM works WHERE ${where} AND work_id = ?`, [...params, id]);
16
+ if (exact)
17
+ return exact;
18
+ const matches = /^WRK-\d{3,}$/.test(id)
19
+ ? context.db.all(`SELECT * FROM works WHERE ${where} AND work_id LIKE ? ORDER BY work_id`, [...params, `${id}-%`]) : [];
20
+ if (matches.length !== 1)
21
+ throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous in the current RUN" : "Work is not registered in the current RUN", 1, { work_id: id, matches: matches.map(work => work.work_id) });
22
+ return matches[0];
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.80",
3
+ "version": "0.9.0-beta.81",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {