@united-workforce/cli 0.2.1-rc.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +15 -8
  2. package/dist/__tests__/adapter-json-roundtrip.test.js +1 -1
  3. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  4. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  8. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  10. package/dist/__tests__/build-step-entry.test.js +173 -0
  11. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  12. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  16. package/dist/__tests__/config.test.js +26 -302
  17. package/dist/__tests__/config.test.js.map +1 -1
  18. package/dist/__tests__/current-role.test.js +7 -6
  19. package/dist/__tests__/current-role.test.js.map +1 -1
  20. package/dist/__tests__/e2e-mock-agent.test.js +20 -23
  21. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  22. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  23. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  24. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  25. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  26. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  27. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  28. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  29. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  30. package/dist/__tests__/pid-recycling.test.js +271 -0
  31. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  32. package/dist/__tests__/prompt.test.js +321 -0
  33. package/dist/__tests__/prompt.test.js.map +1 -1
  34. package/dist/__tests__/resolve-head-hash.test.js +4 -4
  35. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  36. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  37. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  38. package/dist/__tests__/setup-complexity.test.js +2 -168
  39. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  40. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  41. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  42. package/dist/__tests__/setup-no-llm.test.js +52 -0
  43. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  44. package/dist/__tests__/solve-issue-tea-worktree.test.js +24 -27
  45. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  46. package/dist/__tests__/step-ask.test.d.ts +2 -0
  47. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  48. package/dist/__tests__/step-ask.test.js +499 -0
  49. package/dist/__tests__/step-ask.test.js.map +1 -0
  50. package/dist/__tests__/step-show-json.test.js +1 -0
  51. package/dist/__tests__/step-show-json.test.js.map +1 -1
  52. package/dist/__tests__/step-timing.test.js +2 -0
  53. package/dist/__tests__/step-timing.test.js.map +1 -1
  54. package/dist/__tests__/store-global-cas.test.js +2 -2
  55. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  56. package/dist/__tests__/store-unified-threads.test.js +9 -9
  57. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  58. package/dist/__tests__/thread-cancel-status.test.js +6 -6
  59. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  60. package/dist/__tests__/thread-list-filters.test.js +344 -9
  61. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  62. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  63. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  64. package/dist/__tests__/thread-poke.test.js +412 -0
  65. package/dist/__tests__/thread-poke.test.js.map +1 -0
  66. package/dist/__tests__/thread-resume.test.js +10 -14
  67. package/dist/__tests__/thread-resume.test.js.map +1 -1
  68. package/dist/__tests__/thread-show-status.test.js +17 -28
  69. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  70. package/dist/__tests__/thread-suspend-step.test.js +8 -14
  71. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  72. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  73. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  74. package/dist/__tests__/thread.test.js +4 -4
  75. package/dist/__tests__/thread.test.js.map +1 -1
  76. package/dist/__tests__/validate-semantic.test.js +49 -21
  77. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  78. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  79. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  80. package/dist/__tests__/workflow-list-recursive.test.js +283 -0
  81. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  82. package/dist/__tests__/workflow-resolution.test.js +36 -21
  83. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  84. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  85. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  86. package/dist/__tests__/workflow-show-resolution.test.js +210 -0
  87. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  88. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  89. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  90. package/dist/__tests__/workflow-validate.test.js +687 -0
  91. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  92. package/dist/background/background.d.ts +22 -1
  93. package/dist/background/background.d.ts.map +1 -1
  94. package/dist/background/background.js +83 -6
  95. package/dist/background/background.js.map +1 -1
  96. package/dist/background/index.d.ts +1 -1
  97. package/dist/background/index.d.ts.map +1 -1
  98. package/dist/background/index.js +1 -1
  99. package/dist/background/index.js.map +1 -1
  100. package/dist/background/types.d.ts +1 -0
  101. package/dist/background/types.d.ts.map +1 -1
  102. package/dist/cli.js +66 -31
  103. package/dist/cli.js.map +1 -1
  104. package/dist/commands/config.d.ts +3 -1
  105. package/dist/commands/config.d.ts.map +1 -1
  106. package/dist/commands/config.js +7 -33
  107. package/dist/commands/config.js.map +1 -1
  108. package/dist/commands/prompt.d.ts.map +1 -1
  109. package/dist/commands/prompt.js +15 -2
  110. package/dist/commands/prompt.js.map +1 -1
  111. package/dist/commands/setup.d.ts +7 -39
  112. package/dist/commands/setup.d.ts.map +1 -1
  113. package/dist/commands/setup.js +27 -302
  114. package/dist/commands/setup.js.map +1 -1
  115. package/dist/commands/step.d.ts +44 -1
  116. package/dist/commands/step.d.ts.map +1 -1
  117. package/dist/commands/step.js +255 -11
  118. package/dist/commands/step.js.map +1 -1
  119. package/dist/commands/thread.d.ts +16 -3
  120. package/dist/commands/thread.d.ts.map +1 -1
  121. package/dist/commands/thread.js +379 -140
  122. package/dist/commands/thread.js.map +1 -1
  123. package/dist/commands/workflow.d.ts +9 -1
  124. package/dist/commands/workflow.d.ts.map +1 -1
  125. package/dist/commands/workflow.js +130 -6
  126. package/dist/commands/workflow.js.map +1 -1
  127. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  128. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  129. package/dist/moderator/evaluate.d.ts.map +1 -1
  130. package/dist/moderator/evaluate.js +4 -16
  131. package/dist/moderator/evaluate.js.map +1 -1
  132. package/dist/moderator/index.d.ts +1 -2
  133. package/dist/moderator/index.d.ts.map +1 -1
  134. package/dist/moderator/index.js +0 -1
  135. package/dist/moderator/index.js.map +1 -1
  136. package/dist/moderator/types.d.ts +6 -10
  137. package/dist/moderator/types.d.ts.map +1 -1
  138. package/dist/moderator/types.js +1 -3
  139. package/dist/moderator/types.js.map +1 -1
  140. package/dist/schemas.d.ts +2 -0
  141. package/dist/schemas.d.ts.map +1 -1
  142. package/dist/schemas.js +5 -3
  143. package/dist/schemas.js.map +1 -1
  144. package/dist/store.d.ts +28 -9
  145. package/dist/store.d.ts.map +1 -1
  146. package/dist/store.js +75 -16
  147. package/dist/store.js.map +1 -1
  148. package/dist/validate-semantic.d.ts.map +1 -1
  149. package/dist/validate-semantic.js +83 -66
  150. package/dist/validate-semantic.js.map +1 -1
  151. package/dist/validate.d.ts +6 -0
  152. package/dist/validate.d.ts.map +1 -1
  153. package/dist/validate.js +24 -0
  154. package/dist/validate.js.map +1 -1
  155. package/package.json +8 -10
  156. package/src/__tests__/adapter-json-roundtrip.test.ts +1 -1
  157. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  158. package/src/__tests__/build-step-entry.test.ts +203 -0
  159. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  160. package/src/__tests__/config.test.ts +33 -321
  161. package/src/__tests__/current-role.test.ts +7 -6
  162. package/src/__tests__/e2e-mock-agent.test.ts +20 -23
  163. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  164. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  165. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  166. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  167. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  168. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  169. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  170. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  171. package/src/__tests__/pid-recycling.test.ts +328 -0
  172. package/src/__tests__/prompt.test.ts +397 -0
  173. package/src/__tests__/resolve-head-hash.test.ts +4 -4
  174. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  175. package/src/__tests__/setup-complexity.test.ts +1 -203
  176. package/src/__tests__/setup-no-llm.test.ts +68 -0
  177. package/src/__tests__/solve-issue-tea-worktree.test.ts +24 -30
  178. package/src/__tests__/step-ask.test.ts +670 -0
  179. package/src/__tests__/step-show-json.test.ts +1 -0
  180. package/src/__tests__/step-timing.test.ts +2 -0
  181. package/src/__tests__/store-global-cas.test.ts +2 -2
  182. package/src/__tests__/store-unified-threads.test.ts +9 -9
  183. package/src/__tests__/thread-cancel-status.test.ts +6 -6
  184. package/src/__tests__/thread-list-filters.test.ts +434 -8
  185. package/src/__tests__/thread-poke.test.ts +545 -0
  186. package/src/__tests__/thread-resume.test.ts +10 -14
  187. package/src/__tests__/thread-show-status.test.ts +17 -29
  188. package/src/__tests__/thread-suspend-step.test.ts +8 -14
  189. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  190. package/src/__tests__/thread.test.ts +4 -4
  191. package/src/__tests__/validate-semantic.test.ts +59 -31
  192. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  193. package/src/__tests__/workflow-resolution.test.ts +39 -21
  194. package/src/__tests__/workflow-show-resolution.test.ts +285 -0
  195. package/src/__tests__/workflow-validate.test.ts +806 -0
  196. package/src/background/background.ts +88 -6
  197. package/src/background/index.ts +2 -0
  198. package/src/background/types.ts +1 -0
  199. package/src/cli.ts +97 -47
  200. package/src/commands/config.ts +7 -35
  201. package/src/commands/prompt.ts +15 -2
  202. package/src/commands/setup.ts +29 -357
  203. package/src/commands/step.ts +339 -12
  204. package/src/commands/thread.ts +463 -169
  205. package/src/commands/workflow.ts +159 -4
  206. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  207. package/src/moderator/evaluate.ts +5 -17
  208. package/src/moderator/index.ts +1 -6
  209. package/src/moderator/types.ts +6 -14
  210. package/src/schemas.ts +13 -3
  211. package/src/store.ts +86 -20
  212. package/src/validate-semantic.ts +109 -78
  213. package/src/validate.ts +27 -0
  214. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  215. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  216. package/dist/__tests__/setup-validate.test.js +0 -108
  217. package/dist/__tests__/setup-validate.test.js.map +0 -1
  218. package/src/__tests__/setup-validate.test.ts +0 -148
  219. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -1,5 +1,8 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import type { CasStore } from "@ocas/core";
