@viloforge/vfkb 0.2.1

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.
@@ -0,0 +1,130 @@
1
+ // Stop-hook decision-capture reminder (RFC-008 / ADR-0027). A conditional,
2
+ // once-per-turn end-of-turn nudge: when a turn plausibly made a decision but recorded
3
+ // none, inject a reminder so the agent captures it before handing back to the user.
4
+ //
5
+ // Contract is EMPIRICALLY VERIFIED at Claude Code CLI v2.1.195 (brain gotcha
6
+ // d70c0299e144): a Stop hook may emit
7
+ // {"hookSpecificOutput":{"hookEventName":"Stop","decision":"block","additionalContext":"…"}}
8
+ // to continue the turn with that text as context; the harness passes `stop_hook_active`
9
+ // (true on our own re-entry) as the NATIVE loop guard — no marker file needed.
10
+ //
11
+ // `decideStop` is the PURE core (deterministically unit-tested, the backstop). The git /
12
+ // brain gathering is the impure shell, used by `cli.ts hook stop`.
13
+ import { execFileSync } from 'node:child_process';
14
+ import { readFileSync } from 'node:fs';
15
+ import { join, relative } from 'node:path';
16
+ import { brainDir } from './storage.js';
17
+ export const STOP_REMINDER = 'vfkb decision-capture check: this turn changed code/docs but no `decision` was recorded ' +
18
+ 'to the brain. If a load-bearing decision was made, capture it now via ' +
19
+ '`mcp__vfkb__kb_add` (type=decision, why=<rationale>, role=human) — or ' +
20
+ '`vfkb add decision "…" --why "…" --role human` — and add an ADR under docs/adr/ for ' +
21
+ 'anything architectural. If NO decision was made this turn, just finish normally.';
22
+ // The strong-signal threshold: only nudge for a handoff once the session has
23
+ // accumulated enough knowledge that a "what's next" pointer is worth writing. Below
24
+ // this, the SessionEnd B2 floor (ADR-0033) still guarantees a committed handoff.
25
+ export const HANDOFF_MIN_ENTRIES = 3;
26
+ // GAP-1 B1 (RFC-011 §B): the higher-quality, agent-authored handoff nudge. The Stop
27
+ // hook is the ONLY surface that can prompt the agent, but it fires per-turn while a
28
+ // handoff is an end-of-session artifact — so the framing is deliberately conditional
29
+ // ("if you're wrapping up"), letting the agent decline mid-session. It self-silences:
30
+ // the moment ANY handoff/next entry is recorded, the trigger goes quiet (no
31
+ // KB_SESSION_ID / per-session state needed — the side-effect it asks for is the guard).
32
+ export const HANDOFF_REMINDER = 'vfkb handoff check: this session has recorded knowledge but no `handoff`/`next` entry. ' +
33
+ 'If you are WRAPPING UP, record a durable handoff now — `mcp__vfkb__kb_add` ' +
34
+ '(type=fact, tags=handoff,next, role=human) naming what the NEXT session should pick up ' +
35
+ '(a real "next:", not just a summary). If you are still mid-session, ignore this and ' +
36
+ 'finish normally — the SessionEnd floor will leave a fallback if you never do.';
37
+ /**
38
+ * The pure decision. Block (inject the reminder, continuing the turn) only when a
39
+ * decision *plausibly* went unrecorded; otherwise allow the stop.
40
+ * 1. Native loop guard: never block our own re-entry (`stop_hook_active`), or it nags forever.
41
+ * 2. Conditional trigger: substantive work happened AND no decision was recorded this session.
42
+ * (Not a true Brake — working-tree-changed ≠ decision-made; the committed ADR stays the
43
+ * deterministic backstop for *significant* decisions. This just fires the nudge at the right
44
+ * moment, only when plausibly needed.)
45
+ */
46
+ export function decideStop(input, ctx) {
47
+ if (input?.stop_hook_active)
48
+ return { block: false };
49
+ const reminders = [];
50
+ // Decision-capture nudge (ADR-0027): work happened AND no decision recorded this session.
51
+ if (ctx.uncommittedWork && ctx.newDecisions === 0)
52
+ reminders.push(STOP_REMINDER);
53
+ // Handoff nudge (B1, RFC-011): work happened AND a strong-signal amount of knowledge
54
+ // accumulated AND no handoff/next entry yet. Self-silences once a handoff is recorded.
55
+ if (ctx.uncommittedWork && (ctx.newEntries ?? 0) >= HANDOFF_MIN_ENTRIES && (ctx.newHandoffs ?? 0) === 0)
56
+ reminders.push(HANDOFF_REMINDER);
57
+ if (reminders.length === 0)
58
+ return { block: false };
59
+ return { block: true, reminder: reminders.join('\n\n') };
60
+ }
61
+ /** Working tree has uncommitted src/ or docs/ changes (brain-only changes don't count as "work"). */
62
+ export function hasUncommittedWork(cwd = process.cwd(), brain = brainDir()) {
63
+ let out;
64
+ try {
65
+ out = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
66
+ }
67
+ catch {
68
+ return false; // not a repo / git missing → fail-open (don't nag)
69
+ }
70
+ const brainRel = relative(cwd, brain).replace(/\\/g, '/');
71
+ return out.split('\n').some((line) => {
72
+ const p = line.slice(3).trim(); // strip the 2-char status + space
73
+ if (!p)
74
+ return false;
75
+ if (brainRel && (p === brainRel || p.startsWith(brainRel + '/')))
76
+ return false;
77
+ return p.startsWith('src/') || p.startsWith('docs/');
78
+ });
79
+ }
80
+ /**
81
+ * Entries appended to the brain since HEAD, parsed. The brain is append-only
82
+ * (ADR-0019), so fresh entries are exactly the lines beyond the committed line count —
83
+ * no per-session state (KB_SESSION_ID) needed, and it aligns with the "record then commit"
84
+ * workflow: uncommitted entries are exactly this session's captures.
85
+ */
86
+ export function newBrainEntriesSinceHead(brain = brainDir(), cwd = process.cwd()) {
87
+ const file = join(brain, 'entries.jsonl');
88
+ let current;
89
+ try {
90
+ current = readFileSync(file, 'utf8').split('\n').filter(Boolean);
91
+ }
92
+ catch {
93
+ return []; // no brain file yet
94
+ }
95
+ const rel = relative(cwd, file).replace(/\\/g, '/');
96
+ let headCount = 0;
97
+ try {
98
+ const head = execFileSync('git', ['show', `HEAD:${rel}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
99
+ headCount = head.split('\n').filter(Boolean).length;
100
+ }
101
+ catch {
102
+ headCount = 0; // file not in HEAD / no repo → treat all current as fresh
103
+ }
104
+ return current
105
+ .slice(headCount)
106
+ .map((l) => {
107
+ try {
108
+ return JSON.parse(l);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ })
114
+ .filter((e) => e !== null);
115
+ }
116
+ /** Count `decision` entries appended to the brain since HEAD (ADR-0027 nudge trigger). */
117
+ export function uncommittedDecisionCount(brain = brainDir(), cwd = process.cwd()) {
118
+ return newBrainEntriesSinceHead(brain, cwd).filter((e) => e.type === 'decision').length;
119
+ }
120
+ const isHandoff = (e) => (e.tags ?? []).some((t) => t === 'handoff' || t === 'next');
121
+ /** Impure shell: gather the real context for `decideStop` from git + the brain. */
122
+ export function gatherStopContext(cwd = process.cwd(), brain = brainDir()) {
123
+ const fresh = newBrainEntriesSinceHead(brain, cwd);
124
+ return {
125
+ uncommittedWork: hasUncommittedWork(cwd, brain),
126
+ newDecisions: fresh.filter((e) => e.type === 'decision').length,
127
+ newEntries: fresh.length,
128
+ newHandoffs: fresh.filter(isHandoff).length,
129
+ };
130
+ }
@@ -0,0 +1,145 @@
1
+ // vfkb storage POLICY (v2: ADR-0044 layered over the backend seam). This module is
2
+ // backend-agnostic: LWW materialization, ADR-0042 read-boundary normalization, and
3
+ // ADR-0014 content-hash freshness — all computed over whatever transport
4
+ // `storageBackend()` provides (exactly one ships in v2: JSONL-on-disk, ADR-0019).
5
+ // The exported API is unchanged from the pre-seam kernel — the DoD's refactor-safety
6
+ // contract (the full suite passes untouched) hangs on that.
7
+ import { basename, dirname, join, resolve } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { createHash } from 'node:crypto';
10
+ import { storageBackend } from './backend.js';
11
+ import { normalizeEntry } from './validate.js';
12
+ export function isTombstone(r) {
13
+ return r.deleted === true;
14
+ }
15
+ // The resolved data location. For the JSONL backend this is the brain DIRECTORY the
16
+ // committed file lives in — the path git-layer consumers (git.ts, gating.ts,
17
+ // stop-reminder.ts, counters, the lock) anchor to. Kept here (not behind the seam):
18
+ // those consumers exist because of ADR-0019's committed-file property, and a backend
19
+ // without that property simply doesn't wire them.
20
+ export function brainDir() {
21
+ // VFKB_DATA_DIR is canonical; VFKB_DIR is a kept-working deprecated alias (ADR-0032).
22
+ return process.env.VFKB_DATA_DIR || process.env.VFKB_DIR || join(homedir(), '.vfkb');
23
+ }
24
+ // Default project name when VFKB_PROJECT is unset. Generic wiring (the Claude Code
25
+ // plugin, ADR-0045) can point VFKB_DATA_DIR at a brain but cannot know the project's
26
+ // name, so derive it from where the brain lives: an explicit brain dir names its
27
+ // project (its parent when the dir itself is dot-named, e.g. <repo>/.vfkb → repo);
28
+ // otherwise the hook-injected $CLAUDE_PROJECT_DIR, then the cwd. The old hard-coded
29
+ // 'spike' remains only as the last-resort literal (empty basename at fs root, or a
30
+ // name that is nothing but stripped characters).
31
+ export function defaultProject() {
32
+ const raw = (() => {
33
+ if (process.env.VFKB_PROJECT)
34
+ return process.env.VFKB_PROJECT;
35
+ const explicit = process.env.VFKB_DATA_DIR || process.env.VFKB_DIR;
36
+ if (explicit) {
37
+ const abs = resolve(explicit);
38
+ const name = basename(abs);
39
+ return name.startsWith('.') ? basename(dirname(abs)) : name;
40
+ }
41
+ const root = process.env.CLAUDE_PROJECT_DIR;
42
+ if (root)
43
+ return basename(resolve(root));
44
+ return basename(process.cwd());
45
+ })();
46
+ // The name lands verbatim inside the injected pseudo-XML headers
47
+ // (<vfkb-resume project="...">) — strip characters that would deform them.
48
+ return raw.replace(/["<>&]/g, '') || 'spike';
49
+ }
50
+ // --- append-only writes. Every write regenerates the freshness meta as a
51
+ // GUARANTEED side-effect (mykb L11; ADR-0014) — policy, so it lives here,
52
+ // above the backend's raw append. ---
53
+ export function appendRecord(rec) {
54
+ storageBackend().append(rec);
55
+ writeMeta();
56
+ }
57
+ // ADR-0040: the exclusive section for read-decide-append engine ops, provided by
58
+ // the backend (a lockfile for JSONL; a transaction for a future hosted backend).
59
+ export function withExclusive(fn) {
60
+ return storageBackend().withExclusive(fn);
61
+ }
62
+ // --- Project context doc spine (D-ii / ADR-0025) — authored content, stored by the
63
+ // backend, never a JSONL entry (stays freely editable; the never-rewrite Brake
64
+ // governs entries only). ---
65
+ export function contextSpinePath() {
66
+ return storageBackend().spinePath();
67
+ }
68
+ export function readContextSpine() {
69
+ return storageBackend().readSpine();
70
+ }
71
+ export function writeContextSpine(content) {
72
+ storageBackend().writeSpine(content);
73
+ }
74
+ let malformed = [];
75
+ export function lastMalformed() {
76
+ return [...malformed];
77
+ }
78
+ export function readRecords() {
79
+ const { records, malformed: bad } = storageBackend().readAllRaw();
80
+ malformed = [...bad];
81
+ return records;
82
+ }
83
+ // Collapse the append log to the live entry set.
84
+ // Per id, keep the record with the greatest `updated` (ties → later in file);
85
+ // if that newest record is a tombstone, the id is gone. Order-independent in
86
+ // `updated` → merge=union safe.
87
+ export function materialize(records = readRecords()) {
88
+ const newest = new Map();
89
+ for (const r of records) {
90
+ if (!r || typeof r !== 'object' || typeof r.id !== 'string' || !r.id) {
91
+ // Unsalvageable: no usable id → excluded from the live set, visibly counted
92
+ // (ADR-0042 §2 — a distinct surfaced state, never a crash, never silent).
93
+ malformed.push({ issue: 'no usable id', raw: JSON.stringify(r).slice(0, 200) });
94
+ continue;
95
+ }
96
+ const cur = newest.get(r.id);
97
+ if (!cur || r.updated >= cur.updated)
98
+ newest.set(r.id, r);
99
+ }
100
+ const out = [];
101
+ for (const r of newest.values()) {
102
+ if (isTombstone(r))
103
+ continue;
104
+ // Whole-envelope normalization at the read boundary (ADR-0042 §2): every consumer
105
+ // sees a well-formed entry regardless of origin (vfkb write path, external
106
+ // projection, hand edit). Safe defaults for missing/invalid fields; unknown
107
+ // future fields pass through untouched (forward compatibility).
108
+ const n = normalizeEntry(r);
109
+ if (n.ok)
110
+ out.push(n.entry);
111
+ else
112
+ malformed.push({ issue: n.issue, raw: JSON.stringify(r).slice(0, 200) });
113
+ }
114
+ return out;
115
+ }
116
+ // --- content-derived freshness token (ADR-0014: NEVER mtime). Stable across
117
+ // file order and git operations; changes iff the live set changes. ---
118
+ export function contentHash(entries = materialize()) {
119
+ const basis = entries
120
+ .map((e) => `${e.id}@${e.updated}`)
121
+ .sort()
122
+ .join('\n');
123
+ return createHash('sha256').update(basis).digest('hex').slice(0, 16);
124
+ }
125
+ export function readMeta() {
126
+ const raw = storageBackend().readMetaRaw();
127
+ if (raw === null)
128
+ return null;
129
+ try {
130
+ return JSON.parse(raw);
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ export function writeMeta() {
137
+ const entries = materialize();
138
+ const meta = {
139
+ content_hash: contentHash(entries),
140
+ entry_count: entries.length,
141
+ last_write: new Date().toISOString(),
142
+ };
143
+ storageBackend().writeMetaRaw(JSON.stringify(meta));
144
+ return meta;
145
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // vfkb entry envelope — Phase 0 spike shape.
2
+ // Implements ADR-0011 (validity window + structured provenance.origin; trust derived).
3
+ // Runtime companion for validating user-supplied type strings (issue #95).
4
+ export const ENTRY_TYPES = ['fact', 'decision', 'gotcha', 'pattern', 'link'];
5
+ // Runtime companion for validating user-supplied status strings (issue #95).
6
+ export const DECISION_STATUSES = ['proposed', 'accepted', 'deprecated', 'superseded'];
@@ -0,0 +1,83 @@
1
+ // ADR-0042 §2 (v2): whole-envelope validation at the READ boundary — the one place
2
+ // that sees every entry regardless of origin (vfkb's own writes, vfwb's lossy external
3
+ // projection, hand-edited legacy lines, a future RFC-019 backend). Malformed/missing
4
+ // fields get safe DOCUMENTED defaults; a record that cannot be salvaged at all (no
5
+ // usable id) is excluded from the live set and surfaced as a counted, inspectable
6
+ // state (storage.lastMalformed / the context map's malformed count) — visible, never
7
+ // a silent drop, and never a crash in a caller.
8
+ //
9
+ // Philosophy: permissive-with-defaults, loose (unknown future fields PASS THROUGH —
10
+ // a v2 brain read by this code, or a foreign brain with extra fields, must not be
11
+ // stripped). zod is used per the accepted ADR; it is currently satisfied transitively
12
+ // via @modelcontextprotocol/sdk (declaring it directly is a noted follow-up).
13
+ import { z } from 'zod';
14
+ const ROLE = z.enum(['architect', 'pm', 'executor', 'judge', 'human', 'init', 'import']);
15
+ const PROV_STATUS = z.enum(['verified', 'unverified', 'stale', 'expired']);
16
+ // Defaults (documented): unknown role → executor (agent-trust, the safe floor);
17
+ // unknown provenance → unverified (never accidentally verified); unknown zone →
18
+ // incoming (never accidentally injected as established); missing tags → [].
19
+ //
20
+ // TWO KNOWN CONSEQUENCES (review gate, 2026-07-06 — deliberate, documented):
21
+ // 1. PERSISTENCE-ON-UPDATE: update paths (updateEntry/setProvenanceStatus/transition)
22
+ // spread the NORMALIZED entry and re-append, so read-time defaults become stored
23
+ // values on the next edit of a legacy/foreign entry. The original line stays
24
+ // untouched (append-only intact) and normalization is idempotent, but an
25
+ // invalid-but-meaningful original value leaves the live record permanently.
26
+ // 2. UNKNOWN TYPE COERCES TO 'fact' (the one place passthrough does NOT hold): a
27
+ // future v3 entry type read by this code renders as a fact, and via (1) an edit
28
+ // would persist that coercion. Revisit before any v3 schema introduces new types.
29
+ const entrySchema = z
30
+ .looseObject({
31
+ id: z.string().min(1),
32
+ type: z.enum(['fact', 'decision', 'gotcha', 'pattern', 'link']).catch('fact'),
33
+ text: z.string().catch(''),
34
+ tags: z.array(z.string()).catch([]),
35
+ zone: z.enum(['incoming', 'established', 'archive']).catch('incoming'),
36
+ author: z.looseObject({ role: ROLE.catch('executor'), id: z.string().optional() }).catch({ role: 'executor' }),
37
+ refs: z
38
+ .looseObject({
39
+ supersedes: z.string().optional(),
40
+ contradicts: z.array(z.string()).optional().catch(undefined),
41
+ })
42
+ .optional()
43
+ .catch(undefined),
44
+ provenance: z
45
+ .looseObject({
46
+ status: PROV_STATUS.catch('unverified'),
47
+ date: z.string().optional(),
48
+ source: z.string().optional(),
49
+ detail: z.string().optional(),
50
+ origin: z.unknown().optional(),
51
+ })
52
+ .catch({ status: 'unverified' }),
53
+ validity: z
54
+ .looseObject({
55
+ valid_from: z.string().optional(),
56
+ valid_until: z.string().optional(),
57
+ recorded_invalid_at: z.string().optional(),
58
+ })
59
+ .catch({}),
60
+ status: z.enum(['proposed', 'accepted', 'deprecated', 'superseded']).optional().catch(undefined),
61
+ why: z.string().optional().catch(undefined),
62
+ constitutional: z.boolean().optional().catch(undefined),
63
+ adr_no: z.number().optional().catch(undefined),
64
+ session_id: z.string().optional().catch(undefined),
65
+ created: z.string().catch(''),
66
+ updated: z.string().catch(''),
67
+ });
68
+ export function normalizeEntry(raw) {
69
+ const parsed = entrySchema.safeParse(raw);
70
+ if (!parsed.success) {
71
+ // Only an unsalvageable record lands here (everything else is caught-with-default):
72
+ // not an object, or no usable string id.
73
+ return { ok: false, issue: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') };
74
+ }
75
+ const e = parsed.data;
76
+ // valid_from is required by the TS envelope type (no runtime consumer today) —
77
+ // default it from the entry's own created stamp, else epoch (visibly ancient
78
+ // beats invisibly wrong).
79
+ if (!e.validity.valid_from) {
80
+ e.validity = { ...e.validity, valid_from: e.created || '1970-01-01T00:00:00.000Z' };
81
+ }
82
+ return { ok: true, entry: e };
83
+ }
@@ -0,0 +1,32 @@
1
+ // FR-4 (ADR-0030) — engine identity, for the brain↔engine version stamp + doctor.
2
+ //
3
+ // SCHEMA_VERSION is the load-bearing compat signal: the .vfkb/entries.jsonl
4
+ // envelope version (ADR-0011). Bump it ONLY on a breaking envelope change; a brain
5
+ // stamped with a newer schema than the running engine is incompatible.
6
+ //
7
+ // ENGINE_VERSION / ENGINE_COMMIT identify the engine build. They are injected at
8
+ // bundle build time via esbuild `define` (scripts/build-bundles.mjs, kept as the
9
+ // primary); the tsc/dist path has no define, so ENGINE_VERSION falls back to the
10
+ // package's OWN package.json (resolved relative to this module — dist/version.js
11
+ // sits one level below package.json, same in the repo and inside an `npm i -g`
12
+ // install). The old literal '0.0.0-dev' fallback meant an npm-installed vfkb-mcp
13
+ // reported serverInfo.version 0.0.0-dev (observed — PR #122 review F4). `typeof
14
+ // <undeclared>` is safe (never throws), so the define check works without the
15
+ // symbols existing at runtime; the fs read is guarded so a missing/unreadable
16
+ // manifest still yields the honest dev sentinel instead of a crash.
17
+ import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ export const SCHEMA_VERSION = 1;
21
+ function ownPackageVersion() {
22
+ try {
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
25
+ return pkg.version || '0.0.0-dev';
26
+ }
27
+ catch {
28
+ return '0.0.0-dev';
29
+ }
30
+ }
31
+ export const ENGINE_VERSION = typeof __VFKB_VERSION__ !== 'undefined' ? __VFKB_VERSION__ : ownPackageVersion();
32
+ export const ENGINE_COMMIT = typeof __VFKB_COMMIT__ !== 'undefined' ? __VFKB_COMMIT__ : 'dev';
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@viloforge/vfkb",
3
+ "version": "0.2.1",
4
+ "description": "ViloForge KnowledgeBase (vfkb) — per-project knowledge substrate for the ViloForge software factory. Greenfield TypeScript.",
5
+ "type": "module",
6
+ "main": "dist/engine.js",
7
+ "exports": {
8
+ ".": "./dist/engine.js",
9
+ "./pi-extension": "./dist/pi-extension.js",
10
+ "./pi-mcp-bridge": "./dist/pi-mcp-bridge.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "bin": {
14
+ "vfkb": "dist/cli.js",
15
+ "vfkb-mcp": "dist/mcp-server.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/vilosource/vfkb.git"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org",
27
+ "provenance": true
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "build:bundles": "node scripts/build-bundles.mjs",
32
+ "pretest": "tsc",
33
+ "test": "vitest run",
34
+ "clean": "rm -rf dist",
35
+ "prepack": "npm run build && npm run build:bundles"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "devDependencies": {
41
+ "@tsconfig/node20": "^20.1.9",
42
+ "@types/node": "^25.9.1",
43
+ "esbuild": "^0.21.5",
44
+ "typescript": "^5.5.0",
45
+ "vitest": "^2.0.0"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "zod": "^4.4.3"
50
+ }
51
+ }