@united-workforce/cli 0.6.1 β†’ 0.8.1

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 (167) hide show
  1. package/README.md +120 -5
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
  4. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
  5. package/dist/__tests__/broker-prompt.test.d.ts +10 -0
  6. package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
  7. package/dist/__tests__/broker-prompt.test.js +129 -0
  8. package/dist/__tests__/broker-prompt.test.js.map +1 -0
  9. package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
  10. package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
  11. package/dist/__tests__/broker-step-active-turns.test.js +428 -0
  12. package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
  13. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
  14. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
  15. package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
  16. package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
  17. package/dist/__tests__/config.test.js +33 -37
  18. package/dist/__tests__/config.test.js.map +1 -1
  19. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
  20. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
  21. package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
  22. package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
  23. package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
  24. package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
  25. package/dist/__tests__/e2e-broker-step.test.js +278 -0
  26. package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
  27. package/dist/__tests__/e2e-mock-agent.test.js +1 -1
  28. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  29. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
  30. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
  31. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
  32. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
  33. package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
  34. package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
  35. package/dist/__tests__/log-tag-validity.test.js +110 -0
  36. package/dist/__tests__/log-tag-validity.test.js.map +1 -0
  37. package/dist/__tests__/setup-agent-discovery.test.js +35 -23
  38. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  39. package/dist/__tests__/setup-no-llm.test.js +5 -2
  40. package/dist/__tests__/setup-no-llm.test.js.map +1 -1
  41. package/dist/__tests__/step-ask.test.js +9 -6
  42. package/dist/__tests__/step-ask.test.js.map +1 -1
  43. package/dist/__tests__/step-show-json.test.js +5 -5
  44. package/dist/__tests__/step-show-json.test.js.map +1 -1
  45. package/dist/__tests__/step-show-text.test.d.ts +2 -0
  46. package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
  47. package/dist/__tests__/step-show-text.test.js +192 -0
  48. package/dist/__tests__/step-show-text.test.js.map +1 -0
  49. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
  50. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
  51. package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
  52. package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
  53. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
  54. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
  55. package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
  56. package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
  57. package/dist/__tests__/step-turns.test.d.ts +24 -0
  58. package/dist/__tests__/step-turns.test.d.ts.map +1 -0
  59. package/dist/__tests__/step-turns.test.js +646 -0
  60. package/dist/__tests__/step-turns.test.js.map +1 -0
  61. package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
  62. package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
  63. package/dist/__tests__/store-turn-chain.test.js +341 -0
  64. package/dist/__tests__/store-turn-chain.test.js.map +1 -0
  65. package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
  66. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
  67. package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
  68. package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
  69. package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
  70. package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
  71. package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
  72. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
  73. package/dist/__tests__/thread-poke.test.js +6 -6
  74. package/dist/__tests__/thread-poke.test.js.map +1 -1
  75. package/dist/__tests__/thread-resume.test.js +2 -2
  76. package/dist/__tests__/thread-resume.test.js.map +1 -1
  77. package/dist/__tests__/thread-suspend-step.test.js +1 -1
  78. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  79. package/dist/__tests__/thread.test.js +28 -14
  80. package/dist/__tests__/thread.test.js.map +1 -1
  81. package/dist/cli.js +910 -344
  82. package/dist/cli.js.map +1 -1
  83. package/dist/commands/broker-step.d.ts +117 -0
  84. package/dist/commands/broker-step.d.ts.map +1 -0
  85. package/dist/commands/broker-step.js +654 -0
  86. package/dist/commands/broker-step.js.map +1 -0
  87. package/dist/commands/config.d.ts.map +1 -1
  88. package/dist/commands/config.js +2 -23
  89. package/dist/commands/config.js.map +1 -1
  90. package/dist/commands/prompt.d.ts.map +1 -1
  91. package/dist/commands/prompt.js +43 -51
  92. package/dist/commands/prompt.js.map +1 -1
  93. package/dist/commands/setup.d.ts +6 -4
  94. package/dist/commands/setup.d.ts.map +1 -1
  95. package/dist/commands/setup.js +24 -27
  96. package/dist/commands/setup.js.map +1 -1
  97. package/dist/commands/step.d.ts +54 -6
  98. package/dist/commands/step.d.ts.map +1 -1
  99. package/dist/commands/step.js +484 -134
  100. package/dist/commands/step.js.map +1 -1
  101. package/dist/commands/thread.d.ts +4 -0
  102. package/dist/commands/thread.d.ts.map +1 -1
  103. package/dist/commands/thread.js +77 -151
  104. package/dist/commands/thread.js.map +1 -1
  105. package/dist/output-mappers.d.ts +8 -0
  106. package/dist/output-mappers.d.ts.map +1 -1
  107. package/dist/output-mappers.js +72 -18
  108. package/dist/output-mappers.js.map +1 -1
  109. package/dist/schemas.d.ts +3 -0
  110. package/dist/schemas.d.ts.map +1 -1
  111. package/dist/schemas.js +17 -3
  112. package/dist/schemas.js.map +1 -1
  113. package/dist/store.d.ts +147 -1
  114. package/dist/store.d.ts.map +1 -1
  115. package/dist/store.js +254 -1
  116. package/dist/store.js.map +1 -1
  117. package/dist/text-renderers.d.ts.map +1 -1
  118. package/dist/text-renderers.js +27 -2
  119. package/dist/text-renderers.js.map +1 -1
  120. package/package.json +7 -5
  121. package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
  122. package/src/__tests__/broker-prompt.test.ts +142 -0
  123. package/src/__tests__/broker-step-active-turns.test.ts +509 -0
  124. package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
  125. package/src/__tests__/config.test.ts +35 -39
  126. package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
  127. package/src/__tests__/e2e-broker-step.test.ts +320 -0
  128. package/src/__tests__/e2e-mock-agent.test.ts +1 -1
  129. package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
  130. package/src/__tests__/log-tag-validity.test.ts +124 -0
  131. package/src/__tests__/setup-agent-discovery.test.ts +35 -23
  132. package/src/__tests__/setup-no-llm.test.ts +5 -2
  133. package/src/__tests__/step-ask.test.ts +9 -6
  134. package/src/__tests__/step-show-json.test.ts +5 -5
  135. package/src/__tests__/step-show-text.test.ts +236 -0
  136. package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
  137. package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
  138. package/src/__tests__/step-turns.test.ts +734 -0
  139. package/src/__tests__/store-turn-chain.test.ts +386 -0
  140. package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
  141. package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
  142. package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
  143. package/src/__tests__/thread-poke.test.ts +6 -6
  144. package/src/__tests__/thread-resume.test.ts +2 -2
  145. package/src/__tests__/thread-suspend-step.test.ts +1 -1
  146. package/src/__tests__/thread.test.ts +29 -15
  147. package/src/cli.ts +1056 -483
  148. package/src/commands/broker-step.ts +913 -0
  149. package/src/commands/config.ts +2 -24
  150. package/src/commands/prompt.ts +43 -51
  151. package/src/commands/setup.ts +25 -29
  152. package/src/commands/step.ts +645 -176
  153. package/src/commands/thread.ts +87 -192
  154. package/src/output-mappers.ts +99 -21
  155. package/src/schemas.ts +32 -2
  156. package/src/store.ts +297 -2
  157. package/src/text-renderers.ts +35 -2
  158. package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
  159. package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
  160. package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
  161. package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
  162. package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
  163. package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
  164. package/dist/__tests__/spawn-agent-json.test.js +0 -79
  165. package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
  166. package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
  167. package/src/__tests__/spawn-agent-json.test.ts +0 -100
