@united-workforce/cli 0.3.0 → 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,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import type { RunningThreadItem, ThreadId } from "@united-workforce/protocol";
@@ -18,6 +19,42 @@ export function getMarkerPath(storageRoot: string, threadId: ThreadId): string {
18
19
  return join(getRunningDir(storageRoot), `${threadId}.json`);
19
20
  }
20
21
 
22
+ /**
23
+ * Read the process start time from /proc/<pid>/stat (field 22, starttime).
24
+ * Returns the value in clock ticks since boot, or null on non-Linux systems
25
+ * or when the process does not exist.
26
+ */
27
+ export function getProcessStartTime(pid: number): number | null {
28
+ try {
29
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
30
+ // /proc/<pid>/stat format: pid (comm) state ... field22_starttime ...
31
+ // The comm field can contain spaces and parentheses, so we find the last ')' first
32
+ const closeParenIdx = stat.lastIndexOf(")");
33
+ if (closeParenIdx === -1) {
34
+ return null;
35
+ }
36
+ // Fields after (comm) start at index 2 (state is field 3, index 2 in 0-based after split)
37
+ // starttime is field 22 (1-based), which is index 19 in the fields after ')'
38
+ const fieldsAfterComm = stat
39
+ .slice(closeParenIdx + 2)
40
+ .trim()
41
+ .split(" ");
42
+ // Field indices after comm (0-based): 0=state(3), 1=ppid(4), ..., 19=starttime(22)
43
+ const startTimeStr = fieldsAfterComm[19];
44
+ if (startTimeStr === undefined) {
45
+ return null;
46
+ }
47
+ const startTime = Number(startTimeStr);
48
+ if (Number.isNaN(startTime)) {
49
+ return null;
50
+ }
51
+ return startTime;
52
+ } catch {
53
+ // /proc not available (non-Linux) or process doesn't exist
54
+ return null;
55
+ }
56
+ }
57
+
21
58
  /**
22
59
  * Check if a PID is still running.
23
60
  * Returns true if the process exists, false otherwise.
@@ -33,6 +70,39 @@ export function isPidAlive(pid: number): boolean {
33
70
  }
34
71
  }
35
72
 
73
+ /**
74
+ * Validate that a running marker still refers to the same process.
75
+ * Checks both that the PID is alive AND that its start time matches.
76
+ * Returns false if:
77
+ * - The PID is no longer alive
78
+ * - The PID is alive but its start time doesn't match (PID was recycled)
79
+ * Returns true if:
80
+ * - PID is alive AND start times match
81
+ * - PID is alive AND marker has null processStartTime (backward compat / non-Linux)
82
+ */
83
+ export function isMarkerValid(marker: RunningMarker): boolean {
84
+ if (!isPidAlive(marker.pid)) {
85
+ return false;
86
+ }
87
+
88
+ // If marker has no processStartTime (legacy marker or non-Linux at creation time),
89
+ // fall back to PID-alive-only check for backward compatibility
90
+ if (marker.processStartTime === null) {
91
+ return true;
92
+ }
93
+
94
+ // Verify process identity by comparing start times
95
+ const actualStartTime = getProcessStartTime(marker.pid);
96
+
97
+ // If we can't read the actual start time (non-Linux runtime), trust PID-alive check
98
+ if (actualStartTime === null) {
99
+ return true;
100
+ }
101
+
102
+ // Start times must match — if they differ, PID was recycled
103
+ return marker.processStartTime === actualStartTime;
104
+ }
105
+
36
106
  /**
37
107
  * Create a marker file for a running thread.
38
108
  * Writes to a temp file in the same directory, then atomically renames.
@@ -63,6 +133,7 @@ export async function deleteMarker(storageRoot: string, threadId: ThreadId): Pro
63
133
 
64
134
  /**
65
135
  * Read a marker file. Returns null if file doesn't exist or is invalid.
136
+ * Handles legacy markers that lack processStartTime by defaulting to null.
66
137
  */
