ralph-flow-pi 0.1.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +428 -0
  3. package/dist/cli.d.ts +9 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +56 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/prompts.d.ts +25 -0
  8. package/dist/commands/prompts.d.ts.map +1 -0
  9. package/dist/commands/prompts.js +249 -0
  10. package/dist/commands/prompts.js.map +1 -0
  11. package/dist/commands/tools.d.ts +47 -0
  12. package/dist/commands/tools.d.ts.map +1 -0
  13. package/dist/commands/tools.js +633 -0
  14. package/dist/commands/tools.js.map +1 -0
  15. package/dist/engine/check-bash.d.ts +121 -0
  16. package/dist/engine/check-bash.d.ts.map +1 -0
  17. package/dist/engine/check-bash.js +373 -0
  18. package/dist/engine/check-bash.js.map +1 -0
  19. package/dist/engine/check.d.ts +47 -0
  20. package/dist/engine/check.d.ts.map +1 -0
  21. package/dist/engine/check.js +298 -0
  22. package/dist/engine/check.js.map +1 -0
  23. package/dist/engine/core.d.ts +153 -0
  24. package/dist/engine/core.d.ts.map +1 -0
  25. package/dist/engine/core.js +1984 -0
  26. package/dist/engine/core.js.map +1 -0
  27. package/dist/engine/lock.d.ts +27 -0
  28. package/dist/engine/lock.d.ts.map +1 -0
  29. package/dist/engine/lock.js +121 -0
  30. package/dist/engine/lock.js.map +1 -0
  31. package/dist/engine/runner.d.ts +108 -0
  32. package/dist/engine/runner.d.ts.map +1 -0
  33. package/dist/engine/runner.js +510 -0
  34. package/dist/engine/runner.js.map +1 -0
  35. package/dist/engine/skills.d.ts +53 -0
  36. package/dist/engine/skills.d.ts.map +1 -0
  37. package/dist/engine/skills.js +109 -0
  38. package/dist/engine/skills.js.map +1 -0
  39. package/dist/engine/step-tools.d.ts +22 -0
  40. package/dist/engine/step-tools.d.ts.map +1 -0
  41. package/dist/engine/step-tools.js +45 -0
  42. package/dist/engine/step-tools.js.map +1 -0
  43. package/dist/engine/types.d.ts +136 -0
  44. package/dist/engine/types.d.ts.map +1 -0
  45. package/dist/engine/types.js +20 -0
  46. package/dist/engine/types.js.map +1 -0
  47. package/dist/headless.d.ts +57 -0
  48. package/dist/headless.d.ts.map +1 -0
  49. package/dist/headless.js +318 -0
  50. package/dist/headless.js.map +1 -0
  51. package/dist/pi/adapter.d.ts +135 -0
  52. package/dist/pi/adapter.d.ts.map +1 -0
  53. package/dist/pi/adapter.js +231 -0
  54. package/dist/pi/adapter.js.map +1 -0
  55. package/dist/pi/interactive.d.ts +28 -0
  56. package/dist/pi/interactive.d.ts.map +1 -0
  57. package/dist/pi/interactive.js +58 -0
  58. package/dist/pi/interactive.js.map +1 -0
  59. package/dist/pi/tui.d.ts +12 -0
  60. package/dist/pi/tui.d.ts.map +1 -0
  61. package/dist/pi/tui.js +12 -0
  62. package/dist/pi/tui.js.map +1 -0
  63. package/dist/tui/app.d.ts +25 -0
  64. package/dist/tui/app.d.ts.map +1 -0
  65. package/dist/tui/app.js +47 -0
  66. package/dist/tui/app.js.map +1 -0
  67. package/dist/tui/embed.d.ts +42 -0
  68. package/dist/tui/embed.d.ts.map +1 -0
  69. package/dist/tui/embed.js +38 -0
  70. package/dist/tui/embed.js.map +1 -0
  71. package/dist/tui/extension.d.ts +88 -0
  72. package/dist/tui/extension.d.ts.map +1 -0
  73. package/dist/tui/extension.js +114 -0
  74. package/dist/tui/extension.js.map +1 -0
  75. package/dist/tui/history-editor.d.ts +38 -0
  76. package/dist/tui/history-editor.d.ts.map +1 -0
  77. package/dist/tui/history-editor.js +55 -0
  78. package/dist/tui/history-editor.js.map +1 -0
  79. package/dist/tui/launcher.d.ts +24 -0
  80. package/dist/tui/launcher.d.ts.map +1 -0
  81. package/dist/tui/launcher.js +97 -0
  82. package/dist/tui/launcher.js.map +1 -0
  83. package/dist/tui/render.d.ts +87 -0
  84. package/dist/tui/render.d.ts.map +1 -0
  85. package/dist/tui/render.js +266 -0
  86. package/dist/tui/render.js.map +1 -0
  87. package/dist/tui/run-app.d.ts +49 -0
  88. package/dist/tui/run-app.d.ts.map +1 -0
  89. package/dist/tui/run-app.js +317 -0
  90. package/dist/tui/run-app.js.map +1 -0
  91. package/dist/tui/run-model.d.ts +162 -0
  92. package/dist/tui/run-model.d.ts.map +1 -0
  93. package/dist/tui/run-model.js +280 -0
  94. package/dist/tui/run-model.js.map +1 -0
  95. package/dist/tui/run-view.d.ts +71 -0
  96. package/dist/tui/run-view.d.ts.map +1 -0
  97. package/dist/tui/run-view.js +167 -0
  98. package/dist/tui/run-view.js.map +1 -0
  99. package/dist/tui/welcome-header.d.ts +40 -0
  100. package/dist/tui/welcome-header.d.ts.map +1 -0
  101. package/dist/tui/welcome-header.js +90 -0
  102. package/dist/tui/welcome-header.js.map +1 -0
  103. package/package.json +55 -0
  104. package/skills/c-to-rust-audit/SKILL.md +67 -0
  105. package/skills/c-to-rust-implement/SKILL.md +151 -0
  106. package/skills/c-to-rust-implement/references/c-to-rust-patterns.md +86 -0
  107. package/skills/c-to-rust-implement/references/conditional-compilation.md +47 -0
  108. package/skills/c-to-rust-implement/references/crate-reference.md +15 -0
  109. package/skills/c-to-rust-implement/references/error-strategies.md +80 -0
  110. package/skills/c-to-rust-implement/references/inline-asm.md +37 -0
  111. package/skills/c-to-rust-plan/SKILL.md +166 -0
  112. package/skills/c-to-rust-plan/references/detection-commands.md +66 -0
  113. package/skills/c-to-rust-test-gen/SKILL.md +130 -0
  114. package/skills/c-to-rust-test-gen/references/proptest-patterns.md +81 -0
  115. package/skills/c-to-rust-test-gen/references/test-porting.md +56 -0
  116. package/skills/c-to-rust-validate/SKILL.md +121 -0
  117. package/skills/everything2rust-audit/SKILL.md +69 -0
  118. package/skills/everything2rust-design/SKILL.md +121 -0
  119. package/skills/everything2rust-design/references/domain-playbooks.md +68 -0
  120. package/skills/everything2rust-design/references/paradigm-map.md +99 -0
  121. package/skills/everything2rust-implement/SKILL.md +101 -0
  122. package/skills/everything2rust-spec/SKILL.md +86 -0
  123. package/skills/everything2rust-spec/references/oracle-strategies.md +96 -0
  124. package/skills/everything2rust-survey/SKILL.md +99 -0
  125. package/skills/everything2rust-test-gen/SKILL.md +68 -0
  126. package/skills/everything2rust-test-gen/references/harness-patterns.md +186 -0
  127. package/skills/everything2rust-validate/SKILL.md +85 -0
  128. package/workflows/c-to-rust.yaml +202 -0
  129. package/workflows/everything2rust.yaml +259 -0
  130. package/workflows/loop.yaml +68 -0
  131. package/workflows/spec.yaml +183 -0
