harnery 0.5.0 → 0.6.0

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.
Files changed (68) hide show
  1. package/dist/commander.d.ts +10 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +43 -29
  6. package/dist/commands/browse-ai.js +1 -1
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +41 -9
  9. package/dist/commands/cookies.js +1 -1
  10. package/dist/commands/decision.d.ts +4 -0
  11. package/dist/commands/decision.d.ts.map +1 -0
  12. package/dist/commands/decision.js +354 -0
  13. package/dist/commands/docs.d.ts.map +1 -1
  14. package/dist/commands/docs.js +5 -1
  15. package/dist/commands/fetch.js +1 -1
  16. package/dist/core/agents/events/consume.d.ts +25 -2
  17. package/dist/core/agents/events/consume.d.ts.map +1 -1
  18. package/dist/core/agents/events/consume.js +55 -7
  19. package/dist/core/agents/events/emit.d.ts +2 -1
  20. package/dist/core/agents/events/emit.d.ts.map +1 -1
  21. package/dist/core/agents/events/emit.js +2 -1
  22. package/dist/core/agents/state/scratch.d.ts +1 -1
  23. package/dist/core/agents/state/scratch.js +2 -2
  24. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  25. package/dist/core/hooks/effects/index.js +2 -1
  26. package/dist/lib/agent-browser/client.js +1 -1
  27. package/dist/lib/browser/client.d.ts +14 -0
  28. package/dist/lib/browser/client.d.ts.map +1 -1
  29. package/dist/lib/browser/client.js +20 -0
  30. package/dist/lib/browser/index.d.ts +1 -0
  31. package/dist/lib/browser/index.d.ts.map +1 -1
  32. package/dist/lib/browser/runts.d.ts +44 -0
  33. package/dist/lib/browser/runts.d.ts.map +1 -0
  34. package/dist/lib/browser/runts.js +193 -0
  35. package/dist/lib/completion/walk.js +1 -1
  36. package/dist/lib/cookies/client.d.ts +1 -1
  37. package/dist/lib/cookies/client.d.ts.map +1 -1
  38. package/dist/lib/cookies/client.js +1 -1
  39. package/dist/lib/decision/index.d.ts +212 -0
  40. package/dist/lib/decision/index.d.ts.map +1 -0
  41. package/dist/lib/decision/index.js +523 -0
  42. package/dist/lib/docs-lint.d.ts +1 -0
  43. package/dist/lib/docs-lint.d.ts.map +1 -1
  44. package/dist/lib/docs-lint.js +49 -0
  45. package/dist/lib/tunnel/gate.js +1 -1
  46. package/package.json +3 -1
  47. package/src/commander.ts +12 -0
  48. package/src/commands/agents.ts +47 -26
  49. package/src/commands/browse-ai.ts +1 -1
  50. package/src/commands/browse.ts +63 -8
  51. package/src/commands/cookies.ts +1 -1
  52. package/src/commands/decision.ts +438 -0
  53. package/src/commands/docs.ts +5 -1
  54. package/src/commands/fetch.ts +1 -1
  55. package/src/core/agents/events/consume.ts +65 -7
  56. package/src/core/agents/events/emit.ts +2 -1
  57. package/src/core/agents/state/scratch.ts +2 -2
  58. package/src/core/config.ts +1 -1
  59. package/src/core/hooks/effects/index.ts +2 -1
  60. package/src/lib/agent-browser/client.ts +1 -1
  61. package/src/lib/browser/client.ts +28 -0
  62. package/src/lib/browser/index.ts +4 -0
  63. package/src/lib/browser/runts.ts +218 -0
  64. package/src/lib/completion/walk.ts +1 -1
  65. package/src/lib/cookies/client.ts +2 -2
  66. package/src/lib/decision/index.ts +685 -0
  67. package/src/lib/docs-lint.ts +44 -0
  68. package/src/lib/tunnel/gate.ts +1 -1