@@ -1,21 +1,28 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import type { CasStore } from "@ocas/core";
3
2
  import type {
4
- AgentAlias,
5
- AgentConfig,
6
3
  CasRef,
7
4
  StartEntry,
8
5
  StepEntry,
9
6
  StepNodePayload,
7
+ StepStartPayload,
10
8
  ThreadForkOutput,
11
9
  ThreadId,
12
10
  ThreadStepsOutput,
13
- WorkflowConfig,
14
- WorkflowPayload,
11
+ TurnNodePayload,
15
12
  } from "@united-workforce/protocol";
16
13
  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";
14
+ import { isThreadRunning } from "../background/index.js";
15
+ import {
16
+ createUwfStore,
17
+ getActiveStep,
18
+ getActiveTurnHead,
19
+ getThread,
20
+ readActiveTurnRoles,
21
+ readActiveTurns,
22
+ setThread,
23
+ turnsOfStep,
24
+ type UwfStore,
25
+ } from "../store.js";
19
26
  import {
20
27
  collectOrderedSteps,
21
28
  expandDeep,
@@ -191,7 +198,14 @@ export async function cmdStepList(
191
198
  }
192
199
 
193
200
  /**
194
- * Show details of a specific step (previously: thread step-details)
201
+ * Show details of a specific step (previously: thread step-details).
202
+ *
203
+ * Returns a merged object that combines StepNode metadata (role / agent /
204
+ * timing / usage) with the expanded broker-detail payload so callers can
205
+ * read both layers in one envelope. The detail node by itself only carries
206
+ * `{ sessionId, duration, turnCount, turns }` β€” without merging in the
207
+ * StepNode metadata, `step show` would render empty `Role` / `Agent` /
208
+ * `Status` / `-` `Duration` (issue #392).
195
209
  */
196
210
  export async function cmdStepShow(storageRoot: string, stepHash: CasRef): Promise<unknown> {
197
211
  const uwf = await createUwfStore(storageRoot);
@@ -206,7 +220,38 @@ export async function cmdStepShow(storageRoot: string, stepHash: CasRef): Promis
206
220
  if (!payload.detail) {
207
221
  fail(`step ${stepHash} has no detail`);
208
222
  }
209
- return expandDeep(uwf.store, payload.detail);
223
+ const detail = expandDeep(uwf.store, payload.detail);
224
+ const output = expandOutput(uwf, payload.output);
225
+ const status =
226
+ output !== null &&
227
+ typeof output === "object" &&
228
+ !Array.isArray(output) &&
229
+ typeof (output as Record<string, unknown>).$status === "string"
230
+ ? ((output as Record<string, unknown>).$status as string)
231
+ : "";
232
+ const startedAtMs =
233
+ typeof payload.startedAtMs === "number" && Number.isFinite(payload.startedAtMs)
234
+ ? payload.startedAtMs
235
+ : null;
236
+ const completedAtMs =
237
+ typeof payload.completedAtMs === "number" && Number.isFinite(payload.completedAtMs)
238
+ ? payload.completedAtMs
239
+ : null;
240
+ const durationMs =
241
+ startedAtMs !== null && completedAtMs !== null && completedAtMs >= startedAtMs
242
+ ? completedAtMs - startedAtMs
243
+ : null;
244
+ return {
245
+ hash: stepHash,
246
+ role: payload.role,
247
+ agent: payload.agent,
248
+ status,
249
+ startedAtMs,
250
+ completedAtMs,
251
+ durationMs,
252
+ usage: payload.usage ?? null,
253
+ detail,
254
+ };
210
255
  }
211
256
 
212
257
  /**
@@ -455,150 +500,616 @@ export async function cmdStepRead(
455
500
  return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
456
501
  }
457
502
 
458
- // ── step ask ────────────────────────────────────────────────────────────────
503
+ // ── step turns ────────────────────────────────────────────────────────────────
504
+ //
505
+ // Phase 4 (#400) / #409 β€” the consumer side of the realtime-turns RFC. `step
506
+ // turns <thread-id>` renders the **whole-thread turn panorama**: it walks the
507
+ // entire thread chain (reusing the SAME `walkChain` + `collectOrderedSteps`
508
+ // infrastructure as `cmdStepList`) and shows every step's turns in chronological
509
+ // order, each turn attributed to its owning role/step.
510
+ //
511
+ // Per-step turn sourcing (active-var precedence, scoped to each step's role):
512
+ // - the in-flight step (its `@uwf/active-turns/<tid>/<role>` var still present)
513
+ // β†’ read the live active var and mark the step `πŸ”„ θΏ›θ‘ŒδΈ­`;
514
+ // - every completed step β†’ read its own immutable `detail.turns` and mark `βœ“`.
515
+ // Both sources are a `CasRef[]` of pure `{role, content}` turn nodes, so per-turn
516
+ // rendering reuses the SAME `loadTurnData` β†’ `formatTurnBody` pipeline as
517
+ // `step read` β€” a turn block here is byte-identical to `step read` for that step.
518
+ //
519
+ // `--role X` filters the panorama to that role's steps (across the whole chain);
520
+ // `--limit`/`--offset` paginate the flattened cross-step turn sequence (filter
521
+ // first, then paginate). Default is full, untruncated output. Because turns are
522
+ // always sourced per-step, role isolation (#408) falls out structurally β€” the
523
+ // head-only `readHeadDetailTurns` role-guard hack is obsolete.
524
+
525
+ /** Default poll interval for `--live` (ms). Small + fixed; injectable for tests. */
526
+ export const STEP_TURNS_POLL_INTERVAL_MS = 400;
527
+
528
+ export type CmdStepTurnsOptions = {
529
+ /**
530
+ * Chain-wide role filter: keep only steps whose `StepNodePayload.role` (and the
531
+ * in-flight step whose active var) equals this role. `null` = no filter (show
532
+ * every role's steps along the chain).
533
+ */
534
+ role: string | null;
535
+ /** Follow the running step's active var, printing new turns as they arrive. */
536
+ live: boolean;
537
+ /** Pagination: max turns of the flattened cross-step sequence. `null` = no limit. */
538
+ limit: number | null;
539
+ /** Pagination: skip the first N turns of the flattened sequence. Defaults to 0. */
540
+ offset: number;
541
+ /** Poll interval override for `--live` (ms). Defaults to STEP_TURNS_POLL_INTERVAL_MS. */
542
+ pollIntervalMs: number | null;
543
+ /** Sink for `--live` incremental output. Defaults to stdout. */
544
+ onChunk: ((chunk: string) => void) | null;
545
+ /** Injectable sleep between `--live` poll ticks. Defaults to setTimeout. */
546
+ sleep: ((ms: number) => Promise<void>) | null;
547
+ /** Injectable running-step predicate for `--live`. Defaults to isThreadRunning. */
548
+ isRunning: (() => Promise<boolean>) | null;
549
+ };
550
+
551
+ /** Fill optional CmdStepTurnsOptions fields with their runtime defaults. */
552
+ function resolveStepTurnsOptions(
553
+ storageRoot: string,
554
+ threadId: ThreadId,
555
+ options: Partial<CmdStepTurnsOptions> & { live: boolean },
556
+ ): CmdStepTurnsOptions {
557
+ return {
558
+ role: options.role ?? null,
559
+ live: options.live,
560
+ limit: options.limit ?? null,
561
+ offset: options.offset ?? 0,
562
+ pollIntervalMs: options.pollIntervalMs ?? null,
563
+ onChunk: options.onChunk ?? null,
564
+ sleep: options.sleep ?? null,
565
+ isRunning:
566
+ options.isRunning ?? (async () => (await isThreadRunning(storageRoot, threadId)) !== null),
567
+ };
568
+ }
459
569
 
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");
570
+ /**
571
+ * Walk the thread chain from `headHash` and return the **newest** step whose
572
+ * `role === role`'s immutable `detail.turns`, or `[]` when no step on the chain
573
+ * has that role. Used by the `--live` exit reconcile to flush the followed role's
574
+ * own solidified turns without ever surfacing a *different* role's turns: in a
575
+ * multi-step run the head may have advanced past the followed step to another
576
+ * role, so reconciling against `head` blindly (the pre-#409 `readHeadDetailTurns`)
577
+ * could leak the next role's turns. Scoping to the followed role's own step on
578
+ * the chain is the live counterpart of the non-live per-step sourcing.
579
+ */
580
+ function readRoleDetailTurnsFromChain(uwf: UwfStore, headHash: CasRef, role: string): CasRef[] {
581
+ let hash: CasRef | null = headHash;
582
+ while (hash !== null) {
583
+ const node = uwf.store.cas.get(hash);
584
+ if (node === null || node.type !== uwf.schemas.stepNode) {
585
+ break;
586
+ }
587
+ const payload = node.payload as StepNodePayload;
588
+ if (payload.role === role) {
589
+ return readStepDetailTurns(uwf, hash);
590
+ }
591
+ hash = payload.prev;
468
592
  }
469
- return { command, args: parts.slice(1) };
593
+ return [];
470
594
  }
471
595
 
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;
596
+ /**
597
+ * Read a specific step's immutable `detail.turns` (the ordered `CasRef[]` of its
598
+ * turn nodes). Returns `[]` for a non-StepNode, a step with no detail, or a
599
+ * detail whose `turns` is absent/malformed. Unlike `readHeadDetailTurns` this is
600
+ * role-agnostic β€” the caller already knows which step it is reading (the chain
601
+ * walk attributes each step to its own role), so no head-role guard is needed.
602
+ */
603
+ function readStepDetailTurns(uwf: UwfStore, stepHash: CasRef): CasRef[] {
604
+ const node = uwf.store.cas.get(stepHash);
605
+ if (node === null || node.type !== uwf.schemas.stepNode) {
606
+ return [];
607
+ }
608
+ const payload = node.payload as StepNodePayload;
609
+ if (payload.detail === null) {
610
+ return [];
611
+ }
612
+ const detailNode = uwf.store.cas.get(payload.detail);
613
+ if (detailNode === null) {
614
+ return [];
615
+ }
616
+ const detail = detailNode.payload as Record<string, unknown>;
617
+ return Array.isArray(detail.turns) ? (detail.turns as CasRef[]) : [];
618
+ }
619
+
620
+ /**
621
+ * One step group in the whole-thread turn panorama: the owning role, whether the
622
+ * step is still in flight (`running` β†’ `πŸ”„ θΏ›θ‘ŒδΈ­`, else `βœ“`), and its turns
623
+ * (already materialized from CAS via `loadTurnData`).
624
+ */
625
+ type TurnsPanoramaGroup = {
626
+ role: string;
627
+ running: boolean;
628
+ turns: TurnData[];
629
+ /** Step-start hash for this group (used internally for owner-based lookup). */
630
+ stepStartHash: CasRef | null;
631
+ };
632
+
633
+ /**
634
+ * Walk the step-start chain from a turn's owner backward via `prev` pointers.
635
+ * Returns step-starts in chronological order (oldest first).
636
+ */
637
+ function walkStepStartChain(uwf: UwfStore, turnHead: CasRef): CasRef[] {
638
+ // First, find a step-start hash from any turn's owner
639
+ const turnChain: CasRef[] = [];
640
+ let currentTurn: CasRef | null = turnHead;
641
+
642
+ // Walk the turn chain to find all unique owners
643
+ const seenOwners = new Set<string>();
644
+ const owners: CasRef[] = [];
645
+
646
+ while (currentTurn !== null) {
647
+ turnChain.push(currentTurn);
648
+ const node = uwf.store.cas.get(currentTurn);
649
+ if (node === null) break;
650
+
651
+ const payload = node.payload as TurnNodePayload | { prev: CasRef | null; owner: CasRef | null };
652
+ const owner = payload.owner ?? null;
653
+ if (owner !== null && !seenOwners.has(owner)) {
654
+ seenOwners.add(owner);
655
+ owners.push(owner);
656
+ }
657
+ currentTurn = payload.prev ?? null;
658
+ }
659
+
660
+ // Now walk the step-start chain to get them in order
661
+ // Find the newest step-start and walk backward via prev
662
+ if (owners.length === 0) {
663
+ return [];
664
+ }
665
+
666
+ // Use the owners we found and order by stepIndex
667
+ const stepStartsWithIndex: { hash: CasRef; index: number }[] = [];
668
+ for (const owner of owners) {
669
+ const node = uwf.store.cas.get(owner);
670
+ if (node === null || node.type !== uwf.schemas.stepStart) continue;
671
+ const payload = node.payload as StepStartPayload;
672
+ stepStartsWithIndex.push({ hash: owner, index: payload.stepIndex });
673
+ }
674
+
675
+ // Sort by stepIndex to get chronological order
676
+ stepStartsWithIndex.sort((a, b) => a.index - b.index);
677
+ return stepStartsWithIndex.map((s) => s.hash);
678
+ }
679
+
680
+ /**
681
+ * Build the whole-thread turn panorama (#421 Phase 3): walk the step-start chain
682
+ * (via turn owner β†’ step-start β†’ prev) and produce one group per step in
683
+ * chronological order. Each turn is attributed to its owning step-start via the
684
+ * `owner` field.
685
+ *
686
+ * Phase 3 changes (root-causing #412):
687
+ * - Walks step-start chain instead of role-keyed active vars
688
+ * - Each segment's turns sourced via `turnsOfStep(turnHead, stepStartHash)`
689
+ * - In-flight detection: active-step matches step-start AND no step-complete
690
+ * - edgePrompt readable directly from step-start
691
+ *
692
+ * In-flight step detection:
693
+ * 1. Check if `@uwf/active-step/<threadId>` points to this step-start hash
694
+ * 2. If match, this step is in-flight (no step-complete written yet)
695
+ *
696
+ * Fails with the standard `thread not found` message for an unknown thread.
697
+ */
698
+ function buildTurnsPanorama(uwf: UwfStore, threadId: ThreadId): TurnsPanoramaGroup[] {
699
+ const entry = getThread(uwf.varStore, threadId);
700
+ if (entry === null) {
701
+ fail(`thread not found: ${threadId}`);
702
+ }
703
+
704
+ // Get the turn chain head and active-step (if any)
705
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
706
+ const activeStepHash = getActiveStep(uwf.store, threadId);
707
+
708
+ // If no turns yet, try the legacy path via StepNode chain
709
+ if (turnHead === null) {
710
+ return buildTurnsPanoramaLegacy(uwf, threadId, entry.head);
711
+ }
712
+
713
+ // Walk the step-start chain from turn owners
714
+ const stepStarts = walkStepStartChain(uwf, turnHead);
715
+ const groups: TurnsPanoramaGroup[] = [];
716
+
717
+ for (const stepStartHash of stepStarts) {
718
+ const node = uwf.store.cas.get(stepStartHash);
719
+ if (node === null || node.type !== uwf.schemas.stepStart) continue;
720
+
721
+ const payload = node.payload as StepStartPayload;
722
+ const role = payload.role;
723
+
724
+ // Detect in-flight: active-step points to this step-start
725
+ const isInFlight = activeStepHash === stepStartHash;
726
+
727
+ // Get turns for this step using owner-based filtering
728
+ const turnHashes = turnsOfStep(uwf, turnHead, stepStartHash);
729
+ const turns = loadTurnData(uwf.store.cas, turnHashes);
730
+
731
+ groups.push({
732
+ role,
733
+ running: isInFlight,
734
+ turns,
735
+ stepStartHash,
736
+ });
737
+ }
738
+
739
+ return groups;
740
+ }
741
+
742
+ /**
743
+ * Legacy fallback for threads without new turn chain structure.
744
+ * Uses the old role-keyed active vars and StepNode detail.turns.
745
+ */
746
+ function buildTurnsPanoramaLegacy(
747
+ uwf: UwfStore,
748
+ threadId: ThreadId,
749
+ headHash: CasRef,
750
+ ): TurnsPanoramaGroup[] {
751
+ const chain = walkChain(uwf, headHash);
752
+ const ordered = collectOrderedSteps(uwf, headHash, chain);
753
+ const activeRoles = readActiveTurnRoles(uwf.store, threadId);
754
+ const activeByRole = new Map(activeRoles.map((a) => [a.role, a.turns] as const));
755
+ const consumed = new Set<string>();
756
+ const groups: TurnsPanoramaGroup[] = [];
757
+
758
+ for (const item of ordered) {
759
+ const role = item.payload.role;
760
+ const active = activeByRole.get(role);
761
+ if (active !== undefined && active.length > 0 && !consumed.has(role)) {
762
+ groups.push({
763
+ role,
764
+ running: true,
765
+ turns: loadTurnData(uwf.store.cas, active),
766
+ stepStartHash: null,
767
+ });
768
+ consumed.add(role);
769
+ } else {
770
+ groups.push({
771
+ role,
772
+ running: false,
773
+ turns: loadTurnData(uwf.store.cas, readStepDetailTurns(uwf, item.hash)),
774
+ stepStartHash: null,
775
+ });
483
776
  }
484
- return parseAgentOverride(agentOverride);
485
777
  }
486
778
 
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;
779
+ for (const { role, turns } of activeRoles) {
780
+ if (consumed.has(role)) {
781
+ continue;
782
+ }
783
+ groups.push({
784
+ role,
785
+ running: true,
786
+ turns: loadTurnData(uwf.store.cas, turns),
787
+ stepStartHash: null,
788
+ });
789
+ consumed.add(role);
491
790
  }
492
791
 
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;
792
+ return groups;
793
+ }
794
+
795
+ /**
796
+ * Filter the panorama to a single role (exact-match), or pass it through
797
+ * unchanged when `role === null` (show every role's steps). `--role` is a filter
798
+ * over the whole-chain panorama, so it keeps **all** steps of that role across
799
+ * the thread (e.g. a role that ran in two rounds), not just the latest.
800
+ */
801
+ function filterPanoramaByRole(
802
+ groups: TurnsPanoramaGroup[],
803
+ role: string | null,
804
+ ): TurnsPanoramaGroup[] {
805
+ if (role === null) {
806
+ return groups;
807
+ }
808
+ return groups.filter((g) => g.role === role);
809
+ }
810
+
811
+ /** Render a single turn's `## Turn N` block (1-based) via the reused pipeline. */
812
+ function formatTurnBlock(turn: TurnData, displayIndex: number): string {
813
+ return `## Turn ${displayIndex}\n\n${formatTurnBody(turn)}`;
814
+ }
815
+
816
+ /**
817
+ * Slice the panorama's flattened cross-step turn sequence to `[offset, offset+limit)`
818
+ * (`limit === null` β†’ no upper bound, the OCAS `ListOptions` "no limit" convention),
819
+ * keeping each surviving turn's **global** index so numbering is consistent across
820
+ * the whole panorama. Returns per-group survivors paired with their group, so
821
+ * grouping/markers are preserved while pagination removes turns (not steps).
822
+ */
823
+ function paginatePanorama(
824
+ groups: TurnsPanoramaGroup[],
825
+ offset: number,
826
+ limit: number | null,
827
+ ): { group: TurnsPanoramaGroup; turns: { turn: TurnData; globalIndex: number }[] }[] {
828
+ const start = offset > 0 ? offset : 0;
829
+ const end = limit === null ? Number.POSITIVE_INFINITY : start + Math.max(0, limit);
830
+ let globalIndex = 0;
831
+ const result: {
832
+ group: TurnsPanoramaGroup;
833
+ turns: { turn: TurnData; globalIndex: number }[];
834
+ }[] = [];
835
+ for (const group of groups) {
836
+ const survivors: { turn: TurnData; globalIndex: number }[] = [];
837
+ for (const turn of group.turns) {
838
+ const idx = globalIndex;
839
+ globalIndex += 1;
840
+ if (idx >= start && idx < end) {
841
+ survivors.push({ turn, globalIndex: idx });
501
842
  }
502
843
  }
844
+ result.push({ group, turns: survivors });
503
845
  }
846
+ return result;
847
+ }
504
848
 
505
- // Treat the recorded value as a raw command path.
506
- return parseAgentOverride(recordedAgent);
849
+ /** Step group header, e.g. `## developer βœ“ (47 turns)` / `## reviewer πŸ”„ θΏ›θ‘ŒδΈ­ (12 turns so far)`. */
850
+ function formatGroupHeader(group: TurnsPanoramaGroup): string {
851
+ const count = group.turns.length;
852
+ if (group.running) {
853
+ return `## ${group.role} πŸ”„ θΏ›θ‘ŒδΈ­ (${count} turns so far)`;
854
+ }
855
+ return `## ${group.role} βœ“ (${count} turns)`;
507
856
  }
508
857
 
509
858
  /**
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
859
+ * Assemble the whole-thread turn panorama markdown (#409): a thread header, then
860
+ * one group per step (role + `βœ“`/`πŸ”„ θΏ›θ‘ŒδΈ­` marker + turn count), and under each
861
+ * the surviving turns rendered via the reused `formatTurnBlock` pipeline with
862
+ * their global (cross-step) turn numbers. A group whose turns are entirely sliced
863
+ * out by pagination still shows its header (zero turns beneath).
516
864
  */
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}`);
865
+ function formatPanoramaMarkdown(
866
+ threadId: ThreadId,
867
+ groups: TurnsPanoramaGroup[],
868
+ offset: number,
869
+ limit: number | null,
870
+ ): string {
871
+ const parts: string[] = [`# Thread ${threadId}`];
872
+ const paged = paginatePanorama(groups, offset, limit);
873
+ for (const { group, turns } of paged) {
874
+ parts.push("");
875
+ parts.push(formatGroupHeader(group));
876
+ for (const { turn, globalIndex } of turns) {
877
+ parts.push("");
878
+ parts.push(formatTurnBlock(turn, globalIndex + 1));
879
+ }
535
880
  }
