simt-dev 0.4.0 → 0.5.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.
@@ -15,7 +15,7 @@ import { existsSync } from 'node:fs';
15
15
 
16
16
  import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
17
17
  import { loadRegistry } from '../graph/load.mjs';
18
- import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
18
+ import { bodyFile, sourcePath } from '../resolve/body.mjs';
19
19
  import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
20
20
  import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
21
21
  import { fingerprint } from '../resolve/fingerprint.mjs';
@@ -656,36 +656,6 @@ export async function evalCommand(positional, flags) {
656
656
 
657
657
  // --- bodies -------------------------------------------------------------------
658
658
 
659
- /**
660
- * Resolve a skill's body to an absolute path.
661
- *
662
- * Sources are declared as `repo/path`, so finding one needs the workspace that
663
- * CONTAINS the repos. That used to be `resolve(here, '..', '..', '..')`, which is
664
- * right in the checkout and wrong in `node_modules` - so every source resolved to a
665
- * path that could not exist and `skills` failed with 38 ENOENT lines. Now it comes
666
- * from configuration, and `null` is a legitimate answer meaning "this installation
667
- * has no workspace" (ADR-014).
668
- *
669
- * A second location exists and is not a fallback: the repo being read is itself a
670
- * location for a declared source, matched on bytes rather than on hope (ADR-015).
671
- * The canonical source is still tried first, so a workspace body always wins.
672
- */
673
- async function sourcePath(skill, workspaceRoot, root) {
674
- const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
675
-
676
- if (canonical) {
677
- const found = await locateSource(canonical, { workspaceRoot, root });
678
- if (found.path) return found.path;
679
- }
680
-
681
- const held = await sourceHeldLocally(skill, root, { workspaceRoot });
682
- if (held && !held.ambiguous) return held.path;
683
-
684
- // A skill with no canonical source, or one whose bytes match nothing, has no
685
- // body. Say so rather than printing an empty section.
686
- return canonical && workspaceRoot ? join(workspaceRoot, canonical.repo, canonical.path) : null;
687
- }
688
-
689
659
  /** A one-line pointer. The reasons stay, so a brief that cannot inline still says why. */
690
660
  function pointerLine(entry) {
691
661
  const why = [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)];
@@ -705,8 +675,7 @@ export async function readBody(skill, workspaceRoot, root) {
705
675
  }
706
676
  if (!existsSync(full)) return `(body unavailable: ${full} is not on this machine)`;
707
677
  try {
708
- const target = existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
709
- return (await readFile(target, 'utf8')).trimEnd();
678
+ return (await readFile(bodyFile(full), 'utf8')).trimEnd();
710
679
  } catch (error) {
711
680
  return `(body unreadable: ${error.message})`;
712
681
  }
@@ -63,7 +63,9 @@ function reportUnresolved(root, flags, unresolved) {
63
63
  return EXIT.OK;
64
64
  }
65
65
 
66
- async function tracked(root) {
66
+ // `async function tracked(root)` (read by `simt why`) becomes `export async`:
67
+ // the critic's brief resolves governance the same way without a second copy.
68
+ export async function tracked(root) {
67
69
  try {
68
70
  const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
69
71
  return { files: stdout.split('\n').filter(Boolean) };
@@ -98,7 +100,8 @@ export async function decisionRecords(root) {
98
100
  return records;
99
101
  }
100
102
 
101
- async function loadCorpus(root) {
103
+ // `async function loadCorpus(root)` (read by `simt why`) becomes `export async`.
104
+ export async function loadCorpus(root) {
102
105
  const [rules, skills, lessons, commands] = await Promise.all([
103
106
  ruleScopes(root),
104
107
  skillScopes(root),
@@ -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-dev/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-dev/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
+ }
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Non-loopback URLs are refused unless `--allow-remote` is explicitly passed (ADR-075).
12
12
 
13
+ import { EXIT, fail } from '../output/index.mjs';
13
14
  import { MANIFEST_FILE } from '../name.mjs';
14
15
  import { readManifest } from '../projection/manifest.mjs';
15
16
 
@@ -232,3 +233,37 @@ export async function readLocalConfig(root, { allowRemote = false } = {}) {
232
233
  manifestError: null,
233
234
  };
234
235
  }
236
+
237
+ /**
238
+ * The guard every model consumer shares (R37's distill, R39's critic and bench).
239
+ *
240
+ * A `--local`-shaped call without a usable chat endpoint is a configuration
241
+ * error, never a silent fallback to the deterministic path: a caller who asked
242
+ * for the model and got the deterministic answer would read that answer as the
243
+ * model's — and the reverse, reading model output as deterministic, is worse.
244
+ * Second real user arrived with the critic, so the guard moved here from the
245
+ * distiller's `runEngine` (ADR-002).
246
+ *
247
+ * Returns `{ chat }` on success or `{ exit }` with the failure already shaped
248
+ * (the same pass-through shape the command layer returns).
249
+ */
250
+ export function chatEndpoint(config) {
251
+ if (config.manifestError !== null) {
252
+ return { exit: fail(`${MANIFEST_FILE}: ${config.manifestError}`, EXIT.CONFIG) };
253
+ }
254
+ if (!config.configured || !config.chat || config.findings.length > 0) {
255
+ const detail = !config.configured
256
+ ? 'no local model is configured'
257
+ : config.remoteBlocked
258
+ ? 'the configured chat endpoint is non-loopback and was refused'
259
+ : 'the local configuration has errors';
260
+ return {
261
+ exit: fail(
262
+ `--local needs a usable local chat endpoint: ${detail}`,
263
+ EXIT.CONFIG,
264
+ `configure local.chat in ${MANIFEST_FILE} (plan 0003 R37), or run without --local for the deterministic path`,
265
+ ),
266
+ };
267
+ }
268
+ return { chat: config.chat };
269
+ }
@@ -0,0 +1,46 @@
1
+ // Where a skill's body lives, and how a path the registry names becomes a file.
2
+ //
3
+ // Extracted from src/commands/skills.mjs when the distiller (plan 0003 R38) became
4
+ // the second real caller: two commands that resolve the same body differently would
5
+ // print two answers to one question, and ADR-002 refuses the third copy rather than
6
+ // the second use.
7
+ //
8
+ // Sources are declared as `repo/path`, so finding one needs the workspace that
9
+ // CONTAINS the repos. That used to be `resolve(here, '..', '..', '..')`, which is
10
+ // right in the checkout and wrong in `node_modules` - so every source resolved to a
11
+ // path that could not exist and `skills` failed with 38 ENOENT lines. Now it comes
12
+ // from configuration, and `null` is a legitimate answer meaning "this installation
13
+ // has no workspace" (ADR-014).
14
+ //
15
+ // A second location exists and is not a fallback: the repo being read is itself a
16
+ // location for a declared source, matched on bytes rather than on hope (ADR-015).
17
+ // The canonical source is still tried first, so a workspace body always wins.
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync } from 'node:fs';
21
+
22
+ import { locateSource, sourceHeldLocally } from './source.mjs';
23
+
24
+ export async function sourcePath(skill, workspaceRoot, root) {
25
+ const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
26
+
27
+ if (canonical) {
28
+ const found = await locateSource(canonical, { workspaceRoot, root });
29
+ if (found.path) return found.path;
30
+ }
31
+
32
+ const held = await sourceHeldLocally(skill, root, { workspaceRoot });
33
+ if (held && !held.ambiguous) return held.path;
34
+
35
+ // A skill with no canonical source, or one whose bytes match nothing, has no
36
+ // body. Say so rather than printing an empty section.
37
+ return canonical && workspaceRoot ? join(workspaceRoot, canonical.repo, canonical.path) : null;
38
+ }
39
+
40
+ /**
41
+ * The file a source path points at. A path may be a directory that carries its
42
+ * body as SKILL.md, the Agent Skills layout; SKILL.md wins when both exist.
43
+ */
44
+ export function bodyFile(full) {
45
+ return existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
46
+ }