simt-dev 0.6.3 → 0.7.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.
- package/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.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-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- 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/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/locate.mjs
CHANGED
|
@@ -35,23 +35,47 @@ export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: np
|
|
|
35
35
|
const D = '$';
|
|
36
36
|
return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
|
|
37
37
|
# point to hand to node. It cannot be a command line, because there is no safe way
|
|
38
|
-
# to split one in sh without eval, and a value with a space
|
|
39
|
-
# half-executed - running from a source checkout is normal for
|
|
40
|
-
# this project itself, so this path has to work with no install step.
|
|
38
|
+
# to split one in sh without eval, and a value with a space that names no file is
|
|
39
|
+
# refused rather than half-executed - running from a source checkout is normal for
|
|
40
|
+
# anyone working on this project itself, so this path has to work with no install step.
|
|
41
|
+
#
|
|
42
|
+
# Every .mjs entry point goes through run_node, which checks for node first: a GUI
|
|
43
|
+
# client's PATH often carries none (nvm is sourced only in an interactive shell), and
|
|
44
|
+
# exec node there fails with 127 and no explanation of what to install.
|
|
45
|
+
run_node() {
|
|
46
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
47
|
+
echo "${CLI}: cannot find node to run ${D}1." >&2
|
|
48
|
+
echo " the hook runs a .mjs entry point through node; install Node >= 22.20.0," >&2
|
|
49
|
+
echo " or set ${BIN_ENV} to an executable ${CLI}" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
entry="${D}1"
|
|
53
|
+
shift
|
|
54
|
+
exec node "${D}entry" "${D}@"
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
run_simt_dev() {
|
|
42
58
|
case "${D}1" in
|
|
43
59
|
*' '*)
|
|
60
|
+
# A space is ambiguous as text and unambiguous as a file: a value that names
|
|
61
|
+
# something on disk is a path in a directory with a space, and runs as one.
|
|
62
|
+
if [ -f "${D}1" ]; then
|
|
63
|
+
case "${D}1" in
|
|
64
|
+
*.mjs)
|
|
65
|
+
run_node "${D}@"
|
|
66
|
+
;;
|
|
67
|
+
*)
|
|
68
|
+
exec "${D}@"
|
|
69
|
+
;;
|
|
70
|
+
esac
|
|
71
|
+
fi
|
|
44
72
|
echo "${CLI}: ${D}${BIN_ENV} names a command line, not a program." >&2
|
|
45
73
|
echo " set it to a single executable or a .mjs path, e.g." >&2
|
|
46
74
|
echo " ${BIN_ENV}=/path/to/checkout/${BIN_REL}" >&2
|
|
47
75
|
exit 1
|
|
48
76
|
;;
|
|
49
77
|
*.mjs)
|
|
50
|
-
|
|
51
|
-
# itself as the first argument.
|
|
52
|
-
program="${D}1"
|
|
53
|
-
shift
|
|
54
|
-
exec node "${D}program" "${D}@"
|
|
78
|
+
run_node "${D}@"
|
|
55
79
|
;;
|
|
56
80
|
*)
|
|
57
81
|
exec "${D}1" "${D}@"
|
|
@@ -64,7 +88,7 @@ if [ -n "${D}{${BIN_ENV}:-}" ]; then
|
|
|
64
88
|
fi
|
|
65
89
|
|
|
66
90
|
if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
|
|
67
|
-
|
|
91
|
+
run_node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
|
|
68
92
|
fi
|
|
69
93
|
|
|
70
94
|
# The binary this repository downloaded for itself: the pinned bytes, no node.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The manifest's own schema: one table of the keys this file may carry, and one
|
|
2
|
+
// loader that says what is wrong with the file rather than throwing or guessing.
|
|
3
|
+
//
|
|
4
|
+
// The manifest was read in 29 places, each doing a bare `JSON.parse` and then
|
|
5
|
+
// reaching for the key it wanted. That is three answers to "what is this repo" -
|
|
6
|
+
// the parse, the key access, and whatever default the caller invented - and a
|
|
7
|
+
// manifest with `"mode": "kernal"` reached the projection as a mode it had never
|
|
8
|
+
// heard of. A typo in one key silently changed behaviour, because nothing in the
|
|
9
|
+
// tool had ever said what the keys are (review 0003 Q69).
|
|
10
|
+
//
|
|
11
|
+
// Every shape below was read off the manifest this repository carries, not from
|
|
12
|
+
// memory: the first draft of this table called `limits.fileLines` a positive integer
|
|
13
|
+
// and `surfaces` an object, and both are wrong in the only file the tool has to hand.
|
|
14
|
+
// `test/manifest-schema.test.mjs` holds the table to that file, which is the check
|
|
15
|
+
// that would have caught it.
|
|
16
|
+
//
|
|
17
|
+
// `owner` is the reason a key is in this table at all. A `tool` key is written by
|
|
18
|
+
// this binary and a repository editing it is fighting the writer; a `repo` key is
|
|
19
|
+
// the repository's own declaration and this tool only reads it. The distinction is
|
|
20
|
+
// already load-bearing (`GENERATED_KEYS`, `init` keeping `limits`), and stating it
|
|
21
|
+
// here is what keeps the two from drifting apart.
|
|
22
|
+
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
27
|
+
|
|
28
|
+
/** Absent means 1: the field arrived after the file did, and every file predates it. */
|
|
29
|
+
const MANIFEST_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The manifest a repository has, which is not necessarily the one this tool would write.
|
|
33
|
+
*
|
|
34
|
+
* The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
|
|
35
|
+
* `join(root, MANIFEST_FILE)` and would have reported an adopted repository as unadopted the
|
|
36
|
+
* day the constant moved - and "no manifest" means "never adopted", which is the answer
|
|
37
|
+
* that offers to re-adopt a repo that is already done. Fourteen repositories carry the
|
|
38
|
+
* old name on disk.
|
|
39
|
+
*
|
|
40
|
+
* So the old name is read as a fallback and the new one is written. `writeManifest`
|
|
41
|
+
* renames rather than creating a second file, which is how the window closes: the first
|
|
42
|
+
* `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
|
|
43
|
+
*/
|
|
44
|
+
export async function manifestPath(root) {
|
|
45
|
+
const next = join(root, MANIFEST_FILE);
|
|
46
|
+
if (existsSync(next)) return next;
|
|
47
|
+
const previous = join(root, MANIFEST_LEGACY);
|
|
48
|
+
return existsSync(previous) ? previous : next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
53
|
+
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
54
|
+
const isPositiveInt = (value) => Number.isInteger(value) && value > 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The keys a manifest may carry.
|
|
58
|
+
*
|
|
59
|
+
* `check` returns a sentence naming what is wrong, or null. It returns a sentence
|
|
60
|
+
* rather than a boolean because the reader of a problem is a person looking at a file
|
|
61
|
+
* they edited by hand, and "invalid" tells them nothing they did not know.
|
|
62
|
+
*/
|
|
63
|
+
export const KEYS = [
|
|
64
|
+
{ key: '$comment', owner: 'tool', generated: true, since: '0.7.0', check: (v) => (typeof v === 'string' || isStringList(v) ? null : '$comment must be a string or a list of strings') },
|
|
65
|
+
{ key: 'id', owner: 'tool', since: '0.7.0', check: (v) => (typeof v === 'string' && v.length > 0 ? null : 'id must be a non-empty string') },
|
|
66
|
+
{ key: 'version', owner: 'tool', since: '0.9.0', check: (v) => (Number.isInteger(v) && v >= 1 ? null : 'version must be an integer of at least 1') },
|
|
67
|
+
{ key: 'stack', owner: 'tool', since: '0.7.0', check: (v) => (isStringList(v) ? null : 'stack must be a list of profile ids') },
|
|
68
|
+
{ key: 'limits', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'limits must be an object') },
|
|
69
|
+
// {warn, error, excludeTextFiles, exclude} - ADR-005's two tiers plus the paths a
|
|
70
|
+
// repository opts out of, all of which the repository owns and `init` keeps.
|
|
71
|
+
{ key: 'limits.fileLines', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) && isPositiveInt(v.warn) && isPositiveInt(v.error) && v.error >= v.warn ? null : 'limits.fileLines must be {warn, error} with error >= warn') },
|
|
72
|
+
{ key: 'limits.coverage', owner: 'repo', since: '0.7.0', check: (v) => (v === null || (typeof v === 'number' && v >= 0 && v <= 100) ? null : 'limits.coverage must be null or a percentage') },
|
|
73
|
+
{ key: 'gates', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'gates must be an object of gate name to command') },
|
|
74
|
+
{ key: 'localSkills', owner: 'repo', since: '0.7.0', check: (v) => (isStringList(v) ? null : 'localSkills must be a list of skill ids') },
|
|
75
|
+
{ key: 'profiles', owner: 'repo', since: '0.8.0', check: (v) => (isStringList(v) ? null : 'profiles must be a list of profile ids') },
|
|
76
|
+
{ key: 'substitutions', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'substitutions must be an object keyed by rule id') },
|
|
77
|
+
{ key: 'packs', owner: 'repo', since: '0.8.0', check: (v) => (Array.isArray(v) ? null : 'packs must be a list') },
|
|
78
|
+
// A workspace declaration is a path relative to the repository; `workspace.mjs`
|
|
79
|
+
// refuses to walk one that resolves outside, so the type is the whole check.
|
|
80
|
+
{ key: 'workspace', owner: 'repo', since: '0.7.0', check: (v) => (typeof v === 'string' ? null : 'workspace must be a path string') },
|
|
81
|
+
{ key: 'decisions', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'decisions must be an object') },
|
|
82
|
+
{ key: 'budget', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'budget must be an object') },
|
|
83
|
+
{ key: 'budget.bytes', owner: 'repo', since: '0.8.0', check: (v) => (isPositiveInt(v) ? null : 'budget.bytes must be a positive integer') },
|
|
84
|
+
{ key: 'mode', owner: 'repo', since: '0.8.0', check: (v) => (v === 'full' || v === 'kernel' ? null : 'mode must be "full" or "kernel", not ' + JSON.stringify(v)) },
|
|
85
|
+
{ key: 'shared', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'shared must be an object') },
|
|
86
|
+
// The overlay `plan/template.mjs` compiles over the default tracker body; an array
|
|
87
|
+
// would be accepted by an object-typed read and then produce an empty template.
|
|
88
|
+
{ key: 'plans', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'plans must be an object overlay') },
|
|
89
|
+
// Null and absent both mean "the global standard applies" (manifestDrift's own
|
|
90
|
+
// rule), and a list is what `init` writes. An object here is a shape nothing has
|
|
91
|
+
// ever produced, so accepting it would be the validator blessing a typo.
|
|
92
|
+
{ key: 'surfaces', owner: 'repo', since: '0.8.0', check: (v) => (v === null || isStringList(v) ? null : 'surfaces must be null or a list of surface ids') },
|
|
93
|
+
{ key: 'projection', owner: 'tool', generated: true, since: '0.7.0', check: (v) => (isObject(v) ? null : 'projection must be an object') },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const KNOWABLE = new Set(KEYS.map((entry) => entry.key));
|
|
97
|
+
|
|
98
|
+
/** The keys this tool writes, derived from the table: the `generated` marks above. */
|
|
99
|
+
export const GENERATED_KEYS_OF = new Set(KEYS.filter((entry) => entry.generated === true).map((entry) => entry.key));
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Namespaces a consumer registered against this table.
|
|
104
|
+
*
|
|
105
|
+
* A namespace is a sub-object of the manifest that another part of the tool owns -
|
|
106
|
+
* plan 0004's `work` is the first. It registers its keys here rather than editing the
|
|
107
|
+
* table, so adding one touches two files and no reader has to learn a second shape
|
|
108
|
+
* (review 0003 Q69).
|
|
109
|
+
*/
|
|
110
|
+
const NAMESPACES = new Map();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Declare the keys of one namespace. Registered keys are checked exactly like top-level
|
|
114
|
+
* ones; a namespace nobody registered is left alone, because an unknown key has never
|
|
115
|
+
* been an error in this file.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} name the manifest key that carries the namespace
|
|
118
|
+
* @param {{key: string, since?: string, check: (value: unknown) => string|null}[]} keys
|
|
119
|
+
* @returns {number} how many keys the namespace declares
|
|
120
|
+
*/
|
|
121
|
+
export function registerNamespace(name, keys) {
|
|
122
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
123
|
+
throw new Error('namespace "' + name + '" must be lower-case words joined by hyphens');
|
|
124
|
+
}
|
|
125
|
+
if (KNOWABLE.has(name)) throw new Error('namespace "' + name + '" is already a manifest key');
|
|
126
|
+
if (NAMESPACES.has(name)) throw new Error('namespace "' + name + '" is already registered');
|
|
127
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
128
|
+
throw new Error('namespace "' + name + '" must register at least one key');
|
|
129
|
+
}
|
|
130
|
+
for (const entry of keys) {
|
|
131
|
+
if (typeof entry?.key !== 'string' || !/^[a-z][a-z0-9]*$/.test(entry.key)) {
|
|
132
|
+
throw new Error('namespace "' + name + '" keys must be lower-case single words');
|
|
133
|
+
}
|
|
134
|
+
if (typeof entry.check !== 'function') {
|
|
135
|
+
throw new Error('namespace "' + name + '" key "' + entry.key + '" has no check');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
NAMESPACES.set(name, keys.map((entry) => ({ owner: 'repo', since: '0.9.0', ...entry })));
|
|
139
|
+
return keys.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The namespaces a consumer declared, for a reader that needs the whole set. */
|
|
143
|
+
export function registeredNamespaces() {
|
|
144
|
+
return [...NAMESPACES.keys()].sort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What is wrong with one parsed manifest.
|
|
149
|
+
*
|
|
150
|
+
* An unknown key is not a problem. A repository that keeps its own note in this file
|
|
151
|
+
* is doing the thing the file's own comment invites, and a validator that refused it
|
|
152
|
+
* would be the tool insisting its schema is the whole of a file it calls the
|
|
153
|
+
* repository's. Only a KNOWN key with an impossible value is reported.
|
|
154
|
+
*/
|
|
155
|
+
export function manifestProblems(manifest) {
|
|
156
|
+
const problems = [];
|
|
157
|
+
for (const { key, check } of KEYS) {
|
|
158
|
+
const [head, tail] = key.includes('.') ? key.split('.') : [key, null];
|
|
159
|
+
if (!(head in manifest)) continue;
|
|
160
|
+
if (tail !== null && (!isObject(manifest[head]) || !(tail in manifest[head]))) continue;
|
|
161
|
+
const value = tail === null ? manifest[head] : manifest[head][tail];
|
|
162
|
+
if (value === undefined) continue;
|
|
163
|
+
const problem = check(value);
|
|
164
|
+
if (problem !== null) problems.push(problem);
|
|
165
|
+
}
|
|
166
|
+
for (const [name, keys] of NAMESPACES) {
|
|
167
|
+
const value = manifest[name];
|
|
168
|
+
if (!isObject(value)) continue;
|
|
169
|
+
for (const entry of keys) {
|
|
170
|
+
if (!(entry.key in value) || value[entry.key] === undefined) continue;
|
|
171
|
+
const problem = entry.check(value[entry.key]);
|
|
172
|
+
if (problem !== null) problems.push(name + '.' + entry.key + ': ' + problem);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The manifest a repository has, with its problems named.
|
|
180
|
+
*
|
|
181
|
+
* `manifest` is null when there is no file and when the file does not parse, because
|
|
182
|
+
* a caller that receives a half-read object cannot tell which it has - and the one
|
|
183
|
+
* caller that did (`fingerprint`) returned `{error}` in place of a manifest, so a
|
|
184
|
+
* broken file looked like a declaration of a key called `error` (review 0003 Q69).
|
|
185
|
+
* `exists` and `problems` are what tell the two apart, and neither is optional.
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<{manifest: object|null, exists: boolean, version: number, problems: string[], path: string}>}
|
|
188
|
+
*/
|
|
189
|
+
export async function loadManifest(root) {
|
|
190
|
+
const path = await manifestPath(root);
|
|
191
|
+
let text;
|
|
192
|
+
try {
|
|
193
|
+
text = await readFile(path, 'utf8');
|
|
194
|
+
} catch {
|
|
195
|
+
return { manifest: null, exists: false, version: MANIFEST_VERSION, problems: [], path };
|
|
196
|
+
}
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(text);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { manifest: null, exists: true, version: MANIFEST_VERSION, problems: [`unparseable: ${error.message}`], path };
|
|
202
|
+
}
|
|
203
|
+
if (!isObject(parsed)) {
|
|
204
|
+
return { manifest: null, exists: true, version: MANIFEST_VERSION, problems: ['not a JSON object'], path };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
manifest: parsed,
|
|
208
|
+
exists: true,
|
|
209
|
+
version: Number.isInteger(parsed.version) ? parsed.version : MANIFEST_VERSION,
|
|
210
|
+
problems: manifestProblems(parsed),
|
|
211
|
+
path,
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/mcp/resources.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { readFile } from 'node:fs/promises';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
|
|
19
|
-
import { DECISIONS_DIR,
|
|
19
|
+
import { DECISIONS_DIR, loadDecisionsCached, redactEstate, redactInternalPaths } from '../decisions/index.mjs';
|
|
20
20
|
import { planNext } from '../plan/index.mjs';
|
|
21
21
|
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
22
22
|
import { desiredRegion, loadRules } from '../projection/rules.mjs';
|
|
@@ -122,7 +122,9 @@ export async function listResources(root = process.cwd()) {
|
|
|
122
122
|
const [rules, mdc, { decisions }, region, plan] = await Promise.all([
|
|
123
123
|
loadRules(null),
|
|
124
124
|
loadMdcRules(),
|
|
125
|
-
|
|
125
|
+
// Q55: the shipped records through the shared cache (root locates the cache
|
|
126
|
+
// file; the directory stays the package's own, which is what is served).
|
|
127
|
+
loadDecisionsCached({ root, dir: DECISIONS_DIR, all: true }),
|
|
126
128
|
kernelRegion(root),
|
|
127
129
|
planNext(root, null),
|
|
128
130
|
]);
|
|
@@ -197,26 +199,23 @@ export async function readResource(uri, root = process.cwd()) {
|
|
|
197
199
|
const [rules, mdc] = await Promise.all([loadRules(null), loadMdcRules()]);
|
|
198
200
|
const rule = [...rules, ...mdc].find((candidate) => candidate.id === parsed.id);
|
|
199
201
|
if (rule === undefined) return null;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
mimeType: 'text/markdown',
|
|
204
|
-
text,
|
|
205
|
-
// The body a tool returns is redacted for the same reason: a reader outside this
|
|
206
|
-
// estate is told what the rule says and not which directory here it is filed in.
|
|
207
|
-
bytes: Buffer.byteLength(text),
|
|
208
|
-
};
|
|
202
|
+
// The body a tool returns is redacted for the same reason: a reader outside this
|
|
203
|
+
// estate is told what the rule says and not which directory here it is filed in.
|
|
204
|
+
return { uri, mimeType: 'text/markdown', text: `${frontmatterOf(rule)}\n${rule.body ?? ''}`.trim() };
|
|
209
205
|
}
|
|
210
206
|
|
|
211
207
|
// `loadDecisions` reads the index, not the records: it reports status, currency and
|
|
212
208
|
// byte count, and keeps no body. Reading the file the index named is one read of a
|
|
213
209
|
// record the reader has already asked for by id.
|
|
214
|
-
const { decisions } = await
|
|
210
|
+
const { decisions } = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all: true });
|
|
215
211
|
const decision = decisions.find((candidate) => candidate.id === parsed.id);
|
|
216
212
|
if (decision === undefined) return null;
|
|
217
213
|
const raw = await readFile(join(DECISIONS_DIR, decision.file), 'utf8');
|
|
218
214
|
const text = redactInternalPaths(redactEstate(raw)).trim();
|
|
219
|
-
|
|
215
|
+
// `bytes` is not a `ResourceContents` field: a client validating the result against
|
|
216
|
+
// the specification sees an unexpected property, and the length is `text.length` away
|
|
217
|
+
// (Q56).
|
|
218
|
+
return { uri, mimeType: 'text/markdown', text };
|
|
220
219
|
}
|
|
221
220
|
|
|
222
221
|
function frontmatterOf(rule) {
|
package/src/mcp/server.mjs
CHANGED
|
@@ -24,6 +24,7 @@ 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 { check } from './schema.mjs';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Every protocol revision this server speaks, newest first.
|
|
@@ -49,6 +50,8 @@ export const RPC = {
|
|
|
49
50
|
PARSE: -32700,
|
|
50
51
|
REQUEST: -32600,
|
|
51
52
|
METHOD: -32601,
|
|
53
|
+
/** Arguments that do not match the `inputSchema` the client was shown. */
|
|
54
|
+
INVALID_PARAMS: -32602,
|
|
52
55
|
INTERNAL: -32603,
|
|
53
56
|
/**
|
|
54
57
|
* `UnsupportedProtocolVersionError`, renumbered to -32022 in 2026-07-28. The spec
|
|
@@ -86,6 +89,15 @@ const SERVER_INFO = { name: 'simt-dev', title: 'SIMT developer and agent tooling
|
|
|
86
89
|
*/
|
|
87
90
|
const LIST_CACHE = { ttlMs: 3600000, cacheScope: 'private' };
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The largest request line this server will answer, in bytes.
|
|
94
|
+
*
|
|
95
|
+
* One mebibyte, because the largest honest argument here is a task description or a path
|
|
96
|
+
* list and both are kilobytes: a line past this is a malformed request or a stream that
|
|
97
|
+
* lost its newline, and neither should be parsed in full.
|
|
98
|
+
*/
|
|
99
|
+
const MAX_LINE_BYTES = 1 << 20;
|
|
100
|
+
|
|
89
101
|
/**
|
|
90
102
|
* Handle one parsed request. Pure with respect to the process: returns the response
|
|
91
103
|
* object or null (for a notification), and never writes anywhere. Testable without a
|
|
@@ -103,16 +115,31 @@ const LIST_CACHE = { ttlMs: 3600000, cacheScope: 'private' };
|
|
|
103
115
|
* caller names the root and the default is the process's cwd, which is what an editor's
|
|
104
116
|
* stdio server sits in.
|
|
105
117
|
*
|
|
118
|
+
* `signal` aborts the one in-flight `tools/call` (wired by `serve` to
|
|
119
|
+
* `notifications/cancelled`); `notify` emits `notifications/progress` while it
|
|
120
|
+
* runs. Both are null in tests that call this directly, and every tool must
|
|
121
|
+
* treat a missing context as "run to completion quietly".
|
|
122
|
+
*
|
|
106
123
|
* @param {object|null} request a parsed JSON-RPC message
|
|
107
124
|
* @param {object[]} tools
|
|
108
|
-
* @param {{packageVersion?: string, state?: {version: string|null}, root?: string}} options
|
|
125
|
+
* @param {{packageVersion?: string, state?: {version: string|null}, root?: string, signal?: AbortSignal|null, notify?: ((message: object) => void)|null}} options
|
|
109
126
|
* @returns {Promise<object|null>}
|
|
110
127
|
*/
|
|
111
|
-
export async function handle(request, tools, { packageVersion, state, root } = {}) {
|
|
128
|
+
export async function handle(request, tools, { packageVersion, state, root, signal = null, notify = null } = {}) {
|
|
112
129
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
113
130
|
return error(null, RPC.REQUEST, 'request must be a JSON object');
|
|
114
131
|
}
|
|
132
|
+
// The envelope is checked before anything reads it. `jsonrpc:"2.0"` and an id that is
|
|
133
|
+
// a string, a number or absent are the three things every revision requires of a
|
|
134
|
+
// request. An object id used to be echoed back unchanged, which leaves the client
|
|
135
|
+
// holding an answer it cannot match to anything it sent (Q56).
|
|
136
|
+
if (request.jsonrpc !== '2.0') {
|
|
137
|
+
return error(null, RPC.REQUEST, 'request must carry jsonrpc:"2.0"');
|
|
138
|
+
}
|
|
115
139
|
const { id, method, params } = request;
|
|
140
|
+
if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') {
|
|
141
|
+
return error(null, RPC.REQUEST, 'id must be a string, a number or absent');
|
|
142
|
+
}
|
|
116
143
|
// A notification is a request with no id, and the spec's rule is unconditional: it
|
|
117
144
|
// never gets a response, whatever its method. Answering one anyway desynchronises a
|
|
118
145
|
// client that is waiting for the next real answer in the stream.
|
|
@@ -195,23 +222,46 @@ export async function handle(request, tools, { packageVersion, state, root } = {
|
|
|
195
222
|
return error(id, RPC.METHOD, `no tool named "${name}"; call tools/list first`);
|
|
196
223
|
}
|
|
197
224
|
const args = params?.arguments ?? {};
|
|
225
|
+
// The schema in `tools/list` is the schema this server enforces. A tool that
|
|
226
|
+
// declares `required:['name']` and runs happily on `{}` answers a question nobody
|
|
227
|
+
// asked, and the answer looks like a real one - `skills_show` with no name used to
|
|
228
|
+
// be a lookup for the string "undefined" (Q56). Bad arguments are the client's
|
|
229
|
+
// mistake, so they are a protocol error and not a tool result.
|
|
230
|
+
const invalid = check(args, tool.inputSchema);
|
|
231
|
+
if (invalid !== null) {
|
|
232
|
+
return error(id, RPC.INVALID_PARAMS, `invalid arguments for ${name}: ${invalid}`);
|
|
233
|
+
}
|
|
234
|
+
// The one impurity in this file, and scoped to it: progress has to reach
|
|
235
|
+
// the client while the tool runs, not after it returns. Nothing else
|
|
236
|
+
// writes here - diagnostics still go to stderr, never this stream.
|
|
237
|
+
const progressToken = params?._meta?.progressToken;
|
|
238
|
+
const toolCtx = {
|
|
239
|
+
signal,
|
|
240
|
+
onProgress:
|
|
241
|
+
progressToken !== undefined && progressToken !== null && typeof notify === 'function'
|
|
242
|
+
? (progress, total) =>
|
|
243
|
+
notify({
|
|
244
|
+
jsonrpc: '2.0',
|
|
245
|
+
method: 'notifications/progress',
|
|
246
|
+
params: { progressToken, progress, total },
|
|
247
|
+
})
|
|
248
|
+
: null,
|
|
249
|
+
};
|
|
198
250
|
try {
|
|
199
|
-
const payload = await tool.run(args);
|
|
251
|
+
const payload = await tool.run(args, toolCtx);
|
|
200
252
|
// Both blocks, always. `structuredContent` is what a client validates against
|
|
201
253
|
// `outputSchema` and what it can index; the text block is what a client that
|
|
202
254
|
// predates structured output reads. Emitting one without the other leaves half
|
|
203
255
|
// the clients guessing, which is the state this release was filed for.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
structuredContent: payload,
|
|
207
|
-
isError: false,
|
|
208
|
-
});
|
|
256
|
+
const shaped = bounded(payload);
|
|
257
|
+
return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: false });
|
|
209
258
|
} catch (failure) {
|
|
210
259
|
// An error is a result too, and it carries the same two shapes: the remedy is
|
|
211
260
|
// the part a model needs to act on, and it has to survive in whichever block the
|
|
212
261
|
// client reads.
|
|
213
262
|
const payload = { error: failure.message, remedy: failure.remedy ?? null };
|
|
214
|
-
|
|
263
|
+
const shaped = bounded(payload);
|
|
264
|
+
return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: true });
|
|
215
265
|
}
|
|
216
266
|
}
|
|
217
267
|
|
|
@@ -259,8 +309,12 @@ export async function handle(request, tools, { packageVersion, state, root } = {
|
|
|
259
309
|
}
|
|
260
310
|
}
|
|
261
311
|
|
|
312
|
+
// One entry point, named the way a client names it. This used to spell the CLI command
|
|
313
|
+
// (`simt skills brief`) while the tool description called itself THE entry point and the
|
|
314
|
+
// prompt pointed at `brief` - three answers to "where do I start", and the one a model
|
|
315
|
+
// read first was the only one it could not call (Q56).
|
|
262
316
|
const INSTRUCTIONS =
|
|
263
|
-
|
|
317
|
+
'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.';
|
|
264
318
|
|
|
265
319
|
function serverInfo(packageVersion) {
|
|
266
320
|
return { ...SERVER_INFO, version: packageVersion ?? '0.0.0' };
|
|
@@ -348,17 +402,28 @@ function describe(tool, version) {
|
|
|
348
402
|
return entry;
|
|
349
403
|
}
|
|
350
404
|
|
|
351
|
-
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
|
|
355
|
-
|
|
405
|
+
/** Both blocks, bounded together.
|
|
406
|
+
*
|
|
407
|
+
* `maxBytes` used to cut the text block alone, so a client reading `structuredContent`
|
|
408
|
+
* got the whole payload beside a text block that said it had been truncated: two answers
|
|
409
|
+
* to one question, and the unannounced one was the larger. A truncated payload is not the
|
|
410
|
+
* payload, so the structured block says so in its own shape - a model validating against
|
|
411
|
+
* `outputSchema` reads a complete-looking object as complete and the missing part as
|
|
412
|
+
* absent, which is the one reading truncation must never produce (Q56). */
|
|
413
|
+
function bounded(payload) {
|
|
414
|
+
const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
356
415
|
const limit = typeof payload === 'object' && payload !== null ? payload.maxBytes : 0;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
416
|
+
const size = Buffer.byteLength(text);
|
|
417
|
+
if (!Number.isInteger(limit) || limit <= 0 || size <= limit) {
|
|
418
|
+
return { content: [{ type: 'text', text }], structuredContent: payload };
|
|
360
419
|
}
|
|
361
|
-
|
|
420
|
+
const slice = Buffer.from(text).subarray(0, limit).toString('utf8');
|
|
421
|
+
const omitted = size - limit;
|
|
422
|
+
const cut = `${slice}\nTRUNCATED ${omitted} bytes omitted; raise maxBytes`;
|
|
423
|
+
return {
|
|
424
|
+
content: [{ type: 'text', text: cut }],
|
|
425
|
+
structuredContent: { truncated: true, omittedBytes: omitted, maxBytes: limit, text: cut },
|
|
426
|
+
};
|
|
362
427
|
}
|
|
363
428
|
|
|
364
429
|
function result(id, value) {
|
|
@@ -384,33 +449,81 @@ function error(id, code, message) {
|
|
|
384
449
|
export async function serve(tools, { input = process.stdin, output = process.stdout, packageVersion, onError } = {}) {
|
|
385
450
|
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
386
451
|
const state = { version: null };
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
452
|
+
// The in-flight call a `notifications/cancelled` can abort. Read this way
|
|
453
|
+
// because the loop below is blocked awaiting the tool while the client
|
|
454
|
+
// decides: the event loop still turns on subprocess waits, so the peek
|
|
455
|
+
// fires mid-run and the tool checks the signal between rows. The async
|
|
456
|
+
// iterator below yields the same line afterwards and `handle` answers it
|
|
457
|
+
// with null, as every notification is answered - the peek consumes nothing.
|
|
458
|
+
let inflight = null;
|
|
459
|
+
const peekCancel = (line) => {
|
|
460
|
+
if (!inflight || line.trim() === '') return;
|
|
396
461
|
let message;
|
|
397
462
|
try {
|
|
398
463
|
message = JSON.parse(line);
|
|
399
|
-
} catch
|
|
400
|
-
|
|
464
|
+
} catch {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (message?.method === 'notifications/cancelled' && message?.params?.requestId === inflight.id) {
|
|
468
|
+
inflight.controller.abort();
|
|
401
469
|
}
|
|
402
|
-
|
|
470
|
+
};
|
|
471
|
+
rl.on('line', peekCancel);
|
|
472
|
+
try {
|
|
473
|
+
for await (const line of rl) {
|
|
474
|
+
if (line.trim() === '') continue;
|
|
475
|
+
// readline has already buffered this line, so the cap bounds what is parsed and
|
|
476
|
+
// answered rather than what is read. That is still the difference between a request
|
|
477
|
+
// the server refuses by name and one it takes apart for minutes: a 40 MB line used
|
|
478
|
+
// to reach `JSON.parse` and take the process with it (Q56).
|
|
479
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
|
|
480
|
+
output.write(
|
|
481
|
+
`${JSON.stringify(error(null, RPC.REQUEST, `request is larger than ${MAX_LINE_BYTES} bytes`))}\n`,
|
|
482
|
+
);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Parse and handling fail differently and must answer differently. A line that is
|
|
487
|
+
// not JSON has no id to answer to, so it is a parse error with `id: null`; a
|
|
488
|
+
// throw inside `handle` belongs to the request it was answering, so it keeps
|
|
489
|
+
// that request's id — answering it as a parse error hangs the client that is
|
|
490
|
+
// still waiting for its answer (Q12).
|
|
491
|
+
let response;
|
|
492
|
+
let message;
|
|
403
493
|
try {
|
|
404
|
-
|
|
405
|
-
} catch (
|
|
406
|
-
|
|
407
|
-
|
|
494
|
+
message = JSON.parse(line);
|
|
495
|
+
} catch (parseFailure) {
|
|
496
|
+
response = error(null, RPC.PARSE, `invalid JSON: ${parseFailure.message}`);
|
|
497
|
+
}
|
|
498
|
+
if (response === undefined) {
|
|
499
|
+
// A notification never runs: without an id there is nothing to cancel
|
|
500
|
+
// and nothing to answer, so `notifications/cancelled` for an idle server
|
|
501
|
+
// is a no-op rather than an error.
|
|
502
|
+
const isNotification =
|
|
503
|
+
message === null || typeof message !== 'object' || Array.isArray(message) || message.id === undefined || message.id === null;
|
|
504
|
+
const controller = isNotification ? null : new AbortController();
|
|
505
|
+
if (controller) inflight = { id: message.id, controller };
|
|
506
|
+
try {
|
|
507
|
+
response = await handle(message, tools, {
|
|
508
|
+
packageVersion,
|
|
509
|
+
state,
|
|
510
|
+
signal: controller?.signal ?? null,
|
|
511
|
+
notify: (notification) => output.write(`${JSON.stringify(notification)}\n`),
|
|
512
|
+
});
|
|
513
|
+
} catch (handlerFailure) {
|
|
514
|
+
const id = message !== null && typeof message === 'object' && 'id' in message ? message.id : null;
|
|
515
|
+
response = error(id, RPC.INTERNAL, `internal error: ${handlerFailure?.message ?? handlerFailure}`);
|
|
516
|
+
} finally {
|
|
517
|
+
if (controller) inflight = null;
|
|
518
|
+
}
|
|
408
519
|
}
|
|
520
|
+
if (response === null) continue;
|
|
521
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
409
522
|
}
|
|
410
|
-
|
|
411
|
-
|
|
523
|
+
return EXIT_OK;
|
|
524
|
+
} finally {
|
|
525
|
+
rl.off('line', peekCancel);
|
|
412
526
|
}
|
|
413
|
-
return EXIT_OK;
|
|
414
527
|
}
|
|
415
528
|
|
|
416
529
|
const EXIT_OK = 0;
|