agent-horde 0.1.4 → 0.1.6

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
@@ -88,7 +88,7 @@ pi install npm:agent-horde
88
88
  Or from the repository, pinned to a tag:
89
89
 
90
90
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.4
91
+ pi install git:github.com/eliyastein/agent-horde@v0.1.6
92
92
  ```
93
93
 
94
94
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
@@ -122,13 +122,23 @@ When you already know how the work splits, use `plan` to fix the partition at la
122
122
  Each layer becomes a seeded idea owned by one member, recorded in the run's `PLAN.md`, and that
123
123
  member works only its layer. Layers are assigned round-robin, so more layers than members gives
124
124
  some members two. Members without a layer become verifiers, and an unpartitioned run of three or
125
- more members makes its last member the verifier. `plan` suits "analyse each X" tasks; split design
126
- work by output type, such as spec, prototype, and generator, rather than by theme.
125
+ more members makes its last member the verifier (`--no-verifier` turns that off). `plan` suits
126
+ "analyse each X" tasks; split design work by output type, such as spec, prototype, and generator,
127
+ rather than by theme.
128
+
129
+ A run normally winds down once a validated answer exists and every idea is closed. `--until-stop`
130
+ keeps the whole horde working until you stop it: a validated answer is banked as the current one
131
+ and the proposal slot reopens, and a member whose session finishes gets a fresh session told to
132
+ extend, stress-test, or deepen the work.
133
+
134
+ ```
135
+ /agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
136
+ ```
127
137
 
128
138
  | command | effect |
129
139
  |---|---|
130
140
  | `/agent-horde status` | the active run and each member |
131
- | `/agent-horde stop-clean [postmortem]` | members wrap up, then the run finalizes |
141
+ | `/agent-horde stop-clean [postmortem]` | members wrap up, then the run finalizes; this is how an `--until-stop` run ends |
132
142
  | `/agent-horde stop-hard` | terminate members now |
133
143
  | `/agent-horde pause`, `resume` | suspend and resume member processes |
134
144
  | `/agent-horde help` | every flag and target |
@@ -172,11 +182,13 @@ die, runs the repair phase, freezes acceptance, and writes the report.
172
182
  - Ideas, findings, facts, and resources are deduplicated on identifiers first, then wording; a
173
183
  duplicate returns the existing id and records a corroborator.
174
184
  - Every finding names a concern slug; a look-alike of an existing slug is refused.
175
- - A review records the revision it checked, its scope, a verdict, and evidence. Runnable
176
- artifacts need a tested or reproduced review from a non-owner; documents need a sourced or
177
- derived one.
185
+ - A review records the revision it checked, its scope, a verdict, evidence, and any limits
186
+ outside the target's criteria. Runnable artifacts need a tested or reproduced review from a
187
+ non-owner; documents need a sourced or derived one. A reviewer can carry its review forward to
188
+ an unchanged revision instead of redoing it.
178
189
  - Solved is two-phase: one member proposes, a different member validates, and the run winds down
179
- only when every idea it created is closed.
190
+ only when every idea it created is closed. Under `--until-stop` the answer is banked instead
191
+ and the horde keeps going until `stop-clean` or `stop-hard`.
180
192
  - Peer messages, reviews, and evidence enter a member's context as data. They cannot authorize
181
193
  tools or override the task.
182
194
 
@@ -192,7 +204,9 @@ ledgers, the collaboration threads with their review scope and acceptance state,
192
204
  audit, and a graph of the run. The graph is a swimlane, one lane per member, with every event
193
205
  linked to the page that shows it, so a run reads left to right: who claimed what, which findings
194
206
  landed under which ideas, who reviewed whom, and where actions were rejected. It is server-rendered
195
- SVG/HTML with no scripts.
207
+ SVG/HTML with no scripts. The download icon on every page exports the whole dashboard as a zip of
208
+ static pages that opens from disk, after a warning that it may carry sensitive data from the machine.
209
+ Every page's footer names the agent-horde version and, from a checkout, the commit it runs at.
196
210
 
197
211
  ## Benchmark: FinanceBench
198
212
 
package/SECURITY.md CHANGED
@@ -32,6 +32,14 @@ writes, rejects requests whose `Host` header is not localhost, sends a `default-
32
32
  security policy, and renders no scripts. It still exposes everything under `.horde/` to any process
33
33
  or browser tab on the machine, member transcripts included. Do not forward or proxy the port.
34
34
 
