simt-dev 0.7.0 → 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.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -872
  2. package/README.md +19 -133
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  6. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  7. 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
  8. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  9. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  10. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  11. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  12. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  13. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  14. package/package.json +10 -2
  15. package/rules/core.md +7 -0
  16. package/rules/mdc/slice-commit.mdc +57 -4
  17. package/src/agenthooks/prompt.mjs +5 -2
  18. package/src/agenthooks/shim.mjs +1 -1
  19. package/src/api.mjs +29 -0
  20. package/src/argv.mjs +29 -3
  21. package/src/bench/adapters.json +16 -0
  22. package/src/bench/corpus.mjs +150 -0
  23. package/src/bench/grade.mjs +23 -49
  24. package/src/bench/mine.mjs +199 -35
  25. package/src/bench/probes.mjs +99 -62
  26. package/src/bench/process.mjs +128 -0
  27. package/src/bench/report.mjs +17 -4
  28. package/src/bench/run.mjs +242 -142
  29. package/src/brief/assemble.mjs +38 -3
  30. package/src/ci/workflow.mjs +30 -7
  31. package/src/cli.mjs +45 -0
  32. package/src/commands/agent.mjs +3 -4
  33. package/src/commands/agenthook.mjs +4 -5
  34. package/src/commands/bench.mjs +190 -17
  35. package/src/commands/brief.mjs +5 -4
  36. package/src/commands/ci.mjs +2 -1
  37. package/src/commands/compare.mjs +42 -42
  38. package/src/commands/completions.mjs +2 -6
  39. package/src/commands/decide.mjs +4 -4
  40. package/src/commands/distill.mjs +4 -3
  41. package/src/commands/doctor.mjs +11 -7
  42. package/src/commands/evidence.mjs +6 -9
  43. package/src/commands/explain.mjs +0 -1
  44. package/src/commands/gate.mjs +16 -12
  45. package/src/commands/graph.mjs +2 -3
  46. package/src/commands/harness.mjs +3 -2
  47. package/src/commands/help.mjs +125 -17
  48. package/src/commands/hooks.mjs +100 -30
  49. package/src/commands/ignore.mjs +2 -1
  50. package/src/commands/impact.mjs +4 -7
  51. package/src/commands/index.mjs +273 -188
  52. package/src/commands/init.mjs +2 -1
  53. package/src/commands/lease.mjs +6 -7
  54. package/src/commands/lessons.mjs +10 -9
  55. package/src/commands/local.mjs +3 -3
  56. package/src/commands/map.mjs +14 -9
  57. package/src/commands/mcp.mjs +0 -1
  58. package/src/commands/pack.mjs +9 -6
  59. package/src/commands/plan.mjs +6 -5
  60. package/src/commands/plugin.mjs +2 -2
  61. package/src/commands/prbody.mjs +3 -3
  62. package/src/commands/repoindex.mjs +4 -3
  63. package/src/commands/review.mjs +2 -2
  64. package/src/commands/rules.mjs +33 -16
  65. package/src/commands/shared.mjs +2 -3
  66. package/src/commands/skills.mjs +28 -26
  67. package/src/commands/skillscompile.mjs +4 -5
  68. package/src/commands/skillsinstall.mjs +2 -2
  69. package/src/commands/slice.mjs +21 -10
  70. package/src/commands/spec.mjs +151 -0
  71. package/src/commands/status.mjs +4 -7
  72. package/src/commands/sync.mjs +13 -11
  73. package/src/commands/tests.mjs +6 -9
  74. package/src/commands/tokens.mjs +2 -2
  75. package/src/commands/undo.mjs +5 -15
  76. package/src/commands/upgrade.mjs +7 -3
  77. package/src/commands/why.mjs +9 -8
  78. package/src/compare/row.mjs +0 -2
  79. package/src/context.mjs +64 -0
  80. package/src/decisions/index-doc.mjs +46 -0
  81. package/src/diff.mjs +1 -1
  82. package/src/distill/distill.mjs +14 -1
  83. package/src/distill/engine.mjs +5 -1
  84. package/src/graph/load.mjs +4 -1
  85. package/src/hooks/capability.mjs +3 -3
  86. package/src/hooks/dispatcher.mjs +198 -43
  87. package/src/hooks/gates.mjs +0 -43
  88. package/src/hooks/resolve.mjs +2 -7
  89. package/src/hooks/shim.mjs +5 -2
  90. package/src/identity.mjs +29 -1
  91. package/src/journal.mjs +78 -9
  92. package/src/ledger/index.mjs +30 -1
  93. package/src/local/client.mjs +4 -6
  94. package/src/locate.mjs +33 -9
  95. package/src/manifest/schema.mjs +213 -0
  96. package/src/mcp/resources.mjs +7 -10
  97. package/src/mcp/server.mjs +70 -16
  98. package/src/mcp/tools.mjs +45 -7
  99. package/src/measure/session.mjs +3 -2
  100. package/src/node-version.mjs +37 -0
  101. package/src/output/index.mjs +111 -3
  102. package/src/packs/manage.mjs +17 -3
  103. package/src/packs.mjs +20 -2
  104. package/src/projection/lock.mjs +11 -1
  105. package/src/projection/manifest.mjs +20 -34
  106. package/src/projection/rules.mjs +4 -3
  107. package/src/repo-root.mjs +19 -0
  108. package/src/resolve/fingerprint.mjs +19 -15
  109. package/src/review/critic.mjs +2 -1
  110. package/src/toolwords.mjs +3 -0
  111. package/src/workspace.mjs +55 -10
  112. package/docs/decisions/README.md +0 -600
