@deksden-com/dd-flow-cli 0.9.0-beta.52 → 0.9.0-beta.54
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 +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +13 -3
- package/dist/cli/run-cli.js +92 -5
- package/dist/harness-runtime/bin/dd-grok.mjs +3 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +13 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -19
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +20 -4
- package/dist/harness-runtime/lib/dd-zcode.mjs +101 -10
- package/dist/harness-runtime/lib/native-hook-command.mjs +33 -0
- package/dist/services/cli-operation-classifier.js +7 -1
- package/dist/services/eval-snapshots.js +1 -1
- package/dist/services/external-work-launch.js +5 -2
- package/dist/services/hooks.js +158 -113
- package/dist/services/lifecycle-command.js +63 -5
- package/dist/services/lifecycle-invocations.js +371 -0
- package/dist/services/merge-server.js +6 -4
- package/dist/services/recovery-snapshot-database.js +5 -1
- package/dist/services/run-controller-recovery.js +4 -1
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-recovery.js +11 -1
- package/dist/services/stage-pause.js +19 -7
- package/dist/services/vnext-code-review.js +5 -4
- package/dist/services/vnext-code.js +3 -2
- package/dist/services/vnext-merge.js +5 -4
- package/dist/services/vnext-plan-review.js +8 -7
- package/dist/services/vnext-plan.js +3 -2
- package/dist/services/vnext-protocolize.js +5 -4
- package/dist/services/vnext-specify.js +8 -5
- package/dist/services/work-registry.js +6 -3
- package/package.json +1 -1
|
@@ -104,6 +104,8 @@ function isRouterNative(args) {
|
|
|
104
104
|
if (first === "storage" && args[1] === "migrate-writer")
|
|
105
105
|
return true;
|
|
106
106
|
return (first === "codex" && args[1] === "hook" && args[2] === "handle")
|
|
107
|
+
|| (first === "zcode" && args[1] === "hook" && args[2] === "handle")
|
|
108
|
+
|| (first === "zcode" && args[1] === "invocation" && args[2] === "prepare")
|
|
107
109
|
|| (first === "zcode" && args[1] === "event" && args[2] === "handle")
|
|
108
110
|
|| (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
|
|
109
111
|
|| (first === "grok" && args[1] === "event" && args[2] === "handle")
|
|
@@ -129,7 +131,11 @@ function isReadOnlyDiagnostic(family, command, action) {
|
|
|
129
131
|
if (family === "plan")
|
|
130
132
|
return command === "status";
|
|
131
133
|
if (family === "run")
|
|
132
|
-
return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
134
|
+
return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
135
|
+
if (family === "work")
|
|
136
|
+
return ["status", "ls", "show"].includes(command ?? "");
|
|
137
|
+
if (family === "stage")
|
|
138
|
+
return command === "fanout" && action === "status";
|
|
133
139
|
if (family === "lane") {
|
|
134
140
|
return (command === "status" ||
|
|
135
141
|
command === "waiters" ||
|
|
@@ -391,7 +391,7 @@ function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
|
|
|
391
391
|
throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
|
|
392
392
|
if (context.db.get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
|
|
393
393
|
throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
|
|
394
|
-
const detachedTables = ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
|
|
394
|
+
const detachedTables = ["lifecycle_invocations", "run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
|
|
395
395
|
const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
|
|
396
396
|
const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
397
397
|
const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
|
|
@@ -10,6 +10,7 @@ import { resolveExecutionPolicy, runtimeHarness, validateExecutionOverride } fro
|
|
|
10
10
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
11
11
|
import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
|
|
12
12
|
import { initCodexHome } from "./hooks.js";
|
|
13
|
+
import { managedInvocationContext } from "./lifecycle-invocations.js";
|
|
13
14
|
/**
|
|
14
15
|
* Creates an external provider root for one already-created Work. The launch
|
|
15
16
|
* is not a Work claim: only the worker's trusted `work start` hook binds it.
|
|
@@ -48,7 +49,7 @@ export async function launchExternalWork(context, input) {
|
|
|
48
49
|
fs.mkdirSync(root, { recursive: true });
|
|
49
50
|
const now = context.now();
|
|
50
51
|
const flowExecutable = prepareHarnessFlowExecutable(context, root);
|
|
51
|
-
const env = { ...context.env, DD_FLOW_BIN: flowExecutable };
|
|
52
|
+
const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined };
|
|
52
53
|
// An external Codex root is a fresh thread, so it does not inherit the
|
|
53
54
|
// coordinator's managed PreToolUse hook. Its first `work start` must emit
|
|
54
55
|
// that hook event to bind the Work to the physical session.
|
|
@@ -57,7 +58,7 @@ export async function launchExternalWork(context, input) {
|
|
|
57
58
|
initCodexHome(context, { projectRoot: run.project_root, profile: `external-${operationId}`, targetHome: codexHome, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
|
|
58
59
|
env.CODEX_HOME = codexHome;
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
let runtimeContext = { ...context, env };
|
|
61
62
|
const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${work.run_id}:work:${work.work_id}`, role: "worker", project_id: work.project_id, run_id: work.run_id, work_id: work.work_id, operation_id: operationId, state_dir: root, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, work.project_id, work.run_id) };
|
|
62
63
|
const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
|
|
63
64
|
context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner } }), resolved.profile_hash, root, journal, now, now]);
|
|
@@ -73,6 +74,8 @@ export async function launchExternalWork(context, input) {
|
|
|
73
74
|
throw new AppError("harness_adapter_invalid", "Harness adapter did not return a typed Session id", 1, { work_id: work.work_id, harness: runtimeHarness(resolved.worker) });
|
|
74
75
|
// Retain the physical root before any subsequent guard or prompt can fail.
|
|
75
76
|
context.db.run("UPDATE work_launches SET provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created }), context.now(), operationId]);
|
|
77
|
+
runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(resolved.worker), stateDir: root, sessionId,
|
|
78
|
+
projectRoot: run.project_root, runId: work.run_id, generation });
|
|
76
79
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
77
80
|
assertWorkLaunchReady(context, work.work_id);
|
|
78
81
|
const promptFile = path.join(root, "launch.md");
|
package/dist/services/hooks.js
CHANGED
|
@@ -9,7 +9,8 @@ import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
11
11
|
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
12
|
-
import {
|
|
12
|
+
import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
13
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
|
|
13
14
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
14
15
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
15
16
|
const defaultProfileName = "default";
|
|
@@ -286,7 +287,7 @@ export function handleCodexHook(context, input) {
|
|
|
286
287
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
287
288
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
288
289
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
289
|
-
if (lifecycle.analysis.kind === "compound"
|
|
290
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
290
291
|
return {
|
|
291
292
|
ok: false,
|
|
292
293
|
observed: false,
|
|
@@ -409,118 +410,112 @@ function codexNativeParentId(payload, childId) {
|
|
|
409
410
|
return null;
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
|
-
/**
|
|
413
|
+
/** ACP is asynchronous evidence. Only an explicitly issued invocation may use
|
|
414
|
+
* it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
|
|
413
415
|
export function handleZcodeEvent(context, input) {
|
|
414
416
|
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
415
|
-
|
|
417
|
+
const params = objectRecord(notification.params), update = objectRecord(params.update);
|
|
418
|
+
if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
|
|
416
419
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
if (
|
|
420
|
-
return { ok: true, observed: false, reason: "
|
|
421
|
-
const updateMeta = objectRecord(update._meta);
|
|
422
|
-
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
423
|
-
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
424
|
-
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
425
|
-
const rawInput = objectRecord(update.rawInput);
|
|
420
|
+
const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
|
|
421
|
+
const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
|
|
422
|
+
if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
|
|
423
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
426
424
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
427
|
-
|
|
425
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
426
|
+
if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
|
|
427
|
+
return { ok: true, observed: false, reason: "acp_evidence_only" };
|
|
428
|
+
const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
|
|
429
|
+
const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
|
|
430
|
+
return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
|
|
431
|
+
session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
|
|
432
|
+
agent_id: native.agentId, _meta: { ddZcode: controlled }
|
|
433
|
+
}) }, commandOption(parsed.invocation, "invocation-id"));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Accept the synchronous native ZCode PreToolUse callback. ACP notifications
|
|
437
|
+
* are retained as evidence, but cannot be an admission signal: they arrive
|
|
438
|
+
* after the tool call and may be serialized behind unrelated work.
|
|
439
|
+
*/
|
|
440
|
+
export function handleZcodeHook(context, input) {
|
|
441
|
+
return recordZcodeLifecycle(context, input);
|
|
442
|
+
}
|
|
443
|
+
function recordZcodeLifecycle(context, input, invocationId) {
|
|
444
|
+
const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
|
|
445
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
446
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
|
|
447
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
448
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
449
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
450
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
|
|
451
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
428
452
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
429
|
-
}
|
|
430
453
|
const lifecycle = lifecycleFacts(command);
|
|
431
454
|
if (!lifecycle)
|
|
432
455
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
433
|
-
if (lifecycle.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
});
|
|
456
|
+
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
457
|
+
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
458
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
459
|
+
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
|
|
437
460
|
}
|
|
438
|
-
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
439
461
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
440
|
-
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
441
|
-
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
|
|
442
|
-
expected: expectedRoot,
|
|
443
|
-
actual: commandProjectRoot
|
|
444
|
-
});
|
|
462
|
+
if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
|
|
463
|
+
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
|
|
445
464
|
}
|
|
446
|
-
const
|
|
447
|
-
|
|
465
|
+
const meta = objectRecord(hook._meta);
|
|
466
|
+
const ddZcode = objectRecord(meta.ddZcode);
|
|
467
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
468
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
|
|
469
|
+
const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
|
|
470
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
471
|
+
if (!providerSessionId || !rootProviderSessionId || !daemonId) {
|
|
472
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
|
|
473
|
+
}
|
|
474
|
+
if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
|
|
475
|
+
throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
|
|
476
|
+
}
|
|
477
|
+
const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
|
|
478
|
+
if (!toolUseId)
|
|
479
|
+
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
480
|
+
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
481
|
+
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
482
|
+
if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
|
|
483
|
+
throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
|
|
484
|
+
if (lifecycle.bootstrapStageStart)
|
|
448
485
|
registerProject(context, { root: expectedRoot });
|
|
449
486
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
450
|
-
const envelopeMeta = objectRecord(notification._meta);
|
|
451
|
-
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
452
|
-
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
453
|
-
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
454
|
-
const daemonId = stringValue(ddZcode.daemonId);
|
|
455
|
-
if (!rootProviderSessionId)
|
|
456
|
-
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
457
|
-
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
458
|
-
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
459
487
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
460
|
-
const parentSessionId =
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
mode: stringValue(observedProfile.mode),
|
|
484
|
-
agent_type: stringValue(runtime.source)
|
|
488
|
+
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
489
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
490
|
+
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
491
|
+
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
492
|
+
const persist = () => {
|
|
493
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
494
|
+
const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
|
|
495
|
+
...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
|
|
496
|
+
parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
|
|
497
|
+
model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
|
|
498
|
+
mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
|
|
499
|
+
}, sessionId) : undefined;
|
|
500
|
+
const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
501
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
|
|
502
|
+
const inserted = recordHookEvent(context, {
|
|
503
|
+
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "zcode-acp", providerSessionId, parentSessionId,
|
|
504
|
+
daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
|
|
505
|
+
eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
|
|
506
|
+
transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
|
|
507
|
+
});
|
|
508
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
|
|
509
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
|
|
510
|
+
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
485
511
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
parentSessionId,
|
|
492
|
-
daemonId: daemonId ?? null,
|
|
493
|
-
sessionId,
|
|
494
|
-
agentId: agentId ?? null,
|
|
495
|
-
turnId: null,
|
|
496
|
-
eventName: "PreToolUse",
|
|
497
|
-
toolName: toolName ?? "Bash",
|
|
498
|
-
status: "observed",
|
|
499
|
-
payload,
|
|
500
|
-
eventKey,
|
|
501
|
-
matchKey,
|
|
502
|
-
transcriptPath: null,
|
|
503
|
-
cwd: expectedRoot
|
|
512
|
+
if (!invocationId)
|
|
513
|
+
return persist();
|
|
514
|
+
const receipt = observeLifecycleInvocation(context, {
|
|
515
|
+
id: invocationId, command, identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
|
|
516
|
+
recordReceipt: () => { persist(); return eventKey; }
|
|
504
517
|
});
|
|
505
|
-
|
|
506
|
-
projectId: project.id,
|
|
507
|
-
sessionId,
|
|
508
|
-
runId: observedSession?.run_id ?? null,
|
|
509
|
-
protocolId: observedSession?.protocol_id ?? null,
|
|
510
|
-
cwd: expectedRoot,
|
|
511
|
-
toolName: toolName ?? "Bash",
|
|
512
|
-
eventKey
|
|
513
|
-
});
|
|
514
|
-
return {
|
|
515
|
-
ok: true,
|
|
516
|
-
observed: inserted,
|
|
517
|
-
duplicate: !inserted,
|
|
518
|
-
event_key: eventKey,
|
|
519
|
-
harness: "zcode-acp",
|
|
520
|
-
session: nativeSessionIdentity("zcode-acp", providerSessionId),
|
|
521
|
-
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
522
|
-
daemon_id: daemonId ?? null
|
|
523
|
-
};
|
|
518
|
+
return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: invocationId };
|
|
524
519
|
}
|
|
525
520
|
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
526
521
|
export function handleGrokEvent(context, input) {
|
|
@@ -538,7 +533,7 @@ export function handleGrokEvent(context, input) {
|
|
|
538
533
|
const lifecycle = lifecycleFacts(command);
|
|
539
534
|
if (!lifecycle)
|
|
540
535
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
541
|
-
if (lifecycle.analysis.kind === "compound"
|
|
536
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
542
537
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
543
538
|
standalone_command: lifecycle.invocation.command
|
|
544
539
|
});
|
|
@@ -653,7 +648,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
653
648
|
const lifecycle = lifecycleFacts(command);
|
|
654
649
|
if (!lifecycle)
|
|
655
650
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
656
|
-
if (lifecycle.analysis.kind === "compound"
|
|
651
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
657
652
|
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
658
653
|
}
|
|
659
654
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
@@ -741,11 +736,58 @@ function lifecycleCommandFromPayload(payload) {
|
|
|
741
736
|
const command = commandFromPayload(payload);
|
|
742
737
|
return command ? unwrapShellCommand(command) : undefined;
|
|
743
738
|
}
|
|
744
|
-
|
|
739
|
+
/**
|
|
740
|
+
* Adds the receipt to the command that the shell will execute. Heredoc input
|
|
741
|
+
* belongs to that command too, but an option appended after its delimiter is a
|
|
742
|
+
* second shell command rather than an argument, so insert it before `<<`.
|
|
743
|
+
*/
|
|
744
|
+
export function commandWithHookEvent(command, eventKey) {
|
|
745
745
|
const analysis = parseLifecycleCommand(command);
|
|
746
|
-
if (analysis.kind !== "standalone"
|
|
746
|
+
if (analysis.kind !== "standalone")
|
|
747
|
+
return command;
|
|
748
|
+
if (commandHasOption(analysis.invocation, "hook-event-id")) {
|
|
749
|
+
if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
|
|
750
|
+
throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
|
|
747
751
|
return command;
|
|
748
|
-
|
|
752
|
+
}
|
|
753
|
+
const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
|
|
754
|
+
if (analysis.invocation.rewriteSafe) {
|
|
755
|
+
const index = shellSuffixOffset(command);
|
|
756
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
|
|
757
|
+
}
|
|
758
|
+
if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
|
|
759
|
+
const index = firstHeredoc(command);
|
|
760
|
+
if (index >= 0)
|
|
761
|
+
return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
|
|
762
|
+
}
|
|
763
|
+
return command;
|
|
764
|
+
}
|
|
765
|
+
function firstHeredoc(command) {
|
|
766
|
+
let quoteState = null;
|
|
767
|
+
let escaped = false;
|
|
768
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
769
|
+
const char = command[index];
|
|
770
|
+
if (escaped) {
|
|
771
|
+
escaped = false;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (char === "\\" && quoteState !== "'") {
|
|
775
|
+
escaped = true;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (quoteState) {
|
|
779
|
+
if (char === quoteState)
|
|
780
|
+
quoteState = null;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (char === "'" || char === '"') {
|
|
784
|
+
quoteState = char;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
|
|
788
|
+
return index;
|
|
789
|
+
}
|
|
790
|
+
return -1;
|
|
749
791
|
}
|
|
750
792
|
/**
|
|
751
793
|
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
@@ -794,8 +836,11 @@ function lifecycleFacts(command) {
|
|
|
794
836
|
// not a second authority. Everything else remains standalone-only.
|
|
795
837
|
function allowsStdinLifecycleCompound(facts) {
|
|
796
838
|
const operation = facts.invocation.operation;
|
|
797
|
-
return
|
|
798
|
-
|
|
839
|
+
return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
|
|
840
|
+
&& ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
841
|
+
|| (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
|
|
842
|
+
|| (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
|
|
843
|
+
|| (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
|
|
799
844
|
}
|
|
800
845
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
801
846
|
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
@@ -920,17 +965,19 @@ export function findRecentMatchingHookEvent(context, input) {
|
|
|
920
965
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
921
966
|
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
922
967
|
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
923
|
-
|
|
968
|
+
AND harness IN ('codex-desktop', 'antigravity-cli')
|
|
969
|
+
AND (? IS NULL OR session_id = ?)
|
|
970
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
|
|
924
971
|
let events = find();
|
|
925
|
-
//
|
|
926
|
-
//
|
|
972
|
+
// Only the qualified non-rewriting transports may correlate the original
|
|
973
|
+
// command: Codex Desktop and Antigravity. New harnesses fail closed by default.
|
|
927
974
|
const deadline = Date.now() + 250;
|
|
928
975
|
while (events.length === 0 && Date.now() < deadline) {
|
|
929
976
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
930
977
|
events = find();
|
|
931
978
|
}
|
|
932
979
|
if (events.length === 0 || !events[0]?.event_key) {
|
|
933
|
-
throw new AppError(input.errorCode, `${input.operation} requires
|
|
980
|
+
throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery and updatedInput; managed ZCode requires its issued --invocation-id.`, 1);
|
|
934
981
|
}
|
|
935
982
|
if (events.length > 1) {
|
|
936
983
|
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) });
|
|
@@ -1009,10 +1056,8 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
|
1009
1056
|
/** Claims the resume command observed in the Session that received the user answer. */
|
|
1010
1057
|
export function claimStageResumeHookEvent(context, input) {
|
|
1011
1058
|
const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
|
|
1012
|
-
const
|
|
1013
|
-
const event = input.eventKey
|
|
1014
|
-
? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
|
|
1015
|
-
: context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
|
|
1059
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
|
|
1060
|
+
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]);
|
|
1016
1061
|
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
1017
1062
|
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 });
|
|
1018
1063
|
}
|
|
@@ -32,7 +32,7 @@ function parseLifecycleCommandInner(command, wrapped) {
|
|
|
32
32
|
const heredoc = stripHeredocBodies(command);
|
|
33
33
|
let entries;
|
|
34
34
|
try {
|
|
35
|
-
entries = parse(heredoc.command, (name) => `$${name}`);
|
|
35
|
+
entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
|
|
36
36
|
}
|
|
37
37
|
catch {
|
|
38
38
|
// A command that cannot be tokenized cannot create a trusted claim. The
|
|
@@ -56,11 +56,11 @@ function parseLifecycleCommandInner(command, wrapped) {
|
|
|
56
56
|
if (!found.length)
|
|
57
57
|
return { kind: "none" };
|
|
58
58
|
const first = found[0];
|
|
59
|
-
if (found.length !== 1 || composed || first.nestedCompound) {
|
|
59
|
+
if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
|
|
60
60
|
return {
|
|
61
61
|
kind: "compound",
|
|
62
62
|
invocation: { ...first.invocation, rewriteSafe: false },
|
|
63
|
-
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : "shell_composition"
|
|
63
|
+
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
return { kind: "standalone", invocation: first.invocation };
|
|
@@ -170,6 +170,9 @@ function wrappedInvocationFromSegment(segment) {
|
|
|
170
170
|
return parseLifecycleCommandInner(payload, true);
|
|
171
171
|
}
|
|
172
172
|
function lifecycleOperation(argv) {
|
|
173
|
+
// The CLI handles help before dispatch, wherever the flag occurs.
|
|
174
|
+
if (argv.some(arg => arg === "--help" || arg === "-h"))
|
|
175
|
+
return null;
|
|
173
176
|
if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
|
|
174
177
|
return "recovery_accept";
|
|
175
178
|
const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
|
|
@@ -223,6 +226,58 @@ export function commandPosition(invocation, index) {
|
|
|
223
226
|
function assignment(word) {
|
|
224
227
|
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
225
228
|
}
|
|
229
|
+
/** Source offsets matter: shell-quote loses the difference between `2>&1`
|
|
230
|
+
* and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
|
|
231
|
+
function shellSyntaxOffsets(command) {
|
|
232
|
+
const offsets = [];
|
|
233
|
+
let quoted = null, escaped = false;
|
|
234
|
+
for (let i = 0; i < command.length; i++) {
|
|
235
|
+
const char = command[i];
|
|
236
|
+
if (escaped) {
|
|
237
|
+
escaped = false;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (char === "\\" && quoted !== "'") {
|
|
241
|
+
escaped = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (quoted) {
|
|
245
|
+
if (char === quoted)
|
|
246
|
+
quoted = null;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (char === "'" || char === '"') {
|
|
250
|
+
quoted = char;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
|
|
254
|
+
offsets.push(i);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
if (char === "<" || char === ">")
|
|
258
|
+
offsets.push(i);
|
|
259
|
+
}
|
|
260
|
+
return offsets;
|
|
261
|
+
}
|
|
262
|
+
function descriptorStart(command, index) {
|
|
263
|
+
let start = index;
|
|
264
|
+
while (start > 0 && /[0-9]/.test(command[start - 1]))
|
|
265
|
+
start--;
|
|
266
|
+
return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
|
|
267
|
+
}
|
|
268
|
+
export function shellSuffixOffset(command) {
|
|
269
|
+
const offset = shellSyntaxOffsets(command)[0];
|
|
270
|
+
return offset === undefined ? command.length : descriptorStart(command, offset);
|
|
271
|
+
}
|
|
272
|
+
function withoutFileDescriptors(command) {
|
|
273
|
+
for (const index of shellSyntaxOffsets(command).reverse()) {
|
|
274
|
+
if (command[index] === "#")
|
|
275
|
+
continue;
|
|
276
|
+
const start = descriptorStart(command, index);
|
|
277
|
+
command = command.slice(0, start) + command.slice(index);
|
|
278
|
+
}
|
|
279
|
+
return command;
|
|
280
|
+
}
|
|
226
281
|
function addAssignment(env, word) {
|
|
227
282
|
const separator = word.indexOf("=");
|
|
228
283
|
env[word.slice(0, separator)] = word.slice(separator + 1);
|
|
@@ -233,6 +288,7 @@ function stripHeredocBodies(command) {
|
|
|
233
288
|
const pending = [];
|
|
234
289
|
let active;
|
|
235
290
|
let present = false;
|
|
291
|
+
let count = 0, quoted = true;
|
|
236
292
|
for (const line of lines) {
|
|
237
293
|
if (active) {
|
|
238
294
|
const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
@@ -243,13 +299,15 @@ function stripHeredocBodies(command) {
|
|
|
243
299
|
if (line.trim())
|
|
244
300
|
output.push(line);
|
|
245
301
|
const found = heredocDelimiters(line);
|
|
302
|
+
count += found.length;
|
|
303
|
+
quoted &&= found.every(item => item.quoted);
|
|
246
304
|
if (found.length) {
|
|
247
305
|
present = true;
|
|
248
306
|
active = found[0];
|
|
249
307
|
pending.push(...found.slice(1));
|
|
250
308
|
}
|
|
251
309
|
}
|
|
252
|
-
return { command: output.join(" ; "), present };
|
|
310
|
+
return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
|
|
253
311
|
}
|
|
254
312
|
function heredocDelimiters(line) {
|
|
255
313
|
const result = [];
|
|
@@ -292,7 +350,7 @@ function heredocDelimiters(line) {
|
|
|
292
350
|
index += 1;
|
|
293
351
|
const delimiter = line.slice(start, index);
|
|
294
352
|
if (delimiter)
|
|
295
|
-
result.push({ delimiter, stripTabs });
|
|
353
|
+
result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
|
|
296
354
|
}
|
|
297
355
|
return result;
|
|
298
356
|
}
|