simt-dev 0.2.0 → 0.2.2
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 +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/commands/init.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 2. scan for conflicts does it contradict a ruling
|
|
12
12
|
// 3. block on conflicts unless --accept-conflicts
|
|
13
13
|
// 4. plan everything manifest + region, dry
|
|
14
|
-
// 5. branch and tag
|
|
14
|
+
// 5. branch and tag an opt-in escape hatch (--backup), before the first write
|
|
15
15
|
// 6. write only with --yes
|
|
16
16
|
// Never commits, never pushes (ADR-001).
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ import { promisify } from 'node:util';
|
|
|
20
20
|
import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { dirname, join } from 'node:path';
|
|
22
22
|
|
|
23
|
-
import { EXIT, emit, fail, isJson, note } from '../output/index.mjs';
|
|
23
|
+
import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
|
|
24
24
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
25
25
|
import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
|
|
26
26
|
import { MANIFEST, buildManifest, manifestPath, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
|
|
@@ -44,6 +44,7 @@ import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../pr
|
|
|
44
44
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
45
45
|
import { CLI } from '../name.mjs';
|
|
46
46
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
47
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
47
48
|
|
|
48
49
|
const run = promisify(execFile);
|
|
49
50
|
|
|
@@ -59,6 +60,11 @@ export async function initCommand(positional, flags) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const sha = await provenanceDigest();
|
|
63
|
+
// The release this adoption records, and the kind of repository it is adopting: the
|
|
64
|
+
// version goes into the manifest pin and the rendered gate, and the kind decides how
|
|
65
|
+
// a repository without node_modules obtains the binary in CI.
|
|
66
|
+
const version = packageVersion();
|
|
67
|
+
const npm = await hasNpm(root);
|
|
62
68
|
const existing = await readManifest(root);
|
|
63
69
|
const { manifest: current, exists, error } = existing;
|
|
64
70
|
if (error) {
|
|
@@ -70,8 +76,12 @@ export async function initCommand(positional, flags) {
|
|
|
70
76
|
// a repository declared five and could run two, which would have blocked every
|
|
71
77
|
// commit once hooks were installed.
|
|
72
78
|
const capability = await readCapability(root);
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// `facts` carries the ecosystem rows (has-cargo-toml, ...) — the same signal set that
|
|
80
|
+
// selects this repo's profiles, so one marker file reaches both the region the reader
|
|
81
|
+
// gets and the gates its commits run. Without it the ecosystem rows never fire here
|
|
82
|
+
// and a fresh Rust repo is adopted with gates it cannot run.
|
|
83
|
+
const { dropped, commands } = planGates(detected.stack, capability, detected.facts);
|
|
84
|
+
const manifest = buildManifest({ root, stack: detected.stack, sha, version, existing: current, capability, facts: detected.facts });
|
|
75
85
|
const findings = await scanRepo(root);
|
|
76
86
|
const summary = summarise(findings);
|
|
77
87
|
const projected = await plan(root, flags);
|
|
@@ -141,6 +151,12 @@ export async function initCommand(positional, flags) {
|
|
|
141
151
|
// adoption: an existing manifest keeps its gates on purpose, so its drops would
|
|
142
152
|
// describe proposals that were never made.
|
|
143
153
|
droppedGates: exists ? [] : dropped,
|
|
154
|
+
// Commands a FRESH manifest declares under gates.commands. The plan must show them:
|
|
155
|
+
// a command that lands in the manifest without appearing here is a silent write,
|
|
156
|
+
// and the reader would approve it by reading the diff afterwards — the review step
|
|
157
|
+
// this tool exists to make unnecessary. On an existing manifest nothing is seeded
|
|
158
|
+
// (the repo's declaration owns that field), so the empty list is deliberate.
|
|
159
|
+
seededCommands: exists ? [] : Object.entries(commands).map(([gate, command]) => ({ gate, command })),
|
|
144
160
|
surfaces: projected.surfaces,
|
|
145
161
|
surfaceSource: projected.surfaceSource,
|
|
146
162
|
pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
|
|
@@ -193,6 +209,24 @@ export async function initCommand(positional, flags) {
|
|
|
193
209
|
git: null,
|
|
194
210
|
};
|
|
195
211
|
|
|
212
|
+
// A surface this build does not know cannot be written to. Checked before the rule
|
|
213
|
+
// branch, and named for what it is: `init --surfaces` accepts a list, a reader
|
|
214
|
+
// mistypes one, and the misspelling used to be reported as an unprojectable rule with
|
|
215
|
+
// a remedy about substitution keys — the one problem on this path that had no
|
|
216
|
+
// substitution keys anywhere near it.
|
|
217
|
+
const surfaceProblems = projected.surfaceProblems ?? [];
|
|
218
|
+
if (surfaceProblems.length > 0) {
|
|
219
|
+
payload.ok = false;
|
|
220
|
+
payload.blockedBy = 'surface-problem';
|
|
221
|
+
for (const p of surfaceProblems) note(`PROBLEM ${p}`);
|
|
222
|
+
if (isJson(flags)) emitJson(payload);
|
|
223
|
+
return fail(
|
|
224
|
+
`${surfaceProblems.length} surface(s) cannot be resolved (${surfaceProblems[0]})`,
|
|
225
|
+
EXIT.FAIL,
|
|
226
|
+
`pass a known surface id to --surfaces, or omit it to take the default`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
196
230
|
// A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
|
|
197
231
|
// without it would leave the family half-retired, and the user would have no way
|
|
198
232
|
// to tell which half from the diff.
|
|
@@ -200,7 +234,7 @@ export async function initCommand(positional, flags) {
|
|
|
200
234
|
payload.ok = false;
|
|
201
235
|
payload.blockedBy = 'mdc-problem';
|
|
202
236
|
for (const p of mdcProblems) note(`PROBLEM ${p}`);
|
|
203
|
-
if (isJson(flags))
|
|
237
|
+
if (isJson(flags)) emitJson(payload);
|
|
204
238
|
return fail(
|
|
205
239
|
`${mdcProblems.length} canonical .mdc rule(s) cannot be projected (${mdcProblems[0]})`,
|
|
206
240
|
EXIT.FAIL,
|
|
@@ -215,9 +249,9 @@ export async function initCommand(positional, flags) {
|
|
|
215
249
|
payload.ok = false;
|
|
216
250
|
payload.blockedBy = 'conflict';
|
|
217
251
|
if (isJson(flags)) {
|
|
218
|
-
|
|
252
|
+
emitJson(payload);
|
|
219
253
|
} else {
|
|
220
|
-
emit(render(payload, manifest));
|
|
254
|
+
emit(render(payload, manifest, { npm, version }));
|
|
221
255
|
note('');
|
|
222
256
|
note(
|
|
223
257
|
`blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
|
|
@@ -237,8 +271,8 @@ export async function initCommand(positional, flags) {
|
|
|
237
271
|
if (malformed.length > 0) {
|
|
238
272
|
payload.ok = false;
|
|
239
273
|
payload.blockedBy = 'malformed-region';
|
|
240
|
-
if (isJson(flags))
|
|
241
|
-
else emit(render(payload, manifest));
|
|
274
|
+
if (isJson(flags)) emitJson(payload);
|
|
275
|
+
else emit(render(payload, manifest, { npm, version }));
|
|
242
276
|
return fail(
|
|
243
277
|
`${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
|
|
244
278
|
EXIT.FAIL,
|
|
@@ -248,9 +282,9 @@ export async function initCommand(positional, flags) {
|
|
|
248
282
|
|
|
249
283
|
// Step 4/5: report everything, then make changes only when told to.
|
|
250
284
|
if (isJson(flags)) {
|
|
251
|
-
if (!apply)
|
|
285
|
+
if (!apply) emitJson(payload);
|
|
252
286
|
} else {
|
|
253
|
-
emit(render(payload, manifest));
|
|
287
|
+
emit(render(payload, manifest, { npm, version }));
|
|
254
288
|
}
|
|
255
289
|
if (!apply) {
|
|
256
290
|
note('');
|
|
@@ -262,9 +296,40 @@ export async function initCommand(positional, flags) {
|
|
|
262
296
|
return EXIT.OK;
|
|
263
297
|
}
|
|
264
298
|
|
|
265
|
-
|
|
299
|
+
// The escape hatch is opt-in (ADR-063): plan 0002 §8.2 item 3 said "always", and the
|
|
300
|
+
// tool complied, but consumers kept reporting the branch switch as a surprise. The
|
|
301
|
+
// default now writes in place on the branch the reader already has — `git diff` still
|
|
302
|
+
// shows the full adoption, and nothing is tagged or branched. `--backup` restores the
|
|
303
|
+
// tag and the branch for readers who want the named undo path.
|
|
304
|
+
const git = flags.backup
|
|
305
|
+
? await establishEscapeHatch(root)
|
|
306
|
+
: { branch: null, tag: null, from: null, notes: [] };
|
|
266
307
|
payload.git = git;
|
|
267
308
|
for (const message of git.notes) note(`note ${message}`);
|
|
309
|
+
if (!flags.backup) {
|
|
310
|
+
note(
|
|
311
|
+
'in place no backup tag or adopt branch created; the adoption is written on your current branch - git diff shows it and git checkout -- . discards it; re-run with --backup for the tag and the branch',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
// The operations that succeeded, not just the ones that failed.
|
|
315
|
+
//
|
|
316
|
+
// With --backup, `init` moves the reader onto a different branch and writes a tag
|
|
317
|
+
// into their repository. Both were once reported only under `--json`, so the run —
|
|
318
|
+
// the one almost everybody reads — did not say that the branch under them had
|
|
319
|
+
// changed. A reader who ran the documented escape hatch and then committed would be
|
|
320
|
+
// committing to a branch they had not chosen, and nothing in the output told them
|
|
321
|
+
// that had happened.
|
|
322
|
+
//
|
|
323
|
+
// The plan's heading is "Nothing is silent", so whatever the run does to git state —
|
|
324
|
+
// or deliberately does not — the output names it and the command that undoes it.
|
|
325
|
+
// `notes` carried the failures because a failure is the kind of thing an operator
|
|
326
|
+
// notices; a success is not.
|
|
327
|
+
if (git.tag !== null) {
|
|
328
|
+
note(`tagged ${git.tag} at the pre-adoption commit; \`git reset --hard ${git.tag}\` returns this repository to what it was`);
|
|
329
|
+
}
|
|
330
|
+
if (git.branch !== null && git.from !== null) {
|
|
331
|
+
note(`branch switched to ${git.branch}; ${git.from} is untouched and \`git checkout ${git.from}\` returns to it`);
|
|
332
|
+
}
|
|
268
333
|
|
|
269
334
|
await writeManifest(root, manifest);
|
|
270
335
|
note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
|
|
@@ -304,7 +369,7 @@ export async function initCommand(positional, flags) {
|
|
|
304
369
|
// guaranteed to be misled.
|
|
305
370
|
if (payload.ci.action === 'create') {
|
|
306
371
|
await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
|
|
307
|
-
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own }));
|
|
372
|
+
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
|
|
308
373
|
note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
|
|
309
374
|
}
|
|
310
375
|
if (payload.dependabot.action === 'create') {
|
|
@@ -378,7 +443,7 @@ export async function initCommand(positional, flags) {
|
|
|
378
443
|
}
|
|
379
444
|
payload.written = true;
|
|
380
445
|
|
|
381
|
-
if (isJson(flags))
|
|
446
|
+
if (isJson(flags)) emitJson(payload);
|
|
382
447
|
|
|
383
448
|
note('');
|
|
384
449
|
note(`next ${CLI} sync --yes, then ${CLI} check in CI. Commit when you are happy with the diff.`);
|
|
@@ -423,13 +488,14 @@ async function scanRepo(root) {
|
|
|
423
488
|
}
|
|
424
489
|
|
|
425
490
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
491
|
+
* The opt-in escape hatch before the first write, taken when --backup is passed
|
|
492
|
+
* (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
|
|
493
|
+
* are best-effort: a repo that is not a git repo, or has no commits yet, must still be
|
|
428
494
|
* adoptable. Failure is reported as a note rather than blocking, because refusing
|
|
429
495
|
* to initialise an unborn repo would be worse than proceeding without a backup.
|
|
430
496
|
*/
|
|
431
497
|
async function establishEscapeHatch(root) {
|
|
432
|
-
const result = { branch: null, tag: null, notes: [] };
|
|
498
|
+
const result = { branch: null, tag: null, from: null, notes: [] };
|
|
433
499
|
const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
434
500
|
if (!isRepo.ok) {
|
|
435
501
|
result.notes.push('not a git work tree; no backup branch or tag created');
|
|
@@ -457,6 +523,10 @@ async function establishEscapeHatch(root) {
|
|
|
457
523
|
|
|
458
524
|
const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
459
525
|
if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
|
|
526
|
+
// Recorded rather than re-read by the caller: only this function knows what the
|
|
527
|
+
// branch was before it moved it, and a second `rev-parse` would answer with the new
|
|
528
|
+
// value and make every "you were on X" line read as "you were on Y".
|
|
529
|
+
result.from = current.stdout.trim();
|
|
460
530
|
const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
|
|
461
531
|
if (branch.ok) result.branch = ADOPT_BRANCH;
|
|
462
532
|
else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
|
|
@@ -477,7 +547,7 @@ async function git(cwd, args) {
|
|
|
477
547
|
}
|
|
478
548
|
|
|
479
549
|
/** Every line is a decision with a reason. Nothing is silent. */
|
|
480
|
-
function render(payload, manifest) {
|
|
550
|
+
function render(payload, manifest, { npm = true, version } = {}) {
|
|
481
551
|
const lines = [
|
|
482
552
|
`repo ${payload.root}`,
|
|
483
553
|
`stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
|
|
@@ -518,6 +588,12 @@ function render(payload, manifest) {
|
|
|
518
588
|
lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
|
|
519
589
|
lines.push(` remedy: ${d.remedy}`);
|
|
520
590
|
}
|
|
591
|
+
// A seed is a write, and the same rule as for dropped gates applies: the reader must
|
|
592
|
+
// see the line or they approve it from the diff afterwards. Naming the manifest field
|
|
593
|
+
// keeps the line actionable — this is where the reader edits it if the default is wrong.
|
|
594
|
+
for (const s of payload.seededCommands ?? []) {
|
|
595
|
+
lines.push(` ${'seeded'.padEnd(9)} gates.commands["${s.gate}"] = ${s.command}`);
|
|
596
|
+
}
|
|
521
597
|
lines.push(
|
|
522
598
|
` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
|
|
523
599
|
`repo-local profiles are selected by the same matcher (ADR-013)`,
|
|
@@ -532,8 +608,12 @@ function render(payload, manifest) {
|
|
|
532
608
|
// on it would understate the edit the reader is approving. A baseline is the
|
|
533
609
|
// opposite case to AGENTS.md in the one way that matters here — it is placed FIRST,
|
|
534
610
|
// so the repo's own settings below it keep winning (ADR-025).
|
|
611
|
+
// A nested `apps/web/AGENTS.md` is the same kind of target as the root's, so the
|
|
612
|
+
// test is the suffix and not the exact path: a monorepo's package region holds
|
|
613
|
+
// hand-written content too, and describing it as a generated rule file would be
|
|
614
|
+
// the wrong contract for a reader who has prose above the markers.
|
|
535
615
|
const caption =
|
|
536
|
-
target.file
|
|
616
|
+
target.file.endsWith(PRIMARY_TARGET)
|
|
537
617
|
? 'hand-written content outside the markers is never modified'
|
|
538
618
|
: target.kind === 'baseline'
|
|
539
619
|
? 'a region placed first, so your own settings below it still win (ADR-025)'
|
|
@@ -560,7 +640,7 @@ function render(payload, manifest) {
|
|
|
560
640
|
// a reader can tell "this repo does not use GitHub Actions" from "this repo was
|
|
561
641
|
// not offered the gate".
|
|
562
642
|
if (payload.ci.action === 'create') {
|
|
563
|
-
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own }).length} bytes`);
|
|
643
|
+
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own, npm, version }).length} bytes`);
|
|
564
644
|
lines.push(
|
|
565
645
|
payload.ci.own
|
|
566
646
|
? ` this repository is simt-dev's own; ${CLI} ci --yes refreshes this copy when the gate changes`
|
package/src/commands/mcp.mjs
CHANGED
|
@@ -13,9 +13,9 @@ import { readFile } from 'node:fs/promises';
|
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
|
|
16
|
-
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
16
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
17
17
|
import { TOOLS } from '../mcp/tools.mjs';
|
|
18
|
-
import { PROTOCOL_VERSION, serve } from '../mcp/server.mjs';
|
|
18
|
+
import { PROTOCOL_VERSION, SUPPORTED_VERSIONS, serve } from '../mcp/server.mjs';
|
|
19
19
|
import { CLI } from '../name.mjs';
|
|
20
20
|
|
|
21
21
|
export async function mcpCommand(positional, flags) {
|
|
@@ -23,12 +23,27 @@ export async function mcpCommand(positional, flags) {
|
|
|
23
23
|
|
|
24
24
|
if (flags.list === true) {
|
|
25
25
|
if (isJson(flags)) {
|
|
26
|
-
|
|
26
|
+
emitJson({
|
|
27
|
+
ok: true,
|
|
28
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
29
|
+
protocolVersions: SUPPORTED_VERSIONS,
|
|
30
|
+
version,
|
|
31
|
+
// The output schema and the read-only annotation travel with the tool here too:
|
|
32
|
+
// a reader listing the surface should see the same contract a call returns,
|
|
33
|
+
// rather than discovering the shape by making one.
|
|
34
|
+
tools: TOOLS.map(({ name, description, inputSchema, outputSchema, annotations }) => ({
|
|
35
|
+
name,
|
|
36
|
+
description,
|
|
37
|
+
inputSchema,
|
|
38
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
39
|
+
annotations,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
27
42
|
return EXIT.OK;
|
|
28
43
|
}
|
|
29
44
|
emit(
|
|
30
45
|
[
|
|
31
|
-
`simt-dev ${version} - MCP surface over stdio (
|
|
46
|
+
`simt-dev ${version} - MCP surface over stdio (protocols ${SUPPORTED_VERSIONS.join(', ')})`,
|
|
32
47
|
'',
|
|
33
48
|
`${TOOLS.length} read-only tools:`,
|
|
34
49
|
...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
|
|
@@ -44,7 +59,7 @@ export async function mcpCommand(positional, flags) {
|
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
try {
|
|
47
|
-
return await serve(TOOLS, { version });
|
|
62
|
+
return await serve(TOOLS, { packageVersion: version });
|
|
48
63
|
} catch (error) {
|
|
49
64
|
return fail(`mcp transport failed: ${error.message}`, EXIT.FAIL, `run: ${CLI} mcp --list to check the surface loads`);
|
|
50
65
|
}
|
package/src/commands/rules.mjs
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// one, because "this rule has no decision behind it" is exactly the fact a
|
|
15
15
|
// reviewer needs and exactly the fact an empty output would hide.
|
|
16
16
|
|
|
17
|
-
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
17
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
18
18
|
import { loadRules, select } from '../projection/project.mjs';
|
|
19
|
+
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
19
20
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
20
21
|
import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
|
|
21
22
|
import { nearMatches } from '../resolve/near.mjs';
|
|
@@ -23,9 +24,18 @@ import { CLI } from '../name.mjs';
|
|
|
23
24
|
|
|
24
25
|
const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `simt-applies` is the string "always" for core and an array for profiles.
|
|
29
|
+
*
|
|
30
|
+
* An opt-in rule has an EMPTY array, and `-` is a true reading of that which answers
|
|
31
|
+
* none of the questions a reader has. `simt-opt-in` is why it is empty — detection cannot
|
|
32
|
+
* distinguish a repository with the problem from one without it, so the repository
|
|
33
|
+
* declares it — and that is the fact worth printing. It only became visible when the
|
|
34
|
+
* per-surface rules entered this catalogue; before that the rule was not listed at all.
|
|
35
|
+
*/
|
|
27
36
|
function appliesLabel(rule) {
|
|
28
37
|
if (rule.applies === 'always') return 'always';
|
|
38
|
+
if (rule.optIn === true) return 'opt-in (the repo declares it)';
|
|
29
39
|
if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
|
|
30
40
|
return '-';
|
|
31
41
|
}
|
|
@@ -38,7 +48,37 @@ function sorted(rules) {
|
|
|
38
48
|
async function load(root) {
|
|
39
49
|
const rules = await loadRules(root ?? null);
|
|
40
50
|
const broken = rules.filter((r) => r.error);
|
|
41
|
-
return { rules, broken };
|
|
51
|
+
return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `.mdc` rules, in the shape this file's three commands print.
|
|
56
|
+
*
|
|
57
|
+
* `loadRules` reads `rules/*.md` and `rules/stacks/*.md`, which is every rule that is a
|
|
58
|
+
* *stack profile* — and the four per-surface rules are not that. They are projected by
|
|
59
|
+
* `ruleBodies` inside `desiredRegion` rather than by `select`, so they were invisible to
|
|
60
|
+
* `rules list`, `rules show` and `rules why`: a reader could be told by an agent to run
|
|
61
|
+
* `simt rules why test-gate` and be met with "no rule with that id", for a rule that was
|
|
62
|
+
* in their own `AGENTS.md` at the time.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT added to `loadRules`. That function's result is `select`ed and
|
|
65
|
+
* `render`ed into the region, so putting these there would project every one of them
|
|
66
|
+
* twice — once as a profile section and once as `## Process: <id>` — which is the
|
|
67
|
+
* duplication ADR-046 exists to prevent. The catalogue is the right layer for this: it is
|
|
68
|
+
* a view, and a view may be wider than what one projection writes.
|
|
69
|
+
*/
|
|
70
|
+
async function loadPerSurfaceRules() {
|
|
71
|
+
const mdc = await loadMdcRules();
|
|
72
|
+
return mdc.map((r) => ({
|
|
73
|
+
...r,
|
|
74
|
+
// These have no `simt-title`; their `description` is what a reader sees in the
|
|
75
|
+
// generated file, so it is the title here rather than a column of blanks.
|
|
76
|
+
title: r.title ?? r.description ?? null,
|
|
77
|
+
// `mdc` rather than `central`: the column exists so a reader can tell where a rule
|
|
78
|
+
// came from, and "central" is true of all of them and useless for this distinction.
|
|
79
|
+
origin: 'per-surface',
|
|
80
|
+
fallback: false,
|
|
81
|
+
}));
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
// --- list ---------------------------------------------------------------------
|
|
@@ -52,30 +92,25 @@ export async function listCommand(args, flags) {
|
|
|
52
92
|
const selection = root ? await selectionFor(rules, root) : null;
|
|
53
93
|
|
|
54
94
|
if (isJson(flags)) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
null,
|
|
76
|
-
2,
|
|
77
|
-
)}\n`,
|
|
78
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
95
|
+
emitJson(
|
|
96
|
+
{
|
|
97
|
+
ok: broken.length === 0,
|
|
98
|
+
command: 'rules list',
|
|
99
|
+
root,
|
|
100
|
+
rules: sorted(rules).map((r) => ({
|
|
101
|
+
id: r.id,
|
|
102
|
+
title: r.title,
|
|
103
|
+
origin: r.origin,
|
|
104
|
+
source: r.source,
|
|
105
|
+
applies: r.applies,
|
|
106
|
+
fallback: r.fallback,
|
|
107
|
+
bytes: r.bytes,
|
|
108
|
+
error: r.error,
|
|
109
|
+
adrs: citations(r.body),
|
|
110
|
+
selected: selection ? selection.selected.has(r.id) : null,
|
|
111
|
+
})),
|
|
112
|
+
notes: selection?.notes ?? [],
|
|
113
|
+
},
|
|
79
114
|
);
|
|
80
115
|
return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
81
116
|
}
|
|
@@ -148,7 +183,7 @@ export async function showCommand(args, flags) {
|
|
|
148
183
|
|
|
149
184
|
const adrs = citations(rule.body);
|
|
150
185
|
if (isJson(flags)) {
|
|
151
|
-
|
|
186
|
+
emitJson({ ok: !rule.error, ...rule, adrs }, { maxBytes: flags.maxBytes ?? 0 });
|
|
152
187
|
return rule.error ? EXIT.FAIL : EXIT.OK;
|
|
153
188
|
}
|
|
154
189
|
|
|
@@ -182,7 +217,7 @@ export async function whyCommand(args, flags) {
|
|
|
182
217
|
if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
|
|
183
218
|
|
|
184
219
|
const { rules, broken } = await load(flags.root ?? null);
|
|
185
|
-
const { ok, decisions, errors } = await loadDecisions();
|
|
220
|
+
const { ok, decisions, errors } = await loadDecisions({ all: flags.all === true });
|
|
186
221
|
if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
|
|
187
222
|
|
|
188
223
|
return what.match(/^ADR-\d{3}$/i)
|
|
@@ -198,21 +233,16 @@ async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
|
198
233
|
const index = byId(decisions);
|
|
199
234
|
|
|
200
235
|
if (isJson(flags)) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
},
|
|
212
|
-
null,
|
|
213
|
-
2,
|
|
214
|
-
)}\n`,
|
|
215
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
236
|
+
emitJson(
|
|
237
|
+
{
|
|
238
|
+
ok: true,
|
|
239
|
+
command: 'rules why',
|
|
240
|
+
rule: rule.id,
|
|
241
|
+
source: rule.source,
|
|
242
|
+
origin: rule.origin,
|
|
243
|
+
adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
|
|
244
|
+
uncited: cited.length === 0,
|
|
245
|
+
},
|
|
216
246
|
);
|
|
217
247
|
return EXIT.OK;
|
|
218
248
|
}
|
|
@@ -273,22 +303,17 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
273
303
|
// effective ruling is carried alongside rather than instead: the record asked about
|
|
274
304
|
// is still named, and what is in force is named separately.
|
|
275
305
|
const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
},
|
|
288
|
-
null,
|
|
289
|
-
2,
|
|
290
|
-
)}\n`,
|
|
291
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
306
|
+
emitJson(
|
|
307
|
+
{
|
|
308
|
+
ok: true,
|
|
309
|
+
command: 'rules why',
|
|
310
|
+
adr: d,
|
|
311
|
+
supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
|
|
312
|
+
inForce: replacement
|
|
313
|
+
? { id: replacement.id, status: replacement.status, summary: replacement.summary }
|
|
314
|
+
: { id: d.id, status: d.status, summary: d.summary },
|
|
315
|
+
rules: restates.map((r) => r.id),
|
|
316
|
+
},
|
|
292
317
|
);
|
|
293
318
|
return EXIT.OK;
|
|
294
319
|
}
|
|
@@ -342,6 +367,17 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
342
367
|
if (effective.summary.ruled.length > 0) {
|
|
343
368
|
lines.push('', replacement ? 'ruled now' : 'ruled');
|
|
344
369
|
for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
|
|
370
|
+
// The count, because the excerpt above is two rulings and a record may carry eight.
|
|
371
|
+
// Without it the header reads as a claim about everything the record settled, which
|
|
372
|
+
// is how a reader goes looking for a ruling that was never printed and concludes it
|
|
373
|
+
// does not exist.
|
|
374
|
+
const shown = effective.summary.ruled.length;
|
|
375
|
+
const total = effective.summary.rulingCount ?? shown;
|
|
376
|
+
if (total > shown) {
|
|
377
|
+
lines.push(
|
|
378
|
+
` (${shown} of ${total} rulings shown; the rest: ${CLI} rules why ${effective.id} --all)`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
345
381
|
}
|
|
346
382
|
if (effective.summary.rejected.length > 0) {
|
|
347
383
|
// The rejected alternatives are the part a reader cannot reconstruct. A ruling is
|