35
+ `/export.zip` packs every dashboard page, transcripts and logs included, into one archive meant
36
+ to be shared. The export control warns before the download, but the archive carries whatever the
37
+ members read or produced: file contents, command output, paths, and any credentials they met.
38
+ Review it before passing it on. Building it costs seconds of work, so the dashboard runs one
39
+ export at a time and refuses to start one for a request the browser marks as cross-site; a page
40
+ on another origin cannot read the dashboard, but it can otherwise make a browser request its
41
+ localhost port.
42
+
35
43
  ## Supply chain
36
44
 
37
45
  The package has no runtime dependencies beyond the packages pi itself provides to extensions.
package/docs/REFERENCE.md CHANGED
@@ -7,7 +7,7 @@ at one problem beat a single linear one: research, audits, design exploration, R
7
7
  ## Usage
8
8
 
9
9
  ```
10
- /agent-horde [--dashboard[=<port>]] [--no-verifier] <count> <prompt>
10
+ /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>
11
11
  /agent-horde plan [--dashboard] <count> <layer; layer; ...>
12
12
  /agent-horde status [target]
13
13
  /agent-horde stop-hard [target] SIGTERM, SIGKILL after 5s (alias: stop)
@@ -72,7 +72,9 @@ own when the parent is gone, even after a SIGKILL or OOM kill.
72
72
  HEAD, never writes to `.horde/`, and stays up after the run until the session ends. A busy port
73
73
  refuses the run before any run directory is created. Live pages reload every three seconds
74
74
  (`?refresh=0` disables); expandable entries are URL-backed, so they stay open across refreshes.
75
- Every page has a JSON twin under `/api/<same path>`.
75
+ Every page has a JSON twin under `/api/<same path>`. The footer of every page names the
76
+ agent-horde version from its package.json and, when it runs from a git checkout, the commit it is
77
+ at (`-dirty` when the tree has uncommitted changes), linked to the commit on GitHub.
76
78
 
77
79
  | page | shows |
78
80
  |------|-------|
@@ -87,6 +89,24 @@ Every page has a JSON twin under `/api/<same path>`.
87
89
  | `/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 |
88
90
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
89
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 |
92
+ | `/export` | confirms the export and starts the download of `/export.zip` (`?back=<path>` names the page to return to); no JSON twin |
93
+ | `/export.zip` | the whole dashboard as static HTML (`application/zip`, streamed, no JSON twin); see below |
94
+
95
+ The download icon in the top-right corner of every page opens a warning that the export may
96
+ leak sensitive data from this machine. Its download link goes to `/export`, which closes the
97
+ warning, explains the wait, and starts the download of `/export.zip`. The zip holds one
98
+ folder, `horde-dashboard-<timestamp>/`, whose `index.html` opens the dashboard from disk: every
99
+ 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
101
+ each other. Exported pages do not refresh, expandable entries use the browser's own disclosure,
102
+ finding bodies are shown inline, transcripts are paged fifty entries at a time, and the export
103
+ control itself is absent. Pages that fail to load are exported as their error page, so no link
104
+ dangles. The export is built when requested and has no cache. It is streamed one page per
105
+ event-loop turn, so the download appears in the browser at once and grows while the pages are
106
+ packed, and the run is not held up; a dashboard with many long transcripts takes a few seconds
107
+ and the archive can run to hundreds of megabytes. One export runs at a time (a second request
108
+ gets 503), and requests a browser marks as coming from another site (`Sec-Fetch-Site:
109
+ cross-site`) get 403.
90
110
 
91
111
  Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
92
112
  the session files, so they show end cause, turns, and usage but no activity.
@@ -136,17 +156,19 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
136
156
  | `list`, `findings`, `facts`, `claims`, `resources`, `concerns` | print a ledger |
137
157
  | `post` | append a message to BOARD.md |
138
158
  | `idea` | add an open checklist item; a near-duplicate returns the existing id |
139
- | `claim`, `done`, `drop`, `rule-out` | take, finish, release, or close idea #id |
159
+ | `claim`, `done`, `drop`, `rule-out` | take, finish, release, or close idea #id; `done` with a confirmed-negative result line is refused in favour of `rule-out` |
140
160
  | `finding` | file under a claimed `idea` with a `concern` and `support`; `importance` and `confidence` are optional; `deltaOf: F#` adds distinct evidence, `supersedes: F#` corrects |
141
161
  | `verify` | record your own reproduction of F#id with the support level you reached |
