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,523 @@
1
+ /**
2
+ * Decision docket: a persistent queue of decisions an agent would otherwise
3
+ * route to a human, plus the lifecycle that carries each one from filing to a
4
+ * reviewed, graduated resolution.
5
+ *
6
+ * State layout (mirrors councils):
7
+ * - `.harnery/decisions/<id>.json` — one manifest per decision
8
+ * - `.harnery/decisions/<id>/` — long-form bodies (brief, options,
9
+ * evidence write-up) as markdown
10
+ * - `.harnery/decisions/archive/` — graduated / terminal decisions move here
11
+ *
12
+ * This module is the engine only. It stores `tier` (0/1/2) and `stakes` as
13
+ * opaque typed fields; what those *mean* — which decisions belong to which
14
+ * tier — is host policy, applied by the filing agent, never encoded here. That
15
+ * keeps the docket generic across host projects.
16
+ *
17
+ * Every function takes `coordRoot` explicitly (the council pattern) so the
18
+ * state machine is trivially testable against a tmpdir. The command layer
19
+ * resolves the root once via `monorepoRoot()` and threads it down.
20
+ *
21
+ * Concurrency: one file per decision (no shared index to contend on) + atomic
22
+ * temp→rename writes. `claim` is last-writer-wins — deliberating the same
23
+ * decision twice wastes tokens, not correctness.
24
+ */
25
+ import { randomBytes } from "node:crypto";
26
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ export const DECISION_SCHEMA_VERSION = 1;
29
+ /** Tier of human-involvement. Meaning is host policy; the engine only stores it. */
30
+ export const DECISION_TIERS = [0, 1, 2];
31
+ export const DECISION_STAKES = ["small", "medium", "high"];
32
+ export const DECISION_STATUSES = [
33
+ "filed",
34
+ "triaged",
35
+ "deliberating",
36
+ "resolved",
37
+ "enacted",
38
+ "reviewed",
39
+ "archived",
40
+ "superseded",
41
+ "wontfix",
42
+ ];
43
+ export const REVIEW_VERDICTS = [
44
+ "ratified",
45
+ "overridden",
46
+ "wrong-tier-high",
47
+ "wrong-tier-low",
48
+ ];
49
+ export const TERMINAL_STATUSES = ["archived", "superseded", "wontfix"];
50
+ /**
51
+ * Legal status transitions. `superseded` is reachable from any non-terminal
52
+ * state (a decision can be obsoleted at any point); `wontfix` closes an
53
+ * un-deliberated decision. `deliberating → triaged` allows the sweeper to
54
+ * re-triage a decision's tier on first touch (the self-triage safeguard).
55
+ */
56
+ export const LEGAL_TRANSITIONS = {
57
+ filed: ["triaged", "deliberating", "resolved", "superseded", "wontfix"],
58
+ triaged: ["deliberating", "resolved", "superseded", "wontfix"],
59
+ deliberating: ["triaged", "resolved", "superseded", "wontfix"],
60
+ resolved: ["enacted", "reviewed", "superseded"],
61
+ enacted: ["reviewed", "superseded"],
62
+ reviewed: ["archived", "superseded"],
63
+ archived: [],
64
+ superseded: [],
65
+ wontfix: [],
66
+ };
67
+ // ─── Type guards ────────────────────────────────────────────────────────────
68
+ export function isTier(n) {
69
+ return DECISION_TIERS.includes(n);
70
+ }
71
+ export function isStakes(s) {
72
+ return typeof s === "string" && DECISION_STAKES.includes(s);
73
+ }
74
+ export function isStatus(s) {
75
+ return typeof s === "string" && DECISION_STATUSES.includes(s);
76
+ }
77
+ export function isVerdict(v) {
78
+ return typeof v === "string" && REVIEW_VERDICTS.includes(v);
79
+ }
80
+ export function isTerminal(status) {
81
+ return TERMINAL_STATUSES.includes(status);
82
+ }
83
+ export function canTransition(from, to) {
84
+ return (LEGAL_TRANSITIONS[from] ?? []).includes(to);
85
+ }
86
+ // ─── Paths ──────────────────────────────────────────────────────────────────
87
+ export function decisionsDir(coordRoot) {
88
+ return join(coordRoot, ".harnery", "decisions");
89
+ }
90
+ export function archiveDir(coordRoot) {
91
+ return join(decisionsDir(coordRoot), "archive");
92
+ }
93
+ export function manifestPath(coordRoot, id) {
94
+ return join(decisionsDir(coordRoot), `${id}.json`);
95
+ }
96
+ export function archivedManifestPath(coordRoot, id) {
97
+ return join(archiveDir(coordRoot), `${id}.json`);
98
+ }
99
+ export function decisionBodyDir(coordRoot, id) {
100
+ return join(decisionsDir(coordRoot), id);
101
+ }
102
+ // ─── Low-level IO ─────────────────────────────────────────────────────────────
103
+ function nowIso() {
104
+ return new Date().toISOString();
105
+ }
106
+ function atomicWriteText(path, content) {
107
+ mkdirSync(dirname(path), { recursive: true });
108
+ const tmp = `${path}.tmp.${process.pid}`;
109
+ writeFileSync(tmp, content, "utf8");
110
+ renameSync(tmp, path);
111
+ }
112
+ /**
113
+ * Kebab-case slug from question text: first 5 words, lowercased,
114
+ * non-alphanumerics stripped. Local (not imported from council) so the module
115
+ * stays standalone.
116
+ */
117
+ export function deriveSlug(question) {
118
+ const cleaned = question
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9\s-]+/g, " ")
121
+ .split(/\s+/)
122
+ .filter(Boolean)
123
+ .slice(0, 5)
124
+ .join("-")
125
+ .replace(/-+/g, "-")
126
+ .replace(/^-|-$/g, "");
127
+ return cleaned || "decision";
128
+ }
129
+ /**
130
+ * Build a decision_id: `<slug>-<YYYY-MM-DD>-<4hex>`. The hex suffix is
131
+ * crypto-random (not a hash of the question) so two decisions sharing a
132
+ * slug + date don't collide.
133
+ */
134
+ export function buildDecisionId(question, now = new Date()) {
135
+ const slug = deriveSlug(question);
136
+ const date = now.toISOString().slice(0, 10);
137
+ const hash = randomBytes(2).toString("hex");
138
+ return `${slug}-${date}-${hash}`;
139
+ }
140
+ /**
141
+ * Read a manifest by id, checking the active dir then the archive. Returns null
142
+ * if absent or unparseable. Throws on an unsupported schema_version (fail loud
143
+ * on a real-but-incompatible manifest, the way councils do).
144
+ */
145
+ export function readManifest(coordRoot, id) {
146
+ for (const p of [manifestPath(coordRoot, id), archivedManifestPath(coordRoot, id)]) {
147
+ if (!existsSync(p))
148
+ continue;
149
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
150
+ if (parsed.schema_version !== DECISION_SCHEMA_VERSION) {
151
+ throw new Error(`decision ${id}: unsupported schema_version=${parsed.schema_version} (expected ${DECISION_SCHEMA_VERSION})`);
152
+ }
153
+ return parsed;
154
+ }
155
+ return null;
156
+ }
157
+ /** Locate whichever manifest path (active or archive) currently holds this id. */
158
+ function resolveManifestPath(coordRoot, id) {
159
+ const active = manifestPath(coordRoot, id);
160
+ if (existsSync(active))
161
+ return active;
162
+ const archived = archivedManifestPath(coordRoot, id);
163
+ if (existsSync(archived))
164
+ return archived;
165
+ return null;
166
+ }
167
+ export function writeManifest(coordRoot, manifest) {
168
+ const p = resolveManifestPath(coordRoot, manifest.decision_id) ??
169
+ manifestPath(coordRoot, manifest.decision_id);
170
+ atomicWriteText(p, `${JSON.stringify(manifest, null, 2)}\n`);
171
+ }
172
+ export function fileDecision(coordRoot, input) {
173
+ if (!input.question?.trim())
174
+ return { ok: false, reason: "question is empty" };
175
+ if (!isTier(input.tier))
176
+ return { ok: false, reason: `invalid tier ${input.tier} (0 | 1 | 2)` };
177
+ if (!isStakes(input.stakes)) {
178
+ return {
179
+ ok: false,
180
+ reason: `invalid stakes "${input.stakes}" (${DECISION_STAKES.join(" | ")})`,
181
+ };
182
+ }
183
+ const now = input.now ?? new Date();
184
+ const id = buildDecisionId(input.question, now);
185
+ const ts = now.toISOString();
186
+ const manifest = {
187
+ schema_version: DECISION_SCHEMA_VERSION,
188
+ decision_id: id,
189
+ status: "filed",
190
+ tier: input.tier,
191
+ stakes: input.stakes,
192
+ question: input.question.trim(),
193
+ context: input.context?.trim() || undefined,
194
+ default_taken: input.defaultTaken?.trim() || null,
195
+ filed_by: input.filedBy,
196
+ filed_by_id: input.filedById,
197
+ filed_at: ts,
198
+ claimed_by: null,
199
+ council_id: null,
200
+ resolution: null,
201
+ review: null,
202
+ graduated_to: null,
203
+ superseded_by: null,
204
+ wontfix_reason: null,
205
+ updated_at: ts,
206
+ };
207
+ atomicWriteText(manifestPath(coordRoot, id), `${JSON.stringify(manifest, null, 2)}\n`);
208
+ if (input.brief?.trim()) {
209
+ atomicWriteText(join(decisionBodyDir(coordRoot, id), "brief.md"), `${input.brief.trim()}\n`);
210
+ }
211
+ return { ok: true, manifest };
212
+ }
213
+ // ─── Transitions ───────────────────────────────────────────────────────────────
214
+ /**
215
+ * Apply a status change with legality + terminality checks, stamp updated_at,
216
+ * merge extra field patches, and persist. The single mutation chokepoint.
217
+ */
218
+ function transition(coordRoot, id, to, patch = {}) {
219
+ const manifest = readManifest(coordRoot, id);
220
+ if (!manifest)
221
+ return { ok: false, reason: `no decision "${id}"` };
222
+ if (isTerminal(manifest.status) && manifest.status !== to) {
223
+ return { ok: false, reason: `decision is ${manifest.status} (terminal, read-only)` };
224
+ }
225
+ if (manifest.status !== to && !canTransition(manifest.status, to)) {
226
+ return {
227
+ ok: false,
228
+ reason: `illegal transition ${manifest.status} → ${to} (legal: ${(LEGAL_TRANSITIONS[manifest.status] ?? []).join(", ") || "none"})`,
229
+ };
230
+ }
231
+ const updated = {
232
+ ...manifest,
233
+ ...patch,
234
+ status: to,
235
+ updated_at: nowIso(),
236
+ };
237
+ writeManifest(coordRoot, updated);
238
+ return { ok: true, manifest: updated };
239
+ }
240
+ export function triageDecision(coordRoot, id, opts) {
241
+ if (opts.tier !== undefined && !isTier(opts.tier)) {
242
+ return { ok: false, reason: `invalid tier ${opts.tier} (0 | 1 | 2)` };
243
+ }
244
+ if (opts.stakes !== undefined && !isStakes(opts.stakes)) {
245
+ return {
246
+ ok: false,
247
+ reason: `invalid stakes "${opts.stakes}" (${DECISION_STAKES.join(" | ")})`,
248
+ };
249
+ }
250
+ const patch = {};
251
+ if (opts.tier !== undefined)
252
+ patch.tier = opts.tier;
253
+ if (opts.stakes !== undefined)
254
+ patch.stakes = opts.stakes;
255
+ return transition(coordRoot, id, "triaged", patch);
256
+ }
257
+ export function claimDecision(coordRoot, id, owner) {
258
+ if (!owner?.trim())
259
+ return { ok: false, reason: "claim owner is empty" };
260
+ return transition(coordRoot, id, "deliberating", { claimed_by: owner.trim() });
261
+ }
262
+ export function escalateToCouncil(coordRoot, id, councilId) {
263
+ if (!councilId?.trim())
264
+ return { ok: false, reason: "council id is empty" };
265
+ return transition(coordRoot, id, "deliberating", { council_id: councilId.trim() });
266
+ }
267
+ /**
268
+ * Resolve a decision. Evidence is required (≥1 citation): a resolution with no
269
+ * cited evidence is structurally incomplete and bounced here — the same guard
270
+ * the sweeper enforces.
271
+ */
272
+ export function resolveDecision(coordRoot, id, resolution) {
273
+ if (!resolution.recommendation?.trim()) {
274
+ return { ok: false, reason: "resolution requires a recommendation" };
275
+ }
276
+ const evidence = (resolution.evidence ?? []).map((e) => e.trim()).filter(Boolean);
277
+ if (evidence.length === 0) {
278
+ return {
279
+ ok: false,
280
+ reason: "resolution requires ≥1 evidence citation (queries run, files read, costs computed) — an evidence-free resolution is bounced",
281
+ };
282
+ }
283
+ if (!resolution.resolved_by?.trim()) {
284
+ return { ok: false, reason: "resolution requires resolved_by" };
285
+ }
286
+ const full = {
287
+ recommendation: resolution.recommendation.trim(),
288
+ confidence: resolution.confidence?.trim() || undefined,
289
+ reversal_cost: resolution.reversal_cost?.trim() || undefined,
290
+ wrong_if: resolution.wrong_if?.trim() || undefined,
291
+ revisit_when: resolution.revisit_when?.trim() || undefined,
292
+ evidence,
293
+ resolved_by: resolution.resolved_by.trim(),
294
+ resolved_at: resolution.resolved_at ?? nowIso(),
295
+ };
296
+ return transition(coordRoot, id, "resolved", { resolution: full });
297
+ }
298
+ export function enactDecision(coordRoot, id) {
299
+ return transition(coordRoot, id, "enacted");
300
+ }
301
+ export function reviewDecision(coordRoot, id, opts) {
302
+ if (!isVerdict(opts.verdict)) {
303
+ return {
304
+ ok: false,
305
+ reason: `invalid verdict "${opts.verdict}" (${REVIEW_VERDICTS.join(" | ")})`,
306
+ };
307
+ }
308
+ const review = {
309
+ verdict: opts.verdict,
310
+ note: opts.note?.trim() || undefined,
311
+ reviewed_at: nowIso(),
312
+ };
313
+ return transition(coordRoot, id, "reviewed", { review });
314
+ }
315
+ export function supersedeDecision(coordRoot, id, bySupersedingId) {
316
+ return transition(coordRoot, id, "superseded", {
317
+ superseded_by: bySupersedingId?.trim() || null,
318
+ });
319
+ }
320
+ export function wontfixDecision(coordRoot, id, reason) {
321
+ return transition(coordRoot, id, "wontfix", { wontfix_reason: reason?.trim() || null });
322
+ }
323
+ /**
324
+ * Archive a decision (terminal). Records where its output graduated, then moves
325
+ * manifest + body dir into `archive/`. Idempotent-ish: safe to re-run.
326
+ */
327
+ export function archiveDecision(coordRoot, id, graduatedTo) {
328
+ const result = transition(coordRoot, id, "archived", {
329
+ graduated_to: graduatedTo?.trim() || null,
330
+ });
331
+ if (!result.ok)
332
+ return result;
333
+ const activeManifest = manifestPath(coordRoot, id);
334
+ const archivedManifest = archivedManifestPath(coordRoot, id);
335
+ const activeBody = decisionBodyDir(coordRoot, id);
336
+ const archivedBody = join(archiveDir(coordRoot), id);
337
+ mkdirSync(archiveDir(coordRoot), { recursive: true });
338
+ if (existsSync(activeManifest)) {
339
+ try {
340
+ renameSync(activeManifest, archivedManifest);
341
+ }
342
+ catch {
343
+ cpSync(activeManifest, archivedManifest);
344
+ rmSync(activeManifest, { force: true });
345
+ }
346
+ }
347
+ if (existsSync(activeBody)) {
348
+ if (existsSync(archivedBody)) {
349
+ rmSync(activeBody, { recursive: true, force: true });
350
+ }
351
+ else {
352
+ try {
353
+ renameSync(activeBody, archivedBody);
354
+ }
355
+ catch {
356
+ cpSync(activeBody, archivedBody, { recursive: true });
357
+ rmSync(activeBody, { recursive: true, force: true });
358
+ }
359
+ }
360
+ }
361
+ return result;
362
+ }
363
+ /**
364
+ * Reopen an archived decision back to `reviewed` — the inverse of `archive`,
365
+ * and the one sanctioned way out of the (otherwise terminal) archived state.
366
+ * Since `archived` has no legal outgoing transition, this deliberately bypasses
367
+ * the `transition` guard, the same way `archive` does its file moves outside it.
368
+ * Moves the manifest + body dir back from `archive/` into the active dir and
369
+ * clears `graduated_to` (a re-archive sets it fresh). Only `archived` decisions
370
+ * reopen; `superseded`/`wontfix` stay terminal.
371
+ *
372
+ * Ordering is write-active-then-remove-archived so an interrupted call leaves
373
+ * the decision reopened (active copy wins in `readManifest`) rather than lost.
374
+ */
375
+ export function reopenDecision(coordRoot, id) {
376
+ const manifest = readManifest(coordRoot, id);
377
+ if (!manifest)
378
+ return { ok: false, reason: `no decision "${id}"` };
379
+ if (manifest.status !== "archived") {
380
+ return {
381
+ ok: false,
382
+ reason: `only archived decisions can be reopened (this is ${manifest.status})`,
383
+ };
384
+ }
385
+ const archivedBody = join(archiveDir(coordRoot), id);
386
+ const activeBody = decisionBodyDir(coordRoot, id);
387
+ if (existsSync(archivedBody)) {
388
+ if (existsSync(activeBody)) {
389
+ rmSync(archivedBody, { recursive: true, force: true });
390
+ }
391
+ else {
392
+ try {
393
+ renameSync(archivedBody, activeBody);
394
+ }
395
+ catch {
396
+ cpSync(archivedBody, activeBody, { recursive: true });
397
+ rmSync(archivedBody, { recursive: true, force: true });
398
+ }
399
+ }
400
+ }
401
+ const reopened = {
402
+ ...manifest,
403
+ status: "reviewed",
404
+ graduated_to: null,
405
+ updated_at: nowIso(),
406
+ };
407
+ atomicWriteText(manifestPath(coordRoot, id), `${JSON.stringify(reopened, null, 2)}\n`);
408
+ const archived = archivedManifestPath(coordRoot, id);
409
+ if (existsSync(archived))
410
+ rmSync(archived, { force: true });
411
+ return { ok: true, manifest: reopened };
412
+ }
413
+ function scanManifests(dir) {
414
+ if (!existsSync(dir))
415
+ return [];
416
+ const out = [];
417
+ for (const f of readdirSync(dir)) {
418
+ if (!f.endsWith(".json"))
419
+ continue;
420
+ const p = join(dir, f);
421
+ try {
422
+ if (!statSync(p).isFile())
423
+ continue;
424
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
425
+ if (parsed.schema_version !== DECISION_SCHEMA_VERSION)
426
+ continue;
427
+ out.push(parsed);
428
+ }
429
+ catch {
430
+ // skip unparseable manifest; one bad file never kills the scan
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+ export function listDecisions(coordRoot, filter = {}) {
436
+ let rows = scanManifests(decisionsDir(coordRoot));
437
+ if (filter.includeArchived)
438
+ rows = rows.concat(scanManifests(archiveDir(coordRoot)));
439
+ if (filter.status)
440
+ rows = rows.filter((d) => d.status === filter.status);
441
+ if (filter.tier !== undefined)
442
+ rows = rows.filter((d) => d.tier === filter.tier);
443
+ if (filter.stakes)
444
+ rows = rows.filter((d) => d.stakes === filter.stakes);
445
+ if (filter.openOnly)
446
+ rows = rows.filter((d) => !isTerminal(d.status));
447
+ rows.sort((a, b) => (b.filed_at ?? "").localeCompare(a.filed_at ?? ""));
448
+ return rows;
449
+ }
450
+ export function showDecision(coordRoot, id) {
451
+ const manifest = readManifest(coordRoot, id);
452
+ if (!manifest)
453
+ return null;
454
+ const archived = !existsSync(manifestPath(coordRoot, id));
455
+ const bodyDir = archived ? join(archiveDir(coordRoot), id) : decisionBodyDir(coordRoot, id);
456
+ const bodies = [];
457
+ if (existsSync(bodyDir)) {
458
+ for (const f of readdirSync(bodyDir)) {
459
+ if (!f.endsWith(".md"))
460
+ continue;
461
+ try {
462
+ bodies.push({ name: f, content: readFileSync(join(bodyDir, f), "utf8") });
463
+ }
464
+ catch {
465
+ // skip unreadable body
466
+ }
467
+ }
468
+ }
469
+ return { manifest, bodies, archived };
470
+ }
471
+ /**
472
+ * Case-insensitive substring search over manifests + bodies. Deliberately
473
+ * dumb: precedent recall depends on the host's decision skill running this before
474
+ * filing, not on ranking sophistication. Includes the archive (precedent
475
+ * lives there).
476
+ */
477
+ export function searchDecisions(coordRoot, query) {
478
+ const q = query.trim().toLowerCase();
479
+ if (!q)
480
+ return [];
481
+ const all = listDecisions(coordRoot, { includeArchived: true });
482
+ const hits = [];
483
+ for (const manifest of all) {
484
+ const fields = [
485
+ { where: "question", text: manifest.question ?? "" },
486
+ { where: "context", text: manifest.context ?? "" },
487
+ {
488
+ where: "resolution",
489
+ text: manifest.resolution
490
+ ? `${manifest.resolution.recommendation} ${manifest.resolution.evidence.join(" ")}`
491
+ : "",
492
+ },
493
+ ];
494
+ const bodyDir = existsSync(manifestPath(coordRoot, manifest.decision_id))
495
+ ? decisionBodyDir(coordRoot, manifest.decision_id)
496
+ : join(archiveDir(coordRoot), manifest.decision_id);
497
+ if (existsSync(bodyDir)) {
498
+ for (const f of readdirSync(bodyDir)) {
499
+ if (!f.endsWith(".md"))
500
+ continue;
501
+ try {
502
+ fields.push({ where: "body", text: readFileSync(join(bodyDir, f), "utf8") });
503
+ }
504
+ catch {
505
+ // skip
506
+ }
507
+ }
508
+ }
509
+ for (const field of fields) {
510
+ const idx = field.text.toLowerCase().indexOf(q);
511
+ if (idx >= 0) {
512
+ const start = Math.max(0, idx - 40);
513
+ const snippet = field.text
514
+ .slice(start, idx + q.length + 40)
515
+ .replace(/\s+/g, " ")
516
+ .trim();
517
+ hits.push({ manifest, snippet: `${start > 0 ? "…" : ""}${snippet}…`, where: field.where });
518
+ break;
519
+ }
520
+ }
521
+ }
522
+ return hits;
523
+ }
@@ -2,6 +2,7 @@ export declare function initDocsContext(opts: {
2
2
  repoRoot: string;
3
3
  submodules: readonly string[];
4
4
  extraExcludedPrefixes?: readonly string[];
5
+ docsRootAllowlist?: readonly string[];
5
6
  }): void;
6
7
  /**
7
8
  * Documentation linter. Enforces the docs directory-layout + naming contract.
@@ -1 +1 @@
1
- {"version":3,"file":"docs-lint.d.ts","sourceRoot":"","sources":["../../src/lib/docs-lint.ts"],"names":[],"mappings":"AAYA,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C,GAAG,IAAI,CAIP;AAaD;;;;;;GAMG;AAEH,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;AAE3C,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAuVD,wBAAsB,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAoBlE"}
1
+ {"version":3,"file":"docs-lint.d.ts","sourceRoot":"","sources":["../../src/lib/docs-lint.ts"],"names":[],"mappings":"AAaA,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC,GAAG,IAAI,CAKP;AAaD;;;;;;GAMG;AAEH,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;AAE3C,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA+XD,wBAAsB,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAqBlE"}
@@ -8,10 +8,12 @@ import { sh } from "./exec.js";
8
8
  let REPO_ROOT = "";
9
9
  let SUBMODULES = [];
10
10
  let EXTRA_EXCLUDED_PREFIXES = [];
11
+ let DOCS_ROOT_ALLOWLIST = [];
11
12
  export function initDocsContext(opts) {
12
13
  REPO_ROOT = opts.repoRoot;
13
14
  SUBMODULES = opts.submodules;
14
15
  EXTRA_EXCLUDED_PREFIXES = opts.extraExcludedPrefixes ?? [];
16
+ DOCS_ROOT_ALLOWLIST = opts.docsRootAllowlist ?? [];
15
17
  }
16
18
  function submodulePath(name) {
17
19
  return __resolveForDocs(REPO_ROOT, name);
@@ -196,6 +198,52 @@ function checkRootAllowlist(repoName, repoPath) {
196
198
  }
197
199
  return violations;
198
200
  }
201
+ /**
202
+ * The host project's `docs/` root is an entry tier: only allowlisted files may
203
+ * sit loose there; topic docs belong in `docs/<topic>/` subdirs. Config-gated —
204
+ * a no-op unless the host supplies `docsRootAllowlist`. Parent-repo only:
205
+ * submodule `docs/` roots have their own entry tiers, not this one.
206
+ */
207
+ function checkDocsRootAllowlist(repoName, repoPath) {
208
+ const violations = [];
209
+ if (DOCS_ROOT_ALLOWLIST.length === 0)
210
+ return violations; // opt-in
211
+ if (repoName !== "(root)")
212
+ return violations; // parent repo only
213
+ const allow = new Set(DOCS_ROOT_ALLOWLIST);
214
+ const docsDir = join(repoPath, "docs");
215
+ let entries;
216
+ try {
217
+ entries = readdirSync(docsDir);
218
+ }
219
+ catch {
220
+ return violations; // no docs/ dir — nothing to check
221
+ }
222
+ for (const entry of entries) {
223
+ // Subdirs are the intended home for topic docs; only loose files matter.
224
+ let isFile;
225
+ try {
226
+ isFile = statSync(join(docsDir, entry)).isFile();
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ if (!isFile)
232
+ continue;
233
+ if (!(entry.endsWith(".md") || entry.endsWith(".json")))
234
+ continue;
235
+ if (allow.has(entry))
236
+ continue;
237
+ violations.push({
238
+ severity: "error",
239
+ repo: repoName,
240
+ path: join("docs", entry),
241
+ rule: "docs-root-file",
242
+ message: `${entry} is not allowed loose at docs/ root — move it into a docs/<topic>/ subdir (or add it to context.docsRootAllowlist if it's a genuine entry-tier doc)`,
243
+ });
244
+ }
245
+ return violations;
246
+ }
199
247
  /** No SCREAMING_SNAKE_CASE filenames anywhere */
200
248
  function checkNamingConvention(repoName, _repoPath, files) {
201
249
  const violations = [];
@@ -371,6 +419,7 @@ export async function runLint(opts) {
371
419
  for (const { name, path, isSubmodule } of repos) {
372
420
  violations.push(...checkEntryTier(name, path, isSubmodule));
373
421
  violations.push(...checkRootAllowlist(name, path));
422
+ violations.push(...checkDocsRootAllowlist(name, path));
374
423
  const files = await findMarkdownFiles(path);
375
424
  violations.push(...checkNamingConvention(name, path, files));
376
425
  violations.push(...checkDatedDirs(name, path, files));
@@ -97,5 +97,5 @@ const server = Bun.serve({
97
97
  // This worker runs detached via `bun run gate.ts`, outside the CLI command
98
98
  // framework, so no AsyncLocalStorage context is available. stdout/stderr is
99
99
  // captured into .cache/tunnel/gate.log by the spawning command.
100
- console.log(`bp-tunnel-gate :${server.port} -> ${UPSTREAM_HTTP} (Host: ${VHOST})`); // lint-ok-emission: detached worker, see file note above
100
+ console.log(`harn-tunnel-gate :${server.port} -> ${UPSTREAM_HTTP} (Host: ${VHOST})`); // lint-ok-emission: detached worker, see file note above
101
101
  console.log(`allow: ${[...ALLOW].join(", ") || "(empty, denies all)"}`); // lint-ok-emission: detached worker, see file note above
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Multi-agent coordination + harness adapters + portable CLI utilities for Claude Code / Cursor / Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -128,6 +128,8 @@
128
128
  "prepublishOnly": "npm run clean && npm run build",
129
129
  "typecheck": "bun x tsc --noEmit",
130
130
  "lint": "bun x @biomejs/biome check src",
131
+ "check:portability": "bun run scripts/check-portability.ts",
132
+ "lint:fix": "bun x @biomejs/biome check --write src",
131
133
  "format": "bun x @biomejs/biome format --write src",
132
134
  "test": "bun test src tests",
133
135
  "test:web": "cd web && bun install && bun test",
package/src/commander.ts CHANGED
@@ -27,6 +27,7 @@ import { registerCompletionCommand } from "./commands/completion.ts";
27
27
  import { registerConfigGetCommand } from "./commands/config-get.ts";
28
28
  import { registerContextCommand } from "./commands/context.ts";
29
29
  import { registerCookiesCommand } from "./commands/cookies.ts";
30
+ import { registerDecisionCommand } from "./commands/decision.ts";
30
31
  import { registerDeinitCommand } from "./commands/deinit.ts";
31
32
  import { registerDocsCommand } from "./commands/docs.ts";
32
33
  import { registerDoctorCommand } from "./commands/doctor.ts";
@@ -129,6 +130,16 @@ export interface HarneryProgramContext {
129
130
  * exclusions (`.claude/`, `.harnery/`, `.codex/`, `.cursor/`).
130
131
  */
131
132
  extraDocsExcludedPrefixes?: readonly string[];
133
+ /**
134
+ * Filenames permitted at the host project's `docs/` root (parent repo only).
135
+ * When set, `harn docs lint` flags any other `.md`/`.json` file sitting
136
+ * loose at `docs/` root (rule `docs-root-file`) — topic docs belong in
137
+ * `docs/<topic>/` subdirs. Names are matched exactly (basename). When
138
+ * omitted or empty, the rule is a no-op, so standalone `harn` and consumers
139
+ * that don't opt in are unaffected. Submodule `docs/` roots are never
140
+ * checked (their entry tiers differ from the parent's).
141
+ */
142
+ docsRootAllowlist?: readonly string[];
132
143
  /**
133
144
  * Default Host header for `tunnel up` when `--vhost` is omitted: a literal
134
145
  * host, or a resolver evaluated at start time (e.g. read a dev stack's
@@ -225,6 +236,7 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
225
236
  registerCompletionCommand(program, emit, opts.context);
226
237
  registerContextCommand(program, emit, opts.context);
227
238
  registerScratchCommand(program, emit);
239
+ registerDecisionCommand(program, emit);
228
240
  registerTunnelCommand(program, emit, opts.context);
229
241
  registerDocsCommand(program, emit, opts.context);
230
242
  registerAgentsCommand(program, emit);