@deksden-com/dd-flow-cli 0.9.0-beta.87 → 0.9.0-beta.89
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 +14 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/command-inputs.js +13 -10
- package/dist/cli/help.js +4 -0
- package/dist/cli/hook-ingress.js +2 -2
- package/dist/cli/run-cli.js +48 -15
- package/dist/services/codex-hook-delivery.js +3 -3
- package/dist/services/command-context.js +80 -0
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +4 -2
- package/dist/services/hooks.js +21 -15
- package/dist/services/lifecycle-command.js +27 -0
- package/dist/services/lifecycle-invocations.js +119 -38
- package/dist/services/recovery-snapshot-database.js +1 -1
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-controller.js +12 -3
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/stage-pause.js +7 -21
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/vnext-code-review.js +13 -11
- package/dist/services/vnext-code.js +39 -21
- package/dist/services/vnext-fanout.js +27 -6
- package/dist/services/vnext-merge.js +13 -10
- package/dist/services/vnext-plan-review.js +6 -5
- package/dist/services/work-registry.js +18 -9
- package/dist/storage/database.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.89
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Register aggregate CODE and CODE-REVIEW repairs atomically from all failed receipts, with stable replay identity and stage/attempt/cycle membership. Resolve declared contextual file paths independently of lifecycle authority, retain exact native command rejection causes, and emit shell-safe commands with explicit JSON recovery inputs. Keep historical Work out of current dispatch and block ambiguous legacy fanout execution.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.88
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5f3806c: Keep managed HITL answer paths inside retained runtime authority, preserve early
|
|
14
|
+
native CLI failures, and fail controllers conclusively when an answer turn does
|
|
15
|
+
not resume its assigned pause.
|
|
16
|
+
|
|
3
17
|
## 0.9.0-beta.87
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.89",
|
|
4
|
+
"cli_commit": "e36aabd9160805831809ca27575600fb6db47511",
|
|
5
|
+
"built_at": "2026-09-21T08:53:05.446Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "d1a6081ab15ab92ac917ff5d037121a40c709db1",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
|
@@ -11,6 +11,9 @@ const scoped = "project-root"; // Optional when the router resolves a unique ret
|
|
|
11
11
|
const lifecycle = "invocation-id hook-event-id";
|
|
12
12
|
const worker = "worker-id";
|
|
13
13
|
const control = "project-root run";
|
|
14
|
+
/** Path-shaped arguments default to physical paths unless the route explicitly
|
|
15
|
+
* opts into contextualPaths. This inventory does not expand arbitrary strings. */
|
|
16
|
+
export const physicalPathOptions = new Set(words("root project-root workspace-root workspace-path path dir file schema-dir output result-file semantic-file data decision-file verification-file reason-file task-file intake-file context-file observations-file observation-file summary-file question-file answer-file payload-file json-file task-profile-file execution-routing-file protocol-override-file manifest-file routing-file config-file input-file report-file snapshot-file output-dir state-dir capture-root capture-path destination source archive snapshot plan-file backup-path database backup source-home target-home stdout stderr adapter-executable memory-bank flow stage-report dashboard report"));
|
|
14
17
|
export const commandInputs = {
|
|
15
18
|
"version": route(0),
|
|
16
19
|
"status": route(0, "", "root project-root", "check-registry"),
|
|
@@ -40,19 +43,19 @@ export const commandInputs = {
|
|
|
40
43
|
"plan item block": route(2, "reason user-required", scoped),
|
|
41
44
|
"plan item skip": route(2, "reason", scoped),
|
|
42
45
|
"plan-review dispatch": route(1, "", scoped),
|
|
43
|
-
"prompt render": route(0, `${project} run stage profile`, "workspace-root task-file plan-item"),
|
|
46
|
+
"prompt render": { ...route(0, `${project} run stage profile`, "workspace-root task-file plan-item"), contextualPaths: words("project-root workspace-root task-file") },
|
|
44
47
|
"stage start": { ...route([0, 1], `${project} stage`, `${lifecycle} dir subject intake-file context-file context-sha256`, "bootstrap intake-stdin require-session-binding"), contextualPaths: words("project-root intake-file context-file") },
|
|
45
|
-
"stage finish": { ...route(1, `${project} stage`, `${lifecycle} dir semantic-file data result-file outcome decision-file verification-file retry-check reason request work`, "result-stdin"), contextualPaths: words("project-root semantic-file result-file decision-file verification-file") },
|
|
48
|
+
"stage finish": { ...route(1, `${project} stage`, `${lifecycle} dir semantic-file data result-file outcome decision-file verification-file retry-check reason reason-file request work`, "result-stdin"), contextualPaths: words("project-root semantic-file result-file decision-file verification-file reason-file") },
|
|
46
49
|
"stage pause": { ...route(1, `${project} stage work`, `${lifecycle} question-file`, "question-stdin"), contextualPaths: words("project-root question-file") },
|
|
47
50
|
"stage resume": { ...route(1, `${project} stage work`, `${lifecycle} answer-file`, "answer-stdin"), contextualPaths: words("project-root answer-file") },
|
|
48
51
|
"stage block": { ...route(1, `${project} stage work kind code`, "summary-file", "summary-stdin retryable", "", { kind: ["engine", "harness", "environment"] }), contextualPaths: words("project-root summary-file") },
|
|
49
52
|
"stage unblock": route(1, `${project} stage work`),
|
|
50
53
|
"stage fanout status": route(1, "stage", scoped),
|
|
51
54
|
"stage fanout dispatch": route(1, "stage", scoped),
|
|
52
|
-
"stage fanout reconcile": route(1, "stage observations-file", scoped),
|
|
53
|
-
"stage native observe": route(1, "stage observations-file", scoped),
|
|
55
|
+
"stage fanout reconcile": { ...route(1, "stage observations-file", scoped), contextualPaths: words("project-root observations-file") },
|
|
56
|
+
"stage native observe": { ...route(1, "stage observations-file", scoped), contextualPaths: words("project-root observations-file") },
|
|
54
57
|
"work status": route(1, project),
|
|
55
|
-
"work add-batch": route(0, "parent file", scoped),
|
|
58
|
+
"work add-batch": { ...route(0, "parent file", scoped), contextualPaths: words("project-root file") },
|
|
56
59
|
"work ls": route(0, "", `${scoped} run parent status limit`, "ready include-results", "", { status: ["created", "running", "paused", "completed", "failed", "cancelled"] }),
|
|
57
60
|
"work show": route(1, "", scoped),
|
|
58
61
|
"work repair add": { ...route(0, "run", `${scoped} origin-work from-check from-finding from-unresolved check-ref verification-file task-file`, "task-stdin", "origin-work from-finding from-unresolved check-ref"), contextualPaths: words("project-root verification-file task-file") },
|
|
@@ -63,7 +66,7 @@ export const commandInputs = {
|
|
|
63
66
|
"work delete": route(1, "", scoped),
|
|
64
67
|
"work launch": route(1, "", `${scoped} stage`),
|
|
65
68
|
"work start": { ...route(1, "", `${scoped} ${lifecycle} recovery-id`), contextualPaths: ["project-root"] },
|
|
66
|
-
"work finish": { ...route(1, "", `${scoped} ${lifecycle} result-file retry-check reason`, "result-stdin"), contextualPaths: words("project-root result-file") },
|
|
69
|
+
"work finish": { ...route(1, "", `${scoped} ${lifecycle} result-file retry-check reason reason-file`, "result-stdin"), contextualPaths: words("project-root result-file reason-file") },
|
|
67
70
|
"work fail": { ...route(1, "reason", `${scoped} ${lifecycle}`), contextualPaths: ["project-root"] },
|
|
68
71
|
"work cancel": route(1, "reason", scoped),
|
|
69
72
|
"work retry": route(1, "reason", scoped),
|
|
@@ -218,7 +221,7 @@ export const commandInputs = {
|
|
|
218
221
|
"dashboard open": route(0, "", "project project-root protocol viewer format"),
|
|
219
222
|
"dashboard refresh": route(0, "", "project project-root protocol open format output", "all"),
|
|
220
223
|
"dashboard refresh-global": route(0, "", "output format"),
|
|
221
|
-
"schema validate": route(0, "schema file", "schema-dir project-root run"),
|
|
224
|
+
"schema validate": { ...route(0, "schema file", "schema-dir project-root run"), contextualPaths: words("project-root file") },
|
|
222
225
|
"memory permissions preflight": route(0, "root memory-bank flow mode", "tasks")
|
|
223
226
|
};
|
|
224
227
|
export function contextualPathOptionsForLifecycle(operation) {
|
|
@@ -286,14 +289,14 @@ export function prepareCommandRoute(args) {
|
|
|
286
289
|
["payload-file", "payload-json", "payload-base64"], ["semantic-file", "data", "result-file", "result-stdin"],
|
|
287
290
|
["stage-report", "dashboard"], ["task-file", "plan-item"], ["project", "project-root"],
|
|
288
291
|
["intake-file", "intake-stdin", "context-file"], ["question-file", "question-stdin"], ["answer-file", "answer-stdin"],
|
|
289
|
-
["summary-file", "summary-stdin"], ["task-file", "task-stdin"]
|
|
292
|
+
["summary-file", "summary-stdin"], ["task-file", "task-stdin"], ["reason", "reason-file"]
|
|
290
293
|
]) {
|
|
291
294
|
const present = aliases.filter(name => parsed.options.has(name));
|
|
292
295
|
if (present.length > 1)
|
|
293
296
|
reject(`Use only one of ${present.map(name => `--${name}`).join(", ")}`);
|
|
294
297
|
}
|
|
295
|
-
if (contract.values.includes("retry-check") && parsed.options.has("retry-check") !== parsed.options.has("reason"))
|
|
296
|
-
reject("--retry-check
|
|
298
|
+
if (contract.values.includes("retry-check") && parsed.options.has("retry-check") !== (parsed.options.has("reason") || parsed.options.has("reason-file")))
|
|
299
|
+
reject("--retry-check requires exactly one of --reason or --reason-file");
|
|
297
300
|
// Retain the dispatcher's historic offsets for nested action positions.
|
|
298
301
|
const family = parsed.positional[0];
|
|
299
302
|
const singleWord = ["status", "version", "transition"].includes(family);
|
package/dist/cli/help.js
CHANGED
|
@@ -233,6 +233,10 @@ Usage:
|
|
|
233
233
|
dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> (--intake-file <path>|--intake-stdin) [--require-session-binding] --json
|
|
234
234
|
dd-flow stage pause <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--question-file <path>|--question-stdin) --json
|
|
235
235
|
dd-flow stage resume <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--answer-file <path>|--answer-stdin) --json
|
|
236
|
+
|
|
237
|
+
Managed resume commands restore the accepted answer file from runtime state. Execute the returned command without adding an answer path or recreating the answer. The explicit answer flags above are for unmanaged calls.
|
|
238
|
+
Path aliases @project, @workspace and @run are supported only in declared CLI path parameters and require a uniquely bound context. File-writing tools use physical paths. Both --option value and --option=value are accepted; quoted or escaped aliases are normalized identically. Pre-RUN bootstrap paths and external schema directories require physical paths.
|
|
239
|
+
An aggregate CODE/CODE-REVIEW failure may return continuation.kind=repair_required: its repair Work is already registered. End the coordinator turn and let the controller dispatch the fresh child; do not recreate the repair. A recovery retry uses --retry-check <receipt> --reason-file <recovery.json>, containing only {"reason":"actual environment recovery evidence"}. Legacy --reason remains accepted, but cannot be combined with --reason-file. A needs_input continuation is not yet executable: write its requested file first, then use command_after_input.
|
|
236
240
|
dd-flow stage block <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --kind engine|harness|environment --code <code> (--summary-file <path>|--summary-stdin) [--retryable] --json
|
|
237
241
|
dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
|
|
238
242
|
dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
package/dist/cli/hook-ingress.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
-
import { parseCommandArgs, parseLifecycleCommand, lifecycleCommandFromPayload } from "../services/lifecycle-command.js";
|
|
3
|
+
import { parseCommandArgs, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "../services/lifecycle-command.js";
|
|
4
4
|
import { errorRecord, AppError } from "../shared/errors.js";
|
|
5
5
|
import { parseJsonObject } from "../shared/json.js";
|
|
6
6
|
const workerFile = fileURLToPath(import.meta.url);
|
|
@@ -28,7 +28,7 @@ export async function runHookIngress(args) {
|
|
|
28
28
|
}
|
|
29
29
|
const payload = parseJsonObject(stdin || "{}", "native hook stdin");
|
|
30
30
|
const command = lifecycleCommandFromPayload(payload);
|
|
31
|
-
if (event !== "PreToolUse" || !command || parseLifecycleCommand(command).kind === "none") {
|
|
31
|
+
if (event !== "PreToolUse" || !command || (parseLifecycleCommand(command).kind === "none" && !continuationCommand(command))) {
|
|
32
32
|
process.stdout.write("{}\n");
|
|
33
33
|
return 0;
|
|
34
34
|
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { resolveCommandPaths, resolveCommandProjectRoot } from "../services/command-context.js";
|
|
3
|
+
import { assertAuxiliaryCommandScope } from "../services/lifecycle-invocations.js";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
4
6
|
import { quote } from "shell-quote";
|
|
5
|
-
import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
|
|
7
|
+
import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
|
|
6
8
|
import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
|
|
7
9
|
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
8
10
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
@@ -132,12 +134,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
132
134
|
const lifecycleCommand = parseLifecycleCommand(publicCommand);
|
|
133
135
|
if (lifecycleCommand.kind === "standalone")
|
|
134
136
|
validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
|
|
135
|
-
const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
|
|
137
|
+
const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
136
138
|
if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
|
|
137
139
|
const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
138
140
|
if (diagnostic)
|
|
139
141
|
throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
140
|
-
|
|
142
|
+
const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
|
|
143
|
+
throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
|
|
141
144
|
}
|
|
142
145
|
const invocationId = observedInvocation?.id ?? suppliedInvocationId;
|
|
143
146
|
let boundInvocationArgs;
|
|
@@ -170,7 +173,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
170
173
|
const retainedOutput = observedInvocation ? parseOutputOptions(expandedInvocationArgs) : null;
|
|
171
174
|
if (retainedOutput?.responseFile)
|
|
172
175
|
output.responseFile = retainedOutput.responseFile;
|
|
173
|
-
|
|
176
|
+
let invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
|
|
174
177
|
emitHumanProgress(io, output, progress, "start");
|
|
175
178
|
if (output.progressJsonl) {
|
|
176
179
|
writeProgressJsonl(io, "start", `Starting ${output.args.slice(0, 3).join(" ")}`);
|
|
@@ -178,7 +181,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
178
181
|
}
|
|
179
182
|
// Route by retained authority, so a mistyped project path reaches the CLI
|
|
180
183
|
// validator instead of selecting a different engine or failing preflight.
|
|
181
|
-
const scopeProjectRoot = invocationScope?.projectRoot ?? resolveProjectRootBeforeRouting(output.args, env);
|
|
184
|
+
const scopeProjectRoot = invocationScope?.projectRoot ?? resolveCommandProjectRoot(routerContext, output.args, resolveProjectRootBeforeRouting(output.args, env));
|
|
185
|
+
assertAuxiliaryCommandScope({ ...routerContext, env }, publicCommand, scopeProjectRoot);
|
|
186
|
+
invocationArgs = resolveCommandPaths(routerContext, invocationArgs, scopeProjectRoot);
|
|
182
187
|
const routedArgs = output.responseFile ? [...invocationArgs, "--response-file", output.responseFile] : invocationArgs;
|
|
183
188
|
const routed = await routeArgsThroughEngine(routerContext, output.json ? [...routedArgs, "--json", ...(output.progressJsonl ? ["--progress-jsonl"] : [])] : routedArgs, io, io.stdin, env, scopeProjectRoot);
|
|
184
189
|
if (routed?.routed && !routed.inProcess) {
|
|
@@ -236,8 +241,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
236
241
|
preparedInput = await prepareCliInput(invocationArgs, dispatchContext, io, scopeProjectRoot, output.responseFile);
|
|
237
242
|
if (!retainedOutcome)
|
|
238
243
|
preflightCliCompatibility(dispatchContext, invocationArgs, env, scopeProjectRoot);
|
|
239
|
-
if (!retainedOutcome)
|
|
240
|
-
|
|
244
|
+
if (!retainedOutcome) {
|
|
245
|
+
const command = lifecycleCommand.kind === "none" ? publicCommand : quote(["dd-flow", ...dispatchArgs]);
|
|
246
|
+
const event = lifecycleCommand.kind === "none" && env.DD_FLOW_DAEMON_ID ? observedLifecycleNativeEvent(dispatchContext, command, env.DD_FLOW_DAEMON_ID) : null;
|
|
247
|
+
settleHook = observeLifecycleCommand(dispatchContext, command, event ?? undefined);
|
|
248
|
+
}
|
|
241
249
|
const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output, preparedInput);
|
|
242
250
|
const completedHook = settleHook;
|
|
243
251
|
settleHook = undefined;
|
|
@@ -266,6 +274,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
266
274
|
}
|
|
267
275
|
catch (caught) {
|
|
268
276
|
const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
|
|
277
|
+
if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
|
|
278
|
+
let earlyContext;
|
|
279
|
+
try {
|
|
280
|
+
const command = quote(["dd-flow", ...output.args]);
|
|
281
|
+
const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
|
|
282
|
+
if (event) {
|
|
283
|
+
earlyContext = createContext({ ...env }, "hook");
|
|
284
|
+
observeLifecycleCommand(earlyContext, command, event)({ error });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (persistence) {
|
|
288
|
+
error.details.lifecycle_diagnostic_error = String(persistence);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
earlyContext?.db.close?.();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
269
294
|
if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
|
|
270
295
|
try {
|
|
271
296
|
const directory = path.join(diagnosticHome, "diagnostics", "cli");
|
|
@@ -746,6 +771,14 @@ async function prepareCliInput(...args) {
|
|
|
746
771
|
async function prepareCliInputValue(args, context, io, scopeProjectRoot, responseFile = null, route = prepareCommandRoute(args)) {
|
|
747
772
|
const { family, command, parsed } = route;
|
|
748
773
|
const prepared = { route };
|
|
774
|
+
if (optionalOption(parsed, "reason-file")) {
|
|
775
|
+
const value = prepareJsonFile(requiredOption(parsed, "reason-file"), "Check recovery evidence").value;
|
|
776
|
+
if (!value || typeof value.reason !== "string" || !value.reason.trim() || Object.keys(value).some(key => key !== "reason"))
|
|
777
|
+
throw new AppError("validation", "Recovery evidence must be a JSON object with one nonempty reason", 2);
|
|
778
|
+
prepared.retryReason = value.reason;
|
|
779
|
+
}
|
|
780
|
+
else if (optionalOption(parsed, "retry-check"))
|
|
781
|
+
prepared.retryReason = optionalOption(parsed, "reason");
|
|
749
782
|
if (route.key === "project register")
|
|
750
783
|
resolveProjectRoot(requiredOption(parsed, "root"));
|
|
751
784
|
if (route.key === "project archive")
|
|
@@ -1182,7 +1215,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
|
|
|
1182
1215
|
}
|
|
1183
1216
|
}
|
|
1184
1217
|
if (family === "work" && command === "finish") {
|
|
1185
|
-
const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason =
|
|
1218
|
+
const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
|
|
1186
1219
|
if (Boolean(retryReceiptId) !== Boolean(retryReason))
|
|
1187
1220
|
throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
|
|
1188
1221
|
const resultFile = optionalOption(parsed, "result-file");
|
|
@@ -1207,8 +1240,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
|
|
|
1207
1240
|
throw new AppError("usage", "Stage status belongs in the semantic input file", 2);
|
|
1208
1241
|
if (["result-file", "semantic-file", "data", "result-stdin"].filter(key => hasOption(parsed, key)).length > 1)
|
|
1209
1242
|
throw new AppError("usage", "Use exactly one result input: --result-file, --semantic-file, --data or --result-stdin", 2);
|
|
1210
|
-
if (Boolean(optionalOption(parsed, "retry-check")) !== Boolean(
|
|
1211
|
-
throw new AppError("usage", "--retry-check
|
|
1243
|
+
if (Boolean(optionalOption(parsed, "retry-check")) !== Boolean(prepared.retryReason))
|
|
1244
|
+
throw new AppError("usage", "--retry-check requires recovery evidence", 2);
|
|
1212
1245
|
const resultStdin = hasOption(parsed, "result-stdin");
|
|
1213
1246
|
const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
|
|
1214
1247
|
const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
|
|
@@ -1226,8 +1259,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
|
|
|
1226
1259
|
};
|
|
1227
1260
|
const acceptStageInputs = (allowed) => {
|
|
1228
1261
|
const options = allowed.split(" ");
|
|
1229
|
-
for (const name of ["dir", "semantic-file", "data", "result-file", "result-stdin", "outcome", "decision-file", "verification-file", "retry-check", "reason", "request", "work"]) {
|
|
1230
|
-
if (hasOption(parsed, name) && !options.includes(name))
|
|
1262
|
+
for (const name of ["dir", "semantic-file", "data", "result-file", "result-stdin", "outcome", "decision-file", "verification-file", "retry-check", "reason", "reason-file", "request", "work"]) {
|
|
1263
|
+
if (hasOption(parsed, name) && !options.includes(name === "reason-file" ? "reason" : name))
|
|
1231
1264
|
throw new AppError("usage", `stage finish ${stage} does not accept --${name} for this flow`, 2, { parameter: name });
|
|
1232
1265
|
}
|
|
1233
1266
|
};
|
|
@@ -1284,7 +1317,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
|
|
|
1284
1317
|
requiredOption(parsed, "request");
|
|
1285
1318
|
requiredOption(parsed, "work");
|
|
1286
1319
|
try {
|
|
1287
|
-
prepared.mergeFinish = prepareVnextMergeFinish(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(optionalOption(parsed, "retry-check") ? { retryCheckId: optionalOption(parsed, "retry-check"), retryReason:
|
|
1320
|
+
prepared.mergeFinish = prepareVnextMergeFinish(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(optionalOption(parsed, "retry-check") ? { retryCheckId: optionalOption(parsed, "retry-check"), retryReason: prepared.retryReason } : {}) });
|
|
1288
1321
|
}
|
|
1289
1322
|
catch (error) {
|
|
1290
1323
|
return prepareError(error, { parameter: "MERGE finish" });
|
|
@@ -1556,7 +1589,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
1556
1589
|
const workId = requiredPosition(parsed, 0, "work-id");
|
|
1557
1590
|
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
|
|
1558
1591
|
assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
1559
|
-
const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason =
|
|
1592
|
+
const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
|
|
1560
1593
|
if (Boolean(retryReceiptId) !== Boolean(retryReason))
|
|
1561
1594
|
throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
|
|
1562
1595
|
const result = prepared.workFinish?.result ?? (resultFile ? prepareTextFile(resultFile, "Work result").text : await readStdin(io.stdin));
|
|
@@ -1680,7 +1713,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
1680
1713
|
const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
|
|
1681
1714
|
const resultStdin = hasOption(parsed, "result-stdin");
|
|
1682
1715
|
const retryCheckId = optionalOption(parsed, "retry-check");
|
|
1683
|
-
const retryReason =
|
|
1716
|
+
const retryReason = prepared.retryReason;
|
|
1684
1717
|
const hookEventId = optionalOption(parsed, "hook-event-id");
|
|
1685
1718
|
if (retryCheckId && !retryReason?.trim())
|
|
1686
1719
|
throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { callDaemon } from "../harness-runtime/lib/dd-codex-daemon.mjs";
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
|
-
import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand } from "./lifecycle-command.js";
|
|
4
|
+
import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand } from "./lifecycle-command.js";
|
|
5
5
|
/** The hook asks the existing daemon about native evidence; it cannot mint it. */
|
|
6
6
|
export async function verifyCodexHookDelivery(env, payload) {
|
|
7
7
|
const command = lifecycleCommandFromPayload(payload);
|
|
8
8
|
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
9
|
-
if (parsed.kind === "none")
|
|
9
|
+
if (parsed.kind === "none" && !(command && continuationCommand(command)))
|
|
10
10
|
return undefined;
|
|
11
|
-
const managed = commandOption(parsed.invocation, "invocation-id") || env.DD_FLOW_DAEMON_ID;
|
|
11
|
+
const managed = (parsed.kind !== "none" && commandOption(parsed.invocation, "invocation-id")) || env.DD_FLOW_DAEMON_ID;
|
|
12
12
|
if (!managed)
|
|
13
13
|
return undefined; // Explicit legacy/unmanaged transport.
|
|
14
14
|
if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { commandInputs, physicalPathOptions } from "../cli/command-inputs.js";
|
|
3
|
+
import { parseCommandArgs } from "./lifecycle-command.js";
|
|
4
|
+
import { assertPathWithin, canonicalPath } from "../storage/paths.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { resolveWorkReference } from "../storage/work-references.js";
|
|
7
|
+
export function normalizedPathAlias(value) {
|
|
8
|
+
return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
|
|
9
|
+
}
|
|
10
|
+
export function resolveContextualPaths(args, options, roots) {
|
|
11
|
+
const resolve = (option, original) => {
|
|
12
|
+
if (!options.has(option))
|
|
13
|
+
return original;
|
|
14
|
+
const value = normalizedPathAlias(original);
|
|
15
|
+
if (!value.startsWith("@"))
|
|
16
|
+
return value;
|
|
17
|
+
const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
|
|
18
|
+
if (!match || !roots[match[1]])
|
|
19
|
+
throw new AppError("usage", `Unknown or unavailable path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
20
|
+
const root = roots[match[1]];
|
|
21
|
+
return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
|
|
22
|
+
};
|
|
23
|
+
return args.map((value, index) => {
|
|
24
|
+
const equal = /^--([^=]+)=(.*)$/s.exec(value);
|
|
25
|
+
if (equal)
|
|
26
|
+
return `--${equal[1]}=${resolve(equal[1], equal[2])}`;
|
|
27
|
+
const option = args[index - 1]?.match(/^--([^=]+)$/)?.[1];
|
|
28
|
+
return option ? resolve(option, value) : value;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Contextual paths are route semantics, not lifecycle invocation authority. */
|
|
32
|
+
export function resolveCommandProjectRoot(context, args, explicit) {
|
|
33
|
+
if (explicit && !normalizedPathAlias(explicit).startsWith("@"))
|
|
34
|
+
return canonicalPath(explicit, false);
|
|
35
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runs'"))
|
|
36
|
+
return null;
|
|
37
|
+
const parsed = parseCommandArgs(args);
|
|
38
|
+
const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
|
|
39
|
+
const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
|
|
40
|
+
if (reference) {
|
|
41
|
+
const rows = context.db.all("SELECT project_root FROM runs WHERE id = ? OR short_id = ?", [reference, reference]);
|
|
42
|
+
if (rows.length === 1)
|
|
43
|
+
return rows[0].project_root;
|
|
44
|
+
if (rows.length > 1)
|
|
45
|
+
throw new AppError("usage", "RUN reference requires an explicit project scope", 2, { phase: "prepare", effect: "no_effect" });
|
|
46
|
+
}
|
|
47
|
+
const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
|
|
48
|
+
if (work) {
|
|
49
|
+
const resolved = resolveWorkReference(context, work);
|
|
50
|
+
return context.db.get("SELECT root FROM projects WHERE id = ?", [resolved.project_id])?.root ?? null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function resolveCommandPaths(context, args, projectRoot) {
|
|
55
|
+
const parsed = parseCommandArgs(args);
|
|
56
|
+
const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
|
|
57
|
+
const options = new Set(key ? commandInputs[key]?.contextualPaths ?? [] : []);
|
|
58
|
+
if (!args.some(value => /(?:^|=)\\*@/.test(value)))
|
|
59
|
+
return args;
|
|
60
|
+
for (const [option, values] of parsed.options) {
|
|
61
|
+
if (physicalPathOptions.has(option) && !options.has(option) && values.some(value => normalizedPathAlias(value).startsWith("@")))
|
|
62
|
+
throw new AppError("usage", `--${option} requires a physical path on this route`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
|
|
63
|
+
}
|
|
64
|
+
const project = projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]) : undefined;
|
|
65
|
+
const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
|
|
66
|
+
let run;
|
|
67
|
+
if (reference && project) {
|
|
68
|
+
const rows = context.db.all("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
|
|
69
|
+
if (rows.length !== 1)
|
|
70
|
+
throw new AppError("usage", "Path aliases require one RUN in the selected project", 2, { phase: "prepare", effect: "no_effect" });
|
|
71
|
+
run = rows[0];
|
|
72
|
+
}
|
|
73
|
+
const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
|
|
74
|
+
if (work && project) {
|
|
75
|
+
const resolved = resolveWorkReference(context, work, { projectId: project.id, ...(run ? { runId: run.id } : {}) });
|
|
76
|
+
if (!run)
|
|
77
|
+
run = context.db.get("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, resolved.run_id]);
|
|
78
|
+
}
|
|
79
|
+
return resolveContextualPaths(args, options, { project: projectRoot, workspace: run?.workspace_root, run: run?.run_root });
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { isRegisteredRepair } from "./repair-intents.js";
|
|
3
|
+
import { stageAttempt } from "./stage-work-graph.js";
|
|
4
|
+
/** Only the settled coordinator turn can explain a repeated continuation.
|
|
5
|
+
* A stale error from another stage/turn or an error followed by success is not
|
|
6
|
+
* authority. Never inspect the assistant's prose for this decision. */
|
|
7
|
+
export function assertNoCorrelatedRejection(context, scope) {
|
|
8
|
+
const rows = context.db.all(`SELECT outcome_json FROM hook_events
|
|
9
|
+
WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ?
|
|
10
|
+
AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.stage') = ?
|
|
11
|
+
AND json_extract(outcome_json, '$.controller_turn') = ? AND json_extract(outcome_json, '$.attempt') = ? ORDER BY id`, [scope.projectId, scope.runId, scope.generation, scope.stage, scope.turn, stageAttempt(context, scope.projectId, scope.runId, scope.stage)]);
|
|
12
|
+
const latest = new Map();
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
const value = JSON.parse(row.outcome_json);
|
|
15
|
+
latest.set(value.operation, value);
|
|
16
|
+
}
|
|
17
|
+
for (const value of latest.values())
|
|
18
|
+
if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
|
|
19
|
+
throw new AppError(value.error.code, value.error.message, 1, { ...value.error.details, operation: value.operation, hook_event_id: value.hook_event_id, continuation_scope: scope });
|
|
20
|
+
}
|
|
@@ -28,6 +28,8 @@ export async function nextControllerFanout(context, input) {
|
|
|
28
28
|
let fanout = getVnextFanoutStatus(context, input).orchestration;
|
|
29
29
|
if (!fanout)
|
|
30
30
|
return { kind: "none" };
|
|
31
|
+
if (fanout.state === "compatibility_required")
|
|
32
|
+
throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
|
|
31
33
|
if (children.length) {
|
|
32
34
|
const reconciled = await reconcileVnextFanout(context, { ...input, observations });
|
|
33
35
|
const issues = reconciled.issues.filter(issue => issue.code !== "child_check_in_progress");
|
|
@@ -43,7 +45,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
43
45
|
if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
|
|
44
46
|
return { kind: "wait", reason: "qualified_capacity_required" };
|
|
45
47
|
}
|
|
46
|
-
const prompt = (text) => ({ kind: "prompt", prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id,
|
|
48
|
+
const prompt = (text) => ({ kind: "prompt", prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
|
|
47
49
|
if (fanout.state === "coordinator_required")
|
|
48
50
|
return prompt(`Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
|
|
49
51
|
if (fanout.works.failed || fanout.works.cancelled)
|
|
@@ -73,7 +75,7 @@ export async function nextControllerFanout(context, input) {
|
|
|
73
75
|
return { kind: "wait", reason: "work_receipts_pending" };
|
|
74
76
|
if (fanout.works.created)
|
|
75
77
|
throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
|
|
76
|
-
return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish
|
|
78
|
+
return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish with repair_required has already registered its repair Work. End this Turn immediately so the controller can launch a fresh child; do not create another repair or invoke its start_command in this coordinator Session. Do not start a successor Stage.`);
|
|
77
79
|
}
|
|
78
80
|
/** Normalize provider-observed topology only; never parse assistant prose. */
|
|
79
81
|
export function controllerNativeChildren(receipt, rootSessionId) {
|
package/dist/services/hooks.js
CHANGED
|
@@ -12,7 +12,7 @@ import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-pre
|
|
|
12
12
|
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
13
13
|
import { assertRecoveryHookCurrent } from "./run-recovery.js";
|
|
14
14
|
import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
|
|
15
|
-
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload } from "./lifecycle-command.js";
|
|
15
|
+
import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
|
|
16
16
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
17
17
|
import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
|
|
18
18
|
const defaultProfileName = "default";
|
|
@@ -309,13 +309,13 @@ export function handleCodexHook(context, input) {
|
|
|
309
309
|
if (!command)
|
|
310
310
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
311
311
|
const lifecycle = lifecycleFacts(command);
|
|
312
|
-
if (!lifecycle)
|
|
312
|
+
if (!lifecycle && !continuationCommand(command))
|
|
313
313
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
314
314
|
const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
|
|
315
315
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
316
316
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
317
|
-
const
|
|
318
|
-
const invocationId = commandOption(lifecycle.invocation, "invocation-id");
|
|
317
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
318
|
+
const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
|
|
319
319
|
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
320
320
|
const proof = context.codexHookProof;
|
|
321
321
|
if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
|
|
@@ -337,7 +337,7 @@ export function handleCodexHook(context, input) {
|
|
|
337
337
|
throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
|
|
338
338
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
339
339
|
}
|
|
340
|
-
let matchKey = lifecycleMatchKey(lifecycle, project.root);
|
|
340
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
|
|
341
341
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
342
342
|
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
343
343
|
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
@@ -396,6 +396,10 @@ export function handleCodexHook(context, input) {
|
|
|
396
396
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
397
397
|
protocol_id: null
|
|
398
398
|
};
|
|
399
|
+
if (!lifecycle) {
|
|
400
|
+
const persisted = context.db.writeTransaction(() => persist());
|
|
401
|
+
return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
|
|
402
|
+
}
|
|
399
403
|
if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
|
|
400
404
|
const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
|
|
401
405
|
const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
@@ -517,9 +521,9 @@ function recordZcodeLifecycle(context, input, invocationId) {
|
|
|
517
521
|
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
|
|
518
522
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
519
523
|
const lifecycle = lifecycleFacts(command);
|
|
520
|
-
if (!lifecycle)
|
|
524
|
+
if (!lifecycle && !continuationCommand(command))
|
|
521
525
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
522
|
-
if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
|
|
526
|
+
if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
|
|
523
527
|
return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
|
|
524
528
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
525
529
|
const meta = objectRecord(hook._meta);
|
|
@@ -539,13 +543,13 @@ function recordZcodeLifecycle(context, input, invocationId) {
|
|
|
539
543
|
throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
|
|
540
544
|
const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
|
|
541
545
|
const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
|
|
542
|
-
if (lifecycle
|
|
546
|
+
if (lifecycle?.bootstrapStageStart)
|
|
543
547
|
registerProject(context, { root: expectedRoot });
|
|
544
548
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
545
549
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
546
550
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
|
|
547
551
|
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
548
|
-
let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
552
|
+
let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
549
553
|
const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
|
|
550
554
|
const persist = (scope) => {
|
|
551
555
|
if (scope)
|
|
@@ -563,6 +567,8 @@ function recordZcodeLifecycle(context, input, invocationId) {
|
|
|
563
567
|
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 };
|
|
564
568
|
return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
|
|
565
569
|
};
|
|
570
|
+
if (!lifecycle)
|
|
571
|
+
return context.db.writeTransaction(() => persist());
|
|
566
572
|
let persistedResult;
|
|
567
573
|
const receipt = observeLifecycleInvocation(context, {
|
|
568
574
|
...(invocationId ? { id: invocationId } : {}), command,
|
|
@@ -592,11 +598,11 @@ export function handleGrokEvent(context, input) {
|
|
|
592
598
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
593
599
|
}
|
|
594
600
|
const lifecycle = lifecycleFacts(command);
|
|
595
|
-
if (!lifecycle)
|
|
601
|
+
if (!lifecycle && !continuationCommand(command))
|
|
596
602
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
597
|
-
const
|
|
603
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
598
604
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
599
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
605
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
600
606
|
if (bootstrapStageStart)
|
|
601
607
|
registerProject(context, { root: expectedRoot });
|
|
602
608
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
@@ -692,10 +698,10 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
692
698
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
693
699
|
}
|
|
694
700
|
const lifecycle = lifecycleFacts(command);
|
|
695
|
-
if (!lifecycle)
|
|
701
|
+
if (!lifecycle && !continuationCommand(command))
|
|
696
702
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
697
|
-
const
|
|
698
|
-
const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
|
|
703
|
+
const bootstrapStageStart = lifecycle?.bootstrapStageStart;
|
|
704
|
+
const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
|
|
699
705
|
if (bootstrapStageStart)
|
|
700
706
|
registerProject(context, { root: expectedRoot });
|
|
701
707
|
const project = requireProjectByRoot(context, expectedRoot);
|
|
@@ -12,6 +12,33 @@ const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
|
|
|
12
12
|
export function parseLifecycleCommand(command) {
|
|
13
13
|
return parseLifecycleCommandInner(command, false);
|
|
14
14
|
}
|
|
15
|
+
/** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
|
|
16
|
+
export function continuationCommand(command) {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = parse(command, name => `$${name}`);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!entries.every(entry => typeof entry === "string"))
|
|
25
|
+
return null;
|
|
26
|
+
const words = entries;
|
|
27
|
+
const start = words.findIndex(word => ["stage", "work"].includes(word));
|
|
28
|
+
if (start < 1)
|
|
29
|
+
return null;
|
|
30
|
+
const launcher = words.slice(0, start);
|
|
31
|
+
while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
|
|
32
|
+
launcher.shift();
|
|
33
|
+
const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
|
|
34
|
+
const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
|
|
35
|
+
if (!direct && !node)
|
|
36
|
+
return null;
|
|
37
|
+
const args = parseCommandArgs(words.slice(start));
|
|
38
|
+
const route = args.positional.slice(0, 3).join(" ");
|
|
39
|
+
const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
|
|
40
|
+
return operation ? { operation, args } : null;
|
|
41
|
+
}
|
|
15
42
|
/** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
|
|
16
43
|
export function unwrapShellCommand(command) {
|
|
17
44
|
let entries;
|