142
- | `fact` | record a checkable fact; `deltaOf: T#` extends one |
162
+ | `amend` | rewrite the body prose of your own F#id (`text`) with a `note` saying why; the revision changes, reviews stay pinned to what they checked, earlier verification notes are kept as history of the previous revision, and the legacy `verified=` label and corroborators are dropped |
163
+ | `fact` | record a checkable fact; `deltaOf: T#` extends one; a fact about what an open idea or contested claim is still auditing is recorded as provisional on it |
143
164
  | `assert`, `confirm`, `contest`, `retract`, `supersede` | create or move claim C#id |
144
165
  | `resource` | claim a shared dataset, harness, corpus, or sandbox by name, with an optional `cleanup` command |
145
166
  | `solved`, `confirm-solved`, `contest-solved` | propose, validate, or refute the goal answer |
146
167
  | `message`, `inbox`, `ack`, `respond`, `resolve`, `defer` | targeted threads: question, blocker, review-request, challenge, handoff, decision |
147
168
  | `attach` | add supporting, contradicting, or extending evidence to a target |
148
- | `review` | record a revision-pinned check with explicit `checked` and `unchecked` scope, a verdict, and evidence refs; `evidenceText` attaches and reviews in one call |
149
- | `artifact` | publish a file under `artifacts/` with `artifactKind`, `criteria`, and revision-pinned `dependsOn` |
169
+ | `review` | record a revision-pinned check with explicit `checked` and `unchecked` (in-criteria gaps) scope, `limits` outside the criteria, a verdict, and evidence refs; `evidenceText` attaches and reviews in one call |
170
+ | `reattest` | carry your earlier review of F#/A# forward to its current revision, with evidence that what you checked is unchanged; refused across a changed artifact criteria or kind, a changed finding claim line, or a revision no `amend` started from |
171
+ | `artifact` | publish a file under `artifacts/` with `artifactKind`, `criteria`, and revision-pinned `dependsOn`; republishing a revision other artifacts pin needs `text` naming the change |
150
172
  | `resource-state` | publish building, ready, broken, or retired state with a lease, ports, and health evidence |
151
173
  | `inspect`, `collaboration` | one record in full (`F#`, `I#`, `R#`, `A#`, `E#`, `V#`, `Q#`, `answer`), or a paged index |
152
174
 
@@ -173,18 +195,40 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
173
195
  validated and every idea this run created or its members hold is done or ruled out. With no
174
196
  other member running, or no validation within `PI_HORDE_SOLVED_CONFIRM_MS`, an uncontested
175
197
  proposal commits as `solved-unvalidated`. Findings no other member reproduced hold wind-down
176
- while other members run, unless `PI_HORDE_ALLOW_UNVERIFIED=1`.
198
+ 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
202
+ (`solved-reopened`) once every named idea is done or ruled out, with the citations it pinned;
203
+ if a cited finding changed or a quoted fact is still provisional by then, it is withdrawn instead. Repair serves a request about
204
+ the pending answer (`target=answer`) with the blockers.
177
205
  - **Reviews are revision-pinned.** A runnable artifact needs a full-scope `tested` or
178
206
  `reproduced` review from a non-owner. A `document` artifact (.md, .txt, .rst, .adoc, .pdf)