536
- const payload = detailNode.payload as Record<string, unknown>;
537
- const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
538
- return { sessionId, payload };
881
+ return parts.join("\n");
539
882
  }
540
883
 
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
- );
884
+ /**
885
+ * Resolve the turn hashes to flush when the followed step finishes (active var
886
+ * gone AND thread no longer running). Phase 3: uses active-turn-head and owner
887
+ * filtering via turnsOfStep. Falls back to legacy role-keyed vars if no turn
888
+ * chain exists.
889
+ */
890
+ function resolveFinalTurnHashesPhase3(
891
+ uwf: UwfStore,
892
+ threadId: ThreadId,
893
+ activeStepStart: CasRef | null,
894
+ ): CasRef[] {
895
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
896
+ if (turnHead !== null && activeStepStart !== null) {
897
+ return turnsOfStep(uwf, turnHead, activeStepStart);
898
+ }
899
+ // Fallback: no new turn chain, return empty
900
+ return [];
901
+ }
902
+
903
+ /**
904
+ * Legacy fallback for resolveFinalTurnHashes when thread uses role-keyed vars.
905
+ */
906
+ function resolveFinalTurnHashesLegacy(
907
+ uwf: UwfStore,
908
+ threadId: ThreadId,
909
+ followRole: string,
910
+ ): CasRef[] {
911
+ const remaining = readActiveTurns(uwf.store, threadId, followRole);
912
+ if (remaining.length > 0) {
913
+ return remaining;
914
+ }
915
+ const entry = getThread(uwf.varStore, threadId);
916
+ if (entry === null) {
917
+ return [];
918
+ }
919
+ return readRoleDetailTurnsFromChain(uwf, entry.head, followRole);
920
+ }
921
+
922
+ /**
923
+ * Get turns for the in-flight step using Phase 3 owner-based filtering.
924
+ * Returns turn hashes owned by the active step-start.
925
+ */
926
+ function getInFlightTurns(uwf: UwfStore, threadId: ThreadId): CasRef[] {
927
+ const turnHead = getActiveTurnHead(uwf.store, threadId);
928
+ const activeStepStart = getActiveStep(uwf.store, threadId);
929
+
930
+ if (turnHead === null || activeStepStart === null) {
931
+ return [];
932
+ }
933
+
934
+ return turnsOfStep(uwf, turnHead, activeStepStart);
935
+ }
936
+
937
+ /**
938
+ * Check if thread uses Phase 3 turn chain (has active-turn-head var).
939
+ */
940
+ function hasPhase3TurnChain(uwf: UwfStore, threadId: ThreadId): boolean {
941
+ return (
942
+ getActiveTurnHead(uwf.store, threadId) !== null || getActiveStep(uwf.store, threadId) !== null
943
+ );
944
+ }
945
+
946
+ /** State for live follower's flush operation. */
947
+ type LiveFollowerState = {
948
+ printedCount: number;
949
+ lastActiveStepStart: CasRef | null;
950
+ usePhase3: boolean | null;
951
+ };
952
+
953
+ /** Get active turns based on Phase 3 vs legacy mode. */
954
+ function getActiveTurnsForLive(
955
+ uwf: UwfStore,
956
+ threadId: ThreadId,
957
+ state: LiveFollowerState,
958
+ followRole: string,
959
+ ): CasRef[] {
960
+ if (state.usePhase3) {
961
+ const activeStepStart = getActiveStep(uwf.store, threadId);
962
+ if (activeStepStart !== null) {
963
+ state.lastActiveStepStart = activeStepStart;
556
964
  }
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}`);
965
+ return getInFlightTurns(uwf, threadId);
565
966
  }
967
+ return readActiveTurns(uwf.store, threadId, followRole);
566
968
  }
567
969
 
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;
970
+ /** Get final turns for reconciliation based on Phase 3 vs legacy mode. */
971
+ function getFinalTurnsForLive(
972
+ uwf: UwfStore,
973
+ threadId: ThreadId,
974
+ state: LiveFollowerState,
975
+ followRole: string,
976
+ ): CasRef[] {
977
+ if (state.usePhase3) {
978
+ return resolveFinalTurnHashesPhase3(uwf, threadId, state.lastActiveStepStart);
572
979
  }
573
- const start = startNode.payload as { workflow: CasRef };
574
- const workflowNode = uwf.store.cas.get(start.workflow);
575
- if (workflowNode === null) {
576
- return null;
980
+ return resolveFinalTurnHashesLegacy(uwf, threadId, followRole);
981
+ }
982
+
983
+ /**
984
+ * `--live` follower: poll the in-flight step's turns via the Phase 3 turn chain,
985
+ * printing each new turn block exactly once (tracking how many blocks were emitted
986
+ * and rendering only the new tail).
987
+ *
988
+ * Phase 3 changes (#421):
989
+ * - Uses `getActiveTurnHead` and `getActiveStep` instead of role-keyed vars
990
+ * - Filters turns via `turnsOfStep(turnHead, activeStepStart)`
991
+ * - Exits when the thread is no longer running
992
+ *
993
+ * Backward compatible: Falls back to legacy role-keyed vars for threads without
994
+ * Phase 3 turn chain.
995
+ */
996
+ async function followStepTurnsLive(
997
+ storageRoot: string,
998
+ threadId: ThreadId,
999
+ opts: CmdStepTurnsOptions,
1000
+ ): Promise<void> {
1001
+ const emit = opts.onChunk ?? ((chunk: string) => process.stdout.write(chunk));
1002
+ const sleep =
1003
+ opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1004
+ const isRunning =
1005
+ opts.isRunning ?? (async () => (await isThreadRunning(storageRoot, threadId)) !== null);
1006
+ const intervalMs = opts.pollIntervalMs ?? STEP_TURNS_POLL_INTERVAL_MS;
1007
+ const followRole = opts.role ?? (await resolveLiveFollowRole(storageRoot, threadId));
1008
+
1009
+ const state: LiveFollowerState = {
1010
+ printedCount: 0,
1011
+ lastActiveStepStart: null,
1012
+ usePhase3: null,
1013
+ };
1014
+
1015
+ const flush = (uwf: UwfStore, hashes: CasRef[]): void => {
1016
+ if (hashes.length <= state.printedCount) {
1017
+ return;
1018
+ }
1019
+ const tail = loadTurnData(uwf.store.cas, hashes.slice(state.printedCount));
1020
+ for (let i = 0; i < tail.length; i++) {
1021
+ const turn = tail[i];
1022
+ if (turn === undefined) continue;
1023
+ emit(`${formatTurnBlock(turn, state.printedCount + i + 1)}\n`);
1024
+ }
1025
+ state.printedCount = hashes.length;
1026
+ };
1027
+
1028
+ while (true) {
1029
+ const uwf = await createUwfStore(storageRoot);
1030
+
1031
+ if (state.usePhase3 === null) {
1032
+ state.usePhase3 = hasPhase3TurnChain(uwf, threadId);
1033
+ }
1034
+
1035
+ const active = getActiveTurnsForLive(uwf, threadId, state, followRole);
1036
+ flush(uwf, active);
1037
+
1038
+ const running = await isRunning();
1039
+ if (!running) {
1040
+ flush(uwf, getFinalTurnsForLive(uwf, threadId, state, followRole));
1041
+ return;
1042
+ }
1043
+
1044
+ await sleep(intervalMs);
577
1045
  }
578
- return workflowNode.payload as WorkflowPayload;
579
1046
  }
580
1047
 
581
- async function performFork(
582
- agent: AgentConfig,
583
- agentName: string,
584
- stepHash: CasRef,
585
- sourceSessionId: string,
1048
+ /**
1049
+ * Resolve the role for `--live` to follow when `--role` is omitted: the thread's
1050
+ * current in-flight role. Prefers a role with a live `@uwf/active-turns` var
1051
+ * (the genuinely in-flight step); falls back to the head StepNode's role, then to
1052
+ * `"agent"` for a StartNode head. Fails with the standard `thread not found`
1053
+ * message for an unknown thread.
1054
+ */
1055
+ async function resolveLiveFollowRole(storageRoot: string, threadId: ThreadId): Promise<string> {
1056
+ const uwf = await createUwfStore(storageRoot);
1057
+ const entry = getThread(uwf.varStore, threadId);
1058
+ if (entry === null) {
1059
+ fail(`thread not found: ${threadId}`);
1060
+ }
1061
+ const activeRoles = readActiveTurnRoles(uwf.store, threadId);
1062
+ const lastActive = activeRoles[activeRoles.length - 1];
1063
+ if (lastActive !== undefined) {
1064
+ return lastActive.role;
1065
+ }
1066
+ const node = uwf.store.cas.get(entry.head);
1067
+ if (node !== null && node.type === uwf.schemas.stepNode) {
1068
+ return (node.payload as StepNodePayload).role;
1069
+ }
1070
+ return "agent";
1071
+ }
1072
+
1073
+ /**
1074
+ * `uwf step turns <thread-id> [--role <r>] [--live] [--limit <n>] [--offset <m>]`
1075
+ * β€” render the whole-thread turn panorama (#409): walk the entire chain and show
1076
+ * every step's turns (each completed step from its immutable `detail.turns`, the
1077
+ * in-flight step from its active var, marked `πŸ”„ θΏ›θ‘ŒδΈ­`), through the same
1078
+ * per-turn pipeline as `step read`. `--role` filters the panorama to one role;
1079
+ * `--limit`/`--offset` paginate the flattened cross-step turn sequence (after the
1080
+ * role filter). With `--live`, follow the in-flight step's active var, printing
1081
+ * new turns incrementally.
1082
+ *
1083
+ * Returns the assembled markdown (non-live); for `--live` the output is streamed
1084
+ * to `onChunk`/stdout and the resolved string is returned empty.
1085
+ */
1086
+ export async function cmdStepTurns(
586
1087
  storageRoot: string,
587
- cwd: string,
1088
+ threadId: ThreadId,
1089
+ options: Partial<CmdStepTurnsOptions> & { live: boolean },
588
1090
  ): 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})`);
