simt-dev 0.5.0 → 0.6.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 +122 -0
- package/README.md +78 -59
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +13 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +1 -1
- package/docs/decisions/ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md +71 -0
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +149 -0
- package/package.json +4 -4
- package/src/agenthooks/render.mjs +87 -21
- package/src/argv.mjs +13 -0
- package/src/bench/ablate.mjs +1 -3
- package/src/bench/grade.mjs +1 -1
- package/src/bench/mine.mjs +3 -3
- package/src/bench/probes.mjs +1 -3
- package/src/bench/report.mjs +1 -3
- package/src/bench/run.mjs +1 -3
- package/src/bench/tasks.mjs +1 -3
- package/src/bench/variants.mjs +1 -3
- package/src/brief/assemble.mjs +2 -4
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +40 -12
- package/src/commands/agenthook.mjs +6 -2
- package/src/commands/bench.mjs +4 -4
- package/src/commands/ci.mjs +1 -1
- package/src/commands/compare.mjs +1 -1
- package/src/commands/distill.mjs +1 -1
- package/src/commands/gate.mjs +2 -2
- package/src/commands/graph.mjs +2 -2
- package/src/commands/harness.mjs +87 -14
- package/src/commands/help.mjs +29 -7
- package/src/commands/hooks.mjs +1 -1
- package/src/commands/index.mjs +17 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/local.mjs +11 -0
- package/src/commands/mcp.mjs +1 -1
- package/src/commands/pack.mjs +246 -0
- package/src/commands/plugin.mjs +61 -0
- package/src/commands/rules.mjs +1 -1
- package/src/commands/skills.mjs +1 -1
- package/src/commands/skillscompile.mjs +1 -3
- package/src/commands/skillsinstall.mjs +1 -1
- package/src/commands/sync.mjs +8 -8
- package/src/commands/tokens.mjs +1 -1
- package/src/commands/upgrade.mjs +1 -1
- package/src/commands/why.mjs +1 -1
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +19 -7
- package/src/compare/learn.mjs +2 -2
- package/src/compare/row.mjs +3 -3
- package/src/decisions/index.mjs +1 -1
- package/src/distill/distill.mjs +2 -2
- package/src/distribute/release.mjs +142 -0
- package/src/graph/load.mjs +9 -8
- package/src/hooks/dispatcher.mjs +3 -2
- package/src/hooks/shim.mjs +11 -6
- package/src/index/govern.mjs +1 -1
- package/src/index/map.mjs +1 -1
- package/src/index/persist.mjs +2 -2
- package/src/index/store.mjs +50 -20
- package/src/ledger/index.mjs +2 -2
- package/src/local/client.mjs +22 -8
- package/src/local/config.mjs +17 -0
- package/src/locate.mjs +22 -1
- package/src/mcp/prompts.mjs +118 -0
- package/src/mcp/resources.mjs +96 -22
- package/src/mcp/server.mjs +33 -6
- package/src/mcp/tools.mjs +12 -12
- package/src/measure/session.mjs +1 -1
- package/src/memory/decide.mjs +1 -1
- package/src/memory/lessons.mjs +14 -14
- package/src/name.mjs +11 -1
- package/src/package-version.mjs +12 -10
- package/src/packs/manage.mjs +563 -0
- package/src/packs.mjs +15 -5
- package/src/plugins.mjs +164 -0
- package/src/projection/loaders.mjs +84 -10
- package/src/projection/manifest.mjs +4 -4
- package/src/projection/project.mjs +3 -3
- package/src/projection/rules.mjs +24 -4
- package/src/projection/surfaces.mjs +20 -2
- package/src/review/critic.mjs +4 -4
- package/src/toolwords.mjs +8 -5
- package/src/tsconfig.mjs +3 -3
- package/src/upgrade/plan.mjs +1 -1
- /package/bin/{simt-dev.mjs → simt.mjs} +0 -0
package/src/index/store.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// otherwise. Either one is a cache: `git ls-files -s` can rebuild it, so a missing
|
|
5
5
|
// file is not a lost database. A blob that has not changed is not read again.
|
|
6
6
|
|
|
7
|
-
import { execFile } from 'node:child_process';
|
|
7
|
+
import { execFile, spawn } from 'node:child_process';
|
|
8
8
|
import { stat } from 'node:fs/promises';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
@@ -387,9 +387,9 @@ export async function packageOf(root, filePath, cache) {
|
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
function git(root, args, input
|
|
390
|
+
function git(root, args, input) {
|
|
391
391
|
return new Promise((resolve, reject) => {
|
|
392
|
-
const child = execFile('git', args, { cwd: root, encoding:
|
|
392
|
+
const child = execFile('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
393
393
|
if (error) reject(Object.assign(error, { stdout, stderr }));
|
|
394
394
|
else resolve(stdout);
|
|
395
395
|
});
|
|
@@ -477,28 +477,58 @@ function normalise(filePath, got) {
|
|
|
477
477
|
return { symbols, imports: Array.isArray(got.imports) ? got.imports : [], edges };
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The body of each listed blob, read as one `cat-file --batch` stream.
|
|
482
|
+
*
|
|
483
|
+
* Streamed and parsed incrementally rather than buffered: the combined output of a
|
|
484
|
+
* repository whose tracked content exceeds execFile's maxBuffer (64 MiB) used to
|
|
485
|
+
* reject the whole index with "stdout maxBuffer length exceeded" — an ~82 MiB
|
|
486
|
+
* Android checkout died on first run for exactly that reason.
|
|
487
|
+
*/
|
|
480
488
|
export async function blobTexts(root, blobs) {
|
|
481
489
|
const unique = [...new Set(blobs)];
|
|
482
490
|
const texts = new Map();
|
|
483
491
|
if (unique.length === 0) return texts;
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
const child = spawn('git', ['cat-file', '--batch'], { cwd: root });
|
|
493
|
+
child.stdin.end(`${unique.join('\n')}\n`);
|
|
494
|
+
let pending = null;
|
|
495
|
+
const onChunk = (chunk) => {
|
|
496
|
+
pending = pending === null ? chunk : Buffer.concat([pending, chunk]);
|
|
497
|
+
let consumed = 0;
|
|
498
|
+
for (;;) {
|
|
499
|
+
const nl = pending.indexOf(0x0a, consumed);
|
|
500
|
+
if (nl < 0) break;
|
|
501
|
+
const parts = pending.toString('utf8', consumed, nl).split(' ');
|
|
502
|
+
const size = Number(parts[2]);
|
|
503
|
+
if (parts[1] !== 'blob' || !Number.isFinite(size)) {
|
|
504
|
+
consumed = nl + 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const end = nl + 1 + size + 1;
|
|
508
|
+
if (pending.length < end) break; // the body is not fully arrived yet
|
|
509
|
+
const start = nl + 1;
|
|
510
|
+
const body = pending.subarray(start, start + size);
|
|
511
|
+
consumed = end;
|
|
512
|
+
texts.set(parts[0], body.includes(0) ? null : body.toString('utf8'));
|
|
495
513
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
514
|
+
// Drop the consumed prefix. Copy the tail once it has grown past a MiB, so the
|
|
515
|
+
// released bytes are actually freed instead of staying alive behind a view into
|
|
516
|
+
// the buffer they came from.
|
|
517
|
+
if (consumed > 0) {
|
|
518
|
+
const tail = pending.subarray(consumed);
|
|
519
|
+
pending = tail.length === 0 ? null : consumed >= 1 << 20 ? Buffer.from(tail) : tail;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
let stderr = '';
|
|
524
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
525
|
+
child.stdout.on('data', onChunk);
|
|
526
|
+
child.on('error', reject);
|
|
527
|
+
child.on('close', (code) => {
|
|
528
|
+
if (code !== 0) reject(new Error(`git cat-file --batch exited ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
529
|
+
else resolve(texts);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
502
532
|
}
|
|
503
533
|
|
|
504
534
|
async function blobSizes(root, blobs) {
|
package/src/ledger/index.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const exec = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
const LEDGER_FILE = '.simt
|
|
25
|
+
const LEDGER_FILE = '.simt/ledger.jsonl';
|
|
26
26
|
|
|
27
27
|
/** Phrases that claim a run happened. `unverified` and `not verified` are removed
|
|
28
28
|
* first, because both contain the word and neither is a claim. */
|
|
@@ -76,7 +76,7 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
|
|
|
76
76
|
}
|
|
77
77
|
: {}),
|
|
78
78
|
};
|
|
79
|
-
await mkdir(join(root, '.simt
|
|
79
|
+
await mkdir(join(root, '.simt'), { recursive: true });
|
|
80
80
|
await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
|
|
81
81
|
|
|
82
82
|
if (exit === 0 && typeof cmd === 'string') {
|
package/src/local/client.mjs
CHANGED
|
@@ -26,6 +26,7 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
26
26
|
if (!model) {
|
|
27
27
|
return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
|
|
28
28
|
}
|
|
29
|
+
const apiKey = typeof endpoint.apiKey === 'string' && endpoint.apiKey.length > 0 ? endpoint.apiKey : null;
|
|
29
30
|
const loopback = isLoopbackUrl(rawUrl);
|
|
30
31
|
if (!loopback && !allowRemote) {
|
|
31
32
|
return {
|
|
@@ -39,6 +40,7 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
39
40
|
url: rawUrl,
|
|
40
41
|
baseUrl: resolveEndpointUrl(rawUrl, ''),
|
|
41
42
|
model,
|
|
43
|
+
apiKey,
|
|
42
44
|
timeoutMs: Number.isInteger(endpoint.timeoutMs) && endpoint.timeoutMs > 0
|
|
43
45
|
? endpoint.timeoutMs
|
|
44
46
|
: DEFAULT_LOCAL_TIMEOUT_MS,
|
|
@@ -46,13 +48,20 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
|
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
async function requestJson(url, { method = 'GET', body = undefined, timeoutMs = DEFAULT_LOCAL_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) {
|
|
51
|
+
async function requestJson(url, { method = 'GET', body = undefined, timeoutMs = DEFAULT_LOCAL_TIMEOUT_MS, apiKey = null, fetchImpl = globalThis.fetch } = {}) {
|
|
50
52
|
const controller = new AbortController();
|
|
51
53
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
52
54
|
try {
|
|
55
|
+
const headers = {
|
|
56
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
57
|
+
accept: 'application/json',
|
|
58
|
+
};
|
|
59
|
+
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
|
60
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
61
|
+
}
|
|
53
62
|
const response = await fetchImpl(url, {
|
|
54
63
|
method,
|
|
55
|
-
headers
|
|
64
|
+
headers,
|
|
56
65
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
57
66
|
signal: controller.signal,
|
|
58
67
|
});
|
|
@@ -121,6 +130,7 @@ export async function localModels(endpoint, { allowRemote = false, fetchImpl = g
|
|
|
121
130
|
const res = await requestJson(targetUrl, {
|
|
122
131
|
method: 'GET',
|
|
123
132
|
timeoutMs: guard.timeoutMs,
|
|
133
|
+
apiKey: guard.apiKey,
|
|
124
134
|
fetchImpl,
|
|
125
135
|
});
|
|
126
136
|
if (!res.ok) {
|
|
@@ -200,6 +210,7 @@ export async function localChat(
|
|
|
200
210
|
stream: false,
|
|
201
211
|
},
|
|
202
212
|
timeoutMs: guard.timeoutMs,
|
|
213
|
+
apiKey: guard.apiKey,
|
|
203
214
|
fetchImpl,
|
|
204
215
|
});
|
|
205
216
|
|
|
@@ -216,12 +227,14 @@ export async function localChat(
|
|
|
216
227
|
}
|
|
217
228
|
|
|
218
229
|
const choice = Array.isArray(res.data.choices) ? res.data.choices[0] : null;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
230
|
+
const message = choice?.message ?? {};
|
|
231
|
+
// Prefer the final answer. A reasoning model (omlx, Qwen3-thinking) emits its
|
|
232
|
+
// output in `reasoning_content` and leaves `content` empty or absent when the
|
|
233
|
+
// budget is tiny (e.g. doctor's 1-token ping), so fall back to it, then the
|
|
234
|
+
// legacy `text` field. Whitespace-only output counts as absent.
|
|
235
|
+
const content = [message.content, message.reasoning_content, choice?.text]
|
|
236
|
+
.map((value) => (typeof value === 'string' && value.trim().length > 0 ? value : null))
|
|
237
|
+
.find((value) => value !== null) ?? null;
|
|
225
238
|
|
|
226
239
|
if (content === null) {
|
|
227
240
|
return {
|
|
@@ -275,6 +288,7 @@ export async function localEmbed(
|
|
|
275
288
|
input: Array.isArray(input) ? input.map(String) : String(input ?? ''),
|
|
276
289
|
},
|
|
277
290
|
timeoutMs: guard.timeoutMs,
|
|
291
|
+
apiKey: guard.apiKey,
|
|
278
292
|
fetchImpl,
|
|
279
293
|
});
|
|
280
294
|
|
package/src/local/config.mjs
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// "embed": { "url": "http://127.0.0.1:11434/v1", "model": "nomic-embed-code" }
|
|
9
9
|
// }
|
|
10
10
|
//
|
|
11
|
+
// Each role also accepts an optional `apiKey` (a non-empty string). When present it is
|
|
12
|
+
// sent as an `Authorization: Bearer <key>` header, for servers that require a token
|
|
13
|
+
// (omlx, llama.cpp `--api-key`, OpenAI-compatible gateways). Omitted by default.
|
|
14
|
+
//
|
|
11
15
|
// Non-loopback URLs are refused unless `--allow-remote` is explicitly passed (ADR-075).
|
|
12
16
|
|
|
13
17
|
import { EXIT, fail } from '../output/index.mjs';
|
|
@@ -114,6 +118,17 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// Optional bearer credential for servers that require authentication (omlx,
|
|
122
|
+
// llama.cpp `--api-key`, OpenAI-compatible gateways). Absent by default: most
|
|
123
|
+
// local servers (Ollama, LM Studio) accept unauthenticated loopback requests.
|
|
124
|
+
let apiKey = '';
|
|
125
|
+
if (entry.apiKey !== undefined) {
|
|
126
|
+
if (typeof entry.apiKey !== 'string' || entry.apiKey.trim().length === 0) {
|
|
127
|
+
return { endpoint: null, finding: `\`local.${role}.apiKey\` must be a non-empty string when set` };
|
|
128
|
+
}
|
|
129
|
+
apiKey = entry.apiKey;
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
const loopback = isLoopbackHost(parsedUrl.hostname);
|
|
118
133
|
const baseUrl = resolveEndpointUrl(rawUrl, '');
|
|
119
134
|
const blocked = !loopback && !allowRemote;
|
|
@@ -126,6 +141,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
126
141
|
baseUrl,
|
|
127
142
|
model,
|
|
128
143
|
timeoutMs,
|
|
144
|
+
apiKey,
|
|
129
145
|
loopback: false,
|
|
130
146
|
blocked: true,
|
|
131
147
|
},
|
|
@@ -141,6 +157,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
|
|
|
141
157
|
baseUrl,
|
|
142
158
|
model,
|
|
143
159
|
timeoutMs,
|
|
160
|
+
apiKey,
|
|
144
161
|
loopback,
|
|
145
162
|
blocked: false,
|
|
146
163
|
},
|
package/src/locate.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// this is a block and not a script.
|
|
13
13
|
|
|
14
14
|
import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
|
|
15
|
+
import { LOCAL_BIN } from './distribute/release.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @param {string} command arguments to hand the tool, with `$ROOT` spelled as a shell
|
|
@@ -24,6 +25,13 @@ import { BIN_ENV, BIN_REL, CLI, PACKAGE_NAME } from './name.mjs';
|
|
|
24
25
|
* @returns {string} POSIX sh source
|
|
25
26
|
*/
|
|
26
27
|
export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: npm i -D ${PACKAGE_NAME}` } = {}) {
|
|
28
|
+
// The remedy is emitted single-quoted (see the assignment at the bottom of the
|
|
29
|
+
// block), so a quote in it would end that string and re-open the shell to
|
|
30
|
+
// whatever follows. Refuse at render time: the failure is a template bug, and
|
|
31
|
+
// it should be found where the string is written, not in a hook on a machine.
|
|
32
|
+
if (remedy.includes("'")) {
|
|
33
|
+
throw new Error(`the remedy is embedded single-quoted and cannot contain a quote: ${remedy}`);
|
|
34
|
+
}
|
|
27
35
|
const D = '$';
|
|
28
36
|
return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
|
|
29
37
|
# point to hand to node. It cannot be a command line, because there is no safe way
|
|
@@ -59,6 +67,15 @@ if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
|
|
|
59
67
|
exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
|
|
60
68
|
fi
|
|
61
69
|
|
|
70
|
+
# The binary this repository downloaded for itself: the pinned bytes, no node.
|
|
71
|
+
# It is checked before PATH so a repository's own copy wins over whatever a
|
|
72
|
+
# machine happens to have installed, the same way node_modules wins - and the
|
|
73
|
+
# dispatcher still compares the version it runs against the pin, so a stale
|
|
74
|
+
# copy is refused rather than trusted.
|
|
75
|
+
if [ -x "${D}ROOT/${LOCAL_BIN}" ]; then
|
|
76
|
+
exec "${D}ROOT/${LOCAL_BIN}" ${command}
|
|
77
|
+
fi
|
|
78
|
+
|
|
62
79
|
if command -v ${CLI} >/dev/null 2>&1; then
|
|
63
80
|
exec ${CLI} ${command}
|
|
64
81
|
fi
|
|
@@ -68,7 +85,11 @@ echo "${CLI}: cannot locate ${subject}." >&2
|
|
|
68
85
|
# unbound variable aborts the shell, so an unguarded ${D}${BIN_ENV} here would
|
|
69
86
|
# kill the message that exists to explain the failure - and this block only runs
|
|
70
87
|
# when nothing resolved, which is the one moment the reader needs it.
|
|
71
|
-
echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${CLI} on PATH" >&2
|
|
88
|
+
echo " looked for: ${D}{${BIN_ENV}:-<unset>}, ${D}ROOT/node_modules/${PACKAGE_NAME}, ${D}ROOT/${LOCAL_BIN}, ${CLI} on PATH" >&2
|
|
89
|
+
# The remedy is assigned single-quoted and echoed, so a command in it - the binary
|
|
90
|
+
# download derives its platform with $(uname) - stays text here and runs only when
|
|
91
|
+
# the reader runs it. A remedy in double quotes would be half-executed by this echo.
|
|
92
|
+
remedy='${remedy}'
|
|
72
93
|
echo "${remedy}" >&2
|
|
73
94
|
exit 1
|
|
74
95
|
`;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Two prompt templates for the two jobs this server exists for.
|
|
2
|
+
//
|
|
3
|
+
// A prompt is a message template, not an answer. The server is read-only, so a prompt
|
|
4
|
+
// cannot implement anything either: what it CAN do is hand a calling agent a working set
|
|
5
|
+
// of instructions that name the tools to call and the order to call them in, which is the
|
|
6
|
+
// part a model otherwise has to discover by trial. The text therefore speaks ABOUT the
|
|
7
|
+
// tools and never runs them.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `simt-brief`: start a task from the pre-assembled packet.
|
|
11
|
+
*
|
|
12
|
+
* The brief tool is the entry point every other surface points at, so the one prompt a
|
|
13
|
+
* client needs before it knows the model is "start there". Required `task` degrades to a
|
|
14
|
+
* visible placeholder rather than an error: a client that ignored the `required` flag at
|
|
15
|
+
* least gets a message that says so instead of a broken stream, and the agent reading it
|
|
16
|
+
* knows to ask before calling anything.
|
|
17
|
+
*/
|
|
18
|
+
const SIMT_BRIEF = {
|
|
19
|
+
name: 'simt-brief',
|
|
20
|
+
title: 'Work a task from the simt brief',
|
|
21
|
+
description: 'Start a task from the pre-assembled brief packet, and work from what it selected and why.',
|
|
22
|
+
arguments: [
|
|
23
|
+
{ name: 'task', description: "the task, in the user's own words", required: true },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `simt-slice`: implement one slice of the working plan.
|
|
29
|
+
*
|
|
30
|
+
* `id` is optional and omission is the DEFAULT reading - the next slice whose
|
|
31
|
+
* dependencies are complete is what "implement the next slice" means, and a plan whose
|
|
32
|
+
* numbering a reader does not know yet has no id to give.
|
|
33
|
+
*/
|
|
34
|
+
const SIMT_SLICE = {
|
|
35
|
+
name: 'simt-slice',
|
|
36
|
+
title: 'Implement a plan slice',
|
|
37
|
+
description: 'Implement the next ready slice of the working plan, or the named one.',
|
|
38
|
+
arguments: [
|
|
39
|
+
{ name: 'id', description: 'slice id; omitted means the next ready one', required: false },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const PROMPTS = [SIMT_BRIEF, SIMT_SLICE];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The prompt names as words, exported for the tool-vocabulary derivation in
|
|
47
|
+
* `src/toolwords.mjs`: a name this tool serves over MCP is this tool's own name, and the
|
|
48
|
+
* estate guard that reads that derivation would otherwise read `simt-brief` the way it
|
|
49
|
+
* reads a repository (ADR-080).
|
|
50
|
+
*/
|
|
51
|
+
export const PROMPT_WORDS = PROMPTS.map((prompt) => prompt.name);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The prompt named, or null when there is no such name.
|
|
55
|
+
*
|
|
56
|
+
* `null`, not a throw: the caller decides what a missing prompt is worth (the server
|
|
57
|
+
* answers with the method error a client expects), and `getPrompt` stays a lookup.
|
|
58
|
+
*/
|
|
59
|
+
export function getPrompt(name, args = {}) {
|
|
60
|
+
const brief = name === SIMT_BRIEF.name;
|
|
61
|
+
const slice = name === SIMT_SLICE.name;
|
|
62
|
+
if (!brief && !slice) return null;
|
|
63
|
+
|
|
64
|
+
// Prompts degrade on missing arguments instead of failing: a template with an unfilled
|
|
65
|
+
// variable is still readable, and the agent it reaches is exactly the one that can ask
|
|
66
|
+
// the user what was meant.
|
|
67
|
+
const id = typeof args?.id === 'string' && args.id.length > 0 ? args.id : null;
|
|
68
|
+
const text = brief
|
|
69
|
+
? briefText(typeof args?.task === 'string' && args.task.trim() !== '' ? args.task : null)
|
|
70
|
+
: sliceText(id);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
description: brief ? SIMT_BRIEF.description : SIMT_SLICE.description,
|
|
74
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function briefText(task) {
|
|
79
|
+
const ask = task ?? '(none given - ask the user what to work before calling anything)';
|
|
80
|
+
return `Work the task through this repository's simt tools before touching code.
|
|
81
|
+
|
|
82
|
+
The task: ${ask}
|
|
83
|
+
|
|
84
|
+
1. Call the \`brief\` tool with this task. It returns the pre-assembled packet: the kind
|
|
85
|
+
of task, the commands that kind needs, the files the index ranks highest, the tests
|
|
86
|
+
covering them, the decisions governing them and the skills that apply.
|
|
87
|
+
2. Work from the packet. Where something you need was cut to fit the budget, the packet
|
|
88
|
+
names the tool that fetches it - call that tool instead of guessing.
|
|
89
|
+
3. Make the change, then prove it by running the packet's focused test before anything
|
|
90
|
+
broader.
|
|
91
|
+
|
|
92
|
+
Report what changed and the evidence, not a summary of the effort.
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sliceText(id) {
|
|
97
|
+
const ask = id
|
|
98
|
+
? `id ${id}`
|
|
99
|
+
: 'the next one whose dependencies are complete';
|
|
100
|
+
const lead = id
|
|
101
|
+
? `Call the \`plan_next\` tool with id "${id}" for the design and acceptance checks of exactly that slice.`
|
|
102
|
+
: 'Call the `plan_next` tool with no id: it returns the next queued slice whose dependencies are complete, with that slice\'s design section only.';
|
|
103
|
+
return `Implement one slice of this repository's plan, reading it through the simt tools.
|
|
104
|
+
|
|
105
|
+
The slice: ${ask}
|
|
106
|
+
|
|
107
|
+
1. ${lead}
|
|
108
|
+
2. Read \`simt://kernel\` first: the rules this repository packs for its stack, and the
|
|
109
|
+
ones it defers to \`simt rules show\`. They govern how you work here.
|
|
110
|
+
3. Implement in the design's language, writing the regression test its acceptance check
|
|
111
|
+
asks for as you go. A slice is done when its checks hold, not when the code runs.
|
|
112
|
+
4. Run the tests the design names, then the full suite - the last both in place and
|
|
113
|
+
from a copy at a path with no siblings, because a suite that only passes at home has
|
|
114
|
+
not passed.
|
|
115
|
+
5. Commit locally with a conventional subject and a body that records why, and report
|
|
116
|
+
the sha. Push only on explicit user approval.
|
|
117
|
+
`;
|
|
118
|
+
}
|
package/src/mcp/resources.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Rules and
|
|
1
|
+
// Rules, decisions, the kernel region and the next plan slice as MCP resources.
|
|
2
2
|
//
|
|
3
|
-
// The tools answer questions and the answers are arguments to the next call. These
|
|
4
|
-
// answer a different request: "what is the rule you would apply, in full"
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The tools answer questions and the answers are arguments to the next call. These
|
|
4
|
+
// answer a different request: "what is the rule you would apply, in full", "what does an
|
|
5
|
+
// agent actually read here", "what does the plan say to do next". A client that reads
|
|
6
|
+
// them can hold the text rather than reconstructing it from a tool's summary, and a
|
|
7
|
+
// reader outside this estate gets the same bytes an adopting repository's agent reads -
|
|
7
8
|
// redacted, because a decision record cites its own rulings and not this repository's
|
|
8
9
|
// layout (ADR-055, ADR-061).
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
+
// All revisions agree on the shape here. They disagree about what a missing resource
|
|
11
12
|
// returns, and that difference is handled in the server, not here: this module answers
|
|
12
13
|
// with a resource or with nothing at all, and never with an empty `contents` array,
|
|
13
14
|
// which the newest revision forbids because it is indistinguishable from an empty rule.
|
|
@@ -16,9 +17,9 @@ import { readFile } from 'node:fs/promises';
|
|
|
16
17
|
import { join } from 'node:path';
|
|
17
18
|
|
|
18
19
|
import { DECISIONS_DIR, loadDecisions, redactEstate, redactInternalPaths } from '../decisions/index.mjs';
|
|
19
|
-
import {
|
|
20
|
+
import { planNext } from '../plan/index.mjs';
|
|
20
21
|
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
21
|
-
import { loadRules } from '../projection/rules.mjs';
|
|
22
|
+
import { desiredRegion, loadRules } from '../projection/rules.mjs';
|
|
22
23
|
|
|
23
24
|
const RULES_SCHEME = 'simt://rules/';
|
|
24
25
|
const DECISIONS_SCHEME = 'simt://decisions/';
|
|
@@ -27,7 +28,9 @@ const DECISIONS_SCHEME = 'simt://decisions/';
|
|
|
27
28
|
* RFC 6570 templates, so a client can discover the shape without a hard-coded string.
|
|
28
29
|
*
|
|
29
30
|
* `id` and `adr` are the handles a reader already has: a gate names a rule id and a
|
|
30
|
-
* finding names an ADR, so the template's variable names match what those print.
|
|
31
|
+
* finding names an ADR, so the template's variable names match what those print. The two
|
|
32
|
+
* fixed resources (`simt://kernel`, `simt://plan/next`) have no handle to substitute and
|
|
33
|
+
* so are listed directly, not templated.
|
|
31
34
|
*/
|
|
32
35
|
export const RESOURCE_TEMPLATES = [
|
|
33
36
|
{
|
|
@@ -57,12 +60,21 @@ export const RESOURCE_TEMPLATES = [
|
|
|
57
60
|
*/
|
|
58
61
|
function parseResourceUri(uri) {
|
|
59
62
|
if (typeof uri !== 'string') return null;
|
|
63
|
+
// `simt://kernel` is one resource and not a collection: there is no handle to split,
|
|
64
|
+
// so it is matched whole, and anything that only looks like it (`simt://kernel/`)
|
|
65
|
+
// falls through to the refusals below.
|
|
66
|
+
if (uri === 'simt://kernel') return { collection: 'kernel' };
|
|
60
67
|
const [collection, ...rest] = uri.split('://');
|
|
61
68
|
if (collection !== 'simt') return null;
|
|
62
69
|
const [name, ...handle] = rest.join('://').split('/');
|
|
63
70
|
const id = handle.join('/');
|
|
64
71
|
if (!id || id === '.' || id === '..' || id.includes('..')) return null;
|
|
65
72
|
if (name === 'rules' && id.includes('/')) return null;
|
|
73
|
+
// `plan` holds one resource per working plan, and the handle says which: `next` is the
|
|
74
|
+
// plan's own vocabulary for the next ready slice. Any other handle names a resource
|
|
75
|
+
// that does not exist, so it is refused here rather than resolved against a reader's
|
|
76
|
+
// guess.
|
|
77
|
+
if (name === 'plan') return id === 'next' ? { collection: 'plan', id: 'next' } : null;
|
|
66
78
|
if (name !== 'rules' && name !== 'decisions') return null;
|
|
67
79
|
return { collection: name, id };
|
|
68
80
|
}
|
|
@@ -79,20 +91,67 @@ function resource(collection, id, title, bytes) {
|
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
/**
|
|
82
|
-
*
|
|
94
|
+
* The kernel region for a root, rendered on demand.
|
|
95
|
+
*
|
|
96
|
+
* `simt://kernel` promises the kernel's bytes by its name, whatever the repository
|
|
97
|
+
* declared: the declared mode governs what `sync` writes to disk, not what a read path
|
|
98
|
+
* may look at, and a read that re-consulted the manifest would answer a different
|
|
99
|
+
* question than the URI asked (ADR-002). `mode` is threaded through `desiredRegion` for
|
|
100
|
+
* exactly this caller.
|
|
101
|
+
*/
|
|
102
|
+
function kernelRegion(root) {
|
|
103
|
+
return desiredRegion(root, { mode: 'kernel' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every rule, every decision record, the kernel and - when one is ready - the next
|
|
108
|
+
* plan slice, one entry each.
|
|
83
109
|
*
|
|
84
110
|
* The list is built from what is on disk rather than from a hand-kept index, so it cannot
|
|
85
111
|
* describe a rule that was renamed or a record that was never written. Order is sorted:
|
|
86
112
|
* the newest revision forbids a set that varies per connection or as a side effect of
|
|
87
113
|
* another request, and a list built from a directory read is the only shape that satisfies
|
|
88
114
|
* that without a cache to invalidate.
|
|
115
|
+
*
|
|
116
|
+
* The slice is listed only when one is ready. A listing that promises a read the server
|
|
117
|
+
* then answers with "not found" is a listing that lies, and the plan's "no ready slice"
|
|
118
|
+
* cases are information about the plan, which the `plan_next` tool reports, not about a
|
|
119
|
+
* resource's text.
|
|
89
120
|
*/
|
|
90
|
-
export async function listResources() {
|
|
91
|
-
const [rules, mdc, { decisions }] = await Promise.all([
|
|
121
|
+
export async function listResources(root = process.cwd()) {
|
|
122
|
+
const [rules, mdc, { decisions }, region, plan] = await Promise.all([
|
|
123
|
+
loadRules(null),
|
|
124
|
+
loadMdcRules(),
|
|
125
|
+
loadDecisions({ all: true }),
|
|
126
|
+
kernelRegion(root),
|
|
127
|
+
planNext(root, null),
|
|
128
|
+
]);
|
|
92
129
|
// `size` is the bytes on disk rather than the bytes served: `read` redacts a decision
|
|
93
130
|
// before sending it, so the served length is not knowable without reading the file, and
|
|
94
|
-
// a size that disagrees with the payload is worse than a size that rounds.
|
|
131
|
+
// a size that disagrees with the payload is worse than a size that rounds. The kernel
|
|
132
|
+
// and the slice have no such gap - both serve exactly what they list - so their sizes
|
|
133
|
+
// are the served ones.
|
|
95
134
|
return [
|
|
135
|
+
{
|
|
136
|
+
uri: 'simt://kernel',
|
|
137
|
+
name: 'kernel',
|
|
138
|
+
title: 'The kernel region',
|
|
139
|
+
description: 'The rules this repository packs in kernel mode, with the deferrals named by their fetch command',
|
|
140
|
+
mimeType: 'text/markdown',
|
|
141
|
+
size: Buffer.byteLength(region.body),
|
|
142
|
+
},
|
|
143
|
+
...(plan.error || !plan.next
|
|
144
|
+
? []
|
|
145
|
+
: [
|
|
146
|
+
{
|
|
147
|
+
uri: 'simt://plan/next',
|
|
148
|
+
name: 'plan/next',
|
|
149
|
+
title: 'Next plan slice',
|
|
150
|
+
description: plan.next.title ?? 'the next slice whose dependencies are complete',
|
|
151
|
+
mimeType: 'text/markdown',
|
|
152
|
+
size: plan.next.sectionBytes,
|
|
153
|
+
},
|
|
154
|
+
]),
|
|
96
155
|
...[...rules, ...mdc]
|
|
97
156
|
.filter((rule) => rule.id !== null)
|
|
98
157
|
.map((rule) => resource('rules', rule.id, rule.title ?? rule.id, rule.bytes)),
|
|
@@ -107,10 +166,33 @@ export async function listResources() {
|
|
|
107
166
|
* a protocol error with a code that CHANGED between revisions, so the server decides the
|
|
108
167
|
* code and this module only declines to invent content.
|
|
109
168
|
*/
|
|
110
|
-
export async function readResource(uri) {
|
|
169
|
+
export async function readResource(uri, root = process.cwd()) {
|
|
111
170
|
const parsed = parseResourceUri(uri);
|
|
112
171
|
if (parsed === null) return null;
|
|
113
172
|
|
|
173
|
+
if (parsed.collection === 'kernel') {
|
|
174
|
+
const region = await kernelRegion(root);
|
|
175
|
+
return {
|
|
176
|
+
uri,
|
|
177
|
+
mimeType: 'text/markdown',
|
|
178
|
+
text: region.body,
|
|
179
|
+
// Listed size is read size here on purpose: the body is served exactly as it is
|
|
180
|
+
// rendered, so a client that budgets on the listing gets what it budgeted for.
|
|
181
|
+
bytes: Buffer.byteLength(region.body),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (parsed.collection === 'plan') {
|
|
186
|
+
const plan = await planNext(root, null);
|
|
187
|
+
if (plan.error || !plan.next) return null;
|
|
188
|
+
return {
|
|
189
|
+
uri,
|
|
190
|
+
mimeType: 'text/markdown',
|
|
191
|
+
text: plan.next.section,
|
|
192
|
+
bytes: plan.next.sectionBytes,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
114
196
|
if (parsed.collection === 'rules') {
|
|
115
197
|
const [rules, mdc] = await Promise.all([loadRules(null), loadMdcRules()]);
|
|
116
198
|
const rule = [...rules, ...mdc].find((candidate) => candidate.id === parsed.id);
|
|
@@ -147,11 +229,3 @@ function frontmatterOf(rule) {
|
|
|
147
229
|
function formatValue(value) {
|
|
148
230
|
return Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
|
|
149
231
|
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The remedy a client gets when it asks for something that is not here.
|
|
153
|
-
*
|
|
154
|
-
* A command, not a path, for the reason every remedy in this package is a command: the
|
|
155
|
-
* reader runs this tool, not this checkout.
|
|
156
|
-
*/
|
|
157
|
-
const MISSING_REMEDY = `run: ${CLI} rules list, or ${CLI} graph lint to see what this copy can read`;
|