@tingrudeng/worker-review-orchestrator-cli 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PUBLISHING.md ADDED
@@ -0,0 +1,32 @@
1
+ # Publishing @tingrudeng/worker-review-orchestrator-cli
2
+
3
+ ## Release Gate
4
+
5
+ Before publishing:
6
+
7
+ ```bash
8
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli typecheck
9
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli test
10
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
11
+ git diff --check
12
+ ```
13
+
14
+ If the package behavior depends on live dispatcher flows, also run the relevant focused dispatcher tests from the repository root.
15
+
16
+ ## Publish
17
+
18
+ Build and publish from the repository root:
19
+
20
+ ```bash
21
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
22
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli publish --access public --no-git-checks
23
+ ```
24
+
25
+ ## Intended Install Shape
26
+
27
+ After publish, users should be able to install the CLI globally with:
28
+
29
+ ```bash
30
+ npm install -g @tingrudeng/worker-review-orchestrator-cli
31
+ forgeflow-review-orchestrator --help
32
+ ```
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @tingrudeng/worker-review-orchestrator-cli
2
+
3
+ ForgeFlow control-layer CLI for dispatching tasks to workers, watching their status, and submitting review decisions.
4
+
5
+ ## Install
6
+
7
+ Preferred global install after publish:
8
+
9
+ ```bash
10
+ npm install -g @tingrudeng/worker-review-orchestrator-cli
11
+ forgeflow-review-orchestrator --help
12
+ ```
13
+
14
+ Repository-local fallback:
15
+
16
+ ```bash
17
+ pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
18
+ node packages/worker-review-orchestrator-cli/dist/cli.js --help
19
+ ```
20
+
21
+ This package is meant to pair with the `worker-review-orchestrator` skill, but it is installed separately from the skill itself.
22
+
23
+ ## Commands
24
+
25
+ - `dispatch`
26
+ - POST a dispatch payload to `/api/dispatches`
27
+ - Supports `--target-worker-id` to pin all tasks in the payload to one worker
28
+ - `dispatch-task`
29
+ - Build and POST a single-task dispatch payload from CLI flags
30
+ - Preferred when the control layer wants to send one focused task without hand-writing `dispatch.json`
31
+ - Supports `--worker-prompt` and `--context-markdown` for inline string inputs
32
+ - Does NOT support file paths for prompt or context; use `--worker-prompt` or `--context-markdown` with literal strings
33
+ - `watch`
34
+ - Poll `/api/dashboard/snapshot` until a task reaches `review`, `failed`, `merged`, or `blocked`
35
+ - `decide`
36
+ - Submit `merge` or `block` decisions through the dispatcher review flow
37
+ - Supports dispatcher HTTP or local `state-dir` fallback
38
+ - Optional parameters: `--actor`, `--notes`, `--at`
39
+ - `inspect`
40
+ - Retrieve review material for a task from the dispatcher snapshot
41
+ - Fetches task, assignment, reviews, pull request, and events to help a control-layer reviewer summarize before merge/block decision
42
+ - Use `--summary` flag to get concise output with task status, branch, worker, result evidence, recent events, and review/PR state
43
+
44
+ ## Current Boundary
45
+
46
+ - This package only orchestrates existing ForgeFlow dispatcher flows.
47
+ - It does not implement worker execution.
48
+ - It does not rewrite task state arbitrarily.
49
+
50
+ ## Minimal Use
51
+
52
+ ```bash
53
+ forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json
54
+ forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json --target-worker-id trae-remote-forgeflow
55
+ forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --allowed-paths docs/**,README.md --acceptance "pnpm typecheck,git diff --check" --target-worker-id trae-remote-forgeflow
56
+ forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Refactor auth" --pool codex --branch-name ai/codex/auth --allowed-paths "packages/auth/**" --acceptance "pnpm typecheck" --worker-prompt "You are a codex worker. Refactor the auth module." --context-markdown "# Context\n\nFocus on packages/auth only."
57
+ forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
58
+ forgeflow-review-orchestrator decide --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --decision merge
59
+ forgeflow-review-orchestrator decide --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --decision block --notes "Scope exceeded"
60
+ forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
61
+ forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
62
+ ```
63
+
64
+ When `--target-worker-id` is set, the CLI injects that worker id into every task and assignment package before posting the dispatch. This keeps the existing `dispatch.json` shape but gives the control layer an explicit way to target `trae-local-forgeflow` or `trae-remote-forgeflow`.
65
+
66
+ Use `dispatch-task` for the common case of one worker task with one branch, one `allowedPaths` list, and one `acceptance` list. Keep `dispatch --input` for more complex multi-task or prebuilt payloads.
67
+
68
+ For control-layer review flow, prefer this CLI over calling lower-level review-decision scripts directly. Keep the scripts as compatibility and implementation detail, but use `forgeflow-review-orchestrator decide` as the default operator entrypoint.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { runDispatch } from "./dispatch.js";
3
+ import { runDecide } from "./decide.js";
4
+ import { runInspect } from "./inspect.js";
5
+ import { runRedrive } from "./redrive.js";
6
+ import { watchTask } from "./watch.js";
7
+ export interface CliDeps {
8
+ runDispatch: typeof runDispatch;
9
+ watchTask: typeof watchTask;
10
+ runDecide: typeof runDecide;
11
+ runInspect: typeof runInspect;
12
+ runRedrive: typeof runRedrive;
13
+ log: (message: string) => void;
14
+ }
15
+ export interface ParsedCliArgs {
16
+ command: "dispatch" | "dispatch-task" | "watch" | "decide" | "inspect" | "redrive";
17
+ options: Record<string, string | number | boolean>;
18
+ }
19
+ export declare function parseCliArgs(argv: string[]): ParsedCliArgs;
20
+ export declare function runCli(argv: string[], partialDeps?: Partial<CliDeps>): Promise<import("./types.js").DispatchResult | import("./types.js").WatchSummaryResult | import("./types.js").DecideResult | import("./types.js").InspectResult | import("./types.js").InspectSummaryResult | import("./types.js").RedriveResult | {
21
+ dryRun: boolean;
22
+ dispatcherUrl: string;
23
+ payload: import("./types.js").DispatchInput;
24
+ } | null>;
25
+ export declare function isCliEntrypoint(scriptPath?: string): boolean;
package/dist/cli.js ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { buildSingleTaskDispatchInput, runDispatch } from "./dispatch.js";
5
+ import { runDecide } from "./decide.js";
6
+ import { runInspect } from "./inspect.js";
7
+ import { runRedrive } from "./redrive.js";
8
+ import { watchTask } from "./watch.js";
9
+ function parseValue(raw) {
10
+ if (raw === "true") {
11
+ return true;
12
+ }
13
+ if (raw === "false") {
14
+ return false;
15
+ }
16
+ if (/^-?\d+$/.test(raw)) {
17
+ return Number(raw);
18
+ }
19
+ return raw;
20
+ }
21
+ export function parseCliArgs(argv) {
22
+ const [command, ...rest] = argv;
23
+ if (!command) {
24
+ throw new Error("command is required");
25
+ }
26
+ if (!["dispatch", "dispatch-task", "watch", "decide", "inspect", "redrive"].includes(command)) {
27
+ throw new Error(`unknown command: ${command}`);
28
+ }
29
+ const options = {};
30
+ for (let index = 0; index < rest.length; index += 1) {
31
+ const arg = rest[index];
32
+ if (!arg || !arg.startsWith("--")) {
33
+ throw new Error(`unknown argument: ${arg}`);
34
+ }
35
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
36
+ const next = rest[index + 1];
37
+ if (!next || next.startsWith("--")) {
38
+ options[key] = true;
39
+ continue;
40
+ }
41
+ options[key] = parseValue(next);
42
+ index += 1;
43
+ }
44
+ return {
45
+ command: command,
46
+ options,
47
+ };
48
+ }
49
+ function printHelp() {
50
+ console.log(`
51
+ Usage:
52
+ forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json
53
+ forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json --target-worker-id trae-remote-forgeflow
54
+ forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --allowed-paths docs/**,README.md --acceptance "pnpm typecheck,git diff --check"
55
+ forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --worker-prompt-file prompts/worker.md --context-markdown-file context/task.md
56
+ forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
57
+ forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
58
+ forgeflow-review-orchestrator decide --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --decision merge
59
+ forgeflow-review-orchestrator decide --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --decision block
60
+ forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
61
+ forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
62
+ forgeflow-review-orchestrator inspect --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1
63
+ forgeflow-review-orchestrator inspect --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --summary
64
+ forgeflow-review-orchestrator redrive --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
65
+ `);
66
+ }
67
+ export async function runCli(argv, partialDeps = {}) {
68
+ const deps = {
69
+ runDispatch,
70
+ watchTask,
71
+ runDecide,
72
+ runInspect,
73
+ runRedrive,
74
+ log: (message) => console.log(message),
75
+ ...partialDeps,
76
+ };
77
+ const parsed = parseCliArgs(argv);
78
+ const options = parsed.options;
79
+ if (options.help === true) {
80
+ printHelp();
81
+ return null;
82
+ }
83
+ if (parsed.command === "dispatch") {
84
+ const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
85
+ const input = typeof options.input === "string" ? options.input : "-";
86
+ if (!dispatcherUrl) {
87
+ throw new Error("--dispatcher-url is required");
88
+ }
89
+ const result = await deps.runDispatch({
90
+ dispatcherUrl,
91
+ input,
92
+ targetWorkerId: typeof options.targetWorkerId === "string" ? options.targetWorkerId : undefined,
93
+ requestTimeoutMs: typeof options.requestTimeoutMs === "number" ? options.requestTimeoutMs : undefined,
94
+ });
95
+ deps.log(JSON.stringify(result, null, 2));
96
+ return result;
97
+ }
98
+ if (parsed.command === "dispatch-task") {
99
+ const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
100
+ const repo = typeof options.repo === "string" ? options.repo : undefined;
101
+ const defaultBranch = typeof options.defaultBranch === "string" ? options.defaultBranch : undefined;
102
+ const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
103
+ const title = typeof options.title === "string" ? options.title : undefined;
104
+ const pool = typeof options.pool === "string" ? options.pool : undefined;
105
+ const branchName = typeof options.branchName === "string" ? options.branchName : undefined;
106
+ if (!dispatcherUrl) {
107
+ throw new Error("--dispatcher-url is required");
108
+ }
109
+ if (!repo || !defaultBranch || !taskId || !title || !pool || !branchName) {
110
+ throw new Error("--repo, --default-branch, --task-id, --title, --pool, and --branch-name are required");
111
+ }
112
+ const payload = buildSingleTaskDispatchInput({
113
+ repo,
114
+ defaultBranch,
115
+ taskId,
116
+ title,
117
+ pool,
118
+ branchName,
119
+ requestedBy: typeof options.requestedBy === "string" ? options.requestedBy : undefined,
120
+ allowedPaths: typeof options.allowedPaths === "string" ? options.allowedPaths : undefined,
121
+ acceptance: typeof options.acceptance === "string" ? options.acceptance : undefined,
122
+ dependsOn: typeof options.dependsOn === "string" ? options.dependsOn : undefined,
123
+ targetWorkerId: typeof options.targetWorkerId === "string" ? options.targetWorkerId : undefined,
124
+ verificationMode: typeof options.verificationMode === "string" ? options.verificationMode : undefined,
125
+ workerPrompt: typeof options.workerPrompt === "string" ? options.workerPrompt : undefined,
126
+ contextMarkdown: typeof options.contextMarkdown === "string" ? options.contextMarkdown : undefined,
127
+ workerPromptFile: typeof options.workerPromptFile === "string" ? options.workerPromptFile : undefined,
128
+ contextMarkdownFile: typeof options.contextMarkdownFile === "string" ? options.contextMarkdownFile : undefined,
129
+ });
130
+ if (options.dryRun === true) {
131
+ const dryRunResult = { dryRun: true, dispatcherUrl, payload };
132
+ deps.log(JSON.stringify(dryRunResult, null, 2));
133
+ return dryRunResult;
134
+ }
135
+ const result = await deps.runDispatch({
136
+ dispatcherUrl,
137
+ input: "-",
138
+ payload,
139
+ requestTimeoutMs: typeof options.requestTimeoutMs === "number" ? options.requestTimeoutMs : undefined,
140
+ });
141
+ deps.log(JSON.stringify(result, null, 2));
142
+ return result;
143
+ }
144
+ if (parsed.command === "watch") {
145
+ const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
146
+ const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
147
+ if (!dispatcherUrl) {
148
+ throw new Error("--dispatcher-url is required");
149
+ }
150
+ if (!taskId) {
151
+ throw new Error("--task-id is required");
152
+ }
153
+ const result = await deps.watchTask({
154
+ dispatcherUrl,
155
+ taskId,
156
+ intervalMs: typeof options.intervalMs === "number" ? options.intervalMs : undefined,
157
+ timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : undefined,
158
+ summary: options.summary === true,
159
+ });
160
+ deps.log(JSON.stringify(result, null, 2));
161
+ return result;
162
+ }
163
+ if (parsed.command === "decide") {
164
+ const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
165
+ const decision = typeof options.decision === "string" ? options.decision : undefined;
166
+ if (!taskId) {
167
+ throw new Error("--task-id is required");
168
+ }
169
+ if (!decision) {
170
+ throw new Error("--decision is required");
171
+ }
172
+ const result = await deps.runDecide({
173
+ taskId,
174
+ decision: decision,
175
+ actor: typeof options.actor === "string" ? options.actor : undefined,
176
+ notes: typeof options.notes === "string" ? options.notes : undefined,
177
+ at: typeof options.at === "string" ? options.at : undefined,
178
+ dispatcherUrl: typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined,
179
+ stateDir: typeof options.stateDir === "string" ? options.stateDir : undefined,
180
+ });
181
+ deps.log(JSON.stringify(result, null, 2));
182
+ return result;
183
+ }
184
+ if (parsed.command === "inspect") {
185
+ const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
186
+ const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
187
+ const stateDir = typeof options.stateDir === "string" ? options.stateDir : undefined;
188
+ if (!dispatcherUrl && !stateDir) {
189
+ throw new Error("--dispatcher-url or --state-dir is required");
190
+ }
191
+ if (!taskId) {
192
+ throw new Error("--task-id is required");
193
+ }
194
+ const result = await deps.runInspect({
195
+ dispatcherUrl,
196
+ taskId,
197
+ summary: options.summary === true,
198
+ stateDir,
199
+ });
200
+ deps.log(JSON.stringify(result, null, 2));
201
+ return result;
202
+ }
203
+ if (parsed.command === "redrive") {
204
+ const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
205
+ const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
206
+ if (!dispatcherUrl) {
207
+ throw new Error("--dispatcher-url is required");
208
+ }
209
+ if (!taskId) {
210
+ throw new Error("--task-id is required");
211
+ }
212
+ const result = await deps.runRedrive({
213
+ dispatcherUrl,
214
+ taskId,
215
+ });
216
+ deps.log(JSON.stringify(result, null, 2));
217
+ return result;
218
+ }
219
+ throw new Error(`unknown command: ${parsed.command}`);
220
+ }
221
+ function main() {
222
+ const args = process.argv.slice(2);
223
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
224
+ printHelp();
225
+ return;
226
+ }
227
+ runCli(args).catch((error) => {
228
+ console.error(error instanceof Error ? error.message : String(error));
229
+ process.exit(1);
230
+ });
231
+ }
232
+ export function isCliEntrypoint(scriptPath = process.argv[1]) {
233
+ if (!scriptPath) {
234
+ return false;
235
+ }
236
+ const resolvedPath = scriptPath.startsWith("file:")
237
+ ? fileURLToPath(scriptPath)
238
+ : scriptPath;
239
+ try {
240
+ return import.meta.url === pathToFileURL(fs.realpathSync(resolvedPath)).href;
241
+ }
242
+ catch {
243
+ return import.meta.url === pathToFileURL(resolvedPath).href;
244
+ }
245
+ }
246
+ if (isCliEntrypoint()) {
247
+ main();
248
+ }
@@ -0,0 +1,4 @@
1
+ import type { DecideOptions, DecideResult } from "./types.js";
2
+ export declare function runDecide(options: DecideOptions & {
3
+ fetchImpl?: typeof globalThis.fetch;
4
+ }): Promise<DecideResult>;
package/dist/decide.js ADDED
@@ -0,0 +1,143 @@
1
+ import path from "node:path";
2
+ import { createJsonHttpClient, loadRuntimeState, saveRuntimeState } from "./http.js";
3
+ function normalizeDecision(decision) {
4
+ if (decision === "merge") {
5
+ return "merge";
6
+ }
7
+ return "block";
8
+ }
9
+ function readNowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+ function upsertByTaskId(items, payload) {
13
+ const index = items.findIndex((item) => item.taskId === payload.taskId);
14
+ if (index === -1) {
15
+ return [...items, payload];
16
+ }
17
+ const next = [...items];
18
+ next[index] = {
19
+ ...next[index],
20
+ ...payload,
21
+ };
22
+ return next;
23
+ }
24
+ function buildLocalDecisionState(state, input) {
25
+ const task = state.tasks.find((candidate) => candidate.id === input.taskId);
26
+ if (!task) {
27
+ throw new Error(`task not found: ${input.taskId}`);
28
+ }
29
+ const assignment = state.assignments.find((candidate) => candidate.taskId === input.taskId);
30
+ if (!assignment) {
31
+ throw new Error(`assignment not found: ${input.taskId}`);
32
+ }
33
+ if (task.status !== "review") {
34
+ throw new Error(`task not in review: ${input.taskId}`);
35
+ }
36
+ const nextStatus = input.decision === "merge" ? "merged" : "blocked";
37
+ const at = input.at || readNowIso();
38
+ const nextEvents = [
39
+ ...state.events,
40
+ {
41
+ taskId: input.taskId,
42
+ type: "status_changed",
43
+ at,
44
+ payload: {
45
+ from: "review",
46
+ to: nextStatus,
47
+ },
48
+ },
49
+ ];
50
+ const nextTasks = state.tasks.map((candidate) => candidate.id === input.taskId
51
+ ? {
52
+ ...candidate,
53
+ status: nextStatus,
54
+ }
55
+ : candidate);
56
+ const nextAssignments = state.assignments.map((candidate) => candidate.taskId === input.taskId
57
+ ? {
58
+ ...candidate,
59
+ status: nextStatus,
60
+ assignment: {
61
+ ...candidate.assignment,
62
+ status: nextStatus,
63
+ },
64
+ }
65
+ : candidate);
66
+ const nextReviews = upsertByTaskId(state.reviews, {
67
+ taskId: input.taskId,
68
+ decision: input.decision,
69
+ actor: input.actor ?? "codex-control",
70
+ notes: input.notes ?? "",
71
+ decidedAt: at,
72
+ });
73
+ const nextPullRequests = state.pullRequests.map((pullRequest) => pullRequest.taskId === input.taskId
74
+ ? {
75
+ ...pullRequest,
76
+ status: input.decision === "merge" ? "merged" : "changes_requested",
77
+ updatedAt: at,
78
+ }
79
+ : pullRequest);
80
+ const nextState = {
81
+ ...state,
82
+ updatedAt: at,
83
+ events: nextEvents,
84
+ tasks: nextTasks,
85
+ assignments: nextAssignments,
86
+ reviews: nextReviews,
87
+ pullRequests: nextPullRequests,
88
+ };
89
+ return {
90
+ state: nextState,
91
+ result: {
92
+ taskId: input.taskId,
93
+ decision: input.decision,
94
+ status: nextStatus,
95
+ actor: input.actor ?? "codex-control",
96
+ notes: input.notes ?? "",
97
+ at,
98
+ task,
99
+ assignment,
100
+ },
101
+ };
102
+ }
103
+ export async function runDecide(options) {
104
+ const decision = normalizeDecision(options.decision);
105
+ const payload = {
106
+ actor: options.actor ?? "codex-control",
107
+ decision,
108
+ notes: options.notes ?? "",
109
+ at: options.at ?? readNowIso(),
110
+ };
111
+ if (options.dispatcherUrl) {
112
+ const client = createJsonHttpClient(options.dispatcherUrl, {
113
+ fetchImpl: options.fetchImpl,
114
+ });
115
+ const result = await client.request(`/api/reviews/${encodeURIComponent(options.taskId)}/decision`, {
116
+ method: "POST",
117
+ body: payload,
118
+ });
119
+ return {
120
+ taskId: options.taskId,
121
+ decision,
122
+ status: decision === "merge" ? "merged" : "blocked",
123
+ source: "dispatcher",
124
+ payload: result,
125
+ };
126
+ }
127
+ if (!options.stateDir) {
128
+ throw new Error("dispatcherUrl or stateDir is required");
129
+ }
130
+ const state = loadRuntimeState(path.resolve(options.stateDir));
131
+ const result = buildLocalDecisionState(state, {
132
+ ...options,
133
+ decision,
134
+ });
135
+ saveRuntimeState(path.resolve(options.stateDir), result.state);
136
+ return {
137
+ taskId: options.taskId,
138
+ decision,
139
+ status: decision === "merge" ? "merged" : "blocked",
140
+ source: "state-dir",
141
+ payload: result.result,
142
+ };
143
+ }
@@ -0,0 +1,14 @@
1
+ import type { DispatchInput, DispatchResult, DispatchTaskInputOptions } from "./types.js";
2
+ export interface DispatchOptions {
3
+ dispatcherUrl: string;
4
+ input: string;
5
+ payload?: DispatchInput;
6
+ targetWorkerId?: string;
7
+ requestTimeoutMs?: number;
8
+ fetchImpl?: typeof globalThis.fetch;
9
+ readStdin?: () => Promise<string>;
10
+ }
11
+ export declare function loadDispatchInput(source: string, readStdin?: () => Promise<string>): Promise<DispatchInput>;
12
+ export declare function buildSingleTaskDispatchInput(options: DispatchTaskInputOptions): DispatchInput;
13
+ export declare function applyDispatchTargetWorker(payload: DispatchInput, targetWorkerId?: string): DispatchInput;
14
+ export declare function runDispatch(options: DispatchOptions): Promise<DispatchResult>;
@@ -0,0 +1,105 @@
1
+ import { createJsonHttpClient, readJsonInput } from "./http.js";
2
+ import { readFileSync } from "node:fs";
3
+ export async function loadDispatchInput(source, readStdin) {
4
+ return readJsonInput(source, { readStdin });
5
+ }
6
+ function splitCsv(input) {
7
+ return String(input || "")
8
+ .split(",")
9
+ .map((item) => item.trim())
10
+ .filter(Boolean);
11
+ }
12
+ function readFileContent(filePath) {
13
+ if (!filePath) {
14
+ return undefined;
15
+ }
16
+ try {
17
+ return readFileSync(filePath, "utf-8").trim();
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ export function buildSingleTaskDispatchInput(options) {
24
+ const targetWorkerId = String(options.targetWorkerId || "").trim() || undefined;
25
+ const allowedPaths = splitCsv(options.allowedPaths);
26
+ const acceptance = splitCsv(options.acceptance);
27
+ const dependsOn = splitCsv(options.dependsOn);
28
+ const workerPromptFromFile = readFileContent(options.workerPromptFile);
29
+ const contextMarkdownFromFile = readFileContent(options.contextMarkdownFile);
30
+ const workerPrompt = workerPromptFromFile || options.workerPrompt || "You are a ForgeFlow worker. Stay within allowedPaths and satisfy acceptance.";
31
+ const contextMarkdown = contextMarkdownFromFile || options.contextMarkdown || "# Context\n\nComplete the assigned task within scope.";
32
+ return {
33
+ repo: options.repo,
34
+ defaultBranch: options.defaultBranch,
35
+ requestedBy: options.requestedBy || "codex-control",
36
+ tasks: [
37
+ {
38
+ id: options.taskId,
39
+ title: options.title,
40
+ pool: options.pool,
41
+ allowedPaths,
42
+ acceptance,
43
+ dependsOn,
44
+ branchName: options.branchName,
45
+ verification: {
46
+ mode: String(options.verificationMode || "run"),
47
+ },
48
+ ...(targetWorkerId ? { targetWorkerId } : {}),
49
+ ...(options.continuationMode ? { continuationMode: options.continuationMode } : {}),
50
+ ...(options.continueFromTaskId ? { continueFromTaskId: options.continueFromTaskId } : {}),
51
+ },
52
+ ],
53
+ packages: [
54
+ {
55
+ taskId: options.taskId,
56
+ assignment: {
57
+ taskId: options.taskId,
58
+ workerId: null,
59
+ pool: options.pool,
60
+ status: "pending",
61
+ branchName: options.branchName,
62
+ allowedPaths,
63
+ repo: options.repo,
64
+ defaultBranch: options.defaultBranch,
65
+ ...(targetWorkerId ? { targetWorkerId } : {}),
66
+ ...(options.continuationMode ? { continuationMode: options.continuationMode } : {}),
67
+ ...(options.continueFromTaskId ? { continueFromTaskId: options.continueFromTaskId } : {}),
68
+ },
69
+ workerPrompt,
70
+ contextMarkdown,
71
+ },
72
+ ],
73
+ };
74
+ }
75
+ function applyTargetWorkerToRecords(records, targetWorkerId) {
76
+ return records.map((record) => ({
77
+ ...record,
78
+ targetWorkerId,
79
+ target_worker_id: targetWorkerId,
80
+ }));
81
+ }
82
+ export function applyDispatchTargetWorker(payload, targetWorkerId) {
83
+ const normalizedTargetWorkerId = String(targetWorkerId || "").trim();
84
+ if (!normalizedTargetWorkerId) {
85
+ return payload;
86
+ }
87
+ return {
88
+ ...payload,
89
+ tasks: applyTargetWorkerToRecords(payload.tasks, normalizedTargetWorkerId),
90
+ packages: applyTargetWorkerToRecords(payload.packages, normalizedTargetWorkerId),
91
+ };
92
+ }
93
+ export async function runDispatch(options) {
94
+ const payload = options.payload
95
+ ? applyDispatchTargetWorker(options.payload, options.targetWorkerId)
96
+ : applyDispatchTargetWorker(await loadDispatchInput(options.input, options.readStdin), options.targetWorkerId);
97
+ const client = createJsonHttpClient(options.dispatcherUrl, {
98
+ fetchImpl: options.fetchImpl,
99
+ requestTimeoutMs: options.requestTimeoutMs,
100
+ });
101
+ return client.request("/api/dispatches", {
102
+ method: "POST",
103
+ body: payload,
104
+ });
105
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { JsonHttpClientOptions, JsonHttpRequestOptions, LocalRuntimeState } from "./types.js";
2
+ export declare function createJsonHttpClient(baseUrl: string, options?: JsonHttpClientOptions): {
3
+ request: (pathname: string, init?: JsonHttpRequestOptions) => Promise<any>;
4
+ };
5
+ export declare function createEmptyRuntimeState(): LocalRuntimeState;
6
+ export declare function loadRuntimeState(stateDir: string): LocalRuntimeState;
7
+ export declare function saveRuntimeState(stateDir: string, state: LocalRuntimeState): void;
8
+ export declare function readJsonInput(source: string, options?: {
9
+ readStdin?: () => Promise<string>;
10
+ }): Promise<any>;