179
- accepts a full-scope `sourced` or `derived` review against its criteria. Changing criteria or
180
- dependencies needs changed bytes and a fresh review. Open blockers and challenges block
181
- acceptance; `defer` keeps the debt visible, the recipient can still `respond` to a deferred
182
- request, and the requester then resolves it. A challenger can withdraw its own blocking
183
- challenge on an F# or A# before any response by recording a supporting review of the current
184
- revision and resolving with evidence on it; limits that review discloses remain acceptance debt.
185
- The blocking challenge a `partially-supports` review raises is cleared when a different
186
- non-owner member records a full-scope `supports` review of the same revision; the resolution
187
- names both reviews. Acceptance names an open blocker as `blocked by Q#`.
207
+ accepts a full-scope `sourced` or `derived` review against its criteria. `unchecked` names gaps
208
+ inside the target's stated claim or criteria and keeps acceptance open; `limits` names
209
+ disclosures outside the criteria and is recorded on the acceptance (`Accepted with limits` in
210
+ ACCEPTANCE.md and COLLABORATION.md), never as a blocker. A `partially-supports` verdict refutes
211
+ nothing and opens no challenge: the acceptance names the review and what it left unchecked, and
212
+ another member's full-scope review settles it. Only `refutes` opens a challenge. Changing
213
+ criteria or dependencies needs changed bytes and a fresh review; republishing a revision that
214
+ other artifacts pin needs `text` naming the change, and the owner of each dependent is handed a
215
+ repin request when a pinned dependency is superseded or re-hashed. `reattest` carries a
216
+ reviewer's earlier review forward to the current revision with evidence that the checked
217
+ behaviour is unchanged; the parent prefers prior reviewers when assigning a re-review. Open
218
+ blockers and challenges block acceptance; `defer` keeps the debt visible, the recipient can
219
+ still `respond` to a deferred request, and the requester then resolves it. A challenger can
220
+ withdraw its own blocking challenge on an F# or A# before any response by recording a
221
+ 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#`.
223
+ - **Verifier.** The verifier's idea claims are refused while other members' findings await its
224
+ review at their current revision; the pending F# list follows every review, reattest and board
225
+ post. A verifier whose session closes with critical or high-importance findings unreviewed is
226
+ reset once more into a fresh session that owes them, within `PI_HORDE_RESET_CAP`. SUMMARY.md
227
+ derives its coverage sentence from the review records and names the unreviewed F#.
228
+ - **Reproductions.** A finding with an independent corroborator or a full-scope review is
229
+ settled: a claim whose text overlaps it is told to bring a new vector as `deltaOf`, and a
230
+ third duplicate report is dropped. A finding that cites a runnable under `artifacts/` no R# or
231
+ A# covers is told to register it.
188
232
  - **Inbox.** Targeted messages are inserted at turn boundaries, for at most
189
233
  `PI_HORDE_INBOX_TURNS` extra turns per session. A recipient answers with `respond`; `ack` is an
190
234
  optional first step that signals intent. Delivery is a context insertion, not
@@ -208,6 +252,17 @@ highest-importance unchecked assertions first with `horde review`, contest or su
208
252
  not reproduce, and claim ideas only when nothing is left to review. `--no-verifier` turns this
209
253
  off.
210
254
 
