simt-dev 0.4.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +300 -82
  3. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +13 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +1 -1
  5. package/docs/decisions/ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md +71 -0
  6. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +149 -0
  7. package/package.json +8 -4
  8. package/src/advice/advice.mjs +229 -0
  9. package/src/agenthooks/render.mjs +87 -21
  10. package/src/argv.mjs +25 -0
  11. package/src/bench/ablate.mjs +1 -3
  12. package/src/bench/grade.mjs +1 -1
  13. package/src/bench/mine.mjs +3 -3
  14. package/src/bench/probes.mjs +1 -3
  15. package/src/bench/report.mjs +1 -3
  16. package/src/bench/run.mjs +4 -18
  17. package/src/bench/tasks.mjs +36 -0
  18. package/src/bench/variants.mjs +1 -3
  19. package/src/brief/assemble.mjs +24 -4
  20. package/src/brief/packet.mjs +4 -1
  21. package/src/ci/dependabot.mjs +1 -1
  22. package/src/ci/workflow.mjs +40 -12
  23. package/src/commands/agenthook.mjs +6 -2
  24. package/src/commands/bench.mjs +35 -3
  25. package/src/commands/ci.mjs +1 -1
  26. package/src/commands/compare.mjs +1 -1
  27. package/src/commands/distill.mjs +36 -0
  28. package/src/commands/gate.mjs +2 -2
  29. package/src/commands/graph.mjs +2 -2
  30. package/src/commands/harness.mjs +87 -14
  31. package/src/commands/help.mjs +54 -4
  32. package/src/commands/hooks.mjs +1 -1
  33. package/src/commands/index.mjs +29 -3
  34. package/src/commands/init.mjs +2 -2
  35. package/src/commands/local.mjs +11 -0
  36. package/src/commands/mcp.mjs +1 -1
  37. package/src/commands/pack.mjs +246 -0
  38. package/src/commands/plugin.mjs +61 -0
  39. package/src/commands/review.mjs +65 -0
  40. package/src/commands/rules.mjs +1 -1
  41. package/src/commands/skills.mjs +3 -34
  42. package/src/commands/skillscompile.mjs +1 -3
  43. package/src/commands/skillsinstall.mjs +1 -1
  44. package/src/commands/sync.mjs +8 -8
  45. package/src/commands/tokens.mjs +1 -1
  46. package/src/commands/upgrade.mjs +1 -1
  47. package/src/commands/why.mjs +6 -3
  48. package/src/compare/discover.mjs +1 -1
  49. package/src/compare/hooksstate.mjs +19 -7
  50. package/src/compare/learn.mjs +2 -2
  51. package/src/compare/row.mjs +3 -3
  52. package/src/decisions/index.mjs +1 -1
  53. package/src/distill/distill.mjs +246 -0
  54. package/src/distill/engine.mjs +187 -0
  55. package/src/distribute/release.mjs +142 -0
  56. package/src/graph/load.mjs +9 -8
  57. package/src/hooks/dispatcher.mjs +3 -2
  58. package/src/hooks/shim.mjs +11 -6
  59. package/src/index/govern.mjs +1 -1
  60. package/src/index/map.mjs +1 -1
  61. package/src/index/persist.mjs +2 -2
  62. package/src/index/store.mjs +50 -20
  63. package/src/ledger/index.mjs +2 -2
  64. package/src/local/client.mjs +22 -8
  65. package/src/local/config.mjs +52 -0
  66. package/src/locate.mjs +22 -1
  67. package/src/mcp/prompts.mjs +118 -0
  68. package/src/mcp/resources.mjs +96 -22
  69. package/src/mcp/server.mjs +33 -6
  70. package/src/mcp/tools.mjs +12 -12
  71. package/src/measure/session.mjs +1 -1
  72. package/src/memory/decide.mjs +1 -1
  73. package/src/memory/lessons.mjs +14 -14
  74. package/src/name.mjs +11 -1
  75. package/src/package-version.mjs +12 -10
  76. package/src/packs/manage.mjs +563 -0
  77. package/src/packs.mjs +15 -5
  78. package/src/plugins.mjs +164 -0
  79. package/src/projection/loaders.mjs +84 -10
  80. package/src/projection/manifest.mjs +4 -4
  81. package/src/projection/project.mjs +3 -3
  82. package/src/projection/rules.mjs +24 -4
  83. package/src/projection/surfaces.mjs +20 -2
  84. package/src/resolve/body.mjs +46 -0
  85. package/src/review/critic.mjs +509 -0
  86. package/src/toolwords.mjs +8 -5
  87. package/src/tsconfig.mjs +3 -3
  88. package/src/upgrade/plan.mjs +1 -1
  89. /package/bin/{simt-dev.mjs → simt.mjs} +0 -0
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { renderLocator } from '../locate.mjs';
20
20
  import { BIN_ENV, CLI, MANIFEST_FILE, PACKAGE_NAME } from '../name.mjs';
