ftown-bridge 0.9.3 → 0.9.4
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/dist/create-ftown-session.d.ts +1 -0
- package/dist/create-ftown-session.js +3 -2
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-workflows-cli.d.ts +3 -0
- package/dist/install-ftown-workflows-cli.js +30 -0
- package/dist/install-ftown-workflows-cli.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/workflow-runner-cli.d.ts +2 -0
- package/dist/workflow-runner-cli.js +315 -0
- package/dist/workflow-runner-cli.js.map +1 -0
- package/dist/workflow-runner.d.ts +162 -0
- package/dist/workflow-runner.js +305 -0
- package/dist/workflow-runner.js.map +1 -0
- package/package.json +5 -3
- package/skills/ftown-workflows/SKILL.md +282 -0
- package/skills/ftown-workflows/scripts/example.flow.mjs +122 -0
- package/skills/ftown-workflows/scripts/ftown-workflows +4 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ftown-workflows
|
|
3
|
+
description: >-
|
|
4
|
+
Run deterministic, scripted, resumable multi-session workflows across real
|
|
5
|
+
ftown agent sessions. Activates when the user wants to fan out ftown worker
|
|
6
|
+
sessions over a work list, run a pipeline or parallel batch across ftown
|
|
7
|
+
sessions, perform adversarial-verify (majority-vote) across sessions,
|
|
8
|
+
loop-until-dry over a dataset, or write a repeatable orchestration script
|
|
9
|
+
instead of doing it by hand.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# ftown-workflows
|
|
13
|
+
|
|
14
|
+
`ftown-workflows` is a **scripted orchestration engine** for real ftown sessions.
|
|
15
|
+
You write a `.mjs` script using a small API (`agent`, `parallel`, `pipeline`,
|
|
16
|
+
`phase`, `log`, `args`, `budget`) and the runner spawns real ftown sessions (claude /
|
|
17
|
+
cursor / codex), waits for each to write a result file, cleans up, and returns the
|
|
18
|
+
value — all deterministically and repeatably.
|
|
19
|
+
|
|
20
|
+
This complements the by-hand **ftown-orchestrator** skill (which is the ad-hoc,
|
|
21
|
+
human-in-the-loop playbook). Use ftown-workflows when the work is scripted and
|
|
22
|
+
repeatable; use ftown-orchestrator when you need to improvise or keep a human in
|
|
23
|
+
the loop.
|
|
24
|
+
|
|
25
|
+
## Running a workflow
|
|
26
|
+
|
|
27
|
+
You must be **inside an ftown session** — `FTOWN_SESSION_ID` must be set.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
~/.ftown/ftown-workflows run path/to/script.mjs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Caveat:** run ftown-workflows from a **top-level orchestrator session**. If the
|
|
34
|
+
> session running it is itself a child, the bridge flattens the tree so spawned
|
|
35
|
+
> workers become **siblings** of the orchestrator rather than its children. Results
|
|
36
|
+
> are file-based, so this does not affect correctness — only the dashboard topology.
|
|
37
|
+
|
|
38
|
+
Full options:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
~/.ftown/ftown-workflows run <script.mjs> \
|
|
42
|
+
[--args <json>] # parsed and available as ctx.args in the script
|
|
43
|
+
[--workdir <path>] # default working dir for spawned child sessions
|
|
44
|
+
[--shell claude|cursor|codex|opencode|shell]
|
|
45
|
+
[--concurrency <n>] # max simultaneous live sessions (default 4)
|
|
46
|
+
[--timeout <ms>] # per-agent timeout (default 1 800 000 = 30 min)
|
|
47
|
+
[--max-agents <n>] # hard budget cap on total spawns
|
|
48
|
+
[--run-id <id>] # resume a previous run (skip completed steps)
|
|
49
|
+
[--json] # print final result as raw JSON
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The runner prints the **run directory** (`~/.ftown/workflows/<run-id>/`) at start.
|
|
53
|
+
For long runs, launch in the background and tail the run dir:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
~/.ftown/ftown-workflows run script.mjs --args '{"pr":42}' &
|
|
57
|
+
tail -f ~/.ftown/workflows/<run-id>/*.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See the runnable template at `skills/ftown-workflows/scripts/example.flow.mjs`.
|
|
61
|
+
|
|
62
|
+
## Script API
|
|
63
|
+
|
|
64
|
+
A workflow script is an ES module. Its **default export** (or named `run` export)
|
|
65
|
+
is an async function that receives a `WorkflowContext`:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// my-workflow.mjs
|
|
69
|
+
export default async function (ctx) {
|
|
70
|
+
ctx.phase('Gather');
|
|
71
|
+
ctx.log(`args: ${JSON.stringify(ctx.args)}`);
|
|
72
|
+
|
|
73
|
+
const summary = await ctx.agent('Summarise the repo README', {
|
|
74
|
+
label: 'summarise',
|
|
75
|
+
workdir: '/path/to/repo',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
ctx.log(`summary: ${summary}`);
|
|
79
|
+
return summary;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `ctx.agent(prompt, opts?)`
|
|
84
|
+
|
|
85
|
+
Spawns one real ftown session. Blocks until the session writes its result file,
|
|
86
|
+
then removes the session and returns the result.
|
|
87
|
+
|
|
88
|
+
- Without `schema`: returns a **string** (or `null` on failure/timeout). A string
|
|
89
|
+
`result` is returned as-is; a non-string `result` is returned as a JSON string.
|
|
90
|
+
- With `schema`: returns the child's `result` JSON value **as-is** (parsed from the
|
|
91
|
+
result file), or `null` on failure. The engine does **not** validate `result`
|
|
92
|
+
against the schema — the schema is embedded in the child's prompt as guidance only.
|
|
93
|
+
Treat conformance as best-effort and validate it yourself if you depend on it.
|
|
94
|
+
|
|
95
|
+
Returns `null` — never throws — for: timeout, session exits without a result,
|
|
96
|
+
`ok: false` in the result, budget exhausted.
|
|
97
|
+
|
|
98
|
+
Key options:
|
|
99
|
+
|
|
100
|
+
| option | default | meaning |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `label` | `step-<n>` | step key used for the result file and resume |
|
|
103
|
+
| `phase` | — | progress grouping shown in logs |
|
|
104
|
+
| `schema` | — | JSON Schema; forces JSON result |
|
|
105
|
+
| `shell` | run-level default | `claude` / `cursor` / `codex` / `opencode` / `shell` |
|
|
106
|
+
| `model` | — | model override passed to the session |
|
|
107
|
+
| `workdir` | run-level default | working directory for the child session |
|
|
108
|
+
| `timeoutMs` | 1 800 000 | wall-clock cap for this step |
|
|
109
|
+
| `pollIntervalMs` | 2000 | how often to check for the result file |
|
|
110
|
+
|
|
111
|
+
### `ctx.parallel(thunks)`
|
|
112
|
+
|
|
113
|
+
Run an array of thunks concurrently (barrier: waits for all). Respects the
|
|
114
|
+
run-level `--concurrency` cap. A thunk that errors → `null` entry; the call
|
|
115
|
+
never rejects.
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
const reviews = await ctx.parallel(
|
|
119
|
+
files.map(f => () => ctx.agent(`Review ${f}`, { label: `review-${f}` }))
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `ctx.pipeline(items, ...stages)`
|
|
124
|
+
|
|
125
|
+
Thread each item through a sequence of stages independently (no barrier between
|
|
126
|
+
stages). A stage that throws drops that item to `null` and skips its remaining
|
|
127
|
+
stages.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const results = await ctx.pipeline(
|
|
131
|
+
files,
|
|
132
|
+
async (file) => ctx.agent(`lint ${file}`, { label: `lint-${file}` }),
|
|
133
|
+
async (lintResult, file) => ctx.agent(`fix issues in ${file}: ${lintResult}`, { label: `fix-${file}` }),
|
|
134
|
+
);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `ctx.phase(title)` / `ctx.log(message)`
|
|
138
|
+
|
|
139
|
+
Emit progress events to stderr. Use `phase` for major milestones, `log` for
|
|
140
|
+
detail lines.
|
|
141
|
+
|
|
142
|
+
### `ctx.args`
|
|
143
|
+
|
|
144
|
+
The value passed via `--args <json>` (parsed). `undefined` if not provided.
|
|
145
|
+
|
|
146
|
+
### `ctx.budget`
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
ctx.budget.maxAgents // null = unbounded
|
|
150
|
+
ctx.budget.spent() // spawns so far (cached don't count)
|
|
151
|
+
ctx.budget.remaining() // maxAgents - spent(), or Infinity
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Result-file contract
|
|
155
|
+
|
|
156
|
+
Each child session receives a prompt that ends with a protocol block instructing
|
|
157
|
+
it to write its final result as JSON to a specific file path and then stop:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{ "ok": true, "result": "...anything..." }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
or on failure:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "ok": false, "error": "reason" }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The engine polls the file every `pollIntervalMs` ms. A partial write (incomplete
|
|
170
|
+
JSON) is silently ignored until it is valid. The child session is removed (archived)
|
|
171
|
+
once the result is read, or on timeout/exit.
|
|
172
|
+
|
|
173
|
+
**You do not write this file yourself** — the child agent is instructed to do it.
|
|
174
|
+
The prompt injected by the engine tells the child agent exactly what to write.
|
|
175
|
+
|
|
176
|
+
## Patterns
|
|
177
|
+
|
|
178
|
+
### Parallel fan-out
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
export default async function (ctx) {
|
|
182
|
+
const items = ctx.args.items; // e.g. ["auth.ts", "api.ts", "db.ts"]
|
|
183
|
+
|
|
184
|
+
ctx.phase('Review');
|
|
185
|
+
const reviews = await ctx.parallel(
|
|
186
|
+
items.map(f => () => ctx.agent(`Review ${f} for security issues`, {
|
|
187
|
+
label: `review-${f}`,
|
|
188
|
+
}))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
ctx.phase('Synthesise');
|
|
192
|
+
const report = await ctx.agent(
|
|
193
|
+
`Synthesise these security reviews:\n${reviews.filter(Boolean).join('\n---\n')}`,
|
|
194
|
+
{ label: 'synthesis' },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return report;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Pipeline (multi-stage per item)
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
export default async function (ctx) {
|
|
205
|
+
return ctx.pipeline(
|
|
206
|
+
ctx.args.files,
|
|
207
|
+
(file) => ctx.agent(`Lint ${file}`, { label: `lint-${file}` }),
|
|
208
|
+
(lintOut, file) => ctx.agent(`Fix ${file} based on: ${lintOut}`, { label: `fix-${file}` }),
|
|
209
|
+
(fixOut, file) => ctx.agent(`Write tests for ${file}`, { label: `test-${file}` }),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Adversarial verify (majority vote)
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
export default async function (ctx) {
|
|
218
|
+
const claim = ctx.args.claim;
|
|
219
|
+
const REVIEWERS = 3;
|
|
220
|
+
|
|
221
|
+
ctx.phase('Verify');
|
|
222
|
+
const verdicts = await ctx.parallel(
|
|
223
|
+
Array.from({ length: REVIEWERS }, (_, i) =>
|
|
224
|
+
() => ctx.agent(
|
|
225
|
+
`You are a skeptical reviewer. Is this claim correct? "${claim}" Reply with just "yes" or "no".`,
|
|
226
|
+
{ label: `skeptic-${i}` },
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const yes = verdicts.filter(v => v?.toLowerCase().startsWith('yes')).length;
|
|
232
|
+
return { claim, verdict: yes > REVIEWERS / 2 ? 'accepted' : 'rejected', votes: verdicts };
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Loop-until-dry
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
export default async function (ctx) {
|
|
240
|
+
let queue = [...ctx.args.items];
|
|
241
|
+
const done = [];
|
|
242
|
+
|
|
243
|
+
while (queue.length > 0 && ctx.budget.remaining() > 0) {
|
|
244
|
+
ctx.phase(`Batch (${queue.length} remaining)`);
|
|
245
|
+
const batch = queue.splice(0, 4);
|
|
246
|
+
const results = await ctx.parallel(
|
|
247
|
+
batch.map(item => () => ctx.agent(`Process: ${item}`, { label: `proc-${item}` }))
|
|
248
|
+
);
|
|
249
|
+
done.push(...results.filter(Boolean));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return done;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Resume
|
|
257
|
+
|
|
258
|
+
Every step is keyed by its `label` (or `step-<n>`). If a result file already
|
|
259
|
+
exists for a step, the engine returns the cached result without spawning a new
|
|
260
|
+
session. To resume a partial run:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
~/.ftown/ftown-workflows run script.mjs --run-id <the-previous-run-id>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The run id and run directory are printed at startup.
|
|
267
|
+
|
|
268
|
+
## When to use this vs ftown-orchestrator
|
|
269
|
+
|
|
270
|
+
| | ftown-orchestrator | ftown-workflows |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| **style** | ad-hoc, by hand | scripted, deterministic |
|
|
273
|
+
| **human in loop** | yes — you direct workers via mail | no — script drives everything |
|
|
274
|
+
| **repeatability** | each run is improvised | same script, same steps |
|
|
275
|
+
| **resume** | manual | automatic via `--run-id` |
|
|
276
|
+
| **best for** | exploratory tasks, escalations, debugging | batch jobs, CI-style pipelines, fan-out reviews |
|
|
277
|
+
|
|
278
|
+
## If the CLI is missing
|
|
279
|
+
|
|
280
|
+
Start or restart **ftown-bridge** on this machine. It installs
|
|
281
|
+
`~/.ftown/ftown-workflows` and updates this skill under
|
|
282
|
+
`~/.ftown/skills/ftown-workflows/` (linked into ~/.agents/skills and ~/.claude/skills).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* example.flow.mjs — template workflow: parallel code review fan-out + synthesis.
|
|
3
|
+
*
|
|
4
|
+
* Run it inside an ftown session:
|
|
5
|
+
*
|
|
6
|
+
* ~/.ftown/ftown-workflows run example.flow.mjs \
|
|
7
|
+
* --args '{"files":["src/auth.ts","src/api.ts","src/db.ts"]}' \
|
|
8
|
+
* --workdir /path/to/your/repo
|
|
9
|
+
*
|
|
10
|
+
* Add --run-id <previous-id> to resume a partial run without re-running
|
|
11
|
+
* steps whose result files already exist.
|
|
12
|
+
*
|
|
13
|
+
* The script exports a default async function that receives a WorkflowContext.
|
|
14
|
+
* The engine wires FTOWN_SESSION_ID from the calling session so children are
|
|
15
|
+
* registered as its children and are cleaned up on completion.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('../../../src/workflow-runner.js').WorkflowContext} ctx
|
|
20
|
+
*/
|
|
21
|
+
export default async function (ctx) {
|
|
22
|
+
// ── 1. Unpack args ──────────────────────────────────────────────────────────
|
|
23
|
+
// ctx.args is whatever was passed via --args (JSON-parsed).
|
|
24
|
+
// Provide a sensible fallback so the example runs without arguments too.
|
|
25
|
+
const files = /** @type {string[]} */ (
|
|
26
|
+
Array.isArray(ctx.args?.files)
|
|
27
|
+
? ctx.args.files
|
|
28
|
+
: ['src/auth.ts', 'src/api.ts', 'src/db.ts']
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
ctx.phase('Setup');
|
|
32
|
+
ctx.log(`Reviewing ${files.length} file(s): ${files.join(', ')}`);
|
|
33
|
+
ctx.log(`Budget: ${ctx.budget.maxAgents ?? 'unlimited'} agents`);
|
|
34
|
+
|
|
35
|
+
// ── 2. Fan-out: one reviewer per file, all running in parallel ───────────────
|
|
36
|
+
// ctx.parallel() is a BARRIER — it waits for every thunk before returning.
|
|
37
|
+
// A thunk that errors or whose agent returns null produces a null entry;
|
|
38
|
+
// the whole call never rejects.
|
|
39
|
+
// The concurrency cap (--concurrency, default 4) limits how many real sessions
|
|
40
|
+
// run simultaneously — you can safely pass more thunks than the cap.
|
|
41
|
+
ctx.phase('Review');
|
|
42
|
+
|
|
43
|
+
const reviews = await ctx.parallel(
|
|
44
|
+
files.map((file) => async () => {
|
|
45
|
+
// Each thunk is an async function returning a string (or null on failure).
|
|
46
|
+
const result = await ctx.agent(
|
|
47
|
+
// The prompt is the full task description for this child session.
|
|
48
|
+
// Keep it self-contained — the child has no other context.
|
|
49
|
+
`You are a code reviewer. Review the file \`${file}\` for:
|
|
50
|
+
- Security vulnerabilities (auth bypass, injection, secret leakage)
|
|
51
|
+
- Correctness bugs (off-by-one, null dereference, missing error handling)
|
|
52
|
+
- Style issues that reduce readability
|
|
53
|
+
|
|
54
|
+
Reply with a concise bullet-point list. Start with "## ${file}".`,
|
|
55
|
+
{
|
|
56
|
+
// label becomes the step key and the result filename.
|
|
57
|
+
// Unique, filesystem-safe labels enable per-step resume.
|
|
58
|
+
label: `review-${file.replace(/[^a-z0-9]/gi, '-')}`,
|
|
59
|
+
// phase groups events in the log output.
|
|
60
|
+
phase: 'review',
|
|
61
|
+
// shell defaults to 'claude'; override here if needed.
|
|
62
|
+
// shell: 'claude',
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (result == null) {
|
|
67
|
+
ctx.log(`WARN: review of ${file} failed or timed out`);
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// ── 3. Filter out any failed reviews before synthesising ────────────────────
|
|
74
|
+
const successfulReviews = reviews.filter(
|
|
75
|
+
/** @param {string | null} r */ (r) => r != null,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (successfulReviews.length === 0) {
|
|
79
|
+
ctx.log('ERROR: all reviews failed — cannot synthesise');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.log(`${successfulReviews.length}/${files.length} reviews succeeded`);
|
|
84
|
+
|
|
85
|
+
// ── 4. Single synthesis agent consolidates all reviewer findings ─────────────
|
|
86
|
+
// This is a sequential step — one agent, no parallelism needed.
|
|
87
|
+
ctx.phase('Synthesise');
|
|
88
|
+
|
|
89
|
+
const synthesis = await ctx.agent(
|
|
90
|
+
`You are a senior engineer writing a final code-review report.
|
|
91
|
+
Below are ${successfulReviews.length} individual file reviews.
|
|
92
|
+
Consolidate them into a single report with:
|
|
93
|
+
1. An executive summary (2-3 sentences).
|
|
94
|
+
2. Critical issues (must fix before merge).
|
|
95
|
+
3. Minor issues (nice to fix).
|
|
96
|
+
4. Positive observations.
|
|
97
|
+
|
|
98
|
+
--- REVIEWS ---
|
|
99
|
+
${successfulReviews.join('\n\n---\n\n')}`,
|
|
100
|
+
{
|
|
101
|
+
label: 'synthesis',
|
|
102
|
+
phase: 'synthesise',
|
|
103
|
+
// Use schema to get a structured JSON response instead of a string.
|
|
104
|
+
// When schema is set, agent() returns the parsed object (or null).
|
|
105
|
+
// Comment it out to get a plain string instead.
|
|
106
|
+
schema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
required: ['summary', 'critical', 'minor', 'positives'],
|
|
109
|
+
properties: {
|
|
110
|
+
summary: { type: 'string' },
|
|
111
|
+
critical: { type: 'array', items: { type: 'string' } },
|
|
112
|
+
minor: { type: 'array', items: { type: 'string' } },
|
|
113
|
+
positives: { type: 'array', items: { type: 'string' } },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// ── 5. Return value is printed by the CLI (pretty by default, --json for raw) ─
|
|
120
|
+
ctx.log(`Done. Budget used: ${ctx.budget.spent()} agent spawn(s).`);
|
|
121
|
+
return synthesis;
|
|
122
|
+
}
|