67
138
  export async function readMarker(
68
139
  storageRoot: string,
@@ -71,7 +142,15 @@ export async function readMarker(
71
142
  const markerPath = getMarkerPath(storageRoot, threadId);
72
143
  try {
73
144
  const content = await readFile(markerPath, "utf8");
74
- const marker = JSON.parse(content) as RunningMarker;
145
+ const raw = JSON.parse(content) as Record<string, unknown>;
146
+ // Normalize legacy markers that lack processStartTime
147
+ const marker: RunningMarker = {
148
+ thread: raw.thread as ThreadId,
149
+ workflow: raw.workflow as string,
150
+ pid: raw.pid as number,
151
+ startedAt: raw.startedAt as number,
152
+ processStartTime: typeof raw.processStartTime === "number" ? raw.processStartTime : null,
153
+ };
75
154
  return marker;
76
155
  } catch {
77
156
  return null;
@@ -80,6 +159,8 @@ export async function readMarker(
80
159
 
81
160
  /**
82
161
  * List all running threads, filtering out stale markers.
162
+ * A marker is stale if the PID is dead or if the PID was recycled
163
+ * (processStartTime mismatch).
83
164
  */
84
165
  export async function listRunningThreads(storageRoot: string): Promise<RunningThreadItem[]> {
85
166
  const runningDir = getRunningDir(storageRoot);
@@ -107,8 +188,8 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
107
188
  continue;
108
189
  }
109
190
 
110
- if (!isPidAlive(marker.pid)) {
111
- // Stale marker - process no longer exists
191
+ if (!isMarkerValid(marker)) {
192
+ // Stale marker - process no longer exists or PID was recycled
112
193
  await deleteMarker(storageRoot, threadId);
113
194
  continue;
114
195
  }
@@ -126,7 +207,8 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
126
207
 
127
208
  /**
128
209
  * Check if a thread is currently executing in the background.
129
- * Returns the marker if running, null otherwise.
210
+ * Returns the marker if running (and process identity is verified), null otherwise.
211
+ * Automatically deletes stale markers (dead PID or recycled PID).
130
212
  */
131
213
  export async function isThreadRunning(
132
214
  storageRoot: string,
@@ -137,8 +219,8 @@ export async function isThreadRunning(
137
219
  return null;
138
220
  }
139
221
 
140
- if (!isPidAlive(marker.pid)) {
141
- // Stale marker
222
+ if (!isMarkerValid(marker)) {
223
+ // Stale marker — PID dead or recycled
142
224
  await deleteMarker(storageRoot, threadId);
143
225
  return null;
144
226
  }
@@ -2,7 +2,9 @@ export {
2
2
  createMarker,
3
3
  deleteMarker,
4
4
  getMarkerPath,
5
+ getProcessStartTime,
5
6
  getRunningDir,
7
+ isMarkerValid,
6
8
  isPidAlive,
7
9
  isThreadRunning,
8
10
  listRunningThreads,
@@ -6,4 +6,5 @@ export type RunningMarker = {
6
6
  workflow: CasRef;
7
7
  pid: number;
8
8
  startedAt: number;
9
+ processStartTime: number | null;
9
10
  };
package/src/cli.ts CHANGED
@@ -11,12 +11,13 @@ import {
11
11
  cmdPromptUsage,
12
12
  cmdPromptWorkflowAuthoring,
13
13
  } from "./commands/prompt.js";
14
- import { cmdSetup, cmdSetupInteractive, resolvePresetBaseUrl } from "./commands/setup.js";
15
- import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
14
+ import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
15
+ import { cmdStepAsk, cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
16
16
  import {
17
17
  cmdThreadCancel,
18
18
  cmdThreadExec,
19
19
  cmdThreadList,
20
+ cmdThreadPoke,
20
21
  cmdThreadRead,
21
22
  cmdThreadResume,
22
23
  cmdThreadShow,
@@ -25,7 +26,12 @@ import {
25
26
  THREAD_READ_DEFAULT_QUOTA,
26
27
  } from "./commands/thread.js";
27
28
  import { parseTimeInput } from "./commands/thread-time-parser.js";
28
- import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
29
+ import {
30
+ cmdWorkflowAdd,
31
+ cmdWorkflowList,
32
+ cmdWorkflowShow,
33
+ cmdWorkflowValidate,
34
+ } from "./commands/workflow.js";
29
35
  import { formatOutput, type OutputFormat } from "./format.js";
30
36
  import { resolveStorageRoot } from "./store.js";
31
37
 
@@ -72,6 +78,17 @@ workflow
72
78
  });
73
79
  });
74
80
 
81
+ workflow
82
+ .command("validate")
83
+ .description("Validate a workflow YAML without registering it (CI-friendly)")
84
+ .argument("<file>", "Workflow YAML file")
85
+ .action((file: string) => {
86
+ runAction(async () => {
87
+ await cmdWorkflowValidate(file);
88
+ // silent on success — do not call writeOutput
89
+ });
90
+ });
91
+
75
92
  workflow
76
93
  .command("show")
77
94
  .description("Show a workflow by name or CAS hash")
@@ -79,7 +96,7 @@ workflow
79
96
  .action((id: string) => {
80
97
  const storageRoot = resolveStorageRoot();
81
98
  runAction(async () => {
82
- const result = await cmdWorkflowShow(storageRoot, id);
99
+ const result = await cmdWorkflowShow(storageRoot, id, process.cwd());
83
100
  writeOutput(result);
84
101
  });
85
102
  });
@@ -178,11 +195,18 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
178
195
  if (raw === "active") return ["idle", "running"];
179
196
 
180
197
  const parts = raw.split(",").map((s) => s.trim());
181
- const validStatuses: ThreadStatus[] = ["idle", "running", "suspended", "completed", "cancelled"];
198
+ const validStatuses: ThreadStatus[] = [
199
+ "idle",
200
+ "running",
201
+ "suspended",
202
+ "end",
203
+ "cancelled",
204
+ "corrupt",
205
+ ];
182
206
  for (const part of parts) {
183
207
  if (!validStatuses.includes(part as ThreadStatus)) {
184
208
  process.stderr.write(
185
- `Invalid status: ${part}. Must be one of: idle, running, suspended, completed, cancelled, active\n`,
209
+ `Invalid status: ${part}. Must be one of: idle, running, suspended, end, cancelled, active\n`,
186
210
  );
187
211
  process.exit(1);
188
212
  }
@@ -232,11 +256,12 @@ function parsePaginationOptions(
232
256
 
233
257
  thread
234
258
  .command("list")
235
- .description("List threads")
259
+ .description("List threads (defaults to active: idle + running + corrupt)")
236
260
  .option(
237
261
  "--status <status>",
238
- "Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
262
+ "Filter by status: idle, running, end, cancelled, active (idle+running), or comma-separated values",
239
263
  )
264
+ .option("--all", "Show all threads regardless of status (overrides default active-only filter)")
240
265
  .option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
241
266
  .option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
242
267
  .option("--skip <n>", "Skip first n threads")
@@ -244,6 +269,7 @@ thread
244
269
  .action(
245
270
  (opts: {
246
271
  status: string | undefined;
272
+ all: boolean | undefined;
247
273
  after: string | undefined;
248
274
  before: string | undefined;
249
275
  skip: string | undefined;
@@ -255,6 +281,7 @@ thread
255
281
  const nowMs = Date.now();
256
282
  const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
257
283
  const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
284
+ const showAll = opts.all === true;
258
285
 
259
286
  const result = await cmdThreadList(
260
287
  storageRoot,
@@ -263,6 +290,7 @@ thread
263
290
  beforeMs,
264
291
  skip,
265
292
  take,
293
+ showAll,
266
294
  );
267
295
  writeOutput(result);
268
296
  });
@@ -290,6 +318,26 @@ thread
290
318
  });
291
319
  });
292
320
 
321
+ thread
322
+ .command("poke")
323
+ .description("Re-run the head step's agent with a supplementary prompt (replaces head step)")
324
+ .argument("<thread-id>", "Thread ULID")
325
+ .requiredOption("-p, --prompt <text>", "Supplementary prompt for the agent")
326
+ .option("--agent <cmd>", "Override agent command (defaults to head step's agent)")
327
+ .action((threadId: string, opts: { prompt: string; agent: string | undefined }) => {
328
+ const storageRoot = resolveStorageRoot();
329
+ runAction(async () => {
330
+ const agentOverride = opts.agent ?? null;
331
+ const result = await cmdThreadPoke(
332
+ storageRoot,
333
+ threadId as ThreadId,
334
+ opts.prompt,
335
+ agentOverride,
336
+ );
337
+ writeOutput(result);
338
+ });
339
+ });
340
+
293
341
  thread
294
342
  .command("stop")
295
343
  .description("Stop background execution of a thread (keep thread active)")
@@ -369,6 +417,32 @@ step
369
417
  });
370
418
  });
371
419
 
420
+ step
421
+ .command("ask")
422
+ .description(
423
+ "Ask a follow-up question to a historical step's agent (read-only; no thread mutation)",
424
+ )
425
+ .argument("<step-hash>", "CAS hash of the StepNode to query")
426
+ .requiredOption("-p, --prompt <text>", "Question to ask the step's agent")
427
+ .option("--agent <cmd>", "Override agent command (defaults to the step's recorded agent)")
428
+ .option(
429
+ "--no-fork",
430
+ "Skip session-fork; spawn the agent in a fresh ask session and inject the step's detail ref for context",
431
+ )
432
+ .action(
433
+ (stepHash: string, opts: { prompt: string; agent: string | undefined; fork: boolean }) => {
434
+ const storageRoot = resolveStorageRoot();
435
+ runAction(async () => {
436
+ const stdout = await cmdStepAsk(storageRoot, stepHash as CasRef, {
437
+ prompt: opts.prompt,
438
+ agentOverride: opts.agent ?? null,
439
+ fork: opts.fork,
440
+ });
441
+ process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
442
+ });
443
+ },
444
+ );
445
+
372
446
  step
373
447
  .command("read")
374
448
  .description("Read a step's turns as human-readable markdown")
@@ -542,46 +616,22 @@ prompt
542
616
 
543
617
  program
544
618
  .command("setup")
545
- .description("Configure provider, model, and agent. Run without options for interactive wizard.")
546
- .option("--provider <name>", "Provider name")
547
- .option("--base-url <url>", "OpenAI-compatible API base URL")
548
- .option("--api-key <key>", "API key")
549
- .option("--model <name>", "Default model name")
619
+ .description(
620
+ "Configure the default agent. Run without --agent for interactive wizard.\n" +
621
+ "LLM provider/model configuration lives in <storage>/config.yaml under providers and models.",
622
+ )
550
623
  .option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
551
- .action(
552
- (opts: {
553
- provider?: string;
554
- baseUrl?: string;
555
- apiKey?: string;
556
- model?: string;
557
- agent?: string;
558
- }) => {
559
- const storageRoot = resolveStorageRoot();
560
- runAction(async () => {
561
- // Resolve preset base-url when provider is known but --base-url is omitted
562
- const resolvedBaseUrl =
563
- opts.baseUrl ??
564
- (opts.provider !== undefined ? resolvePresetBaseUrl(opts.provider) : null);
565
- if (opts.provider && resolvedBaseUrl && opts.apiKey && opts.model) {
566
- const result = await cmdSetup({
567
- provider: opts.provider,
568
- baseUrl: resolvedBaseUrl,
569
- apiKey: opts.apiKey,
570
- model: opts.model,
571
- agent: opts.agent ?? undefined,
572
- storageRoot,
573
- });
574
- writeOutput(result);
575
- } else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
576
- await cmdSetupInteractive(storageRoot);
577
- } else {
578
- throw new Error(
579
- "Non-interactive setup requires: --provider, --api-key, --model (--base-url is optional for preset providers)",
580
- );
581
- }
582
- });
583
- },
584
- );
624
+ .action((opts: { agent?: string }) => {
625
+ const storageRoot = resolveStorageRoot();
626
+ runAction(async () => {
627
+ if (opts.agent !== undefined && opts.agent !== "") {
628
+ const result = await cmdSetup({ agent: opts.agent, storageRoot });
629
+ writeOutput(result);
630
+ } else {
631
+ await cmdSetupInteractive(storageRoot);
632
+ }
633
+ });
634
+ });
585
635
 
