simt-dev 0.5.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 +122 -0
- package/README.md +78 -59
- 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 +4 -4
- package/src/agenthooks/render.mjs +87 -21
- package/src/argv.mjs +13 -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 +1 -3
- package/src/bench/tasks.mjs +1 -3
- package/src/bench/variants.mjs +1 -3
- package/src/brief/assemble.mjs +2 -4
- 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 +4 -4
- package/src/commands/ci.mjs +1 -1
- package/src/commands/compare.mjs +1 -1
- package/src/commands/distill.mjs +1 -1
- 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 +29 -7
- package/src/commands/hooks.mjs +1 -1
- package/src/commands/index.mjs +17 -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/rules.mjs +1 -1
- package/src/commands/skills.mjs +1 -1
- 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 +1 -1
- 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 +2 -2
- 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 +17 -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/review/critic.mjs +4 -4
- 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/commands/harness.mjs
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// a defect in the repository: the rules in AGENTS.md still reach whichever harnesses
|
|
14
14
|
// read that file, and a reader who wanted a failing command could not tell the
|
|
15
15
|
// difference between "no channel" and "broken".
|
|
16
|
+
//
|
|
17
|
+
// R42 added the MCP registration files to the same answer. They are reported from the
|
|
18
|
+
// loader table (one source of paths, with `verified` naming the vendor page) because a
|
|
19
|
+
// detection question deserves the same provenance rule the loader table set: a row with
|
|
20
|
+
// no source is a guess in a report.
|
|
16
21
|
|
|
17
22
|
import { stat } from 'node:fs/promises';
|
|
18
23
|
import { homedir } from 'node:os';
|
|
@@ -24,7 +29,7 @@ import { LOADERS, LOADER_IDS, caps, loader } from '../projection/loaders.mjs';
|
|
|
24
29
|
import { viewAll } from '../measure/loaderView.mjs';
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
|
-
* `simt
|
|
32
|
+
* `simt harness detect`. The subcommand is validated by dispatch, which rejects a
|
|
28
33
|
* second token that is not in the group registry rather than silently routing to the
|
|
29
34
|
* default; this function therefore has exactly one thing to do and no second spelling
|
|
30
35
|
* to keep in step with it.
|
|
@@ -36,7 +41,12 @@ import { viewAll } from '../measure/loaderView.mjs';
|
|
|
36
41
|
export async function harnessCommand(positional, flags) {
|
|
37
42
|
const root = flags.root ?? process.cwd();
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
// MCP evidence is project-scope by construction: the loader table carries repo-root
|
|
45
|
+
// paths (.mcp.json, .codex/config.toml, …), and the same path means different scopes
|
|
46
|
+
// per harness in $HOME - Codex registers user servers in its user config, Claude Code
|
|
47
|
+
// does not keep .mcp.json there - so a home scan reports directories only, and stays
|
|
48
|
+
// right by saying less rather than by carrying a second per-harness user-scope table.
|
|
49
|
+
const repo = await scan(root, { mcp: true });
|
|
40
50
|
// Only on request. The home directory is the reader's, not the repository's, and a
|
|
41
51
|
// command that reports both by default is a command whose output changes because
|
|
42
52
|
// somebody installed something elsewhere.
|
|
@@ -79,8 +89,13 @@ export async function harnessCommand(positional, flags) {
|
|
|
79
89
|
* rather than raised: a permissions error on one candidate must not make the whole
|
|
80
90
|
* command fail, and the difference between "could not look" and "not there" is one
|
|
81
91
|
* note in the output rather than a second failure mode nobody asked for.
|
|
92
|
+
*
|
|
93
|
+
* `{ mcp: true }` (repo scans only) adds the MCP registration files the loader table
|
|
94
|
+
* records - single-sourced from `loaders.mjs` because that is the whole point of R42:
|
|
95
|
+
* the evidence is data the tool already verified once, not a second list of paths to
|
|
96
|
+
* keep in step by hand. It is file stat and nothing else: present, labelled, cited.
|
|
82
97
|
*/
|
|
83
|
-
async function scan(root) {
|
|
98
|
+
async function scan(root, { mcp = false } = {}) {
|
|
84
99
|
const rows = [];
|
|
85
100
|
for (const s of SURFACES) {
|
|
86
101
|
for (const dir of s.configDirs ?? []) {
|
|
@@ -91,9 +106,10 @@ async function scan(root) {
|
|
|
91
106
|
}
|
|
92
107
|
for (const c of CANDIDATE_DIRS) {
|
|
93
108
|
if (await present(root, c.dir)) {
|
|
94
|
-
rows.push(row(c.dir, c.harness, { readsAgentsMd: c.readsAgentsMd ?? null, verified: c.verified ?? null }));
|
|
109
|
+
rows.push(row(c.dir, c.harness, { readsAgentsMd: c.readsAgentsMd ?? null, readsAgentsMdNote: c.readsAgentsMdNote ?? null, verified: c.verified ?? null }));
|
|
95
110
|
}
|
|
96
111
|
}
|
|
112
|
+
if (mcp) rows.push(...(await scanMcpFiles(root)));
|
|
97
113
|
rows.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
|
|
98
114
|
return { root, rows };
|
|
99
115
|
}
|
|
@@ -106,11 +122,49 @@ async function present(root, dir) {
|
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The MCP registration files the loader table says a project can carry.
|
|
127
|
+
*
|
|
128
|
+
* A row requires the path to be a FILE the loader table names, and it is skipped when
|
|
129
|
+
* the loader carries none (opencode registers servers in a profile key, DeepSeek as a
|
|
130
|
+
* profile plugin). Nothing reads the file: whether the JSON parses or names servers is
|
|
131
|
+
* the harness's problem at session time, and a service that promised to validate that
|
|
132
|
+
* would be a service somebody had to keep current.
|
|
133
|
+
*
|
|
134
|
+
* This is evidence, not a channel: no rule file is projected into an MCP config, so
|
|
135
|
+
* `channel` stays false and `readsAgentsMd` stays null - the fields say what they mean
|
|
136
|
+
* instead of borrowing the meaning the directory rows give them.
|
|
137
|
+
*/
|
|
138
|
+
async function scanMcpFiles(root) {
|
|
139
|
+
const rows = [];
|
|
140
|
+
for (const loader of LOADERS) {
|
|
141
|
+
const mcp = loader.mcp;
|
|
142
|
+
if (mcp?.path == null) continue;
|
|
143
|
+
const file = join(root, mcp.path);
|
|
144
|
+
try {
|
|
145
|
+
if (!(await stat(file)).isFile()) continue;
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
rows.push({
|
|
150
|
+
dir: mcp.path,
|
|
151
|
+
kind: 'mcp',
|
|
152
|
+
label: loader.label,
|
|
153
|
+
surface: loader.id,
|
|
154
|
+
channel: false,
|
|
155
|
+
readsAgentsMd: null,
|
|
156
|
+
verified: mcp.verified ?? loader.verified ?? null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return rows;
|
|
160
|
+
}
|
|
161
|
+
|
|
109
162
|
/** One present directory, and what - if anything - this tool can project into it. */
|
|
110
163
|
function row(dir, label, surface) {
|
|
111
164
|
const id = surface.id ?? null;
|
|
112
165
|
return {
|
|
113
166
|
dir,
|
|
167
|
+
kind: 'rules',
|
|
114
168
|
label,
|
|
115
169
|
surface: id,
|
|
116
170
|
// `channel: false` is the honest half of the answer: the directory exists and this
|
|
@@ -119,14 +173,20 @@ function row(dir, label, surface) {
|
|
|
119
173
|
// identical in a list of names.
|
|
120
174
|
channel: id !== null && surface.rule != null,
|
|
121
175
|
readsAgentsMd: surface.readsAgentsMd ?? null,
|
|
176
|
+
// Which reader ("some") or which configuration ("some, under this setting") - the
|
|
177
|
+
// bare three-valued column started life on rows with many readers, where "which" is
|
|
178
|
+
// a per-row note. A row that lacks one renders without it.
|
|
179
|
+
readsAgentsMdNote: surface.readsAgentsMdNote ?? null,
|
|
122
180
|
verified: surface.verified ?? null,
|
|
123
181
|
};
|
|
124
182
|
}
|
|
125
183
|
|
|
126
184
|
function countUnclaimed(scan) {
|
|
127
185
|
// A candidate that cites a document and reads AGENTS.md is covered by the standard
|
|
128
|
-
// region. One that cites nothing is the case the note exists for (ADR-067).
|
|
129
|
-
|
|
186
|
+
// region. One that cites nothing is the case the note exists for (ADR-067). An MCP
|
|
187
|
+
// config row is not "unclaimed" - it never claimed a rule channel, so it must not
|
|
188
|
+
// count toward the note, or mcp.json presence would read as a rule gap it is not.
|
|
189
|
+
return scan.rows.filter((r) => r.kind === 'rules' && r.surface === null && r.readsAgentsMd == null).length;
|
|
130
190
|
}
|
|
131
191
|
|
|
132
192
|
/**
|
|
@@ -149,15 +209,28 @@ function render(root, repo, user) {
|
|
|
149
209
|
function block(scan, pad) {
|
|
150
210
|
if (scan.rows.length === 0) return [`${pad}(none present)`];
|
|
151
211
|
return scan.rows.map((r) => {
|
|
212
|
+
// An MCP row is judged by presence alone, so it is named first: routed last, its
|
|
213
|
+
// non-null `surface` (the loader it belongs to) would collide with the directory
|
|
214
|
+
// rows' meaning and print "AGENTS.md" under a file that carries no rules at all.
|
|
152
215
|
const through =
|
|
153
|
-
r.
|
|
154
|
-
?
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
:
|
|
160
|
-
|
|
216
|
+
r.kind === 'mcp'
|
|
217
|
+
? 'MCP config present'
|
|
218
|
+
: r.surface === null
|
|
219
|
+
? r.readsAgentsMd === 'all'
|
|
220
|
+
? 'AGENTS.md'
|
|
221
|
+
: 'no rule channel in this release'
|
|
222
|
+
: r.channel
|
|
223
|
+
? 'projected rule file'
|
|
224
|
+
: 'AGENTS.md';
|
|
225
|
+
// "some" never renders bare when the row knows which: a reader who needs the
|
|
226
|
+
// detail already had it only in the JSON, and the detail is the answer to "is my
|
|
227
|
+
// AGENTS.md read" - the question this text is here to answer.
|
|
228
|
+
const agents =
|
|
229
|
+
r.readsAgentsMd === 'all'
|
|
230
|
+
? ' (reads AGENTS.md)'
|
|
231
|
+
: r.readsAgentsMd === 'some'
|
|
232
|
+
? ` (some readers read AGENTS.md${r.readsAgentsMdNote ? `: ${r.readsAgentsMdNote}` : ''})`
|
|
233
|
+
: '';
|
|
161
234
|
return `${pad}${r.dir.padEnd(12)} ${r.label.padEnd(38)} ${through}${agents}`;
|
|
162
235
|
});
|
|
163
236
|
}
|
package/src/commands/help.mjs
CHANGED
|
@@ -57,6 +57,10 @@ ${CLI} agent-hooks install write this repo's policies into the harnesses tha
|
|
|
57
57
|
(dry-run unless --yes; merges into the file, keeps every
|
|
58
58
|
key it does not own; Codex needs the hook trusted)
|
|
59
59
|
${CLI} agent-hooks status what each harness will run, and what is not trusted yet
|
|
60
|
+
${CLI} plugin <claude|gemini|cursor> generate a harness package from the same tables
|
|
61
|
+
(dry-run unless --yes; writes a self-contained runtime, the
|
|
62
|
+
hooks and MCP registration, and for cursor the kernel rules;
|
|
63
|
+
the skill registry is workspace data and is not shipped)
|
|
60
64
|
${CLI} plan status count tracker rows
|
|
61
65
|
(${MANIFEST_FILE} key plans when set, otherwise
|
|
62
66
|
the ${DEFAULT_TEMPLATE} template:
|
|
@@ -92,7 +96,7 @@ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
|
|
|
92
96
|
${CLI} evidence the runs recorded against the tree on disk
|
|
93
97
|
${CLI} evidence check <file> a commit message that claims a run this tree does not
|
|
94
98
|
have: a warning, or a block when gates.evidence is "block"
|
|
95
|
-
${CLI} lessons list deterministic lesson candidates in .simt
|
|
99
|
+
${CLI} lessons list deterministic lesson candidates in .simt/lessons.jsonl
|
|
96
100
|
and promoted lessons in docs/lessons/*.md
|
|
97
101
|
${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
|
|
98
102
|
(dry-run unless --yes; writes Appendix A frontmatter)
|
|
@@ -105,7 +109,7 @@ ${CLI} local doctor probe configured local model endpoints (chat and
|
|
|
105
109
|
unset; refuses non-loopback URLs without --allow-remote)
|
|
106
110
|
${CLI} distill <skill|doc> a budgeted proposal summary of a skill body or doc,
|
|
107
111
|
every claim citing the source line range it rests on
|
|
108
|
-
(writes .simt
|
|
112
|
+
(writes .simt/distilled/<digest>.md; a proposal a
|
|
109
113
|
human merges - never projected or committed;
|
|
110
114
|
deterministic outline by default; --local proposes
|
|
111
115
|
from the configured chat model instead; the cache is
|
|
@@ -115,7 +119,7 @@ ${CLI} review [--local] an ADR-008 verdict skeleton for the staged diff,
|
|
|
115
119
|
(deterministic without --local: the brief and its
|
|
116
120
|
governing ids only; --local proposes from the
|
|
117
121
|
configured chat model and writes
|
|
118
|
-
.simt
|
|
122
|
+
.simt/reviews/<digest>.md — a proposal a human
|
|
119
123
|
reads, verified against the verdict vocabulary and
|
|
120
124
|
the brief's ids; advisory always, never a gate)
|
|
121
125
|
${CLI} shared plan what a workspace's repeated rules cost to carry
|
|
@@ -132,8 +136,9 @@ ${CLI} tokens [--repos] what a reader loads at session start, in bytes
|
|
|
132
136
|
repository under the root and what repeats an earlier)
|
|
133
137
|
${CLI} harness detect which harnesses this repo (and, with --user, you)
|
|
134
138
|
are set up for, and which have a rule channel here
|
|
135
|
-
(presence only: a directory
|
|
136
|
-
what its owner reads;
|
|
139
|
+
(presence only: a directory or an MCP config file
|
|
140
|
+
found is not a claim about what its owner reads;
|
|
141
|
+
exits 0 whatever it finds)
|
|
137
142
|
${CLI} harness loaders what each reader loads at session start, in bytes,
|
|
138
143
|
and what its size cap drops
|
|
139
144
|
(--table prints the vendor facts without measuring;
|
|
@@ -157,10 +162,22 @@ ${CLI} bench variants [--yes] evaluate simt-variants rule slots on the canary s
|
|
|
157
162
|
winning variants in simt-dev.lock.json (dry-run unless --yes;
|
|
158
163
|
re-opens when model or harness changes)
|
|
159
164
|
${CLI} bench critic precision and recall of the local critic over a labelled
|
|
160
|
-
set of historical diffs (.simt
|
|
165
|
+
set of historical diffs (.simt/critic-labels.jsonl;
|
|
161
166
|
overrides --labels; labels are human-written verdicts
|
|
162
167
|
under ADR-008; the critic is advisory until precision
|
|
163
168
|
reaches 0.8 on 30 labelled diffs)
|
|
169
|
+
${CLI} pack new <name> --source <spec>
|
|
170
|
+
scaffold a versioned pack: pack.json, an unmeasured
|
|
171
|
+
bench.json, and one stack rule (dry-run unless --yes;
|
|
172
|
+
refuses a directory that already exists)
|
|
173
|
+
${CLI} pack lint [dir] validate a pack: identity, source, rules, and the
|
|
174
|
+
bench summary (--require-measured rejects a pack with
|
|
175
|
+
no measured ΔR; a pack vendored under .simt/packs/ is
|
|
176
|
+
also checked against the manifest's pin)
|
|
177
|
+
${CLI} pack publish [dir] [--yes]
|
|
178
|
+
publish an npm-sourced pack to the registry
|
|
179
|
+
(dry-run unless --yes; skips a version already live;
|
|
180
|
+
a git-sourced pack names the tag-and-push instead)
|
|
164
181
|
${CLI} mcp the read-only surface as MCP tools over stdio
|
|
165
182
|
(--list prints it without reading stdin; init, sync,
|
|
166
183
|
upgrade and the installers stay CLI-only by design)`;
|
|
@@ -175,6 +192,7 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
175
192
|
--task "<text>" the task text used for capability resolution
|
|
176
193
|
--paths <globs> comma-separated path globs used for path resolution
|
|
177
194
|
--root <path> repository root (default: cwd)
|
|
195
|
+
--out <path> plugin: where the generated package lands (required)
|
|
178
196
|
--surfaces <ids> comma-separated editors to write rule files for;
|
|
179
197
|
"" means AGENTS.md only (default: every surface,
|
|
180
198
|
or the "surfaces" list in simt-dev.json)
|
|
@@ -224,12 +242,16 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
224
242
|
--state <name> plan done: target state (default: Complete)
|
|
225
243
|
--level <name> map: detail level (outline, names, signatures)
|
|
226
244
|
--supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
|
|
245
|
+
--source <spec> pack new: where the pack's bytes come from
|
|
246
|
+
(npm:<package> or git+https://…; required)
|
|
247
|
+
--require-measured pack lint: reject a pack with no measured ΔR
|
|
248
|
+
(a bench.json marked unmeasured, or missing)
|
|
227
249
|
--allow-remote local doctor / distill / review: allow non-loopback model
|
|
228
250
|
URLs (refused by default)
|
|
229
251
|
--embed index / map / brief: compute or query optional local vector embeddings
|
|
230
252
|
(off by default unless recall@10 gains at least 10 points)
|
|
231
253
|
--labels <path> bench critic: the labelled set of historical diffs to score
|
|
232
|
-
(default .simt
|
|
254
|
+
(default .simt/critic-labels.jsonl; one human-written
|
|
233
255
|
{sha, verdict} pair per line)
|
|
234
256
|
--refresh distill / review: regenerate a proposal that is already cached
|
|
235
257
|
--local distill / review: propose the answer from the configured
|
package/src/commands/hooks.mjs
CHANGED
package/src/commands/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Every command named in docs/plans/0002-simt-dev-cli.md section 5 is registered
|
|
4
4
|
// here from day one. A command that is declared but not yet implemented exits
|
|
5
5
|
// UNSUPPORTED with a pointer to its implementation step; it never silently
|
|
6
|
-
// succeeds. An agent that calls `simt
|
|
6
|
+
// succeeds. An agent that calls `simt compare` on an unreleased build must get a
|
|
7
7
|
// failure, not an empty result it will read as "no drift".
|
|
8
8
|
|
|
9
9
|
import {
|
|
@@ -34,6 +34,8 @@ import { agentHookCommand, agentHooksCommand } from './agenthook.mjs';
|
|
|
34
34
|
import { compareCommand } from './compare.mjs';
|
|
35
35
|
import { upgradeCommand } from './upgrade.mjs';
|
|
36
36
|
import { mcpCommand } from './mcp.mjs';
|
|
37
|
+
import { pluginCommand } from './plugin.mjs';
|
|
38
|
+
import { packCommand } from './pack.mjs';
|
|
37
39
|
import { installCommand } from './skillsinstall.mjs';
|
|
38
40
|
import { compileCommand } from './skillscompile.mjs';
|
|
39
41
|
import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
|
|
@@ -91,6 +93,9 @@ const GROUPS = new Map([
|
|
|
91
93
|
['shared', 'plan'],
|
|
92
94
|
['lessons', 'list'],
|
|
93
95
|
['local', 'doctor'],
|
|
96
|
+
// Plan 0003 R45. The default is the read: `pack` alone lints the working
|
|
97
|
+
// directory, the way `skills` alone indexes and `graph` alone lints.
|
|
98
|
+
['pack', 'lint'],
|
|
94
99
|
]);
|
|
95
100
|
|
|
96
101
|
/**
|
|
@@ -169,6 +174,15 @@ export const IMPLEMENTED = {
|
|
|
169
174
|
'bench critic': (args, flags) => benchCriticCommand(args, flags, COMMANDS),
|
|
170
175
|
upgrade: upgradeCommand,
|
|
171
176
|
mcp: mcpCommand,
|
|
177
|
+
// plan 0003 R43. A bare command: the kind is a positional, so there is no group and
|
|
178
|
+
// no default subcommand to resolve.
|
|
179
|
+
plugin: pluginCommand,
|
|
180
|
+
// Plan 0003 R45. A group, not a bare command: `new` and `publish` write (or
|
|
181
|
+
// publish) and `lint` reads, and the dispatcher routes all three through one
|
|
182
|
+
// subcommand switch the way `hooks` does.
|
|
183
|
+
'pack new': (args, flags) => packCommand('new', args, flags),
|
|
184
|
+
'pack lint': (args, flags) => packCommand('lint', args, flags),
|
|
185
|
+
'pack publish': (args, flags) => packCommand('publish', args, flags),
|
|
172
186
|
};
|
|
173
187
|
|
|
174
188
|
/**
|
|
@@ -228,7 +242,7 @@ export async function dispatch(argv) {
|
|
|
228
242
|
/**
|
|
229
243
|
* Normalise argv into a registry key, or null when unrecognised.
|
|
230
244
|
* A group with an omitted subcommand resolves to that group's default, so
|
|
231
|
-
* `simt
|
|
245
|
+
* `simt skills` is `simt skills index`.
|
|
232
246
|
*/
|
|
233
247
|
function commandPath(first, second) {
|
|
234
248
|
if (first === undefined) return null;
|
|
@@ -237,7 +251,7 @@ function commandPath(first, second) {
|
|
|
237
251
|
const group = GROUPS.get(first);
|
|
238
252
|
if (second === undefined) return `${first} ${group}`;
|
|
239
253
|
// Reject a second token that is not a subcommand of this group, rather than
|
|
240
|
-
// silently routing `simt
|
|
254
|
+
// silently routing `simt skills nonsense` to the default subcommand.
|
|
241
255
|
//
|
|
242
256
|
// Read from IMPLEMENTED rather than from a second list. The list existed because
|
|
243
257
|
// there was no way to enumerate what dispatch could run — `IMPLEMENTED` was an
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// simt
|
|
1
|
+
// simt init - adopt a repo into the ecosystem, or scaffold a new one.
|
|
2
2
|
//
|
|
3
3
|
// The governing idea: init never destroys hand-written work. It writes a manifest
|
|
4
4
|
// and a generated region, reports every conflict it found BEFORE writing anything,
|
|
@@ -647,7 +647,7 @@ function render(payload, manifest, { npm = true, version } = {}) {
|
|
|
647
647
|
: ` runs ${CLI} check where a pull request is merged; yours to edit after this`,
|
|
648
648
|
);
|
|
649
649
|
} else if (payload.ci.action === 'refresh') {
|
|
650
|
-
// init does not write it. `simt
|
|
650
|
+
// init does not write it. `simt ci` owns the template's own copy, and a
|
|
651
651
|
// second writer would be a second answer to who owns it.
|
|
652
652
|
lines.push(` ${'refresh'.padEnd(9)} ${payload.ci.file} ${payload.ci.reason}`);
|
|
653
653
|
lines.push(` remedy: ${CLI} ci --yes (init does not write simt-dev's own copy)`);
|
package/src/commands/local.mjs
CHANGED
|
@@ -30,6 +30,14 @@ function formatRoleLine(role, report) {
|
|
|
30
30
|
return `${role.padEnd(8)} ok ${report.model} @ ${report.url}${extra}${listed}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// `requestJson` shapes a non-OK response as "METHOD url failed (STATUS): message",
|
|
34
|
+
// so the HTTP status is the reliable signal here — 401/403 mean the server is up but
|
|
35
|
+
// rejected the request as unauthorised, which is a different remedy than "start it".
|
|
36
|
+
function isAuthFailure(report) {
|
|
37
|
+
if (!report || typeof report.error !== 'string') return false;
|
|
38
|
+
return /failed \(40[13]\)/.test(report.error);
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function remedyFor(result) {
|
|
34
42
|
if (result.status === 'refused-remote') {
|
|
35
43
|
return `set a loopback URL (http://127.0.0.1:<port>/v1) in ${MANIFEST_FILE}, or run: ${CLI} local doctor --allow-remote`;
|
|
@@ -38,6 +46,9 @@ function remedyFor(result) {
|
|
|
38
46
|
return `fix the \`local\` configuration in ${MANIFEST_FILE}`;
|
|
39
47
|
}
|
|
40
48
|
if (result.status === 'unreachable') {
|
|
49
|
+
if ([result.chat, result.embed].some(isAuthFailure)) {
|
|
50
|
+
return `the server is up but rejected the request as unauthorised (401/403); set \`local.<role>.apiKey\` in ${MANIFEST_FILE} to the server's bearer token, then rerun: ${CLI} local doctor`;
|
|
51
|
+
}
|
|
41
52
|
return `start the local model server at the URL in ${MANIFEST_FILE}, then rerun: ${CLI} local doctor`;
|
|
42
53
|
}
|
|
43
54
|
return null;
|
package/src/commands/mcp.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// simt
|
|
1
|
+
// simt mcp - serve the tool surface over MCP on stdio.
|
|
2
2
|
//
|
|
3
3
|
// Optional by design (plan 0002 section 5): it adds a transport, not a capability, and
|
|
4
4
|
// the CLI stays the primary interface because it works in CI, in a hook and in a bare
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// simt pack new | lint | publish - the author side of a pack.
|
|
2
|
+
//
|
|
3
|
+
// A pack is vendored bytes (ADR-065): the adopting repository carries them and
|
|
4
|
+
// pins a digest, and nothing renders from outside the repository. These three
|
|
5
|
+
// subcommands are how a pack comes to exist, how it is judged, and how its bytes
|
|
6
|
+
// become obtainable - without ever fetching at render time.
|
|
7
|
+
//
|
|
8
|
+
// Like every writer here, `new` and `publish` are previews by default and write
|
|
9
|
+
// (or publish) only with `--yes`. `lint` is a read: it judges and reports, and
|
|
10
|
+
// exits non-zero when the pack fails.
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
16
|
+
import { CLI, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
17
|
+
import { PACK_DIR, packNameProblem } from '../packs.mjs';
|
|
18
|
+
import {
|
|
19
|
+
BENCH_JSON,
|
|
20
|
+
PACK_JSON,
|
|
21
|
+
describeSourceProblem,
|
|
22
|
+
lintPack,
|
|
23
|
+
npmPackageOf,
|
|
24
|
+
publishPack,
|
|
25
|
+
readBench,
|
|
26
|
+
scaffoldPack,
|
|
27
|
+
writeScaffold,
|
|
28
|
+
} from '../packs/manage.mjs';
|
|
29
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
30
|
+
|
|
31
|
+
export async function packCommand(sub, args, flags) {
|
|
32
|
+
if (sub === 'new') return packNew(args, flags);
|
|
33
|
+
if (sub === 'lint') return packLint(args, flags);
|
|
34
|
+
if (sub === 'publish') return packPublish(args, flags);
|
|
35
|
+
return fail(`unknown pack subcommand ${sub ?? '(none)'}`, EXIT.USAGE, `run: ${CLI} pack lint [dir]`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where a lint or publish resolves its pack: the positional directory, the
|
|
40
|
+
* `--root`, or the working directory. `hooks` reads the same three in the same
|
|
41
|
+
* order, so the two commands cannot disagree about which directory was meant.
|
|
42
|
+
*/
|
|
43
|
+
function packDir(args, flags) {
|
|
44
|
+
return resolve(flags.root ?? args[0] ?? process.cwd());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The manifest entry that declares this pack, when the directory is a vendored
|
|
49
|
+
* copy inside an adopting repository. Lint then checks the two promises a
|
|
50
|
+
* declaration makes - version and digest - against the bytes on disk, so a
|
|
51
|
+
* pack that drifted from its pin is reported where it sits, not only in the
|
|
52
|
+
* adopting repo's gate.
|
|
53
|
+
*/
|
|
54
|
+
async function declaredEntry(dir) {
|
|
55
|
+
let cursor = dir;
|
|
56
|
+
for (;;) {
|
|
57
|
+
if (existsSync(join(cursor, MANIFEST_FILE)) || existsSync(join(cursor, MANIFEST_LEGACY))) {
|
|
58
|
+
const { manifest, exists, error } = await readManifest(cursor);
|
|
59
|
+
if (!exists || error !== null || manifest === null) return null;
|
|
60
|
+
const packs = Array.isArray(manifest.packs) ? manifest.packs : [];
|
|
61
|
+
const here = join(cursor, PACK_DIR);
|
|
62
|
+
if (dir === here || dir.startsWith(`${here}/`)) {
|
|
63
|
+
const name = dir.slice(here.length + 1).split('/')[0];
|
|
64
|
+
return packs.find((p) => p?.name === name) ?? null;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(cursor);
|
|
69
|
+
if (parent === cursor) return null;
|
|
70
|
+
cursor = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function packNew(args, flags) {
|
|
75
|
+
const name = args[0];
|
|
76
|
+
if (!name) {
|
|
77
|
+
return fail(
|
|
78
|
+
'pack new needs a name',
|
|
79
|
+
EXIT.USAGE,
|
|
80
|
+
`run: ${CLI} pack new <name> --source <npm:<package>|git+https://…>`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const nameProblem = packNameProblem(name);
|
|
84
|
+
if (nameProblem !== null) {
|
|
85
|
+
return fail(nameProblem, EXIT.USAGE, 'a pack name is the directory name its bytes vendor under');
|
|
86
|
+
}
|
|
87
|
+
const source = flags.source;
|
|
88
|
+
if (!source) {
|
|
89
|
+
return fail(
|
|
90
|
+
'pack new needs --source',
|
|
91
|
+
EXIT.USAGE,
|
|
92
|
+
`run: ${CLI} pack new ${name} --source <npm:<package>|git+https://…>`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const sourceProblem = describeSourceProblem(source);
|
|
96
|
+
if (sourceProblem !== null) {
|
|
97
|
+
return fail(sourceProblem, EXIT.USAGE, 'a pack source names where the bytes come from: `npm:<package>` or `git+https://…`');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const dir = join(resolve(flags.root ?? process.cwd()), name);
|
|
101
|
+
const files = scaffoldPack({ name, source });
|
|
102
|
+
const { apply, dryRun } = writeMode(flags);
|
|
103
|
+
|
|
104
|
+
if (isJson(flags)) {
|
|
105
|
+
emitJson({
|
|
106
|
+
ok: true,
|
|
107
|
+
command: 'pack new',
|
|
108
|
+
dir,
|
|
109
|
+
dryRun: !apply,
|
|
110
|
+
files: files.map((f) => f.path),
|
|
111
|
+
packFile: PACK_JSON,
|
|
112
|
+
benchFile: BENCH_JSON,
|
|
113
|
+
});
|
|
114
|
+
return EXIT.OK;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!apply) {
|
|
118
|
+
emit(
|
|
119
|
+
[
|
|
120
|
+
`pack ${name} -> ${dir}`,
|
|
121
|
+
`source ${source}`,
|
|
122
|
+
'',
|
|
123
|
+
...files.map((f) => `PLAN ${f.path}`),
|
|
124
|
+
'',
|
|
125
|
+
dryRun ? 'DRY-RUN no files written' : `PLAN nothing written; apply with: ${CLI} pack new ${name} --source ${source} --yes`,
|
|
126
|
+
].join('\n'),
|
|
127
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
128
|
+
);
|
|
129
|
+
return EXIT.OK;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await writeScaffold(dir, files);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return fail(error.message, EXIT.FAIL, 'pick a name whose directory does not exist yet');
|
|
136
|
+
}
|
|
137
|
+
emit(
|
|
138
|
+
[
|
|
139
|
+
`pack ${name} -> ${dir}`,
|
|
140
|
+
`source ${source}`,
|
|
141
|
+
...files.map((f) => `WRITE ${f.path}`),
|
|
142
|
+
'',
|
|
143
|
+
`WROTE ${files.length} scaffold file(s); write the rule, then: ${CLI} pack lint ${dir}`,
|
|
144
|
+
].join('\n'),
|
|
145
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
146
|
+
);
|
|
147
|
+
return EXIT.OK;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function packLint(args, flags) {
|
|
151
|
+
const dir = packDir(args, flags);
|
|
152
|
+
const declared = await declaredEntry(dir);
|
|
153
|
+
const { pack, ok, findings } = await lintPack(dir, {
|
|
154
|
+
requireMeasured: flags.requireMeasured === true,
|
|
155
|
+
declared,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// The one-line answer first, because a gate that failed should say what it
|
|
159
|
+
// judged before it says why: the pack, its version, and the digest the
|
|
160
|
+
// adopting manifest would pin.
|
|
161
|
+
const headline = pack === null ? `pack ${dir} (no ${PACK_JSON})` : `pack ${pack.name} ${pack.version} digest ${pack.digest}`;
|
|
162
|
+
|
|
163
|
+
if (isJson(flags)) {
|
|
164
|
+
emitJson({ ok, command: 'pack lint', dir, pack, findings });
|
|
165
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lines = [headline];
|
|
169
|
+
if (pack !== null) {
|
|
170
|
+
const summary = await summaryLine(dir);
|
|
171
|
+
if (summary !== null) lines.push(summary);
|
|
172
|
+
}
|
|
173
|
+
if (declared !== null && pack !== null) {
|
|
174
|
+
lines.push(`declared ${declared.version ?? '(no version)'} pinned ${declared.digest ?? '(unpinned)'}`);
|
|
175
|
+
}
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
lines.push(`${f.status.toUpperCase().padEnd(24)} ${f.file} ${f.detail}`);
|
|
178
|
+
lines.push(` remedy: ${f.remedy}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push('', ok ? 'PASSED the pack is well-formed' : `FAILED ${findings.length} finding(s)`);
|
|
181
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
182
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The measured ΔR, in the pack's own words. Reported, never judged, here:
|
|
187
|
+
* judging is `lint`'s with `--require-measured`; this line is what a reader
|
|
188
|
+
* who asked \"what is this pack worth\" reads either way.
|
|
189
|
+
*/
|
|
190
|
+
async function summaryLine(dir) {
|
|
191
|
+
const { bench } = await readBench(dir);
|
|
192
|
+
if (bench === null) return null;
|
|
193
|
+
if (bench.measured !== true) return `measured unmeasured (no ΔR recorded in ${BENCH_JSON})`;
|
|
194
|
+
const dR = typeof bench.dR === 'number' ? (bench.dR >= 0 ? `+${bench.dR}` : `${bench.dR}`) : '(no dR)';
|
|
195
|
+
return `measured dR ${dR} over ${bench.tasks ?? '?'} tasks (${bench.run ?? 'unrecorded run'})`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function packPublish(args, flags) {
|
|
199
|
+
const dir = packDir(args, flags);
|
|
200
|
+
const { apply, dryRun } = writeMode(flags);
|
|
201
|
+
const result = await publishPack(dir, { apply });
|
|
202
|
+
|
|
203
|
+
if (isJson(flags)) {
|
|
204
|
+
emitJson({
|
|
205
|
+
ok: result.ok,
|
|
206
|
+
command: 'pack publish',
|
|
207
|
+
dir,
|
|
208
|
+
pack: result.pack,
|
|
209
|
+
published: result.published,
|
|
210
|
+
skipped: result.skipped,
|
|
211
|
+
planned: result.planned,
|
|
212
|
+
findings: result.findings,
|
|
213
|
+
});
|
|
214
|
+
return result.ok ? EXIT.OK : EXIT.FAIL;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const name = result.pack === null ? dir : `${result.pack.name} ${result.pack.version}`;
|
|
218
|
+
const lines = [`pack ${name}`, `source ${result.pack?.source ?? '(unknown)'}`];
|
|
219
|
+
if (result.skipped) {
|
|
220
|
+
lines.push(`SKIPPED ${npmPackageOf(result.pack.source)}@${result.pack.version} is already live; nothing to publish`);
|
|
221
|
+
}
|
|
222
|
+
for (const step of result.planned) lines.push(`${apply ? 'RUN ' : 'PLAN '}${step}`);
|
|
223
|
+
for (const f of result.findings) {
|
|
224
|
+
lines.push(`${f.status.toUpperCase().padEnd(24)} ${f.file} ${f.detail}`);
|
|
225
|
+
lines.push(` remedy: ${f.remedy}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.ok && !result.skipped) {
|
|
228
|
+
lines.push(
|
|
229
|
+
'',
|
|
230
|
+
apply
|
|
231
|
+
? `PUBLISHED ${result.pack.name}@${result.pack.version}`
|
|
232
|
+
: dryRun
|
|
233
|
+
? 'DRY-RUN nothing published'
|
|
234
|
+
: `PLAN nothing published; apply with: ${CLI} pack publish ${dir} --yes`,
|
|
235
|
+
);
|
|
236
|
+
} else if (result.ok) {
|
|
237
|
+
lines.push('');
|
|
238
|
+
} else {
|
|
239
|
+
lines.push('', `FAILED ${result.findings.length} finding(s)`);
|
|
240
|
+
}
|
|
241
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
242
|
+
if (result.ok && apply && result.published) {
|
|
243
|
+
note(`note never committed or pushed by this tool (ADR-001); the registry now has the bytes, the repos still need vendoring`);
|
|
244
|
+
}
|
|
245
|
+
return result.ok ? EXIT.OK : EXIT.FAIL;
|
|
246
|
+
}
|