agent-horde 0.1.6 → 0.2.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/README.md CHANGED
@@ -50,7 +50,9 @@ found there as its brief. Unrelated files cost members context and invite them t
50
50
  from earlier runs persist in `.horde/` and will steer members; start with a fresh one when the
51
51
  task changes.
52
52
 
53
- A horde is at most twelve members today. Hordes of hordes are on the roadmap.
53
+ A horde is at most twelve members. For more hands, run a swarm: `3/12` launches three hordes of
54
+ twelve on disjoint workstreams of one task, each with its own ledgers, and merges their
55
+ deliverables at the end.
54
56
 
55
57
  **Tips.**
56
58
 
@@ -88,7 +90,7 @@ pi install npm:agent-horde
88
90
  Or from the repository, pinned to a tag:
89
91
 
90
92
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.6
93
+ pi install git:github.com/eliyastein/agent-horde@v0.2.1
92
94
  ```
93
95
 
94
96
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
@@ -135,14 +137,31 @@ extend, stress-test, or deepen the work.
135
137
  /agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
136
138
  ```
137
139
 
140
+ A count of `H/M` runs a swarm: H hordes of M members on one task, at most four hordes. A planner
141
+ helper first splits the task into H disjoint workstreams with acceptance criteria and each horde
142
+ works only its slice, with its own ledgers under `.horde/swarms/<id>/hN/`; if the planner cannot
143
+ produce H distinct workstreams the launch stops and says so. `plan 3/12 a; b; c; d; e; f` skips
144
+ the planner and deals the layers round-robin to the hordes. Members read the other hordes' facts,
145
+ rule-outs and resources through the `horde` tool but write only to their own horde. The hordes
146
+ start a few seconds apart, run the normal lifecycle independently, and when the last one has
147
+ finished a synthesis helper merges their deliverables into the swarm's `DELIVERABLE.md`, each
148
+ claim attributed to its horde. `--until-stop` applies to every horde.
149
+
150
+ ```
151
+ /agent-horde --dashboard=1234 3/12 audit the service for authorization bugs
152
+ ```
153
+
138
154
  | command | effect |
139
155
  |---|---|
140
- | `/agent-horde status` | the active run and each member |
156
+ | `/agent-horde status` | the active run and each member; one block per horde in a swarm |
141
157
  | `/agent-horde stop-clean [postmortem]` | members wrap up, then the run finalizes; this is how an `--until-stop` run ends |
142
158
  | `/agent-horde stop-hard` | terminate members now |
143
159
  | `/agent-horde pause`, `resume` | suspend and resume member processes |
144
160
  | `/agent-horde help` | every flag and target |
145
161
 
162
+ Every control takes a target: `member-3`, `h2` for one horde of a swarm, `h2:member-3` for one
163
+ member of one horde, or `all`; a bare `member-3` in a swarm names that member in every horde.
164
+
146
165
  When the run ends, the report is injected into your conversation. Everything else stays under
147
166
  `.horde/`; add it to your `.gitignore`.
148
167
 
package/docs/REFERENCE.md CHANGED
@@ -16,8 +16,10 @@ at one problem beat a single linear one: research, audits, design exploration, R
16
16
  /agent-horde help
