simt-dev 0.7.0 → 0.8.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/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/mcp/server.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { createInterface } from 'node:readline';
|
|
|
24
24
|
import { CLI } from '../name.mjs';
|
|
25
25
|
import { getPrompt, PROMPTS } from './prompts.mjs';
|
|
26
26
|
import { RESOURCE_TEMPLATES, listResources, readResource } from './resources.mjs';
|
|
27
|
+
import { paginate } from './paginate.mjs';
|
|
28
|
+
import { check } from './schema.mjs';
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Every protocol revision this server speaks, newest first.
|
|
@@ -49,6 +51,8 @@ export const RPC = {
|
|
|
49
51
|
PARSE: -32700,
|
|
50
52
|
REQUEST: -32600,
|
|
51
53
|
METHOD: -32601,
|
|
54
|
+
/** Arguments that do not match the `inputSchema` the client was shown. */
|
|
55
|
+
INVALID_PARAMS: -32602,
|
|
52
56
|
INTERNAL: -32603,
|
|
53
57
|
/**
|
|
54
58
|
* `UnsupportedProtocolVersionError`, renumbered to -32022 in 2026-07-28. The spec
|
|
@@ -86,6 +90,15 @@ const SERVER_INFO = { name: 'simt-dev', title: 'SIMT developer and agent tooling
|
|
|
86
90
|
*/
|
|
87
91
|
const LIST_CACHE = { ttlMs: 3600000, cacheScope: 'private' };
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The largest request line this server will answer, in bytes.
|
|
95
|
+
*
|
|
96
|
+
* One mebibyte, because the largest honest argument here is a task description or a path
|
|
97
|
+
* list and both are kilobytes: a line past this is a malformed request or a stream that
|
|
98
|
+
* lost its newline, and neither should be parsed in full.
|
|
99
|
+
*/
|
|
100
|
+
const MAX_LINE_BYTES = 1 << 20;
|
|
101
|
+
|
|
89
102
|
/**
|
|
90
103
|
* Handle one parsed request. Pure with respect to the process: returns the response
|
|
91
104
|
* object or null (for a notification), and never writes anywhere. Testable without a
|
|
@@ -117,7 +130,17 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
117
130
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
118
131
|
return error(null, RPC.REQUEST, 'request must be a JSON object');
|
|
119
132
|
}
|
|
133
|
+
// The envelope is checked before anything reads it. `jsonrpc:"2.0"` and an id that is
|
|
134
|
+
// a string, a number or absent are the three things every revision requires of a
|
|
135
|
+
// request. An object id used to be echoed back unchanged, which leaves the client
|
|
136
|
+
// holding an answer it cannot match to anything it sent (Q56).
|
|
137
|
+
if (request.jsonrpc !== '2.0') {
|
|
138
|
+
return error(null, RPC.REQUEST, 'request must carry jsonrpc:"2.0"');
|
|
139
|
+
}
|
|
120
140
|
const { id, method, params } = request;
|
|
141
|
+
if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') {
|
|
142
|
+
return error(null, RPC.REQUEST, 'id must be a string, a number or absent');
|
|
143
|
+
}
|
|
121
144
|
// A notification is a request with no id, and the spec's rule is unconditional: it
|
|
122
145
|
// never gets a response, whatever its method. Answering one anyway desynchronises a
|
|
123
146
|
// client that is waiting for the next real answer in the stream.
|
|
@@ -185,11 +208,15 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
185
208
|
}
|
|
186
209
|
return reply({});
|
|
187
210
|
|
|
188
|
-
case 'tools/list':
|
|
211
|
+
case 'tools/list': {
|
|
212
|
+
const page = paginate(tools, params?.cursor);
|
|
213
|
+
if (page === null) return badCursor(id);
|
|
189
214
|
return reply({
|
|
190
|
-
tools:
|
|
215
|
+
tools: page.items.map((t) => describe(t, v)),
|
|
216
|
+
...nextPage(page),
|
|
191
217
|
...(cacheable(v) ? LIST_CACHE : {}),
|
|
192
218
|
});
|
|
219
|
+
}
|
|
193
220
|
|
|
194
221
|
case 'tools/call': {
|
|
195
222
|
const name = params?.name;
|
|
@@ -200,6 +227,15 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
200
227
|
return error(id, RPC.METHOD, `no tool named "${name}"; call tools/list first`);
|
|
201
228
|
}
|
|
202
229
|
const args = params?.arguments ?? {};
|
|
230
|
+
// The schema in `tools/list` is the schema this server enforces. A tool that
|
|
231
|
+
// declares `required:['name']` and runs happily on `{}` answers a question nobody
|
|
232
|
+
// asked, and the answer looks like a real one - `skills_show` with no name used to
|
|
233
|
+
// be a lookup for the string "undefined" (Q56). Bad arguments are the client's
|
|
234
|
+
// mistake, so they are a protocol error and not a tool result.
|
|
235
|
+
const invalid = check(args, tool.inputSchema);
|
|
236
|
+
if (invalid !== null) {
|
|
237
|
+
return error(id, RPC.INVALID_PARAMS, `invalid arguments for ${name}: ${invalid}`);
|
|
238
|
+
}
|
|
203
239
|
// The one impurity in this file, and scoped to it: progress has to reach
|
|
204
240
|
// the client while the tool runs, not after it returns. Nothing else
|
|
205
241
|
// writes here - diagnostics still go to stderr, never this stream.
|
|
@@ -222,30 +258,34 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
222
258
|
// `outputSchema` and what it can index; the text block is what a client that
|
|
223
259
|
// predates structured output reads. Emitting one without the other leaves half
|
|
224
260
|
// the clients guessing, which is the state this release was filed for.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
structuredContent: payload,
|
|
228
|
-
isError: false,
|
|
229
|
-
});
|
|
261
|
+
const shaped = bounded(payload);
|
|
262
|
+
return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: false });
|
|
230
263
|
} catch (failure) {
|
|
231
264
|
// An error is a result too, and it carries the same two shapes: the remedy is
|
|
232
265
|
// the part a model needs to act on, and it has to survive in whichever block the
|
|
233
266
|
// client reads.
|
|
234
267
|
const payload = { error: failure.message, remedy: failure.remedy ?? null };
|
|
235
|
-
|
|
268
|
+
const shaped = bounded(payload);
|
|
269
|
+
return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: true });
|
|
236
270
|
}
|
|
237
271
|
}
|
|
238
272
|
|
|
239
|
-
case 'resources/list':
|
|
273
|
+
case 'resources/list': {
|
|
274
|
+
const page = paginate(await listResources(root), params?.cursor);
|
|
275
|
+
if (page === null) return badCursor(id);
|
|
240
276
|
return reply(
|
|
241
|
-
cacheable(v) ? { resources:
|
|
277
|
+
cacheable(v) ? { resources: page.items, ...nextPage(page), ...LIST_CACHE } : { resources: page.items, ...nextPage(page) },
|
|
242
278
|
);
|
|
243
|
-
|
|
279
|
+
}
|
|
280
|
+
case 'resources/templates/list': {
|
|
281
|
+
const page = paginate(RESOURCE_TEMPLATES, params?.cursor);
|
|
282
|
+
if (page === null) return badCursor(id);
|
|
244
283
|
return reply(
|
|
245
284
|
cacheable(v)
|
|
246
|
-
? { resourceTemplates:
|
|
247
|
-
: { resourceTemplates:
|
|
285
|
+
? { resourceTemplates: page.items, ...nextPage(page), ...LIST_CACHE }
|
|
286
|
+
: { resourceTemplates: page.items, ...nextPage(page) },
|
|
248
287
|
);
|
|
288
|
+
}
|
|
249
289
|
case 'resources/read': {
|
|
250
290
|
// The code for a resource that is not there CHANGED, and a server that answers
|
|
251
291
|
// with one code to both revisions is a server that is wrong to whichever half of
|
|
@@ -259,10 +299,13 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
259
299
|
cacheable(v) ? { contents: [found], ...LIST_CACHE } : { contents: [found] },
|
|
260
300
|
);
|
|
261
301
|
}
|
|
262
|
-
case 'prompts/list':
|
|
302
|
+
case 'prompts/list': {
|
|
303
|
+
const page = paginate(PROMPTS, params?.cursor);
|
|
304
|
+
if (page === null) return badCursor(id);
|
|
263
305
|
return reply(
|
|
264
|
-
cacheable(v) ? { prompts:
|
|
306
|
+
cacheable(v) ? { prompts: page.items, ...nextPage(page), ...LIST_CACHE } : { prompts: page.items, ...nextPage(page) },
|
|
265
307
|
);
|
|
308
|
+
}
|
|
266
309
|
case 'prompts/get': {
|
|
267
310
|
const name = params?.name;
|
|
268
311
|
// `getPrompt` returns null only for a prompt this server does not have: its
|
|
@@ -275,13 +318,27 @@ export async function handle(request, tools, { packageVersion, state, root, sign
|
|
|
275
318
|
if (found === null) return error(id, RPC.METHOD, `no prompt named "${String(name)}"; call prompts/list first`);
|
|
276
319
|
return reply(found);
|
|
277
320
|
}
|
|
321
|
+
case 'resources/subscribe':
|
|
322
|
+
case 'resources/unsubscribe':
|
|
323
|
+
// A named refusal, not `unknown method`. The capability block above says `resources:
|
|
324
|
+
// {}` - no subscriptions - so a well-behaved client never asks; one that asks anyway
|
|
325
|
+
// is asking a protocol question and deserves the reason rather than a message that
|
|
326
|
+
// reads as a typo and sends it hunting for a spelling.
|
|
327
|
+
return isNotification
|
|
328
|
+
? null
|
|
329
|
+
: error(id, RPC.METHOD, `${method} is not supported: this server has no change source; resources/list is always current`);
|
|
330
|
+
|
|
278
331
|
default:
|
|
279
332
|
return isNotification ? null : error(id, RPC.METHOD, `unknown method "${method}"`);
|
|
280
333
|
}
|
|
281
334
|
}
|
|
282
335
|
|
|
336
|
+
// One entry point, named the way a client names it. This used to spell the CLI command
|
|
337
|
+
// (`simt skills brief`) while the tool description called itself THE entry point and the
|
|
338
|
+
// prompt pointed at `brief` - three answers to "where do I start", and the one a model
|
|
339
|
+
// read first was the only one it could not call (Q56).
|
|
283
340
|
const INSTRUCTIONS =
|
|
284
|
-
|
|
341
|
+
'Read-only. Every answer is JSON. Call `skills_brief` first: it returns the pre-assembled skill set for a task, plus what it deliberately left out and why, and it needs no index. Call `brief` for the repository packet once the index exists.';
|
|
285
342
|
|
|
286
343
|
function serverInfo(packageVersion) {
|
|
287
344
|
return { ...SERVER_INFO, version: packageVersion ?? '0.0.0' };
|
|
@@ -350,6 +407,22 @@ function decorate(value, version, { packageVersion }) {
|
|
|
350
407
|
};
|
|
351
408
|
}
|
|
352
409
|
|
|
410
|
+
/** The cursor field, present only when there is a next page. */
|
|
411
|
+
function nextPage(page) {
|
|
412
|
+
return page.nextCursor === null ? {} : { nextCursor: page.nextCursor };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A cursor this server did not issue.
|
|
417
|
+
*
|
|
418
|
+
* A protocol error, not an empty page: an empty page reads as "the list ended", which is
|
|
419
|
+
* a different fact from "your cursor is stale" and leads the client to show a partial
|
|
420
|
+
* estate as a complete one.
|
|
421
|
+
*/
|
|
422
|
+
function badCursor(id) {
|
|
423
|
+
return error(id, RPC.INVALID_PARAMS, 'invalid cursor: omit it to start at the first page');
|
|
424
|
+
}
|
|
425
|
+
|
|
353
426
|
function describe(tool, version) {
|
|
354
427
|
const entry = {
|
|
355
428
|
name: tool.name,
|
|
@@ -369,17 +442,28 @@ function describe(tool, version) {
|
|
|
369
442
|
return entry;
|
|
370
443
|
}
|
|
371
444
|
|
|
372
|
-
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
|
|
445
|
+
/** Both blocks, bounded together.
|
|
446
|
+
*
|
|
447
|
+
* `maxBytes` used to cut the text block alone, so a client reading `structuredContent`
|
|
448
|
+
* got the whole payload beside a text block that said it had been truncated: two answers
|
|
449
|
+
* to one question, and the unannounced one was the larger. A truncated payload is not the
|
|
450
|
+
* payload, so the structured block says so in its own shape - a model validating against
|
|
451
|
+
* `outputSchema` reads a complete-looking object as complete and the missing part as
|
|
452
|
+
* absent, which is the one reading truncation must never produce (Q56). */
|
|
453
|
+
function bounded(payload) {
|
|
454
|
+
const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
377
455
|
const limit = typeof payload === 'object' && payload !== null ? payload.maxBytes : 0;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
456
|
+
const size = Buffer.byteLength(text);
|
|
457
|
+
if (!Number.isInteger(limit) || limit <= 0 || size <= limit) {
|
|
458
|
+
return { content: [{ type: 'text', text }], structuredContent: payload };
|
|
381
459
|
}
|
|
382
|
-
|
|
460
|
+
const slice = Buffer.from(text).subarray(0, limit).toString('utf8');
|
|
461
|
+
const omitted = size - limit;
|
|
462
|
+
const cut = `${slice}\nTRUNCATED ${omitted} bytes omitted; raise maxBytes`;
|
|
463
|
+
return {
|
|
464
|
+
content: [{ type: 'text', text: cut }],
|
|
465
|
+
structuredContent: { truncated: true, omittedBytes: omitted, maxBytes: limit, text: cut },
|
|
466
|
+
};
|
|
383
467
|
}
|
|
384
468
|
|
|
385
469
|
function result(id, value) {
|
|
@@ -428,6 +512,16 @@ export async function serve(tools, { input = process.stdin, output = process.std
|
|
|
428
512
|
try {
|
|
429
513
|
for await (const line of rl) {
|
|
430
514
|
if (line.trim() === '') continue;
|
|
515
|
+
// readline has already buffered this line, so the cap bounds what is parsed and
|
|
516
|
+
// answered rather than what is read. That is still the difference between a request
|
|
517
|
+
// the server refuses by name and one it takes apart for minutes: a 40 MB line used
|
|
518
|
+
// to reach `JSON.parse` and take the process with it (Q56).
|
|
519
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
|
|
520
|
+
output.write(
|
|
521
|
+
`${JSON.stringify(error(null, RPC.REQUEST, `request is larger than ${MAX_LINE_BYTES} bytes`))}\n`,
|
|
522
|
+
);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
431
525
|
|
|
432
526
|
// Parse and handling fail differently and must answer differently. A line that is
|
|
433
527
|
// not JSON has no id to answer to, so it is a parse error with `id: null`; a
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Half of the MCP tool list. The tools are read-only and answer through the commands.
|
|
2
|
+
|
|
3
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
4
|
+
import { provenanceDigest } from '../projection/project.mjs';
|
|
5
|
+
import { discover } from '../compare/discover.mjs';
|
|
6
|
+
import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
|
|
7
|
+
import { GATES } from '../hooks/gates.mjs';
|
|
8
|
+
import { checkRepo } from '../commands/sync.mjs';
|
|
9
|
+
import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
|
|
10
|
+
import { rulesCatalogue, rulesWhyFacts } from '../commands/rules.mjs';
|
|
11
|
+
import { skillsBriefFacts, skillsFindFacts, skillsShowFacts, skillsWhyFacts } from '../commands/skills.mjs';
|
|
12
|
+
import { assembleBrief } from '../brief/assemble.mjs';
|
|
13
|
+
import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
|
|
14
|
+
import { federationAccess, readFederation } from '../federation/config.mjs';
|
|
15
|
+
import { consult } from '../federation/query.mjs';
|
|
16
|
+
import { planNext, planDone } from '../plan/index.mjs';
|
|
17
|
+
import { buildPrBody } from '../prbody.mjs';
|
|
18
|
+
import { configPaths, readUserConfig, resolveWorkspace } from '../workspace.mjs';
|
|
19
|
+
import { measureSkill } from '../graph/load.mjs';
|
|
20
|
+
import { CLI } from '../name.mjs';
|
|
21
|
+
import { ERROR_SHAPE, GATE_KINDS, noArgs, output, rootArg } from './schemas.mjs';
|
|
22
|
+
import { registry, rootOf } from './schemas.mjs';
|
|
23
|
+
|
|
24
|
+
export const TOOLS_FACTS = [
|
|
25
|
+
{
|
|
26
|
+
outputSchema: output.check,
|
|
27
|
+
name: 'check',
|
|
28
|
+
description:
|
|
29
|
+
'The gate for one repository: region current, unmanaged, drifted or malformed, and rule files lacking an id. Read-only - it never repairs.',
|
|
30
|
+
inputSchema: rootArg,
|
|
31
|
+
async run(args) {
|
|
32
|
+
const root = rootOf(args);
|
|
33
|
+
const { ok, findings } = await checkRepo(root, {});
|
|
34
|
+
return { root, ok, findings };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
outputSchema: output.compare,
|
|
40
|
+
name: 'compare',
|
|
41
|
+
description:
|
|
42
|
+
'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
root: { type: 'string', description: 'a workspace directory, or one repository' },
|
|
47
|
+
workspace: { type: 'string', description: 'workspace root when root is not given' },
|
|
48
|
+
limit: { type: 'number', description: 'page size; without it every row is built' },
|
|
49
|
+
cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
|
|
50
|
+
concurrency: { type: 'number', description: 'rows built at once (default 4)' },
|
|
51
|
+
},
|
|
52
|
+
required: [],
|
|
53
|
+
},
|
|
54
|
+
async run(args, ctx = {}) {
|
|
55
|
+
const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
|
|
56
|
+
const found = await discover(root);
|
|
57
|
+
if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
|
|
58
|
+
const expectedSha = await provenanceDigest();
|
|
59
|
+
const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
|
|
60
|
+
// A page names its start as an index into discovery order, which is
|
|
61
|
+
// stable between calls on an unchanged estate; garbage parses to 0
|
|
62
|
+
// rather than erroring, because a dashboard that refuses to run over a
|
|
63
|
+
// stale cursor is worse than one that restarts the listing.
|
|
64
|
+
const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
|
|
65
|
+
const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
|
|
66
|
+
const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
|
|
67
|
+
const rows = await buildRows(page, context, {
|
|
68
|
+
concurrency: resolveConcurrency(args ?? {}),
|
|
69
|
+
signal: ctx?.signal ?? null,
|
|
70
|
+
onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
|
|
71
|
+
});
|
|
72
|
+
const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
|
|
73
|
+
return {
|
|
74
|
+
root,
|
|
75
|
+
shape: found.shape,
|
|
76
|
+
expectedSha,
|
|
77
|
+
summary: summarise(rows, { root, shape: found.shape, expectedSha }),
|
|
78
|
+
repos: rows,
|
|
79
|
+
worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
|
|
80
|
+
directories: found.directories,
|
|
81
|
+
...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
outputSchema: output.plan_next,
|
|
87
|
+
name: 'plan_next',
|
|
88
|
+
description: "The next queued slice whose dependencies are complete; its design section only. Pass id for one slice whether or not it is ready.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
93
|
+
id: { type: 'string', description: 'slice id; when given, return that slice' },
|
|
94
|
+
},
|
|
95
|
+
required: [],
|
|
96
|
+
},
|
|
97
|
+
async run(args) {
|
|
98
|
+
const root = rootOf(args);
|
|
99
|
+
const id = typeof args?.id === 'string' && args.id.length > 0 ? args.id : null;
|
|
100
|
+
const result = await planNext(root, id);
|
|
101
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
102
|
+
return { root: result.root, next: result.next, reason: result.reason };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
outputSchema: output.plan_done,
|
|
107
|
+
name: 'plan_done',
|
|
108
|
+
// Same function as the command, and deliberately a preview. This surface does
|
|
109
|
+
// not write: the row change is a diff, and the command applies it behind --yes.
|
|
110
|
+
description: 'Preview the one-row rewrite for a slice. The write stays on the plan done command, behind --yes.',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
115
|
+
id: { type: 'string', description: 'slice id' },
|
|
116
|
+
sha: { type: 'string', description: 'commit sha that closed the slice' },
|
|
117
|
+
test: { type: 'string', description: 'test command that closed it' },
|
|
118
|
+
state: { type: 'string', description: 'target state; omitted means the completed state of the repository template (Complete for tracker)' },
|
|
119
|
+
},
|
|
120
|
+
required: ['id'],
|
|
121
|
+
},
|
|
122
|
+
async run(args) {
|
|
123
|
+
const root = rootOf(args);
|
|
124
|
+
const result = await planDone(root, args?.id, {
|
|
125
|
+
sha: args?.sha ?? null,
|
|
126
|
+
test: args?.test ?? null,
|
|
127
|
+
state: args?.state ?? null,
|
|
128
|
+
dryRun: true,
|
|
129
|
+
});
|
|
130
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
131
|
+
return {
|
|
132
|
+
root: result.root,
|
|
133
|
+
id: result.id,
|
|
134
|
+
doc: result.doc,
|
|
135
|
+
line: result.line,
|
|
136
|
+
from: result.from,
|
|
137
|
+
to: result.to,
|
|
138
|
+
wouldChange: result.wouldChange,
|
|
139
|
+
dryRun: true,
|
|
140
|
+
remedy: result.remedy,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
// The PR-flow entry point over this surface: the same engine the `pr-body`
|
|
145
|
+
// command renders, returning JSON for a model. Read-only like everything
|
|
146
|
+
// here — it names the tree, reads the ledger and the check cache, and writes
|
|
147
|
+
// nothing, so the exit-code-never-carries-the-verdict contract stays intact.
|
|
148
|
+
{
|
|
149
|
+
outputSchema: output.pr_body,
|
|
150
|
+
name: 'pr_body',
|
|
151
|
+
// R41's 2 KB description budget is shared, not per tool: this sentence was
|
|
152
|
+
// paid for by shortening `brief` and `federation_query`, and the test on the
|
|
153
|
+
// total holds every future tool to the same trade.
|
|
154
|
+
description:
|
|
155
|
+
'The pull request body: ledger evidence for this tree, the decisions the diff touches, gate state, and the ADR-008 verdict with what was not reviewed.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
160
|
+
base: { type: 'string', description: 'the ref the body diffs against; defaults to origin/main, then main' },
|
|
161
|
+
},
|
|
162
|
+
required: [],
|
|
163
|
+
},
|
|
164
|
+
async run(args) {
|
|
165
|
+
const root = rootOf(args);
|
|
166
|
+
const base = typeof args?.base === 'string' && args.base.length > 0 ? args.base : null;
|
|
167
|
+
const report = await buildPrBody(root, { base });
|
|
168
|
+
if (report.error !== null) {
|
|
169
|
+
throw Object.assign(new Error(report.error), {
|
|
170
|
+
remedy: `run: ${CLI} pr-body --base <ref> with a ref git knows`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
root,
|
|
175
|
+
base: report.base,
|
|
176
|
+
tree: report.tree,
|
|
177
|
+
verdict: report.verdict,
|
|
178
|
+
unreviewed: report.unreviewed,
|
|
179
|
+
paths: report.paths,
|
|
180
|
+
decisions: report.decisions,
|
|
181
|
+
gates: report.gates,
|
|
182
|
+
evidence: report.evidence,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
outputSchema: output.brief,
|
|
188
|
+
name: 'brief',
|
|
189
|
+
description:
|
|
190
|
+
'The packet for one task in prompt-sized bytes: kind, commands, files, tests, decisions, skills. With no task it describes the repository.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
195
|
+
task: { type: 'string', description: "what the agent is about to do, in the user's own words" },
|
|
196
|
+
paths: { type: 'string', description: 'comma-separated paths to scope the packet to, instead of the files git reports as modified' },
|
|
197
|
+
budget: { type: 'number', description: 'byte ceiling; 0 means the whole packet (default 2048)' },
|
|
198
|
+
},
|
|
199
|
+
required: [],
|
|
200
|
+
},
|
|
201
|
+
async run(args) {
|
|
202
|
+
const root = rootOf(args);
|
|
203
|
+
const budget = Number.isFinite(args?.budget) && args.budget >= 0 ? args.budget : DEFAULT_BRIEF_BYTES;
|
|
204
|
+
// Read, never build: every tool here declares itself read-only, and a call that
|
|
205
|
+
// indexed the tree would make that declaration false. A brief with no index to read
|
|
206
|
+
// says which command makes one - the same rule the delivered packet follows, so a
|
|
207
|
+
// pull and a push pay the same price and neither one writes.
|
|
208
|
+
const result = await assembleBrief({
|
|
209
|
+
root,
|
|
210
|
+
task: typeof args?.task === 'string' ? args.task : '',
|
|
211
|
+
paths: typeof args?.paths === 'string' ? args.paths : null,
|
|
212
|
+
budget,
|
|
213
|
+
build: false,
|
|
214
|
+
});
|
|
215
|
+
if (result.error) throw Object.assign(new Error('the index could not be read'), { remedy: `run: ${CLI} index` });
|
|
216
|
+
// A repository with no index is not a failed call. Every surface points here first,
|
|
217
|
+
// so the one tool told to be the entry point must answer in a repository that has
|
|
218
|
+
// never been indexed - it falls back to the skill packet, which is read from the
|
|
219
|
+
// package rather than the tree, and names the command that unlocks the rest (Q56).
|
|
220
|
+
if (result.cold) {
|
|
221
|
+
const task = typeof args?.task === 'string' ? args.task : '';
|
|
222
|
+
const facts = await skillsBriefFacts(root, { ...(args ?? {}), task });
|
|
223
|
+
// Through JSON once, so the packet carries exactly what the text block shows: a
|
|
224
|
+
// property whose value is `undefined` survives `stringify` as nothing at all, and
|
|
225
|
+
// the two blocks would then describe different objects.
|
|
226
|
+
const text = JSON.stringify(facts, null, 2);
|
|
227
|
+
const packet = JSON.parse(text);
|
|
228
|
+
const size = Buffer.byteLength(text);
|
|
229
|
+
return {
|
|
230
|
+
root,
|
|
231
|
+
kind: 'skills',
|
|
232
|
+
basis: 'registry',
|
|
233
|
+
task,
|
|
234
|
+
scope: [],
|
|
235
|
+
budget,
|
|
236
|
+
bytes: size,
|
|
237
|
+
before: size,
|
|
238
|
+
// The skill packet is one document and is not cut to fit: saying the floor held
|
|
239
|
+
// when the answer is over it is the class of claim this review exists to remove.
|
|
240
|
+
floorHeld: size <= budget,
|
|
241
|
+
cut: size <= budget ? [] : ['text'],
|
|
242
|
+
sections: { skills: (packet.resolved ?? []).map((s) => s.name) },
|
|
243
|
+
text,
|
|
244
|
+
note: `no index here, so this is the skill packet; run: ${CLI} index for the repository packet`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
root: result.root,
|
|
249
|
+
kind: result.kind,
|
|
250
|
+
basis: result.basis,
|
|
251
|
+
task: result.task,
|
|
252
|
+
scope: result.scope,
|
|
253
|
+
budget: result.budget,
|
|
254
|
+
bytes: result.packed.after,
|
|
255
|
+
before: result.packed.before,
|
|
256
|
+
floorHeld: result.packed.floorHeld,
|
|
257
|
+
cut: result.packed.cut,
|
|
258
|
+
sections: result.sections,
|
|
259
|
+
text: result.packed.text,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
outputSchema: output.federation_query,
|
|
265
|
+
name: 'federation_query',
|
|
266
|
+
description:
|
|
267
|
+
'Ask a declared code-graph server one structural question; the answer names the server. Starts only servers simt.json names; none declared is an error.',
|
|
268
|
+
// This tool spawns the repo manifest's `command`, so it is not read-only: a client
|
|
269
|
+
// that cached its answer as one would replay a process spawn as a pure read (Q13).
|
|
270
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
275
|
+
query: { type: 'string', description: "the structural question to ask, in the user's own words" },
|
|
276
|
+
server: { type: 'string', description: 'ask only this declared server, by name; omit to ask every one of them' },
|
|
277
|
+
},
|
|
278
|
+
required: ['query'],
|
|
279
|
+
},
|
|
280
|
+
async run(args) {
|
|
281
|
+
const root = rootOf(args);
|
|
282
|
+
const query = typeof args?.query === 'string' ? args.query.trim() : '';
|
|
283
|
+
if (query.length === 0) {
|
|
284
|
+
throw Object.assign(new Error('the query is empty'), {
|
|
285
|
+
remedy: 'pass a query, for example the symbol or file to look up',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const { servers, findings } = await readFederation(root);
|
|
289
|
+
if (servers.length === 0) {
|
|
290
|
+
throw Object.assign(new Error('this repository declares no federation server'), {
|
|
291
|
+
remedy: 'add a "federation" block naming a server to simt.json, or use the brief tool',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const only = typeof args?.server === 'string' && args.server.length > 0 ? args.server : null;
|
|
295
|
+
const chosen = only === null ? servers : servers.filter((server) => server.name === only);
|
|
296
|
+
if (only !== null && chosen.length === 0) {
|
|
297
|
+
throw Object.assign(new Error(`no declared federation server is named ${only}`), {
|
|
298
|
+
remedy: `declared servers: ${servers.map((server) => server.name).join(', ')}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// A model-supplied root can point at a directory with a crafted manifest, and a
|
|
302
|
+
// repo manifest alone must not authorise a spawn on the operator's machine (Q13).
|
|
303
|
+
const ws = await resolveWorkspace(args ?? {});
|
|
304
|
+
const user = await readUserConfig();
|
|
305
|
+
const denied = federationAccess({
|
|
306
|
+
root,
|
|
307
|
+
cwd: process.cwd(),
|
|
308
|
+
workspaceRoot: ws.root,
|
|
309
|
+
serverNames: chosen.map((server) => server.name),
|
|
310
|
+
userConfig: user.config,
|
|
311
|
+
configPath: user.path ?? configPaths()[0] ?? '~/.config/simt-dev/config.json',
|
|
312
|
+
});
|
|
313
|
+
if (denied !== null) {
|
|
314
|
+
throw Object.assign(new Error(denied.message), { remedy: denied.remedy });
|
|
315
|
+
}
|
|
316
|
+
const { answers, findings: failures } = await consult({ root, query, servers: chosen });
|
|
317
|
+
return {
|
|
318
|
+
root,
|
|
319
|
+
query,
|
|
320
|
+
servers: chosen.map((server) => server.name),
|
|
321
|
+
answers,
|
|
322
|
+
findings: [...findings, ...failures],
|
|
323
|
+
};
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
];
|