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.
- package/dist/commander.d.ts +10 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +43 -29
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +5 -1
- package/dist/commands/fetch.js +1 -1
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +2 -1
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +2 -1
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/docs-lint.d.ts +1 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +49 -0
- package/dist/lib/tunnel/gate.js +1 -1
- package/package.json +3 -1
- package/src/commander.ts +12 -0
- package/src/commands/agents.ts +47 -26
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/docs.ts +5 -1
- package/src/commands/fetch.ts +1 -1
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +2 -1
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/hooks/effects/index.ts +2 -1
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/docs-lint.ts +44 -0
- 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
|
+
}
|
package/src/commands/docs.ts
CHANGED
|
@@ -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({
|
|
19
|
+
initDocsLint({
|
|
20
|
+
...opts,
|
|
21
|
+
extraExcludedPrefixes: context.extraDocsExcludedPrefixes,
|
|
22
|
+
docsRootAllowlist: context.docsRootAllowlist,
|
|
23
|
+
});
|
|
20
24
|
initDocsSweep(opts);
|
|
21
25
|
}
|
|
22
26
|
|
package/src/commands/fetch.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
15
|
-
*
|
|
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
|
-
//
|
|
107
|
-
|
|
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
|
|
118
|
-
//
|
|
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.*,
|
|
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
|
|
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
|
|
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
|
|
package/src/core/config.ts
CHANGED
|
@@ -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. "
|
|
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",
|
|
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/
|
|
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;
|