harnery 0.5.0 → 0.7.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 (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -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
+ }