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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Delegates to the bridge-installed CLI (~/.ftown/ftown-workflows).
3
+ set -euo pipefail
4
+ exec "${HOME}/.ftown/ftown-workflows" "$@"