@@ -48,7 +48,13 @@ export async function treeId(root) {
48
48
  // tree it names. `git add -A` would take it once the file exists, and the
49
49
  // line would prove a tree that stopped existing when the line was written
50
50
  // (review 0003 Q19).
51
- const excludes = TREE_EXCLUDES.map((p) => `:(exclude)${p}`);
51
+ //
52
+ // Where .gitignore already keeps a path out of the index, the pathspec
53
+ // would name an ignored path, and `git add` refuses it ("use -f") - and -f
54
+ // would force the very state back into the tree. The excludes therefore
55
+ // carry only the paths git does not already ignore.
56
+ const ignored = await ignoredSet(root, TREE_EXCLUDES);
57
+ const excludes = TREE_EXCLUDES.filter((p) => !ignored.has(p)).map((p) => `:(exclude)${p}`);
52
58
  await exec('git', ['add', '-A', '--', '.', ...excludes], { cwd: root, env });
53
59
  const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
54
60
  return stdout.trim();
@@ -57,6 +63,29 @@ export async function treeId(root) {
57
63
  }
58
64
  }
59
65
 
66
+ /**
67
+ * The paths git already ignores, so the exclude pathspecs do not name them.
68
+ * A repository with no .gitignore ignores nothing, and every exclude ships
69
+ * exactly as before.
70
+ *
71
+ * @param {string} root
72
+ * @param {string[]} paths
73
+ * @returns {Promise<Set<string>>}
74
+ */
75
+ async function ignoredSet(root, paths) {
76
+ try {
77
+ // Paths as argv, not `--stdin`: `promisify(execFile)` drops the `input`
78
+ // option, so a stdin-fed `check-ignore` waits for input that never comes
79
+ // and the promise never settles.
80
+ const { stdout } = await exec('git', ['check-ignore', ...paths], { cwd: root });
81
+ return new Set(String(stdout).split('\n').filter(Boolean));
82
+ } catch {
83
+ // `check-ignore` exits 1 when nothing is ignored - the common case, not an
84
+ // error. Failing to the full exclude list is the pre-filter behaviour.
85
+ return new Set();
86
+ }
87
+ }
88
+
60
89
  /**
61
90
  * Append one run. The output itself is not stored: the digest is enough to notice a
62
91
  * change, and a ledger that kept logs would keep whatever the command printed.
@@ -18,14 +18,12 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
18
18
  if (!endpoint || typeof endpoint !== 'object') {
19
19
  return { ok: false, error: 'local model endpoint is not configured', refusedRemote: false };
20
20
  }
21
+ // `url` and `model` are validated once, where the config is read
22
+ // (`local/config.mjs`): an endpoint that reaches this function has both. What is left
23
+ // here is the one rule the config cannot enforce - a loopback-only default - and that
24
+ // is why this function still exists (review 0003 Q83).
21
25
  const rawUrl = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
22
- if (!rawUrl) {
23
- return { ok: false, error: 'local model endpoint is missing `url`', refusedRemote: false };
24
- }
25
26
  const model = typeof endpoint.model === 'string' ? endpoint.model.trim() : '';
26
- if (!model) {
27
- return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
28
- }
29
27
  const apiKey = typeof endpoint.apiKey === 'string' && endpoint.apiKey.length > 0 ? endpoint.apiKey : null;
30
28
  const loopback = isLoopbackUrl(rawUrl);
31
29
  if (!loopback && !allowRemote) {
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 is refused rather than
39
- # half-executed - running from a source checkout is normal for anyone working on
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
- # shift first: "${D}@" still holds ${D}1, which would pass the program path to
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
- exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
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
+ }
@@ -199,15 +199,9 @@ export async function readResource(uri, root = process.cwd()) {
199
199
  const [rules, mdc] = await Promise.all([loadRules(null), loadMdcRules()]);
200
200
  const rule = [...rules, ...mdc].find((candidate) => candidate.id === parsed.id);
201
201
  if (rule === undefined) return null;
202
- const text = `${frontmatterOf(rule)}\n${rule.body ?? ''}`.trim();
203
- return {
204
- uri,
205
- mimeType: 'text/markdown',
206
- text,
207
- // The body a tool returns is redacted for the same reason: a reader outside this
208
- // estate is told what the rule says and not which directory here it is filed in.
209
- bytes: Buffer.byteLength(text),
210
- };
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() };
211
205
  }
212
206
 
213
207
  // `loadDecisions` reads the index, not the records: it reports status, currency and
@@ -218,7 +212,10 @@ export async function readResource(uri, root = process.cwd()) {
218
212
  if (decision === undefined) return null;
219
213
  const raw = await readFile(join(DECISIONS_DIR, decision.file), 'utf8');
220
214
  const text = redactInternalPaths(redactEstate(raw)).trim();
221
- return { uri, mimeType: 'text/markdown', text, bytes: Buffer.byteLength(text) };
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 };
222
219
  }
223
220
 
224
221
  function frontmatterOf(rule) {
@@ -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
@@ -117,7 +129,17 @@ export async function handle(request, tools, { packageVersion, state, root, sign
117
129
  if (request === null || typeof request !== 'object' || Array.isArray(request)) {
118
130
  return error(null, RPC.REQUEST, 'request must be a JSON object');
119
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
+ }
120
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
+ }
121
143
  // A notification is a request with no id, and the spec's rule is unconditional: it
122
144
  // never gets a response, whatever its method. Answering one anyway desynchronises a
123
145
  // client that is waiting for the next real answer in the stream.
@@ -200,6 +222,15 @@ export async function handle(request, tools, { packageVersion, state, root, sign
200
222
  return error(id, RPC.METHOD, `no tool named "${name}"; call tools/list first`);
201
223
  }
202
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
+ }
203
234
  // The one impurity in this file, and scoped to it: progress has to reach
204
235
  // the client while the tool runs, not after it returns. Nothing else
205
236
  // writes here - diagnostics still go to stderr, never this stream.
@@ -222,17 +253,15 @@ export async function handle(request, tools, { packageVersion, state, root, sign
222
253
  // `outputSchema` and what it can index; the text block is what a client that
223
254
  // predates structured output reads. Emitting one without the other leaves half
224
255
  // the clients guessing, which is the state this release was filed for.
225
- return reply({
226
- content: [contentOf(payload)],
227
- structuredContent: payload,
228
- isError: false,
229
- });
256
+ const shaped = bounded(payload);
257
+ return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: false });
230
258
  } catch (failure) {
231
259
  // An error is a result too, and it carries the same two shapes: the remedy is
232
260
  // the part a model needs to act on, and it has to survive in whichever block the
233
261
  // client reads.
234
262
  const payload = { error: failure.message, remedy: failure.remedy ?? null };
235
- return reply({ content: [contentOf(payload)], structuredContent: payload, isError: true });
263
+ const shaped = bounded(payload);
264
+ return reply({ content: shaped.content, structuredContent: shaped.structuredContent, isError: true });
236
265
  }
237
266
  }
238
267
 
@@ -280,8 +309,12 @@ export async function handle(request, tools, { packageVersion, state, root, sign
280
309
  }
281
310
  }
282
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).
283
316
  const INSTRUCTIONS =
284
- `Read-only. Every answer is JSON. \`${CLI} skills brief\` is the one to call first: it returns the pre-assembled set for a task, plus what it deliberately left out and why.`;
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.';
285
318
 
286
319
  function serverInfo(packageVersion) {
287
320
  return { ...SERVER_INFO, version: packageVersion ?? '0.0.0' };
@@ -369,17 +402,28 @@ function describe(tool, version) {
369
402
  return entry;
370
403
  }
371
404
 
372
- /** Content is one text block. Bounded, and truncation is announced in the text itself -
373
- * the same rule the CLI follows, because a model that cannot tell a truncated answer
374
- * from a complete one will guess. */
375
- function contentOf(payload) {
376
- let text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
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);
377
415
  const limit = typeof payload === 'object' && payload !== null ? payload.maxBytes : 0;
