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,685 @@
|
|
|
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
|
+
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
import {
|
|
28
|
+
cpSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
statSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
|
|
40
|
+
export const DECISION_SCHEMA_VERSION = 1 as const;
|
|
41
|
+
|
|
42
|
+
/** Tier of human-involvement. Meaning is host policy; the engine only stores it. */
|
|
43
|
+
export const DECISION_TIERS = [0, 1, 2] as const;
|
|
44
|
+
export type DecisionTier = (typeof DECISION_TIERS)[number];
|
|
45
|
+
|
|
46
|
+
export const DECISION_STAKES = ["small", "medium", "high"] as const;
|
|
47
|
+
export type DecisionStakes = (typeof DECISION_STAKES)[number];
|
|
48
|
+
|
|
49
|
+
export const DECISION_STATUSES = [
|
|
50
|
+
"filed",
|
|
51
|
+
"triaged",
|
|
52
|
+
"deliberating",
|
|
53
|
+
"resolved",
|
|
54
|
+
"enacted",
|
|
55
|
+
"reviewed",
|
|
56
|
+
"archived",
|
|
57
|
+
"superseded",
|
|
58
|
+
"wontfix",
|
|
59
|
+
] as const;
|
|
60
|
+
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
|
|
61
|
+
|
|
62
|
+
export const REVIEW_VERDICTS = [
|
|
63
|
+
"ratified",
|
|
64
|
+
"overridden",
|
|
65
|
+
"wrong-tier-high",
|
|
66
|
+
"wrong-tier-low",
|
|
67
|
+
] as const;
|
|
68
|
+
export type ReviewVerdict = (typeof REVIEW_VERDICTS)[number];
|
|
69
|
+
|
|
70
|
+
export const TERMINAL_STATUSES: readonly DecisionStatus[] = ["archived", "superseded", "wontfix"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Legal status transitions. `superseded` is reachable from any non-terminal
|
|
74
|
+
* state (a decision can be obsoleted at any point); `wontfix` closes an
|
|
75
|
+
* un-deliberated decision. `deliberating → triaged` allows the sweeper to
|
|
76
|
+
* re-triage a decision's tier on first touch (the self-triage safeguard).
|
|
77
|
+
*/
|
|
78
|
+
export const LEGAL_TRANSITIONS: Record<DecisionStatus, readonly DecisionStatus[]> = {
|
|
79
|
+
filed: ["triaged", "deliberating", "resolved", "superseded", "wontfix"],
|
|
80
|
+
triaged: ["deliberating", "resolved", "superseded", "wontfix"],
|
|
81
|
+
deliberating: ["triaged", "resolved", "superseded", "wontfix"],
|
|
82
|
+
resolved: ["enacted", "reviewed", "superseded"],
|
|
83
|
+
enacted: ["reviewed", "superseded"],
|
|
84
|
+
reviewed: ["archived", "superseded"],
|
|
85
|
+
archived: [],
|
|
86
|
+
superseded: [],
|
|
87
|
+
wontfix: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export interface DecisionResolution {
|
|
91
|
+
recommendation: string;
|
|
92
|
+
confidence?: string;
|
|
93
|
+
reversal_cost?: string;
|
|
94
|
+
/** What would make this resolution wrong (the pre-mortem). */
|
|
95
|
+
wrong_if?: string;
|
|
96
|
+
/** When this should be revisited (a trigger, not a date). */
|
|
97
|
+
revisit_when?: string;
|
|
98
|
+
/** Citations: queries run, files read, costs computed. Required (≥1). */
|
|
99
|
+
evidence: string[];
|
|
100
|
+
resolved_by: string;
|
|
101
|
+
resolved_at: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DecisionReview {
|
|
105
|
+
verdict: ReviewVerdict;
|
|
106
|
+
note?: string;
|
|
107
|
+
reviewed_at: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DecisionManifest {
|
|
111
|
+
schema_version: typeof DECISION_SCHEMA_VERSION;
|
|
112
|
+
decision_id: string;
|
|
113
|
+
status: DecisionStatus;
|
|
114
|
+
/** Provisional at file time; the sweeper may re-check on claim. */
|
|
115
|
+
tier: DecisionTier;
|
|
116
|
+
stakes: DecisionStakes;
|
|
117
|
+
question: string;
|
|
118
|
+
context?: string;
|
|
119
|
+
/** What the filer proceeded with under always-proceed (tier 0/1), or null. */
|
|
120
|
+
default_taken?: string | null;
|
|
121
|
+
/** Agent name (e.g. "agent-Quill"), for display. */
|
|
122
|
+
filed_by?: string;
|
|
123
|
+
/** Filer instance_id. */
|
|
124
|
+
filed_by_id?: string;
|
|
125
|
+
filed_at: string;
|
|
126
|
+
/** Deliberator (agent instance_id or a sweeper session), or null. */
|
|
127
|
+
claimed_by?: string | null;
|
|
128
|
+
/** Set when escalated to a council. */
|
|
129
|
+
council_id?: string | null;
|
|
130
|
+
resolution?: DecisionResolution | null;
|
|
131
|
+
review?: DecisionReview | null;
|
|
132
|
+
/** Where the resolved output graduated (e.g. "docs/decisions.md#…"), or null. */
|
|
133
|
+
graduated_to?: string | null;
|
|
134
|
+
/** Set when superseded by another decision. */
|
|
135
|
+
superseded_by?: string | null;
|
|
136
|
+
/** Reason recorded on wontfix. */
|
|
137
|
+
wontfix_reason?: string | null;
|
|
138
|
+
updated_at?: string;
|
|
139
|
+
[extra: string]: unknown;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface DecisionOpResult {
|
|
143
|
+
ok: boolean;
|
|
144
|
+
reason?: string;
|
|
145
|
+
manifest?: DecisionManifest;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Type guards ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export function isTier(n: unknown): n is DecisionTier {
|
|
151
|
+
return (DECISION_TIERS as readonly unknown[]).includes(n);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isStakes(s: unknown): s is DecisionStakes {
|
|
155
|
+
return typeof s === "string" && (DECISION_STAKES as readonly string[]).includes(s);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isStatus(s: unknown): s is DecisionStatus {
|
|
159
|
+
return typeof s === "string" && (DECISION_STATUSES as readonly string[]).includes(s);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isVerdict(v: unknown): v is ReviewVerdict {
|
|
163
|
+
return typeof v === "string" && (REVIEW_VERDICTS as readonly string[]).includes(v);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isTerminal(status: DecisionStatus): boolean {
|
|
167
|
+
return TERMINAL_STATUSES.includes(status);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function canTransition(from: DecisionStatus, to: DecisionStatus): boolean {
|
|
171
|
+
return (LEGAL_TRANSITIONS[from] ?? []).includes(to);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export function decisionsDir(coordRoot: string): string {
|
|
177
|
+
return join(coordRoot, ".harnery", "decisions");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function archiveDir(coordRoot: string): string {
|
|
181
|
+
return join(decisionsDir(coordRoot), "archive");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function manifestPath(coordRoot: string, id: string): string {
|
|
185
|
+
return join(decisionsDir(coordRoot), `${id}.json`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function archivedManifestPath(coordRoot: string, id: string): string {
|
|
189
|
+
return join(archiveDir(coordRoot), `${id}.json`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function decisionBodyDir(coordRoot: string, id: string): string {
|
|
193
|
+
return join(decisionsDir(coordRoot), id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Low-level IO ─────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function nowIso(): string {
|
|
199
|
+
return new Date().toISOString();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function atomicWriteText(path: string, content: string): void {
|
|
203
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
204
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
205
|
+
writeFileSync(tmp, content, "utf8");
|
|
206
|
+
renameSync(tmp, path);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Kebab-case slug from question text: first 5 words, lowercased,
|
|
211
|
+
* non-alphanumerics stripped. Local (not imported from council) so the module
|
|
212
|
+
* stays standalone.
|
|
213
|
+
*/
|
|
214
|
+
export function deriveSlug(question: string): string {
|
|
215
|
+
const cleaned = question
|
|
216
|
+
.toLowerCase()
|
|
217
|
+
.replace(/[^a-z0-9\s-]+/g, " ")
|
|
218
|
+
.split(/\s+/)
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.slice(0, 5)
|
|
221
|
+
.join("-")
|
|
222
|
+
.replace(/-+/g, "-")
|
|
223
|
+
.replace(/^-|-$/g, "");
|
|
224
|
+
return cleaned || "decision";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build a decision_id: `<slug>-<YYYY-MM-DD>-<4hex>`. The hex suffix is
|
|
229
|
+
* crypto-random (not a hash of the question) so two decisions sharing a
|
|
230
|
+
* slug + date don't collide.
|
|
231
|
+
*/
|
|
232
|
+
export function buildDecisionId(question: string, now: Date = new Date()): string {
|
|
233
|
+
const slug = deriveSlug(question);
|
|
234
|
+
const date = now.toISOString().slice(0, 10);
|
|
235
|
+
const hash = randomBytes(2).toString("hex");
|
|
236
|
+
return `${slug}-${date}-${hash}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read a manifest by id, checking the active dir then the archive. Returns null
|
|
241
|
+
* if absent or unparseable. Throws on an unsupported schema_version (fail loud
|
|
242
|
+
* on a real-but-incompatible manifest, the way councils do).
|
|
243
|
+
*/
|
|
244
|
+
export function readManifest(coordRoot: string, id: string): DecisionManifest | null {
|
|
245
|
+
for (const p of [manifestPath(coordRoot, id), archivedManifestPath(coordRoot, id)]) {
|
|
246
|
+
if (!existsSync(p)) continue;
|
|
247
|
+
const parsed = JSON.parse(readFileSync(p, "utf8")) as DecisionManifest;
|
|
248
|
+
if (parsed.schema_version !== DECISION_SCHEMA_VERSION) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`decision ${id}: unsupported schema_version=${parsed.schema_version} (expected ${DECISION_SCHEMA_VERSION})`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return parsed;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Locate whichever manifest path (active or archive) currently holds this id. */
|
|
259
|
+
function resolveManifestPath(coordRoot: string, id: string): string | null {
|
|
260
|
+
const active = manifestPath(coordRoot, id);
|
|
261
|
+
if (existsSync(active)) return active;
|
|
262
|
+
const archived = archivedManifestPath(coordRoot, id);
|
|
263
|
+
if (existsSync(archived)) return archived;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function writeManifest(coordRoot: string, manifest: DecisionManifest): void {
|
|
268
|
+
const p =
|
|
269
|
+
resolveManifestPath(coordRoot, manifest.decision_id) ??
|
|
270
|
+
manifestPath(coordRoot, manifest.decision_id);
|
|
271
|
+
atomicWriteText(p, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Filing ───────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export interface FileDecisionInput {
|
|
277
|
+
question: string;
|
|
278
|
+
tier: DecisionTier;
|
|
279
|
+
stakes: DecisionStakes;
|
|
280
|
+
context?: string;
|
|
281
|
+
defaultTaken?: string;
|
|
282
|
+
filedBy?: string;
|
|
283
|
+
filedById?: string;
|
|
284
|
+
/** Long-form brief written to `<id>/brief.md`. */
|
|
285
|
+
brief?: string;
|
|
286
|
+
now?: Date;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function fileDecision(coordRoot: string, input: FileDecisionInput): DecisionOpResult {
|
|
290
|
+
if (!input.question?.trim()) return { ok: false, reason: "question is empty" };
|
|
291
|
+
if (!isTier(input.tier)) return { ok: false, reason: `invalid tier ${input.tier} (0 | 1 | 2)` };
|
|
292
|
+
if (!isStakes(input.stakes)) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: `invalid stakes "${input.stakes}" (${DECISION_STAKES.join(" | ")})`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const now = input.now ?? new Date();
|
|
299
|
+
const id = buildDecisionId(input.question, now);
|
|
300
|
+
const ts = now.toISOString();
|
|
301
|
+
const manifest: DecisionManifest = {
|
|
302
|
+
schema_version: DECISION_SCHEMA_VERSION,
|
|
303
|
+
decision_id: id,
|
|
304
|
+
status: "filed",
|
|
305
|
+
tier: input.tier,
|
|
306
|
+
stakes: input.stakes,
|
|
307
|
+
question: input.question.trim(),
|
|
308
|
+
context: input.context?.trim() || undefined,
|
|
309
|
+
default_taken: input.defaultTaken?.trim() || null,
|
|
310
|
+
filed_by: input.filedBy,
|
|
311
|
+
filed_by_id: input.filedById,
|
|
312
|
+
filed_at: ts,
|
|
313
|
+
claimed_by: null,
|
|
314
|
+
council_id: null,
|
|
315
|
+
resolution: null,
|
|
316
|
+
review: null,
|
|
317
|
+
graduated_to: null,
|
|
318
|
+
superseded_by: null,
|
|
319
|
+
wontfix_reason: null,
|
|
320
|
+
updated_at: ts,
|
|
321
|
+
};
|
|
322
|
+
atomicWriteText(manifestPath(coordRoot, id), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
323
|
+
if (input.brief?.trim()) {
|
|
324
|
+
atomicWriteText(join(decisionBodyDir(coordRoot, id), "brief.md"), `${input.brief.trim()}\n`);
|
|
325
|
+
}
|
|
326
|
+
return { ok: true, manifest };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Transitions ───────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Apply a status change with legality + terminality checks, stamp updated_at,
|
|
333
|
+
* merge extra field patches, and persist. The single mutation chokepoint.
|
|
334
|
+
*/
|
|
335
|
+
function transition(
|
|
336
|
+
coordRoot: string,
|
|
337
|
+
id: string,
|
|
338
|
+
to: DecisionStatus,
|
|
339
|
+
patch: Partial<DecisionManifest> = {},
|
|
340
|
+
): DecisionOpResult {
|
|
341
|
+
const manifest = readManifest(coordRoot, id);
|
|
342
|
+
if (!manifest) return { ok: false, reason: `no decision "${id}"` };
|
|
343
|
+
if (isTerminal(manifest.status) && manifest.status !== to) {
|
|
344
|
+
return { ok: false, reason: `decision is ${manifest.status} (terminal, read-only)` };
|
|
345
|
+
}
|
|
346
|
+
if (manifest.status !== to && !canTransition(manifest.status, to)) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
reason: `illegal transition ${manifest.status} → ${to} (legal: ${(LEGAL_TRANSITIONS[manifest.status] ?? []).join(", ") || "none"})`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const updated: DecisionManifest = {
|
|
353
|
+
...manifest,
|
|
354
|
+
...patch,
|
|
355
|
+
status: to,
|
|
356
|
+
updated_at: nowIso(),
|
|
357
|
+
};
|
|
358
|
+
writeManifest(coordRoot, updated);
|
|
359
|
+
return { ok: true, manifest: updated };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function triageDecision(
|
|
363
|
+
coordRoot: string,
|
|
364
|
+
id: string,
|
|
365
|
+
opts: { tier?: DecisionTier; stakes?: DecisionStakes },
|
|
366
|
+
): DecisionOpResult {
|
|
367
|
+
if (opts.tier !== undefined && !isTier(opts.tier)) {
|
|
368
|
+
return { ok: false, reason: `invalid tier ${opts.tier} (0 | 1 | 2)` };
|
|
369
|
+
}
|
|
370
|
+
if (opts.stakes !== undefined && !isStakes(opts.stakes)) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
reason: `invalid stakes "${opts.stakes}" (${DECISION_STAKES.join(" | ")})`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const patch: Partial<DecisionManifest> = {};
|
|
377
|
+
if (opts.tier !== undefined) patch.tier = opts.tier;
|
|
378
|
+
if (opts.stakes !== undefined) patch.stakes = opts.stakes;
|
|
379
|
+
return transition(coordRoot, id, "triaged", patch);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function claimDecision(coordRoot: string, id: string, owner: string): DecisionOpResult {
|
|
383
|
+
if (!owner?.trim()) return { ok: false, reason: "claim owner is empty" };
|
|
384
|
+
return transition(coordRoot, id, "deliberating", { claimed_by: owner.trim() });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function escalateToCouncil(
|
|
388
|
+
coordRoot: string,
|
|
389
|
+
id: string,
|
|
390
|
+
councilId: string,
|
|
391
|
+
): DecisionOpResult {
|
|
392
|
+
if (!councilId?.trim()) return { ok: false, reason: "council id is empty" };
|
|
393
|
+
return transition(coordRoot, id, "deliberating", { council_id: councilId.trim() });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Resolve a decision. Evidence is required (≥1 citation): a resolution with no
|
|
398
|
+
* cited evidence is structurally incomplete and bounced here — the same guard
|
|
399
|
+
* the sweeper enforces.
|
|
400
|
+
*/
|
|
401
|
+
export function resolveDecision(
|
|
402
|
+
coordRoot: string,
|
|
403
|
+
id: string,
|
|
404
|
+
resolution: Omit<DecisionResolution, "resolved_at"> & { resolved_at?: string },
|
|
405
|
+
): DecisionOpResult {
|
|
406
|
+
if (!resolution.recommendation?.trim()) {
|
|
407
|
+
return { ok: false, reason: "resolution requires a recommendation" };
|
|
408
|
+
}
|
|
409
|
+
const evidence = (resolution.evidence ?? []).map((e) => e.trim()).filter(Boolean);
|
|
410
|
+
if (evidence.length === 0) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
reason:
|
|
414
|
+
"resolution requires ≥1 evidence citation (queries run, files read, costs computed) — an evidence-free resolution is bounced",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (!resolution.resolved_by?.trim()) {
|
|
418
|
+
return { ok: false, reason: "resolution requires resolved_by" };
|
|
419
|
+
}
|
|
420
|
+
const full: DecisionResolution = {
|
|
421
|
+
recommendation: resolution.recommendation.trim(),
|
|
422
|
+
confidence: resolution.confidence?.trim() || undefined,
|
|
423
|
+
reversal_cost: resolution.reversal_cost?.trim() || undefined,
|
|
424
|
+
wrong_if: resolution.wrong_if?.trim() || undefined,
|
|
425
|
+
revisit_when: resolution.revisit_when?.trim() || undefined,
|
|
426
|
+
evidence,
|
|
427
|
+
resolved_by: resolution.resolved_by.trim(),
|
|
428
|
+
resolved_at: resolution.resolved_at ?? nowIso(),
|
|
429
|
+
};
|
|
430
|
+
return transition(coordRoot, id, "resolved", { resolution: full });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function enactDecision(coordRoot: string, id: string): DecisionOpResult {
|
|
434
|
+
return transition(coordRoot, id, "enacted");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function reviewDecision(
|
|
438
|
+
coordRoot: string,
|
|
439
|
+
id: string,
|
|
440
|
+
opts: { verdict: ReviewVerdict; note?: string },
|
|
441
|
+
): DecisionOpResult {
|
|
442
|
+
if (!isVerdict(opts.verdict)) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
reason: `invalid verdict "${opts.verdict}" (${REVIEW_VERDICTS.join(" | ")})`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const review: DecisionReview = {
|
|
449
|
+
verdict: opts.verdict,
|
|
450
|
+
note: opts.note?.trim() || undefined,
|
|
451
|
+
reviewed_at: nowIso(),
|
|
452
|
+
};
|
|
453
|
+
return transition(coordRoot, id, "reviewed", { review });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function supersedeDecision(
|
|
457
|
+
coordRoot: string,
|
|
458
|
+
id: string,
|
|
459
|
+
bySupersedingId?: string,
|
|
460
|
+
): DecisionOpResult {
|
|
461
|
+
return transition(coordRoot, id, "superseded", {
|
|
462
|
+
superseded_by: bySupersedingId?.trim() || null,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function wontfixDecision(coordRoot: string, id: string, reason?: string): DecisionOpResult {
|
|
467
|
+
return transition(coordRoot, id, "wontfix", { wontfix_reason: reason?.trim() || null });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Archive a decision (terminal). Records where its output graduated, then moves
|
|
472
|
+
* manifest + body dir into `archive/`. Idempotent-ish: safe to re-run.
|
|
473
|
+
*/
|
|
474
|
+
export function archiveDecision(
|
|
475
|
+
coordRoot: string,
|
|
476
|
+
id: string,
|
|
477
|
+
graduatedTo?: string,
|
|
478
|
+
): DecisionOpResult {
|
|
479
|
+
const result = transition(coordRoot, id, "archived", {
|
|
480
|
+
graduated_to: graduatedTo?.trim() || null,
|
|
481
|
+
});
|
|
482
|
+
if (!result.ok) return result;
|
|
483
|
+
|
|
484
|
+
const activeManifest = manifestPath(coordRoot, id);
|
|
485
|
+
const archivedManifest = archivedManifestPath(coordRoot, id);
|
|
486
|
+
const activeBody = decisionBodyDir(coordRoot, id);
|
|
487
|
+
const archivedBody = join(archiveDir(coordRoot), id);
|
|
488
|
+
|
|
489
|
+
mkdirSync(archiveDir(coordRoot), { recursive: true });
|
|
490
|
+
if (existsSync(activeManifest)) {
|
|
491
|
+
try {
|
|
492
|
+
renameSync(activeManifest, archivedManifest);
|
|
493
|
+
} catch {
|
|
494
|
+
cpSync(activeManifest, archivedManifest);
|
|
495
|
+
rmSync(activeManifest, { force: true });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (existsSync(activeBody)) {
|
|
499
|
+
if (existsSync(archivedBody)) {
|
|
500
|
+
rmSync(activeBody, { recursive: true, force: true });
|
|
501
|
+
} else {
|
|
502
|
+
try {
|
|
503
|
+
renameSync(activeBody, archivedBody);
|
|
504
|
+
} catch {
|
|
505
|
+
cpSync(activeBody, archivedBody, { recursive: true });
|
|
506
|
+
rmSync(activeBody, { recursive: true, force: true });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reopen an archived decision back to `reviewed` — the inverse of `archive`,
|
|
515
|
+
* and the one sanctioned way out of the (otherwise terminal) archived state.
|
|
516
|
+
* Since `archived` has no legal outgoing transition, this deliberately bypasses
|
|
517
|
+
* the `transition` guard, the same way `archive` does its file moves outside it.
|
|
518
|
+
* Moves the manifest + body dir back from `archive/` into the active dir and
|
|
519
|
+
* clears `graduated_to` (a re-archive sets it fresh). Only `archived` decisions
|
|
520
|
+
* reopen; `superseded`/`wontfix` stay terminal.
|
|
521
|
+
*
|
|
522
|
+
* Ordering is write-active-then-remove-archived so an interrupted call leaves
|
|
523
|
+
* the decision reopened (active copy wins in `readManifest`) rather than lost.
|
|
524
|
+
*/
|
|
525
|
+
export function reopenDecision(coordRoot: string, id: string): DecisionOpResult {
|
|
526
|
+
const manifest = readManifest(coordRoot, id);
|
|
527
|
+
if (!manifest) return { ok: false, reason: `no decision "${id}"` };
|
|
528
|
+
if (manifest.status !== "archived") {
|
|
529
|
+
return {
|
|
530
|
+
ok: false,
|
|
531
|
+
reason: `only archived decisions can be reopened (this is ${manifest.status})`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const archivedBody = join(archiveDir(coordRoot), id);
|
|
536
|
+
const activeBody = decisionBodyDir(coordRoot, id);
|
|
537
|
+
if (existsSync(archivedBody)) {
|
|
538
|
+
if (existsSync(activeBody)) {
|
|
539
|
+
rmSync(archivedBody, { recursive: true, force: true });
|
|
540
|
+
} else {
|
|
541
|
+
try {
|
|
542
|
+
renameSync(archivedBody, activeBody);
|
|
543
|
+
} catch {
|
|
544
|
+
cpSync(archivedBody, activeBody, { recursive: true });
|
|
545
|
+
rmSync(archivedBody, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const reopened: DecisionManifest = {
|
|
551
|
+
...manifest,
|
|
552
|
+
status: "reviewed",
|
|
553
|
+
graduated_to: null,
|
|
554
|
+
updated_at: nowIso(),
|
|
555
|
+
};
|
|
556
|
+
atomicWriteText(manifestPath(coordRoot, id), `${JSON.stringify(reopened, null, 2)}\n`);
|
|
557
|
+
const archived = archivedManifestPath(coordRoot, id);
|
|
558
|
+
if (existsSync(archived)) rmSync(archived, { force: true });
|
|
559
|
+
return { ok: true, manifest: reopened };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ─── Read / query ─────────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
export interface ListFilter {
|
|
565
|
+
status?: DecisionStatus;
|
|
566
|
+
tier?: DecisionTier;
|
|
567
|
+
stakes?: DecisionStakes;
|
|
568
|
+
/** Only non-terminal decisions (the live queue). */
|
|
569
|
+
openOnly?: boolean;
|
|
570
|
+
/** Include the archive dir in the scan (default: active only). */
|
|
571
|
+
includeArchived?: boolean;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function scanManifests(dir: string): DecisionManifest[] {
|
|
575
|
+
if (!existsSync(dir)) return [];
|
|
576
|
+
const out: DecisionManifest[] = [];
|
|
577
|
+
for (const f of readdirSync(dir)) {
|
|
578
|
+
if (!f.endsWith(".json")) continue;
|
|
579
|
+
const p = join(dir, f);
|
|
580
|
+
try {
|
|
581
|
+
if (!statSync(p).isFile()) continue;
|
|
582
|
+
const parsed = JSON.parse(readFileSync(p, "utf8")) as DecisionManifest;
|
|
583
|
+
if (parsed.schema_version !== DECISION_SCHEMA_VERSION) continue;
|
|
584
|
+
out.push(parsed);
|
|
585
|
+
} catch {
|
|
586
|
+
// skip unparseable manifest; one bad file never kills the scan
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function listDecisions(coordRoot: string, filter: ListFilter = {}): DecisionManifest[] {
|
|
593
|
+
let rows = scanManifests(decisionsDir(coordRoot));
|
|
594
|
+
if (filter.includeArchived) rows = rows.concat(scanManifests(archiveDir(coordRoot)));
|
|
595
|
+
if (filter.status) rows = rows.filter((d) => d.status === filter.status);
|
|
596
|
+
if (filter.tier !== undefined) rows = rows.filter((d) => d.tier === filter.tier);
|
|
597
|
+
if (filter.stakes) rows = rows.filter((d) => d.stakes === filter.stakes);
|
|
598
|
+
if (filter.openOnly) rows = rows.filter((d) => !isTerminal(d.status));
|
|
599
|
+
rows.sort((a, b) => (b.filed_at ?? "").localeCompare(a.filed_at ?? ""));
|
|
600
|
+
return rows;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface DecisionDetail {
|
|
604
|
+
manifest: DecisionManifest;
|
|
605
|
+
bodies: { name: string; content: string }[];
|
|
606
|
+
archived: boolean;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function showDecision(coordRoot: string, id: string): DecisionDetail | null {
|
|
610
|
+
const manifest = readManifest(coordRoot, id);
|
|
611
|
+
if (!manifest) return null;
|
|
612
|
+
const archived = !existsSync(manifestPath(coordRoot, id));
|
|
613
|
+
const bodyDir = archived ? join(archiveDir(coordRoot), id) : decisionBodyDir(coordRoot, id);
|
|
614
|
+
const bodies: { name: string; content: string }[] = [];
|
|
615
|
+
if (existsSync(bodyDir)) {
|
|
616
|
+
for (const f of readdirSync(bodyDir)) {
|
|
617
|
+
if (!f.endsWith(".md")) continue;
|
|
618
|
+
try {
|
|
619
|
+
bodies.push({ name: f, content: readFileSync(join(bodyDir, f), "utf8") });
|
|
620
|
+
} catch {
|
|
621
|
+
// skip unreadable body
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { manifest, bodies, archived };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface SearchHit {
|
|
629
|
+
manifest: DecisionManifest;
|
|
630
|
+
/** A short surrounding snippet of the first match. */
|
|
631
|
+
snippet: string;
|
|
632
|
+
/** Where the match landed. */
|
|
633
|
+
where: "question" | "context" | "resolution" | "body";
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Case-insensitive substring search over manifests + bodies. Deliberately
|
|
638
|
+
* dumb: precedent recall depends on the host's decision skill running this before
|
|
639
|
+
* filing, not on ranking sophistication. Includes the archive (precedent
|
|
640
|
+
* lives there).
|
|
641
|
+
*/
|
|
642
|
+
export function searchDecisions(coordRoot: string, query: string): SearchHit[] {
|
|
643
|
+
const q = query.trim().toLowerCase();
|
|
644
|
+
if (!q) return [];
|
|
645
|
+
const all = listDecisions(coordRoot, { includeArchived: true });
|
|
646
|
+
const hits: SearchHit[] = [];
|
|
647
|
+
for (const manifest of all) {
|
|
648
|
+
const fields: { where: SearchHit["where"]; text: string }[] = [
|
|
649
|
+
{ where: "question", text: manifest.question ?? "" },
|
|
650
|
+
{ where: "context", text: manifest.context ?? "" },
|
|
651
|
+
{
|
|
652
|
+
where: "resolution",
|
|
653
|
+
text: manifest.resolution
|
|
654
|
+
? `${manifest.resolution.recommendation} ${manifest.resolution.evidence.join(" ")}`
|
|
655
|
+
: "",
|
|
656
|
+
},
|
|
657
|
+
];
|
|
658
|
+
const bodyDir = existsSync(manifestPath(coordRoot, manifest.decision_id))
|
|
659
|
+
? decisionBodyDir(coordRoot, manifest.decision_id)
|
|
660
|
+
: join(archiveDir(coordRoot), manifest.decision_id);
|
|
661
|
+
if (existsSync(bodyDir)) {
|
|
662
|
+
for (const f of readdirSync(bodyDir)) {
|
|
663
|
+
if (!f.endsWith(".md")) continue;
|
|
664
|
+
try {
|
|
665
|
+
fields.push({ where: "body", text: readFileSync(join(bodyDir, f), "utf8") });
|
|
666
|
+
} catch {
|
|
667
|
+
// skip
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const field of fields) {
|
|
672
|
+
const idx = field.text.toLowerCase().indexOf(q);
|
|
673
|
+
if (idx >= 0) {
|
|
674
|
+
const start = Math.max(0, idx - 40);
|
|
675
|
+
const snippet = field.text
|
|
676
|
+
.slice(start, idx + q.length + 40)
|
|
677
|
+
.replace(/\s+/g, " ")
|
|
678
|
+
.trim();
|
|
679
|
+
hits.push({ manifest, snippet: `${start > 0 ? "…" : ""}${snippet}…`, where: field.where });
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return hits;
|
|
685
|
+
}
|