@@ -0,0 +1,438 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { Command } from "commander";
3
+ import type { EmitContext } from "../commander.ts";
4
+ import {
5
+ emitCanonical,
6
+ monorepoRoot,
7
+ normalizeHarness,
8
+ readHeartbeat,
9
+ resolveOwner,
10
+ } from "../core/agents/index.ts";
11
+ import { resolveBinName } from "../core/config.ts";
12
+ import {
13
+ archiveDecision,
14
+ claimDecision,
15
+ DECISION_STAKES,
16
+ type DecisionManifest,
17
+ type DecisionStakes,
18
+ type DecisionStatus,
19
+ type DecisionTier,
20
+ fileDecision,
21
+ isStakes,
22
+ isStatus,
23
+ isTier,
24
+ isVerdict,
25
+ listDecisions,
26
+ type ReviewVerdict,
27
+ reopenDecision,
28
+ resolveDecision,
29
+ reviewDecision,
30
+ searchDecisions,
31
+ showDecision,
32
+ supersedeDecision,
33
+ triageDecision,
34
+ wontfixDecision,
35
+ } from "../lib/decision/index.ts";
36
+
37
+ /**
38
+ * `harn decision`: the decision docket — a persistent queue of decisions an
39
+ * agent would otherwise route to a human, carried through triage → deliberation
40
+ * → an evidence-cited resolution → async review.
41
+ *
42
+ * The engine is generic: it stores `tier` (0/1/2) + `stakes` but never decides
43
+ * what belongs in which tier — that's host policy, applied by the filing agent
44
+ * (for a host, via its own decision skill + rubric).
45
+ */
46
+ let emit: EmitContext;
47
+
48
+ export function registerDecisionCommand(program: Command, emitParam: EmitContext): void {
49
+ emit = emitParam;
50
+ const bin = () => resolveBinName();
51
+ const root = program
52
+ .command("decision")
53
+ .alias("decisions")
54
+ .description(
55
+ "Decision docket: file a decision an agent would otherwise escalate, " +
56
+ "deliberate it, resolve with cited evidence, review async.",
57
+ );
58
+
59
+ // ── file ─────────────────────────────────────────────────────────────────
60
+ root
61
+ .command("file <question...>")
62
+ .description("File a decision into the docket. Tier/stakes are the filer's triage call.")
63
+ .option("--tier <0|1|2>", "Human-involvement tier (0 none, 1 review, 2 decide-with-brief)", "2")
64
+ .option("--stakes <small|medium|high>", "Reversal cost / blast radius", "medium")
65
+ .option("--context <text>", "Why it matters, what's blocked")
66
+ .option("--default-taken <text>", "What you proceeded with (always-proceed, tier 0/1)")
67
+ .option("--brief <path>", "Path to a markdown file with the long-form brief")
68
+ .option("--filed-by <name>", "Filer agent name (else resolved from heartbeat)")
69
+ .action((question: string[], opts: FileOpts) => {
70
+ const coordRoot = coordRootOrExit();
71
+ const tier = parseTier(opts.tier);
72
+ const stakes = parseStakes(opts.stakes);
73
+ let brief: string | undefined;
74
+ if (opts.brief) {
75
+ if (!existsSync(opts.brief)) {
76
+ emit.error({ code: "no_brief_file", message: `brief file not found: ${opts.brief}` });
77
+ process.exit(1);
78
+ }
79
+ brief = readFileSync(opts.brief, "utf8");
80
+ }
81
+ const owner = resolveOwner();
82
+ const hb = owner ? readHeartbeat(owner) : null;
83
+ const r = fileDecision(coordRoot, {
84
+ question: question.join(" "),
85
+ tier,
86
+ stakes,
87
+ context: opts.context,
88
+ defaultTaken: opts.defaultTaken,
89
+ brief,
90
+ filedBy: opts.filedBy ?? hb?.name ?? undefined,
91
+ filedById: owner ?? undefined,
92
+ });
93
+ if (!r.ok) return fail("file_failed", r.reason);
94
+ emitDecisionEvent("decision.filed", {
95
+ decision_id: r.manifest!.decision_id,
96
+ tier,
97
+ stakes,
98
+ });
99
+ emit.data(r.manifest);
100
+ });
101
+
102
+ // ── list ─────────────────────────────────────────────────────────────────
103
+ root
104
+ .command("list")
105
+ .description("List docket decisions (default: active only, newest first).")
106
+ .option("--status <status>", "Filter by status")
107
+ .option("--tier <0|1|2>", "Filter by tier")
108
+ .option("--stakes <small|medium|high>", "Filter by stakes")
109
+ .option("--open", "Only non-terminal decisions (the live queue)")
110
+ .option("--archived", "Include archived (graduated/terminal) decisions")
111
+ .action((opts: ListOpts) => {
112
+ const coordRoot = coordRootOrExit();
113
+ const status = opts.status ? parseStatus(opts.status) : undefined;
114
+ const tier = opts.tier !== undefined ? parseTier(opts.tier) : undefined;
115
+ const stakes = opts.stakes ? parseStakes(opts.stakes) : undefined;
116
+ const rows = listDecisions(coordRoot, {
117
+ status,
118
+ tier,
119
+ stakes,
120
+ openOnly: opts.open,
121
+ includeArchived: opts.archived,
122
+ }).map(summarize);
123
+ emit.data({
124
+ rows,
125
+ meta: {
126
+ total: rows.length,
127
+ filter: { status, tier, stakes, open: !!opts.open, archived: !!opts.archived },
128
+ },
129
+ });
130
+ });
131
+
132
+ // ── show ─────────────────────────────────────────────────────────────────
133
+ root
134
+ .command("show <id>")
135
+ .description("Show one decision: manifest + long-form bodies.")
136
+ .action((id: string) => {
137
+ const coordRoot = coordRootOrExit();
138
+ const detail = showDecision(coordRoot, id);
139
+ if (!detail) return fail("not_found", `no decision "${id}"`);
140
+ emit.data(detail);
141
+ });
142
+
143
+ // ── search ───────────────────────────────────────────────────────────────
144
+ root
145
+ .command("search <query...>")
146
+ .description("Substring search over questions, context, resolutions, bodies (incl. archive).")
147
+ .action((query: string[]) => {
148
+ const coordRoot = coordRootOrExit();
149
+ const hits = searchDecisions(coordRoot, query.join(" ")).map((h) => ({
150
+ ...summarize(h.manifest),
151
+ where: h.where,
152
+ snippet: h.snippet,
153
+ }));
154
+ emit.data({ rows: hits, meta: { total: hits.length, query: query.join(" ") } });
155
+ });
156
+
157
+ // ── claim ────────────────────────────────────────────────────────────────
158
+ root
159
+ .command("claim <id>")
160
+ .description("Claim a decision for deliberation (last-writer-wins).")
161
+ .option("--owner <id>", "Claim as this owner (else the current agent)")
162
+ .action((id: string, opts: { owner?: string }) => {
163
+ const coordRoot = coordRootOrExit();
164
+ const owner = opts.owner ?? resolveOwner();
165
+ if (!owner) {
166
+ return fail(
167
+ "no_owner",
168
+ `not in an agent session; pass --owner or run \`${bin()} agents whoami\` to check`,
169
+ );
170
+ }
171
+ const r = claimDecision(coordRoot, id, owner);
172
+ if (!r.ok) return fail("claim_failed", r.reason);
173
+ emit.data(r.manifest);
174
+ });
175
+
176
+ // ── resolve ──────────────────────────────────────────────────────────────
177
+ root
178
+ .command("resolve <id>")
179
+ .description("Resolve a decision. Evidence (≥1 citation) is required.")
180
+ .requiredOption("--recommendation <text>", "The recommendation")
181
+ .option(
182
+ "--evidence <text>",
183
+ "A cited fact (query run, file read, cost computed). Repeatable; ≥1 required.",
184
+ collect,
185
+ [] as string[],
186
+ )
187
+ .option("--confidence <text>", "Confidence in the recommendation")
188
+ .option("--reversal-cost <text>", "Cost to reverse if wrong")
189
+ .option("--wrong-if <text>", "What would make this wrong (pre-mortem)")
190
+ .option("--revisit-when <text>", "Revisit trigger")
191
+ .option("--resolved-by <name>", "Resolver (else the current agent)")
192
+ .action((id: string, opts: ResolveOpts) => {
193
+ const coordRoot = coordRootOrExit();
194
+ const owner = resolveOwner();
195
+ const resolvedBy = opts.resolvedBy ?? (owner ? readHeartbeat(owner)?.name : undefined);
196
+ if (!resolvedBy) {
197
+ return fail("no_resolver", "pass --resolved-by (no agent session to infer it from)");
198
+ }
199
+ const r = resolveDecision(coordRoot, id, {
200
+ recommendation: opts.recommendation,
201
+ evidence: opts.evidence,
202
+ confidence: opts.confidence,
203
+ reversal_cost: opts.reversalCost,
204
+ wrong_if: opts.wrongIf,
205
+ revisit_when: opts.revisitWhen,
206
+ resolved_by: resolvedBy,
207
+ });
208
+ if (!r.ok) return fail("resolve_failed", r.reason);
209
+ emitDecisionEvent("decision.resolved", {
210
+ decision_id: id,
211
+ tier: r.manifest!.tier,
212
+ stakes: r.manifest!.stakes,
213
+ confidence: opts.confidence ?? null,
214
+ });
215
+ emit.data(r.manifest);
216
+ });
217
+
218
+ // ── review ───────────────────────────────────────────────────────────────
219
+ root
220
+ .command("review <id>")
221
+ .description("Record a review verdict (calibration, not approval — work already proceeded).")
222
+ .requiredOption(
223
+ "--verdict <verdict>",
224
+ "ratified | overridden | wrong-tier-high | wrong-tier-low",
225
+ )
226
+ .option("--note <text>", "Reviewer note")
227
+ .action((id: string, opts: { verdict: string; note?: string }) => {
228
+ const coordRoot = coordRootOrExit();
229
+ if (!isVerdict(opts.verdict)) {
230
+ return fail(
231
+ "bad_verdict",
232
+ "verdict must be: ratified | overridden | wrong-tier-high | wrong-tier-low",
233
+ );
234
+ }
235
+ const r = reviewDecision(coordRoot, id, {
236
+ verdict: opts.verdict as ReviewVerdict,
237
+ note: opts.note,
238
+ });
239
+ if (!r.ok) return fail("review_failed", r.reason);
240
+ emitDecisionEvent("decision.reviewed", {
241
+ decision_id: id,
242
+ verdict: opts.verdict,
243
+ tier: r.manifest!.tier,
244
+ });
245
+ emit.data(r.manifest);
246
+ });
247
+
248
+ // ── triage ─────────────────────────────────────────────────────────────────
249
+ // Re-set tier/stakes on an already-filed decision (e.g. after a sweeper or
250
+ // reviewer flags a wrong-tier). Cheap enough to surface in phase 1.
251
+ root
252
+ .command("triage <id>")
253
+ .description("Adjust an already-filed decision's tier/stakes.")
254
+ .option("--tier <0|1|2>", "New tier")
255
+ .option("--stakes <small|medium|high>", "New stakes")
256
+ .action((id: string, opts: { tier?: string; stakes?: string }) => {
257
+ const coordRoot = coordRootOrExit();
258
+ if (opts.tier === undefined && opts.stakes === undefined) {
259
+ return fail("nothing_to_do", "pass --tier and/or --stakes");
260
+ }
261
+ const r = triageDecision(coordRoot, id, {
262
+ tier: opts.tier !== undefined ? parseTier(opts.tier) : undefined,
263
+ stakes: opts.stakes !== undefined ? parseStakes(opts.stakes) : undefined,
264
+ });
265
+ if (!r.ok) return fail("triage_failed", r.reason);
266
+ emit.data(r.manifest);
267
+ });
268
+
269
+ // ── archive ─────────────────────────────────────────────────────────────────
270
+ // The graduation exit: a reviewed decision's output has landed in a canonical
271
+ // home (an ADR, AGENTS.md, a code change), so the decision closes and moves to
272
+ // the archive, still searchable as precedent. `--graduated-to` records where.
273
+ root
274
+ .command("archive <id>")
275
+ .description("Archive a reviewed decision (terminal). Record where its output graduated.")
276
+ .option("--graduated-to <ref>", "Where the resolved output landed (e.g. docs/decisions.md#foo)")
277
+ .action((id: string, opts: { graduatedTo?: string }) => {
278
+ const coordRoot = coordRootOrExit();
279
+ const r = archiveDecision(coordRoot, id, opts.graduatedTo);
280
+ if (!r.ok) return fail("archive_failed", r.reason);
281
+ emitDecisionEvent("decision.archived", {
282
+ decision_id: id,
283
+ tier: r.manifest!.tier,
284
+ graduated_to: opts.graduatedTo ?? null,
285
+ });
286
+ emit.data(r.manifest);
287
+ });
288
+
289
+ // ── reopen ───────────────────────────────────────────────────────────────────
290
+ // The inverse of archive: pull an archived decision back to `reviewed`. The one
291
+ // sanctioned way out of the terminal archive (e.g. a fat-fingered graduated_to
292
+ // or the wrong decision archived). Clears graduated_to; a re-archive sets it fresh.
293
+ root
294
+ .command("reopen <id>")
295
+ .alias("unarchive")
296
+ .description("Reopen an archived decision back to reviewed (the inverse of archive).")
297
+ .action((id: string) => {
298
+ const coordRoot = coordRootOrExit();
299
+ const r = reopenDecision(coordRoot, id);
300
+ if (!r.ok) return fail("reopen_failed", r.reason);
301
+ emitDecisionEvent("decision.reopened", { decision_id: id, tier: r.manifest!.tier });
302
+ emit.data(r.manifest);
303
+ });
304
+
305
+ // ── supersede ────────────────────────────────────────────────────────────────
306
+ root
307
+ .command("supersede <id>")
308
+ .description("Mark a decision superseded by a newer one (terminal).")
309
+ .option("--by <id>", "The superseding decision's id")
310
+ .action((id: string, opts: { by?: string }) => {
311
+ const coordRoot = coordRootOrExit();
312
+ const r = supersedeDecision(coordRoot, id, opts.by);
313
+ if (!r.ok) return fail("supersede_failed", r.reason);
314
+ emitDecisionEvent("decision.superseded", {
315
+ decision_id: id,
316
+ superseded_by: opts.by ?? null,
317
+ });
318
+ emit.data(r.manifest);
319
+ });
320
+
321
+ // ── wontfix ──────────────────────────────────────────────────────────────────
322
+ root
323
+ .command("wontfix <id>")
324
+ .description("Close an un-deliberated decision without action (terminal).")
325
+ .option("--reason <text>", "Why it's being closed")
326
+ .action((id: string, opts: { reason?: string }) => {
327
+ const coordRoot = coordRootOrExit();
328
+ const r = wontfixDecision(coordRoot, id, opts.reason);
329
+ if (!r.ok) return fail("wontfix_failed", r.reason);
330
+ emitDecisionEvent("decision.wontfix", {
331
+ decision_id: id,
332
+ reason: opts.reason ?? null,
333
+ });
334
+ emit.data(r.manifest);
335
+ });
336
+ }
337
+
338
+ // ─── option types ────────────────────────────────────────────────────────────
339
+
340
+ interface FileOpts {
341
+ tier: string;
342
+ stakes: string;
343
+ context?: string;
344
+ defaultTaken?: string;
345
+ brief?: string;
346
+ filedBy?: string;
347
+ }
348
+ interface ListOpts {
349
+ status?: string;
350
+ tier?: string;
351
+ stakes?: string;
352
+ open?: boolean;
353
+ archived?: boolean;
354
+ }
355
+ interface ResolveOpts {
356
+ recommendation: string;
357
+ evidence: string[];
358
+ confidence?: string;
359
+ reversalCost?: string;
360
+ wrongIf?: string;
361
+ revisitWhen?: string;
362
+ resolvedBy?: string;
363
+ }
364
+
365
+ // ─── helpers ───────────────────────────────────────────────────────────────────
366
+
367
+ function coordRootOrExit(): string {
368
+ const root = monorepoRoot();
369
+ if (!root) {
370
+ emit.error({
371
+ code: "no_coord_root",
372
+ message: "not in a coord-aware repo (no .harnery/ found)",
373
+ });
374
+ process.exit(1);
375
+ }
376
+ return root;
377
+ }
378
+
379
+ function fail(code: string, message?: string): never {
380
+ emit.error({ code, message: message ?? code });
381
+ process.exit(1);
382
+ }
383
+
384
+ function parseTier(raw: string): DecisionTier {
385
+ const n = Number.parseInt(raw, 10);
386
+ if (!isTier(n)) fail("bad_tier", `tier must be 0, 1, or 2 (got "${raw}")`);
387
+ return n as DecisionTier;
388
+ }
389
+
390
+ function parseStakes(raw: string): DecisionStakes {
391
+ if (!isStakes(raw))
392
+ fail("bad_stakes", `stakes must be ${DECISION_STAKES.join(" | ")} (got "${raw}")`);
393
+ return raw as DecisionStakes;
394
+ }
395
+
396
+ function parseStatus(raw: string): DecisionStatus {
397
+ if (!isStatus(raw)) fail("bad_status", `unknown status "${raw}"`);
398
+ return raw as DecisionStatus;
399
+ }
400
+
401
+ function collect(value: string, previous: string[]): string[] {
402
+ return previous.concat([value]);
403
+ }
404
+
405
+ /** Compact row for list/search output — the full manifest is available via `show`. */
406
+ function summarize(m: DecisionManifest): Record<string, unknown> {
407
+ return {
408
+ decision_id: m.decision_id,
409
+ status: m.status,
410
+ tier: m.tier,
411
+ stakes: m.stakes,
412
+ question: m.question,
413
+ filed_by: m.filed_by ?? null,
414
+ filed_at: m.filed_at,
415
+ claimed_by: m.claimed_by ?? null,
416
+ resolved: !!m.resolution,
417
+ reviewed: !!m.review,
418
+ graduated_to: m.graduated_to ?? null,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Emit a canonical `decision.*` event. Soft: no-ops when there's no agent
424
+ * session to attribute it to (operator-side filing). Powers the docket metrics
425
+ * without new telemetry plumbing.
426
+ */
427
+ function emitDecisionEvent(type: string, data: Record<string, unknown>): void {
428
+ const owner = resolveOwner();
429
+ if (!owner) return;
430
+ const hb = readHeartbeat(owner);
431
+ emitCanonical({
432
+ type,
433
+ owner,
434
+ session: hb?.session_id ?? owner,
435
+ harness: normalizeHarness(hb?.platform),
436
+ data,
437
+ });
438
+ }
@@ -16,7 +16,11 @@ function ensureContext(context: HarneryProgramContext | undefined): void {
16
16
  const opts = { repoRoot: context.repoRoot, submodules: context.submodules };
17
17
  initDocs(opts);
18
18
  initDocsIndex(opts);
19
- initDocsLint({ ...opts, extraExcludedPrefixes: context.extraDocsExcludedPrefixes });
19
+ initDocsLint({
20
+ ...opts,
21
+ extraExcludedPrefixes: context.extraDocsExcludedPrefixes,
22
+ docsRootAllowlist: context.docsRootAllowlist,
23
+ });
20
24
  initDocsSweep(opts);
21
25
  }
22
26
 
@@ -80,7 +80,7 @@ async function runFetch(
80
80
 
81
81
  const jar =
82
82
  opts.cookies !== false
83
- ? new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "bp-fetch" })
83
+ ? new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-fetch" })
84
84
  : null;
85
85
 
86
86
  const timeoutMs = Number.parseInt(opts.timeout, 10);
@@ -11,8 +11,12 @@
11
11
  * with a bounded **tail read** (reading only the last `tailBytes` of the file
12
12
  * and locating the cursor there) which turns an O(file-size) read into
13
13
  * O(window). If the cursor is older than the window (a long-idle system, or a
14
- * cursor that's been rotated out) we fall back to a full read. The fallback is
15
- * always correct, just slower, so undersizing the window can never lose events.
14
+ * cursor that's been rotated out) we fall back to a wider read that is itself
15
+ * capped (`fallbackCapBytes`): the stream is an append-only ledger that grows
16
+ * without bound, so a whole-file `readFileSync` throws V8's max-string-length
17
+ * error ("Cannot create a string longer than 0x1fffffe8 characters") once it
18
+ * passes ~512MB, which would abort the projection. Events older than the cap are
19
+ * stale for coord-state purposes, so a bounded replay is the correct fallback.
16
20
  *
17
21
  * Idempotency: projectors are idempotent by `event_id`. The
18
22
  * cursor file makes this cheap (we never replay an already-projected event by
@@ -36,6 +40,11 @@ import { coordEnv } from "../../../lib/env.ts";
36
40
  const STREAM_REL = ".harnery/events.ndjson";
37
41
  const CURSOR_REL = ".harnery/.events-cursor";
38
42
  const DEFAULT_TAIL_BYTES = 2 * 1024 * 1024; // 2 MiB, thousands of events of headroom
43
+ /** Cap for the fall-through read when the cursor misses the tail window. Well
44
+ * under V8's ~512MB max string length so it never throws on the unbounded
45
+ * ledger; comfortably larger than any realistic cursor drift (the global cursor
46
+ * advances on every agent's turn.stop, so it sits near EOF in practice). */
47
+ const DEFAULT_FALLBACK_CAP_BYTES = 64 * 1024 * 1024; // 64 MiB
39
48
 
40
49
  export interface CanonicalEvent {
41
50
  schema_version: number;
@@ -69,6 +78,13 @@ export interface ConsumeOpts {
69
78
  * callers should leave it unset.
70
79
  */
71
80
  tailBytes?: number;
81
+ /**
82
+ * Cap in bytes for the fall-through read (cursor missed the tail window).
83
+ * Defaults to `HARNERY_AGENT_COORD_FALLBACK_CAP_BYTES` env or 64 MiB. Bounds
84
+ * the read so the unbounded ledger can never overflow V8's max string length.
85
+ * Mainly a test seam; production callers should leave it unset.
86
+ */
87
+ fallbackCapBytes?: number;
72
88
  }
73
89
 
74
90
  /**
@@ -103,8 +119,19 @@ export function consumeSince(coordRoot: string, opts: ConsumeOpts = {}): Consume
103
119
  // Cursor older than the tail window → fall through to a full read.
104
120
  }
105
121
 
106
- // Full read: first run, replayAll, small file, or cursor not found in tail.
107
- const all = parseLines(readFileSync(streamPath, "utf8"));
122
+ // Bounded fall-through: first run, replayAll, small file, or cursor not found
123
+ // in the tail. This must NEVER read the whole file: the stream grows without
124
+ // bound and a >512MB readFileSync throws V8's max-string-length error, which
125
+ // would abort the projection. Read at most `cap` bytes from the tail; when we
126
+ // start mid-file, drop the (likely partial) first line.
127
+ const cap = resolveFallbackCap(opts.fallbackCapBytes);
128
+ const readBytes = Math.min(fileSize, cap);
129
+ let text = readTailUtf8(streamPath, fileSize, readBytes);
130
+ if (readBytes < fileSize) {
131
+ const firstNl = text.indexOf("\n");
132
+ text = firstNl >= 0 ? text.slice(firstNl + 1) : "";
133
+ }
134
+ const all = parseLines(text);
108
135
  const parsed = eventsAfterCursor(all, cursor);
109
136
  if (parsed.foundCursor) {
110
137
  return {
@@ -114,12 +141,19 @@ export function consumeSince(coordRoot: string, opts: ConsumeOpts = {}): Consume
114
141
  };
115
142
  }
116
143
 
117
- // The cursor names an event that's been rotated out of the file, so replay
118
- // everything (safer than silent state drift; the projector is idempotent by
119
- // event_id).
144
+ // The cursor names an event older than the capped window (or rotated out), so
145
+ // replay what the window holds (safer than silent state drift; the projector
146
+ // is idempotent by event_id). lastEventId null keeps the cursor put.
120
147
  return { events: all, lastEventId: null, streamBytes: fileSize };
121
148
  }
122
149
 
150
+ function resolveFallbackCap(override?: number): number {
151
+ if (override !== undefined && override > 0) return override;
152
+ const env = coordEnv("AGENT_COORD_FALLBACK_CAP_BYTES");
153
+ const n = env ? Number(env) : Number.NaN;
154
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_FALLBACK_CAP_BYTES;
155
+ }
156
+
123
157
  function resolveTailBytes(override?: number): number {
124
158
  if (override !== undefined && override > 0) return override;
125
159
  const env = coordEnv("AGENT_COORD_TAIL_BYTES");
@@ -127,6 +161,30 @@ function resolveTailBytes(override?: number): number {
127
161
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_TAIL_BYTES;
128
162
  }
129
163
 
164
+ /**
165
+ * Read at most `capBytes` from the tail of the event stream as UTF-8, dropping
166
+ * the (partial) leading line when the file is larger than the cap. Shared
167
+ * bounded reader for CLI consumers (`agents trace` / `agents health`) that must
168
+ * never `readFileSync` the whole unbounded ledger — a >512MB read throws V8's
169
+ * max-string-length error. `truncated` is true when older bytes were skipped, so
170
+ * callers can surface the cap rather than silently under-reporting.
171
+ */
172
+ export function readStreamTailBounded(
173
+ streamPath: string,
174
+ capBytes: number,
175
+ ): { text: string; truncated: boolean } {
176
+ if (!existsSync(streamPath)) return { text: "", truncated: false };
177
+ const fileSize = statSync(streamPath).size;
178
+ const readBytes = Math.min(fileSize, capBytes);
179
+ let text = readTailUtf8(streamPath, fileSize, readBytes);
180
+ const truncated = readBytes < fileSize;
181
+ if (truncated) {
182
+ const nl = text.indexOf("\n");
183
+ text = nl >= 0 ? text.slice(nl + 1) : "";
184
+ }
185
+ return { text, truncated };
186
+ }
187
+
130
188
  /** Read the trailing `windowBytes` of a file as UTF-8 without loading the rest. */
131
189
  function readTailUtf8(streamPath: string, fileSize: number, windowBytes: number): string {
132
190
  const start = Math.max(0, fileSize - windowBytes);
@@ -5,7 +5,8 @@
5
5
  * event/schema types).
6
6
  *
7
7
  * Phase 4: agent-coord uses this from CLI handlers (state.task_set,
8
- * state.status_checked, state.scratch_append, council.*, presence.*).
8
+ * state.status_checked, state.scratch_append, council.*, decision.*,
9
+ * presence.*).
9
10
  */
10
11
 
11
12
  import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
@@ -81,7 +81,7 @@ export function appendScratch(
81
81
 
82
82
  /**
83
83
  * Replace the scratchpad with new body, archiving prior contents and
84
- * appending an "(edited via UI by Ryan)" audit-marker note.
84
+ * appending an "(edited via UI by the operator)" audit-marker note.
85
85
  */
86
86
  export function editScratchpad(
87
87
  coordRoot: string,
@@ -112,7 +112,7 @@ export function editScratchpad(
112
112
 
113
113
  const summaryText = summary && summary.length > 0 ? summary : "(no summary)";
114
114
  const auditMarker =
115
- `## [${ts}] note (edited via UI by Ryan)\n` +
115
+ `## [${ts}] note (edited via UI by the operator)\n` +
116
116
  `${summaryText}\n` +
117
117
  `Pre-edit archived at .harnery/scratch/archived/${instanceId}-${archiveSuffix}.md\n\n`;
118
118
 
@@ -23,7 +23,7 @@ import { findCoordRoot } from "./hooks/resolve/coord-root.ts";
23
23
  export const DEFAULT_BIN_NAME = "harn";
24
24
 
25
25
  interface HarneryConfig {
26
- /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "bp"). */
26
+ /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
27
27
  binName?: string;
28
28
  /**
29
29
  * Host-specific command that (re)installs the project's git hooks, surfaced
@@ -17,6 +17,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import { join } from "node:path";
19
19
  import { applyDetection } from "../../../lib/presence.ts";
20
+ import { resolveBinName } from "../../config.ts";
20
21
 
21
22
  export type { CaptureContext } from "./image-capture.ts";
22
23
  export { captureImages, imageJanitor } from "./image-capture.ts";
@@ -126,7 +127,7 @@ export function scratchArchive(repoRoot: string, owner: string): void {
126
127
  */
127
128
  export function syncClaudeSessions(repoRoot: string, force: boolean): void {
128
129
  try {
129
- const bin = join(repoRoot, "bin", "bp");
130
+ const bin = join(repoRoot, "bin", resolveBinName(repoRoot));
130
131
  if (!existsSync(bin)) return;
131
132
  const env: Record<string, string | undefined> = {
132
133
  ...process.env,
@@ -124,7 +124,7 @@ export class AgentBrowser {
124
124
  return;
125
125
  }
126
126
  const stateFile =
127
- this.opts.stateFilePath ?? `/tmp/bp-agent-browser-state-${process.pid}.json`;
127
+ this.opts.stateFilePath ?? `/tmp/harn-agent-browser-state-${process.pid}.json`;
128
128
  writeFileSync(stateFile, JSON.stringify(jarStore, null, 2));
129
129
  this.exec(["state", "load", stateFile], 10_000);
130
130
  this.cookiesSeeded = true;