378
- if (Number.isInteger(limit) && limit > 0 && Buffer.byteLength(text) > limit) {
379
- const slice = Buffer.from(text).subarray(0, limit).toString('utf8');
380
- text = `${slice}\nTRUNCATED ${Buffer.byteLength(text) - limit} bytes omitted; raise maxBytes`;
416
+ const size = Buffer.byteLength(text);
417
+ if (!Number.isInteger(limit) || limit <= 0 || size <= limit) {
418
+ return { content: [{ type: 'text', text }], structuredContent: payload };
381
419
  }
382
- return { type: 'text', text };
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
+ };
383
427
  }
384
428
 
385
429
  function result(id, value) {
@@ -428,6 +472,16 @@ export async function serve(tools, { input = process.stdin, output = process.std
428
472
  try {
429
473
  for await (const line of rl) {
430
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
+ }
431
485
 
432
486
  // Parse and handling fail differently and must answer differently. A line that is
433
487
  // not JSON has no id to answer to, so it is a parse error with `id: null`; a
package/src/mcp/tools.mjs CHANGED
@@ -248,9 +248,10 @@ const output = {
248
248
  workspace: { type: 'string' },
249
249
  body: { type: ['string', 'null'] },
250
250
  bodyFrom: { type: 'string' },
251
- bodyText: { type: 'string' },
251
+ bodyText: { type: ['string', 'null'] },
252
+ reason: { type: ['string', 'null'], description: 'why there is no body, when there is none' },
252
253
  },
253
- required: ['name', 'bytes', 'bodyFrom', 'requires', 'variants'],
254
+ required: ['name', 'bytes', 'body', 'bodyFrom', 'bodyText', 'reason', 'requires', 'variants'],
254
255
  },
255
256
  skills_find: {
256
257
  type: 'object',
@@ -426,6 +427,7 @@ const output = {
426
427
  cut: { type: 'array', items: { type: 'string' } },
427
428
  sections: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } },
428
429
  text: { type: 'string' },
430
+ note: { type: ['string', 'null'] },
429
431
  },
430
432
  required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
431
433
  },
@@ -528,7 +530,7 @@ export const TOOLS = [
528
530
  outputSchema: output.doctor,
529
531
  name: 'doctor',
530
532
  description:
531
- 'Classify a repository: stack tags, emitted facts with the file that proved each, and manifest presence. Call this first when the repo is unknown.',
533
+ 'Classify a repository: stack tags, the facts with the file that proved each, and manifest presence. Call this first when the repo is unknown. Read-only: `--fix` and `--diff` stay on the command.',
532
534
  inputSchema: rootArg,
533
535
  async run(args) {
534
536
  const root = rootOf(args);
@@ -577,7 +579,7 @@ export const TOOLS = [
577
579
  outputSchema: output.skills_brief,
578
580
  name: 'skills_brief',
579
581
  description:
580
- 'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with reason - the reasons let you override the resolver instead of guessing at it.',
582
+ 'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with the reason to override.',
581
583
  inputSchema: {
582
584
  type: 'object',
583
585
  properties: {
@@ -627,12 +629,18 @@ export const TOOLS = [
627
629
  async run(args) {
628
630
  const root = rootOf(args);
629
631
  const facts = await skillsShowFacts(root, args?.name, args ?? {});
632
+ // The schema promises `body: null` when there is no body, and this used to hand the
633
+ // prose that says so to the field a client tests for null: a caller checking `body`
634
+ // for null read a paragraph as a body (Q56). The prose moves to `reason`, which is
635
+ // where a model looks for what to do about it.
636
+ const missing = typeof facts.bodyText === 'string' && facts.bodyText.startsWith('(body unavailable');
630
637
  return {
631
638
  ...facts.skill,
632
639
  workspace: facts.workspace,
633
- body: facts.body,
640
+ body: missing ? null : facts.body,
634
641
  bodyFrom: facts.bodyFrom,
635
- bodyText: facts.bodyText,
642
+ bodyText: missing ? null : facts.bodyText,
643
+ reason: missing ? facts.bodyText : null,
636
644
  };
637
645
  },
638
646
  },
@@ -905,7 +913,37 @@ export const TOOLS = [
905
913
  build: false,
906
914
  });
907
915
  if (result.error) throw Object.assign(new Error('the index could not be read'), { remedy: `run: ${CLI} index` });
908
- if (result.cold) throw Object.assign(new Error('this repository has no index to answer from'), { remedy: `run: ${CLI} index` });
916
+ // A repository with no index is not a failed call. Every surface points here first,
917
+ // so the one tool told to be the entry point must answer in a repository that has
918
+ // never been indexed - it falls back to the skill packet, which is read from the
919
+ // package rather than the tree, and names the command that unlocks the rest (Q56).
920
+ if (result.cold) {
921
+ const task = typeof args?.task === 'string' ? args.task : '';
922
+ const facts = await skillsBriefFacts(root, { ...(args ?? {}), task });
923
+ // Through JSON once, so the packet carries exactly what the text block shows: a
924
+ // property whose value is `undefined` survives `stringify` as nothing at all, and
925
+ // the two blocks would then describe different objects.
926
+ const text = JSON.stringify(facts, null, 2);
927
+ const packet = JSON.parse(text);
928
+ const size = Buffer.byteLength(text);
929
+ return {
930
+ root,
931
+ kind: 'skills',
932
+ basis: 'registry',
933
+ task,
934
+ scope: [],
935
+ budget,
936
+ bytes: size,
937
+ before: size,
938
+ // The skill packet is one document and is not cut to fit: saying the floor held
939
+ // when the answer is over it is the class of claim this review exists to remove.
940
+ floorHeld: size <= budget,
941
+ cut: size <= budget ? [] : ['text'],
942
+ sections: { skills: (packet.resolved ?? []).map((s) => s.name) },
943
+ text,
944
+ note: `no index here, so this is the skill packet; run: ${CLI} index for the repository packet`,
945
+ };
946
+ }
909
947
  return {
910
948
  root: result.root,
911
949
  kind: result.kind,