@@ -0,0 +1,1984 @@
1
+ /**
2
+ * Ralph Flow engine core — the host-independent semantic core.
3
+ *
4
+ * The WORKFLOW LOGIC (YAML schema + validation, prompt building, the
5
+ * check-result state machine, the sub-workflow stack, lint/doctor, reports)
6
+ * is a verbatim mirror of the opencode plugin's engine.ts so existing user
7
+ * workflows keep running unchanged; see SYNC.md for the mirror boundary.
8
+ *
9
+ * The RUNTIME PLUMBING is NOT a mirror. ralph-flow-pi owns its sessions: the
10
+ * engine creates a fresh AgentSession per DO step instead of borrowing a host
11
+ * chat session. Three consequences show up in this file:
12
+ *
13
+ * - Completion signalling is structured. The opencode/Claude versions asked the
14
+ * model to emit `<promise>done</promise>` / `<promise-check>true</promise-check>`
15
+ * and parsed the text back out. Here the DO session gets a `report_done` tool
16
+ * and the CHECK session a `verdict` tool, so the tag-parsing helpers (and
17
+ * their last-line/100-char/code-fence tolerance rules) are gone.
18
+ * - The driver's idle-dedup markers (.last-phase-report/.post-tool-active) are
19
+ * gone with the idle event that needed them; the runner awaits turns directly.
20
+ * - Data lives under `.ralph-flow/` (no host dir), and there is no pre-2.0
21
+ * legacy layout to migrate — this package starts fresh.
22
+ */
23
+ import fs from "fs";
24
+ import path from "path";
25
+ import { fileURLToPath } from "url";
26
+ import yaml from "js-yaml";
27
+ import { validateExtraAllowedBash } from "./check-bash.js";
28
+ import { isSubWorkflowStep, stripBom, } from "./types.js";
29
+ export * from "./types.js";
30
+ // ─── Constants ────────────────────────────────────────────────────────────────
31
+ /** Project-relative data root. Unlike the plugin versions this is not nested
32
+ * under a host's config dir (.opencode/ / .claude/) — we are the host. */
33
+ export const RALPH_FLOW_DIRNAME = ".ralph-flow";
34
+ /** Global config home basename under ~/.config. */
35
+ export const GLOBAL_CONFIG_DIRNAME = "ralph-flow-pi";
36
+ const INSTANCES_DIRNAME = "instances";
37
+ const REPORTS_DIRNAME = "reports";
38
+ const STATE_FILENAME = "state.json";
39
+ const STACK_FILENAME = "state-stack.json";
40
+ // Mirrors the Claude version's .adversarial-pid / opencode's .adversarial-session:
41
+ // holds the CHECK session's JSONL path so a cross-process cancel can find it.
42
+ const ADVERSARIAL_SESSION_FILENAME = ".adversarial-session";
43
+ export const MANUAL_STEP_MARKER = ".manual-step-active";
44
+ export const MANUAL_GATE_MARKER = ".manual-gate";
45
+ /** Written by the DO session's `report_done` tool (was .done-tag-detected, set
46
+ * by text-tag detection). */
47
+ export const DONE_REPORTED_MARKER = ".done-reported";
48
+ export const REINJECT_COUNT_MARKER = ".do-reinject-count";
49
+ export const DO_PROMPT_CACHE_MARKER = ".do-prompt-cache";
50
+ /** PID of the process currently driving this instance's runner loop. */
51
+ export const RUNNER_PID_MARKER = ".runner-pid";
52
+ const MAX_STEP_RECORDS = 1000;
53
+ export const MAX_NESTING_DEPTH = 5;
54
+ const MAX_WORKFLOW_FILE_SIZE = 1024 * 1024; // 1 MB
55
+ export const MAX_ADVERSARIAL_TIMEOUT_MS = 3_600_000; // 1 hour
56
+ const isWin = process.platform === "win32";
57
+ export function createEngine(projectDir, platform = {}) {
58
+ // The runner is built on top of the engine (it needs readState, the prompts,
59
+ // the transitions), so its abort handle can only be wired in afterwards —
60
+ // hence a setter rather than a constructor argument.
61
+ function setAbortActiveStep(fn) {
62
+ platform = { ...platform, abortActiveStep: fn };
63
+ }
64
+ // ─── Atomic File I/O ────────────────────────────────────────────────────────
65
+ function atomicWriteJson(filePath, data) {
66
+ atomicWriteText(filePath, JSON.stringify(data, null, 2));
67
+ }
68
+ function atomicWriteText(filePath, text) {
69
+ const dir = path.dirname(filePath);
70
+ if (!fs.existsSync(dir))
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ const tmp = filePath + ".tmp." + process.pid;
73
+ fs.writeFileSync(tmp, text);
74
+ try {
75
+ fs.renameSync(tmp, filePath);
76
+ }
77
+ catch (e) {
78
+ // Windows can throw EPERM/EEXIST when renaming over an existing file that
79
+ // is momentarily open (antivirus, indexer). Retry once after a short spin.
80
+ if (isWin && (e.code === "EPERM" || e.code === "EEXIST" || e.code === "EACCES")) {
81
+ try {
82
+ fs.unlinkSync(filePath);
83
+ }
84
+ catch { }
85
+ fs.renameSync(tmp, filePath);
86
+ }
87
+ else {
88
+ try {
89
+ fs.unlinkSync(tmp);
90
+ }
91
+ catch { }
92
+ throw e;
93
+ }
94
+ }
95
+ }
96
+ // ─── Instance Infrastructure ────────────────────────────────────────────────
97
+ function getRalphFlowDir() {
98
+ return path.join(projectDir, RALPH_FLOW_DIRNAME);
99
+ }
100
+ // Diagnostic sink. Every user-facing problem is already surfaced through the
101
+ // `problems` array / tool responses, so nothing important is hidden here. It
102
+ // must stay a file: the TUI owns the terminal, so any stray console write
103
+ // corrupts the display.
104
+ function diag(...args) {
105
+ try {
106
+ const dir = path.join(getRalphFlowDir(), "logs");
107
+ if (!fs.existsSync(dir))
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ fs.appendFileSync(path.join(dir, "engine-diag.log"), `[${new Date().toISOString()}] ${args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ")}\n`);
110
+ }
111
+ catch { }
112
+ }
113
+ function getInstancesRoot() {
114
+ return path.join(getRalphFlowDir(), INSTANCES_DIRNAME);
115
+ }
116
+ function getReportsDir() {
117
+ return path.join(getRalphFlowDir(), REPORTS_DIRNAME);
118
+ }
119
+ // Per-instance artifacts directory. Lives OUTSIDE instances/<id>/ because the
120
+ // instance dir is deleted on completion/cancel (destroyInstance) — artifacts
121
+ // are workflow deliverables that must survive the workflow and stay isolated
122
+ // between parallel instances.
123
+ const ARTIFACTS_DIRNAME = "artifacts";
124
+ const ARTIFACTS_NAME_FILENAME = "artifacts-dir";
125
+ // OpenSpec-style human-readable dir name: short task summary + instance-id
126
+ // suffix so parallel instances of the same task never collide.
127
+ function makeArtifactsDirName(task, instId) {
128
+ // Truncate by code point (Array.from), not UTF-16 unit: a plain slice() can
129
+ // cut an emoji in half, leaving a lone surrogate that round-trips through
130
+ // the utf-8 name file as U+FFFD — the prompt and the file would then name
131
+ // two different directories. Dash-trim runs after the cut for the same
132
+ // reason (the cut itself can expose a trailing dash).
133
+ const slug = Array.from(String(task || "").trim()
134
+ .replace(/\s+/g, "-")
135
+ .replace(/[\\/:*?"'`<>|.$&(){}[\];!#~^]/g, "")).slice(0, 30).join("").replace(/^-+|-+$/g, "");
136
+ const suffix = String(instId).split("-").pop() || "0";
137
+ return slug ? `${slug}-${suffix}` : String(instId);
138
+ }
139
+ // The name is fixed at workflow start and read back from the instance dir —
140
+ // sub-workflow pushes rewrite state.json (including user_task), so the name
141
+ // cannot be re-derived from state later.
142
+ function writeArtifactsDirName(instId, task) {
143
+ atomicWriteText(instPath(ARTIFACTS_NAME_FILENAME, instId), makeArtifactsDirName(task, instId));
144
+ }
145
+ function getArtifactsDirName(instId) {
146
+ try {
147
+ const v = stripBom(fs.readFileSync(instPath(ARTIFACTS_NAME_FILENAME, instId), "utf-8")).trim();
148
+ // A hand-edited name file must not be able to walk out of the artifacts
149
+ // root (this path is joined and later mkdir'd/rmdir'd).
150
+ if (v && !v.includes("/") && !v.includes("\\") && !v.includes(".."))
151
+ return v;
152
+ }
153
+ catch { }
154
+ return reqInst(instId);
155
+ }
156
+ function getArtifactsDir(instId) {
157
+ return path.join(getRalphFlowDir(), ARTIFACTS_DIRNAME, getArtifactsDirName(instId));
158
+ }
159
+ // Project-relative form with forward slashes, embeddable in DO/CHECK prompts
160
+ // (both the step session and the adversarial checker run with cwd = projectDir).
161
+ function getArtifactsRelDir(instId) {
162
+ return `${RALPH_FLOW_DIRNAME}/${ARTIFACTS_DIRNAME}/${getArtifactsDirName(instId)}`;
163
+ }
164
+ // Internal escape hatch only: {{artifacts_dir}} in step text still resolves,
165
+ // but workflow authors never need it — every DO/CHECK prompt carries a 产出目录
166
+ // section pointing at the same path.
167
+ const ARTIFACTS_TOKEN = "{{artifacts_dir}}";
168
+ function renderStepText(instId, text) {
169
+ if (typeof text !== "string" || !text.includes(ARTIFACTS_TOKEN))
170
+ return text;
171
+ return text.split(ARTIFACTS_TOKEN).join(getArtifactsRelDir(instId));
172
+ }
173
+ // Extra read-access dirs for the adversarial checker, declared explicitly at
174
+ // ralphflow_start for tasks whose source material lives outside the project
175
+ // dir. Stored as an instance file so sub-workflow state pushes can't drop them.
176
+ const EXTRA_DIRS_FILENAME = "extra-dirs";
177
+ function writeExtraDirs(instId, dirs) {
178
+ if (Array.isArray(dirs) && dirs.length > 0) {
179
+ atomicWriteJson(instPath(EXTRA_DIRS_FILENAME, instId), dirs);
180
+ }
181
+ }
182
+ function readExtraDirs(instId) {
183
+ try {
184
+ const v = JSON.parse(stripBom(fs.readFileSync(instPath(EXTRA_DIRS_FILENAME, instId), "utf-8")));
185
+ if (Array.isArray(v))
186
+ return v.filter((d) => typeof d === "string");
187
+ }
188
+ catch { }
189
+ return [];
190
+ }
191
+ function getInstanceDir(instId) {
192
+ return path.join(getInstancesRoot(), instId);
193
+ }
194
+ /** Every instance-scoped helper requires an explicit instId. */
195
+ function reqInst(instId) {
196
+ if (!instId)
197
+ throw new Error("instId is required");
198
+ return instId;
199
+ }
200
+ function instPath(name, instId) {
201
+ return path.join(getInstanceDir(reqInst(instId)), name);
202
+ }
203
+ function isValidInstanceId(id) {
204
+ return typeof id === "string" && /^[a-z0-9][a-z0-9-]{0,80}$/.test(id);
205
+ }
206
+ function generateInstanceId(workflowName) {
207
+ const base = String(workflowName).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24) || "wf";
208
+ const d = new Date();
209
+ const pad = (n) => String(n).padStart(2, "0");
210
+ const ts = `${String(d.getFullYear()).slice(2)}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
211
+ const rand = Math.random().toString(36).slice(2, 6).padEnd(4, "0");
212
+ return `${base}-${ts}-${rand}`;
213
+ }
214
+ // ─── Step session dirs (owned by the runner; path knowledge lives here) ─────
215
+ //
216
+ // One DIRECTORY per step attempt, not one file: pi derives its own transcript
217
+ // filename (`<timestamp>_<id>.jsonl`) and offers no way to dictate it, so the
218
+ // directory is the unit we can address. The runner resumes an attempt by
219
+ // handing pi the same dir back.
220
+ const SESSIONS_DIRNAME = "sessions";
221
+ function getSessionsDir(instId) {
222
+ return path.join(getInstanceDir(reqInst(instId)), SESSIONS_DIRNAME);
223
+ }
224
+ function stepSlug(stepId) {
225
+ return String(stepId).replace(/[^a-zA-Z0-9._-]/g, "_");
226
+ }
227
+ /** Session dir for one attempt of one step phase. */
228
+ function getStepSessionDir(instId, stepId, phase, attempt) {
229
+ return path.join(getSessionsDir(instId), `${stepSlug(stepId)}-${phase}-${attempt}`);
230
+ }
231
+ /** Existing attempt dirs for a step phase, oldest first. */
232
+ function listStepSessionDirs(instId, stepId, phase) {
233
+ const root = getSessionsDir(instId);
234
+ const prefix = `${stepSlug(stepId)}-${phase}-`;
235
+ try {
236
+ return fs.readdirSync(root, { withFileTypes: true })
237
+ .filter((e) => e.isDirectory() && e.name.startsWith(prefix))
238
+ .map((e) => ({ name: e.name, n: parseInt(e.name.slice(prefix.length), 10) }))
239
+ .filter((e) => Number.isInteger(e.n))
240
+ .sort((a, b) => a.n - b.n)
241
+ .map((e) => path.join(root, e.name));
242
+ }
243
+ catch {
244
+ return [];
245
+ }
246
+ }
247
+ /** The owning session id is stored in the instance's state.json. */
248
+ function readOwnerSession(instId) {
249
+ const s = readState(instId);
250
+ return s?.session_id || null;
251
+ }
252
+ /** Claim ownership by writing session_id into the state (no-op if gone). */
253
+ function claimOwnership(instId, sessionId) {
254
+ if (!sessionId)
255
+ return;
256
+ const s = readState(instId);
257
+ if (!s || !s.active)
258
+ return;
259
+ if (s.session_id === sessionId)
260
+ return;
261
+ writeState({ ...s, session_id: sessionId }, instId);
262
+ clearMarker(".orphan-notified", instId);
263
+ }
264
+ // ─── Runner pid (cross-process double-drive guard) ──────────────────────────
265
+ function writeRunnerPid(instId) {
266
+ writeMarker(RUNNER_PID_MARKER, String(process.pid), instId);
267
+ }
268
+ function clearRunnerPid(instId) {
269
+ clearMarker(RUNNER_PID_MARKER, instId);
270
+ }
271
+ function readRunnerPid(instId) {
272
+ try {
273
+ const v = stripBom(fs.readFileSync(instPath(RUNNER_PID_MARKER, instId), "utf-8")).trim();
274
+ const pid = parseInt(v, 10);
275
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
281
+ /** Is another live process driving this instance? Our own pid never counts. */
282
+ function foreignRunnerPid(instId) {
283
+ const pid = readRunnerPid(instId);
284
+ if (pid === null || pid === process.pid)
285
+ return null;
286
+ return isPidAlive(pid) ? pid : null;
287
+ }
288
+ // ─── State Management (per instance) ────────────────────────────────────────
289
+ function getStateFile(instId) {
290
+ return instPath(STATE_FILENAME, instId);
291
+ }
292
+ /**
293
+ * A live instance is one whose state.json still exists. Writers below check
294
+ * this before writing so no code path can resurrect a destroyed instance
295
+ * directory (e.g. a cross-session cancel racing an in-flight check).
296
+ */
297
+ function instanceExists(instId) {
298
+ try {
299
+ return fs.existsSync(getStateFile(instId));
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ function isValidState(s) {
306
+ return s && typeof s === "object"
307
+ && typeof s.active === "boolean"
308
+ && typeof s.workflow_name === "string" && s.workflow_name.length > 0
309
+ && typeof s.current_step === "string" && s.current_step.length > 0
310
+ && typeof s.current_phase === "string"
311
+ && typeof s.fail_count === "number" && s.fail_count >= 0
312
+ && typeof s.paused === "boolean"
313
+ && (s.pause_reason === undefined || s.pause_reason === null || typeof s.pause_reason === "string");
314
+ }
315
+ function readState(instId) {
316
+ try {
317
+ const stateFile = getStateFile(instId);
318
+ if (fs.existsSync(stateFile)) {
319
+ try {
320
+ const parsed = JSON.parse(stripBom(fs.readFileSync(stateFile, "utf-8")));
321
+ if (!isValidState(parsed)) {
322
+ diag("[ralph-flow] State file has invalid schema, backing up");
323
+ try {
324
+ fs.renameSync(stateFile, stateFile + ".invalid." + Date.now());
325
+ }
326
+ catch { }
327
+ return null;
328
+ }
329
+ return parsed;
330
+ }
331
+ catch (parseErr) {
332
+ diag("[ralph-flow] State file corrupted, backing up:", parseErr.message);
333
+ try {
334
+ fs.renameSync(stateFile, stateFile + ".corrupted." + Date.now());
335
+ }
336
+ catch { }
337
+ return null;
338
+ }
339
+ }
340
+ }
341
+ catch (e) {
342
+ diag("[ralph-flow] Error reading state:", e.message);
343
+ }
344
+ return null;
345
+ }
346
+ function writeState(state, instId) {
347
+ try {
348
+ const id = reqInst(instId);
349
+ // Preserve the owning session_id when the caller's state object omits the
350
+ // key entirely. Pure-logic transitions (sub-workflow entry, check routing)
351
+ // build fresh state objects without session_id; without this they would
352
+ // orphan the instance. An explicit session_id (even null, to clear
353
+ // ownership) still wins.
354
+ const session_id = Object.prototype.hasOwnProperty.call(state, "session_id")
355
+ ? state.session_id
356
+ : readState(id)?.session_id;
357
+ atomicWriteJson(getStateFile(id), { ...state, session_id, instance_id: id });
358
+ }
359
+ catch (e) {
360
+ diag("[ralph-flow] Error writing state:", e.message);
361
+ }
362
+ }
363
+ function writeMarker(name, content, instId) {
364
+ try {
365
+ const id = reqInst(instId);
366
+ if (!instanceExists(id))
367
+ return; // never resurrect a destroyed instance
368
+ fs.writeFileSync(path.join(getInstanceDir(id), name), content);
369
+ }
370
+ catch { }
371
+ }
372
+ function clearMarker(name, instId) {
373
+ try {
374
+ const marker = instPath(name, instId);
375
+ if (fs.existsSync(marker))
376
+ fs.unlinkSync(marker);
377
+ }
378
+ catch { }
379
+ }
380
+ function markerExists(name, instId) {
381
+ try {
382
+ return fs.existsSync(instPath(name, instId));
383
+ }
384
+ catch {
385
+ return false;
386
+ }
387
+ }
388
+ function writeManualStepMarker(instId) { writeMarker(MANUAL_STEP_MARKER, "active", instId); }
389
+ function clearManualStepMarker(instId) { clearMarker(MANUAL_STEP_MARKER, instId); }
390
+ function writeManualGate(instId) { writeMarker(MANUAL_GATE_MARKER, "waiting", instId); }
391
+ function clearManualGate(instId) { clearMarker(MANUAL_GATE_MARKER, instId); }
392
+ function clearReinjectCounter(instId) { clearMarker(REINJECT_COUNT_MARKER, instId); }
393
+ function clearDoPromptCache(instId) { clearMarker(DO_PROMPT_CACHE_MARKER, instId); }
394
+ /** Set by the DO session's `report_done` tool. */
395
+ function writeDoneReported(instId) { writeMarker(DONE_REPORTED_MARKER, "done", instId); }
396
+ function clearDoneReported(instId) { clearMarker(DONE_REPORTED_MARKER, instId); }
397
+ function doneReported(instId) { return markerExists(DONE_REPORTED_MARKER, instId); }
398
+ // The counter is keyed by "<step>:<phase>" exactly as the driver's was: moving
399
+ // to another step or phase must start the keep-alive budget over, and keying
400
+ // the file rather than clearing it on every transition means no transition can
401
+ // forget to.
402
+ function readReinjectCount(instId, key) {
403
+ try {
404
+ const content = stripBom(fs.readFileSync(instPath(REINJECT_COUNT_MARKER, instId), "utf-8")).trim();
405
+ const [storedKey, storedCount] = content.split(" ");
406
+ if (storedKey !== key)
407
+ return 0;
408
+ const v = parseInt(storedCount, 10);
409
+ return Number.isInteger(v) && v >= 0 ? v : 0;
410
+ }
411
+ catch {
412
+ return 0;
413
+ }
414
+ }
415
+ function incrementReinjectCount(instId, key) {
416
+ const next = readReinjectCount(instId, key) + 1;
417
+ writeMarker(REINJECT_COUNT_MARKER, `${key} ${next}`, instId);
418
+ return next;
419
+ }
420
+ function writeDoPromptCache(prompt, instId) {
421
+ try {
422
+ const id = reqInst(instId);
423
+ if (!instanceExists(id))
424
+ return;
425
+ atomicWriteText(instPath(DO_PROMPT_CACHE_MARKER, id), prompt);
426
+ }
427
+ catch { }
428
+ }
429
+ function readDoPromptCache(instId) {
430
+ try {
431
+ const v = stripBom(fs.readFileSync(instPath(DO_PROMPT_CACHE_MARKER, instId), "utf-8"));
432
+ return v || null;
433
+ }
434
+ catch {
435
+ return null;
436
+ }
437
+ }
438
+ // ─── Adversarial-check session file (cross-process cancel support) ──────────
439
+ function writeAdversarialSession(checkSessionRef, instId) {
440
+ try {
441
+ const id = reqInst(instId);
442
+ if (!instanceExists(id))
443
+ return;
444
+ atomicWriteText(instPath(ADVERSARIAL_SESSION_FILENAME, id), String(checkSessionRef));
445
+ }
446
+ catch { }
447
+ }
448
+ function clearAdversarialSession(instId) {
449
+ clearMarker(ADVERSARIAL_SESSION_FILENAME, instId);
450
+ }
451
+ function readAdversarialSession(instId) {
452
+ try {
453
+ const v = stripBom(fs.readFileSync(instPath(ADVERSARIAL_SESSION_FILENAME, instId), "utf-8")).trim();
454
+ return v || null;
455
+ }
456
+ catch {
457
+ return null;
458
+ }
459
+ }
460
+ // ─── Instance listing / resolution ──────────────────────────────────────────
461
+ function listInstances() {
462
+ const result = [];
463
+ const root = getInstancesRoot();
464
+ if (!fs.existsSync(root))
465
+ return result;
466
+ let entries;
467
+ try {
468
+ entries = fs.readdirSync(root, { withFileTypes: true });
469
+ }
470
+ catch {
471
+ return result;
472
+ }
473
+ for (const entry of entries) {
474
+ if (!entry.isDirectory())
475
+ continue;
476
+ const id = entry.name;
477
+ if (!isValidInstanceId(id))
478
+ continue;
479
+ const state = readState(id);
480
+ if (!state || !state.active)
481
+ continue;
482
+ let lastActivity = null;
483
+ try {
484
+ lastActivity = fs.statSync(getStateFile(id)).mtime;
485
+ }
486
+ catch { }
487
+ result.push({
488
+ id,
489
+ state,
490
+ owner: state.session_id || null,
491
+ manualGate: markerExists(MANUAL_GATE_MARKER, id),
492
+ doneReported: markerExists(DONE_REPORTED_MARKER, id),
493
+ lastActivity,
494
+ });
495
+ }
496
+ return result;
497
+ }
498
+ function instanceStatusLabel(info) {
499
+ const s = info.state;
500
+ if (s.paused) {
501
+ if (s.pause_reason === "max_failures")
502
+ return "⏸ 已暂停(达到最大失败次数)";
503
+ if (s.pause_reason === "config_error")
504
+ return "⏸ 已暂停(工作流配置错误)";
505
+ if (s.pause_reason === "check_error")
506
+ return "⏸ 已暂停(验证未能运行,continue 重新验证)";
507
+ if (s.pause_reason === "session_aborted")
508
+ return "⏸ 已暂停(会话中断,continue 恢复)";
509
+ return `⏸ 已暂停(${s.pause_reason || "未知原因"})`;
510
+ }
511
+ if (s.current_phase === "check")
512
+ return "🔍 验证中";
513
+ if (info.manualGate)
514
+ return "⏸ 等待手动审查";
515
+ if (info.doneReported)
516
+ return "✅ DO 完成,待验证";
517
+ return "🔨 执行中";
518
+ }
519
+ function formatLastActivity(date) {
520
+ if (!date)
521
+ return "未知";
522
+ const diffMs = Date.now() - date.getTime();
523
+ const mins = Math.floor(diffMs / 60000);
524
+ if (mins < 1)
525
+ return "刚刚";
526
+ if (mins < 60)
527
+ return `${mins} 分钟前`;
528
+ const hours = Math.floor(mins / 60);
529
+ if (hours < 24)
530
+ return `${hours} 小时前`;
531
+ return `${Math.floor(hours / 24)} 天前`;
532
+ }
533
+ function formatInstanceList(instances, actionHint) {
534
+ const lines = [`## 工作流实例(${instances.length} 个)`, ""];
535
+ for (const info of instances) {
536
+ const task = (info.state.user_task || "").replace(/\s+/g, " ").slice(0, 60);
537
+ lines.push(`### \`${info.id}\``);
538
+ lines.push(`- **工作流**: ${info.state.workflow_name}`);
539
+ if (task)
540
+ lines.push(`- **任务**: ${task}${(info.state.user_task || "").length > 60 ? "…" : ""}`);
541
+ lines.push(`- **步骤**: ${info.state.current_step}(${info.state.current_phase})`);
542
+ lines.push(`- **状态**: ${instanceStatusLabel(info)}`);
543
+ lines.push(`- **属主会话**: ${info.owner ? `\`${info.owner.slice(0, 8)}\`` : "无"}`);
544
+ lines.push(`- **最后活动**: ${formatLastActivity(info.lastActivity)}`);
545
+ lines.push("");
546
+ }
547
+ if (actionHint)
548
+ lines.push(actionHint);
549
+ return lines.join("\n");
550
+ }
551
+ /**
552
+ * Resolve which instance a tool call from `sessionId` targets.
553
+ * `attached` is true when the call takes over an instance owned by a
554
+ * different (or no) session — the caller uses it to pick attach semantics.
555
+ * Ownership is advisory: takeover is always allowed (explicitly, or
556
+ * implicitly when a single instance exists). The hard guard against two
557
+ * processes driving one instance is the .runner-pid liveness check, not this.
558
+ */
559
+ function resolveInstance(explicitId, sessionId) {
560
+ const instances = listInstances();
561
+ // 1. Explicit id (unique prefix allowed).
562
+ if (explicitId) {
563
+ const wanted = String(explicitId).trim();
564
+ const matches = instances.filter((i) => i.id === wanted);
565
+ const prefixMatches = matches.length > 0 ? matches : instances.filter((i) => i.id.startsWith(wanted));
566
+ if (prefixMatches.length === 1) {
567
+ const inst = prefixMatches[0];
568
+ return { ok: true, id: inst.id, attached: inst.owner !== sessionId };
569
+ }
570
+ if (prefixMatches.length === 0) {
571
+ return {
572
+ ok: false,
573
+ text: instances.length === 0
574
+ ? `没有找到实例 "${wanted}"。当前没有活跃的工作流实例。`
575
+ : `没有找到匹配 "${wanted}" 的实例。\n\n${formatInstanceList(instances)}`,
576
+ };
577
+ }
578
+ return { ok: false, text: `前缀 "${wanted}" 匹配到 ${prefixMatches.length} 个实例,请提供更长的前缀:\n\n${formatInstanceList(prefixMatches)}` };
579
+ }
580
+ // 2. An instance already owned by this session.
581
+ if (sessionId) {
582
+ const mine = instances.filter((i) => i.owner === sessionId);
583
+ if (mine.length >= 1) {
584
+ mine.sort((a, b) => (b.lastActivity?.getTime() || 0) - (a.lastActivity?.getTime() || 0));
585
+ return { ok: true, id: mine[0].id, attached: false };
586
+ }
587
+ }
588
+ // 3. No instance owned by this session.
589
+ if (instances.length === 0) {
590
+ return { ok: false, text: "没有活跃的工作流。使用 ralphflow_start 启动一个。" };
591
+ }
592
+ // Exactly one instance in the project → attach to it.
593
+ if (instances.length === 1) {
594
+ return { ok: true, id: instances[0].id, attached: instances[0].owner !== sessionId };
595
+ }
596
+ return { ok: false, text: formatInstanceList(instances, "存在多个实例,请显式指定要操作的实例:调用工具时传入 `instance: \"<实例ID>\"`(支持唯一前缀)。") };
597
+ }
598
+ /** Claim an instance for a session (writes session_id into its state). */
599
+ function bindInstance(instId, sessionId) {
600
+ claimOwnership(instId, sessionId);
601
+ }
602
+ // ─── Instance destruction (complete / cancel) ───────────────────────────────
603
+ function archiveReport(instId, workflowName, status, records) {
604
+ try {
605
+ const reportsDir = getReportsDir();
606
+ if (!fs.existsSync(reportsDir))
607
+ fs.mkdirSync(reportsDir, { recursive: true });
608
+ const reportPath = path.join(reportsDir, `${instId}-final-report.md`);
609
+ let artifactsNote = "";
610
+ try {
611
+ const artifactsDir = getArtifactsDir(instId);
612
+ if (fs.readdirSync(artifactsDir).length > 0) {
613
+ artifactsNote = `\n\n产出目录:\`${getArtifactsRelDir(instId)}/\`\n`;
614
+ }
615
+ }
616
+ catch { }
617
+ atomicWriteText(reportPath, buildReportText(workflowName, status, records || []) + artifactsNote);
618
+ return reportPath;
619
+ }
620
+ catch (e) {
621
+ diag("[ralph-flow] Report generation failed:", e.message);
622
+ return null;
623
+ }
624
+ }
625
+ /**
626
+ * Destroy an instance: abort any running session, archive the final report,
627
+ * remove the instance directory. Returns the archived report path.
628
+ */
629
+ function destroyInstance(instId, status) {
630
+ let workflowName = instId;
631
+ const state = readState(instId);
632
+ if (state)
633
+ workflowName = state.workflow_name;
634
+ const records = loadStepRecords(instId);
635
+ // Abort sessions running in THIS process. A runner in another process can't
636
+ // be reached here — it notices the instance is gone on its next state read,
637
+ // and its check verdict is discarded by the same staleness re-read.
638
+ try {
639
+ platform.abortActiveCheck?.(instId);
640
+ }
641
+ catch { }
642
+ try {
643
+ platform.abortActiveStep?.(instId);
644
+ }
645
+ catch { }
646
+ const reportPath = archiveReport(instId, workflowName, status, records);
647
+ // Resolve before the instance dir goes away — the artifacts-dir name file
648
+ // lives inside it.
649
+ const artifactsDir = getArtifactsDir(instId);
650
+ // Delete state.json first: even if the recursive removal partially fails
651
+ // (Windows EBUSY on files still held open), the instance is de-listed and
652
+ // can't act as a ghost.
653
+ try {
654
+ fs.unlinkSync(getStateFile(instId));
655
+ }
656
+ catch { }
657
+ try {
658
+ fs.rmSync(getInstanceDir(instId), { recursive: true, force: true });
659
+ }
660
+ catch (e) {
661
+ diag("[ralph-flow] Error removing instance dir:", e.message);
662
+ }
663
+ // A workflow that produced nothing leaves no folder behind — rmdir refuses
664
+ // non-empty dirs, so real deliverables always outlive the instance.
665
+ try {
666
+ fs.rmdirSync(artifactsDir);
667
+ }
668
+ catch { }
669
+ return reportPath;
670
+ }
671
+ // ─── Workflow Loader ────────────────────────────────────────────────────────
672
+ function getBuiltinWorkflowsDir() {
673
+ const __filename = fileURLToPath(import.meta.url);
674
+ // dist/engine/core.js → package root → workflows/
675
+ return path.join(path.dirname(__filename), "..", "..", "workflows");
676
+ }
677
+ function getProjectWorkflowsDir() {
678
+ return path.join(getRalphFlowDir(), "workflows");
679
+ }
680
+ // Global user workflows, available across ALL projects and surviving package
681
+ // updates (built-ins live inside the installed npm package, which is
682
+ // overwritten on update and not user-editable for a global install).
683
+ // Honors XDG_CONFIG_HOME.
684
+ function getGlobalConfigHome() {
685
+ const xdg = process.env.XDG_CONFIG_HOME;
686
+ if (xdg && path.isAbsolute(xdg))
687
+ return path.join(xdg, GLOBAL_CONFIG_DIRNAME);
688
+ const home = process.env.HOME || process.env.USERPROFILE || "";
689
+ if (!home)
690
+ return null;
691
+ return path.join(home, ".config", GLOBAL_CONFIG_DIRNAME);
692
+ }
693
+ function getGlobalWorkflowsDir() {
694
+ const cfg = getGlobalConfigHome();
695
+ return cfg ? path.join(cfg, "workflows") : null;
696
+ }
697
+ function parseWorkflowFile(filePath, workflowName, problems) {
698
+ // Validation failures are collected into `problems` (when provided) so tool
699
+ // responses can tell the user WHY a workflow is unusable.
700
+ const problem = (msg) => { if (Array.isArray(problems))
701
+ problems.push(msg); };
702
+ const skipStep = (msg) => { diag(`[ralph-flow] ${msg}`); problem(msg); };
703
+ try {
704
+ const stats = fs.statSync(filePath);
705
+ if (stats.size > MAX_WORKFLOW_FILE_SIZE) {
706
+ diag(`[ralph-flow] Workflow file ${filePath} exceeds ${MAX_WORKFLOW_FILE_SIZE} bytes, skipped`);
707
+ problem(`工作流文件超过 ${MAX_WORKFLOW_FILE_SIZE} 字节上限`);
708
+ return null;
709
+ }
710
+ const content = stripBom(fs.readFileSync(filePath, "utf-8"));
711
+ const parsed = yaml.load(content);
712
+ if (!parsed || typeof parsed !== "object") {
713
+ problem("YAML 内容不是对象");
714
+ return null;
715
+ }
716
+ if (!Array.isArray(parsed.steps) || parsed.steps.length === 0) {
717
+ problem("缺少非空的 steps 数组");
718
+ return null;
719
+ }
720
+ const validSteps = [];
721
+ for (let i = 0; i < parsed.steps.length; i++) {
722
+ const step = parsed.steps[i];
723
+ if (!step || typeof step !== "object") {
724
+ skipStep(`Step ${i} in ${workflowName}: not an object, skipped`);
725
+ continue;
726
+ }
727
+ if (!step.id || typeof step.id !== "string") {
728
+ skipStep(`Step ${i} in ${workflowName}: missing/invalid 'id', skipped`);
729
+ continue;
730
+ }
731
+ if (!step.desc || typeof step.desc !== "string") {
732
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'desc', skipped`);
733
+ continue;
734
+ }
735
+ if (!step.on_pass || typeof step.on_pass !== "string") {
736
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'on_pass', skipped`);
737
+ continue;
738
+ }
739
+ if (!step.on_fail || typeof step.on_fail !== "string") {
740
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'on_fail', skipped`);
741
+ continue;
742
+ }
743
+ if (typeof step.max_fail_count !== "number" || step.max_fail_count < 1) {
744
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'max_fail_count', skipped`);
745
+ continue;
746
+ }
747
+ // Validate input/output fields (they become the 输入说明/输出要求 sections of the DO/CHECK prompts)
748
+ if (!step.input || typeof step.input !== "string") {
749
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'input' field, skipped`);
750
+ continue;
751
+ }
752
+ if (!step.output || typeof step.output !== "string") {
753
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'output' field, skipped`);
754
+ continue;
755
+ }
756
+ if (step.workflow) {
757
+ if (typeof step.workflow !== "string") {
758
+ skipStep(`Step "${step.id}" in ${workflowName}: invalid 'workflow', skipped`);
759
+ continue;
760
+ }
761
+ validSteps.push(step);
762
+ continue;
763
+ }
764
+ if (!step.do || typeof step.do !== "string") {
765
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'do', skipped`);
766
+ continue;
767
+ }
768
+ if (!step.check || typeof step.check !== "string") {
769
+ skipStep(`Step "${step.id}" in ${workflowName}: missing/invalid 'check', skipped`);
770
+ continue;
771
+ }
772
+ validSteps.push(step);
773
+ }
774
+ if (validSteps.length === 0) {
775
+ problem("没有任何有效步骤");
776
+ return null;
777
+ }
778
+ // Duplicate step ids make on_pass/on_fail ambiguous (getStep returns the
779
+ // first match, and the id Set silently collapses the rest) — a hard
780
+ // error, not a silent merge.
781
+ const dupIds = [...new Set(validSteps.map((s) => s.id).filter((id, i, arr) => arr.indexOf(id) !== i))];
782
+ if (dupIds.length > 0) {
783
+ problem(`步骤 id 重复:${dupIds.map((id) => `"${id}"`).join("、")}(每个步骤的 id 必须唯一)`);
784
+ return null;
785
+ }
786
+ // Validate on_pass/on_fail references
787
+ const stepIds = new Set(validSteps.map((s) => s.id));
788
+ for (const step of validSteps) {
789
+ if (step.on_pass !== "done" && !stepIds.has(step.on_pass)) {
790
+ diag(`[ralph-flow] Step "${step.id}" on_pass references unknown step "${step.on_pass}"`);
791
+ problem(`步骤 "${step.id}" 的 on_pass 引用了不存在的步骤 "${step.on_pass}"`);
792
+ return null;
793
+ }
794
+ if (!stepIds.has(step.on_fail)) {
795
+ diag(`[ralph-flow] Step "${step.id}" on_fail references unknown step "${step.on_fail}"`);
796
+ problem(`步骤 "${step.id}" 的 on_fail 引用了不存在的步骤 "${step.on_fail}"`);
797
+ return null;
798
+ }
799
+ }
800
+ const manual_step = Array.isArray(parsed.manual_step)
801
+ ? parsed.manual_step.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim())
802
+ : typeof parsed.manual_step === "string"
803
+ ? parsed.manual_step.split(",").map((s) => s.trim()).filter(Boolean)
804
+ : [];
805
+ // A typo'd manual_step entry would silently drop a human review gate the
806
+ // user is counting on — the workflow would run fully automated past the
807
+ // point that was supposed to stop for review. Hard error, not a warning.
808
+ const unknownManual = manual_step.filter((id) => !stepIds.has(id));
809
+ if (unknownManual.length > 0) {
810
+ diag(`[ralph-flow] manual_step in ${workflowName} references unknown step(s): ${unknownManual.join(", ")}`);
811
+ problem(`manual_step 引用了不存在的步骤:${unknownManual.map((s) => `"${s}"`).join("、")}`);
812
+ return null;
813
+ }
814
+ const adv = parsed.adversarial_check;
815
+ let adversarial_check = undefined;
816
+ if (adv && typeof adv === "object") {
817
+ // Both historical shapes are accepted. The object form ({providerID,
818
+ // modelID}) was the opencode SDK's own; pi-ai resolves strings, so it is
819
+ // normalized to "provider/model" here and everything downstream sees a
820
+ // string. A modelID without providerID is passed through bare and left
821
+ // for pi to resolve (doctor warns about it).
822
+ let model = undefined;
823
+ if (typeof adv.model === "string" && adv.model.trim()) {
824
+ model = adv.model.trim();
825
+ }
826
+ else if (adv.model && typeof adv.model === "object") {
827
+ if (adv.model.modelID && typeof adv.model.modelID === "string") {
828
+ const modelID = adv.model.modelID.trim();
829
+ const providerID = typeof adv.model.providerID === "string" ? adv.model.providerID.trim() : "";
830
+ model = providerID ? `${providerID}/${modelID}` : modelID;
831
+ }
832
+ }
833
+ const system_prompt = typeof adv.system_prompt === "string" && adv.system_prompt.trim() ? adv.system_prompt.trim() : undefined;
834
+ const agent = typeof adv.agent === "string" && adv.agent.trim() ? adv.agent.trim() : undefined;
835
+ let timeout_ms = undefined;
836
+ if (typeof adv.timeout_ms === "number" && adv.timeout_ms > 0) {
837
+ timeout_ms = Math.min(adv.timeout_ms, MAX_ADVERSARIAL_TIMEOUT_MS); // Cap at 1 hour
838
+ }
839
+ const extra_allowed_bash = Array.isArray(adv.extra_allowed_bash)
840
+ ? [...new Set(adv.extra_allowed_bash.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim()))]
841
+ : undefined;
842
+ adversarial_check = { model, agent, system_prompt, timeout_ms, extra_allowed_bash };
843
+ }
844
+ return {
845
+ name: workflowName,
846
+ description: parsed.description || validSteps[0].desc || workflowName,
847
+ manual_step,
848
+ steps: validSteps,
849
+ adversarial_check,
850
+ };
851
+ }
852
+ catch (e) {
853
+ diag(`[ralph-flow] Error parsing workflow ${filePath}:`, e.message);
854
+ problem(`解析失败:${e.message}`);
855
+ return null;
856
+ }
857
+ }
858
+ function isValidWorkflowName(name) {
859
+ // Reject names with path separators, traversal sequences, or special chars
860
+ return typeof name === "string" && name.length > 0 && name.length < 100
861
+ && !/[\/\\]/.test(name) && !name.includes("..") && !name.startsWith(".");
862
+ }
863
+ function loadWorkflow(workflowName, problems) {
864
+ if (!isValidWorkflowName(workflowName))
865
+ return null;
866
+ const globalDir = getGlobalWorkflowsDir();
867
+ // Resolution order: project > global user > built-in. A same-named
868
+ // workflow at an earlier tier shadows the later ones.
869
+ const searchPaths = [
870
+ path.join(getProjectWorkflowsDir(), `${workflowName}.yaml`),
871
+ path.join(getProjectWorkflowsDir(), `${workflowName}.yml`),
872
+ ...(globalDir ? [
873
+ path.join(globalDir, `${workflowName}.yaml`),
874
+ path.join(globalDir, `${workflowName}.yml`),
875
+ ] : []),
876
+ path.join(getBuiltinWorkflowsDir(), `${workflowName}.yaml`),
877
+ path.join(getBuiltinWorkflowsDir(), `${workflowName}.yml`),
878
+ ];
879
+ for (const p of searchPaths) {
880
+ if (fs.existsSync(p)) {
881
+ const result = parseWorkflowFile(p, workflowName, problems);
882
+ if (result)
883
+ return result;
884
+ }
885
+ }
886
+ return null;
887
+ }
888
+ function listWorkflows() {
889
+ const workflows = new Map();
890
+ const scanDir = (dir) => {
891
+ if (!fs.existsSync(dir))
892
+ return;
893
+ try {
894
+ for (const file of fs.readdirSync(dir)) {
895
+ if (file.endsWith(".yaml") || file.endsWith(".yml")) {
896
+ try {
897
+ const filePath = path.join(dir, file);
898
+ const stats = fs.statSync(filePath);
899
+ if (stats.size > MAX_WORKFLOW_FILE_SIZE) {
900
+ diag(`[ralph-flow] Workflow file ${filePath} exceeds ${MAX_WORKFLOW_FILE_SIZE} bytes, skipped`);
901
+ continue;
902
+ }
903
+ const content = stripBom(fs.readFileSync(filePath, "utf-8"));
904
+ const parsed = yaml.load(content);
905
+ // Not workflow-shaped at all (stray yaml) — skip silently, as before.
906
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.steps) || parsed.steps.length === 0)
907
+ continue;
908
+ const name = file.replace(/\.(yaml|yml)$/, "");
909
+ // Run the FULL validation so the list agrees with what
910
+ // ralphflow_start will accept — a file that fails loadWorkflow must
911
+ // not be listed as launchable, it gets flagged instead.
912
+ const problems = [];
913
+ const wf = parseWorkflowFile(filePath, name, problems);
914
+ const existing = workflows.get(name);
915
+ if (wf) {
916
+ // First valid candidate in resolution order wins; a valid later
917
+ // candidate replaces an invalid earlier one — loadWorkflow falls
918
+ // through invalid files the same way.
919
+ if (!existing || existing.invalid) {
920
+ workflows.set(name, { name, desc: wf.description });
921
+ }
922
+ }
923
+ else if (!existing) {
924
+ workflows.set(name, {
925
+ name,
926
+ desc: `⚠️ 定义无效,无法启动:${problems[0] || "解析失败"}`,
927
+ invalid: true,
928
+ });
929
+ }
930
+ }
931
+ catch (e) {
932
+ diag(`[ralph-flow] Error reading workflow ${file}:`, e.message);
933
+ }
934
+ }
935
+ }
936
+ }
937
+ catch (e) {
938
+ diag(`[ralph-flow] Error scanning dir ${dir}:`, e.message);
939
+ }
940
+ };
941
+ // Scan project → global → built-in — the first VALID writer wins, so a valid
942
+ // project workflow shadows a same-named global one which shadows a built-in,
943
+ // while an invalid one falls through. Matches loadWorkflow's resolution
944
+ // order exactly, so list and execution agree.
945
+ scanDir(getProjectWorkflowsDir());
946
+ const globalDir = getGlobalWorkflowsDir();
947
+ if (globalDir)
948
+ scanDir(globalDir);
949
+ scanDir(getBuiltinWorkflowsDir());
950
+ return Array.from(workflows.values());
951
+ }
952
+ // ─── Workflow Doctor ────────────────────────────────────────────────────────
953
+ //
954
+ // Deep diagnosis behind the ralphflow_doctor tool. Reuses parseWorkflowFile so
955
+ // its verdicts always agree with what ralphflow_start actually accepts, then
956
+ // layers lints for problems the engine only surfaces at runtime (or never).
957
+ /**
958
+ * Lint a workflow that already passed full validation. Returns human-readable
959
+ * warning strings — things that won't stop ralphflow_start but will bite later.
960
+ * `rawParsed` is the untouched yaml.load result (parseWorkflowFile drops fields
961
+ * the lints need to see).
962
+ */
963
+ function lintWorkflow(wf, rawParsed) {
964
+ const warnings = [];
965
+ // Unreachable steps: execution enters at steps[0] and only moves along
966
+ // on_pass/on_fail edges, so anything outside that closure never runs.
967
+ const byId = new Map(wf.steps.map((s) => [s.id, s]));
968
+ const reachable = new Set();
969
+ const queue = [wf.steps[0].id];
970
+ while (queue.length > 0) {
971
+ const id = queue.pop();
972
+ if (reachable.has(id))
973
+ continue;
974
+ reachable.add(id);
975
+ const s = byId.get(id);
976
+ if (!s)
977
+ continue;
978
+ if (s.on_pass !== "done")
979
+ queue.push(s.on_pass);
980
+ queue.push(s.on_fail);
981
+ }
982
+ const unreachable = wf.steps.filter((s) => !reachable.has(s.id)).map((s) => s.id);
983
+ if (unreachable.length > 0) {
984
+ warnings.push(`步骤 ${unreachable.map((s) => `"${s}"`).join("、")} 从入口(steps 的第一项)沿 on_pass/on_fail 不可达,永远不会执行`);
985
+ }
986
+ // A workflow none of whose reachable steps can reach "done" never finishes.
987
+ if (!wf.steps.some((s) => reachable.has(s.id) && s.on_pass === "done")) {
988
+ warnings.push(`没有任何可达步骤的 on_pass 为 "done",工作流永远无法正常完成`);
989
+ }
990
+ // Template tokens: the engine resolves exactly one token, {{artifacts_dir}}
991
+ // (byte-exact — even extra spaces inside the braces break it). Anything else
992
+ // reaches the DO/CHECK prompt unresolved.
993
+ for (const s of wf.steps) {
994
+ for (const field of ["desc", "do", "check", "input", "output"]) {
995
+ const text = s[field];
996
+ if (typeof text !== "string")
997
+ continue;
998
+ for (const m of text.matchAll(/\{\{[^{}]*\}\}/g)) {
999
+ if (m[0] !== ARTIFACTS_TOKEN) {
1000
+ warnings.push(`步骤 "${s.id}" 的 ${field} 含模板变量 ${m[0]},引擎不会解析(唯一支持的记号是 ${ARTIFACTS_TOKEN},花括号内不能有空格;产出目录本就会自动注入到提示词,通常不需要任何记号)`);
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+ // Sub-workflow references resolve lazily at runtime — a broken one passes
1006
+ // validation and then fails the workflow mid-run.
1007
+ for (const s of wf.steps) {
1008
+ if (!isSubWorkflowStep(s))
1009
+ continue;
1010
+ const subProblems = [];
1011
+ if (!loadWorkflow(s.workflow, subProblems)) {
1012
+ warnings.push(`步骤 "${s.id}" 引用的子工作流 "${s.workflow}" 无法加载(${subProblems[0] || "未找到定义文件"})— 校验能通过,但运行到该步时工作流会失败`);
1013
+ }
1014
+ }
1015
+ const cycle = findSubWorkflowCycle(wf.name);
1016
+ if (cycle) {
1017
+ warnings.push(`子工作流引用成环:${cycle.join(" → ")}。运行时会在嵌套深度 ${MAX_NESTING_DEPTH} 处报错暂停`);
1018
+ }
1019
+ // adversarial_check fields the engine clamps or reinterprets.
1020
+ const adv = rawParsed && typeof rawParsed === "object" ? rawParsed.adversarial_check : undefined;
1021
+ if (adv && typeof adv === "object") {
1022
+ if (typeof adv.timeout_ms === "number" && adv.timeout_ms > MAX_ADVERSARIAL_TIMEOUT_MS) {
1023
+ warnings.push(`adversarial_check.timeout_ms(${adv.timeout_ms})超过 1 小时上限,会被截断为 ${MAX_ADVERSARIAL_TIMEOUT_MS}`);
1024
+ }
1025
+ if (typeof adv.model === "string" && adv.model.includes("/") === false && adv.model.trim()) {
1026
+ warnings.push(`adversarial_check.model 是裸模型名("${adv.model}")——需要 "provider/model" 形式(如 "anthropic/claude-sonnet-4-5",可加 ":high" 指定思考档位),无法解析时将回退到主会话当前模型`);
1027
+ }
1028
+ if (typeof adv.agent === "string" && adv.agent.trim()) {
1029
+ warnings.push(`adversarial_check.agent("${adv.agent}")会被忽略:ralph-flow-pi 没有 agent 概念,验证者的只读沙箱由内置工具集预设保证(只读工具 + bash 白名单),比 agent 权限更严格`);
1030
+ }
1031
+ if (Array.isArray(adv.extra_allowed_bash)) {
1032
+ const { rejected } = validateExtraAllowedBash(adv.extra_allowed_bash);
1033
+ for (const r of rejected) {
1034
+ warnings.push(`adversarial_check.extra_allowed_bash 中的 "${r.pattern}" 被拒绝:${r.reason}`);
1035
+ }
1036
+ }
1037
+ }
1038
+ return warnings;
1039
+ }
1040
+ /**
1041
+ * DFS through sub-workflow references looking for a cycle starting at `name`.
1042
+ * Returns the cycle path (["a", "b", "a"]) or null. `clean` memoizes names
1043
+ * proven cycle-free so shared sub-workflows aren't re-walked.
1044
+ */
1045
+ function findSubWorkflowCycle(name, stack = [], clean = new Set()) {
1046
+ if (clean.has(name))
1047
+ return null;
1048
+ const idx = stack.indexOf(name);
1049
+ if (idx >= 0)
1050
+ return [...stack.slice(idx), name];
1051
+ const wf = loadWorkflow(name);
1052
+ if (wf) {
1053
+ for (const s of wf.steps) {
1054
+ if (!isSubWorkflowStep(s))
1055
+ continue;
1056
+ const cycle = findSubWorkflowCycle(s.workflow, [...stack, name], clean);
1057
+ if (cycle)
1058
+ return cycle;
1059
+ }
1060
+ }
1061
+ clean.add(name);
1062
+ return null;
1063
+ }
1064
+ /**
1065
+ * Diagnose every workflow file in all search dirs. Returns per-name entries
1066
+ * in loadWorkflow's exact resolution order (project, global user, built-in)
1067
+ * so "which file actually runs" is derivable, plus yaml files that aren't
1068
+ * workflow-shaped at all.
1069
+ */
1070
+ function diagnoseWorkflowFiles() {
1071
+ const globalDir = getGlobalWorkflowsDir();
1072
+ const sources = [
1073
+ { source: "project", label: "项目自定义", dir: getProjectWorkflowsDir() },
1074
+ ...(globalDir ? [{ source: "global", label: "全局用户", dir: globalDir }] : []),
1075
+ { source: "builtin", label: "内置", dir: getBuiltinWorkflowsDir() },
1076
+ ];
1077
+ const byName = new Map(); // name -> candidate[] in resolution order
1078
+ const strays = []; // yaml files that aren't workflow definitions
1079
+ for (const { source, label, dir } of sources) {
1080
+ if (!fs.existsSync(dir))
1081
+ continue;
1082
+ let files;
1083
+ try {
1084
+ files = fs.readdirSync(dir);
1085
+ }
1086
+ catch {
1087
+ continue;
1088
+ }
1089
+ // .yaml before .yml within a dir, matching loadWorkflow's searchPaths.
1090
+ files = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
1091
+ .sort((a, b) => (a.endsWith(".yaml") ? 0 : 1) - (b.endsWith(".yaml") ? 0 : 1) || a.localeCompare(b));
1092
+ for (const file of files) {
1093
+ const filePath = path.join(dir, file);
1094
+ const name = file.replace(/\.(yaml|yml)$/, "");
1095
+ const relPath = source === "project"
1096
+ ? `${RALPH_FLOW_DIRNAME}/workflows/${file}`
1097
+ : source === "global"
1098
+ ? `~/.config/${GLOBAL_CONFIG_DIRNAME}/workflows/${file}`
1099
+ : `<内置>/workflows/${file}`;
1100
+ const candidate = { source, sourceLabel: label, file, filePath, relPath, name };
1101
+ try {
1102
+ if (fs.statSync(filePath).size > MAX_WORKFLOW_FILE_SIZE) {
1103
+ candidate.verdict = "invalid";
1104
+ candidate.problems = [`工作流文件超过 ${MAX_WORKFLOW_FILE_SIZE} 字节上限`];
1105
+ pushCandidate(byName, candidate);
1106
+ continue;
1107
+ }
1108
+ let rawParsed;
1109
+ try {
1110
+ rawParsed = yaml.load(stripBom(fs.readFileSync(filePath, "utf-8")));
1111
+ }
1112
+ catch (e) {
1113
+ candidate.verdict = "invalid";
1114
+ candidate.problems = [`YAML 解析失败:${e.message.split("\n")[0]}`];
1115
+ pushCandidate(byName, candidate);
1116
+ continue;
1117
+ }
1118
+ if (!rawParsed || typeof rawParsed !== "object" || !("steps" in rawParsed)) {
1119
+ // Not workflow-shaped: probably a stray yaml, but the user may have
1120
+ // MEANT it as a workflow — surface it instead of skipping silently.
1121
+ candidate.verdict = "stray";
1122
+ strays.push(candidate);
1123
+ continue;
1124
+ }
1125
+ const problems = [];
1126
+ const wf = parseWorkflowFile(filePath, name, problems);
1127
+ if (wf) {
1128
+ candidate.verdict = "valid";
1129
+ candidate.desc = wf.description;
1130
+ // Soft problems (skipped steps) that didn't invalidate the file are
1131
+ // exactly the silent-drop trap — merge them with the lints.
1132
+ candidate.warnings = [
1133
+ ...problems.map((p) => `${p}(该步骤已被静默丢弃,工作流其余部分照常运行)`),
1134
+ ...lintWorkflow(wf, rawParsed),
1135
+ ];
1136
+ }
1137
+ else {
1138
+ candidate.verdict = "invalid";
1139
+ candidate.problems = problems.length > 0 ? problems : ["解析失败"];
1140
+ }
1141
+ pushCandidate(byName, candidate);
1142
+ }
1143
+ catch (e) {
1144
+ candidate.verdict = "invalid";
1145
+ candidate.problems = [`读取失败:${e.message}`];
1146
+ pushCandidate(byName, candidate);
1147
+ }
1148
+ }
1149
+ }
1150
+ return { byName, strays };
1151
+ }
1152
+ function pushCandidate(byName, candidate) {
1153
+ if (!byName.has(candidate.name))
1154
+ byName.set(candidate.name, []);
1155
+ byName.get(candidate.name).push(candidate);
1156
+ }
1157
+ /** Instance-dir health: state.json missing or corrupt makes an instance invisible to every tool. */
1158
+ function diagnoseInstances() {
1159
+ const issues = [];
1160
+ const root = getInstancesRoot();
1161
+ if (!fs.existsSync(root))
1162
+ return issues;
1163
+ let entries;
1164
+ try {
1165
+ entries = fs.readdirSync(root, { withFileTypes: true });
1166
+ }
1167
+ catch {
1168
+ return issues;
1169
+ }
1170
+ for (const entry of entries) {
1171
+ if (!entry.isDirectory())
1172
+ continue;
1173
+ const stateFile = path.join(root, entry.name, "state.json");
1174
+ if (!fs.existsSync(stateFile)) {
1175
+ issues.push(`实例目录 \`instances/${entry.name}/\` 缺少 state.json — 所有工具都看不到它。若是残留目录可直接删除`);
1176
+ continue;
1177
+ }
1178
+ try {
1179
+ const parsed = JSON.parse(stripBom(fs.readFileSync(stateFile, "utf-8")));
1180
+ if (!parsed || typeof parsed !== "object")
1181
+ throw new Error("not an object");
1182
+ }
1183
+ catch (e) {
1184
+ issues.push(`实例 \`${entry.name}\` 的 state.json 损坏(${e.message.split("\n")[0]})— 该实例无法恢复,确认无需保留后可删除整个目录`);
1185
+ }
1186
+ }
1187
+ return issues;
1188
+ }
1189
+ function buildDoctorReport() {
1190
+ const { byName, strays } = diagnoseWorkflowFiles();
1191
+ const instanceIssues = diagnoseInstances();
1192
+ const sections = [];
1193
+ let launchable = 0, withWarnings = 0, broken = 0;
1194
+ const detailLines = [];
1195
+ const names = Array.from(byName.keys()).sort((a, b) => a.localeCompare(b));
1196
+ for (const name of names) {
1197
+ const candidates = byName.get(name);
1198
+ const effective = candidates.find((c) => c.verdict === "valid") || null;
1199
+ const lines = [`### ${name}`];
1200
+ if (effective) {
1201
+ launchable++;
1202
+ const shadowed = candidates.filter((c) => c !== effective);
1203
+ let sourceNote = `${effective.relPath}(${effective.sourceLabel}`;
1204
+ // A valid candidate LATER in resolution order is shadowed by this one.
1205
+ const shadowedValid = shadowed.find((c) => c.verdict === "valid" && candidates.indexOf(c) > candidates.indexOf(effective));
1206
+ if (shadowedValid) {
1207
+ sourceNote += `,遮蔽了同名${shadowedValid.sourceLabel} ${shadowedValid.relPath}`;
1208
+ }
1209
+ sourceNote += ")";
1210
+ lines.push(`- 生效文件:${sourceNote}`);
1211
+ // An invalid candidate EARLIER in resolution order means the user's file
1212
+ // is being silently skipped in favor of this one — worth shouting about.
1213
+ const brokenBefore = candidates.slice(0, candidates.indexOf(effective)).filter((c) => c.verdict === "invalid");
1214
+ for (const b of brokenBefore) {
1215
+ broken++;
1216
+ lines.push(`- ❌ ${b.relPath} 定义无效,已回退到上面的生效文件(启动的不是你这份!)`);
1217
+ for (const p of b.problems || [])
1218
+ lines.push(` - ${p}`);
1219
+ }
1220
+ if ((effective.warnings || []).length > 0) {
1221
+ withWarnings++;
1222
+ lines.push(`- ✅ 可启动,但有 ${effective.warnings.length} 条警告:`);
1223
+ for (const w of effective.warnings)
1224
+ lines.push(` - ⚠️ ${w}`);
1225
+ }
1226
+ else {
1227
+ lines.push(`- ✅ 可启动,无警告`);
1228
+ }
1229
+ // Invalid candidates AFTER the effective one are harmless (never reached)
1230
+ // — mention them only so the user knows the file exists and is dead.
1231
+ const deadAfter = candidates.slice(candidates.indexOf(effective) + 1).filter((c) => c.verdict === "invalid");
1232
+ for (const d of deadAfter) {
1233
+ lines.push(`- ℹ️ ${d.relPath} 定义无效,但已被上面的生效文件遮蔽,不影响使用(问题:${(d.problems || [])[0]})`);
1234
+ }
1235
+ }
1236
+ else {
1237
+ broken += candidates.length;
1238
+ lines.push(`- ❌ 无法启动(没有任何有效定义)`);
1239
+ for (const c of candidates) {
1240
+ lines.push(`- 文件 ${c.relPath}:`);
1241
+ for (const p of c.problems || [])
1242
+ lines.push(` - ${p}`);
1243
+ }
1244
+ }
1245
+ detailLines.push(lines.join("\n"));
1246
+ }
1247
+ sections.push(`# Ralph Flow 工作流诊断\n\n## 概览\n\n- 可启动工作流:**${launchable}** 个${withWarnings > 0 ? `(其中 ${withWarnings} 个有警告)` : ""}\n- 有问题的定义文件:**${broken}** 个\n- 非工作流 YAML:**${strays.length}** 个\n- 实例目录异常:**${instanceIssues.length}** 个`);
1248
+ if (detailLines.length > 0) {
1249
+ sections.push(`## 工作流详情\n\n${detailLines.join("\n\n")}`);
1250
+ }
1251
+ else {
1252
+ sections.push(`## 工作流详情\n\n三个目录(项目 ${RALPH_FLOW_DIRNAME}/workflows/、全局 ~/.config/${GLOBAL_CONFIG_DIRNAME}/workflows/、内置 workflows/)里都没有找到工作流定义文件。可以用 /ralphflow-create 交互式创建一个。`);
1253
+ }
1254
+ if (strays.length > 0) {
1255
+ sections.push(`## 被忽略的 YAML 文件\n\n以下文件不是工作流定义(缺少 steps 数组),list/start 都会忽略它们。若本意是工作流,需要补上 steps:\n\n${strays.map((s) => `- ${s.relPath}`).join("\n")}`);
1256
+ }
1257
+ if (instanceIssues.length > 0) {
1258
+ sections.push(`## 实例目录异常\n\n${instanceIssues.map((i) => `- ⚠️ ${i}`).join("\n")}`);
1259
+ }
1260
+ const projectDirExists = fs.existsSync(getProjectWorkflowsDir());
1261
+ const hasProjectWorkflow = names.some((n) => byName.get(n).some((c) => c.source === "project"));
1262
+ const hasGlobalWorkflow = names.some((n) => byName.get(n).some((c) => c.source === "global"));
1263
+ if (!hasProjectWorkflow && !hasGlobalWorkflow) {
1264
+ sections.push(`## 提示\n\n还没有自定义工作流${projectDirExists ? "" : `(${RALPH_FLOW_DIRNAME}/workflows/ 目录尚未创建)`}。内置工作流开箱即用;要定制自己的流程,可以运行 /ralphflow-create 交互式创建。放在 \`${RALPH_FLOW_DIRNAME}/workflows/\` 只对本项目生效;放在全局 \`~/.config/${GLOBAL_CONFIG_DIRNAME}/workflows/\` 则所有项目可用(且版本更新不会覆盖)。`);
1265
+ }
1266
+ return sections.join("\n\n");
1267
+ }
1268
+ // ─── Step Helpers ───────────────────────────────────────────────────────────
1269
+ function getStep(workflow, stepId) {
1270
+ return workflow.steps.find((s) => s.id === stepId) || null;
1271
+ }
1272
+ function buildDoPrompt(instId, step, userTask, retryContext, retryCount) {
1273
+ const sections = [];
1274
+ const isRetry = retryContext || (retryCount && retryCount > 0);
1275
+ if (userTask)
1276
+ sections.push(`## 用户需求\n\n${userTask}`);
1277
+ if (retryContext)
1278
+ sections.push(`## 上次失败原因\n\n${retryContext}`);
1279
+ if (retryCount && retryCount > 0) {
1280
+ sections.push(`## 重试信息\n\n这是第 **${retryCount}** 次重试,最大重试次数为 **${step.max_fail_count}** 次。`);
1281
+ }
1282
+ if (sections.length > 0)
1283
+ sections.push("---");
1284
+ try {
1285
+ fs.mkdirSync(getArtifactsDir(instId), { recursive: true });
1286
+ }
1287
+ catch { }
1288
+ sections.push(`## 当前任务
1289
+
1290
+ **步骤**:${step.id}
1291
+ **描述**:${step.desc}
1292
+
1293
+ **任务**:${renderStepText(instId, step.do)}
1294
+
1295
+ **输入说明**:${renderStepText(instId, step.input)}
1296
+
1297
+ **输出要求**:${renderStepText(instId, step.output)}
1298
+
1299
+ **产出目录**:\`${getArtifactsRelDir(instId)}/\` — 本工作流的文档产出(清单、方案、报告等)统一放在此目录。步骤中提到的文档文件名(如 checkpoints.md)若未写路径,即指此目录下的文件;明确写了其他路径的除外。`);
1300
+ // The completion instruction is the one part that is NOT a mirror of the
1301
+ // plugin versions: they asked for a `<promise>done</promise>` tag on the
1302
+ // last line and parsed it back out. A tool call cannot be faked by prose,
1303
+ // can't land inside a code fence, and can't be truncated.
1304
+ if (isRetry) {
1305
+ sections.push(`---
1306
+
1307
+ ## 执行指令
1308
+
1309
+ 上次执行未通过,原因见上方。请执行以下操作:
1310
+
1311
+ 1. **针对上述失败原因进行修复**,不要重复之前未通过的做法
1312
+ 2. 完成实际工作(修改代码、创建文件、执行命令等)
1313
+ 3. 所有任务要求和输出要求都满足后,调用 \`report_done\` 工具 — 这是结束本步骤的唯一方式
1314
+
1315
+ 不要只描述你打算怎么做,直接去做。工作未完成时不要调用 report_done。`);
1316
+ }
1317
+ else {
1318
+ sections.push(`---
1319
+
1320
+ ## 执行指令
1321
+
1322
+ 请执行上述任务。完成实际工作(修改代码、创建文件、执行命令等),不要只做分析或规划。
1323
+
1324
+ 所有任务要求和输出要求都满足后,调用 \`report_done\` 工具 — 这是结束本步骤的唯一方式。
1325
+
1326
+ 如果遇到无法解决的问题,说明具体问题,不要调用 report_done。`);
1327
+ }
1328
+ const prompt = sections.join("\n\n");
1329
+ // Cache the do prompt: the runner re-injects it after a context compaction,
1330
+ // and ralphflow_start/continue echo it into the transcript.
1331
+ writeDoPromptCache(prompt, instId);
1332
+ return prompt;
1333
+ }
1334
+ function buildCheckPrompt(instId, step, userTask) {
1335
+ const sections = [];
1336
+ if (userTask)
1337
+ sections.push(`## 用户需求\n\n${userTask}`);
1338
+ sections.push(`## Do 阶段任务
1339
+
1340
+ **步骤**:${step.id}
1341
+ **任务描述**:${renderStepText(instId, step.do)}
1342
+ **输入**:${renderStepText(instId, step.input)}
1343
+ **预期输出**:${renderStepText(instId, step.output)}
1344
+ **产出目录**:\`${getArtifactsRelDir(instId)}/\` — 检查依据中未写路径的文档文件名即指此目录下的文件`);
1345
+ if (sections.length > 0)
1346
+ sections.push("---");
1347
+ sections.push(`## 检查依据
1348
+
1349
+ ${renderStepText(instId, step.check)}
1350
+
1351
+ ---
1352
+
1353
+ 请基于上述信息,自主探索项目验证任务完成情况。基于你自己的探索结果判断,不要依赖任何外部提供的"实现总结"。
1354
+
1355
+ 检查完成后**必须调用 \`verdict\` 工具**提交结论:
1356
+ - 通过:\`verdict(pass=true, reason="<通过的具体原因>")\`
1357
+ - 不通过:\`verdict(pass=false, reason="<失败的具体原因>")\`
1358
+
1359
+ 只输出文字不调用 verdict 工具,等同于没有给出结论。`);
1360
+ return sections.join("\n\n");
1361
+ }
1362
+ function buildSubWorkflowUserTask(instId, step, parentUserTask) {
1363
+ const parts = [];
1364
+ if (step.inputs && typeof step.inputs === "object" && !Array.isArray(step.inputs)) {
1365
+ for (const [key, value] of Object.entries(step.inputs)) {
1366
+ parts.push(`${key}: ${renderStepText(instId, String(value))}`);
1367
+ }
1368
+ }
1369
+ if (parentUserTask) {
1370
+ if (parts.length > 0)
1371
+ parts.push("");
1372
+ parts.push(`原始需求:${parentUserTask}`);
1373
+ }
1374
+ return parts.join("\n");
1375
+ }
1376
+ /**
1377
+ * Recursively resolve a sub-workflow entry point.
1378
+ * If the sub-workflow's first step is itself a sub-workflow, push intermediate states and recurse.
1379
+ * Returns { text, error? } where text is the do prompt for the deepest normal step.
1380
+ */
1381
+ function resolveSubWorkflowEntry(instId, subWorkflowName, parentUserTask, parentStep, maxDepth, retryContext, retryCount) {
1382
+ const depth = getStackDepth(instId);
1383
+ if (depth >= (maxDepth || MAX_NESTING_DEPTH)) {
1384
+ return { text: `嵌套深度超过限制(${depth}/${maxDepth || MAX_NESTING_DEPTH})。可能存在循环引用。`, error: true };
1385
+ }
1386
+ const subProblems = [];
1387
+ const subWorkflow = loadWorkflow(subWorkflowName, subProblems);
1388
+ if (!subWorkflow) {
1389
+ return {
1390
+ text: subProblems.length > 0
1391
+ ? `子工作流 "${subWorkflowName}" 定义无效:\n${subProblems.map((p) => `- ${p}`).join("\n")}`
1392
+ : `子工作流 "${subWorkflowName}" 未找到。`,
1393
+ error: true,
1394
+ };
1395
+ }
1396
+ const firstStep = subWorkflow.steps[0];
1397
+ if (!firstStep) {
1398
+ return { text: `子工作流 "${subWorkflowName}" 没有步骤。`, error: true };
1399
+ }
1400
+ const subUserTask = buildSubWorkflowUserTask(instId, parentStep, parentUserTask);
1401
+ if (isSubWorkflowStep(firstStep)) {
1402
+ // Push intermediate state and recurse
1403
+ const intermediateState = {
1404
+ active: true, workflow_name: subWorkflowName, current_step: firstStep.id,
1405
+ current_phase: "do", fail_count: 0, user_task: subUserTask, paused: false,
1406
+ };
1407
+ pushState(intermediateState, instId);
1408
+ const result = resolveSubWorkflowEntry(instId, firstStep.workflow, subUserTask, firstStep, maxDepth, retryContext, retryCount);
1409
+ if (result.error) {
1410
+ popState(instId); // undo the push on error
1411
+ }
1412
+ return result;
1413
+ }
1414
+ // Normal first step — write state and return do prompt
1415
+ writeState({
1416
+ active: true, workflow_name: subWorkflowName, current_step: firstStep.id,
1417
+ current_phase: "do", fail_count: 0, user_task: subUserTask, paused: false,
1418
+ }, instId);
1419
+ // If the sub-workflow's first step is manual, arm the marker for the runner
1420
+ if (subWorkflow.manual_step && subWorkflow.manual_step.includes(firstStep.id)) {
1421
+ writeManualStepMarker(instId);
1422
+ }
1423
+ else {
1424
+ clearManualStepMarker(instId);
1425
+ }
1426
+ recordStepStart(instId, firstStep.id, "do");
1427
+ logEvent(instId, "info", "step_start", { step: firstStep.id, phase: "do" });
1428
+ return { text: buildDoPrompt(instId, firstStep, subUserTask, retryContext, retryCount) };
1429
+ }
1430
+ // ─── State Stack (for sub-workflows, per instance) ──────────────────────────
1431
+ function getStackFile(instId) {
1432
+ return instPath(STACK_FILENAME, instId);
1433
+ }
1434
+ function pushState(state, instId) {
1435
+ try {
1436
+ const stackFile = getStackFile(instId);
1437
+ let stack = [];
1438
+ if (fs.existsSync(stackFile)) {
1439
+ try {
1440
+ const parsed = JSON.parse(stripBom(fs.readFileSync(stackFile, "utf-8")));
1441
+ if (Array.isArray(parsed))
1442
+ stack = parsed;
1443
+ else
1444
+ diag("[ralph-flow] Stack file is not an array, starting fresh");
1445
+ }
1446
+ catch (parseErr) {
1447
+ diag("[ralph-flow] Stack file corrupted, backing up and starting fresh:", parseErr.message);
1448
+ try {
1449
+ fs.renameSync(stackFile, stackFile + ".corrupted." + Date.now());
1450
+ }
1451
+ catch { }
1452
+ }
1453
+ }
1454
+ stack.push(state);
1455
+ atomicWriteJson(stackFile, stack);
1456
+ }
1457
+ catch (e) {
1458
+ diag("[ralph-flow] Error pushing state:", e.message);
1459
+ }
1460
+ }
1461
+ function popState(instId) {
1462
+ try {
1463
+ const stackFile = getStackFile(instId);
1464
+ if (!fs.existsSync(stackFile))
1465
+ return null;
1466
+ let stack;
1467
+ try {
1468
+ stack = JSON.parse(stripBom(fs.readFileSync(stackFile, "utf-8")));
1469
+ }
1470
+ catch (parseErr) {
1471
+ diag("[ralph-flow] Stack file corrupted, backing up and clearing:", parseErr.message);
1472
+ try {
1473
+ fs.renameSync(stackFile, stackFile + ".corrupted." + Date.now());
1474
+ }
1475
+ catch { }
1476
+ return null;
1477
+ }
1478
+ if (!Array.isArray(stack) || stack.length === 0)
1479
+ return null;
1480
+ const parentState = stack.pop();
1481
+ atomicWriteJson(stackFile, stack);
1482
+ return parentState;
1483
+ }
1484
+ catch (e) {
1485
+ diag("[ralph-flow] Error popping state:", e.message);
1486
+ return null;
1487
+ }
1488
+ }
1489
+ function getStackDepth(instId) {
1490
+ try {
1491
+ const stackFile = getStackFile(instId);
1492
+ if (!fs.existsSync(stackFile))
1493
+ return 0;
1494
+ const stack = JSON.parse(stripBom(fs.readFileSync(stackFile, "utf-8")));
1495
+ return Array.isArray(stack) ? stack.length : 0;
1496
+ }
1497
+ catch {
1498
+ return 0;
1499
+ }
1500
+ }
1501
+ // ─── Log Helpers ────────────────────────────────────────────────────────────
1502
+ function getLogDir(instId) {
1503
+ // Fall back to the global logs dir after the instance dir was destroyed
1504
+ // (e.g. a cancel during a check) — never resurrect a deleted dir.
1505
+ if (!instId || !fs.existsSync(getInstanceDir(instId)))
1506
+ return path.join(getRalphFlowDir(), "logs");
1507
+ return path.join(getInstanceDir(instId), "logs");
1508
+ }
1509
+ function ensureLogDir(instId) {
1510
+ const logDir = getLogDir(instId);
1511
+ if (!fs.existsSync(logDir))
1512
+ fs.mkdirSync(logDir, { recursive: true });
1513
+ }
1514
+ const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
1515
+ const MAX_LOG_ROTATIONS = 3;
1516
+ function rotateLogIfNeeded(instId) {
1517
+ try {
1518
+ const logFile = path.join(getLogDir(instId), "execution.log");
1519
+ if (!fs.existsSync(logFile))
1520
+ return;
1521
+ const stats = fs.statSync(logFile);
1522
+ if (stats.size < MAX_LOG_SIZE_BYTES)
1523
+ return;
1524
+ // Rotate: .3 → delete, .2 → .3, .1 → .2, current → .1
1525
+ for (let i = MAX_LOG_ROTATIONS; i >= 1; i--) {
1526
+ const older = `${logFile}.${i}`;
1527
+ if (i === MAX_LOG_ROTATIONS) {
1528
+ if (fs.existsSync(older))
1529
+ fs.unlinkSync(older);
1530
+ }
1531
+ else {
1532
+ if (fs.existsSync(older))
1533
+ fs.renameSync(older, `${logFile}.${i + 1}`);
1534
+ }
1535
+ }
1536
+ fs.renameSync(logFile, `${logFile}.1`);
1537
+ }
1538
+ catch (e) {
1539
+ diag("[ralph-flow] Log rotation failed:", e.message);
1540
+ }
1541
+ }
1542
+ function logEvent(instId, level, event, extra) {
1543
+ try {
1544
+ ensureLogDir(instId);
1545
+ rotateLogIfNeeded(instId);
1546
+ const entry = { ts: new Date().toISOString(), level, event, ...extra };
1547
+ fs.appendFileSync(path.join(getLogDir(instId), "execution.log"), JSON.stringify(entry) + "\n");
1548
+ }
1549
+ catch (e) {
1550
+ diag(`[ralph-flow] Log failed (${event}):`, e.message);
1551
+ }
1552
+ }
1553
+ // ─── Step Records Persistence (per instance) ────────────────────────────────
1554
+ const STEP_RECORDS_FILENAME = "step-records.json";
1555
+ function getStepRecordsFile(instId) {
1556
+ return path.join(getLogDir(instId), STEP_RECORDS_FILENAME);
1557
+ }
1558
+ function loadStepRecords(instId) {
1559
+ try {
1560
+ const file = getStepRecordsFile(instId);
1561
+ if (fs.existsSync(file)) {
1562
+ try {
1563
+ const parsed = JSON.parse(stripBom(fs.readFileSync(file, "utf-8")));
1564
+ if (Array.isArray(parsed))
1565
+ return parsed;
1566
+ diag("[ralph-flow] Step records file is not an array, resetting");
1567
+ }
1568
+ catch (parseErr) {
1569
+ diag("[ralph-flow] Step records file corrupted, backing up:", parseErr.message);
1570
+ try {
1571
+ fs.renameSync(file, file + ".corrupted." + Date.now());
1572
+ }
1573
+ catch { }
1574
+ }
1575
+ }
1576
+ }
1577
+ catch (e) {
1578
+ diag("[ralph-flow] Error loading step records:", e.message);
1579
+ }
1580
+ return [];
1581
+ }
1582
+ function saveStepRecords(instId, records) {
1583
+ try {
1584
+ ensureLogDir(instId);
1585
+ atomicWriteJson(getStepRecordsFile(instId), records);
1586
+ }
1587
+ catch (e) {
1588
+ diag("[ralph-flow] Error saving step records:", e.message);
1589
+ }
1590
+ }
1591
+ // ─── Report Generation ──────────────────────────────────────────────────────
1592
+ function formatDuration(startTime, endTime) {
1593
+ const durationMs = Math.max(0, new Date(endTime).getTime() - new Date(startTime).getTime());
1594
+ const minutes = Math.floor(durationMs / 60000);
1595
+ const seconds = Math.floor((durationMs % 60000) / 1000);
1596
+ return minutes > 0 ? `${minutes}m${seconds}s` : `${seconds}s`;
1597
+ }
1598
+ function buildReportText(workflowName, status, stepRecords) {
1599
+ const totalFailures = stepRecords.reduce((sum, s) => sum + (s.failCount || 0), 0);
1600
+ const startTime = stepRecords.length > 0 ? stepRecords[0].startTime : new Date().toISOString();
1601
+ const endTime = stepRecords.length > 0 ? stepRecords[stepRecords.length - 1].endTime || new Date().toISOString() : new Date().toISOString();
1602
+ const statusCn = { completed: "已完成", cancelled: "已取消", paused: "已暂停" };
1603
+ const lines = [
1604
+ "# 工作流执行报告", "",
1605
+ "## 执行摘要", "",
1606
+ `- **工作流**: ${workflowName}`,
1607
+ `- **状态**: ${statusCn[status] || status}`,
1608
+ `- **总步骤数**: ${stepRecords.length}`,
1609
+ `- **失败次数**: ${totalFailures}`,
1610
+ `- **总耗时**: ${formatDuration(startTime, endTime)}`,
1611
+ "", "## 步骤执行情况", "",
1612
+ ];
1613
+ for (let i = 0; i < stepRecords.length; i++) {
1614
+ const step = stepRecords[i];
1615
+ const icon = step.status === "passed" ? "✓" : "✗";
1616
+ lines.push(`### ${i + 1}. ${step.stepId} (${step.phase}) ${icon}`);
1617
+ lines.push(`- 状态:${step.status === "passed" ? "通过" : "失败"}`);
1618
+ if (step.failCount > 0)
1619
+ lines.push(`- 失败次数:${step.failCount}`);
1620
+ if (step.reason)
1621
+ lines.push(`- ${step.status === "passed" ? "通过原因" : "失败原因"}:${step.reason}`);
1622
+ if (step.startTime && step.endTime)
1623
+ lines.push(`- 耗时:${formatDuration(step.startTime, step.endTime)}`);
1624
+ lines.push("");
1625
+ }
1626
+ return lines.join("\n");
1627
+ }
1628
+ // ─── Workflow Advancement Logic (shared by the runner) ──────────────────────
1629
+ function handleCheckPassed(instId, state, workflow, step, checkResult) {
1630
+ // Note: manual steps do not pause after the check — the manual review gate
1631
+ // sits BEFORE the check (the runner stops when the DO phase of a manual step
1632
+ // completes; the user's ralphflow_continue call is the approval that starts
1633
+ // the check). Once the check passes, the workflow advances.
1634
+ if (step.on_pass === "done") {
1635
+ const parentState = popState(instId);
1636
+ if (parentState) {
1637
+ const parentWorkflow = loadWorkflow(parentState.workflow_name);
1638
+ if (parentWorkflow) {
1639
+ const parentStep = getStep(parentWorkflow, parentState.current_step);
1640
+ if (parentStep) {
1641
+ // Sub-workflow completed — advance to parent step's on_pass target
1642
+ const grandparentResult = handleCheckPassed(instId, { ...parentState, current_phase: "do", fail_count: 0, last_failure_reason: undefined, paused: false, pause_reason: undefined }, parentWorkflow, parentStep, { reason: `子工作流 "${state.workflow_name}" 已完成。` });
1643
+ // Only record parent step's check as passed if transition succeeded and
1644
+ // the instance still exists (a completed workflow already destroyed it)
1645
+ if (!grandparentResult.paused && !grandparentResult.completed) {
1646
+ addStepRecord(instId, parentState.current_step, "check", "passed", parentState.fail_count || 0, `子工作流 "${state.workflow_name}" 已完成。`);
1647
+ }
1648
+ logEvent(instId, "info", "sub_workflow_end", { workflow: state.workflow_name, parent_workflow: parentState.workflow_name, parent_step: parentState.current_step });
1649
+ return {
1650
+ text: `## 检查结果:通过 ✓\n\n${checkResult.reason || "检查通过。"}\n\n---\n\n## 子工作流 "${state.workflow_name}" 已完成!\n\n---\n\n${grandparentResult.text}`,
1651
+ paused: grandparentResult.paused,
1652
+ completed: grandparentResult.completed,
1653
+ };
1654
+ }
1655
+ }
1656
+ // Parent workflow not found — push parent state back and pause so user can fix and resume
1657
+ pushState({ ...parentState, paused: true, pause_reason: "config_error", last_failure_reason: `父工作流 "${parentState.workflow_name}" 加载失败。` }, instId);
1658
+ writeState({ ...parentState, paused: true, pause_reason: "config_error", last_failure_reason: `父工作流 "${parentState.workflow_name}" 加载失败。` }, instId);
1659
+ logEvent(instId, "warn", "parent_workflow_not_found", { workflow: state.workflow_name, parent_workflow: parentState.workflow_name });
1660
+ return {
1661
+ text: `## 检查结果:通过 ✓\n\n${checkResult.reason || "检查通过。"}\n\n---\n\n子工作流 "${state.workflow_name}" 已完成,但父工作流 "${parentState.workflow_name}" 加载失败。工作流已暂停 — 请修复工作流 YAML 后调用 \`ralphflow_continue\` 恢复。`,
1662
+ paused: true,
1663
+ };
1664
+ }
1665
+ // No parent — this is the top-level workflow, complete it.
1666
+ // Archive the report and destroy the instance directory.
1667
+ const reportPath = destroyInstance(instId, "completed");
1668
+ logEvent(instId, "info", "workflow_end", { workflow: state.workflow_name });
1669
+ return {
1670
+ text: `## 检查结果:通过 ✓\n\n${checkResult.reason || "检查通过。"}\n\n---\n\n## 工作流完成!\n\n所有步骤已验证通过。${reportPath ? `执行报告:${path.relative(projectDir, reportPath)}` : ""}`,
1671
+ completed: true,
1672
+ };
1673
+ }
1674
+ const nextStep = getStep(workflow, step.on_pass);
1675
+ if (!nextStep) {
1676
+ logEvent(instId, "error", "next_step_not_found", { step: state.current_step, on_pass: step.on_pass });
1677
+ writeState({ ...state, paused: true, pause_reason: "config_error", last_failure_reason: `下一步 "${step.on_pass}" 在工作流定义中未找到。` }, instId);
1678
+ return { text: `## 检查结果:通过 ✓\n\n下一步 "${step.on_pass}" 在工作流定义中未找到。\n\n## 工作流已暂停\n\n工作流配置错误。请修复工作流定义,然后调用 \`ralphflow_continue\` 恢复。`, paused: true };
1679
+ }
1680
+ if (isSubWorkflowStep(nextStep)) {
1681
+ recordStepStart(instId, nextStep.id, "do");
1682
+ logEvent(instId, "info", "step_start", { step: nextStep.id, phase: "do" });
1683
+ // Write parent's next step state before entering sub-workflow.
1684
+ // This ensures the state file reflects the correct parent step after sub-workflow completes
1685
+ const nextState = { ...state, current_step: nextStep.id, current_phase: "do", fail_count: 0, last_failure_reason: undefined, paused: false, pause_reason: undefined };
1686
+ writeState(nextState, instId);
1687
+ pushState({ ...state, current_step: nextStep.id, current_phase: "do", fail_count: 0, paused: false, pause_reason: undefined }, instId);
1688
+ const subResult = resolveSubWorkflowEntry(instId, nextStep.workflow, state.user_task, nextStep);
1689
+ if (subResult.error) {
1690
+ popState(instId);
1691
+ writeState({ ...state, paused: true, pause_reason: "config_error", last_failure_reason: subResult.text }, instId);
1692
+ return { text: subResult.text, paused: true };
1693
+ }
1694
+ return {
1695
+ text: `## 检查结果:通过 ✓\n\n${checkResult.reason || "检查通过。"}\n\n---\n\n## 进入子工作流:${nextStep.id}\n\n---\n\n${subResult.text}`,
1696
+ };
1697
+ }
1698
+ const nextState = { ...state, current_step: nextStep.id, current_phase: "do", fail_count: 0, last_failure_reason: undefined, paused: false, pause_reason: undefined };
1699
+ writeState(nextState, instId);
1700
+ recordStepStart(instId, nextStep.id, "do");
1701
+ logEvent(instId, "info", "step_start", { step: nextStep.id, phase: "do" });
1702
+ return {
1703
+ text: `## 检查结果:通过 ✓\n\n${checkResult.reason || "检查通过。"}\n\n---\n\n下一步:**${nextStep.id}** - ${nextStep.desc}\n\n---\n\n${buildDoPrompt(instId, nextStep, state.user_task)}`,
1704
+ };
1705
+ }
1706
+ function handleCheckFailed(instId, state, workflow, step, checkResult) {
1707
+ const newFailCount = state.fail_count + 1;
1708
+ logEvent(instId, "warn", "fail_count_increment", { step: state.current_step, fail_count: newFailCount });
1709
+ if (newFailCount >= step.max_fail_count) {
1710
+ const parentState = popState(instId);
1711
+ if (parentState) {
1712
+ const parentFailCount = parentState.fail_count + 1;
1713
+ // Check if parent step's max_fail_count is exceeded
1714
+ const parentWorkflow = loadWorkflow(parentState.workflow_name);
1715
+ const parentStep = parentWorkflow ? getStep(parentWorkflow, parentState.current_step) : null;
1716
+ if (parentStep && parentFailCount >= parentStep.max_fail_count) {
1717
+ // Parent step also exceeded max failures — pause parent workflow
1718
+ // Push parent state back to stack so resume/cancel can restore nesting
1719
+ pushState({ ...parentState, current_phase: "do", fail_count: parentFailCount, paused: true, pause_reason: "max_failures", last_failure_reason: checkResult.reason }, instId);
1720
+ writeState({ ...parentState, current_phase: "do", fail_count: parentFailCount, paused: true, pause_reason: "max_failures", last_failure_reason: checkResult.reason }, instId);
1721
+ logEvent(instId, "warn", "workflow_paused", { workflow: parentState.workflow_name, step: parentState.current_step, fail_count: parentFailCount });
1722
+ return {
1723
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n## 工作流已暂停\n\n子工作流失败且父步骤最大失败次数 (${parentFailCount}/${parentStep.max_fail_count}) 已达。请修复问题,然后调用 \`ralphflow_continue\` 恢复。`,
1724
+ paused: true,
1725
+ };
1726
+ }
1727
+ // Parent step not at max — follow parent's on_fail
1728
+ if (!parentWorkflow || !parentStep) {
1729
+ // Push parent state back so resume can restore the stack
1730
+ pushState({ ...parentState, fail_count: parentFailCount, paused: true, pause_reason: "config_error", last_failure_reason: `父工作流 "${parentState.workflow_name}" 或步骤 "${parentState.current_step}" 未找到。` }, instId);
1731
+ writeState({ ...parentState, fail_count: parentFailCount, paused: true, pause_reason: "config_error", last_failure_reason: `父工作流 "${parentState.workflow_name}" 或步骤 "${parentState.current_step}" 未找到。` }, instId);
1732
+ logEvent(instId, "error", "parent_workflow_or_step_not_found", { workflow: parentState.workflow_name, step: parentState.current_step });
1733
+ return {
1734
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n父工作流或步骤未找到。工作流已暂停。`,
1735
+ paused: true,
1736
+ };
1737
+ }
1738
+ const failStep = getStep(parentWorkflow, parentStep.on_fail);
1739
+ if (failStep) {
1740
+ if (isSubWorkflowStep(failStep)) {
1741
+ recordStepStart(instId, failStep.id, "do");
1742
+ logEvent(instId, "info", "step_start", { step: failStep.id, phase: "do" });
1743
+ pushState({ ...parentState, current_step: failStep.id, current_phase: "do", fail_count: parentFailCount, last_failure_reason: checkResult.reason }, instId);
1744
+ const subResult = resolveSubWorkflowEntry(instId, failStep.workflow, parentState.user_task, failStep, MAX_NESTING_DEPTH, checkResult.reason, parentFailCount);
1745
+ if (subResult.error) {
1746
+ popState(instId);
1747
+ writeState({ ...parentState, fail_count: parentFailCount, paused: true, pause_reason: "config_error", last_failure_reason: subResult.text }, instId);
1748
+ return { text: subResult.text, paused: true };
1749
+ }
1750
+ return {
1751
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n子工作流失败。使用父步骤重试:**${failStep.id}**\n\n---\n\n${subResult.text}`,
1752
+ };
1753
+ }
1754
+ const retryState = { ...parentState, current_step: failStep.id, current_phase: "do", fail_count: parentFailCount, last_failure_reason: checkResult.reason };
1755
+ writeState(retryState, instId);
1756
+ recordStepStart(instId, failStep.id, "do");
1757
+ logEvent(instId, "info", "step_start", { step: failStep.id, phase: "do" });
1758
+ return {
1759
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n子工作流失败。使用父步骤重试:**${failStep.id}** - ${failStep.desc}\n\n---\n\n${buildDoPrompt(instId, failStep, parentState.user_task, checkResult.reason, parentFailCount)}`,
1760
+ };
1761
+ }
1762
+ // on_fail step not found — pause, but push parent state back so resume can restore stack
1763
+ pushState({ ...parentState, fail_count: parentFailCount, paused: true, pause_reason: "config_error", last_failure_reason: `父步骤 on_fail "${parentStep.on_fail}" 未找到。` }, instId);
1764
+ writeState({ ...parentState, fail_count: parentFailCount, paused: true, pause_reason: "config_error", last_failure_reason: `父步骤 on_fail "${parentStep.on_fail}" 未找到。` }, instId);
1765
+ return {
1766
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n父步骤 on_fail "${parentStep.on_fail}" 未找到。工作流已暂停。`,
1767
+ paused: true,
1768
+ };
1769
+ }
1770
+ clearManualStepMarker(instId);
1771
+ const pausedState = { ...state, fail_count: newFailCount, paused: true, pause_reason: "max_failures", last_failure_reason: checkResult.reason };
1772
+ writeState(pausedState, instId);
1773
+ logEvent(instId, "warn", "workflow_paused", { workflow: state.workflow_name, step: state.current_step, fail_count: newFailCount });
1774
+ return {
1775
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n## 工作流已暂停\n\n已达最大失败次数。请修复问题,然后调用 \`ralphflow_continue\` 恢复。`,
1776
+ paused: true,
1777
+ };
1778
+ }
1779
+ const failStep = getStep(workflow, step.on_fail);
1780
+ if (!failStep) {
1781
+ const pausedState = { ...state, fail_count: newFailCount, paused: true, pause_reason: "config_error", last_failure_reason: `失败步骤 "${step.on_fail}" 在工作流定义中未找到。` };
1782
+ writeState(pausedState, instId);
1783
+ logEvent(instId, "error", "fail_step_not_found", { step: state.current_step, on_fail: step.on_fail });
1784
+ return {
1785
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n失败步骤 "${step.on_fail}" 在工作流定义中未找到。\n\n---\n\n## 工作流已暂停\n\n工作流配置错误。请修复工作流定义,然后调用 \`ralphflow_continue\` 恢复。`,
1786
+ paused: true,
1787
+ };
1788
+ }
1789
+ if (isSubWorkflowStep(failStep)) {
1790
+ recordStepStart(instId, failStep.id, "do");
1791
+ logEvent(instId, "info", "step_start", { step: failStep.id, phase: "do" });
1792
+ // Always use newFailCount (never reset on routing to different step)
1793
+ pushState({ ...state, current_step: failStep.id, current_phase: "do", fail_count: newFailCount, last_failure_reason: checkResult.reason }, instId);
1794
+ const subResult = resolveSubWorkflowEntry(instId, failStep.workflow, state.user_task, failStep, MAX_NESTING_DEPTH, checkResult.reason, newFailCount);
1795
+ if (subResult.error) {
1796
+ popState(instId);
1797
+ writeState({ ...state, fail_count: newFailCount, paused: true, pause_reason: "config_error", last_failure_reason: subResult.text }, instId);
1798
+ return { text: subResult.text, paused: true };
1799
+ }
1800
+ return {
1801
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n使用子工作流重试:**${failStep.id}**\n\n---\n\n${subResult.text}`,
1802
+ };
1803
+ }
1804
+ // Always use newFailCount (never reset on routing to different step)
1805
+ const retryState = { ...state, current_step: failStep.id, current_phase: "do", fail_count: newFailCount, last_failure_reason: checkResult.reason };
1806
+ writeState(retryState, instId);
1807
+ recordStepStart(instId, failStep.id, "do");
1808
+ logEvent(instId, "info", "step_start", { step: failStep.id, phase: "do" });
1809
+ return {
1810
+ text: `## 检查结果:失败 ✗ (${newFailCount}/${step.max_fail_count})\n\n${checkResult.reason || "检查失败。"}\n\n---\n\n重试:**${failStep.id}** - ${failStep.desc}\n\n---\n\n${buildDoPrompt(instId, failStep, state.user_task, checkResult.reason, newFailCount)}`,
1811
+ };
1812
+ }
1813
+ // ─── Step Records (per instance, file-backed) ───────────────────────────────
1814
+ // Ephemeral start times keyed by instId:stepId:phase (only used to compute a
1815
+ // record's duration when addStepRecord fires).
1816
+ const stepStartTimes = new Map();
1817
+ function recordStepStart(instId, stepId, phase) {
1818
+ stepStartTimes.set(`${instId}:${stepId}:${phase}`, new Date().toISOString());
1819
+ }
1820
+ function addStepRecord(instId, stepId, phase, status, failCount, reason) {
1821
+ const now = new Date().toISOString();
1822
+ const key = `${instId}:${stepId}:${phase}`;
1823
+ const startTime = stepStartTimes.get(key) || now;
1824
+ stepStartTimes.delete(key);
1825
+ const records = loadStepRecords(instId);
1826
+ records.push({ stepId, phase, status, failCount: failCount || 0, startTime, endTime: now, reason });
1827
+ saveStepRecords(instId, records.length > MAX_STEP_RECORDS ? records.slice(-MAX_STEP_RECORDS) : records);
1828
+ }
1829
+ function ensureProjectWorkflows() {
1830
+ // Ensure the project AND global user workflow dirs exist as places for the
1831
+ // user to drop *custom* workflows. Built-in workflows are intentionally NOT
1832
+ // copied into either: loadWorkflow falls back to the package dir, so
1833
+ // built-ins always resolve to the latest shipped version. Seeding copies
1834
+ // would shadow the package dir and go stale on updates. The global dir
1835
+ // matters most for global installs, where the package itself is a managed,
1836
+ // non-editable location.
1837
+ for (const dir of [getProjectWorkflowsDir(), getGlobalWorkflowsDir()]) {
1838
+ if (!dir)
1839
+ continue;
1840
+ try {
1841
+ if (!fs.existsSync(dir))
1842
+ fs.mkdirSync(dir, { recursive: true });
1843
+ }
1844
+ catch (e) {
1845
+ diag("[ralph-flow] Error initializing workflows dir:", dir, e.message);
1846
+ }
1847
+ }
1848
+ }
1849
+ return {
1850
+ projectDir,
1851
+ diag,
1852
+ setAbortActiveStep,
1853
+ // paths
1854
+ getRalphFlowDir, getInstancesRoot, getReportsDir, getInstanceDir, instPath,
1855
+ getArtifactsDir, getArtifactsRelDir, getBuiltinWorkflowsDir, getProjectWorkflowsDir,
1856
+ getGlobalWorkflowsDir, getGlobalConfigHome,
1857
+ getSessionsDir, getStepSessionDir, listStepSessionDirs,
1858
+ // instance infra
1859
+ generateInstanceId, isValidInstanceId, instanceExists,
1860
+ writeArtifactsDirName, writeExtraDirs, readExtraDirs,
1861
+ readOwnerSession, claimOwnership,
1862
+ listInstances, resolveInstance, bindInstance, destroyInstance,
1863
+ instanceStatusLabel, formatInstanceList, formatLastActivity,
1864
+ // runner pid
1865
+ writeRunnerPid, clearRunnerPid, readRunnerPid, foreignRunnerPid,
1866
+ // state + markers
1867
+ readState, writeState, isValidState,
1868
+ writeMarker, clearMarker, markerExists,
1869
+ writeManualStepMarker, clearManualStepMarker, writeManualGate, clearManualGate,
1870
+ clearReinjectCounter, clearDoPromptCache,
1871
+ writeDoneReported, clearDoneReported, doneReported,
1872
+ readReinjectCount, incrementReinjectCount,
1873
+ writeDoPromptCache, readDoPromptCache,
1874
+ writeAdversarialSession, clearAdversarialSession, readAdversarialSession,
1875
+ // workflows
1876
+ parseWorkflowFile, loadWorkflow, listWorkflows, lintWorkflow, buildDoctorReport,
1877
+ isValidWorkflowName,
1878
+ // steps + prompts
1879
+ getStep, buildDoPrompt, buildCheckPrompt, buildSubWorkflowUserTask,
1880
+ resolveSubWorkflowEntry, renderStepText,
1881
+ // stack
1882
+ pushState, popState, getStackDepth,
1883
+ // logs + records
1884
+ logEvent, recordStepStart, addStepRecord, loadStepRecords,
1885
+ // reports
1886
+ buildReportText, archiveReport,
1887
+ // transitions
1888
+ handleCheckPassed, handleCheckFailed,
1889
+ // startup
1890
+ ensureProjectWorkflows,
1891
+ };
1892
+ }
1893
+ /** Liveness probe used by the runner-pid guard and the instance lock. */
1894
+ export function isPidAlive(pid) {
1895
+ try {
1896
+ // Signal 0 performs error checking without actually sending a signal.
1897
+ process.kill(pid, 0);
1898
+ return true;
1899
+ }
1900
+ catch (e) {
1901
+ // EPERM means the process exists but belongs to another user.
1902
+ return e.code === "EPERM";
1903
+ }
1904
+ }
1905
+ // ─── Adversarial check defaults (shared with check.ts) ──────────────────────
1906
+ //
1907
+ // Mirrors the plugin versions' DEFAULT_ADVERSARIAL_SYSTEM_PROMPT verbatim except
1908
+ // the output-format section: the verdict is a tool call here, not a text tag.
1909
+ export const DEFAULT_ADVERSARIAL_SYSTEM_PROMPT = `你是一个严格的检查者。你的职责是根据检查依据判断任务是否完成。
1910
+
1911
+ ## 核心原则
1912
+
1913
+ 1. 只审查,不修改
1914
+ 2. 严格按照"检查依据"判断,不要被其他因素干扰
1915
+ 3. 如果有任何疑问,判定为不通过
1916
+
1917
+ ## 验证方法
1918
+
1919
+ 你必须**自主探索**项目来验证任务是否完成:
1920
+ - 根据任务类型,选择合适的验证方式
1921
+ - 基于检查依据中的要求,逐一验证每一项
1922
+ - 不要依赖任何外部提供的"实现总结",只基于你自己的验证结果判断
1923
+
1924
+ ## 判断逻辑
1925
+
1926
+ **通过条件**:检查依据中的每一项都满足
1927
+ **不通过条件**:检查依据中任何一项不满足
1928
+
1929
+ ## 提交结论
1930
+
1931
+ 检查完成后**必须调用 \`verdict\` 工具**提交结论:
1932
+ - 通过:\`verdict(pass=true, reason="<通过的具体原因>")\`
1933
+ - 不通过:\`verdict(pass=false, reason="<失败的具体原因>")\`
1934
+
1935
+ reason 要具体(引用你实际看到的文件内容、命令输出),不要只写"符合要求"。`;
1936
+ export const DEFAULT_ADVERSARIAL_TIMEOUT_MS = 900_000;
1937
+ /** Cap for the verdict reason carried into state.last_failure_reason. */
1938
+ export const MAX_CHECK_REASON_LENGTH = 5000;
1939
+ export function truncateCheckReason(reason) {
1940
+ const r = String(reason || "").trim();
1941
+ return r.length > MAX_CHECK_REASON_LENGTH ? r.substring(0, MAX_CHECK_REASON_LENGTH) + "..." : r;
1942
+ }
1943
+ // ─── Read-only verifier permissions ──────────────────────────────────────────
1944
+ //
1945
+ // The Claude plugin runs the checker as `claude -p --allowedTools "…"`; the
1946
+ // opencode plugin uses an agent permission map. Both express the same ALLOW-list:
1947
+ // read-only file/text tools + a curated set of non-mutating Bash subcommands
1948
+ // (never rm/mv/cargo-fix/plain-fmt). ralph-flow-pi has no agent or host
1949
+ // permission layer to lean on — check-bash.ts enforces this table itself before
1950
+ // executing anything, which is why the verifier cannot mutate the workspace it
1951
+ // is judging.
1952
+ //
1953
+ // Patterns use the trailing-space form ("cat *") so short names can't overmatch
1954
+ // a mutating command (e.g. a bare "tr*" would also match "truncate"). Bare
1955
+ // forms are added only for the handful of commands checks commonly run without
1956
+ // arguments.
1957
+ export const RALPH_CHECK_BASH_PERMISSION = {
1958
+ "*": "deny",
1959
+ // Inspection / read-only file + text tools.
1960
+ "cat *": "allow", "head *": "allow", "tail *": "allow", "ls *": "allow",
1961
+ "find *": "allow", "grep *": "allow", "wc *": "allow", "file *": "allow", "stat *": "allow",
1962
+ "awk *": "allow", "sed *": "allow", "cut *": "allow", "sort *": "allow", "uniq *": "allow",
1963
+ "tr *": "allow", "cd *": "allow", "xargs *": "allow",
1964
+ // Read-only text / arithmetic / structured-data helpers used by check scripts.
1965
+ "jq *": "allow", "bc *": "allow", "echo *": "allow", "printf *": "allow",
1966
+ "test *": "allow", "true": "allow", "true *": "allow",
1967
+ "diff *": "allow", "cmp *": "allow", "comm *": "allow", "basename *": "allow",
1968
+ "dirname *": "allow", "realpath *": "allow", "readlink *": "allow", "pwd": "allow", "pwd *": "allow",
1969
+ "nm *": "allow",
1970
+ // Git inspection (never mutating).
1971
+ "git status": "allow", "git status *": "allow", "git diff": "allow", "git diff *": "allow",
1972
+ "git log": "allow", "git log *": "allow", "git show *": "allow",
1973
+ // Test runners.
1974
+ "npm test": "allow", "npm test *": "allow", "npm run test *": "allow",
1975
+ "pytest": "allow", "pytest *": "allow", "go test *": "allow", "make test": "allow", "make test *": "allow",
1976
+ // Cargo verification — build/test/run only touch target/, never source.
1977
+ // `cargo fmt` is allowed ONLY with --check (plain fmt rewrites source).
1978
+ "cargo build": "allow", "cargo build *": "allow", "cargo test": "allow", "cargo test *": "allow",
1979
+ "cargo run *": "allow", "cargo nextest *": "allow", "cargo clippy": "allow", "cargo clippy *": "allow",
1980
+ "cargo llvm-cov *": "allow", "cargo geiger *": "allow", "cargo clean *": "allow",
1981
+ "cargo fmt --check*": "allow", "cargo metadata *": "allow", "cargo tree *": "allow",
1982
+ "cargo audit *": "allow", "cargo deny *": "allow",
1983
+ };
1984
+ //# sourceMappingURL=core.js.map