17
17
  ```
18
18
 
19
- `target` is `all` (default), `member-N`, `N`, or a comma or space separated list. Subcommands and
20
- member targets tab-complete.
19
+ `count` is `M` members (1 to 12) or `H/M`: H hordes of M members, H at most 4; a plain `M` is a
20
+ swarm of one. `target` is `all` (default), `member-N`, `N`, `hN` (one horde of a swarm),
21
+ `hN:member-M`, `hN:M`, or a comma or space separated list; a bare member names that member in
22
+ every horde. Subcommands and targets tab-complete.
21
23
 
22
24
  The command returns immediately. A widget above the editor shows each member's status, current
23
25
  tool, elapsed time, turns, and resets. Members inherit the parent session's model and thinking
@@ -40,7 +42,14 @@ synthesize it.
40
42
  The default totals follow the queues' demand (one floor per queue plus one per further review
41
43
  request or blocking challenge), each attempt's share is cut in proportion to its demand, and
42
44
  every attempt gets at least a floor; the phase start records the budget, `repair-start` the
43
- attempt's demand, and `repair-end` its turns and first-turn cost. A user stop skips this phase.
45
+ attempt's demand, and `repair-end` its turns, first-turn cost and the threads it left
46
+ `unchanged`. A thread that two consecutive completed attempts did not move (same state and
47
+ response revision) is held back from that member until another member changes it; a member
48
+ whose whole queue is held is recorded as `repair-skipped` with reason `no-progress` and costs
49
+ later rounds nothing. Each round serves a member at most once, but a round other than the last
50
+ ends early when the only blocking threads left belong to members it already served, so a
51
+ requester rechecks a correction before unserved members' non-blocking threads.
52
+ A user stop skips this phase.
44
53
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
45
54
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
46
55
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -76,9 +85,19 @@ Every page has a JSON twin under `/api/<same path>`. The footer of every page na
76
85
  agent-horde version from its package.json and, when it runs from a git checkout, the commit it is
77
86
  at (`-dirty` when the tree has uncommitted changes), linked to the commit on GitHub.
78
87
 
88
+ Every page belongs to one scope, fixed by its URL: the **project** (`/` and the bare ledger paths,
89
+ the ledgers single runs share), one **run** (`/runs/<id>/...`), or one **swarm**
90
+ (`/swarms/<id>/...`). Under the `runs` link a scope line names the scope, and a page row offers that
91
+ scope's pages (overview, the six ledgers, collaboration, graph; the project has the ledgers only),
92
+ with the current page marked. Inside a swarm the scope line adds horde tabs, `all | h1 | h2 ...`,
93
+ that keep the page type: from h2's findings, h3 opens h3's findings and `all` the swarm's. A horde's
94
+ pages are its run's pages, `/runs/<swarm-id>-h2/...`. No link falls back to another run. While a swarm
95
+ is running or finishing, project pages show a pointer to the same page in the swarm, since its
96
+ hordes write their own ledgers and leave the project ones untouched.
97
+
79
98
  | page | shows |
80
99
  |------|-------|
81
- | `/` | every run: active marker, start, count, model, outcome, prompt, files present |
100
+ | `/` | every swarm and run: state (active, finalizing, pending, finished, not started), start, count, model, outcome, prompt, files present |
82
101
  | `/runs/<id>` | prompt, outcome, canary, clean-stop countdown, plan layers, members table, run files |
83
102
  | `/runs/<id>/members/<m>` | live activity, last and final text, log tail (`?tail=<n>`), transcript link |
84
103
  | `/runs/<id>/members/<m>/log` | the member log; `tail=0` is the whole file (1 MiB cap) |
@@ -88,16 +107,27 @@ at (`-dirty` when the tree has uncommitted changes), linked to the commit on Git
88
107
  | `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
89
108
  | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
90
109
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
91
- | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them; `/resources` also lists who cited each resource in the active run, as REPORT.md counts reuse |
110
+ | `/runs/<id>/ideas`, `board`, `findings`, `claims`, `facts`, `resources` | the run's ledgers: a horde's own, or for a single run the project ledgers with board and findings narrowed to the run and facts split into this run and carried; `?view=clusters`, `?owner=<m>`, `?order=oldest` |
111
+ | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the project ledgers across every single run; `/resources` also lists who cited each resource in a live single run, as REPORT.md counts reuse |
112
+ | `/swarms/<id>` | one row per horde (state, outcome, members, findings, accepted artifacts, cost, workstream), the partition, the merge state, swarm files |
113
+ | `/swarms/<id>/ideas`, `board`, `findings`, `claims`, `facts`, `resources`, `collaboration` | every horde's ledger, or its acceptance, requests and reviews, on one page under a per-horde heading; ids repeat per horde, and each horde's event audit stays on its run page |
114
+ | `/swarms/<id>/graph` | one lane per horde with its phase marks, and the merge step |
115
+ | `/swarms/<id>/files/<NAME>` | `SWARM`, `REPORT`, `DELIVERABLE`; 404 until written |
92
116
  | `/export` | confirms the export and starts the download of `/export.zip` (`?back=<path>` names the page to return to); no JSON twin |
93
117
  | `/export.zip` | the whole dashboard as static HTML (`application/zip`, streamed, no JSON twin); see below |
94
118
 
119
+ A page auto-refreshes while what it shows can change: a run's pages while the run is live or
120
+ finalizing, a swarm's while the swarm is in memory, the project ledgers while a single run is
121
+ live, and the index always. A swarm and its hordes show as `pending` from the moment their directories
122
+ are allocated until the hordes have a TASK.md, which covers the minutes the planner takes, and as
123
+ `not started` when the planner could not partition the task.
124
+
95
125
  The download icon in the top-right corner of every page opens a warning that the export may
96
126
  leak sensitive data from this machine. Its download link goes to `/export`, which closes the
97
127
  warning, explains the wait, and starts the download of `/export.zip`. The zip holds one
98
128
  folder, `horde-dashboard-<timestamp>/`, whose `index.html` opens the dashboard from disk: every