255
+ `--until-stop` keeps the whole horde working until you stop it. A validated solve does not wind
256
+ the run down: the answer is banked in ANSWER.md (and under `answers/`) as round N and the proposal
257
+ slot reopens, so a materially better answer can be proposed later. A member whose session finishes
258
+ is given a fresh session that reads the board, findings, and answer and adds ideas that extend,
259
+ stress-test, or deepen the work; these continuations count against no reset cap and are not
260
+ resets in the report. A session that finished within `PI_HORDE_CONTINUE_COOLDOWN_MS` (default
261
+ 60000) of starting waits out the remainder first, so a member that finds nothing to do cannot spin.
262
+ Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run as usual and
263
+ `stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
264
+ a later proposal was validated too.
265
+
211
266
  ## Finding metadata
212
267
 
213
268
  `importance` (critical, high, medium, low, info), `confidence` (high, medium, low), and
@@ -235,6 +290,7 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
235
290
  | variable | default | effect |
236
291
  |----------|---------|--------|
237
292
  | `PI_HORDE_DASHBOARD_PORT` | 7433 | dashboard port when `--dashboard` names none |
293
+ | `PI_HORDE_CONTINUE_COOLDOWN_MS` | 60000 | `--until-stop`: a finished session younger than this waits out the remainder before its fresh session starts |
238
294
  | `PI_HORDE_MAX_MINUTES` | off | clean stop at a wall-clock deadline |
239
295
  | `PI_HORDE_CANARY_MS` | 60000 | time for member-1 to produce output before a retry |
240
296
  | `PI_HORDE_STALL_MS` | 1800000 | reset a member with no ledger write for this long; `0` disables |
@@ -277,6 +333,7 @@ watchdog.ts child exits when its parent pid is gone
277
333
  widget.ts TUI widget above the editor
278
334
  citations.ts R#/F#/T# and file-path citation checks for findings and generated artifacts
279
335
  dashboard.ts localhost HTTP server; dashboard-data.ts loads pages, dashboard-html.ts renders
336
+ build-info.ts the running package's version and checkout commit, for the dashboard footer
280
337
  collaboration-report.ts acceptance checks, frozen snapshots, collaboration metrics
281
338
  run-graph.ts events.jsonl projected into lanes, phases, nodes, and edges with dashboard links
282
339
  run-graph-svg.ts the swimlane SVG and its legend; run-graph-mermaid.ts the Mermaid export and GRAPH.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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,7 +1,7 @@
1
1
  export const MAX_MEMBERS = 12;
2
2
 
3
3
  export const USAGE = [
4
- "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] <count> <prompt>",
4
+ "usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
5
5
  " spawn <count> members with the same prompt",
6
6
  " /agent-horde status [target] show the active run",
7
7
  " /agent-horde stop-hard [target] terminate members now (SIGTERM, then SIGKILL)",
@@ -20,6 +20,9 @@ export const USAGE = [
20
20
  "PI_HORDE_DASHBOARD_PORT); --dashboard=<port> picks the port.",
21
21
  "--no-verifier skips the default verifier role that runs of 3 or more members assign",
22
22
  "to their last member.",
23
+ "--until-stop keeps the whole horde working until you run stop-clean or stop-hard: a",
24
+ "validated solve is banked as the current answer and a member that finishes gets a fresh",
25
+ "session told to add new ideas.",
23
26
  ].join("\n");
24
27
 
25
28
  /** Which members a control command applies to. */
@@ -49,6 +52,8 @@ type HordeArgs =
49
52
  dashboard?: DashboardOption | undefined;
50
53
  /** Present (true) only when `--no-verifier` was given. */
51
54
  noVerifier?: boolean | undefined;
55
+ /** Present (true) only when `--until-stop` was given. */
56
+ untilStop?: boolean | undefined;
52
57
  };
53
58
 
54
59
  export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
@@ -102,6 +107,7 @@ function parseStopRest(
102
107
 
103
108
  const DASHBOARD_FLAG = /^--dashboard(?:=(\S*))?(?=\s|$)/;
104
109
  const NO_VERIFIER_FLAG = /^--no-verifier(?=\s|$)/;
110
+ const UNTIL_STOP_FLAG = /^--until-stop(?=\s|$)/;
105
111
  const DASHBOARD_ERROR = "invalid --dashboard; use --dashboard or --dashboard=<port 1-65535>";
106
112
 
107
113
  /** Launch flags found at one position of the command; `rest` is the remaining text. */
@@ -109,6 +115,7 @@ interface LaunchFlags {
109
115
  rest: string;
110
116
  dashboard?: DashboardOption | undefined;
111
117
  noVerifier?: boolean | undefined;
118
+ untilStop?: boolean | undefined;
112
119
  error?: string | undefined;
113
120
  }
114
121
 
@@ -120,7 +127,7 @@ function parseDashboardFlag(port: string | undefined): DashboardOption | { error
120
127
  return { port: value };
121
128
  }
122
129
 
123
- /** Strip leading `--dashboard[=<port>]` and `--no-verifier` tokens, in any order. */
130
+ /** Strip leading `--dashboard[=<port>]`, `--no-verifier`, and `--until-stop` tokens, in any order. */
124
131
  function takeLaunchFlags(text: string): LaunchFlags {
125
132
  const flags: LaunchFlags = { rest: text.trim() };
126
133
  for (;;) {
@@ -138,6 +145,12 @@ function takeLaunchFlags(text: string): LaunchFlags {
138
145
  flags.rest = flags.rest.slice(noVerifier[0].length).trim();
139
146
  continue;
140
147
  }
148
+ const untilStop = UNTIL_STOP_FLAG.exec(flags.rest);
149
+ if (untilStop) {
150
+ flags.untilStop = true;
151
+ flags.rest = flags.rest.slice(untilStop[0].length).trim();
152
+ continue;
153
+ }
141
154
  return flags;
142
155
  }
143
156
  }
@@ -148,6 +161,7 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
148
161
  rest: trailing.rest,
149
162
  dashboard: leading.dashboard ?? trailing.dashboard,
150
163
  noVerifier: leading.noVerifier === true || trailing.noVerifier === true ? true : undefined,
164
+ untilStop: leading.untilStop === true || trailing.untilStop === true ? true : undefined,
151
165
  error: leading.error ?? trailing.error,
152
166
  };
153
167
  }
@@ -173,6 +187,7 @@ function runArgs(count: number, prompt: string, flags: LaunchFlags, plan?: strin
173
187
  if (plan) args.plan = plan;
174
188
  if (flags.dashboard) args.dashboard = flags.dashboard;
175
189
  if (flags.noVerifier) args.noVerifier = true;
190
+ if (flags.untilStop) args.untilStop = true;
176
191
  return args;
177
192
  }
178
193
 
@@ -180,7 +195,7 @@ export function parseHordeArgs(raw: string): HordeArgs {
180
195
  const leading = takeLaunchFlags(raw);
181
196
  if (leading.error) return { kind: "usage", error: leading.error };
182
197
  const trimmed = leading.rest;
183
- const hasFlags = leading.dashboard !== undefined || leading.noVerifier === true;
198
+ const hasFlags = leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true;
184
199
  if (trimmed === "") {
185
200
  return hasFlags ? { kind: "usage", error: "count is required" } : { kind: "usage" };
186
201
  }
@@ -0,0 +1,65 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /** What is running: the package version and, from a git checkout, the commit it is at. */
7
+ export interface BuildInfo {
8
+ name: string;
9
+ version: string;
10
+ /** Short hash of HEAD, suffixed `-dirty` when the tree has uncommitted changes; absent outside a checkout. */
11
+ commit?: string | undefined;
12
+ /** The project's web repository, for linking the commit. */
13
+ repository?: string | undefined;
14
+ }
15
+
16
+ const GIT_TIMEOUT_MS = 2_000;
17
+
18
+ /** The package directory: the parent of `src/`, which is also where `package.json` lives. */
19
+ export function packageRoot(): string {
20
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
+ }
22
+
23
+ function repositoryUrl(raw: unknown): string | undefined {
24
+ const url = typeof raw === "string" ? raw : typeof raw === "object" && raw !== null ? (raw as { url?: unknown }).url : undefined;
25
+ if (typeof url !== "string") return undefined;
26
+ const web = url.replace(/^git\+/, "").replace(/\.git$/, "");
27
+ return web.startsWith("https://") ? web : undefined;
28
+ }
29
+
30
+ /** The commit of the checkout rooted at `root`, or undefined when `root` is not itself a checkout (no parent repo is consulted). */
31
+ function describeCommit(root: string): string | undefined {
32
+ if (!fs.existsSync(path.join(root, ".git"))) return undefined;
33
+ try {
34
+ const out = execFileSync("git", ["describe", "--always", "--dirty", "--exclude=*", "--abbrev=7"], {
35
+ cwd: root,
36
+ encoding: "utf8",
37
+ timeout: GIT_TIMEOUT_MS,
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ }).trim();
40
+ return /^[0-9a-f]{7,40}(?:-dirty)?$/.test(out) ? out : undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /** Reads the package at `root`; throws when its package.json is missing or malformed. */
47
+ export function readBuildInfo(root: string): BuildInfo {
48
+ const file = path.join(root, "package.json");
49
+ let pkg: { name?: unknown; version?: unknown; repository?: unknown };
50
+ try {
51
+ pkg = JSON.parse(fs.readFileSync(file, "utf8")) as typeof pkg;
52
+ } catch (error) {
53
+ throw new Error(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}; reinstall agent-horde`);
54
+ }
55
+ if (typeof pkg.name !== "string" || typeof pkg.version !== "string") throw new Error(`${file} has no name or version; reinstall agent-horde`);
56
+ return { name: pkg.name, version: pkg.version, commit: describeCommit(root), repository: repositoryUrl(pkg.repository) };
57
+ }
58
+
59
+ let cached: BuildInfo | undefined;
60
+
61
+ /** The running package's build, read once per process. */
62
+ export function buildInfo(): BuildInfo {
63
+ cached ??= readBuildInfo(packageRoot());
64
+ return cached;
65
+ }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { artifactAccepted, blockingMessages, collaborationState, openMessages } from "./ledger/collaboration.js";
3
+ import { artifactAccepted, artifactLimits, blockingMessages, collaborationState, openMessages, partialArtifactReviews } from "./ledger/collaboration.js";
4
4
  import type { ArtifactRecord, CollaborationState, PeerMessage } from "./ledger/collaboration.js";