586
636
  const log = program.command("log").description("Process-level debug logs");
587
637
 
@@ -3,20 +3,14 @@ import { join } from "node:path";
3
3
  import { parse, stringify } from "yaml";
4
4
 
5
5
  /**
6
- * Valid configuration key schema
6
+ * Valid configuration key schema. Engine config is LLM-free — providers,
7
+ * models, defaultModel, and modelOverrides are no longer accepted here.
8
+ * Each adapter owns its own LLM configuration.
7
9
  */
8
10
  const VALID_CONFIG_KEYS: Record<
9
11
  string,
10
12
  { nested: boolean; knownFields?: string[]; minDepth?: number }
11
13
  > = {
12
- providers: {
13
- nested: true,
14
- knownFields: ["baseUrl", "apiKey"],
15
- },
16
- models: {
17
- nested: true,
18
- knownFields: ["provider", "name"],
19
- },
20
14
  agents: {
21
15
  nested: true,
22
16
  knownFields: ["command", "args"],
@@ -26,14 +20,7 @@ const VALID_CONFIG_KEYS: Record<
26
20
  // agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
27
21
  // No knownFields — workflow/role names are user-defined
28
22
  },
29
- modelOverrides: {
30
- nested: true,
31
- minDepth: 2,
32
- // modelOverrides.<scenario> = modelAlias (string value)
33
- // No knownFields — scenarios are user-defined
34
- },
35
23
  defaultAgent: { nested: false },
36
- defaultModel: { nested: false },
37
24
  };
