@deksden-com/dd-flow-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
package/dist/services/hooks.js
CHANGED
|
@@ -7,7 +7,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
7
7
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
8
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
|
-
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
+
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
12
12
|
const defaultProfileName = "default";
|
|
13
13
|
const maxSanitizedSummaryLength = 1600;
|
|
@@ -269,14 +269,56 @@ export function handleCodexHook(context, input) {
|
|
|
269
269
|
const sessionId = stringValue(payload.session_id);
|
|
270
270
|
const turnId = stringValue(payload.turn_id);
|
|
271
271
|
const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
|
|
272
|
-
const command =
|
|
272
|
+
const command = lifecycleCommandFromPayload(payload);
|
|
273
273
|
if (!command)
|
|
274
274
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
275
|
+
const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
|
|
276
|
+
const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
|
|
277
|
+
const lifecycleShell = analyzeLifecycleShellCommand(command);
|
|
278
|
+
// Resume legitimately receives an answer through stdin. It is matched by
|
|
279
|
+
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
280
|
+
if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
observed: false,
|
|
284
|
+
reason: "compound_lifecycle_command",
|
|
285
|
+
message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
|
|
286
|
+
standalone_command: lifecycleShell.standaloneCommand,
|
|
287
|
+
hookSpecificOutput: {
|
|
288
|
+
hookEventName: "PreToolUse",
|
|
289
|
+
permissionDecision: "deny",
|
|
290
|
+
permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycleShell.standaloneCommand}`
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
275
294
|
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
276
|
-
const
|
|
277
|
-
|
|
295
|
+
const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
|
|
296
|
+
const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
|
|
297
|
+
const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
298
|
+
if (!flowPayload && !stageStart && !stageResume && !workStart)
|
|
278
299
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
279
|
-
const
|
|
300
|
+
const commandProjectRoot = optionFromCommand(command, "project-root");
|
|
301
|
+
const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
|
|
302
|
+
const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
|
|
303
|
+
const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
|
|
304
|
+
const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
|
|
305
|
+
const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
|
|
306
|
+
const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
|
|
307
|
+
const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
|
|
308
|
+
const matchKey = bootstrapStageStart && commandProjectRoot ? bootstrapMatchKey({
|
|
309
|
+
projectRoot: commandProjectRoot,
|
|
310
|
+
subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
|
|
311
|
+
intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
|
|
312
|
+
...(contextSha256 ? { contextSha256 } : {})
|
|
313
|
+
}) : stageStart && stageRunId && stageName && commandProjectRoot ? stageStartMatchKey(stageRunId, stageName, commandProjectRoot, contextSha256)
|
|
314
|
+
: stageResume && resumeRunId && resumeStageName && resumeWorkId && commandProjectRoot ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, commandProjectRoot)
|
|
315
|
+
: workStart && workId && commandProjectRoot ? workStartMatchKey(workId, commandProjectRoot) : null;
|
|
316
|
+
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
317
|
+
// Its PreToolUse event arrives before stage start can register that project.
|
|
318
|
+
if (bootstrapStageStart && commandProjectRoot) {
|
|
319
|
+
registerProject(context, { root: commandProjectRoot });
|
|
320
|
+
}
|
|
321
|
+
const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
|
|
280
322
|
if (!project)
|
|
281
323
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
282
324
|
const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
|
|
@@ -289,13 +331,20 @@ export function handleCodexHook(context, input) {
|
|
|
289
331
|
const inserted = recordHookEvent(context, {
|
|
290
332
|
projectId: project.id,
|
|
291
333
|
protocolId,
|
|
334
|
+
harness: "codex-desktop",
|
|
335
|
+
providerSessionId: sessionId ?? null,
|
|
336
|
+
parentSessionId: null,
|
|
292
337
|
sessionId: effectiveSessionId,
|
|
338
|
+
agentId: stringValue(payload.agent_id) ?? null,
|
|
293
339
|
turnId: turnId ?? null,
|
|
294
340
|
eventName,
|
|
295
341
|
toolName: toolName ?? null,
|
|
296
342
|
status: "observed",
|
|
297
343
|
payload,
|
|
298
|
-
eventKey
|
|
344
|
+
eventKey,
|
|
345
|
+
matchKey,
|
|
346
|
+
transcriptPath: binding?.transcript_path ?? null,
|
|
347
|
+
cwd: stringValue(payload.cwd) ?? null
|
|
299
348
|
});
|
|
300
349
|
if (effectiveSessionId) {
|
|
301
350
|
recordFlowSessionObservation(context, {
|
|
@@ -315,21 +364,649 @@ export function handleCodexHook(context, input) {
|
|
|
315
364
|
event_key: eventKey,
|
|
316
365
|
session_id: effectiveSessionId,
|
|
317
366
|
protocol_id: protocolId,
|
|
318
|
-
...(sessionId
|
|
367
|
+
...(sessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
319
368
|
? {
|
|
320
369
|
hookSpecificOutput: {
|
|
321
370
|
hookEventName: "PreToolUse",
|
|
322
371
|
permissionDecision: "allow",
|
|
323
|
-
updatedInput: { command:
|
|
372
|
+
updatedInput: { command: commandWithHookEvent(command, eventKey) }
|
|
324
373
|
}
|
|
325
374
|
}
|
|
326
375
|
: {})
|
|
327
376
|
};
|
|
328
377
|
}
|
|
329
|
-
|
|
330
|
-
|
|
378
|
+
/** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
|
|
379
|
+
export function handleZcodeEvent(context, input) {
|
|
380
|
+
const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
|
|
381
|
+
if (notification.method !== "session/update")
|
|
382
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
383
|
+
const params = objectRecord(notification.params);
|
|
384
|
+
const update = objectRecord(params.update);
|
|
385
|
+
if (update.sessionUpdate !== "tool_call")
|
|
386
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
387
|
+
const updateMeta = objectRecord(update._meta);
|
|
388
|
+
const claudeCode = objectRecord(updateMeta.claudeCode);
|
|
389
|
+
const runtime = objectRecord(updateMeta.zcodeRuntime);
|
|
390
|
+
const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
|
|
391
|
+
const rawInput = objectRecord(update.rawInput);
|
|
392
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
|
|
393
|
+
if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
|
|
394
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
395
|
+
}
|
|
396
|
+
const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
|
|
397
|
+
const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
|
|
398
|
+
const lifecycleShell = analyzeLifecycleShellCommand(command, true);
|
|
399
|
+
if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
|
|
400
|
+
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
401
|
+
standalone_command: lifecycleShell.standaloneCommand
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
405
|
+
const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
|
|
406
|
+
const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
|
|
407
|
+
const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
408
|
+
if (!flowPayload && !stageStart && !stageResume && !workStart) {
|
|
409
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
410
|
+
}
|
|
411
|
+
const commandProjectRoot = optionFromCommand(command, "project-root");
|
|
412
|
+
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
413
|
+
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
414
|
+
throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
|
|
415
|
+
expected: expectedRoot,
|
|
416
|
+
actual: commandProjectRoot
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
|
|
420
|
+
const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
|
|
421
|
+
const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
|
|
422
|
+
const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
|
|
423
|
+
const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
|
|
424
|
+
const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
|
|
425
|
+
const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
|
|
426
|
+
const matchKey = bootstrapStageStart ? bootstrapMatchKey({
|
|
427
|
+
projectRoot: expectedRoot,
|
|
428
|
+
subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
|
|
429
|
+
intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
|
|
430
|
+
...(contextSha256 ? { contextSha256 } : {})
|
|
431
|
+
}) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
|
|
432
|
+
: stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
|
|
433
|
+
: workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
|
|
434
|
+
if (bootstrapStageStart)
|
|
435
|
+
registerProject(context, { root: expectedRoot });
|
|
436
|
+
const project = requireProjectByRoot(context, expectedRoot);
|
|
437
|
+
const envelopeMeta = objectRecord(notification._meta);
|
|
438
|
+
const ddZcode = objectRecord(envelopeMeta.ddZcode);
|
|
439
|
+
const observedProfile = objectRecord(ddZcode.observedProfile);
|
|
440
|
+
const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
|
|
441
|
+
const daemonId = stringValue(ddZcode.daemonId);
|
|
442
|
+
if (!rootProviderSessionId)
|
|
443
|
+
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
444
|
+
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
445
|
+
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
446
|
+
const sessionId = `zcode-acp:${providerSessionId}`;
|
|
447
|
+
const parentSessionId = childProviderSessionId ? `zcode-acp:${rootProviderSessionId}` : null;
|
|
448
|
+
const agentId = stringValue(runtime.agentId);
|
|
449
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
450
|
+
...flowPayload,
|
|
451
|
+
harness: "zcode-acp",
|
|
452
|
+
provider_session_id: providerSessionId,
|
|
453
|
+
agent_id: agentId ?? null,
|
|
454
|
+
parent_session_id: parentSessionId
|
|
455
|
+
}, sessionId) : undefined;
|
|
456
|
+
const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
457
|
+
harness: "zcode-acp", providerSessionId, command
|
|
458
|
+
})).digest("hex");
|
|
459
|
+
const payload = {
|
|
460
|
+
session_id: providerSessionId,
|
|
461
|
+
agent_id: agentId,
|
|
462
|
+
cwd: expectedRoot,
|
|
463
|
+
tool_name: toolName ?? "Bash",
|
|
464
|
+
tool_input: rawInput,
|
|
465
|
+
command,
|
|
466
|
+
provider: stringValue(observedProfile.provider),
|
|
467
|
+
model: stringValue(observedProfile.model),
|
|
468
|
+
reasoning: stringValue(observedProfile.reasoning),
|
|
469
|
+
mode: stringValue(observedProfile.mode),
|
|
470
|
+
agent_type: stringValue(runtime.source)
|
|
471
|
+
};
|
|
472
|
+
const inserted = recordHookEvent(context, {
|
|
473
|
+
projectId: project.id,
|
|
474
|
+
protocolId: observedSession?.protocol_id ?? null,
|
|
475
|
+
harness: "zcode-acp",
|
|
476
|
+
providerSessionId,
|
|
477
|
+
parentSessionId,
|
|
478
|
+
daemonId: daemonId ?? null,
|
|
479
|
+
sessionId,
|
|
480
|
+
agentId: agentId ?? null,
|
|
481
|
+
turnId: null,
|
|
482
|
+
eventName: "PreToolUse",
|
|
483
|
+
toolName: toolName ?? "Bash",
|
|
484
|
+
status: "observed",
|
|
485
|
+
payload,
|
|
486
|
+
eventKey,
|
|
487
|
+
matchKey,
|
|
488
|
+
transcriptPath: null,
|
|
489
|
+
cwd: expectedRoot
|
|
490
|
+
});
|
|
491
|
+
recordFlowSessionObservation(context, {
|
|
492
|
+
projectId: project.id,
|
|
493
|
+
sessionId,
|
|
494
|
+
runId: observedSession?.run_id ?? null,
|
|
495
|
+
protocolId: observedSession?.protocol_id ?? null,
|
|
496
|
+
cwd: expectedRoot,
|
|
497
|
+
toolName: toolName ?? "Bash",
|
|
498
|
+
eventKey
|
|
499
|
+
});
|
|
500
|
+
return {
|
|
501
|
+
ok: true,
|
|
502
|
+
observed: inserted,
|
|
503
|
+
duplicate: !inserted,
|
|
504
|
+
event_key: eventKey,
|
|
505
|
+
harness: "zcode-acp",
|
|
506
|
+
session_id: sessionId,
|
|
507
|
+
provider_session_id: providerSessionId,
|
|
508
|
+
parent_session_id: parentSessionId,
|
|
509
|
+
daemon_id: daemonId ?? null
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
|
|
513
|
+
export function handleGrokEvent(context, input) {
|
|
514
|
+
const hook = parseJsonObject(input.stdin || "{}", "Grok Build hook event");
|
|
515
|
+
const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
|
|
516
|
+
if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use") {
|
|
517
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
518
|
+
}
|
|
519
|
+
const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
|
|
520
|
+
const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
|
|
521
|
+
const command = stringValue(rawInput?.command) ?? stringValue(rawInput?.cmd) ?? stringValue(hook.command);
|
|
522
|
+
if (!command || (toolName && !["Bash", "bash", "run_terminal_command", "RunTerminalCommand"].includes(toolName))) {
|
|
523
|
+
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
524
|
+
}
|
|
525
|
+
const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
|
|
526
|
+
const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
|
|
527
|
+
const lifecycleShell = analyzeLifecycleShellCommand(command, true);
|
|
528
|
+
if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
|
|
529
|
+
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
530
|
+
standalone_command: lifecycleShell.standaloneCommand
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
534
|
+
const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
|
|
535
|
+
const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
|
|
536
|
+
const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
537
|
+
if (!flowPayload && !stageStart && !stageResume && !workStart)
|
|
538
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
539
|
+
const commandProjectRoot = optionFromCommand(command, "project-root");
|
|
540
|
+
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
541
|
+
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
542
|
+
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
543
|
+
expected: expectedRoot, actual: commandProjectRoot
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
|
|
547
|
+
const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
|
|
548
|
+
const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
|
|
549
|
+
const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
|
|
550
|
+
const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
|
|
551
|
+
const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
|
|
552
|
+
const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
|
|
553
|
+
const matchKey = bootstrapStageStart ? bootstrapMatchKey({
|
|
554
|
+
projectRoot: expectedRoot,
|
|
555
|
+
subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
|
|
556
|
+
intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
|
|
557
|
+
...(contextSha256 ? { contextSha256 } : {})
|
|
558
|
+
}) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
|
|
559
|
+
: stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
|
|
560
|
+
: workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
|
|
561
|
+
if (bootstrapStageStart)
|
|
562
|
+
registerProject(context, { root: expectedRoot });
|
|
563
|
+
const project = requireProjectByRoot(context, expectedRoot);
|
|
564
|
+
const meta = objectRecord(hook._meta);
|
|
565
|
+
const ddGrok = objectRecord(meta.ddGrok);
|
|
566
|
+
const observedProfile = objectRecord(ddGrok.observedProfile);
|
|
567
|
+
const rootProviderSessionId = stringValue(ddGrok.rootProviderSessionId);
|
|
568
|
+
const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
|
|
569
|
+
if (!rootProviderSessionId || !providerSessionId)
|
|
570
|
+
throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
|
|
571
|
+
const isChild = providerSessionId !== rootProviderSessionId;
|
|
572
|
+
const sessionId = `grok-acp:${providerSessionId}`;
|
|
573
|
+
const parentSessionId = isChild ? `grok-acp:${stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId}` : null;
|
|
574
|
+
const daemonId = stringValue(ddGrok.daemonId);
|
|
575
|
+
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
576
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
577
|
+
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
578
|
+
}, sessionId) : undefined;
|
|
579
|
+
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
580
|
+
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
581
|
+
})).digest("hex");
|
|
582
|
+
const payload = {
|
|
583
|
+
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
584
|
+
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
|
|
585
|
+
};
|
|
586
|
+
const inserted = recordHookEvent(context, {
|
|
587
|
+
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "grok-acp", providerSessionId, parentSessionId,
|
|
588
|
+
daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
|
|
589
|
+
eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
590
|
+
});
|
|
591
|
+
recordFlowSessionObservation(context, {
|
|
592
|
+
projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
|
|
593
|
+
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
594
|
+
});
|
|
595
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "grok-acp", session_id: sessionId,
|
|
596
|
+
provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId ?? null };
|
|
597
|
+
return (flowPayload || stageStart || stageResume || workStart)
|
|
598
|
+
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
599
|
+
: result;
|
|
600
|
+
}
|
|
601
|
+
/** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
|
|
602
|
+
export function handleOpenCodeEvent(context, input) {
|
|
603
|
+
const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
|
|
604
|
+
const agy = event.source_harness === "antigravity-cli";
|
|
605
|
+
const harness = agy ? "antigravity-cli" : "opencode-server";
|
|
606
|
+
if (event.schema_id !== "dd-flow/opencode-tool-event@1")
|
|
607
|
+
throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
|
|
608
|
+
const phase = stringValue(event.phase);
|
|
609
|
+
if (phase !== "before" && phase !== "after")
|
|
610
|
+
throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
|
|
611
|
+
const session = objectRecord(event.session);
|
|
612
|
+
const profile = objectRecord(event.profile);
|
|
613
|
+
const rawInput = objectRecord(event.input);
|
|
614
|
+
const providerSessionId = stringValue(session.provider_session_id);
|
|
615
|
+
const nativeParentId = stringValue(session.parent_provider_session_id);
|
|
616
|
+
const daemonId = stringValue(event.daemon_id);
|
|
617
|
+
const toolCallId = stringValue(event.tool_call_id);
|
|
618
|
+
const eventId = stringValue(event.event_id) ?? toolCallId;
|
|
619
|
+
const toolName = stringValue(event.tool);
|
|
620
|
+
const directory = stringValue(session.directory);
|
|
621
|
+
if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
|
|
622
|
+
throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
|
|
623
|
+
}
|
|
624
|
+
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
625
|
+
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
626
|
+
throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
627
|
+
}
|
|
628
|
+
const sessionId = `${harness}:${providerSessionId}`;
|
|
629
|
+
const parentSessionId = nativeParentId ? `${harness}:${nativeParentId}` : null;
|
|
630
|
+
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
631
|
+
const baseKey = `${eventId}:${phase}`;
|
|
632
|
+
if (phase === "after") {
|
|
633
|
+
const project = requireProjectByRoot(context, expectedRoot);
|
|
634
|
+
const inserted = recordHookEvent(context, {
|
|
635
|
+
projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
|
|
636
|
+
daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
|
|
637
|
+
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
638
|
+
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
639
|
+
});
|
|
640
|
+
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId };
|
|
641
|
+
}
|
|
642
|
+
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
643
|
+
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
644
|
+
}
|
|
645
|
+
const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
|
|
646
|
+
const lifecycleShell = analyzeLifecycleShellCommand(command, true);
|
|
647
|
+
const agyBootstrapHeredoc = agy && containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
648
|
+
const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
|
|
649
|
+
if (lifecycleShell.kind === "compound" && !stageResume && !agyBootstrapHeredoc && !stagePauseHeredoc) {
|
|
650
|
+
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycleShell.standaloneCommand });
|
|
651
|
+
}
|
|
652
|
+
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
653
|
+
const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
|
|
654
|
+
const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
|
|
655
|
+
const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
656
|
+
if (!flowPayload && !stageStart && !stageResume && !workStart)
|
|
657
|
+
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
658
|
+
const commandProjectRoot = optionFromCommand(command, "project-root");
|
|
659
|
+
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
660
|
+
throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
|
|
661
|
+
}
|
|
662
|
+
const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
|
|
663
|
+
const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
|
|
664
|
+
const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
|
|
665
|
+
const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
|
|
666
|
+
const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
|
|
667
|
+
const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
|
|
668
|
+
const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
|
|
669
|
+
const matchKey = bootstrapStageStart ? bootstrapMatchKey({
|
|
670
|
+
projectRoot: expectedRoot,
|
|
671
|
+
subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
|
|
672
|
+
intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
|
|
673
|
+
...(contextSha256 ? { contextSha256 } : {})
|
|
674
|
+
}) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
|
|
675
|
+
: stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
|
|
676
|
+
: workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
|
|
677
|
+
if (bootstrapStageStart)
|
|
678
|
+
registerProject(context, { root: expectedRoot });
|
|
679
|
+
const project = requireProjectByRoot(context, expectedRoot);
|
|
680
|
+
const agentId = stringValue(session.agent_id);
|
|
681
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
682
|
+
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
683
|
+
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
684
|
+
reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
|
|
685
|
+
metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
|
|
686
|
+
}, sessionId) : undefined;
|
|
687
|
+
const payload = {
|
|
688
|
+
session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
|
|
689
|
+
provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
|
|
690
|
+
mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
|
|
691
|
+
};
|
|
692
|
+
const inserted = recordHookEvent(context, {
|
|
693
|
+
projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
|
|
694
|
+
daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
|
|
695
|
+
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
696
|
+
});
|
|
697
|
+
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
698
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId, provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId };
|
|
699
|
+
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
700
|
+
}
|
|
701
|
+
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
702
|
+
export function handleAgyEvent(context, input) {
|
|
703
|
+
const event = parseJsonObject(input.stdin || "{}", "Antigravity tool event");
|
|
704
|
+
if (event.schema_id !== "dd-flow/agy-tool-event@1")
|
|
705
|
+
throw new AppError("unsupported_agy_event_schema", "Unsupported Antigravity event schema", 1);
|
|
706
|
+
const workspacePaths = Array.isArray(event.workspace_paths) ? event.workspace_paths : [];
|
|
707
|
+
const directory = workspacePaths.find((value) => typeof value === "string" && path.isAbsolute(value));
|
|
708
|
+
const providerSessionId = stringValue(event.conversation_id);
|
|
709
|
+
const daemonId = stringValue(event.daemon_id);
|
|
710
|
+
const eventId = stringValue(event.event_id);
|
|
711
|
+
const toolName = stringValue(event.tool);
|
|
712
|
+
if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
|
|
713
|
+
throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
|
|
714
|
+
const translated = {
|
|
715
|
+
schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
|
|
716
|
+
daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
|
|
717
|
+
message_id: event.step_index === undefined ? null : String(event.step_index),
|
|
718
|
+
session: { provider_session_id: providerSessionId, parent_provider_session_id: event.parent_conversation_id, directory },
|
|
719
|
+
profile: { provider: objectRecord(event.profile).provider, model: objectRecord(event.profile).model, variant: objectRecord(event.profile).reasoning, agent: objectRecord(event.profile).mode },
|
|
720
|
+
outcome: { status: stringValue(event.error) ? "error" : "completed" }
|
|
721
|
+
};
|
|
722
|
+
return handleOpenCodeEvent(context, { projectRoot: input.projectRoot, stdin: JSON.stringify(translated) });
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* A Desktop hook inherits the app environment, not an inline `VAR=value`
|
|
726
|
+
* assignment made by the pending Bash tool call. Find the explicit
|
|
727
|
+
* DD_FLOW_HOME assignment for the dd-flow invocation, including a safe stdin
|
|
728
|
+
* pipeline such as `cat <<EOF | CODEX_HOME=... DD_FLOW_HOME=... dd-flow`.
|
|
729
|
+
* Other shell syntax is deliberately not evaluated here.
|
|
730
|
+
*/
|
|
731
|
+
export function isolatedDdFlowHomeFromHookStdin(stdin) {
|
|
732
|
+
try {
|
|
733
|
+
const payload = parseJsonObject(stdin || "{}", "codex hook stdin");
|
|
734
|
+
const command = lifecycleCommandFromPayload(payload);
|
|
735
|
+
const direct = command?.match(/(?:^|[|;\r\n]\s*)(?:env\s+)?(?:(?!DD_FLOW_HOME=)[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s|;]+)\s+)*DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1\s+dd-flow\b/);
|
|
736
|
+
// An exported home applies to the following pipeline command; this is the
|
|
737
|
+
// other form emitted by generated commands that send stdin through a pipe.
|
|
738
|
+
const exported = command?.match(/(?:^|[;\r\n]\s*)export\s+DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1(?=\s*(?:[;\r\n]|$))/);
|
|
739
|
+
const home = direct?.[2] ?? exported?.[2];
|
|
740
|
+
return home && path.isAbsolute(home) ? path.resolve(home) : null;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Desktop can wrap a single Bash tool invocation in `bash -c "…"`. Keep the
|
|
748
|
+
* hook contract intentionally narrow: unwrap only that whole-command form,
|
|
749
|
+
* then apply the same lifecycle parser to its actual shell payload.
|
|
750
|
+
*/
|
|
751
|
+
function lifecycleCommandFromPayload(payload) {
|
|
752
|
+
const command = commandFromPayload(payload);
|
|
753
|
+
if (!command)
|
|
754
|
+
return undefined;
|
|
755
|
+
const wrapped = command.trim().match(/^(?:\S*\/)?bash\s+-c\s+"((?:\\.|[^"\\])*)"\s*$/);
|
|
756
|
+
return wrapped ? wrapped[1].replace(/\\([\\"])/g, "$1") : command;
|
|
757
|
+
}
|
|
758
|
+
function commandWithHookEvent(command, eventKey) {
|
|
759
|
+
if (/(?:^|\s)--hook-event-id(?:=|\s)/.test(command))
|
|
760
|
+
return command;
|
|
761
|
+
// Heredoc payloads are opaque bytes from the user. Appending a flag to the
|
|
762
|
+
// whole Bash command changes those bytes and corrupts `stage resume` input.
|
|
763
|
+
if (containsUnquotedHeredoc(command))
|
|
331
764
|
return command;
|
|
332
|
-
|
|
765
|
+
if (/^\s*dd-flow\s+session\s+register\b/.test(command) && !/[;|&\n\r]|\$\(/.test(command))
|
|
766
|
+
return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
|
|
767
|
+
const lifecycle = analyzeLifecycleShellCommand(command, true);
|
|
768
|
+
if (lifecycle.kind !== "standalone")
|
|
769
|
+
return command;
|
|
770
|
+
return `${lifecycle.standaloneCommand} --hook-event-id ${JSON.stringify(eventKey)}`;
|
|
771
|
+
}
|
|
772
|
+
function containsUnquotedHeredoc(command) {
|
|
773
|
+
let quote = null;
|
|
774
|
+
let escaped = false;
|
|
775
|
+
for (let index = 0; index < command.length - 1; index += 1) {
|
|
776
|
+
const char = command[index];
|
|
777
|
+
if (escaped) {
|
|
778
|
+
escaped = false;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (char === "\\" && quote !== "'") {
|
|
782
|
+
escaped = true;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (quote) {
|
|
786
|
+
if (char === quote)
|
|
787
|
+
quote = null;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (char === "'" || char === '"') {
|
|
791
|
+
quote = char;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (char === "<" && command[index + 1] === "<")
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Recognise the small shell surface used by generated dd-flow lifecycle
|
|
801
|
+
* commands. This intentionally does not evaluate Bash: ambiguous composition
|
|
802
|
+
* is rejected so a hook event can never bind the wrong command.
|
|
803
|
+
*/
|
|
804
|
+
export function analyzeLifecycleShellCommand(command, includeSessionRegister = false) {
|
|
805
|
+
const segments = [];
|
|
806
|
+
let start = 0;
|
|
807
|
+
let quote = null;
|
|
808
|
+
let escaped = false;
|
|
809
|
+
let compound = false;
|
|
810
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
811
|
+
const char = command[index];
|
|
812
|
+
if (escaped) {
|
|
813
|
+
escaped = false;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (char === "\\" && quote !== "'") {
|
|
817
|
+
escaped = true;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (quote) {
|
|
821
|
+
if (char === quote)
|
|
822
|
+
quote = null;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (char === "'" || char === '"') {
|
|
826
|
+
quote = char;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const pair = command.slice(index, index + 2);
|
|
830
|
+
if (pair === "$(" || pair === "&&" || pair === "||") {
|
|
831
|
+
compound = true;
|
|
832
|
+
segments.push(command.slice(start, index).trim());
|
|
833
|
+
index += 1;
|
|
834
|
+
start = index + 1;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r" || char === "(") {
|
|
838
|
+
compound = true;
|
|
839
|
+
segments.push(command.slice(start, index).trim());
|
|
840
|
+
start = index + 1;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
segments.push(command.slice(start).trim());
|
|
844
|
+
// `stage resume` intentionally accepts the user's complete answer on stdin.
|
|
845
|
+
// We recognise it for observability but never rewrite a compound invocation.
|
|
846
|
+
const lifecyclePattern = includeSessionRegister
|
|
847
|
+
? /\bdd-flow\s+(?:session\s+register|stage\s+(?:start|resume)|work\s+start)\b/
|
|
848
|
+
: /\bdd-flow\s+(?:stage\s+(?:start|resume)|work\s+start)\b/;
|
|
849
|
+
const lifecycle = segments.filter((segment) => lifecyclePattern.test(segment));
|
|
850
|
+
if (!lifecycle.length)
|
|
851
|
+
return { kind: "none" };
|
|
852
|
+
const standaloneCommand = lifecycle[0];
|
|
853
|
+
return compound || lifecycle.length !== 1
|
|
854
|
+
? { kind: "compound", standaloneCommand }
|
|
855
|
+
: { kind: "standalone", standaloneCommand };
|
|
856
|
+
}
|
|
857
|
+
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
858
|
+
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
859
|
+
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
860
|
+
if (!event?.session_id) {
|
|
861
|
+
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
862
|
+
}
|
|
863
|
+
return event.session_id;
|
|
864
|
+
}
|
|
865
|
+
/** Reads immutable identity facts already captured by PreToolUse. */
|
|
866
|
+
export function hookSessionIdentity(context, projectId, eventKey) {
|
|
867
|
+
const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
|
|
868
|
+
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
|
|
869
|
+
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
870
|
+
if (!event?.session_id)
|
|
871
|
+
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
872
|
+
return {
|
|
873
|
+
hookEventId: event.id,
|
|
874
|
+
harness: event.harness,
|
|
875
|
+
providerSessionId: event.provider_session_id ?? event.session_id,
|
|
876
|
+
parentSessionId: event.parent_session_id,
|
|
877
|
+
daemonId: event.daemon_id,
|
|
878
|
+
agentId: event.agent_id,
|
|
879
|
+
sessionId: event.harness === "codex-desktop" ? event.agent_id ?? event.session_id : event.session_id,
|
|
880
|
+
turnId: event.turn_id,
|
|
881
|
+
transcriptPath: event.transcript_path,
|
|
882
|
+
provider: event.provider,
|
|
883
|
+
model: event.model,
|
|
884
|
+
reasoning: event.reasoning,
|
|
885
|
+
mode: event.mode,
|
|
886
|
+
agentType: event.agent_type,
|
|
887
|
+
cwd: event.cwd
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Claims the short-lived PreToolUse event for a lifecycle command when its host
|
|
892
|
+
* cannot apply `updatedInput` to a nested Bash invocation. The match key is
|
|
893
|
+
* derived from immutable command arguments; an event remains single-use.
|
|
894
|
+
*/
|
|
895
|
+
export function claimRecentMatchingHookEvent(context, input) {
|
|
896
|
+
const event = findRecentMatchingHookEvent(context, input);
|
|
897
|
+
const claim = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
898
|
+
if (claim.changes !== 1) {
|
|
899
|
+
throw new AppError(input.errorCode, `matching ${input.operation} PreToolUse hook event was already claimed`, 1);
|
|
900
|
+
}
|
|
901
|
+
return event.eventKey;
|
|
902
|
+
}
|
|
903
|
+
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
904
|
+
export function findRecentMatchingHookEvent(context, input) {
|
|
905
|
+
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
906
|
+
const find = () => context.db.get(`SELECT id, event_key FROM hook_events
|
|
907
|
+
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
908
|
+
ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
|
|
909
|
+
let event = find();
|
|
910
|
+
// Some harnesses publish nested-agent tool events concurrently with command
|
|
911
|
+
// execution. Give the trusted event a short bounded window to reach SQLite.
|
|
912
|
+
const deadline = Date.now() + 250;
|
|
913
|
+
while (!event?.event_key && Date.now() < deadline) {
|
|
914
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
915
|
+
event = find();
|
|
916
|
+
}
|
|
917
|
+
if (!event?.event_key) {
|
|
918
|
+
throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
|
|
919
|
+
}
|
|
920
|
+
return { id: event.id, eventKey: event.event_key };
|
|
921
|
+
}
|
|
922
|
+
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
923
|
+
export function claimBootstrapHookEvent(context, input) {
|
|
924
|
+
return claimRecentMatchingHookEvent(context, {
|
|
925
|
+
projectId: input.projectId,
|
|
926
|
+
matchKey: bootstrapMatchKey(input),
|
|
927
|
+
errorCode: "trusted_session_binding_required",
|
|
928
|
+
operation: "vNext SPECIFY start"
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
export function bootstrapMatchKey(input) {
|
|
932
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
933
|
+
operation: "stage_start",
|
|
934
|
+
bootstrap: true,
|
|
935
|
+
stage: "specify",
|
|
936
|
+
project_root: resolveProjectRoot(input.projectRoot),
|
|
937
|
+
subject: input.subject,
|
|
938
|
+
intake_mode: input.intakeMode,
|
|
939
|
+
...(input.contextSha256 ? { context_sha256: input.contextSha256 } : {})
|
|
940
|
+
})).digest("hex");
|
|
941
|
+
}
|
|
942
|
+
export function claimWorkStartHookEvent(context, input) {
|
|
943
|
+
const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
|
|
944
|
+
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]);
|
|
945
|
+
if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
|
|
946
|
+
throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
|
|
947
|
+
}
|
|
948
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
949
|
+
if (claimed.changes !== 1)
|
|
950
|
+
throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
|
|
951
|
+
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
952
|
+
}
|
|
953
|
+
function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
954
|
+
/** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
|
|
955
|
+
export function assertStageStartHookEvent(context, input) {
|
|
956
|
+
const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
|
|
957
|
+
const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
958
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
|
|
959
|
+
throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey });
|
|
960
|
+
}
|
|
961
|
+
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
962
|
+
}
|
|
963
|
+
export function claimStageStartHookEvent(context, input) {
|
|
964
|
+
assertStageStartHookEvent(context, input);
|
|
965
|
+
const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
966
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
967
|
+
if (claimed.changes !== 1)
|
|
968
|
+
throw new AppError("trusted_stage_launch_required", "matching stage-start hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
969
|
+
return hookSessionIdentity(context, input.projectId, input.eventKey);
|
|
970
|
+
}
|
|
971
|
+
export function stageStartMatchKey(runId, stage, projectRoot, contextSha256) {
|
|
972
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
973
|
+
operation: "stage_start",
|
|
974
|
+
run_id: runId,
|
|
975
|
+
stage,
|
|
976
|
+
project_root: resolveProjectRoot(projectRoot),
|
|
977
|
+
...(contextSha256 ? { context_sha256: contextSha256 } : {})
|
|
978
|
+
})).digest("hex");
|
|
979
|
+
}
|
|
980
|
+
export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
|
|
981
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
982
|
+
operation: "stage_resume",
|
|
983
|
+
run_id: runId,
|
|
984
|
+
stage,
|
|
985
|
+
work_id: workId,
|
|
986
|
+
project_root: resolveProjectRoot(projectRoot)
|
|
987
|
+
})).digest("hex");
|
|
988
|
+
}
|
|
989
|
+
/** Claims the resume command observed in the Session that received the user answer. */
|
|
990
|
+
export function claimStageResumeHookEvent(context, input) {
|
|
991
|
+
const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
|
|
992
|
+
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
993
|
+
const event = input.eventKey
|
|
994
|
+
? 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])
|
|
995
|
+
: 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]);
|
|
996
|
+
if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
|
|
997
|
+
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 });
|
|
998
|
+
}
|
|
999
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
1000
|
+
if (claimed.changes !== 1)
|
|
1001
|
+
throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
1002
|
+
return hookSessionIdentity(context, input.projectId, event.event_key);
|
|
1003
|
+
}
|
|
1004
|
+
export function workStartMatchKey(workId, projectRoot) {
|
|
1005
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
1006
|
+
operation: "work_start",
|
|
1007
|
+
work_id: workId,
|
|
1008
|
+
project_root: resolveProjectRoot(projectRoot)
|
|
1009
|
+
})).digest("hex");
|
|
333
1010
|
}
|
|
334
1011
|
export function hookStatusForProject(context, projectId) {
|
|
335
1012
|
return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
|
|
@@ -343,6 +1020,11 @@ export function activeCodexSessionBindingsForProject(context, projectId) {
|
|
|
343
1020
|
return context.db.all(`SELECT session_id, protocol_id, handshake_id, cwd, transcript_path, status, continuation_count, updated_at
|
|
344
1021
|
FROM codex_session_bindings WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC`, [projectId]);
|
|
345
1022
|
}
|
|
1023
|
+
/** Resolve the transcript once at the hook boundary; agents never provide this path. */
|
|
1024
|
+
export function transcriptPathForCodexSession(context, projectRoot, sessionId) {
|
|
1025
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
1026
|
+
return sessionBinding(context, project.id, sessionId)?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
1027
|
+
}
|
|
346
1028
|
export function activeFlowSessionBindingsForProject(context, projectId) {
|
|
347
1029
|
return activeFlowSessionsForProject(context, projectId).map((session) => ({
|
|
348
1030
|
session_id: session.session_id,
|
|
@@ -360,7 +1042,7 @@ export function activeFlowSessionBindingsForProject(context, projectId) {
|
|
|
360
1042
|
}
|
|
361
1043
|
export function codexHookEventsForProject(context, projectId) {
|
|
362
1044
|
return context.db.all(`SELECT protocol_id, session_id, turn_id, event_name, tool_name, status, sanitized_summary, created_at
|
|
363
|
-
FROM
|
|
1045
|
+
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
|
|
364
1046
|
}
|
|
365
1047
|
function resolveProfileName(profile) {
|
|
366
1048
|
return sanitizeProfile(profile ?? defaultProfileName);
|
|
@@ -473,7 +1155,8 @@ function commandHook(command, statusMessage) {
|
|
|
473
1155
|
return { type: "command", command, timeout: 5, statusMessage };
|
|
474
1156
|
}
|
|
475
1157
|
function hookCommand(event) {
|
|
476
|
-
|
|
1158
|
+
const handler = `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
|
|
1159
|
+
return `/bin/bash -c 'log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
|
|
477
1160
|
}
|
|
478
1161
|
function resolveHookTarget(target) {
|
|
479
1162
|
if (!target || target === "isolated") {
|
|
@@ -652,7 +1335,7 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
|
652
1335
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
653
1336
|
const now = context.now();
|
|
654
1337
|
const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
|
|
655
|
-
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ??
|
|
1338
|
+
const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
|
|
656
1339
|
context.db.run(`INSERT INTO codex_session_bindings
|
|
657
1340
|
(session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
|
|
658
1341
|
VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
|
|
@@ -663,21 +1346,73 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
|
663
1346
|
updated_at = excluded.updated_at`, [sessionId, project.id, cwd, transcriptPath, now, now]);
|
|
664
1347
|
return sessionBinding(context, project.id, sessionId);
|
|
665
1348
|
}
|
|
1349
|
+
function locateCodexTranscript(sessionId, configuredHome) {
|
|
1350
|
+
// The hook runs inside one selected CODEX_HOME. An explicit profile must
|
|
1351
|
+
// not also scan unrelated real homes; that breaks isolation and can attach
|
|
1352
|
+
// a transcript from a different client. Only an unconfigured legacy call
|
|
1353
|
+
// probes the two conventional locations.
|
|
1354
|
+
const homes = configuredHome
|
|
1355
|
+
? [configuredHome]
|
|
1356
|
+
: [path.join(os.homedir(), ".codex"), path.join(os.homedir(), ".codex-cpa")];
|
|
1357
|
+
for (const home of homes) {
|
|
1358
|
+
for (const directory of recentCodexSessionDirectories(home)) {
|
|
1359
|
+
const found = findTranscript(directory, sessionId);
|
|
1360
|
+
if (found)
|
|
1361
|
+
return found;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
function findTranscript(root, sessionId) {
|
|
1367
|
+
if (!fs.existsSync(root))
|
|
1368
|
+
return null;
|
|
1369
|
+
let entries;
|
|
1370
|
+
try {
|
|
1371
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1372
|
+
}
|
|
1373
|
+
catch {
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
for (const entry of entries) {
|
|
1377
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId))
|
|
1378
|
+
return path.join(root, entry.name);
|
|
1379
|
+
}
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
function recentCodexSessionDirectories(home) {
|
|
1383
|
+
return [0, 1].map((daysAgo) => {
|
|
1384
|
+
const date = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
|
1385
|
+
return path.join(home, "sessions", String(date.getUTCFullYear()), String(date.getUTCMonth() + 1).padStart(2, "0"), String(date.getUTCDate()).padStart(2, "0"));
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
666
1388
|
function sessionBinding(context, projectId, sessionId) {
|
|
667
1389
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
668
1390
|
}
|
|
669
1391
|
function recordHookEvent(context, input) {
|
|
670
|
-
const existing = context.db.get("SELECT id FROM
|
|
1392
|
+
const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
671
1393
|
if (existing)
|
|
672
1394
|
return false;
|
|
673
|
-
context.db.run(`INSERT INTO
|
|
674
|
-
(project_id, protocol_id, session_id, turn_id, event_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
675
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1395
|
+
context.db.run(`INSERT INTO hook_events
|
|
1396
|
+
(project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
1397
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
676
1398
|
input.projectId,
|
|
677
1399
|
input.protocolId,
|
|
1400
|
+
input.harness,
|
|
1401
|
+
input.providerSessionId,
|
|
1402
|
+
input.parentSessionId,
|
|
1403
|
+
input.daemonId ?? null,
|
|
678
1404
|
input.sessionId,
|
|
1405
|
+
input.agentId,
|
|
679
1406
|
input.turnId,
|
|
1407
|
+
stringValue(input.payload.transcript_path) ?? input.transcriptPath,
|
|
1408
|
+
stringValue(input.payload.provider) ?? null,
|
|
1409
|
+
stringValue(input.payload.model) ?? null,
|
|
1410
|
+
stringValue(input.payload.reasoning) ?? null,
|
|
1411
|
+
stringValue(input.payload.mode) ?? null,
|
|
1412
|
+
stringValue(input.payload.agent_type) ?? null,
|
|
1413
|
+
input.cwd,
|
|
680
1414
|
input.eventKey,
|
|
1415
|
+
input.matchKey,
|
|
681
1416
|
input.eventName,
|
|
682
1417
|
input.toolName,
|
|
683
1418
|
input.status,
|
|
@@ -700,6 +1435,27 @@ function projectForHook(context, explicitRoot, cwd) {
|
|
|
700
1435
|
})
|
|
701
1436
|
.sort((left, right) => right.root.length - left.root.length)[0];
|
|
702
1437
|
}
|
|
1438
|
+
function optionFromCommand(command, name) {
|
|
1439
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1440
|
+
const match = command.match(new RegExp(`(?:^|\\s)--${escaped}(?:=|\\s+)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
|
|
1441
|
+
return match?.[1] ? shellArgument(match[1]) : undefined;
|
|
1442
|
+
}
|
|
1443
|
+
function positionalFromCommand(command, prefix) {
|
|
1444
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1445
|
+
const match = command.match(new RegExp(`\\b${escaped}\\s+("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
|
|
1446
|
+
return match?.[1] ? shellArgument(match[1]) : undefined;
|
|
1447
|
+
}
|
|
1448
|
+
function shellArgument(raw) {
|
|
1449
|
+
if (raw.startsWith('"')) {
|
|
1450
|
+
try {
|
|
1451
|
+
return JSON.parse(raw);
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
return undefined;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return raw.startsWith("'") ? raw.slice(1, -1) : raw;
|
|
1458
|
+
}
|
|
703
1459
|
function hookEventKey(payload, eventName, toolName, command) {
|
|
704
1460
|
const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
705
1461
|
if (explicit)
|