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,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
|
+
}
|
package/dist/lib/docs-lint.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/lib/docs-lint.js
CHANGED
|
@@ -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));
|
package/dist/lib/tunnel/gate.js
CHANGED
|
@@ -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(`
|
|
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.
|
|
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);
|