99
129
  run, member, transcript page and entry, log, run document, collaboration page, graph (each width,
100
- plus the Mermaid file), and the ledgers under each run filter, with links rewritten relative to
130
+ plus the Mermaid file), and the ledgers of every scope, with links rewritten relative to
101
131
  each other. Exported pages do not refresh, expandable entries use the browser's own disclosure,
102
132
  finding bodies are shown inline, transcripts are paged fifty entries at a time, and the export
103
133
  control itself is absent. Pages that fail to load are exported as their error page, so no link
@@ -134,11 +164,17 @@ the session files, so they show end cause, turns, and usage but no activity.
134
164
  SUMMARY.md REPORT.md per-member status and usage; the reconciled run report
135
165
  ANSWER.md DELIVERABLE.md POSTMORTEM.md solved answer; synthesis output; reviewer critique
136
166
  GRAPH.md Mermaid graph of the run, written last at finalize
167
+ swarms/<swarm-id>/
168
+ SWARM.md SWARM.json prompt, partition, horde list; the swarm's timeline for the dashboard
169
+ planner-sessions/ deliverable-sessions/ planner and merge helper sessions
170
+ REPORT.md DELIVERABLE.md per-horde totals and file list; the merged deliverable
171
+ h1/ h2/ ... one complete horde directory per horde: its own ledgers,
172
+ .lock and runs/<swarm-id>-hN/ with everything a run has
137
173
  ```
138
174
 
139
175
  Each run appends a `## Run <id>` heading to BOARD.md and FINDINGS.md that cites `TASK.md` by
140
- sha256. `runs/` is gitignored inside `.horde/`; add `.horde/` to the project `.gitignore` to keep
141
- the ledgers out of git too.
176
+ sha256. `runs/` and `swarms/` are gitignored inside `.horde/`; add `.horde/` to the project
177
+ `.gitignore` to keep the ledgers out of git too.
142
178
 
143
179
  ## The `horde` tool
144
180
 
@@ -196,9 +232,12 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
196
232
  other member running, or no validation within `PI_HORDE_SOLVED_CONFIRM_MS`, an uncontested
197
233
  proposal commits as `solved-unvalidated`. Findings no other member reproduced hold wind-down
198
234
  while other members run, unless `PI_HORDE_ALLOW_UNVERIFIED=1`. A proposal pins the revision of
199
- every finding it cites. An answer that cites a superseded F# or quotes a provisional T# is
200
- refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
201
- superseded or amended later is withdrawn (`solved-withdrawn`) for re-proposal. A contest that names idea ids reopens the proposal under a new revision
235
+ every finding and artifact it cites. An answer that cites a superseded F# or quotes a provisional
236
+ T# is refused at `solved` and at `confirm-solved`; a pending proposal whose cited finding is
237
+ superseded or amended later, or whose cited A# is republished, is withdrawn (`solved-withdrawn`)
238
+ for re-proposal, and ANSWER.md's status says so instead of keeping an earlier validation. At
239
+ finalize, ANSWER.md's status names every citation that changed after the proposal pinned it. A
240
+ contest that names idea ids reopens the proposal under a new revision
202
241
  (`solved-reopened`) once every named idea is done or ruled out, with the citations it pinned;
203
242
  if a cited finding changed or a quoted fact is still provisional by then, it is withdrawn instead. Repair serves a request about
204
243
  the pending answer (`target=answer`) with the blockers.
@@ -219,7 +258,10 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
219
258
  still `respond` to a deferred request, and the requester then resolves it. A challenger can
220
259
  withdraw its own blocking challenge on an F# or A# before any response by recording a
221
260
  supporting review of the current revision and resolving with evidence on it; limits that
222
- review discloses remain acceptance debt. Acceptance names an open blocker as `blocked by Q#`.
261
+ review discloses remain acceptance debt. A blocking request about the sender's own work (a
262
+ re-review after a correction) resolves on the recipient's supporting review of the current
263
+ revision, full-scope for an A#, since the owner may not review it. Acceptance names an open
264
+ blocker as `blocked by Q#`.
223
265
  - **Verifier.** The verifier's idea claims are refused while other members' findings await its
224
266
  review at their current revision; the pending F# list follows every review, reattest and board
225
267
  post. A verifier whose session closes with critical or high-importance findings unreviewed is
