@deksden-com/dd-flow-cli 0.9.0-beta.121 → 0.9.0-beta.123
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 +17 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/hook-ingress.js +4 -3
- package/dist/cli/native-hook-ingress.js +34 -2
- package/dist/cli/run-cli.js +3 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +68 -6
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +21 -5
- package/dist/harness-runtime/lib/dd-codex.mjs +5 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
- package/dist/harness-runtime/lib/dd-zcode.mjs +40 -16
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/model-observations.mjs +16 -9
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
- package/dist/runtime/context.js +1 -1
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/services/codex-capacity.js +131 -12
- package/dist/services/codex-hook-delivery.js +6 -2
- package/dist/services/eval-snapshots.js +13 -8
- package/dist/services/external-work-launch.js +27 -9
- package/dist/services/hooks.js +51 -59
- package/dist/services/lifecycle-command.js +1 -383
- package/dist/services/lifecycle-contract.js +1 -30
- package/dist/services/lifecycle-invocations.js +34 -13
- package/dist/services/merge-server.js +26 -9
- package/dist/services/review-copy.js +21 -0
- package/dist/services/run-controller-adapter.js +25 -3
- package/dist/services/run-controller-recovery.js +9 -2
- package/dist/services/run-controller.js +59 -20
- package/dist/services/run-observations.js +7 -2
- package/dist/services/runs.js +60 -47
- package/dist/services/stage-report-renderer.js +44 -2
- package/dist/services/verification-matrix.js +345 -0
- package/dist/services/vnext-code-review.js +30 -11
- package/dist/services/vnext-code.js +31 -10
- package/dist/services/vnext-merge.js +30 -7
- package/dist/services/vnext-plan-review.js +28 -7
- package/dist/services/vnext-plan.js +16 -3
- package/dist/services/work-registry.js +15 -1
- package/dist/storage/database.js +43 -9
- package/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { observedTimeout } from "./observation-clock.mjs";
|
|
2
2
|
import { ToolObservations, acpToolObservation, zcodePhysicalSession } from "./tool-observations.mjs";
|
|
3
3
|
import { invokeNativeHook } from "./native-hook-command.mjs";
|
|
4
|
+
import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
7
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
@@ -19,7 +20,7 @@ const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
|
19
20
|
|
|
20
21
|
function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
|
|
21
22
|
function fatalLifecycleError(error) {
|
|
22
|
-
return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict"].includes(error?.code);
|
|
23
|
+
return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict", "native_hook_timeout", "native_hook_failed", "native_hook_response_invalid", "hook_storage_unprepared", "storage_write_failed"].includes(error?.code);
|
|
23
24
|
}
|
|
24
25
|
function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
|
|
25
26
|
function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
@@ -163,13 +164,15 @@ export class AcpBridge {
|
|
|
163
164
|
const identity = this.modelSessions.get(adapterId); if (!identity) return;
|
|
164
165
|
this.modelProfiles.set(identity.sessionId, observed);
|
|
165
166
|
await observeModel({ journal: this.options.journal, harness: this.options.clientInfo?.name === "dd-grok" ? "grok-acp" : "zcode-acp",
|
|
166
|
-
sessionId: identity.sessionId, parentSessionId: identity.parentSessionId, requested: identity.requested, observed, source });
|
|
167
|
+
sessionId: identity.sessionId, parentSessionId: identity.parentSessionId, requested: identity.requested, observed, source: typeof source === "string" ? { channel: source, scope: "configured", raw_model: observed.raw_model ?? observed.model, raw_provider: observed.provider } : source });
|
|
167
168
|
}
|
|
168
169
|
async observeModelUpdate(message) {
|
|
169
170
|
const update = message.params?.update;
|
|
170
171
|
if (update?.sessionUpdate !== "config_option_update" || !Array.isArray(update.configOptions)) return;
|
|
171
172
|
const value = category => update.configOptions.find(option => option.type === "select" && (option.category === category || option.id === category))?.currentValue ?? null;
|
|
172
|
-
const
|
|
173
|
+
const raw = value("model");
|
|
174
|
+
const model = this.options.clientInfo?.name === "dd-grok" ? { model: raw } : zcodeModelSelector(raw);
|
|
175
|
+
const observed = { ...model, reasoning: value("thought_level"), mode: value("mode") };
|
|
173
176
|
const identity = this.modelSessions.get(message.params.sessionId); if (!identity?.armed) return;
|
|
174
177
|
checkObservedProfile(identity.requested, observed);
|
|
175
178
|
await this.recordModel(message.params.sessionId, observed, "acp.config_option_update");
|
|
@@ -233,13 +236,14 @@ export class AcpBridge {
|
|
|
233
236
|
// A validated no-effect rejection is returned through the invocation
|
|
234
237
|
// ledger. It is evidence, not a poisoned ACP Session.
|
|
235
238
|
const fatal = fatalLifecycleError(error);
|
|
236
|
-
|
|
239
|
+
const current = this.options.onLifecycleNotification === observer;
|
|
240
|
+
if (current && (!this.notificationError || fatal && !fatalLifecycleError(this.notificationError) && this.notificationError.code !== "profile_integrity_violation")) this.notificationError = error;
|
|
237
241
|
// A missing/foreign authority is not a recoverable CLI validation
|
|
238
242
|
// result. Stop admitting productive requests immediately; the managed
|
|
239
243
|
// controller still owns physical stop/drain and evidence capture.
|
|
240
|
-
if (fatal) this.rejectAll(error);
|
|
244
|
+
if (fatal && current) this.rejectAll(this.notificationError ?? error);
|
|
241
245
|
try {
|
|
242
|
-
await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
|
|
246
|
+
await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message, ...(error.details ? { details: error.details } : {}),
|
|
243
247
|
session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
|
|
244
248
|
} catch (persistenceError) {
|
|
245
249
|
process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
|
|
@@ -281,7 +285,7 @@ export class AcpBridge {
|
|
|
281
285
|
if (message.method) this.notifications = this.notifications
|
|
282
286
|
.then(async () => { await this.observeModelUpdate(message); await this.options.onNotification?.(message); })
|
|
283
287
|
.catch(async (error) => {
|
|
284
|
-
const failure = { code: error.code ?? "notification_observation_failed", message: error.message, session_id: message.params?.sessionId ?? null, tool_call_id: message.params?.update?.toolCallId ?? null, event_time: now(), delivery_stage: "acp_evidence", completeness: "incomplete" };
|
|
288
|
+
const failure = { code: error.code ?? "notification_observation_failed", message: error.message, ...(error.details ? { details: error.details } : {}), session_id: message.params?.sessionId ?? null, tool_call_id: message.params?.update?.toolCallId ?? null, event_time: now(), delivery_stage: "acp_evidence", completeness: "incomplete" };
|
|
285
289
|
try { await this.journal.write("notification_failure", failure); }
|
|
286
290
|
catch (persistenceError) { process.stderr.write(`${JSON.stringify({ ...failure, persistence_error: persistenceError.message })}\n`); }
|
|
287
291
|
// Evidence loss does not retroactively revoke a committed native hook.
|
|
@@ -441,11 +445,20 @@ function isProductiveSessionNotification(message) {
|
|
|
441
445
|
return true;
|
|
442
446
|
}
|
|
443
447
|
|
|
448
|
+
// The qualified bridge encodes exactly providerId\\modelId; no other suffix or
|
|
449
|
+
// slash normalization is authorized. Bare IDs do not prove account routing.
|
|
450
|
+
export function zcodeModelSelector(raw, provider = null) {
|
|
451
|
+
if (typeof raw !== "string") return { model: null, provider, raw_model: raw };
|
|
452
|
+
const parts = raw.split("\\");
|
|
453
|
+
if (parts.length === 1) return { model: raw || null, provider, raw_model: raw };
|
|
454
|
+
if (parts.length !== 2 || !parts.every(Boolean) || provider && provider !== parts[0]) return { model: null, provider, raw_model: raw };
|
|
455
|
+
return { model: parts[1], provider: parts[0], raw_model: raw };
|
|
456
|
+
}
|
|
457
|
+
|
|
444
458
|
export function observedProfile(read) {
|
|
445
459
|
const model = read?.settings?.model?.current ?? {};
|
|
446
460
|
return {
|
|
447
|
-
|
|
448
|
-
model: typeof model.modelId === "string" ? model.modelId : null,
|
|
461
|
+
...zcodeModelSelector(model.modelId, typeof model.providerId === "string" ? model.providerId : null),
|
|
449
462
|
reasoning: read?.settings?.thoughtLevel?.current ?? null,
|
|
450
463
|
mode: read?.settings?.mode?.current ?? read?.projection?.mode ?? null,
|
|
451
464
|
};
|
|
@@ -551,7 +564,7 @@ export function zcodeChildSessionIds(snapshot) {
|
|
|
551
564
|
// Read residency BEFORE each durable topology snapshot. An inactive node cannot
|
|
552
565
|
// add descendants; a resident node never proves settlement, even when idle.
|
|
553
566
|
// No resolve/read/subagents extension here: those may implicitly resume.
|
|
554
|
-
export async function observeClosedZcodeTree(bridge, root, retainedChildren = []) {
|
|
567
|
+
export async function observeClosedZcodeTree(bridge, root, retainedChildren = [], neverPromptedRootProof = null) {
|
|
555
568
|
const pending = new Set([root, ...retainedChildren]), residents = [], snapshots = [];
|
|
556
569
|
const deadline = performance.now() + 30_000;
|
|
557
570
|
try {
|
|
@@ -561,6 +574,15 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
|
|
|
561
574
|
const resident = await bridge.request("zcode/session/resident", { sessionId }, timeout());
|
|
562
575
|
if (resident?.sessionId !== sessionId || typeof resident.resident !== "boolean") throw new Error("Invalid residency identity or state");
|
|
563
576
|
residents.push(resident);
|
|
577
|
+
// A newly allocated root is not durable in ZCode until its first prompt.
|
|
578
|
+
// This is an explicit owned-daemon ledger fact, never error→empty inference.
|
|
579
|
+
if (sessionId === root && resident.resident === false && pending.size === 1
|
|
580
|
+
&& neverPromptedRootProof?.source === "owned_daemon_create_without_prompt"
|
|
581
|
+
&& neverPromptedRootProof.close_confirmed === true
|
|
582
|
+
&& neverPromptedRootProof.provider_session_id === root && typeof neverPromptedRootProof.daemon_id === "string") {
|
|
583
|
+
snapshots.push({ sessionId, childSessionIds: [], running: [], ended: {total:0,items:[]}, provenance: neverPromptedRootProof });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
564
586
|
const tree = await readZcodeSubagents(bridge, sessionId, deadline - performance.now(), true);
|
|
565
587
|
if (!Array.isArray(tree?.childSessionIds)
|
|
566
588
|
|| tree.childSessionIds.some(id => typeof id !== "string" || !id || id === sessionId)
|
|
@@ -577,7 +599,7 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
|
|
|
577
599
|
}
|
|
578
600
|
}
|
|
579
601
|
|
|
580
|
-
async function cancelTree(bridge, sessionId, before, providerSessionId, retainedChildren = []) {
|
|
602
|
+
async function cancelTree(bridge, sessionId, before, providerSessionId, retainedChildren = [], neverPromptedRootProof = null) {
|
|
581
603
|
const knownChildren = new Set(retainedChildren);
|
|
582
604
|
const remember = snapshot => {
|
|
583
605
|
for (const id of zcodeChildSessionIds(snapshot)) knownChildren.add(id);
|
|
@@ -616,7 +638,7 @@ async function cancelTree(bridge, sessionId, before, providerSessionId, retained
|
|
|
616
638
|
let close = null;
|
|
617
639
|
try { close = await bridge.request("zcode/session/close", { sessionId }); }
|
|
618
640
|
catch (error) { close = { closed: false, error: { code: error.code ?? "close_failed", message: error.message } }; }
|
|
619
|
-
const observation = close?.closed === true ? await observeClosedZcodeTree(bridge, providerSessionId, [...knownChildren])
|
|
641
|
+
const observation = close?.closed === true ? await observeClosedZcodeTree(bridge, providerSessionId, [...knownChildren], neverPromptedRootProof ? {...neverPromptedRootProof,close_confirmed:true} : null)
|
|
620
642
|
: { topology: { root_session_id: providerSessionId, child_session_ids: [...knownChildren], complete: false }, settled: false };
|
|
621
643
|
return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, ...observation };
|
|
622
644
|
}
|
|
@@ -713,9 +735,8 @@ function flowForwarder(options, rootProviderSessionId, bridge) {
|
|
|
713
735
|
const childId = notification.params?.update?._meta?.zcodeRuntime?.childSessionId;
|
|
714
736
|
const observed = bridge?.modelProfiles?.get(childId ?? rootProviderSessionId) ?? {};
|
|
715
737
|
if (childId && !observed.model) await observeModel({ journal: options.journal, harness: "zcode-acp", sessionId: childId, parentSessionId: rootProviderSessionId, requested: profile(options), observed, source: "zcode.child_tool_event", evidence: "unavailable" });
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
// lag behind execution and must never mint a second lifecycle receipt.
|
|
738
|
+
// This serialized path is model evidence only. The independent observer
|
|
739
|
+
// below owns qualified ACP admission; do not mint a second receipt here.
|
|
719
740
|
};
|
|
720
741
|
}
|
|
721
742
|
|
|
@@ -753,6 +774,8 @@ export async function zcodeInvocationObserver(options, rootProviderSessionId, se
|
|
|
753
774
|
const command = update.rawInput?.command ?? update.rawInput?.cmd;
|
|
754
775
|
const toolName = update._meta?.claudeCode?.toolName;
|
|
755
776
|
if (!["Bash", "bash", "execute"].includes(toolName) || typeof command !== "string") return;
|
|
777
|
+
const analysis = parseLifecycleCommand(command);
|
|
778
|
+
if (analysis.kind === "none" && !analysis.unresolved && !continuationCommand(command)) return;
|
|
756
779
|
const parent = session === rootProviderSessionId ? null : owners.get(native.parentToolCallId);
|
|
757
780
|
if (session !== rootProviderSessionId && !parent) throw Object.assign(new Error("Native child has no confirmed immediate parent tool owner"), { code: "zcode_parent_identity_missing" });
|
|
758
781
|
await send({ bin: options.ddFlowBin, home: options.ddFlowHome,
|
|
@@ -808,6 +831,7 @@ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContrac
|
|
|
808
831
|
["91a1470650f5fd6105f9863fd31d1cad7b90a109", "dd-zcode-harness@2"],
|
|
809
832
|
["b9e0f5106dddb439b76186278bd9be5f45dbf3ef", "dd-zcode-harness@2"],
|
|
810
833
|
["636b14181d5c9a95476055079836fd6b58169f58", "dd-zcode-harness@2"],
|
|
834
|
+
["4621fabd3f517ecdd349dfebebae9077c01d2f2b", "dd-zcode-harness@2"],
|
|
811
835
|
]);
|
|
812
836
|
const qualified = qualifiedBridgeCommits.get(bridgeCommit) === harnessContract;
|
|
813
837
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
@@ -965,7 +989,7 @@ export async function cancelSessionWithBridge(bridge, options) {
|
|
|
965
989
|
requireJournal(options);
|
|
966
990
|
const identity = await controlledIdentity(bridge, options);
|
|
967
991
|
const before = await readZcodeSubagents(bridge, identity.adapterSessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
968
|
-
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds) });
|
|
992
|
+
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds, options.neverPromptedRootProof) });
|
|
969
993
|
}
|
|
970
994
|
|
|
971
995
|
export async function forkSession(options) {
|
|
@@ -56,7 +56,7 @@ export const lifecycleRetryInstruction = `${lifecycleCommandCompletionInstructio
|
|
|
56
56
|
export const stageLifecycleInstruction = [
|
|
57
57
|
"The project root identifies this RUN; use the Stage packet's declared execution workspace and absolute artifact paths. Do not change a native Session's working directory to a workspace provisioned for a later Stage.",
|
|
58
58
|
"In a controller-managed fanout, the coordinator ends the entry Turn after the Work graph is returned; the controller dispatches children. A completed child or Work is not Stage success: settle the declared graph and Stage gate before finishing.",
|
|
59
|
-
"Execute each runtime-issued lifecycle operation
|
|
59
|
+
"Execute each runtime-issued lifecycle operation and its arguments exactly. One literal lifecycle call may be composed with ordinary shell commands; preserve conditions, stdin and redirects, and never replay the surrounding shell chain after failure. Invocation IDs are internal authority; never add, copy or invent one.",
|
|
60
60
|
lifecycleRetryInstruction
|
|
61
61
|
].join(" ");
|
|
62
62
|
|
|
@@ -68,13 +68,13 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
|
68
68
|
return [
|
|
69
69
|
`Complete one already-declared Work: ${workId}.`,
|
|
70
70
|
context,
|
|
71
|
-
"Your first technical action must be this exact
|
|
71
|
+
"Your first technical action must be this exact lifecycle command:",
|
|
72
72
|
startCommand,
|
|
73
73
|
"Execute the issued operation and target exactly. Lifecycle invocation IDs are internal runtime authority and are intentionally omitted; never add or invent them. Preserve any explicit native receipt handle that the command does contain.",
|
|
74
74
|
"Use the native shell's inherited working directory. Do not set or override a workdir/cwd field for this lifecycle command.",
|
|
75
75
|
"Continue only if it succeeds and returns the authoritative Work packet.",
|
|
76
76
|
lifecycleRetryInstruction,
|
|
77
|
-
"After a successful start, complete the assigned Work, write its required result, invoke its exact
|
|
77
|
+
"After a successful start, complete the assigned Work, write its required result, invoke its exact work finish command, then stop.",
|
|
78
78
|
"Do not start another Work, create a child, change dependencies, ask the user, or treat a quiet sibling as failed. You cannot ask the user or pause the parent Stage. Missing facts go through this Work's declared result/failure contract; only the coordinator handles Stage HITL."
|
|
79
79
|
].join("\n");
|
|
80
80
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LifecycleOperation } from "./lifecycle-contract.mjs";
|
|
2
|
+
export type { LifecycleOperation } from "./lifecycle-contract.mjs";
|
|
3
|
+
export interface ParsedCommandArgs { positional: string[]; options: Map<string, string[]> }
|
|
4
|
+
export interface LifecycleInvocation {
|
|
5
|
+
operation: LifecycleOperation; executable: string; launcher?: string; argv: string[]; env: Record<string, string>; args: ParsedCommandArgs; command: string;
|
|
6
|
+
rewriteSafe: boolean; wrapped: boolean; heredoc: boolean; sourceRange?: { start: number; end: number }; contexts?: string[]; expectedCwd?: string;
|
|
7
|
+
}
|
|
8
|
+
export type LifecycleAnalysis =
|
|
9
|
+
| { kind: "none"; unresolved?: true; reason?: string }
|
|
10
|
+
| { kind: "standalone"; invocation: LifecycleInvocation; supported?: boolean; invocations?: LifecycleInvocation[] }
|
|
11
|
+
| { kind: "compound"; invocation: LifecycleInvocation; reason: string; supported?: boolean; invocations?: LifecycleInvocation[] };
|
|
12
|
+
export function lifecycleCommandFromPayload(payload: { command?: unknown; tool_input?: unknown }): string | undefined;
|
|
13
|
+
export function parseLifecycleCommand(command: string): LifecycleAnalysis;
|
|
14
|
+
export function continuationCommand(command: string): { operation: string; args: ParsedCommandArgs } | null;
|
|
15
|
+
export function unwrapShellCommand(command: string): string;
|
|
16
|
+
export function parseCommandArgs(argv: string[]): ParsedCommandArgs;
|
|
17
|
+
export function commandOption(invocation: LifecycleInvocation, name: string): string | undefined;
|
|
18
|
+
export function commandHasOption(invocation: LifecycleInvocation, name: string): boolean;
|
|
19
|
+
export function commandPosition(invocation: LifecycleInvocation, index: number): string | undefined;
|
|
20
|
+
export function shellSuffixOffset(command: string): number;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import Parser from "tree-sitter";
|
|
3
|
+
import Bash from "tree-sitter-bash";
|
|
4
|
+
import { parse, quote } from "shell-quote";
|
|
5
|
+
import { lifecycleOperationForArgv } from "./lifecycle-contract.mjs";
|
|
6
|
+
|
|
7
|
+
// One grammar owns structure. shell-quote decodes identified command words;
|
|
8
|
+
// neither parser grants authority or executes expansions.
|
|
9
|
+
const parser = new Parser();
|
|
10
|
+
parser.setLanguage(Bash);
|
|
11
|
+
const shells = new Set(["bash", "sh", "zsh"]);
|
|
12
|
+
const repeatedContexts = new Set(["for_statement", "while_statement", "function_definition", "c_style_for_statement"]);
|
|
13
|
+
const dynamicTypes = ["command_substitution", "process_substitution", "arithmetic_expansion", "brace_expression", "extglob_pattern"];
|
|
14
|
+
function words(text) {
|
|
15
|
+
try { const entries = parse(text, name => `$${name}`); return entries.every(entry => typeof entry === "string") ? entries : null; }
|
|
16
|
+
catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
function ancestors(node) {
|
|
19
|
+
const result = [];
|
|
20
|
+
for (let parent = node.parent; parent; parent = parent.parent) result.push(parent.type);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function backgroundContext(node) {
|
|
24
|
+
for (let unit = node; unit.parent; unit = unit.parent) {
|
|
25
|
+
const siblings = unit.parent.children;
|
|
26
|
+
if (siblings[siblings.findIndex(child => child.id === unit.id) + 1]?.type === "&") return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function dynamicWord(node) {
|
|
31
|
+
return node.descendantsOfType(dynamicTypes).length > 0
|
|
32
|
+
|| node.descendantsOfType(["simple_expansion", "expansion"]).some(expansion => !["$DD_FLOW_BIN", "${DD_FLOW_BIN}"].includes(expansion.text))
|
|
33
|
+
|| node.type === "word" && /[*?[]/.test(node.text);
|
|
34
|
+
}
|
|
35
|
+
function launcherIndex(values, env) {
|
|
36
|
+
let index = 0;
|
|
37
|
+
while (values[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(values[index])) {
|
|
38
|
+
const value = values[index++], equals = value.indexOf("="); env[value.slice(0, equals)] = value.slice(equals + 1);
|
|
39
|
+
}
|
|
40
|
+
if (path.basename(values[index] ?? "") === "env") {
|
|
41
|
+
index++; if (values[index] === "--") index++;
|
|
42
|
+
if (values[index]?.startsWith("-")) return null;
|
|
43
|
+
while (values[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(values[index])) {
|
|
44
|
+
const value = values[index++], equals = value.indexOf("="); env[value.slice(0, equals)] = value.slice(equals + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (["command", "exec"].includes(values[index] ?? "")) { index++; if (values[index] === "--") index++; }
|
|
48
|
+
return index;
|
|
49
|
+
}
|
|
50
|
+
function executionCwd(node, nodes) {
|
|
51
|
+
const lineage = unit => { const result = []; for (; unit; unit = unit.parent) result.push(unit); return result; };
|
|
52
|
+
const target = lineage(node), targetIds = new Set(target.map(unit => unit.id));
|
|
53
|
+
let cwd;
|
|
54
|
+
for (const previous of nodes) {
|
|
55
|
+
if (previous.endIndex >= node.startIndex) continue;
|
|
56
|
+
const values = words(previous.text);
|
|
57
|
+
if (!values) continue;
|
|
58
|
+
let index = launcherIndex(values, {});
|
|
59
|
+
if (values[index - 1] === "command" && values[index] === "-p") { index++; if (values[index] === "--") index++; }
|
|
60
|
+
if (values[index] === "builtin") { index++; if (values[index] === "--") index++; }
|
|
61
|
+
if (!["cd", "pushd", "popd"].includes(values[index])) continue;
|
|
62
|
+
const directoryIndex = index + (values[index + 1] === "--" ? 2 : 1);
|
|
63
|
+
const source = lineage(previous);
|
|
64
|
+
// A different subshell/substitution cannot change this leaf's directory.
|
|
65
|
+
// Pipelines are not treated as isolated: bash lastpipe is configurable.
|
|
66
|
+
if (source.some(unit => ["subshell", "command_substitution", "process_substitution"].includes(unit.type) && !targetIds.has(unit.id))) continue;
|
|
67
|
+
const common = source.find(unit => targetIds.has(unit.id));
|
|
68
|
+
const andList = unit => unit.type === "list" && unit.children.some(child => child.type === "&&");
|
|
69
|
+
const sourcePath = source.slice(1, source.indexOf(common));
|
|
70
|
+
const targetPath = target.slice(1, target.findIndex(unit => unit.id === common?.id));
|
|
71
|
+
// Only a successful, foreground absolute cd dominating the leaf is a
|
|
72
|
+
// route. This walks the grammar's nested left-associative && lists;
|
|
73
|
+
// it does not evaluate conditionals, directory stacks or shell programs.
|
|
74
|
+
// `command cd` is a builtin in bash but an external child in zsh;
|
|
75
|
+
// without native dialect proof none of these prefixes proves parent cwd.
|
|
76
|
+
if (values.slice(0, index).some(value => ["env", "exec", "command"].includes(path.basename(value)))
|
|
77
|
+
|| values[index] !== "cd" || values.length !== directoryIndex + 1 || !path.isAbsolute(values[directoryIndex]) || dynamicWord(previous)
|
|
78
|
+
|| backgroundContext(previous) || !common || !andList(common)
|
|
79
|
+
|| sourcePath.some(unit => unit.type !== "redirected_statement" && !andList(unit))
|
|
80
|
+
|| targetPath.some(unit => !["redirected_statement", "subshell", "compound_statement"].includes(unit.type) && !andList(unit))) return { unresolved: true };
|
|
81
|
+
cwd = path.resolve(values[directoryIndex]);
|
|
82
|
+
}
|
|
83
|
+
return { cwd };
|
|
84
|
+
}
|
|
85
|
+
function invocationFromWords(values, node, wrapped, env) {
|
|
86
|
+
let index = launcherIndex(values, env);
|
|
87
|
+
if (index === null) return null;
|
|
88
|
+
let executable = values[index], launcher;
|
|
89
|
+
if (path.basename(executable ?? "") === "node" && (path.basename(values[index + 1] ?? "") === "cli.js" || values[index + 1] === "$DD_FLOW_BIN")) {
|
|
90
|
+
launcher = executable; executable = values[++index];
|
|
91
|
+
}
|
|
92
|
+
if (!executable || !(path.basename(executable) === "dd-flow" || executable === "$DD_FLOW_BIN" || launcher && path.basename(executable) === "cli.js")) return null;
|
|
93
|
+
const argv = values.slice(index + 1), operation = lifecycleOperationForArgv(argv);
|
|
94
|
+
if (!operation) return null;
|
|
95
|
+
const contexts = ancestors(node);
|
|
96
|
+
const heredoc = node.parent?.type === "redirected_statement" && node.parent.descendantsOfType("heredoc_redirect").length > 0;
|
|
97
|
+
return { operation, executable, ...(launcher ? { launcher } : {}), argv, env, args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
|
|
98
|
+
command: quote(values), rewriteSafe: !wrapped, wrapped, heredoc, contexts, ...(!wrapped ? { sourceRange: { start: node.startIndex, end: node.endIndex } } : {}) };
|
|
99
|
+
}
|
|
100
|
+
export function parseLifecycleCommand(command) { return analyze(command, false, 0); }
|
|
101
|
+
function analyze(command, wrapped, depth) {
|
|
102
|
+
if (depth > 8 || command.length > 1_000_000) return { kind: "none", unresolved: true, reason: "shell_input_limit" };
|
|
103
|
+
const tree = parser.parse(command);
|
|
104
|
+
if (!tree) return { kind: "none", unresolved: true, reason: "shell_parse_error" };
|
|
105
|
+
try {
|
|
106
|
+
const root = tree.rootNode;
|
|
107
|
+
if (root.hasError) return { kind: "none", unresolved: true, reason: "shell_parse_error" };
|
|
108
|
+
const nodes = root.descendantsOfType("command"), found = [];
|
|
109
|
+
let unsupported;
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
const name = node.childForFieldName("name"), values = words(node.text);
|
|
112
|
+
if (!name || !values) { if (name && dynamicWord(name)) unsupported ??= "dynamic_executable"; continue; }
|
|
113
|
+
const inherited = {};
|
|
114
|
+
// Conditional exports cannot become unconditional facts in a sibling.
|
|
115
|
+
for (const previous of root.namedChildren) {
|
|
116
|
+
if (previous.endIndex > node.startIndex) break;
|
|
117
|
+
if (previous.type !== "declaration_command" || !previous.text.startsWith("export ")) continue;
|
|
118
|
+
for (const assignment of previous.descendantsOfType("variable_assignment")) {
|
|
119
|
+
if (dynamicWord(assignment)) continue;
|
|
120
|
+
const decoded = words(assignment.text)?.[0], equals = decoded?.indexOf("=") ?? -1;
|
|
121
|
+
if (decoded && equals > 0) inherited[decoded.slice(0, equals)] = decoded.slice(equals + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const direct = invocationFromWords(values, node, wrapped, inherited);
|
|
125
|
+
if (direct) {
|
|
126
|
+
if (backgroundContext(node)) unsupported ??= "background_lifecycle_execution";
|
|
127
|
+
if (node.namedChildren.some(dynamicWord)) unsupported ??= "unresolved_lifecycle_word";
|
|
128
|
+
if (direct.contexts.some(type => repeatedContexts.has(type))) unsupported ??= "repeated_lifecycle_context";
|
|
129
|
+
if (direct.contexts.some(type => ["command_substitution", "process_substitution"].includes(type))) unsupported ??= "executable_substitution";
|
|
130
|
+
const route = executionCwd(node, nodes);
|
|
131
|
+
if (route.unresolved) unsupported ??= "unresolved_execution_cwd";
|
|
132
|
+
else if (route.cwd) direct.expectedCwd = route.cwd;
|
|
133
|
+
found.push(direct); continue;
|
|
134
|
+
}
|
|
135
|
+
const outerEnv = { ...inherited }, shellIndex = launcherIndex(values, outerEnv);
|
|
136
|
+
const wrapper = path.basename(values[shellIndex] ?? "");
|
|
137
|
+
if (["time", "nohup", "timeout"].includes(wrapper)) {
|
|
138
|
+
let nestedIndex = shellIndex + 1;
|
|
139
|
+
if (wrapper === "time" && values[nestedIndex] === "-p") nestedIndex++;
|
|
140
|
+
if (values[nestedIndex] === "--") nestedIndex++;
|
|
141
|
+
if (wrapper === "timeout" && /^\d+(?:\.\d+)?[smhd]?$/.test(values[nestedIndex] ?? "")) nestedIndex++;
|
|
142
|
+
const nested = analyze(quote(values.slice(nestedIndex)), true, depth + 1);
|
|
143
|
+
if (nested.kind !== "none") { found.push(...(nested.invocations ?? [nested.invocation])); unsupported ??= "opaque_execution_wrapper"; continue; }
|
|
144
|
+
if (nested.unresolved) unsupported ??= nested.reason;
|
|
145
|
+
}
|
|
146
|
+
if (shellIndex !== null && shells.has(path.basename(values[shellIndex] ?? "")) && /^-(?:l?c|cl)$/.test(values[shellIndex + 1] ?? "") && values[shellIndex + 2] !== undefined) {
|
|
147
|
+
const payloadNode = node.namedChildren[shellIndex + 2];
|
|
148
|
+
if (!payloadNode || dynamicWord(payloadNode)) { unsupported ??= "dynamic_shell_payload"; continue; }
|
|
149
|
+
if (node.namedChildren.some(child => child !== payloadNode && dynamicWord(child))) unsupported ??= "unresolved_shell_environment";
|
|
150
|
+
const nested = analyze(values[shellIndex + 2], true, depth + 1);
|
|
151
|
+
if (nested.kind !== "none") {
|
|
152
|
+
const route = executionCwd(node, nodes);
|
|
153
|
+
if (route.unresolved) unsupported ??= "unresolved_execution_cwd";
|
|
154
|
+
for (const invocation of nested.invocations ?? [nested.invocation]) {
|
|
155
|
+
invocation.env = { ...outerEnv, ...invocation.env };
|
|
156
|
+
if (!invocation.expectedCwd && route.cwd) invocation.expectedCwd = route.cwd;
|
|
157
|
+
}
|
|
158
|
+
found.push(...(nested.invocations ?? [nested.invocation]));
|
|
159
|
+
if (backgroundContext(node)) unsupported ??= "background_lifecycle_execution";
|
|
160
|
+
if (ancestors(node).some(type => repeatedContexts.has(type))) unsupported ??= "repeated_lifecycle_context";
|
|
161
|
+
if (ancestors(node).some(type => ["command_substitution", "process_substitution"].includes(type))) unsupported ??= "executable_substitution";
|
|
162
|
+
if (nested.supported === false) unsupported ??= nested.reason ?? "unsupported_wrapper";
|
|
163
|
+
}
|
|
164
|
+
else if (nested.unresolved) unsupported ??= nested.reason;
|
|
165
|
+
} else if (values[shellIndex] === "eval") unsupported ??= "dynamic_eval";
|
|
166
|
+
else if (values[shellIndex] === "coproc") unsupported ??= "background_lifecycle_execution";
|
|
167
|
+
else if (dynamicWord(name)) unsupported ??= "dynamic_executable";
|
|
168
|
+
}
|
|
169
|
+
if (!found.length) return unsupported ? { kind: "none", unresolved: true, reason: unsupported } : { kind: "none" };
|
|
170
|
+
if (found.length > 1) unsupported ??= "multiple_lifecycle_invocations";
|
|
171
|
+
const invocation = found[0];
|
|
172
|
+
const composed = nodes.length > 1 || root.namedChildren.length !== 1 || invocation.contexts.some(type => !["program", "redirected_statement", "command"].includes(type));
|
|
173
|
+
return composed || unsupported ? { kind: "compound", invocation, invocations: found, supported: !unsupported, reason: unsupported ?? "shell_composition" }
|
|
174
|
+
: { kind: "standalone", invocation, invocations: found, supported: true };
|
|
175
|
+
} finally { /* Native Tree lifetime is managed by the Node binding. */ }
|
|
176
|
+
}
|
|
177
|
+
export function lifecycleCommandFromPayload(payload) {
|
|
178
|
+
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
179
|
+
const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
|
|
180
|
+
return typeof command === "string" ? unwrapShellCommand(command) : undefined;
|
|
181
|
+
}
|
|
182
|
+
export function continuationCommand(command) {
|
|
183
|
+
const values = words(command); if (!values) return null;
|
|
184
|
+
const start = values.findIndex(word => ["stage", "work"].includes(word)); if (start < 1) return null;
|
|
185
|
+
const launcher = values.slice(0, start);
|
|
186
|
+
while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0])) launcher.shift();
|
|
187
|
+
const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
|
|
188
|
+
const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
|
|
189
|
+
if (!direct && !node) return null;
|
|
190
|
+
const args = parseCommandArgs(values.slice(start)), route = args.positional.slice(0, 3).join(" ");
|
|
191
|
+
const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
|
|
192
|
+
return operation ? { operation, args } : null;
|
|
193
|
+
}
|
|
194
|
+
export function unwrapShellCommand(command) {
|
|
195
|
+
const values = words(command); if (!values || values.length !== 3 || !shells.has(path.basename(values[0])) || !/^-(?:l?c|cl)$/.test(values[1])) return command;
|
|
196
|
+
const tree = parser.parse(command);
|
|
197
|
+
try { const node = tree?.rootNode.namedChildren[0]?.namedChildren[2]; return node && !dynamicWord(node) ? values[2] : command; }
|
|
198
|
+
finally { /* Native Tree lifetime is managed by the Node binding. */ }
|
|
199
|
+
}
|
|
200
|
+
export function parseCommandArgs(argv) {
|
|
201
|
+
const positional = [], options = new Map();
|
|
202
|
+
for (let index = 0; index < argv.length; index++) {
|
|
203
|
+
const value = argv[index];
|
|
204
|
+
if (value?.startsWith("--")) {
|
|
205
|
+
const equals = value.indexOf("=");
|
|
206
|
+
if (equals > 2) { const key = value.slice(2, equals); options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]); continue; }
|
|
207
|
+
const key = value.slice(2), next = argv[index + 1], optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
|
|
208
|
+
options.set(key, [...(options.get(key) ?? []), optionValue]); if (optionValue !== "__dd_flow_flag__") index++;
|
|
209
|
+
} else if (value) positional.push(value);
|
|
210
|
+
}
|
|
211
|
+
return { positional, options };
|
|
212
|
+
}
|
|
213
|
+
export function commandOption(invocation, name) { const value = invocation.args.options.get(name)?.at(-1); return value === "__dd_flow_flag__" ? undefined : value; }
|
|
214
|
+
export function commandHasOption(invocation, name) { return invocation.args.options.has(name); }
|
|
215
|
+
export function commandPosition(invocation, index) { return invocation.args.positional[index]; }
|
|
216
|
+
export function shellSuffixOffset(command) {
|
|
217
|
+
const analysis = parseLifecycleCommand(command); return analysis.kind === "none" ? command.length : analysis.invocation.sourceRange?.end ?? command.length;
|
|
218
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const lifecycleRoutes: {
|
|
2
|
+
readonly "session register": "session_register"; readonly "stage start": "stage_start"; readonly "stage finish": "stage_finish";
|
|
3
|
+
readonly "stage pause": "stage_pause"; readonly "stage resume": "stage_resume"; readonly "work start": "work_start";
|
|
4
|
+
readonly "work finish": "work_finish"; readonly "work fail": "work_fail"; readonly "merge apply": "merge_apply";
|
|
5
|
+
readonly "merge repair": "merge_repair"; readonly "run recovery accept": "recovery_accept";
|
|
6
|
+
};
|
|
7
|
+
export type LifecycleOperation = typeof lifecycleRoutes[keyof typeof lifecycleRoutes];
|
|
8
|
+
export const lifecycleInternalOptions: readonly ["invocation-id", "hook-event-id"];
|
|
9
|
+
export function lifecycleOperationForArgv(argv: readonly string[]): LifecycleOperation | null;
|
|
10
|
+
export function lifecycleRouteForOperation(operation: LifecycleOperation): keyof typeof lifecycleRoutes;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const lifecycleRoutes = {
|
|
2
|
+
"session register": "session_register", "stage start": "stage_start", "stage finish": "stage_finish",
|
|
3
|
+
"stage pause": "stage_pause", "stage resume": "stage_resume", "work start": "work_start",
|
|
4
|
+
"work finish": "work_finish", "work fail": "work_fail", "merge apply": "merge_apply",
|
|
5
|
+
"merge repair": "merge_repair", "run recovery accept": "recovery_accept"
|
|
6
|
+
};
|
|
7
|
+
export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
|
|
8
|
+
export function lifecycleOperationForArgv(argv) {
|
|
9
|
+
if (argv.some(arg => arg === "--help" || arg === "-h")) return null;
|
|
10
|
+
for (const [route, operation] of Object.entries(lifecycleRoutes)) {
|
|
11
|
+
if (route.split(" ").every((part, index) => argv[index] === part)) return operation;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export function lifecycleRouteForOperation(operation) {
|
|
16
|
+
const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
|
|
17
|
+
if (!entry) throw new Error(`Unknown lifecycle operation: ${operation}`);
|
|
18
|
+
return entry[0];
|
|
19
|
+
}
|
|
@@ -293,6 +293,7 @@ export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
|
293
293
|
// signal operation; there the direct child is the safe best effort.
|
|
294
294
|
const target = process.platform === "win32" ? child.pid : -child.pid;
|
|
295
295
|
const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
|
|
296
|
+
const signal = name => { try { process.kill(target, name); return true; } catch (error) { if (error.code === "ESRCH") return false; throw error; } };
|
|
296
297
|
if (!alive()) return;
|
|
297
298
|
if ((child.exitCode !== undefined && child.exitCode !== null) || (child.signalCode !== undefined && child.signalCode !== null)) {
|
|
298
299
|
// The leader can exit a few ticks before its helpers. Observe their normal
|
|
@@ -302,11 +303,11 @@ export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
|
302
303
|
if (!alive()) return;
|
|
303
304
|
throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: { pid: child.pid } });
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
+
if (!signal("SIGTERM")) return; // The group may exit after the liveness read.
|
|
306
307
|
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
307
308
|
if (!alive()) return;
|
|
308
309
|
if (child.exitCode != null || child.signalCode != null) return stopProcessGroup(child, graceMs);
|
|
309
|
-
|
|
310
|
+
if (!signal("SIGKILL")) return;
|
|
310
311
|
// Grace controls escalation, not the OS acknowledgement budget. A caller
|
|
311
312
|
// testing immediate escalation must still allow the kernel to reap children.
|
|
312
313
|
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
@@ -5,6 +5,7 @@ import { withRunnerLock } from "./runner-lock.mjs";
|
|
|
5
5
|
|
|
6
6
|
const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
7
7
|
const fields = ["provider", "model", "reasoning", "mode", "permission_mode"];
|
|
8
|
+
const observationScope = source => JSON.stringify([source?.channel ?? null, source?.scope ?? null, source?.attempt_id ?? null, source?.turn_id ?? null, source?.non_asserting === true]);
|
|
8
9
|
export const observedFields = value => Object.fromEntries(fields.map(key => [key, text(value?.[key])]));
|
|
9
10
|
export const modelObservationFile = journal => path.join(path.dirname(journal), "model-observations.jsonl");
|
|
10
11
|
|
|
@@ -31,20 +32,23 @@ export async function readModelObservations(journal) {
|
|
|
31
32
|
export async function observeModel({ journal, harness, sessionId, parentSessionId = null, requested = {}, observed = {}, source, evidence = "configured", reason = null, nativeTimestamp = null, previousModel = null }) {
|
|
32
33
|
if (!journal || !sessionId) return null;
|
|
33
34
|
if (!source || !["configured", "request", "response", "unavailable"].includes(evidence)) throw new Error("model observation requires a native source and evidence level");
|
|
35
|
+
if (typeof source === "string") source = { channel: source, ...(["configured", "response"].includes(evidence) ? { scope: evidence } : {}) };
|
|
34
36
|
const file = modelObservationFile(journal), profile = observedFields(observed);
|
|
35
37
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
38
|
return withRunnerLock(file, async () => {
|
|
37
39
|
const events = await readModelObservations(journal);
|
|
38
|
-
const replay = source?.event_sha256 ? events.find(item => item.harness === harness && item.session_id === sessionId && item.source?.event_sha256 === source.event_sha256) : null;
|
|
40
|
+
const replay = source?.event_sha256 ? events.find(item => item.harness === harness && item.session_id === sessionId && observationScope(item.source) === observationScope(source) && item.source?.event_sha256 === source.event_sha256) : null;
|
|
39
41
|
if (replay) return replay;
|
|
40
|
-
const
|
|
41
|
-
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && (item.source?.channel ?? item.evidence) === channel
|
|
42
|
-
&& (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
42
|
+
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && observationScope(item.source) === observationScope(source));
|
|
43
43
|
const normalizedReason = text(reason)?.slice(0, 200) ?? null;
|
|
44
|
-
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence
|
|
45
|
-
|
|
44
|
+
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence
|
|
45
|
+
&& previous.source?.raw_model === source?.raw_model && previous.source?.raw_provider === source?.raw_provider) return previous;
|
|
46
|
+
const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model
|
|
47
|
+
&& (item.source?.scope ?? "session") === (source?.scope ?? "session") && (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
46
48
|
const fromModel = text(previousModel) ?? previousKnown?.observed.model ?? null;
|
|
47
|
-
const
|
|
49
|
+
const previousRouting = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.provider
|
|
50
|
+
&& (item.source?.scope ?? "session") === (source?.scope ?? "session") && (item.source?.attempt_id ?? null) === (source?.attempt_id ?? null));
|
|
51
|
+
const changed = Boolean(profile.model && fromModel && (profile.model !== fromModel || profile.provider && previousRouting && profile.provider !== previousRouting.observed.provider));
|
|
48
52
|
const value = { schema_id: "dd-flow/model-observation@1", sequence: events.length + 1,
|
|
49
53
|
harness, session_id: sessionId, parent_session_id: parentSessionId,
|
|
50
54
|
kind: changed ? "model_changed" : !profile.model ? "model_observation_unavailable" : "model_observed",
|
|
@@ -76,19 +80,22 @@ export function modelAttribution(events, expectedSessions = []) {
|
|
|
76
80
|
const missingSessions = expectedSessions.filter(session => typeof session === "string" ? !observedIds.has(session)
|
|
77
81
|
: !sessions.has(`${session.harness}:${session.session_id}`));
|
|
78
82
|
const models = [...new Set(events.map(event => event.observed?.model).filter(Boolean))];
|
|
83
|
+
const providers = [...new Set(events.map(event => event.observed?.provider).filter(Boolean))];
|
|
79
84
|
const incomplete = !events.length || missingSessions.length > 0 || [...sessions.values()].some(session => {
|
|
80
85
|
const known = session.observations.filter(event => event.observed?.model);
|
|
81
86
|
if (!known.length) return true;
|
|
82
87
|
const knownScopes = new Set(known.map(event => `${event.evidence}:${event.source?.scope ?? "session"}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`));
|
|
83
88
|
return session.observations.some(event => {
|
|
84
|
-
if (event.evidence !== "unavailable") return false;
|
|
85
89
|
if (event.source?.non_asserting) return false;
|
|
90
|
+
if (["configured", "response"].includes(event.evidence) && !event.observed?.model) return true;
|
|
91
|
+
if (event.evidence !== "unavailable") return false;
|
|
86
92
|
const scope = event.source?.scope;
|
|
87
93
|
return !scope || !knownScopes.has(`configured:${scope}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`)
|
|
88
94
|
&& !knownScopes.has(`response:${scope}:${event.source?.attempt_id ?? ""}:${event.source?.turn_id ?? ""}`);
|
|
89
95
|
});
|
|
90
96
|
});
|
|
91
|
-
return { models, mixed: models.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model
|
|
97
|
+
return { models, providers, mixed: models.length > 1 || providers.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model
|
|
98
|
+
|| event.observed?.provider && event.requested?.provider && event.observed.provider !== event.requested.provider),
|
|
92
99
|
observation_completeness: incomplete ? "incomplete" : "available_native_sources", transitions: events.filter(event => event.kind === "model_changed"),
|
|
93
100
|
sessions: [...sessions.values()], missing_sessions: missingSessions, usage_attribution: "session_totals_unattributed_to_model", note: "Native settings/events do not prove undisclosed server routing or per-model token costs." };
|
|
94
101
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
|
-
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { remaining(phase: string): number };
|
|
1
|
+
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
|
+
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
|
|
3
3
|
export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
|