21
+ import { localInstall } from '../distribute/release.mjs';
21
22
  import { packageVersion } from '../package-version.mjs';
22
23
 
23
24
  /**
@@ -49,8 +50,8 @@ export function fileFor(event) {
49
50
  * @param {string} event one of EVENTS
50
51
  * @param {{npm?: boolean, version?: string}} [opts]
51
52
  * `npm` is whether the repository has a package.json: it decides the remedy's install
52
- * (a devDependency where one exists, a pinned global install where there is no
53
- * node_modules to pin in). `version` defaults to the running package's own version,
53
+ * (a devDependency where one exists, the release binary into the repository where
54
+ * there is no node_modules to pin in). `version` defaults to the running package's own version,
54
55
  * so a shim generated by 0.2.0 names 0.2.0 in its remedy and CI install.
55
56
  * @returns {string} POSIX sh source, LF-terminated, executable
56
57
  */
@@ -62,8 +63,9 @@ export function renderShim(event, { npm = true, version } = {}) {
62
63
  // The install that finds the binary when node_modules and PATH both come up empty.
63
64
  // A repository with a package.json pins simt-dev as a devDependency, so the remedy
64
65
  // installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
65
- // so the remedy is a pinned global install - the same pinned bytes CI gets.
66
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
66
+ // so the remedy downloads the release binary into the repository - the same pinned
67
+ // bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
68
+ const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
67
69
  const D = '$';
68
70
  return `#!/bin/sh
69
71
  ${SHIM_MARKER}
@@ -75,7 +77,8 @@ ${SHIM_MARKER}
75
77
  # This file only locates the dispatcher and hands over. It resolves in order:
76
78
  # 1. ${D}SIMT_DEV_BIN explicit override, for CI images and odd checkouts
77
79
  # 2. node_modules/ the pinned project dependency
78
- # 3. ${CLI} on PATH a global install
80
+ # 3. .simt/bin/ the release binary this repository downloaded
81
+ # 4. ${CLI} on PATH a global install
79
82
  # If none resolves it REFUSES rather than exiting 0. A hook that cannot verify
80
83
  # anything must never report success - that is what teaches authors to reach for
81
84
  # --no-verify.
@@ -102,7 +105,9 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
102
105
  */
103
106
  export function renderEvidenceShim({ npm = true, version } = {}) {
104
107
  const v = version ?? packageVersion();
105
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : `npm i -g ${PACKAGE_NAME}@${v}`;
108
+ // The same split as renderShim: a devDependency where one exists, the release
109
+ // binary into the repository where there is no node_modules to pin in.
110
+ const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
106
111
  const D = '$';
107
112
  return `#!/bin/sh
108
113
  ${SHIM_MARKER}
@@ -56,7 +56,7 @@ export function sourceRoots(files) {
56
56
  /**
57
57
  * One Affects token.
58
58
  *
59
- * A prefixed command (`simt-dev init`, `simt hooks run`) is a command only when
59
+ * A prefixed command (`simt init`, `simt hooks run`) is a command only when
60
60
  * that command exists. The tool name on its own is not a command and not a path.
61
61
  * Anything else is a path or a glob, at the repo root and then under each source
62
62
  * root. A token that is none of those is unresolved.
package/src/index/map.mjs CHANGED
@@ -285,7 +285,7 @@ export async function embedTaskQuery(root, task, { embed = false, allowRemote =
285
285
  * }} [opts]
286
286
  */
287
287
  export async function measureRecall(root, opts = {}) {
288
- const tasksDir = join(root, '.simt-dev', 'bench', 'tasks');
288
+ const tasksDir = join(root, '.simt', 'bench', 'tasks');
289
289
  let names;
290
290
  try {
291
291
  names = (await readdir(tasksDir)).filter((name) => name.endsWith('.json')).sort();
@@ -7,8 +7,8 @@
7
7
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8
8
  import { dirname, join } from 'node:path';
9
9
 
10
- export const SQLITE_FILE = '.simt-dev/index.sqlite';
11
- export const JSONL_FILE = '.simt-dev/index.jsonl';
10
+ export const SQLITE_FILE = '.simt/index.sqlite';
11
+ export const JSONL_FILE = '.simt/index.jsonl';
12
12
  export const SCHEMA_V = '3';
13
13
 
14
14
  const SCHEMA = `
@@ -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, binary = false) {
390
+ function git(root, args, input) {
391
391
  return new Promise((resolve, reject) => {
392
- const child = execFile('git', args, { cwd: root, encoding: binary ? 'buffer' : 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
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 stdout = await git(root, ['cat-file', '--batch'], `${unique.join('\n')}\n`, true);
485
- let offset = 0;
486
- while (offset < stdout.length) {
487
- const nl = stdout.indexOf(0x0a, offset);
488
- if (nl < 0) break;
489
- const header = stdout.toString('utf8', offset, nl);
490
- const parts = header.split(' ');
491
- const size = Number(parts[2]);
492
- if (parts[1] !== 'blob' || !Number.isFinite(size)) {
493
- offset = nl + 1;
494
- continue;
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
- const start = nl + 1;
497
- const body = stdout.subarray(start, start + size);
498
- offset = start + size + 1;
499
- texts.set(parts[0], body.includes(0) ? null : body.toString('utf8'));
500
- }
501
- return texts;
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) {
@@ -22,7 +22,7 @@ import {
22
22
 
23
23
  const exec = promisify(execFile);
24
24
 
25
- const LEDGER_FILE = '.simt-dev/ledger.jsonl';
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-dev'), { recursive: true });
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') {
@@ -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: body !== undefined ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
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 content =
220
- typeof choice?.message?.content === 'string'
221
- ? choice.message.content
222
- : typeof choice?.text === 'string'
223
- ? choice.text
224
- : null;
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
 
@@ -8,8 +8,13 @@
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
 
17
+ import { EXIT, fail } from '../output/index.mjs';
13
18
  import { MANIFEST_FILE } from '../name.mjs';
14
19
  import { readManifest } from '../projection/manifest.mjs';
15
20
 
@@ -113,6 +118,17 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
113
118
  };
114
119
  }
115
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
+
116
132
  const loopback = isLoopbackHost(parsedUrl.hostname);
117
133
  const baseUrl = resolveEndpointUrl(rawUrl, '');
118
134
  const blocked = !loopback && !allowRemote;
@@ -125,6 +141,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
125
141
  baseUrl,
126
142
  model,
127
143
  timeoutMs,
144
+ apiKey,
128
145
  loopback: false,
129
146
  blocked: true,
130
147
  },
@@ -140,6 +157,7 @@ function parseEndpoint(role, entry, { allowRemote = false } = {}) {
140
157
  baseUrl,
141
158
  model,
142
159
  timeoutMs,
160
+ apiKey,
143
161
  loopback,
144
162
  blocked: false,
145
163
  },
@@ -232,3 +250,37 @@ export async function readLocalConfig(root, { allowRemote = false } = {}) {
232
250
  manifestError: null,
233
251
  };
234
252
  }
253
+
254
+ /**
255
+ * The guard every model consumer shares (R37's distill, R39's critic and bench).
256
+ *
257
+ * A `--local`-shaped call without a usable chat endpoint is a configuration
258
+ * error, never a silent fallback to the deterministic path: a caller who asked
259
+ * for the model and got the deterministic answer would read that answer as the
260
+ * model's — and the reverse, reading model output as deterministic, is worse.
261
+ * Second real user arrived with the critic, so the guard moved here from the
262
+ * distiller's `runEngine` (ADR-002).
263
+ *
264
+ * Returns `{ chat }` on success or `{ exit }` with the failure already shaped
265
+ * (the same pass-through shape the command layer returns).
266
+ */
267
+ export function chatEndpoint(config) {
268
+ if (config.manifestError !== null) {
269
+ return { exit: fail(`${MANIFEST_FILE}: ${config.manifestError}`, EXIT.CONFIG) };
270
+ }
271
+ if (!config.configured || !config.chat || config.findings.length > 0) {
272
+ const detail = !config.configured
273
+ ? 'no local model is configured'
274
+ : config.remoteBlocked
275
+ ? 'the configured chat endpoint is non-loopback and was refused'
276
+ : 'the local configuration has errors';
277
+ return {
278
+ exit: fail(
279
+ `--local needs a usable local chat endpoint: ${detail}`,
280
+ EXIT.CONFIG,
281
+ `configure local.chat in ${MANIFEST_FILE} (plan 0003 R37), or run without --local for the deterministic path`,
282
+ ),
283
+ };
284
+ }
285
+ return { chat: config.chat };
286
+ }
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
+ }