@@ -263,6 +305,46 @@ Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run
263
305
  `stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
264
306
  a later proposal was validated too.
265
307
 
308
+ ## Horde of hordes
309
+
310
+ `H/M` launches a swarm: H concurrent hordes of M members on one task, H at most 4. A plain `M` is
311
+ a swarm of one and keeps the layout above; a larger swarm gets `.horde/swarms/<swarm-id>/` with one
312
+ complete horde directory per horde (`h1/`, `h2/`, ...), each with its own ledgers, lock and
313
+ `runs/<swarm-id>-hN/`. Hordes therefore never share a lock, a dedup pass or a coverage watermark,
314
+ and run ids stay unique across the swarm.
315
+
316
+ **Partition.** A plain prompt is split before launch by a read-only planner helper (`pi -p`, the
317
+ parent's model, `PI_HORDE_PLANNER_MS` timeout) into exactly H disjoint workstreams with one
318
+ acceptance criterion each, recorded in `SWARM.md` and in each horde's `TASK.md`; every member of a
319
+ horde is told its workstream and to work only on it. If the planner cannot produce H distinct
320
+ workstreams the launch stops with that message, the swarm directory keeps the planner session and
321
+ a `SWARM.md` saying why, and the slot stays free. `plan H/M a; b; c; ...` skips the planner: the
322
+ layers are dealt round-robin to the hordes (layer 1 to h1, layer 2 to h2, ...) and each horde runs
323
+ its share as its own plan, so there must be at least H layers.
324
+
325
+ **Sharing.** Shared read, local write. Each member's system prompt lists the sibling horde
326
+ directories; `horde facts` and `horde resources` append every sibling's entries tagged `[hN]`, and
327
+ `horde list` appends their done and ruled-out ideas, so a fact, a rule-out or a lab another horde
328
+ recorded is not re-derived. Open sibling ideas are not shown and nothing is written to a sibling:
329
+ the lock, dedup and coverage rules keep their per-horde scope. Members reuse a sibling's resource
330
+ by its path, not its R#.
331
+
332
+ **Lifecycle.** The hordes start `PI_HORDE_SWARM_STAGGER_MS` (default 5000) apart so H canaries do
333
+ not hit the provider at once; each then runs the lifecycle above unchanged, with its own canary,
334
+ resets, repair, acceptance, deliverable and post-mortem. `--until-stop` applies to every horde.
335
+ Controls take horde targets: `stop-clean h2` winds one horde down, `pause h2:member-3` one member,
336
+ `stop-hard` every horde, and `status` prints one block per horde. The swarm slot frees once every
337
+ horde has written its REPORT.md; after the last horde's finalize a synthesis helper reads the
338
+ hordes' DELIVERABLE.md, REPORT.md and ACCEPTANCE.md and writes the swarm `DELIVERABLE.md`, keeping
339
+ each claim attributed to its horde and to that horde's acceptance label, and the swarm `REPORT.md`
340
+ with per-horde members, turns, tokens, cost, findings and outcome. One swarm report is injected into
341
+ the parent conversation instead of one per horde. No horde reviews another's work; a verification
342
+ pass over the merged deliverable is not built.
343
+
344
+ **Dashboard and widget.** Each swarm has its own scope in the dashboard, with horde tabs and
345
+ swarm-wide ledger, collaboration and graph pages; see [Dashboard](#dashboard). The widget shows one
346
+ line per horde.
347
+
266
348
  ## Finding metadata
267
349
 
268
350
  `importance` (critical, high, medium, low, info), `confidence` (high, medium, low), and
@@ -281,7 +363,9 @@ for an abnormal end after measurable work; distinct finding clusters with review
281
363
  superseded findings in their own section, and any resets; claim states; this run's facts split
282
364
  from carried context; resources and cleanup results; coverage (released and still-open ideas,
283
365
  the solved answer); late board posts; and consistency warnings for missing metadata,
284
- mixed-concern clusters, unknown R# citations, and cited paths that do not exist. A killed
366
+ mixed-concern clusters, unknown R# citations, and cited paths that do not exist (rooted URL paths
367
+ and templates with a date or placeholder slot, such as `logs/gate-YYYY-MM-DD.jsonl` or
368
+ `out/<id>.json`, are not checked). A killed
285
369
  member's summary is reconstructed from its last board post. The message injected into the parent
286
370
  carries a one-line diagnosis and, when generated, the post-mortem text.
287
371
 
@@ -295,6 +379,8 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
295
379
  | `PI_HORDE_CANARY_MS` | 60000 | time for member-1 to produce output before a retry |
296
380
  | `PI_HORDE_STALL_MS` | 1800000 | reset a member with no ledger write for this long; `0` disables |
297
381
  | `PI_HORDE_RESET_CAP` | 2 | resets per member |
382
+ | `PI_HORDE_SWARM_STAGGER_MS` | 5000 | pause between one horde's launch and the next in a swarm |
383
+ | `PI_HORDE_PLANNER_MS` | 300000 | planner helper timeout when a swarm partitions a plain prompt |
298
384
  | `PI_HORDE_ACTIVE_GRACE_MS` | 15000 | one clean-stop extension for a member still producing output |
299
385
  | `PI_HORDE_SOLVED_CONFIRM_MS` | 120000 | wait for a second member to validate a solve |
300
386
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
@@ -337,6 +423,7 @@ build-info.ts the running package's version and checkout commit, for the
337
423
  collaboration-report.ts acceptance checks, frozen snapshots, collaboration metrics
338
424
  run-graph.ts events.jsonl projected into lanes, phases, nodes, and edges with dashboard links
339
425
  run-graph-svg.ts the swimlane SVG and its legend; run-graph-mermaid.ts the Mermaid export and GRAPH.md
426
+ swarm-graph.ts a swarm's hordes as lanes of lifecycle marks, plus the merge step
340
427
 
341
428
  ledger/ one module per .horde/ document, all writes under the .horde lock
342
429
  files.ts paths, init, atomic writes, shared entry formatting
@@ -361,10 +448,13 @@ tool/ the member-side `horde` tool
361
448
  resource-contract.ts resource generations, readiness, leases, and port coordination
362
449
 
363
450
  run/ the parent-side run lifecycle, sharing one HordeState
364
- types.ts HordeRun, HordeState, ReportDetails
365
- command.ts /agent-horde dispatch and session shutdown
451
+ types.ts HordeRun, Swarm, HordeState, ReportDetails
452
+ command.ts /agent-horde dispatch, horde-prefixed targets, session shutdown
366
453
  setup.ts run directory, TASK.md, roles, plan seeding
367
- launch.ts dashboard, deadline, canary preflight, fan-out
454
+ swarm.ts swarm directories and ids, dealing plan layers, planner output parsing
455
+ swarm-finalize.ts the swarm slot, the merge step, SWARM.json
456
+ swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
457
+ launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
368
458
  supervise.ts member spawn, reset on exit, stall detection
369
459
  schedule.ts artifact review assignment and superseded-dependency handoffs
370
460
  repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
@@ -372,7 +462,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
372
462
  controls.ts stop-hard, pause and resume, clean stop and its deadline
373
463
  finalize.ts reconcile, cleanups, SUMMARY.md and REPORT.md, report message, GRAPH.md
374
464
  lifecycle.ts parent lifecycle events in events.jsonl
375
- synthesis.ts deliverable and post-mortem helper agents
465
+ synthesis.ts deliverable, post-mortem, swarm merge and planner helper agents
376
466
  diagnosis.ts one-line run diagnosis
377
467
  ui.ts widget mount, status line, report renderer, notifications
378
468
  completions.ts command argument completions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/args.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  export const MAX_MEMBERS = 12;
2
2
 
3
+ /** Hordes per swarm; `H/M` launches H hordes of M members. */
4
+ export const MAX_HORDES = 4;
5
+
3
6
  export const USAGE = [
4
7
  "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
5
- " spawn <count> members with the same prompt",
8
+ " spawn <count> members with the same prompt;",
9
+ " <count> may be H/M: H hordes of M members, the",
10
+ " task partitioned between the hordes by a planner",
6
11
  " /agent-horde status [target] show the active run",
7
12
  " /agent-horde stop-hard [target] terminate members now (SIGTERM, then SIGKILL)",
8
13
  " /agent-horde stop-clean [target] let members wrap up, then reconcile their ideas",
@@ -11,11 +16,13 @@ export const USAGE = [
11
16
  " /agent-horde pause [target] suspend members (SIGSTOP)",
12
17
  " /agent-horde resume [target] resume suspended members (SIGCONT)",
13
18
  ` /agent-horde plan [--dashboard] <count> <a; b; ...>`,
14
- " partition members across workstreams",
19
+ " partition members across workstreams; with H/M",
20
+ " the layers go round-robin to the hordes",
15
21
  " /agent-horde help show this text",
16
22
  "",
17
- "target is 'all' (default), 'member-N', 'N', or a comma/space separated list.",
18
- `count is a whole number from 1 to ${MAX_MEMBERS}`,
23
+ "target is 'all' (default), 'member-N', 'N', 'hN' (one horde), 'hN:member-M', 'hN:M', or a",
24
+ "comma/space separated list; a bare member names that member in every horde.",
25
+ `count is a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES}`,
19
26
  "--dashboard serves a read-only localhost dashboard (default port 7433, or",
20
27
  "PI_HORDE_DASHBOARD_PORT); --dashboard=<port> picks the port.",
21
28
  "--no-verifier skips the default verifier role that runs of 3 or more members assign",
@@ -25,10 +32,18 @@ export const USAGE = [
25
32
  "session told to add new ideas.",
26
33
  ].join("\n");
27
34
 
28
- /** Which members a control command applies to. */
35
+ /** One token of a control target: a horde, a member in every horde, or a member of one horde. */
36
+ export interface TargetItem {
37
+ /** 1-based horde index (`h2`); absent for every horde. */
38
+ horde?: number;
39
+ /** `member-N`; absent for every member of the horde. */
40
+ member?: string;
41
+ }
42
+
43
+ /** Which hordes and members a control command applies to. */
29
44
  export type HordeTarget =
30
45
  | { kind: "all" }
31
- | { kind: "members"; names: string[] };
46
+ | { kind: "selection"; items: TargetItem[] };
32
47
 
33
48
  type HordeStopMode = "hard" | "clean";
34
49
 
@@ -46,6 +61,9 @@ type HordeArgs =
46
61
  | { kind: "resume"; target: HordeTarget }
47
62
  | {
48
63
  kind: "run";
64
+ /** Concurrent hordes; `12` is one horde, `3/12` three. */
65
+ hordes: number;
66
+ /** Members per horde. */
49
67
  count: number;
50
68
  prompt: string;
51
69
  plan?: string[] | undefined;
@@ -58,22 +76,51 @@ type HordeArgs =
58
76
 
59
77
  export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
60
78
 
79
+ const HORDE_TOKEN = /^h(\d+)$/i;
80
+ const MEMBER_TOKEN = /^(?:member-)?(\d+)$/i;
81
+
82
+ function memberName(token: string): string | undefined {
83
+ const match = MEMBER_TOKEN.exec(token);
84
+ if (!match) return undefined;
85
+ const index = Number(match[1]);
86
+ return index >= 1 && index <= MAX_MEMBERS ? `member-${index}` : undefined;
87
+ }
88
+
89
+ function hordeIndex(token: string): number | undefined {
90
+ const match = HORDE_TOKEN.exec(token);
91
+ if (!match) return undefined;
92
+ const index = Number(match[1]);
93
+ return index >= 1 && index <= MAX_HORDES ? index : undefined;
94
+ }
95
+
96
+ /** `h2`, `h2:member-3`, `h2:3`, `member-3`, or `3`; undefined for anything else. */
97
+ function parseTargetItem(token: string): TargetItem | undefined {
98
+ const colon = token.indexOf(":");
99
+ if (colon === -1) {
100
+ const horde = hordeIndex(token);
101
+ if (horde !== undefined) return { horde };
102
+ const member = memberName(token);
103
+ return member === undefined ? undefined : { member };
104
+ }
105
+ const horde = hordeIndex(token.slice(0, colon));
106
+ const member = memberName(token.slice(colon + 1));
107
+ return horde === undefined || member === undefined ? undefined : { horde, member };
108
+ }
109
+
61
110
  function parseTargets(raw: string): HordeTarget | undefined {
62
111
  const trimmed = raw.trim();
63
112
  if (trimmed === "") return { kind: "all" };
64
- const names: string[] = [];
113
+ const items: TargetItem[] = [];
65
114
  for (const token of trimmed.split(/[\s,]+/).filter(Boolean)) {
66
115
  if (token.toLowerCase() === "all") return { kind: "all" };
67
- const match = /^(?:member-)?(\d+)$/i.exec(token);
68
- if (!match) return undefined;
69
- const index = Number(match[1]);
70
- if (index < 1 || index > MAX_MEMBERS) return undefined;
71
- names.push(`member-${index}`);
116
+ const item = parseTargetItem(token);
117
+ if (!item) return undefined;
118
+ items.push(item);
72
119
  }
73
- return names.length > 0 ? { kind: "members", names } : { kind: "all" };
120
+ return items.length > 0 ? { kind: "selection", items } : { kind: "all" };
74
121
  }
75
122
 
76
- const TARGET_ERROR = "invalid target; use all, member-N, or N";
123
+ const TARGET_ERROR = "invalid target; use all, member-N, N, hN, or hN:member-M";
77
124
 
78
125
  function parseStopRest(
79
126
  rest: string,
@@ -166,24 +213,31 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
166
213
  };
167
214
  }
168
215
 
169
- /** Parse `<count> [flags] <tail>`. */
170
- function parseCountedRun(
171
- text: string,
172
- leading: LaunchFlags,
173
- ): { count: number; rest: string; flags: LaunchFlags } | { error: string } {
174
- const match = /^(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
175
- if (!match) return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
176
- const count = Number(match[1]);
177
- if (count < 1 || count > MAX_MEMBERS) {
178
- return { error: `count must be a whole number from 1 to ${MAX_MEMBERS}` };
179
- }
180
- const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[2] ?? ""));
216
+ const COUNT_ERROR =
217
+ `count must be a whole number from 1 to ${MAX_MEMBERS}, or H/M with H from 1 to ${MAX_HORDES} hordes`;
218
+
219
+ interface CountedRun {
220
+ hordes: number;
221
+ count: number;
222
+ rest: string;
223
+ flags: LaunchFlags;
224
+ }
225
+
226
+ /** Parse `<count> [flags] <tail>`, where count is `M` or `H/M`. */
227
+ function parseCountedRun(text: string, leading: LaunchFlags): CountedRun | { error: string } {
228
+ const match = /^(?:(\d+)\/)?(\d+)(?:\s+([\s\S]*))?$/.exec(text.trim());
229
+ if (!match) return { error: COUNT_ERROR };
230
+ const hordes = match[1] === undefined ? 1 : Number(match[1]);
231
+ const count = Number(match[2]);
232
+ if (count < 1 || count > MAX_MEMBERS || hordes < 1 || hordes > MAX_HORDES) return { error: COUNT_ERROR };
233
+ const flags = mergeLaunchFlags(leading, takeLaunchFlags(match[3] ?? ""));
181
234
  if (flags.error) return { error: flags.error };
182
- return { count, rest: flags.rest, flags };
235
+ return { hordes, count, rest: flags.rest, flags };
183
236
  }
184
237
 
185
- function runArgs(count: number, prompt: string, flags: LaunchFlags, plan?: string[]): HordeRunArgs {
186
- const args: HordeRunArgs = { kind: "run", count, prompt };
238
+ function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunArgs {
239
+ const { hordes, count, flags } = parsed;
240
+ const args: HordeRunArgs = { kind: "run", hordes, count, prompt };
187
241
  if (plan) args.plan = plan;
188
242
  if (flags.dashboard) args.dashboard = flags.dashboard;
189
243
  if (flags.noVerifier) args.noVerifier = true;
@@ -191,6 +245,26 @@ function runArgs(count: number, prompt: string, flags: LaunchFlags, plan?: strin
191
245
  return args;
192
246
  }
193
247
 
248
+ function parsePlan(rest: string, leading: LaunchFlags): HordeArgs {
249
+ const afterPlan = takeLaunchFlags(rest);
250
+ if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
251
+ const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
252
+ if ("error" in parsed) {
253
+ return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
254
+ }
255
+ const layers = parsed.rest
256
+ .split(/[;\n]/)
257
+ .map((layer) => layer.trim())
258
+ .filter(Boolean);
259
+ if (layers.length < 2) {
260
+ return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
261
+ }
262
+ if (layers.length < parsed.hordes) {
263
+ return { kind: "usage", error: `plan with ${parsed.hordes} hordes needs at least ${parsed.hordes} layers` };
264
+ }
265
+ return runArgs(parsed, layers.join("; "), layers);
266
+ }
267
+
194
268
  export function parseHordeArgs(raw: string): HordeArgs {
195
269
  const leading = takeLaunchFlags(raw);
196
270
  if (leading.error) return { kind: "usage", error: leading.error };
@@ -203,7 +277,7 @@ export function parseHordeArgs(raw: string): HordeArgs {
203
277
  const space = trimmed.search(/\s/);
204
278
  const head = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
205
279
  const rest = space === -1 ? "" : trimmed.slice(space + 1);
206
- if (hasFlags && head !== "plan" && !/^\d+$/.test(head)) {
280
+ if (hasFlags && head !== "plan" && !/^\d+(?:\/\d+)?$/.test(head)) {
207
281
  return { kind: "usage", error: "launch flags only apply when starting a run" };
208
282
  }
209
283
 
@@ -232,22 +306,8 @@ export function parseHordeArgs(raw: string): HordeArgs {
232
306
  const target = parseTargets(rest);
233
307
  return target ? { kind: "pause", target } : { kind: "usage", error: TARGET_ERROR };
234
308
  }
235
- case "plan": {
236
- const afterPlan = takeLaunchFlags(rest);
237
- if (afterPlan.error) return { kind: "usage", error: afterPlan.error };
238
- const parsed = parseCountedRun(afterPlan.rest, mergeLaunchFlags(leading, afterPlan));
239
- if ("error" in parsed) {
240
- return { kind: "usage", error: "plan usage: /agent-horde plan <count> <layer1; layer2; ...>" };
241
- }
242
- const layers = parsed.rest
243
- .split(/[;\n]/)
244
- .map((layer) => layer.trim())
245
- .filter(Boolean);
246
- if (layers.length < 2) {
247
- return { kind: "usage", error: "plan needs at least 2 layers separated by ';'" };
248
- }
249
- return runArgs(parsed.count, layers.join("; "), parsed.flags, layers);
250
- }
309
+ case "plan":
310
+ return parsePlan(rest, leading);
251
311
  case "resume": {
252
312
  const target = parseTargets(rest);
253
313
  return target ? { kind: "resume", target } : { kind: "usage", error: TARGET_ERROR };
@@ -257,5 +317,5 @@ export function parseHordeArgs(raw: string): HordeArgs {
257
317
  const parsed = parseCountedRun(trimmed, leading);
258
318
  if ("error" in parsed) return { kind: "usage", error: parsed.error };
259
319
  if (parsed.rest === "") return { kind: "usage", error: "prompt is required" };
260
- return runArgs(parsed.count, parsed.rest, parsed.flags);
320
+ return runArgs(parsed, parsed.rest);
261
321
  }
package/src/citations.ts CHANGED
@@ -32,6 +32,12 @@ const DATA_EXT =
32
32
  /** Path roots that are worth checking even without a file extension. */
33
33
  const RUN_ROOT = /^(?:\.horde|\.tmp|runs|artifacts)(?:[/-]|$)/;
34
34
 
35
+ /**
36
+ * A path with a date or placeholder slot (`logs/gate-YYYY-MM-DD.jsonl`, `out/<id>.json`,
37
+ * `%Y%m%d.log`) names a pattern the code writes, not one file that must exist.
38
+ */
39
+ const PATH_TEMPLATE = /(?:^|[^A-Za-z])(?:YYYY|YYYYMMDD|yyyy|yyyymmdd)(?:[^A-Za-z]|$)|<[^<>]+>|%[YmdHMS]/;
40
+
35
41
  /** Host-anchored references such as docs.example.com/page are URLs, not local paths. */
36
42
  const HOST_PATH = /^(?:[a-z0-9-]+\.)+[a-z]{2,}\//i;
37
43
 
@@ -57,6 +63,7 @@ export function citedDataFiles(text: string): string[] {
57
63
  if (ref === "") return;
58
64
  if (ref.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return;
59
65
  if (/[{}*$]/.test(ref)) return;
66
+ if (PATH_TEMPLATE.test(ref)) return;
60
67
  if (HOST_PATH.test(ref)) return;
61
68
  const isRooted = RUN_ROOT.test(ref);
62
69
  // Under a run root, any extension is a member artifact worth checking. Off a
@@ -66,7 +73,9 @@ export function citedDataFiles(text: string): string[] {
66
73
  out.add(ref);
67
74
  };
68
75
  for (const match of text.matchAll(/`([^`\s]+)`/g)) consider(match[1] ?? "");