2
3
  import type {
4
+ AgentAlias,
5
+ AgentConfig,
3
6
  CasRef,
4
7
  StartEntry,
5
8
  StepEntry,
@@ -7,9 +10,12 @@ import type {
7
10
  ThreadForkOutput,
8
11
  ThreadId,
9
12
  ThreadStepsOutput,
13
+ WorkflowConfig,
14
+ WorkflowPayload,
10
15
  } from "@united-workforce/protocol";
11
- import { generateUlid } from "@united-workforce/util";
12
- import { createUwfStore, setThread } from "../store.js";
16
+ import { createLogger, generateUlid } from "@united-workforce/util";
17
+ import { getAskSessionId, loadWorkflowConfig, setAskSessionId } from "@united-workforce/util-agent";
18
+ import { createUwfStore, setThread, type UwfStore } from "../store.js";
13
19
  import {
14
20
  collectOrderedSteps,
15
21
  expandDeep,
@@ -19,6 +25,8 @@ import {
19
25
  walkChain,
20
26
  } from "./shared.js";
21
27
 
28
+ const log = createLogger({ sink: { kind: "stderr" } });
29
+
22
30
  type TurnToolCall = {
23
31
  name: string;
24
32
  args: string;
@@ -31,6 +39,117 @@ type TurnData = {
31
39
  toolCalls: TurnToolCall[] | null;
32
40
  };
33
41
 
42
+ /**
43
+ * Build a StepEntry for a single step CAS hash, recursively populating its
44
+ * `previousAttempts` from prior failed StepNode hashes (if any). Failed steps
45
+ * are persisted to CAS but never reachable through `prev`; they live only via
46
+ * the successful step's `previousAttempts` array.
47
+ */
48
+ export function buildStepEntry(uwf: UwfStore, stepHash: CasRef): StepEntry | null {
49
+ const node = uwf.store.cas.get(stepHash);
50
+ if (node === null || node.type !== uwf.schemas.stepNode) {
51
+ return null;
52
+ }
53
+ const payload = node.payload as StepNodePayload;
54
+ const previousHashes = payload.previousAttempts ?? null;
55
+ let previousAttempts: StepEntry[] | null = null;
56
+ if (previousHashes !== null && previousHashes.length > 0) {
57
+ const entries: StepEntry[] = [];
58
+ for (const prevHash of previousHashes) {
59
+ const entry = buildStepEntry(uwf, prevHash);
60
+ if (entry !== null) {
61
+ entries.push(entry);
62
+ } else {
63
+ log(
64
+ "STP7K2QM",
65
+ `previousAttempts ref ${prevHash} for step ${stepHash} did not resolve to a StepNode; skipping it in retry lineage`,
66
+ );
67
+ }
68
+ }
69
+ previousAttempts = entries.length > 0 ? entries : null;
70
+ }
71
+ return {
72
+ hash: stepHash,
73
+ role: payload.role,
74
+ output: expandOutput(uwf, payload.output),
75
+ detail: payload.detail ?? null,
76
+ agent: payload.agent,
77
+ timestamp: node.timestamp,
78
+ durationMs: payload.completedAtMs - payload.startedAtMs,
79
+ usage: payload.usage ?? null,
80
+ previousAttempts,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Sum of usage across an entry and its nested previousAttempts.
86
+ * Treats null usage as zero. Returns a flat aggregate — recursive traversal is
87
+ * internal.
88
+ */
89
+ export function sumStepEntryUsage(entry: StepEntry): {
90
+ turns: number;
91
+ inputTokens: number;
92
+ outputTokens: number;
93
+ duration: number;
94
+ } {
95
+ let turns = 0;
96
+ let inputTokens = 0;
97
+ let outputTokens = 0;
98
+ let duration = 0;
99
+ if (entry.usage !== null) {
100
+ turns += entry.usage.turns;
101
+ inputTokens += entry.usage.inputTokens;
102
+ outputTokens += entry.usage.outputTokens;
103
+ duration += entry.usage.duration;
104
+ }
105
+ if (entry.previousAttempts !== null) {
106
+ for (const attempt of entry.previousAttempts) {
107
+ const sub = sumStepEntryUsage(attempt);
108
+ turns += sub.turns;
109
+ inputTokens += sub.inputTokens;
110
+ outputTokens += sub.outputTokens;
111
+ duration += sub.duration;
112
+ }
113
+ }
114
+ return { turns, inputTokens, outputTokens, duration };
115
+ }
116
+
117
+ /**
118
+ * Aggregate token usage across the entire thread chain, including any
119
+ * recorded failed retry attempts via `previousAttempts`. Returns zeros when
120
+ * no usage is recorded anywhere on the thread.
121
+ */
122
+ export async function aggregateThreadUsage(
123
+ storageRoot: string,
124
+ threadId: ThreadId,
125
+ ): Promise<{
126
+ turns: number;
127
+ inputTokens: number;
128
+ outputTokens: number;
129
+ duration: number;
130
+ }> {
131
+ const result = await cmdStepList(storageRoot, threadId);
132
+ let turns = 0;
133
+ let inputTokens = 0;
134
+ let outputTokens = 0;
135
+ let duration = 0;
136
+ for (const entry of result.steps) {
137
+ if (!isStepEntry(entry)) {
138
+ continue;
139
+ }
140
+ const sub = sumStepEntryUsage(entry);
141
+ turns += sub.turns;
142
+ inputTokens += sub.inputTokens;
143
+ outputTokens += sub.outputTokens;
144
+ duration += sub.duration;
145
+ }
146
+ return { turns, inputTokens, outputTokens, duration };
147
+ }
148
+
149
+ function isStepEntry(entry: StartEntry | StepEntry): entry is StepEntry {
150
+ return "role" in entry && "agent" in entry;
151
+ }
152
+
34
153
  /**
35
154
  * List all steps in a thread (previously: thread steps)
36
155
  */
@@ -58,16 +177,10 @@ export async function cmdStepList(
58
177
  const ordered = collectOrderedSteps(uwf, headHash, chain);
59
178
 
60
179
  for (const item of ordered) {
61
- stepEntries.push({
62
- hash: item.hash,
63
- role: item.payload.role,
64
- output: expandOutput(uwf, item.payload.output),
65
- detail: item.payload.detail ?? null,
66
- agent: item.payload.agent,
67
- timestamp: item.timestamp,
68
- durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
69
- usage: item.payload.usage ?? null,
70
- });
180
+ const entry = buildStepEntry(uwf, item.hash);
181
+ if (entry !== null) {
182
+ stepEntries.push(entry);
183
+ }
71
184
  }
72
185
 
73
186
  return {
@@ -341,3 +454,217 @@ export async function cmdStepRead(
341
454
 
342
455
  return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
343
456
  }
457
+
458
+ // ── step ask ────────────────────────────────────────────────────────────────
459
+
460
+ function parseAgentOverride(override: string): AgentConfig {
461
+ const parts = override
462
+ .trim()
463
+ .split(/\s+/)
464
+ .filter((p) => p.length > 0);
465
+ const command = parts[0];
466
+ if (command === undefined) {
467
+ fail("agent override must not be empty");
468
+ }
469
+ return { command, args: parts.slice(1) };
470
+ }
471
+
472
+ function resolveAskAgentConfig(
473
+ config: WorkflowConfig,
474
+ workflow: WorkflowPayload | null,
475
+ role: string,
476
+ agentOverride: string | null,
477
+ recordedAgent: string,
478
+ ): AgentConfig {
479
+ if (agentOverride !== null) {
480
+ const fromAlias = config.agents[agentOverride as AgentAlias];
481
+ if (fromAlias !== undefined) {
482
+ return fromAlias;
483
+ }
484
+ return parseAgentOverride(agentOverride);
485
+ }
486
+
487
+ // Try to resolve via the recorded agent name as a config alias.
488
+ const fromRecorded = config.agents[recordedAgent as AgentAlias];
489
+ if (fromRecorded !== undefined) {
490
+ return fromRecorded;
491
+ }
492
+
493
+ // Fall back to default agent for the workflow / role.
494
+ if (workflow !== null && config.agentOverrides !== null) {
495
+ const roleOverrides = config.agentOverrides[workflow.name];
496
+ if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
497
+ const alias = roleOverrides[role];
498
+ const agentConfig = config.agents[alias];
499
+ if (agentConfig !== undefined) {
500
+ return agentConfig;
501
+ }
502
+ }
503
+ }
504
+
505
+ // Treat the recorded value as a raw command path.
506
+ return parseAgentOverride(recordedAgent);
507
+ }
508
+
509
+ /**
510
+ * Derive the agent name used for cache file partitioning from an executable
511
+ * path or alias. Examples:
512
+ * uwf-hermes → hermes
513
+ * uwf-claude-code → claude-code
514
+ * /tmp/mock-agent.sh → mock
515
+ * /usr/bin/agent → agent
516
+ */
517
+ function deriveAgentName(commandPath: string): string {
518
+ const basename = commandPath.split(/[/\\]/).pop() ?? commandPath;
519
+ // Strip a trailing extension (.sh, .js, .mjs, .cjs)
520
+ const noExt = basename.replace(/\.(sh|js|mjs|cjs|ts)$/i, "");
521
+ // Strip the `uwf-` prefix introduced by agentLabel().
522
+ const noPrefix = noExt.startsWith("uwf-") ? noExt.slice(4) : noExt;
523
+ // Strip the trailing `-agent` suffix used by tests / generic agent shells.
524
+ const noSuffix = noPrefix.endsWith("-agent") ? noPrefix.slice(0, -"-agent".length) : noPrefix;
525
+ return noSuffix === "" ? noExt : noSuffix;
526
+ }
527
+
528
+ function loadDetailNode(
529
+ store: CasStore,
530
+ detailRef: CasRef,
531
+ ): { sessionId: string | null; payload: Record<string, unknown> } {
532
+ const detailNode = store.get(detailRef);
533
+ if (detailNode === null) {
534
+ fail(`detail node not found: ${detailRef}`);
535
+ }
536
+ const payload = detailNode.payload as Record<string, unknown>;
537
+ const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
538
+ return { sessionId, payload };
539
+ }
540
+
541
+ function spawnAskAgent(agent: AgentConfig, argv: string[], cwd: string): { stdout: string } {
542
+ try {
543
+ const stdout = execFileSync(agent.command, [...agent.args, ...argv], {
544
+ encoding: "utf8",
545
+ stdio: ["ignore", "pipe", "pipe"],
546
+ maxBuffer: 50 * 1024 * 1024,
547
+ cwd,
548
+ });
549
+ return { stdout };
550
+ } catch (e) {
551
+ const err = e as NodeJS.ErrnoException & { stderr: Buffer | string | null };
552
+ if (err.code === "ENOENT") {
553
+ fail(
554
+ `"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
555
+ );
556
+ }
557
+ const stderr =
558
+ err.stderr == null
559
+ ? ""
560
+ : typeof err.stderr === "string"
561
+ ? err.stderr
562
+ : err.stderr.toString("utf8");
563
+ const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
564
+ fail(`agent command failed (${agent.command})${detail}`);
565
+ }
566
+ }
567
+
568
+ function resolveAskWorkflow(uwf: UwfStore, payload: StepNodePayload): WorkflowPayload | null {
569
+ const startNode = uwf.store.cas.get(payload.start);
570
+ if (startNode === null) {
571
+ return null;
572
+ }
573
+ const start = startNode.payload as { workflow: CasRef };
574
+ const workflowNode = uwf.store.cas.get(start.workflow);
575
+ if (workflowNode === null) {
576
+ return null;
577
+ }
578
+ return workflowNode.payload as WorkflowPayload;
579
+ }
580
+
581
+ async function performFork(
582
+ agent: AgentConfig,
583
+ agentName: string,
584
+ stepHash: CasRef,
585
+ sourceSessionId: string,
586
+ storageRoot: string,
587
+ cwd: string,
588
+ ): Promise<string> {
589
+ const cached = await getAskSessionId(agentName, stepHash, storageRoot);
590
+ if (cached !== null) {
591
+ return cached;
592
+ }
593
+ const { stdout } = spawnAskAgent(agent, ["--mode", "fork", "--session", sourceSessionId], cwd);
594
+ const newSessionId = stdout.trim().split("\n").pop()?.trim() ?? "";
595
+ if (newSessionId === "") {
596
+ fail(`agent fork did not return a session id (${agent.command})`);
597
+ }
598
+ await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
599
+ return newSessionId;
600
+ }
601
+
602
+ export type CmdStepAskOptions = {
603
+ prompt: string;
604
+ agentOverride: string | null;
605
+ /** When false, skip session forking and pass detail ref for context injection. */
606
+ fork: boolean;
607
+ };
608
+
609
+ /**
610
+ * Ask a follow-up question to a historical step's agent (read-only).
611
+ *
612
+ * Does NOT write a new StepNode and does NOT mutate thread state. The agent's
613
+ * raw stdout is returned so the CLI entry point can stream it directly.
614
+ */
615
+ export async function cmdStepAsk(
616
+ storageRoot: string,
617
+ stepHash: CasRef,
618
+ options: CmdStepAskOptions,
619
+ ): Promise<string> {
620
+ const uwf = await createUwfStore(storageRoot);
621
+ const node = uwf.store.cas.get(stepHash);
622
+ if (node === null) {
623
+ fail(`CAS node not found: ${stepHash}`);
624
+ }
625
+ if (node.type !== uwf.schemas.stepNode) {
626
+ fail(`node ${stepHash} is not a StepNode`);
627
+ }
628
+ const payload = node.payload as StepNodePayload;
629
+ if (payload.detail === null) {
630
+ fail(`step ${stepHash} has no detail; cannot ask`);
631
+ }
632
+
633
+ const detailRef = payload.detail;
634
+ const { sessionId: sourceSessionId } = loadDetailNode(uwf.store.cas, detailRef);
635
+
636
+ const workflow = resolveAskWorkflow(uwf, payload);
637
+ const config = await loadWorkflowConfig(storageRoot);
638
+ const agent = resolveAskAgentConfig(
639
+ config,
640
+ workflow,
641
+ payload.role,
642
+ options.agentOverride,
643
+ payload.agent,
644
+ );
645
+ const agentName = deriveAgentName(agent.command);
646
+
647
+ const cwd = payload.cwd !== "" ? payload.cwd : process.cwd();
648
+
649
+ // Fork path: fork (or reuse cached fork) → ask with that session.
650
+ if (options.fork && sourceSessionId !== null) {
651
+ const askSessionId = await performFork(
652
+ agent,
653
+ agentName,
654
+ stepHash,
655
+ sourceSessionId,
656
+ storageRoot,
657
+ cwd,
658
+ );
659
+ const argv = ["--mode", "ask", "--session", askSessionId, "--prompt", options.prompt];
660
+ argv.push("--detail", detailRef);
661
+ const { stdout } = spawnAskAgent(agent, argv, cwd);
662
+ return stdout;
663
+ }
664
+
665
+ // Fallback path: ask without forking; inject detail ref for context.
666
+ const argv = ["--mode", "ask", "--prompt", options.prompt];
667
+ argv.push("--detail", detailRef);
668
+ const { stdout } = spawnAskAgent(agent, argv, cwd);
669
+ return stdout;
670
+ }