5
5
  import { readEvents } from "./ledger/events.js";
6
6
  import { findingReviewState, findingRevision, listFindingsForRun } from "./ledger/findings.js";
@@ -13,6 +13,8 @@ export interface CollaborationReport {
13
13
  sequence: number;
14
14
  state: CollaborationState;
15
15
  issues: string[];
16
+ /** Limits the accepting reviews disclosed outside each accepted artifact's criteria. */
17
+ limits: string[];
16
18
  acceptedArtifacts: string[];
17
19
  acceptedDocuments: string[];
18
20
  acceptedRunnableArtifacts: string[];
@@ -55,9 +57,14 @@ function artifactProblem(hordeDir: string, runId: string, state: CollaborationSt
55
57
  // "needs a review" sent them after a review that already existed.
56
58
  const blocker = blockingMessages(state).find((m) => m.target === artifact.id);
57
59
  if (blocker) return `blocked by ${blocker.id} (${blocker.state} blocking ${blocker.kind} from ${blocker.sender})`;
58
- return artifactAccepted(state, artifact) ? undefined : artifact.kind === "document"
60
+ if (artifactAccepted(state, artifact)) return undefined;
61
+ // A partial review is coverage debt, not an objection: the acceptance names what it left
62
+ // unchecked so a full review, or one covering that scope, can settle it.
63
+ const partial = partialArtifactReviews(state, artifact).map((r) => `${r.id} (${r.actor}) left unchecked: ${r.unchecked.trim()}`);
64
+ const need = artifact.kind === "document"
59
65
  ? "needs a full independent review against document criteria"
60
66
  : "needs a full independent tested/reproduced review";
67
+ return partial.length ? `${need}; ${partial.join("; ")}` : need;
61
68
  }
62
69
 
63
70
  export function loadCollaborationReport(hordeDir: string, runId: string): CollaborationReport {
@@ -65,10 +72,15 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
65
72
  const state = collaborationState(hordeDir, runId);
66
73
  const issues = blockingMessages(state).map((m) => `${m.id} ${m.target}: ${m.state} blocking ${m.kind}`);
67
74
  const acceptedArtifacts: string[] = [];
75
+ const limits: string[] = [];
68
76
  for (const artifact of state.artifacts) {
69
77
  const problem = artifactProblem(hordeDir, runId, state, artifact);
70
- if (problem) issues.push(`${artifact.id}@${artifact.hash}: ${problem}; exclude from recommended ${artifact.kind === "document" ? "document" : "runnable"} outputs`);
71
- else acceptedArtifacts.push(artifact.id);
78
+ if (problem) {
79
+ issues.push(`${artifact.id}@${artifact.hash}: ${problem}; exclude from recommended ${artifact.kind === "document" ? "document" : "runnable"} outputs`);
80
+ continue;
81
+ }
82
+ acceptedArtifacts.push(artifact.id);
83
+ limits.push(...artifactLimits(state, artifact).map((limit) => `${artifact.id}@${artifact.hash}: accepted with limits outside its criteria; ${limit}`));
72
84
  }
73
85
  for (const f of listFindingsForRun(hordeDir, runId)) {
74
86
  if (f.supersededBy !== undefined) continue;
@@ -83,7 +95,7 @@ export function loadCollaborationReport(hordeDir: string, runId: string): Collab
83
95
  const delays = messages.flatMap((m) => Object.values(m.deliveries).slice(0, 1).map((time) => Date.parse(time) - Date.parse(m.createdAt)));
84
96
  const acceptedDocuments = state.artifacts.filter((a) => a.kind === "document" && acceptedArtifacts.includes(a.id)).map((a) => a.id);
85
97
  const acceptedRunnableArtifacts = acceptedArtifacts.filter((id) => !acceptedDocuments.includes(id));
86
- return { runId, sequence: state.seq, state, issues, acceptedArtifacts, acceptedDocuments, acceptedRunnableArtifacts, metrics: {
98
+ return { runId, sequence: state.seq, state, issues, limits, acceptedArtifacts, acceptedDocuments, acceptedRunnableArtifacts, metrics: {
87
99
  events: events.length, rejected: events.filter((e) => e.outcome === "rejected").length,
88
100
  unresolved: openMessages(state).length, blocking: blockingMessages(state).length,
89
101
  resolvedChallenges: messages.filter((m) => m.kind === "challenge" && m.state === "resolved").length,
@@ -116,9 +128,10 @@ function collaborationMarkdown(report: CollaborationReport): string {
116
128
  return [
117
129
  `# Collaboration ${report.runId}`, `\nEvent high-water mark: ${report.sequence}. Delivery records context insertion, not comprehension.`,
118
130
  "\n## Acceptance issues", ...(report.issues.length ? report.issues.map((i) => `- ${i}`) : ["- No protocol-level issues recorded; task acceptance still requires coverage and review."]),
131
+ "\n## Accepted with limits", ...(report.limits.length ? report.limits.map((l) => `- ${l}`) : ["- none disclosed"]),
119
132
  "\n## Questions, challenges and handoffs", ...state.messages.map(messageMarkdown),
120
- "\n## Artifact revisions", ...state.artifacts.map((a) => `- ${a.id} ${a.path}@${a.hash} owner=${a.owner} kind=${a.kind ?? "runnable"} snapshot=${a.snapshot} accepted=${report.acceptedArtifacts.includes(a.id)}\n Criteria: ${a.criteria}\n Dependencies: ${JSON.stringify(a.dependencies)}`),
121
- "\n## Reviews", ...state.reviews.map((r) => `- ${r.id} ${r.target}@${r.revision} @${r.actor} ${r.verdict} [${r.support}] evidence=${r.evidence.join(",")}\n Checked: ${r.checked}\n Unchecked: ${r.unchecked || "none declared"}\n ${r.text}`),
133
+ "\n## Artifact revisions", ...state.artifacts.map((a) => `- ${a.id} ${a.path}@${a.hash} owner=${a.owner} kind=${a.kind ?? "runnable"} snapshot=${a.snapshot} accepted=${report.acceptedArtifacts.includes(a.id)}\n Criteria: ${a.criteria}\n Dependencies: ${JSON.stringify(a.dependencies)}${a.changeNote ? `\n Change: ${a.changeNote}` : ""}`),
134
+ "\n## Reviews", ...state.reviews.map((r) => `- ${r.id} ${r.target}@${r.revision} @${r.actor} ${r.verdict} [${r.support}] evidence=${r.evidence.join(",")}${r.reattests ? ` reattests=${r.reattests}` : ""}\n Checked: ${r.checked}\n Unchecked: ${r.unchecked || "none declared"}\n Limits: ${r.limits || "none disclosed"}\n ${r.text}`),
122
135
  "\n## Evidence", ...state.evidence.map((e) => `- ${e.id} ${e.target}@${e.revision} @${e.actor} ${e.relation}${e.path ? ` ${e.path}@${e.hash}` : ""}\n ${e.text}`),
123
136
  "\n## Resources", ...state.resources.map((r) => `- ${r.target} ${r.state} generation=${r.generation} @${r.owner}/${r.attempt} ports=${r.ports.join(",")} lease=${new Date(r.leaseUntil).toISOString()}\n Health: ${r.health}`),
124
137
  "\n## Metrics", ...Object.entries(report.metrics).map(([key, value]) => `- ${key}: ${value}`), "",
@@ -139,12 +152,13 @@ export function freezeAcceptance(hordeDir: string, runId: string, startIdeaId =
139
152
  if (check.status === "disputed") issues.push(`${check.target}: unresolved challenge or refuting review`);
140
153
  }
141
154
  const runDir = path.join(hordeDir, "runs", runId);
142
- const snapshot = { report, findings, findingReviews, ideas, issues };
155
+ const snapshot = { report, findings, findingReviews, ideas, issues, limits: report.limits };
143
156
  const hash = hashText(JSON.stringify(snapshot));
144
157
  const status = issues.length ? "incomplete" : "reviewed";
145
158
  writeFileAtomic(path.join(runDir, "COLLABORATION.md"), collaborationMarkdown(report));
146
159
  writeFileAtomic(path.join(runDir, "ACCEPTANCE.json"), JSON.stringify({ ...snapshot, hash, status }, null, 2));
147
- writeFileAtomic(path.join(runDir, "ACCEPTANCE.md"), `# Acceptance ${runId}\n\nStatus: ${status}\nSnapshot: ${hash}\nEvent sequence: ${report.sequence}\n\n${issues.map((i) => `- ${i}`).join("\n")}\n`);
160
+ const limitsSection = report.limits.length ? `\n\n## Accepted with limits\n\n${report.limits.map((l) => `- ${l}`).join("\n")}` : "";
161
+ writeFileAtomic(path.join(runDir, "ACCEPTANCE.md"), `# Acceptance ${runId}\n\nStatus: ${status}\nSnapshot: ${hash}\nEvent sequence: ${report.sequence}\n\n${issues.map((i) => `- ${i}`).join("\n")}${limitsSection}\n`);
148
162
  const dir = path.join(runDir, "snapshot");
149
163
  fs.mkdirSync(dir, { recursive: true });
150
164
  for (const name of ["BOARD", "FINDINGS", "IDEAS", "FACTS", "CLAIMS", "RESOURCES", "CONCERNS"]) {