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.
- package/CHANGELOG.md +170 -0
- package/README.md +300 -82
- 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 +8 -4
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/render.mjs +87 -21
- package/src/argv.mjs +25 -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 +4 -18
- package/src/bench/tasks.mjs +36 -0
- package/src/bench/variants.mjs +1 -3
- package/src/brief/assemble.mjs +24 -4
- package/src/brief/packet.mjs +4 -1
- 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 +35 -3
- package/src/commands/ci.mjs +1 -1
- package/src/commands/compare.mjs +1 -1
- package/src/commands/distill.mjs +36 -0
- 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 +54 -4
- package/src/commands/hooks.mjs +1 -1
- package/src/commands/index.mjs +29 -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/review.mjs +65 -0
- package/src/commands/rules.mjs +1 -1
- package/src/commands/skills.mjs +3 -34
- 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 +6 -3
- 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 +246 -0
- package/src/distill/engine.mjs +187 -0
- 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 +52 -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/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- 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/compare/row.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The matrix a `simt
|
|
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
|
|
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
|
|
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
|
);
|
package/src/decisions/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The decision index: what `simt
|
|
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
|
+
}
|
package/src/graph/load.mjs
CHANGED
|
@@ -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
|
|
39
|
-
* own registry
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -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
|
|
72
|
-
// the one this repo adopted, the gates that run here
|
|
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.
|