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
@@ -1,4 +1,4 @@
1
- // The matrix a `simt-dev compare` row is built from: one repo's adoption state.
1
+ // The matrix a `simt compare` row is built from: one repo's adoption state.
2
2
  //
3
3
  // The shape of a row answers one question - "would this repo behave correctly if
4
4
  // an agent followed it today?" - from the four things that can make it not:
@@ -255,7 +255,7 @@ export async function buildRow(path, context) { const name = path.split('/').fi
255
255
  if (region.status === 'drift') {
256
256
  // A drift has two causes with different fixes, and the SHA says which. When
257
257
  // the recorded SHA matches this simt-dev, the region was hand-edited and
258
- // `simt-dev sync --yes` is right; when it does not, the repo is simply behind and the
258
+ // `simt sync --yes` is right; when it does not, the repo is simply behind and the
259
259
  // same command is also right - but the message must not claim a version
260
260
  // mismatch when the versions are identical, because that sends the reader
261
261
  // looking for a pin that is already correct.
@@ -274,7 +274,7 @@ export async function buildRow(path, context) { const name = path.split('/').fi
274
274
  } else if (region.status === 'current' && row.pinnedSha && row.pinnedSha !== context.expectedSha) {
275
275
  // The region matches this simt-dev but the manifest pins an older one. The
276
276
  // next sync would stamp the newer SHA silently, so the pin is the thing
277
- // that is stale - and it is the value `simt-dev upgrade` reads.
277
+ // that is stale - and it is the value `simt upgrade` reads.
278
278
  problems.push(
279
279
  `manifest pins simt-dev ${row.pinnedSha}, this is ${context.expectedSha}; run: ${CLI} upgrade`,
280
280
  );
@@ -1,4 +1,4 @@
1
- // The decision index: what `simt-dev rules why` traces back to.
1
+ // The decision index: what `simt rules why` traces back to.
2
2
  //
3
3
  // Rule bodies cite the decision that settled them inline, as `(ADR-001)`. That
4
4
  // citation is the only thing standing between "a rule someone wrote" and "a rule
@@ -0,0 +1,246 @@
1
+ // The distiller's orchestrator (plan 0003 R38): one target in, one proposal out.
2
+ //
3
+ // R37 gave this repository a local model client, and R38 is the first consumer of
4
+ // it: distillation is the cheapest thing a local model can do that the
5
+ // deterministic engine cannot — abstractive compression — and the one that most
6
+ // needs the guardrails here, because its output is the kind a reader would paste
7
+ // into a pack and forget to check.
8
+ //
9
+ // Nothing here writes outside `.simt/distilled/`, and nothing here edits a
10
+ // source file (ADR-090, ADR-075). A proposal that a human did not merge is a dead
11
+ // file, which is the intended failure mode: proposals expire, projections do not.
12
+
13
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
14
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+
16
+ import { EXIT, fail } from '../output/index.mjs';
17
+ import { CLI } from '../name.mjs';
18
+ import { loadRegistry } from '../graph/load.mjs';
19
+ import { nearMatches } from '../resolve/near.mjs';
20
+ import { bodyFile, sourcePath } from '../resolve/body.mjs';
21
+ import { resolveWorkspace } from '../workspace.mjs';
22
+ import { readLocalConfig, chatEndpoint } from '../local/config.mjs';
23
+ import { localChat } from '../local/client.mjs';
24
+ import {
25
+ ENGINE_LOCAL,
26
+ ENGINE_OUTLINE,
27
+ buildLocalPrompt,
28
+ digestOf,
29
+ outlineSummary,
30
+ proposalFrontmatter,
31
+ promptHash,
32
+ sourceDigest,
33
+ verifyCitations,
34
+ } from './engine.mjs';
35
+
36
+ export const DEFAULT_BUDGET = 2048;
37
+ const DISTILL_DIR = '.simt/distilled';
38
+
39
+ /**
40
+ * The row's acceptance metric, stated where a caller reads results (ADR-076):
41
+ * "bench shows distilled body plus references match the full body on R at lower
42
+ * C, or the feature stays experimental." It is the second of the two until the
43
+ * bench measures it, so every report says so rather than implying parity.
44
+ */
45
+ const METRIC_NOTE = 'parity at lower C unmeasured on R; experimental (plan 0003 R38)';
46
+
47
+ /**
48
+ * Resolve `<skill|doc>` to an absolute source path.
49
+ *
50
+ * A name first: `distill core-rule` is read as the skill that `skills show` would
51
+ * read, so both commands answering differently is the defect. A path second:
52
+ * everything else is a file under the root, and one that escapes the root is
53
+ * refused — a proposal whose provenance names `../../etc` is provenance nobody
54
+ * should trust.
55
+ */
56
+ async function resolveTarget(target, root, flags) {
57
+ const { registry, errors, ok } = await loadRegistry(null, flags);
58
+ if (!ok) return { exit: fail(errors[0], EXIT.REGISTRY) };
59
+
60
+ const skill = registry.skills.find((s) => s.name === target);
61
+ if (skill) {
62
+ const ws = await resolveWorkspace(flags);
63
+ const full = await sourcePath(skill, ws.root ?? null, root);
64
+ const readable = full !== null && (full === root || bodyFile(full).startsWith(root + sep));
65
+ if (!readable) {
66
+ const near = nearMatches(target, registry.skills.map((s) => s.name));
67
+ return {
68
+ exit: fail(
69
+ `skill "${target}" resolved to ${full ?? 'no body'} and that body is not under ${root}`,
70
+ EXIT.FAIL,
71
+ `run ${CLI} skills show ${target} to see how the body resolves, or distill a doc path`,
72
+ ),
73
+ };
74
+ }
75
+ const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
76
+ return {
77
+ path: bodyFile(full),
78
+ name: canonical ? `${canonical.repo}/${canonical.path}` : skill.name,
79
+ how: 'skill',
80
+ };
81
+ }
82
+
83
+ const candidate = isAbsolute(target) ? target : resolve(root, target);
84
+ if (candidate !== root && !candidate.startsWith(root + sep)) {
85
+ return {
86
+ exit: fail(`${target} is outside ${root}`, EXIT.FAIL, 'distill reads docs under the repository root only'),
87
+ };
88
+ }
89
+ try {
90
+ const text = await readFile(bodyFile(candidate), 'utf8');
91
+ return {
92
+ path: candidate,
93
+ name: relative(root, candidate) || target,
94
+ how: 'doc',
95
+ text,
96
+ };
97
+ } catch {
98
+ const near = nearMatches(target, registry.skills.map((s) => s.name));
99
+ return {
100
+ exit: fail(
101
+ `no skill named "${target}" and no file ${relative(root, candidate) ?? target}`,
102
+ EXIT.FAIL,
103
+ near.length ? `did you mean the skill ${near.join(', ')}?` : `run ${CLI} skills index`,
104
+ ),
105
+ };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The engine dispatch, including the model path's guardrails.
111
+ *
112
+ * `--local` without a usable `local.chat` is a configuration error, not a silent
113
+ * fallback to the deterministic engine: a caller who asked for the model and got
114
+ * the outline would read the outline's confidence as the model's — and the
115
+ * reverse, reading model output as deterministic, is worse.
116
+ */
117
+ async function runEngine(kind, text, budget, context) {
118
+ if (kind === ENGINE_OUTLINE) return outlineSummary(text, budget);
119
+
120
+ const { root, allowRemote, name } = context;
121
+ const config = await readLocalConfig(root, { allowRemote });
122
+ // The shared guard arrived from `local/config.mjs` when the critic became the
123
+ // second real user of the same rules (plan 0003 R39, ADR-002).
124
+ const endpoint = chatEndpoint(config);
125
+ if (endpoint.exit !== undefined) return endpoint;
126
+
127
+ const prompt = buildLocalPrompt(name, text, budget);
128
+ const chat = await localChat(config.chat, {
129
+ system: 'You compress documents for software agents. Follow the rules the user message states exactly.',
130
+ prompt,
131
+ temperature: 0,
132
+ maxTokens: budget === 0 ? 2048 : Math.min(2048, Math.max(256, Math.ceil(budget / 4))),
133
+ allowRemote,
134
+ });
135
+ if (!chat.ok) {
136
+ return {
137
+ exit: fail(
138
+ `local chat failed: ${chat.error}`,
139
+ EXIT.FAIL,
140
+ 'fix the endpoint, or run without --local for the deterministic engine',
141
+ ),
142
+ };
143
+ }
144
+ return { engine: ENGINE_LOCAL, model: chat.model, body: String(chat.content).trim(), prompt };
145
+ }
146
+
147
+ function parseExistingFrontmatter(text) {
148
+ if (!text.startsWith('---\n')) return {};
149
+ const head = text.split('\n---', 1)[0];
150
+ const fields = {};
151
+ for (const line of head.split('\n').slice(1)) {
152
+ const m = /^([a-z]+):\s?(.*)$/.exec(line);
153
+ if (m) fields[m[1]] = m[2];
154
+ }
155
+ return fields;
156
+ }
157
+
158
+ function countCitations(text) {
159
+ return (text.match(/\[L\d+(-L\d+)?\]/g) ?? []).length;
160
+ }
161
+
162
+ /**
163
+ * Returns either the report object (ok) or an exit code (fail) — the same shape
164
+ * the command layer returns, so a failure passes through unchanged.
165
+ */
166
+ export async function distillTarget(args, flags) {
167
+ const target = args[0];
168
+ if (!target) {
169
+ return fail(
170
+ 'distill needs a skill name or a doc path',
171
+ EXIT.USAGE,
172
+ `usage: ${CLI} distill <skill|doc> [--budget <bytes>]`,
173
+ );
174
+ }
175
+
176
+ const root = resolve(flags.root ?? process.cwd());
177
+ const source = await resolveTarget(target, root, flags);
178
+ if (source.exit !== undefined) return source.exit;
179
+
180
+ const text = source.text ?? (await readFile(bodyFile(source.path), 'utf8'));
181
+ const bytes = Buffer.byteLength(text, 'utf8');
182
+ const budget = Number.isInteger(flags.budget) ? flags.budget : DEFAULT_BUDGET;
183
+ const allowRemote = flags.allowRemote === true;
184
+ const engineKind = flags.local === true ? ENGINE_LOCAL : ENGINE_OUTLINE;
185
+ const digest = digestOf(text, engineKind, budget);
186
+ const relProposal = `${DISTILL_DIR}/${digest}.md`;
187
+ const absProposal = join(root, relProposal);
188
+ const lineCount = text.split('\n').length;
189
+
190
+ const cached = flags.refresh === true ? null : await readFile(absProposal, 'utf8').catch(() => null);
191
+ if (cached !== null) {
192
+ const fields = parseExistingFrontmatter(cached);
193
+ return {
194
+ ok: true,
195
+ command: 'distill',
196
+ target: { kind: source.how, source: source.name, bytes, lines: lineCount },
197
+ digest: sourceDigest(text),
198
+ engine: fields.engine ?? ENGINE_OUTLINE,
199
+ budget: Number(fields.budget ?? budget),
200
+ citations: countCitations(cached),
201
+ verified: fields.verified !== 'false',
202
+ metric: { note: METRIC_NOTE },
203
+ proposal: { path: relProposal, state: 'cached', bytes: Buffer.byteLength(cached, 'utf8') },
204
+ };
205
+ }
206
+
207
+ const outcome = await runEngine(engineKind, text, budget, { root, allowRemote, name: source.name });
208
+ if (outcome.exit !== undefined) return outcome.exit;
209
+
210
+ const check = verifyCitations(outcome.body, lineCount);
211
+ const faults = [...check.faults];
212
+ const bodyBytes = Buffer.byteLength(outcome.body, 'utf8');
213
+ if (budget > 0 && bodyBytes > budget) {
214
+ faults.push(`summary is ${bodyBytes} bytes against a ${budget}-byte budget`);
215
+ }
216
+
217
+ const provenance =
218
+ outcome.engine === ENGINE_LOCAL
219
+ ? `${ENGINE_LOCAL}:${outcome.model} (prompt ${promptHash(outcome.prompt)})`
220
+ : ENGINE_OUTLINE;
221
+ const proposal =
222
+ proposalFrontmatter({
223
+ source: source.name,
224
+ digest,
225
+ engine: provenance,
226
+ budget,
227
+ verified: faults.length === 0,
228
+ }) + `\n${outcome.body}\n`;
229
+
230
+ await mkdir(join(root, DISTILL_DIR), { recursive: true });
231
+ await writeFile(absProposal, proposal);
232
+
233
+ return {
234
+ ok: true,
235
+ command: 'distill',
236
+ target: { kind: source.how, source: source.name, bytes, lines: lineCount },
237
+ digest: sourceDigest(text),
238
+ engine: provenance,
239
+ budget,
240
+ citations: check.ranges.length,
241
+ verified: faults.length === 0,
242
+ ...(faults.length > 0 ? { faults } : {}),
243
+ metric: { note: METRIC_NOTE },
244
+ proposal: { path: relProposal, state: 'fresh', bytes: Buffer.byteLength(proposal, 'utf8') },
245
+ };
246
+ }
@@ -0,0 +1,187 @@
1
+ // The distiller's engines (plan 0003 R38).
2
+ //
3
+ // `simt distill` writes a budgeted summary of a skill body or doc, with every claim
4
+ // citing the source line range it rests on. Two engines produce that shape:
5
+ //
6
+ // - `outline` (default): a deterministic, extractive summary — the heading
7
+ // skeleton plus the first content of each section, cut to the budget. No
8
+ // model, no network, byte-stable: the same text at the same budget produces
9
+ // the same bytes forever (P1, P4).
10
+ // - `local`: the configured local chat model (R37) proposes a abstractive
11
+ // summary. It is opt-in (`--local`), loopback-only by default, and its output
12
+ // is verified against the source before anything is written.
13
+ //
14
+ // Both engines write the same artefact: a *proposal*, never a projection. The
15
+ // proposal file carries its provenance in frontmatter (source, digest, engine,
16
+ // prompt hash for the model path); a human merges it into the pack or repo, and
17
+ // nothing in this module ever edits a source file (ADR-090/ADR-075, principle P2).
18
+
19
+ import { createHash } from 'node:crypto';
20
+
21
+ export const ENGINE_OUTLINE = 'outline';
22
+ export const ENGINE_LOCAL = 'local';
23
+
24
+ /**
25
+ * The cache key: source digest plus the recipe that shaped the output. Two
26
+ * proposals of the same text at different budgets are different answers, so one
27
+ * key carries both rather than letting a second write collide with the first.
28
+ */
29
+ export function digestOf(text, engine, budget) {
30
+ const h = createHash('sha256');
31
+ h.update(`${engine}\0${budget}\0${text}`);
32
+ return `sha256-${h.digest('hex').slice(0, 16)}`;
33
+ }
34
+
35
+ /** Same function for the source itself, where the recipe does not apply. */
36
+ export function sourceDigest(text) {
37
+ return `sha256-${createHash('sha256').update(text).digest('hex').slice(0, 16)}`;
38
+ }
39
+
40
+ /** The hash of the prompt sent to a model engine — the provenance P2 asks for. */
41
+ export function promptHash(prompt) {
42
+ return `sha256-${createHash('sha256').update(prompt).digest('hex').slice(0, 16)}`;
43
+ }
44
+
45
+ const FENCE = /^\s*(```|~~~)/;
46
+ const HEADING = /^#{1,6}\s/;
47
+
48
+ /**
49
+ * Build the prompt a local model receives (ENGINE_LOCAL only).
50
+ *
51
+ * The source ships with line numbers printed, because the only citation format
52
+ * the verifier can check is `[L<n>-<m>]` against those numbers. A model asked to
53
+ * "cite the source" but given unnumbered text can only guess, and every guess is
54
+ * a citation that looks true and cannot be checked — which is the defect this
55
+ * command exists to prevent.
56
+ */
57
+ export function buildLocalPrompt(fileName, text, budget) {
58
+ const numbered = text
59
+ .split('\n')
60
+ .map((line, i) => `${i + 1}: ${line}`)
61
+ .join('\n');
62
+ return [
63
+ 'Distill the document below into what an agent must know to act on it.',
64
+ `The summary must not exceed ${budget} bytes. Rules:`,
65
+ '- Every claim ends with a citation to a source line range, written [L12] or [L12-L15], using the line numbers printed below.',
66
+ '- Cite only lines that exist in the source. Never cite a line you cannot see.',
67
+ '- Copy commands, paths, thresholds and names verbatim; do not paraphrase them.',
68
+ '- Do not invent content, and do not add advice the source does not contain.',
69
+ `- Reply with the summary only; nothing else.`,
70
+ '',
71
+ `SOURCE ${fileName}`,
72
+ numbered,
73
+ ].join('\n');
74
+ }
75
+
76
+ /**
77
+ * The deterministic extractive engine (ENGINE_OUTLINE).
78
+ *
79
+ * Selection, readable as a score: every heading first (they are the document's
80
+ * own structure), then the immediate non-empty content under each heading. The
81
+ * result is the document in its own words, in document order, at a fraction of
82
+ * the bytes — which is the comparison the acceptance criterion asks the bench to
83
+ * run, and the honest thing an offline engine can claim.
84
+ *
85
+ * Bounds: the returned body never exceeds `budget` bytes (0 = unlimited), and
86
+ * every line it renders carries a citation the verifier below can check.
87
+ */
88
+ export function outlineSummary(text, budget) {
89
+ const lines = text.split('\n');
90
+ const picks = [];
91
+ let inline = false;
92
+
93
+ // Pass 1: headings.
94
+ for (let i = 0; i < lines.length; i += 1) {
95
+ const line = lines[i];
96
+ if (FENCE.test(line)) inline = !inline;
97
+ if (inline) continue;
98
+ if (HEADING.test(line)) picks.push(i);
99
+ }
100
+
101
+ // Pass 2: the first content line under each heading.
102
+ for (const i of [...picks]) {
103
+ for (let j = i + 1; j < lines.length; j += 1) {
104
+ if (FENCE.test(lines[j])) break;
105
+ if (HEADING.test(lines[j])) break;
106
+ if (lines[j].trim() === '') continue;
107
+ picks.push(j);
108
+ break;
109
+ }
110
+ }
111
+
112
+ if (picks.length === 0 && lines.some((l) => l.trim() !== '')) {
113
+ // No headings at all: a doc of prose. The whole thing is one section, so the
114
+ // first content line is the citation anchor rather than an empty summary.
115
+ const first = lines.findIndex((l) => l.trim() !== '' && !FENCE.test(l));
116
+ if (first !== -1) picks.push(first);
117
+ }
118
+
119
+ picks.sort((a, b) => a - b);
120
+
121
+ const body = [];
122
+ let used = 0;
123
+ for (const i of picks) {
124
+ const text = lines[i].trimEnd();
125
+ const bullet = `>[L${i + 1}] ${text}`;
126
+ const cost = Buffer.byteLength(`${bullet}\n`, 'utf8');
127
+ if (budget > 0 && used + cost > budget) break;
128
+ used += cost;
129
+ body.push(bullet);
130
+ }
131
+
132
+ return { engine: ENGINE_OUTLINE, body: body.join('\n'), citations: body.length, budgetUsed: used };
133
+ }
134
+
135
+ /**
136
+ * Check a summary's citations against the source that supposedly backs them.
137
+ *
138
+ * A citation naming a line past the end of the source is fabricated; a summary
139
+ * with any such citation is returned with `--json` detailed faults and is never
140
+ * silently trusted. The proposal still writes (it is only a proposal), but its
141
+ * provenance records `verified: false` so a reader cannot mistake it for checked
142
+ * output (ADR-083).
143
+ */
144
+ export function verifyCitations(body, sourceLineCount) {
145
+ const faults = [];
146
+ const ranges = [];
147
+ const re = /\[L(\d+)(?:-L?(\d+))?\]/g;
148
+ let match;
149
+ while ((match = re.exec(body)) !== null) {
150
+ const start = Number(match[1]);
151
+ const end = match[2] === undefined ? start : Number(match[2]);
152
+ const kind =
153
+ start < 1 || start > sourceLineCount
154
+ ? `start`
155
+ : end < start
156
+ ? `order`
157
+ : end > sourceLineCount
158
+ ? `end`
159
+ : null;
160
+ if (kind !== null) {
161
+ faults.push(`citation [L${start}-L${end}] ${kind} outside the source (1..${sourceLineCount})`);
162
+ continue;
163
+ }
164
+ ranges.push({ start, end });
165
+ }
166
+ return { ok: faults.length === 0, ranges, faults };
167
+ }
168
+
169
+ /** The proposal file's frontmatter. No wall-clock value here: byte-stable (P4). */
170
+ export function proposalFrontmatter({ source, digest, engine, budget, prompt, verified, verdict, advisory }) {
171
+ return [
172
+ '---',
173
+ `source: ${source}`,
174
+ `digest: ${digest}`,
175
+ `engine: ${engine}`,
176
+ `budget: ${budget}`,
177
+ prompt ? `prompt: ${prompt}` : null,
178
+ verdict ? `verdict: ${verdict}` : null,
179
+ advisory ? 'advisory: true' : null,
180
+ verified === false ? 'verified: false' : 'verified: true',
181
+ 'status: proposal',
182
+ '---',
183
+ '',
184
+ ]
185
+ .filter((l) => l !== null)
186
+ .join('\n');
187
+ }
@@ -0,0 +1,142 @@
1
+ // The binary channel: what a release publishes, where a repository keeps its copy,
2
+ // and the one command that gets it there.
3
+ //
4
+ // This module is the single place that knows those facts, because three generated
5
+ // surfaces must agree on them and they are rendered from different call sites: the
6
+ // locator block (src/locate.mjs) checks a path, the hook shim's remedy prints a
7
+ // download command, and the CI workflow renders the same download as a step. A
8
+ // second copy of the asset name or the URL is a copy that drifts, and a drifted
9
+ // remedy tells a reader to fetch bytes that do not exist.
10
+ //
11
+ // The channel is a fourth beside npm, PyPI and Homebrew (ADR-058), and unlike the
12
+ // other three it is the one that needs no toolchain at all: the npm channel needs
13
+ // npm, the wheel needs a Python, and the formula - until this release - needed
14
+ // node. The binary is a file. That is the whole design (ADR-098), and it is why
15
+ // the locator checks a path in the repository before it checks PATH: the bytes a
16
+ // repository pinned are the bytes its gate runs, on a machine with nothing else.
17
+
18
+ import { CLI, PACKAGE_NAME } from '../name.mjs';
19
+
20
+ // The GitHub repository the release is published from. A fact about where the
21
+ // bytes come from, like the npm registry host: a reader outside this estate
22
+ // reads it as a URL, not as an organisation. Not exported: the only surface a
23
+ // caller needs is the URL, and a second copy of the slug is a copy that drifts.
24
+ const REPO_SLUG = 'QuazarMedia/simt-dev';
25
+
26
+ /** The platforms the release builds. The set is small on purpose: it is the set
27
+ * the locator's remedy and the CI step can name, and a platform in this list that
28
+ * the release does not build is a remedy that downloads a 404. */
29
+ export const TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64'];
30
+
31
+ // Where a repository keeps the binary it pinned, relative to its top level.
32
+ // Beneath `.simt`, like every other per-repo state this tool writes, so a reader
33
+ // who has seen one knows where to look for all of them. The name is the command,
34
+ // not the package: the reader types one program and this is it, the same way
35
+ // `bin/simt.mjs` is it on npm.
36
+ const LOCAL_BIN_NAME = CLI;
37
+
38
+ /** The directory a repository keeps its pinned binary in, relative to its top
39
+ * level. Exported because a caller that installs the binary must create it and
40
+ * has no other source for the fact. */
41
+ export const LOCAL_BIN_DIR = '.simt/bin';
42
+
43
+ /** The path the locator checks, relative to the repository's top level. */
44
+ export const LOCAL_BIN = `${LOCAL_BIN_DIR}/${LOCAL_BIN_NAME}`;
45
+
46
+ /**
47
+ * The release asset name for a version and a target.
48
+ *
49
+ * The package name, not the command: it sits beside its siblings in the release
50
+ * and next to the npm tarball `simt-dev-0.5.0.tgz`, where one name for the project
51
+ * is what a reader matching names expects.
52
+ *
53
+ * @param {string} version x.y.z
54
+ * @param {string} osArch one of TARGETS
55
+ * @returns {string} e.g. `simt-dev-0.5.0-darwin-arm64`
56
+ */
57
+ export function assetName(version, osArch) {
58
+ if (!TARGETS.includes(osArch)) throw new Error(`unknown target ${osArch}; known: ${TARGETS.join(', ')}`);
59
+ return `${PACKAGE_NAME}-${version}-${osArch}`;
60
+ }
61
+
62
+ /**
63
+ * The asset name with the platform left for the shell to fill in at runtime.
64
+ *
65
+ * The CI step and the remedy both run on a machine whose platform is not known
66
+ * where the text was written, so the name is completed from `uname` or the
67
+ * runner there. One template shared with {@link assetName} keeps the two from
68
+ * drifting: a name rendered here that the release does not publish is a 404.
69
+ *
70
+ * @param {string} version x.y.z
71
+ * @returns {string} e.g. `simt-dev-0.5.0-$os-$arch`, for a shell with $os and $arch set
72
+ */
73
+ export function assetNameExpr(version) {
74
+ return `${PACKAGE_NAME}-${version}-$os-$arch`;
75
+ }
76
+
77
+ /**
78
+ * The URL the release serves a version's assets from.
79
+ *
80
+ * @param {string} version x.y.z
81
+ * @returns {string}
82
+ */
83
+ export function releaseBase(version) {
84
+ return `https://github.com/${REPO_SLUG}/releases/download/v${version}`;
85
+ }
86
+
87
+ /**
88
+ * The URL the release serves one asset from.
89
+ *
90
+ * @param {string} version x.y.z
91
+ * @param {string} osArch one of TARGETS
92
+ * @returns {string}
93
+ */
94
+ export function releaseUrl(version, osArch) {
95
+ return `${releaseBase(version)}/${assetName(version, osArch)}`;
96
+ }
97
+
98
+ /**
99
+ * The download a reader without npm runs from their repository's top level: fetch
100
+ * the asset this machine needs and put it where the locator looks.
101
+ *
102
+ * One line on purpose - it is rendered into a remedy that a reader copies from an
103
+ * error message, and a multi-line script in an error message is not copied. The
104
+ * platform is derived rather than baked in, because the remedy lives in a file
105
+ * that is committed and shared: a URL rendered for the machine that wrote the
106
+ * shim would be a 404 for the colleague who clones it.
107
+ *
108
+ * The transport is TLS from the release, and the version the bytes report is
109
+ * checked against the repository's pin by the dispatcher itself (ADR-098), so
110
+ * the remedy does not also carry a checksum: it is the shortest command that
111
+ * reaches a working state, and the gate re-verifies what it runs.
112
+ *
113
+ * @param {string} version x.y.z
114
+ * @returns {string} POSIX sh, run from the repository's top level
115
+ */
116
+ export function localInstall(version) {
117
+ return [
118
+ `mkdir -p ${LOCAL_BIN_DIR}`,
119
+ ...platformLines(),
120
+ `curl -fsSL "${releaseBase(version)}/${assetNameExpr(version)}" -o ${LOCAL_BIN}`,
121
+ `chmod +x ${LOCAL_BIN}`,
122
+ ].join(' && ');
123
+ }
124
+
125
+ /**
126
+ * The two shell lines that derive this machine's release target at run time.
127
+ *
128
+ * `uname -m` answers `x86_64` on both platforms' Intel builds and `arm64` on
129
+ * Apple Silicon, so one substitution covers the naming gap. Both the remedy and
130
+ * the generated CI step name their asset from these lines: the text is committed
131
+ * and shared, so a URL baked for the machine that wrote it is a 404 for the
132
+ * colleague who clones it, and a workflow expression interpolated into a command
133
+ * substitution is worse - the CI step once rendered `os=$('${{ runner.os }}' …)`,
134
+ * which the runner substitutes to `os=$('Linux' …)` and `set -eu` refuses, so the
135
+ * install failed on every run in the one repository kind this channel exists for.
136
+ * The step is executed, not pattern-matched, in test/binary.test.mjs.
137
+ *
138
+ * @returns {string[]} POSIX sh lines assigning `$os` and `$arch`
139
+ */
140
+ export function platformLines() {
141
+ return [`os=$(uname -s | tr A-Z a-z)`, `arch=$(uname -m | sed "s/x86_64/x64/")`];
142
+ }
@@ -35,14 +35,15 @@ function defaultRegistryPath() {
35
35
  /**
36
36
  * The registry to read when the reader names none, in order (ADR-061 ruling 3):
37
37
  *
38
- * 1. The package root, when it holds one. In this checkout that is how the tool finds its
39
- * own registry; in an installed package `graph` is not in the published `files`, so it
40
- * never does, and the reader falls through to 2 instead of being handed this workspace's
41
- * catalogue.
42
- * 2. A member of the resolved workspace, when exactly one holds one: a tool installed from
43
- * node_modules or a formula resolves its workspace by configuration, and the member that
44
- * holds the registry is usually the tool's own checkout. More than one holding it is
45
- * ambiguity and the reader must pick; none is an honest absence, reported with the
38
+ * 1. The package root, when it holds one. In a source checkout that is how the tool
39
+ * finds its own registry, sitting next to the code that reads it. An installed
40
+ * package holds no registry (ADR-061 ruling 2): the catalogue is workspace data,
41
+ * and an install serving another estate's skills to an adopting repository would
42
+ * be a confident wrong answer. This tier is a source-checkout convenience; when it
43
+ * is empty, resolution falls through to the workspace member or an honest absence.
44
+ * 2. A member of the resolved workspace, when exactly one holds one: reachable in a checkout
45
+ * whose package root holds no registry. More than one holding it is ambiguity and the
46
+ * reader must pick; none is an honest absence, reported with the
46
47
  * workspace that was looked in.
47
48
  *
48
49
  * Every step is file existence plus the configured workspace, so the answer can be
@@ -68,8 +68,9 @@ export async function runGates(event, { root, manifest }) {
68
68
  }
69
69
 
70
70
  // The gates below run from the binary that resolved this shim. For a repository
71
- // without npm that is a global install, whose version nothing else pins: if it is not
72
- // the one this repo adopted, the gates that run here are not the ones CI runs.
71
+ // without npm that is the release binary in `.simt/bin` (ADR-098), whose version
72
+ // nothing else pins: if it is not the one this repo adopted, the gates that run here
73
+ // are not the ones CI runs.
73
74
  // Placed after the empty-list check on purpose - with no gates there is nothing the
74
75
  // version could get wrong, and failing that case would block commits in repos that
75
76
  // deliberately declare none.