1091
+ const opts = resolveStepTurnsOptions(storageRoot, threadId, options);
1092
+
1093
+ if (opts.live) {
1094
+ await followStepTurnsLive(storageRoot, threadId, opts);
1095
+ return "";
597
1096
  }
598
- await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
599
- return newSessionId;
1097
+
1098
+ const uwf = await createUwfStore(storageRoot);
1099
+ const panorama = buildTurnsPanorama(uwf, threadId);
1100
+ const filtered = filterPanoramaByRole(panorama, opts.role);
1101
+ return formatPanoramaMarkdown(threadId, filtered, opts.offset, opts.limit);
600
1102
  }
601
1103
 
1104
+ // ── step ask ────────────────────────────────────────────────────────────────
1105
+ //
1106
+ // Phase 3 (#380) β€” Option B: `step ask` is disabled while broker integration
1107
+ // lands. The pre-broker spawn-agent path depended on the legacy
1108
+ // `agents.<alias>: {command, args}` config shape; that shape was replaced by
1109
+ // `{host, gateway}` and the equivalent broker `ask`/`fork` primitives are
1110
+ // scheduled for Phase 4 (#381). The command exits non-zero with a clear
1111
+ // migration pointer so existing scripts fail fast rather than silently.
1112
+
602
1113
  export type CmdStepAskOptions = {
603
1114
  prompt: string;
604
1115
  agentOverride: string | null;
@@ -607,64 +1118,22 @@ export type CmdStepAskOptions = {
607
1118
  };
608
1119
 
609
1120
  /**
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.
1121
+ * `uwf step ask` is unavailable in 0.x while broker integration (#381) is in
1122
+ * progress. The legacy spawn-agent code path was removed alongside the
1123
+ * `agents.<alias>: {command, args}` config shape. Use `uwf thread exec` /
1124
+ * `uwf thread resume` instead β€” those routes go through `broker.send()` and
1125
+ * preserve the Sumeru session.
614
1126
  */
615
1127
  export async function cmdStepAsk(
616
- storageRoot: string,
617
- stepHash: CasRef,
618
- options: CmdStepAskOptions,
1128
+ _storageRoot: string,
1129
+ _stepHash: CasRef,
1130
+ _options: CmdStepAskOptions,
619
1131
  ): 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,
1132
+ fail(
1133
+ "step ask is unavailable in 0.x while broker integration (#381) is in progress. " +
1134
+ "The pre-broker spawn-agent path was removed in #380; equivalent ask/fork primitives " +
1135
+ "will return in Phase 4 once the Sumeru broker exposes session-fork APIs. " +
1136
+ "Use `uwf thread resume <id> -p '...'` to continue a suspended thread, or " +
1137
+ "`uwf thread exec <id>` to advance an idle thread.",
644
1138
  );
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
1139
  }