38
25
 
39
26
  /**
@@ -175,27 +162,12 @@ export function setNestedValue(obj: Record<string, unknown>, path: string[], val
175
162
  }
176
163
 
177
164
  /**
178
- * Deep clone and mask all apiKey values in providers section
165
+ * Deep clone the config. Engine config is LLM-free, so there are no apiKey
166
+ * fields to mask — this function is preserved as a defensive deep-clone
167
+ * boundary used by `cmdConfigList`.
179
168
  */
180
169
  export function maskApiKeys(config: Record<string, unknown>): Record<string, unknown> {
181
- // Deep clone
182
- const cloned = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
183
-
184
- // Mask apiKey values in providers
185
- if (cloned.providers && typeof cloned.providers === "object") {
186
- const providers = cloned.providers as Record<string, unknown>;
187
- for (const providerName of Object.keys(providers)) {
188
- const provider = providers[providerName];
189
- if (provider && typeof provider === "object") {
190
- const providerObj = provider as Record<string, unknown>;
191
- if ("apiKey" in providerObj) {
192
- providerObj.apiKey = "***MASKED***";
193
- }
194
- }
195
- }
196
- }
197
-
198
- return cloned;
170
+ return JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
199
171
  }
200
172
 
201
173
  /**
@@ -241,7 +241,17 @@ uwf thread exec <thread-id>
241
241
  uwf thread show <thread-id>
242
242
  \`\`\`
243
243
 
244
- If the thread reaches \`$END\` with status \`completed\`, the setup is working.
244
+ If the thread reaches \`$END\` with status \`end\`, the setup is working.
245
+
246
+ To verify suspend/resume and poke:
247
+
248
+ \`\`\`bash
249
+ # After a role yields with $status: "$SUSPEND", resume the suspended thread:
250
+ uwf thread resume <thread-id> -p "Additional context for the agent"
251
+
252
+ # Re-run the head step's agent with a supplementary prompt (replaces head step):
253
+ uwf thread poke <thread-id> -p "Try again with this hint"
254
+ \`\`\`
245
255
 
246
256
  ## Scenario B: Upgrade from Previous Version
247
257
 
@@ -297,7 +307,7 @@ Check the changelog for breaking changes. Known migrations:
297
307
  resume: { role: planner, prompt: "Review previous run and continue." }
298
308
  \`\`\`
299
309
 
300
- Update all \`.workflow/\` and \`.workflows/\` YAML files in your projects. \`uwf workflow add\` will reject files with the old \`_\` syntax.
310
+ Update all \`.workflows/\` and \`.workflow/\` YAML files in your projects. \`uwf workflow add\` will reject files with the old \`_\` syntax.
301
311
 
302
312
  - **v0.2.1**: \`$status: { enum: [value] }\` → \`$status: { const: "value" }\`. The validator no longer accepts \`enum\` for \`$status\`. Update all workflow YAML files:
303
313
  \`\`\`yaml
@@ -310,6 +320,9 @@ Update all \`.workflow/\` and \`.workflows/\` YAML files in your projects. \`uwf
310
320
  # For multi-exit, use oneOf with const (unchanged)
311
321
  \`\`\`
312
322
 
323
+ - **v0.4.0**: Thread status \`completed\` → \`end\`. Update scripts that filter \`--status completed\` to use \`--status end\`. Legacy on-disk \`status: completed\` is normalized to \`end\` on read.
324
+ - **v0.4.0**: \`$SUSPEND\` is now an engine-level coroutine yield, not a graph target. Workflows that routed to \`role: "$SUSPEND"\` must emit \`$status: "$SUSPEND"\` with a \`reason\` from the role output instead. The thread becomes \`suspended\`; continue with \`uwf thread resume\`.
325
+
313
326
  ### Step 4 — Verify
314
327
 
315
328
  \`\`\`bash