simt-dev 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.md +300 -82
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +13 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +1 -1
- package/docs/decisions/ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md +71 -0
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +149 -0
- package/package.json +8 -4
- package/src/advice/advice.mjs +229 -0
- package/src/agenthooks/render.mjs +87 -21
- package/src/argv.mjs +25 -0
- package/src/bench/ablate.mjs +1 -3
- package/src/bench/grade.mjs +1 -1
- package/src/bench/mine.mjs +3 -3
- package/src/bench/probes.mjs +1 -3
- package/src/bench/report.mjs +1 -3
- package/src/bench/run.mjs +4 -18
- package/src/bench/tasks.mjs +36 -0
- package/src/bench/variants.mjs +1 -3
- package/src/brief/assemble.mjs +24 -4
- package/src/brief/packet.mjs +4 -1
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +40 -12
- package/src/commands/agenthook.mjs +6 -2
- package/src/commands/bench.mjs +35 -3
- package/src/commands/ci.mjs +1 -1
- package/src/commands/compare.mjs +1 -1
- package/src/commands/distill.mjs +36 -0
- package/src/commands/gate.mjs +2 -2
- package/src/commands/graph.mjs +2 -2
- package/src/commands/harness.mjs +87 -14
- package/src/commands/help.mjs +54 -4
- package/src/commands/hooks.mjs +1 -1
- package/src/commands/index.mjs +29 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/local.mjs +11 -0
- package/src/commands/mcp.mjs +1 -1
- package/src/commands/pack.mjs +246 -0
- package/src/commands/plugin.mjs +61 -0
- package/src/commands/review.mjs +65 -0
- package/src/commands/rules.mjs +1 -1
- package/src/commands/skills.mjs +3 -34
- package/src/commands/skillscompile.mjs +1 -3
- package/src/commands/skillsinstall.mjs +1 -1
- package/src/commands/sync.mjs +8 -8
- package/src/commands/tokens.mjs +1 -1
- package/src/commands/upgrade.mjs +1 -1
- package/src/commands/why.mjs +6 -3
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +19 -7
- package/src/compare/learn.mjs +2 -2
- package/src/compare/row.mjs +3 -3
- package/src/decisions/index.mjs +1 -1
- package/src/distill/distill.mjs +246 -0
- package/src/distill/engine.mjs +187 -0
- package/src/distribute/release.mjs +142 -0
- package/src/graph/load.mjs +9 -8
- package/src/hooks/dispatcher.mjs +3 -2
- package/src/hooks/shim.mjs +11 -6
- package/src/index/govern.mjs +1 -1
- package/src/index/map.mjs +1 -1
- package/src/index/persist.mjs +2 -2
- package/src/index/store.mjs +50 -20
- package/src/ledger/index.mjs +2 -2
- package/src/local/client.mjs +22 -8
- package/src/local/config.mjs +52 -0
- package/src/locate.mjs +22 -1
- package/src/mcp/prompts.mjs +118 -0
- package/src/mcp/resources.mjs +96 -22
- package/src/mcp/server.mjs +33 -6
- package/src/mcp/tools.mjs +12 -12
- package/src/measure/session.mjs +1 -1
- package/src/memory/decide.mjs +1 -1
- package/src/memory/lessons.mjs +14 -14
- package/src/name.mjs +11 -1
- package/src/package-version.mjs +12 -10
- package/src/packs/manage.mjs +563 -0
- package/src/packs.mjs +15 -5
- package/src/plugins.mjs +164 -0
- package/src/projection/loaders.mjs +84 -10
- package/src/projection/manifest.mjs +4 -4
- package/src/projection/project.mjs +3 -3
- package/src/projection/rules.mjs +24 -4
- package/src/projection/surfaces.mjs +20 -2
- package/src/resolve/body.mjs +46 -0
- package/src/review/critic.mjs +509 -0
- package/src/toolwords.mjs +8 -5
- package/src/tsconfig.mjs +3 -3
- package/src/upgrade/plan.mjs +1 -1
- /package/bin/{simt-dev.mjs → simt.mjs} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// simt plugin <claude|gemini|cursor> --out <dir> [--yes]
|
|
2
|
+
//
|
|
3
|
+
// Build one harness package from the same tables `agent-hooks install` writes into a
|
|
4
|
+
// repository, so a package and a repository install cannot drift apart. A dry run prints
|
|
5
|
+
// the plan and writes nothing; --yes copies the runtime and writes every generated file.
|
|
6
|
+
// The package is self-contained — it carries its own copy of the runtime — so it installs
|
|
7
|
+
// into a clean profile that has never seen this tool (plan 0003 R43).
|
|
8
|
+
|
|
9
|
+
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
10
|
+
import { CLI } from '../name.mjs';
|
|
11
|
+
import { buildPackage, PLUGIN_KINDS } from '../plugins.mjs';
|
|
12
|
+
|
|
13
|
+
export async function pluginCommand(args, flags) {
|
|
14
|
+
const kind = args[0];
|
|
15
|
+
if (!kind || !PLUGIN_KINDS.includes(kind)) {
|
|
16
|
+
return fail(
|
|
17
|
+
kind ? `unknown package kind \`${kind}\`` : 'plugin needs a kind',
|
|
18
|
+
EXIT.USAGE,
|
|
19
|
+
`one of: ${PLUGIN_KINDS.join(', ')}`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const out = flags.out;
|
|
24
|
+
if (!out) {
|
|
25
|
+
return fail('plugin needs --out <dir>', EXIT.USAGE, 'where the generated package lands');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { apply } = writeMode(flags);
|
|
29
|
+
const root = flags.root ?? process.cwd();
|
|
30
|
+
const plan = await buildPackage(kind, { root, out, apply });
|
|
31
|
+
|
|
32
|
+
if (isJson(flags)) {
|
|
33
|
+
emitJson({
|
|
34
|
+
ok: true,
|
|
35
|
+
command: `plugin ${kind}`,
|
|
36
|
+
out,
|
|
37
|
+
dryRun: !apply,
|
|
38
|
+
runtime: plan.runtime,
|
|
39
|
+
files: plan.files,
|
|
40
|
+
notes: plan.notes,
|
|
41
|
+
});
|
|
42
|
+
return EXIT.OK;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runtimeTarget = plan.runtime.target === '.' ? '(the package root)' : `${plan.runtime.target}/`;
|
|
46
|
+
emit(
|
|
47
|
+
[
|
|
48
|
+
`kind ${kind}`,
|
|
49
|
+
`out ${out}`,
|
|
50
|
+
`runtime copies ${plan.runtime.parts.join(', ')} into ${runtimeTarget}`,
|
|
51
|
+
...plan.files.map((f) => `${apply ? 'WRITE' : 'PLAN '} ${f.path}`),
|
|
52
|
+
...plan.notes.map((n) => `note ${n}`),
|
|
53
|
+
'',
|
|
54
|
+
apply
|
|
55
|
+
? `WROTE ${plan.files.length} generated file(s) plus the runtime`
|
|
56
|
+
: `PLAN nothing written; apply with: ${CLI} plugin ${kind} --out ${out} --yes`,
|
|
57
|
+
].join('\n'),
|
|
58
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
59
|
+
);
|
|
60
|
+
return EXIT.OK;
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `simt review [--local]` (plan 0003 R39).
|
|
2
|
+
//
|
|
3
|
+
// The critic's report layer: the deterministic path prints the brief (staged diff
|
|
4
|
+
// plus what governs it); `--local` adds the model verdict, which always arrives as
|
|
5
|
+
// a proposal a human reads — nothing here is a gate, and the exit code never
|
|
6
|
+
// carries the verdict, because a critic that fails the run would be a gate by
|
|
7
|
+
// another name (ADR-090, principle P2: the reply's text is model output and never
|
|
8
|
+
// lands in projected or committed text).
|
|
9
|
+
|
|
10
|
+
import { CLI } from '../name.mjs';
|
|
11
|
+
import { EXIT, emit, emitJson, isJson } from '../output/index.mjs';
|
|
12
|
+
import { reviewTarget } from '../review/critic.mjs';
|
|
13
|
+
|
|
14
|
+
function governsLine(items) {
|
|
15
|
+
if (items.length === 0) return 'none';
|
|
16
|
+
const ids = items.slice(0, 8).map((item) => item.id);
|
|
17
|
+
const more = items.length > ids.length ? ` …+${items.length - ids.length}` : '';
|
|
18
|
+
return ids.join(', ') + more;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function reviewCommand(args, flags, commands = []) {
|
|
22
|
+
const result = await reviewTarget(args, flags, { commands });
|
|
23
|
+
if (typeof result === 'number') return result;
|
|
24
|
+
|
|
25
|
+
if (isJson(flags)) {
|
|
26
|
+
emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
|
|
27
|
+
return EXIT.OK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lines = [
|
|
31
|
+
`staged ${result.staged.files.length} files, ${result.staged.bytes} bytes`,
|
|
32
|
+
`governs ${governsLine(result.governs)}`,
|
|
33
|
+
];
|
|
34
|
+
// The branch keys on the path, not on the verdict: a local run whose reply
|
|
35
|
+
// lacked a verdict line still carries faults and a proposal, and hiding them
|
|
36
|
+
// behind the "needs --local" reading would erase the model's failure.
|
|
37
|
+
if (result.brief !== undefined) {
|
|
38
|
+
// The nothing-staged run is a plain answer, not an invitation to rerun with
|
|
39
|
+
// --local: there is no brief yet, so there is nothing for the critic to read.
|
|
40
|
+
if (result.staged.files.length === 0) {
|
|
41
|
+
lines.push('verdict none (nothing is staged)');
|
|
42
|
+
} else {
|
|
43
|
+
lines.push(
|
|
44
|
+
`verdict needs ${CLI} review --local (advisory; a critic is never a gate)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
if (result.verdict !== null) {
|
|
49
|
+
lines.push(
|
|
50
|
+
`verdict ${result.verdict} (${result.verified ? 'verified' : 'unverified'})`,
|
|
51
|
+
`findings ${result.findings}`,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
lines.push('verdict absent (unverified)');
|
|
55
|
+
}
|
|
56
|
+
if (!result.verified) {
|
|
57
|
+
for (const fault of result.faults) lines.push(`unverified ${fault}`);
|
|
58
|
+
}
|
|
59
|
+
lines.push(`proposal ${result.proposal.path} (${result.proposal.state}, ${result.proposal.bytes} bytes)`);
|
|
60
|
+
}
|
|
61
|
+
if (result.metric) lines.push(`metric ${result.metric.note}`);
|
|
62
|
+
|
|
63
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
64
|
+
return EXIT.OK;
|
|
65
|
+
}
|
package/src/commands/rules.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `simt
|
|
1
|
+
// `simt rules` - the rule catalogue and its provenance.
|
|
2
2
|
//
|
|
3
3
|
// A rule is only meaningful if you can answer "why is this here?". Three
|
|
4
4
|
// commands, mirroring the skill surface deliberately: `list` is the catalogue,
|
package/src/commands/skills.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `simt
|
|
1
|
+
// `simt skills` - tier 0 (index), tier 1 (brief), tier 2 (show), plus find/why.
|
|
2
2
|
//
|
|
3
3
|
// Implementation sequence steps 4 and 5.
|
|
4
4
|
//
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -20,9 +20,7 @@ import { resolveVaultDir } from '../bench/mine.mjs';
|
|
|
20
20
|
import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
|
|
21
21
|
import { nearMatches } from '../resolve/near.mjs';
|
|
22
22
|
import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
23
|
-
import { CLI,
|
|
24
|
-
|
|
25
|
-
const STATE_DIR = `.${CONFIG_DIR}`;
|
|
23
|
+
import { CLI, STATE_DIR } from '../name.mjs';
|
|
26
24
|
|
|
27
25
|
async function readSkillRawText(skill, workspaceRoot, root) {
|
|
28
26
|
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `simt
|
|
1
|
+
// `simt skills install` - pin a skill into a repo as a local copy.
|
|
2
2
|
//
|
|
3
3
|
// Plan 0002 section 8.3: installed skills are recorded in `simt-dev.json`
|
|
4
4
|
// `localSkills` and are excluded from central-drift accounting. The point is a
|
package/src/commands/sync.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// simt
|
|
2
|
-
// simt
|
|
1
|
+
// simt sync --yes — write the generated regions.
|
|
2
|
+
// simt check — the gate.
|
|
3
3
|
//
|
|
4
4
|
// They share plan/project.mjs so they can never disagree about what the regions
|
|
5
5
|
// should contain. sync decides whether to act; check decides severity and exit
|
|
@@ -36,7 +36,7 @@ import { lintDecisions } from '../memory/decide.mjs';
|
|
|
36
36
|
/** Findings `sync` reports and does not fail on.
|
|
37
37
|
*
|
|
38
38
|
* `pointer-unread` is a misaimed pointer whose reader this repository serves. `sync`
|
|
39
|
-
* must not refuse on it: the remedy is `simt
|
|
39
|
+
* must not refuse on it: the remedy is `simt sync --yes`, so a `sync` that blocked
|
|
40
40
|
* would be a gate whose remedy is the command being run — ADR-025 ruling 6's hazard, and
|
|
41
41
|
* worse than a noisy gate. `check` disagrees, and the reason is the whole of ADR-051.
|
|
42
42
|
*
|
|
@@ -194,9 +194,9 @@ export async function syncCommand(positional, flags) {
|
|
|
194
194
|
if (st.state === 'current') continue;
|
|
195
195
|
// A *mispointed* pointer is one this tool wrote and this tool may repair: it is a
|
|
196
196
|
// symlink, so it has no content, and the remedy printed by `check` for this exact
|
|
197
|
-
// finding is `simt
|
|
197
|
+
// finding is `simt sync --yes`. Reporting a plan and then calling `symlink()` at
|
|
198
198
|
// an existing path is `EEXIST` — which is what this did, and the remedy it offered
|
|
199
|
-
// was `simt
|
|
199
|
+
// was `simt doctor`, which reports nothing about it. A command whose own remedy
|
|
200
200
|
// crashes on the state it names has no remedy at all.
|
|
201
201
|
//
|
|
202
202
|
// Only a symlink is replaced. A regular file or a directory is somebody's work and
|
|
@@ -364,7 +364,7 @@ export async function syncCommand(positional, flags) {
|
|
|
364
364
|
|
|
365
365
|
const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
|
|
366
366
|
if (checkOnly) return idle ? EXIT.OK : EXIT.FAIL;
|
|
367
|
-
// A preview that found work is not a success: `simt
|
|
367
|
+
// A preview that found work is not a success: `simt sync --yes` with no flags is a
|
|
368
368
|
// check that also prints the plan, and a check that exits 0 while reporting drift
|
|
369
369
|
// is the one-question-two-answers shape ADR-035 rules out. This is what lets a
|
|
370
370
|
// caller run `sync` in place of `sync --check` and get the same verdict.
|
|
@@ -500,8 +500,8 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
500
500
|
const { manifest } = await readManifest(root);
|
|
501
501
|
if (manifest) {
|
|
502
502
|
// The gate runs from whatever binary resolved it. For a repository without npm that
|
|
503
|
-
// is
|
|
504
|
-
// the pin the repo recorded at adoption. A stale binary would run a different gate
|
|
503
|
+
// is the release binary in `.simt/bin` (ADR-098), and the only thing that knows
|
|
504
|
+
// which version it should be is the pin the repo recorded at adoption. A stale binary would run a different gate
|
|
505
505
|
// than CI does, so the mismatch fails the gate rather than warning.
|
|
506
506
|
const mismatch = assertVersionPin(manifest);
|
|
507
507
|
if (mismatch) {
|
package/src/commands/tokens.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// simt
|
|
1
|
+
// simt tokens - what a reader loads, counted.
|
|
2
2
|
//
|
|
3
3
|
// Review 0002 S38 put the estate's always-on context at 90 KB per turn, 72% of it a
|
|
4
4
|
// paragraph an earlier root had already loaded, and could only say so by reading seven
|
package/src/commands/upgrade.mjs
CHANGED
package/src/commands/why.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { listPromotedLessons } from '../memory/lessons.mjs';
|
|
|
26
26
|
|
|
27
27
|
const exec = promisify(execFile);
|
|
28
28
|
|
|
29
|
-
const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt
|
|
29
|
+
const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt/profiles'];
|
|
30
30
|
|
|
31
31
|
export async function whyCommand(args, flags, commands = []) {
|
|
32
32
|
if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
|
|
@@ -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),
|
package/src/compare/discover.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Find the sibling repositories a `simt
|
|
1
|
+
// Find the sibling repositories a `simt compare` should report on.
|
|
2
2
|
//
|
|
3
3
|
// Three things here are load-bearing, and each exists because the alternative is
|
|
4
4
|
// a wrong answer rather than a missing one:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Read a repo's hook routing and shim state without changing anything.
|
|
2
2
|
//
|
|
3
|
-
// This is the read half of what `simt
|
|
4
|
-
// module because two callers need it - `hooks status` and `simt
|
|
3
|
+
// This is the read half of what `simt hooks status` does inline. It is a separate
|
|
4
|
+
// module because two callers need it - `hooks status` and `simt compare` - and a
|
|
5
5
|
// second implementation of "is this shim current?" would be exactly the
|
|
6
6
|
// duplicated-source-of-truth problem the tool exists to remove. `compare` reads it
|
|
7
7
|
// for N repos, so it must not shell out per repo per event.
|
|
@@ -11,7 +11,7 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
11
11
|
import { basename, join } from 'node:path';
|
|
12
12
|
import { promisify } from 'node:util';
|
|
13
13
|
|
|
14
|
-
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderShim } from '../hooks/shim.mjs';
|
|
14
|
+
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
15
15
|
import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
|
|
16
16
|
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
17
17
|
|
|
@@ -123,8 +123,8 @@ export async function readHooksPath(root) {
|
|
|
123
123
|
* already records for the same reason.
|
|
124
124
|
*
|
|
125
125
|
* `root` is the repository the shim belongs to: the remedy a shim prints depends on
|
|
126
|
-
* the repo's kind (a devDependency where package.json exists,
|
|
127
|
-
* where there is none), so the comparison must render the shim the way THIS repo got it,
|
|
126
|
+
* the repo's kind (a devDependency where package.json exists, the release binary into
|
|
127
|
+
* the repository where there is none), so the comparison must render the shim the way THIS repo got it,
|
|
128
128
|
* or a non-npm repository would read as stale the moment this tool ships.
|
|
129
129
|
*/
|
|
130
130
|
async function describeShim(path, root) {
|
|
@@ -142,7 +142,15 @@ async function describeShim(path, root) {
|
|
|
142
142
|
}
|
|
143
143
|
let stale = true;
|
|
144
144
|
try {
|
|
145
|
-
|
|
145
|
+
// A chained shim is current against the repository hook it chains to, not against
|
|
146
|
+
// the dispatcher template: comparing it to renderShim read every chained ungated
|
|
147
|
+
// event as stale forever, and no reinstall could clear it.
|
|
148
|
+
if (content.includes(CHAIN_MARKER)) {
|
|
149
|
+
const chain = await readChainedShim(path);
|
|
150
|
+
stale = chain === null || content !== renderChainedShim(chain.event, chain.target);
|
|
151
|
+
} else {
|
|
152
|
+
stale = content !== renderShim(basename(path), { npm: await hasNpm(root), version: packageVersion() });
|
|
153
|
+
}
|
|
146
154
|
} catch {
|
|
147
155
|
stale = true;
|
|
148
156
|
}
|
|
@@ -177,17 +185,21 @@ export async function readHooksState(root, options = {}) {
|
|
|
177
185
|
const staleOurs = [];
|
|
178
186
|
for (const hook of live) {
|
|
179
187
|
const shim = EVENTS.includes(hook.event) ? await describeShim(hook.path, root) : { stale: true };
|
|
180
|
-
if (!shim.stale) continue;
|
|
181
188
|
// A chained shim is ours too, and its name says nothing: `post-commit` is what the
|
|
182
189
|
// repository called its own hook, and the shim exists under that name precisely so
|
|
183
190
|
// git finds it. Reading it as foreign would make every re-install refuse with "this
|
|
184
191
|
// repo already runs hooks simt did not write" - a shim this tool wrote, refusing
|
|
185
192
|
// itself. Identity is content, the same rule as the dispatcher shim above.
|
|
193
|
+
//
|
|
194
|
+
// Classified before the currency skip, because a current chain shim is not stale -
|
|
195
|
+
// and install still has to maintain it from `chained`, or a second run would forget
|
|
196
|
+
// the very shim that is already correct.
|
|
186
197
|
const chain = await readChainedShim(hook.path);
|
|
187
198
|
if (chain) {
|
|
188
199
|
chained.push({ ...chain, path: hook.path });
|
|
189
200
|
continue;
|
|
190
201
|
}
|
|
202
|
+
if (!shim.stale) continue;
|
|
191
203
|
// And so is a dispatcher shim from an EARLIER version of this tool, which is the
|
|
192
204
|
// ordinary state of every repository adopted before the last release.
|
|
193
205
|
//
|
package/src/compare/learn.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Estate learning through `compare` (plan 0003 R36).
|
|
2
2
|
//
|
|
3
|
-
// Aggregates `.simt
|
|
3
|
+
// Aggregates `.simt/ledger.jsonl`, `.simt/lessons.jsonl`, and ablation/bench
|
|
4
4
|
// records across workspace repositories to surface:
|
|
5
5
|
// 1. the most-violated rules (from hook denials and `review-block` findings);
|
|
6
6
|
// 2. the most-failing and slowest gates (from evidence ledgers);
|
|
@@ -189,7 +189,7 @@ async function collectRepoAblationRecords(repoPath) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
async function readExplicitNoValueSections(repoPath) {
|
|
192
|
-
const file = join(repoPath, '.simt
|
|
192
|
+
const file = join(repoPath, '.simt', 'ablation.json');
|
|
193
193
|
try {
|
|
194
194
|
const raw = JSON.parse(await readFile(file, 'utf8'));
|
|
195
195
|
const list = Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
|