@codedology/code-forge 0.5.1 → 0.6.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +39 -2
  3. package/docs/README.md +5 -4
  4. package/docs/reference/blocks.json +127 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +2 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +3 -1
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/ledger.md +2 -0
  15. package/skill/references/plan.md +6 -4
  16. package/skill/references/review.md +2 -2
  17. package/skill/references/rules-core.md +4 -1
  18. package/skill/scripts/forge +1 -1
  19. package/skill/templates/plan.md +4 -3
  20. package/src/autopilot/actions.mjs +491 -0
  21. package/src/autopilot/binnacle.mjs +494 -0
  22. package/src/autopilot/delegate.mjs +422 -0
  23. package/src/autopilot/grant.mjs +354 -0
  24. package/src/autopilot/limits.mjs +848 -0
  25. package/src/autopilot/scopes.mjs +95 -0
  26. package/src/cli/author.mjs +7 -5
  27. package/src/cli/autopilot.mjs +500 -0
  28. package/src/cli/block.mjs +40 -8
  29. package/src/cli/facts.mjs +7 -6
  30. package/src/cli/jev.mjs +10 -6
  31. package/src/cli/logs.mjs +4 -142
  32. package/src/cli/plan.mjs +18 -7
  33. package/src/cli/run.mjs +20 -61
  34. package/src/cli/s2.mjs +5 -4
  35. package/src/cli/spawn.mjs +5 -4
  36. package/src/config/closed-book.mjs +20 -3
  37. package/src/config/load.mjs +113 -1
  38. package/src/doctor/probes.mjs +34 -11
  39. package/src/engines/builders/claude.mjs +3 -3
  40. package/src/engines/builders/codex.mjs +6 -5
  41. package/src/engines/builders/grok.mjs +3 -3
  42. package/src/engines/builders/validate-params.mjs +3 -3
  43. package/src/ledger/report.mjs +2 -0
  44. package/src/review/fixloop.mjs +69 -5
  45. package/src/review/gate-check.mjs +156 -6
  46. package/src/session/facts.mjs +291 -73
  47. package/src/session/plan-check.mjs +75 -6
  48. package/src/session/spawn.mjs +68 -8
  49. package/src/state/run.mjs +8 -1
  50. package/src/util/breadcrumbs.mjs +1 -0
  51. package/src/util/forbidden.mjs +10 -0
  52. package/src/util/gh.mjs +175 -0
  53. package/src/worker/engine.mjs +8 -4
  54. package/src/worker/loop.mjs +42 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Autopilot grants (issue #5, plan autopilot §1, block B45): the owner's time-boxed permission for
3
+ * a delegate to answer some owner-level questions while the owner is away.
4
+ *
5
+ * A grant lives in the run record under `autopilot` (written under the run lock) and is signed in
6
+ * the ledger:
7
+ * - `autopilot.grant` — at `start`: grant_id, scopes, deny, delegate, until, caps, stop_at;
8
+ * - `autopilot.stop` — at `stop`;
9
+ * - `autopilot.expire` — ONCE, the first time any check finds the window over.
10
+ *
11
+ * Expiry is lazy (no daemon): {@link checkExpiry} is called by every `autopilot` command, by
12
+ * {@link grantFor} and — from later blocks — by `block` commands and every worker drain. The first
13
+ * caller that sees `now >= until` on a grant still marked active takes the run lock, re-reads the
14
+ * record, and only then writes the row and marks the grant expired, so two callers racing write
15
+ * one row.
16
+ *
17
+ * Every delegated action asks {@link grantFor}`(runId, scope, now)`; a refusal sends the question
18
+ * back to the owner. A run with no grant behaves exactly as before (`no-grant`).
19
+ */
20
+
21
+ import { randomBytes } from 'node:crypto';
22
+ import { appendRow } from '../ledger/write.mjs';
23
+ import { StateError } from '../state/paths.mjs';
24
+ import { readRun, saveRun, withRunLock, writeSigned } from '../state/run.mjs';
25
+ import { ALL_SCOPES, ALLOWABLE_SCOPES, BUDGET_CATEGORIES, DELEGATE_LEVELS, FIXED_DENY, isAllowable, isFixedDeny, SCOPE_CATEGORY } from './scopes.mjs';
26
+
27
+ /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
28
+ /**
29
+ * @typedef {{
30
+ * grant_id: string, status: 'active' | 'stopped' | 'expired', scopes: string[], deny: string[],
31
+ * delegate: string, until: string, caps: Record<string, number>, stop_at: number,
32
+ * started_at: string, stopped_at: string | null, expired_at: string | null, link: string | null,
33
+ * paused?: Record<string, {at: string, spent_usd: number, reserved_usd: number, cap_usd: number}>,
34
+ * }} Grant - `paused` (B48): the budget categories that reached `stop_at` of their cap.
35
+ */
36
+ /** @typedef {'no-grant' | 'run-ended' | 'expired' | 'stopped' | 'denied' | 'not-allowed' | 'paused'} RefusalReason */
37
+ /**
38
+ * A further check run on an active grant after the scope checks; it returns a refusal reason
39
+ * string, or null to let the action through. B48 adds the per-category budget / stop-at check here.
40
+ * @typedef {(grant: Grant, scope: string, now: Date) => Promise<string | null> | string | null} GrantCheck
41
+ */
42
+ /** @typedef {{writeRow?: WriteRow, checks?: GrantCheck[]}} GrantDeps */
43
+
44
+ /** The longest window a grant may cover. */
45
+ export const MAX_WINDOW_MS = 24 * 60 * 60 * 1000;
46
+ /** The default `--stop-at` (plan autopilot §5 Q2). */
47
+ export const DEFAULT_STOP_AT = 0.9;
48
+
49
+ const UNTIL_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2}))$/;
50
+ const UNTIL_EXAMPLE = '2026-10-07T07:00:00+02:00';
51
+ const MONEY_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)$/;
52
+
53
+ /** @param {string} message @returns {StateError} */
54
+ const usage = (message) => new StateError('usage', message);
55
+
56
+ /**
57
+ * Epoch ms of an `--until` text, or NaN. Calendar-checked by round trip: the fields are composed
58
+ * as UTC and must read back unchanged (`2026-02-30`, month 13, hour 24 and minute 60 all fail),
59
+ * and the offset is at most ±23:59; only then is the offset applied.
60
+ * @param {string} text @returns {number}
61
+ */
62
+ function untilMs(text) {
63
+ const m = UNTIL_PATTERN.exec(text);
64
+ if (m === null) return Number.NaN;
65
+ const [y, mo, d, h, mi, sec] = [1, 2, 3, 4, 5, 6].map((i) => Number(m[i] ?? 0));
66
+ const frac = m[7] === undefined ? 0 : Number(m[7].padEnd(3, '0'));
67
+ const local = new Date(Date.UTC(y, mo - 1, d, h, mi, sec, frac));
68
+ local.setUTCFullYear(y); // Date.UTC maps years 0–99 to 1900–1999
69
+ const same = local.getUTCFullYear() === y && local.getUTCMonth() === mo - 1 && local.getUTCDate() === d && local.getUTCHours() === h && local.getUTCMinutes() === mi && local.getUTCSeconds() === sec;
70
+ if (!same) return Number.NaN;
71
+ if (m[8] === 'Z') return local.getTime();
72
+ const [oh, om] = [Number(m[10]), Number(m[11])];
73
+ if (oh > 23 || om > 59) return Number.NaN;
74
+ return local.getTime() - (m[9] === '+' ? 1 : -1) * (oh * 60 + om) * 60000;
75
+ }
76
+
77
+ /**
78
+ * `--until`: an ISO-8601 time WITH an offset (`Z` or `±hh:mm`), after `now`, at most 24 h ahead.
79
+ * @param {unknown} text @param {Date} now
80
+ * @returns {string} the time as a UTC ISO string
81
+ * @throws {StateError} `usage`
82
+ */
83
+ export function parseUntil(text, now) {
84
+ if (typeof text !== 'string' || text.length === 0) throw usage(`--until is required: an ISO-8601 time with an offset, e.g. ${UNTIL_EXAMPLE}`);
85
+ const ms = untilMs(text);
86
+ if (!Number.isFinite(ms)) throw usage(`--until must be an ISO-8601 time with an offset (Z or ±hh:mm), e.g. ${UNTIL_EXAMPLE}`);
87
+ if (ms <= now.getTime()) throw usage('--until is in the past; give a time after now, at most 24 h ahead');
88
+ if (ms - now.getTime() > MAX_WINDOW_MS) throw usage('--until is more than 24 h ahead; a grant lasts at most 24 h');
89
+ return new Date(ms).toISOString();
90
+ }
91
+
92
+ /**
93
+ * Split a comma list, trimming items and dropping empty ones and repeats (order kept).
94
+ * @param {unknown} text @returns {string[]}
95
+ */
96
+ function commaList(text) {
97
+ if (typeof text !== 'string') return [];
98
+ return [...new Set(text.split(',').map((s) => s.trim()).filter((s) => s.length > 0))];
99
+ }
100
+
101
+ /**
102
+ * `--allow`: one or more allowable scopes; a fixed-deny scope is refused by name.
103
+ * @param {unknown} text @returns {string[]}
104
+ * @throws {StateError} `usage`
105
+ */
106
+ export function parseAllow(text) {
107
+ const scopes = commaList(text);
108
+ if (scopes.length === 0) throw usage(`--allow is required: one or more of ${ALLOWABLE_SCOPES.join(', ')}`);
109
+ for (const scope of scopes) {
110
+ if (isFixedDeny(scope)) throw usage(`scope ${scope} is on the fixed deny list and can never be allowed (${FIXED_DENY[/** @type {keyof typeof FIXED_DENY} */ (scope)]})`);
111
+ if (!isAllowable(scope)) throw usage(`unknown scope ${JSON.stringify(scope)} in --allow; allowable scopes: ${ALLOWABLE_SCOPES.join(', ')}`);
112
+ }
113
+ return scopes;
114
+ }
115
+
116
+ /**
117
+ * `--deny`: more scopes to deny, from the allowable list (a fixed-deny scope is accepted and
118
+ * changes nothing: it is always denied).
119
+ * @param {unknown} text @returns {string[]} only the allowable scopes named
120
+ * @throws {StateError} `usage`
121
+ */
122
+ export function parseDeny(text) {
123
+ if (text === undefined) return [];
124
+ const scopes = commaList(text);
125
+ if (scopes.length === 0) throw usage('--deny needs one or more scopes');
126
+ for (const scope of scopes) {
127
+ if (!ALL_SCOPES.includes(scope)) throw usage(`unknown scope ${JSON.stringify(scope)} in --deny; allowable scopes: ${ALLOWABLE_SCOPES.join(', ')}`);
128
+ }
129
+ return scopes.filter(isAllowable);
130
+ }
131
+
132
+ /**
133
+ * `--budget <category>=<usd>,…`: a cap in USD per category.
134
+ * @param {unknown} text @returns {Record<string, number>}
135
+ * @throws {StateError} `usage`
136
+ */
137
+ export function parseBudget(text) {
138
+ /** @type {Record<string, number>} */
139
+ const caps = {};
140
+ if (text === undefined) return caps;
141
+ const items = typeof text === 'string' ? text.split(',').map((s) => s.trim()).filter((s) => s.length > 0) : [];
142
+ if (items.length === 0) throw usage('--budget needs <category>=<usd>, e.g. review=5,coding=20');
143
+ for (const item of items) {
144
+ const eq = item.indexOf('=');
145
+ const category = eq < 0 ? item : item.slice(0, eq);
146
+ if (!BUDGET_CATEGORIES.includes(category)) throw usage(`unknown budget category ${JSON.stringify(category)}; categories: ${BUDGET_CATEGORIES.join(', ')}`);
147
+ if (Object.hasOwn(caps, category)) throw usage(`budget category ${category} given more than once`);
148
+ const raw = eq < 0 ? '' : item.slice(eq + 1);
149
+ const usd = MONEY_PATTERN.test(raw) ? Number(raw) : Number.NaN;
150
+ if (!(Number.isFinite(usd) && usd > 0)) throw usage(`budget for ${category} must be a number of USD above 0, e.g. ${category}=5`);
151
+ caps[category] = usd;
152
+ }
153
+ return caps;
154
+ }
155
+
156
+ /**
157
+ * `--stop-at`: the share of a category's cap at which new sessions stop (B48); default 0.9.
158
+ * @param {unknown} text @returns {number}
159
+ * @throws {StateError} `usage`
160
+ */
161
+ export function parseStopAt(text) {
162
+ if (text === undefined) return DEFAULT_STOP_AT;
163
+ const value = typeof text === 'string' && MONEY_PATTERN.test(text) ? Number(text) : Number.NaN;
164
+ if (!(Number.isFinite(value) && value > 0 && value <= 1)) throw usage('--stop-at must be a number above 0 and at most 1, e.g. 0.9');
165
+ return value;
166
+ }
167
+
168
+ /**
169
+ * `--delegate`: the level the delegate runs at.
170
+ * @param {unknown} text @returns {string}
171
+ * @throws {StateError} `usage`
172
+ */
173
+ export function parseDelegate(text) {
174
+ if (typeof text !== 'string' || !DELEGATE_LEVELS.includes(text)) throw usage(`--delegate must be ${DELEGATE_LEVELS.join(' or ')}`);
175
+ return text;
176
+ }
177
+
178
+ /**
179
+ * Validate every `start` input; nothing is read or written.
180
+ * @param {{until?: unknown, delegate?: unknown, allow?: unknown, deny?: unknown, budget?: unknown, stopAt?: unknown}} input
181
+ * @param {Date} now
182
+ * @returns {{until: string, delegate: string, scopes: string[], deny: string[], caps: Record<string, number>, stop_at: number}}
183
+ * @throws {StateError} `usage`, naming the flag (and the scope, for a scope refusal)
184
+ */
185
+ export function validateGrantInput(input, now) {
186
+ const until = parseUntil(input.until, now);
187
+ const delegate = parseDelegate(input.delegate);
188
+ const scopes = parseAllow(input.allow);
189
+ const deny = parseDeny(input.deny);
190
+ const both = scopes.filter((s) => deny.includes(s));
191
+ if (both.length > 0) throw usage(`scope ${both[0]} is in both --allow and --deny; name it in one of them`);
192
+ return { until, delegate, scopes, deny, caps: parseBudget(input.budget), stop_at: parseStopAt(input.stopAt) };
193
+ }
194
+
195
+ /**
196
+ * The grant's state at `now` (pure): a stopped grant stays stopped; an active one whose window
197
+ * is over is expired even before its expire row is written.
198
+ * @param {Grant} grant @param {Date} now
199
+ * @returns {'active' | 'stopped' | 'expired'}
200
+ */
201
+ export function grantState(grant, now) {
202
+ if (grant.status === 'stopped') return 'stopped';
203
+ if (grant.status === 'expired') return 'expired';
204
+ return now.getTime() >= Date.parse(grant.until) ? 'expired' : 'active';
205
+ }
206
+
207
+ /**
208
+ * @param {Record<string, any>} record @returns {Grant | null}
209
+ */
210
+ const grantOf = (record) => (record.autopilot && typeof record.autopilot === 'object' ? /** @type {Grant} */ (record.autopilot) : null);
211
+
212
+ /** @param {Record<string, any>} record @param {GrantDeps} deps @returns {WriteRow} */
213
+ const writerFor = (record, deps) => deps.writeRow ?? ((row) => appendRow(row, { slug: record.project }));
214
+
215
+ /**
216
+ * The refusal for a second grant while one is active.
217
+ * @param {string} runId @param {string} until @returns {string}
218
+ */
219
+ export const activeGrantMessage = (runId, until) => `run ${runId} already has an active autopilot grant until ${until}; stop it first with code-forge autopilot stop --run ${runId}`;
220
+
221
+ /**
222
+ * The refusal for stopping a grant whose window is already over.
223
+ * @param {string} runId @param {string} until @returns {string}
224
+ */
225
+ export const expiredMessage = (runId, until) => `run ${runId}'s autopilot grant already expired at ${until}`;
226
+
227
+ /**
228
+ * Lazy expiry: when the run's grant is past `until` but still marked active, mark it expired and
229
+ * write ONE signed `autopilot.expire` row (under the run lock, re-checked after the lock is held).
230
+ * The row's `ts` is the time of the check that found the expiry (not `until`, which the row also
231
+ * carries). A run that has ended (`status` not active) answers `run-ended` and writes nothing:
232
+ * its grant can no longer act, whatever its window says.
233
+ * @param {string} runId @param {Date} [now] @param {GrantDeps} [deps]
234
+ * @returns {Promise<{state: 'none' | 'active' | 'stopped' | 'expired' | 'run-ended', grant: Grant | null, expiredNow: boolean}>}
235
+ */
236
+ export async function checkExpiry(runId, now = new Date(), deps = {}) {
237
+ const record = await readRun(runId);
238
+ const grant = grantOf(record);
239
+ if (grant === null) return { state: 'none', grant: null, expiredNow: false };
240
+ if (record.status !== 'active') return { state: 'run-ended', grant, expiredNow: false };
241
+ const state = grantState(grant, now);
242
+ if (!(state === 'expired' && grant.status === 'active')) return { state, grant, expiredNow: false };
243
+ return withRunLock(runId, async () => {
244
+ const fresh = await readRun(runId);
245
+ const current = grantOf(fresh);
246
+ if (current === null) return { state: 'none', grant: null, expiredNow: false };
247
+ if (fresh.status !== 'active') return { state: 'run-ended', grant: current, expiredNow: false };
248
+ if (!(grantState(current, now) === 'expired' && current.status === 'active')) return { state: grantState(current, now), grant: current, expiredNow: false };
249
+ // the row first: a record saved without its row would never get one
250
+ await writeSigned(runId, writerFor(fresh, deps), { event: 'autopilot.expire', grant_id: current.grant_id, until: current.until, ts: now.toISOString() });
251
+ current.status = 'expired';
252
+ current.expired_at = now.toISOString();
253
+ await saveRun(fresh);
254
+ return { state: 'expired', grant: current, expiredNow: true };
255
+ });
256
+ }
257
+
258
+ /**
259
+ * The one gate every delegated action asks. Order: no grant → run ended → stopped → expired (writes the
260
+ * expire row the first time) → denied (fixed deny list or the grant's `--deny`) → not allowed →
261
+ * paused (B48: the scope's budget category reached `stop_at` of its cap; `SCOPE_CATEGORY`) →
262
+ * each `deps.checks` hook (its reason is passed through).
263
+ * @param {string} runId @param {string} scope @param {Date} [now] @param {GrantDeps} [deps]
264
+ * @returns {Promise<{ok: true, grant: Grant, reason?: undefined} | {ok: false, reason: RefusalReason | string, grant: Grant | null}>}
265
+ * @throws {StateError} `unknown-scope` for a scope outside the vocabulary (a caller bug, not a refusal)
266
+ */
267
+ export async function grantFor(runId, scope, now = new Date(), deps = {}) {
268
+ if (!ALL_SCOPES.includes(scope)) throw new StateError('unknown-scope', `unknown autopilot scope ${JSON.stringify(scope)}`);
269
+ const { state, grant } = await checkExpiry(runId, now, deps);
270
+ if (grant === null) return { ok: false, reason: 'no-grant', grant: null };
271
+ if (state === 'run-ended') return { ok: false, reason: 'run-ended', grant };
272
+ if (state === 'stopped') return { ok: false, reason: 'stopped', grant };
273
+ if (state === 'expired') return { ok: false, reason: 'expired', grant };
274
+ if (isFixedDeny(scope) || grant.deny.includes(scope)) return { ok: false, reason: 'denied', grant };
275
+ if (!grant.scopes.includes(scope)) return { ok: false, reason: 'not-allowed', grant };
276
+ const category = /** @type {Record<string, string>} */ (SCOPE_CATEGORY)[scope];
277
+ if (category !== undefined && grant.paused && Object.hasOwn(grant.paused, category)) return { ok: false, reason: 'paused', grant };
278
+ for (const check of deps.checks ?? []) {
279
+ const reason = await check(grant, scope, now);
280
+ if (reason !== null) return { ok: false, reason, grant };
281
+ }
282
+ return { ok: true, grant };
283
+ }
284
+
285
+ /**
286
+ * `autopilot start`: validate, then — under the run lock — refuse an ended run or a second active
287
+ * grant, write the signed `autopilot.grant` row and store the grant. An earlier grant whose window
288
+ * is over is expired first (its row written once), then replaced.
289
+ * @param {{runId: string, input: Parameters<typeof validateGrantInput>[0], now?: Date, writeRow?: WriteRow, grantId?: string}} opts
290
+ * @returns {Promise<Grant>}
291
+ */
292
+ export async function startGrant({ runId, input, now = new Date(), writeRow, grantId }) {
293
+ const valid = validateGrantInput(input, now);
294
+ await checkExpiry(runId, now, { writeRow });
295
+ return withRunLock(runId, async () => {
296
+ const record = await readRun(runId);
297
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
298
+ const previous = grantOf(record);
299
+ if (previous !== null && grantState(previous, now) === 'active') {
300
+ throw new StateError('grant-active', activeGrantMessage(runId, previous.until));
301
+ }
302
+ /** @type {Grant} */
303
+ const grant = {
304
+ grant_id: grantId ?? `ap-${randomBytes(4).toString('hex')}`,
305
+ status: 'active',
306
+ ...valid,
307
+ started_at: now.toISOString(),
308
+ stopped_at: null,
309
+ expired_at: null,
310
+ link: null,
311
+ };
312
+ // the row first: a grant stored without its signed row would act unrecorded (fail closed)
313
+ await writeSigned(runId, writerFor(record, { writeRow }), {
314
+ event: 'autopilot.grant',
315
+ grant_id: grant.grant_id,
316
+ scopes: grant.scopes,
317
+ deny: grant.deny,
318
+ delegate: grant.delegate,
319
+ until: grant.until,
320
+ caps: grant.caps,
321
+ stop_at: grant.stop_at,
322
+ ts: now.toISOString(),
323
+ });
324
+ record.autopilot = grant;
325
+ await saveRun(record);
326
+ return grant;
327
+ });
328
+ }
329
+
330
+ /**
331
+ * `autopilot stop`: end the active grant now — the signed `autopilot.stop` row first, then the
332
+ * grant is saved as stopped. A row that cannot be written leaves the grant active and unchanged,
333
+ * so the stop can simply be retried (and the ledger never misses a stop that took effect).
334
+ * An expired grant is refused (`grant-expired`; its expire row comes from the check, once).
335
+ * @param {{runId: string, now?: Date, writeRow?: WriteRow}} opts
336
+ * @returns {Promise<Grant>}
337
+ */
338
+ export async function stopGrant({ runId, now = new Date(), writeRow }) {
339
+ await checkExpiry(runId, now, { writeRow });
340
+ return withRunLock(runId, async () => {
341
+ const record = await readRun(runId);
342
+ const grant = grantOf(record);
343
+ if (grant === null) throw new StateError('no-grant', `run ${runId} has no autopilot grant`);
344
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
345
+ const state = grantState(grant, now);
346
+ if (state === 'stopped') throw new StateError('grant-stopped', `run ${runId}'s autopilot grant was already stopped at ${grant.stopped_at}`);
347
+ if (state === 'expired') throw new StateError('grant-expired', expiredMessage(runId, grant.until));
348
+ await writeSigned(runId, writerFor(record, { writeRow }), { event: 'autopilot.stop', grant_id: grant.grant_id, ts: now.toISOString() });
349
+ grant.status = 'stopped';
350
+ grant.stopped_at = now.toISOString();
351
+ await saveRun(record);
352
+ return grant;
353
+ });
354
+ }