@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/cli/run-cli.js +3 -0
  6. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  7. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +68 -6
  8. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +21 -5
  9. package/dist/harness-runtime/lib/dd-codex.mjs +5 -1
  10. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  11. package/dist/harness-runtime/lib/dd-zcode.mjs +40 -16
  12. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  13. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  14. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  15. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  16. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  17. package/dist/harness-runtime/lib/managed-daemon.mjs +3 -2
  18. package/dist/harness-runtime/lib/model-observations.mjs +16 -9
  19. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  20. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  21. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  22. package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
  23. package/dist/runtime/context.js +1 -1
  24. package/dist/schemas/flow-run.schema.json +1 -0
  25. package/dist/services/codex-capacity.js +131 -12
  26. package/dist/services/codex-hook-delivery.js +6 -2
  27. package/dist/services/eval-snapshots.js +13 -8
  28. package/dist/services/external-work-launch.js +27 -9
  29. package/dist/services/hooks.js +51 -59
  30. package/dist/services/lifecycle-command.js +1 -383
  31. package/dist/services/lifecycle-contract.js +1 -30
  32. package/dist/services/lifecycle-invocations.js +34 -13
  33. package/dist/services/merge-server.js +26 -9
  34. package/dist/services/review-copy.js +21 -0
  35. package/dist/services/run-controller-adapter.js +25 -3
  36. package/dist/services/run-controller-recovery.js +9 -2
  37. package/dist/services/run-controller.js +59 -20
  38. package/dist/services/run-observations.js +7 -2
  39. package/dist/services/runs.js +60 -47
  40. package/dist/services/stage-report-renderer.js +44 -2
  41. package/dist/services/verification-matrix.js +345 -0
  42. package/dist/services/vnext-code-review.js +30 -11
  43. package/dist/services/vnext-code.js +31 -10
  44. package/dist/services/vnext-merge.js +30 -7
  45. package/dist/services/vnext-plan-review.js +28 -7
  46. package/dist/services/vnext-plan.js +16 -3
  47. package/dist/services/work-registry.js +15 -1
  48. package/dist/storage/database.js +43 -9
  49. 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 observed = { model: value("model"), reasoning: value("thought_level"), mode: value("mode") };
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
- if (!this.notificationError || fatal) this.notificationError = error;
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
- provider: typeof model.providerId === "string" ? model.providerId : null,
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
- // Native PreToolUse has already admitted (or denied) the command. ACP is
717
- // intentionally only journal/model evidence: its serialized delivery can
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 as a standalone call. Invocation IDs are internal authority; never add, copy or invent one.",
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 standalone lifecycle command:",
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 standalone work finish command, then stop.",
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
- process.kill(target, "SIGTERM");
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
- process.kill(target, "SIGKILL");
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 channel = source?.channel ?? evidence;
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) return previous;
45
- const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model);
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 changed = Boolean(profile.model && fromModel && profile.model !== fromModel);
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>;