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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- 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/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- 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 +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- 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/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- 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/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- 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/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /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
|
+
}
|