69
- for (const match of text.matchAll(/(?<![\w:/.])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
76
+ // A match never starts inside a hyphenated word: `/standalone-client-gate/logs` is one URL path,
77
+ // not a relative `client-gate/logs` with its leading slash cut off.
78
+ for (const match of text.matchAll(/(?<![\w:/.-])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+(?:\.[A-Za-z0-9]{1,6})?/g)) {
70
79
  consider(match[0] ?? "", text[(match.index ?? 0) + match[0].length] ?? "");
71
80
  }
72
81
  for (const match of text.matchAll(/(?<![\w/.-])([\w-]+\.[A-Za-z0-9]{1,6})\b/g)) consider(match[1] ?? "");
@@ -95,14 +104,16 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
95
104
 
96
105
  /**
97
106
  * True when a cited file cannot be resolved to an existing file on disk. Paths
98
- * are tried against the repo root, the run directory, its artifacts directory,
99
- * and the directory of the citing file. Because generated artifacts usually cite
100
- * retained files by a relative name, and a deliverable cites the reviewed project's
107
+ * are tried against the repo root, the horde directory (where `runs/<id>/...`
108
+ * references resolve, in a single run and in a swarm horde alike), the run
109
+ * directory, its artifacts directory, and the directory of the citing file; a
110
+ * path without a file extension may name a directory. Because generated artifacts
111
+ * usually cite retained files by a relative name, and a deliverable cites the reviewed project's
101
112
  * own files by bare name, a basename match in this run's tree or in the project tree
102
113
  * also counts, which keeps the warning precise instead of noisy.
103
114
  */
104
115
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
105
- const roots = [run.cwd, run.runDir, path.join(run.runDir, "artifacts")];
116
+ const roots = [run.cwd, run.hordeDir, run.runDir, path.join(run.runDir, "artifacts")];
106
117
  if (baseDir) roots.push(baseDir);
107
118
  const mustBeFile = FILE_EXT.test(ref);
108
119
  for (const root of roots) {