simt-dev 0.6.3 → 0.7.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 +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +296 -77
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -22,15 +22,18 @@
|
|
|
22
22
|
// tool call. Failures of this command exit 1, which a harness reports as a failed hook,
|
|
23
23
|
// and that is the right blast radius for a hook that is broken rather than refusing.
|
|
24
24
|
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
25
26
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
26
|
-
import { dirname, join } from 'node:path';
|
|
27
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
27
28
|
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
29
|
+
import { AGENT_HOOK_SHIM, renderAgentHookShim } from '../agenthooks/shim.mjs';
|
|
28
30
|
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
29
31
|
|
|
30
32
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
31
|
-
import { CLI } from '../name.mjs';
|
|
32
|
-
import { ALLOW_ENV, EVENTS, POLICY_IDS } from '../agenthooks/policy.mjs';
|
|
33
|
+
import { CLI, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
34
|
+
import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV } from '../agenthooks/policy.mjs';
|
|
33
35
|
import { decide, factsFor } from '../agenthooks/decide.mjs';
|
|
36
|
+
import { repoRoot } from '../repo-root.mjs';
|
|
34
37
|
import {
|
|
35
38
|
adapter,
|
|
36
39
|
adapters,
|
|
@@ -97,10 +100,33 @@ export async function agentHookCommand(args, flags) {
|
|
|
97
100
|
// `--root` outranks the payload: a packaged hook runs from the plugin directory and the
|
|
98
101
|
// harness's environment variable names the project, so a payload cwd that is the plugin's
|
|
99
102
|
// own working directory must not win. The repository-install path passes no flag, and the
|
|
100
|
-
// payload's cwd is what the harness reports.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
// payload's cwd is what the harness reports. The answer is then resolved to the
|
|
104
|
+
// repository's top level: a harness reports the directory it happens to be in, and an
|
|
105
|
+
// agent works from subdirectories, so every repository-relative fact (the lease file,
|
|
106
|
+
// `.gitmodules`) must resolve against the root git itself uses, not the cwd (review
|
|
107
|
+
// 0003 Q16).
|
|
108
|
+
const { root: payloadRoot, command, writes, session: payloadSession } = factsFrom(row, payload, { root: flags.root });
|
|
109
|
+
const root = repoRoot(payloadRoot);
|
|
110
|
+
// A relative write path is relative to the directory the harness is in, not to the
|
|
111
|
+
// repository root the facts resolve against: an agent editing `keep.mjs` from `src/`
|
|
112
|
+
// means `src/keep.mjs`, and a path joined to the wrong base is a path the lease file
|
|
113
|
+
// will never meet (review 0003 Q16).
|
|
114
|
+
// Writes are `{path, ...edit}` rows (Q17): the path resolves against the harness
|
|
115
|
+
// cwd, the edit detail travels untouched so the region check sees the old_string.
|
|
116
|
+
const writesAbsolute = writes.map((w) => {
|
|
117
|
+
const path = typeof w === 'string' ? w : w?.path;
|
|
118
|
+
const abs = typeof path === 'string' && isAbsolute(path) ? path : join(payloadRoot, path);
|
|
119
|
+
return typeof w === 'string' ? abs : { ...w, path: abs };
|
|
120
|
+
});
|
|
121
|
+
const facts = await factsFor(root, { command, writes: writesAbsolute });
|
|
122
|
+
// Harness hook processes rarely carry SIMT_SESSION, so the payload's own
|
|
123
|
+
// session id is the fallback: without it an agent is blocked by its own
|
|
124
|
+
// lease (review 0003 Q20). The environment still wins when it names one.
|
|
125
|
+
const session =
|
|
126
|
+
typeof process.env[SESSION_ENV] === 'string' && process.env[SESSION_ENV] !== ''
|
|
127
|
+
? process.env[SESSION_ENV]
|
|
128
|
+
: (payloadSession ?? '');
|
|
129
|
+
const decision = decide({ event, facts, env: { ...process.env, [SESSION_ENV]: session } });
|
|
104
130
|
|
|
105
131
|
// Both notes are about a conclusion the author is about to draw: a lift that did
|
|
106
132
|
// nothing because of a typo, and a lift that is the reason the command they expected to
|
|
@@ -115,7 +141,11 @@ export async function agentHookCommand(args, flags) {
|
|
|
115
141
|
const json = decisionJson(row, decision);
|
|
116
142
|
if (json === null) return EXIT.OK;
|
|
117
143
|
|
|
118
|
-
|
|
144
|
+
// A denial is recorded only for a repository that adopted the tool: hook
|
|
145
|
+
// denials in a never-adopted repo are noise about someone else's policy, and
|
|
146
|
+
// that is how an unadopted checkout accumulated 23 candidates nobody would
|
|
147
|
+
// ever promote (review 0003 Q36). The manifest is the adoption mark.
|
|
148
|
+
if (root && (existsSync(join(root, MANIFEST_FILE)) || existsSync(join(root, MANIFEST_LEGACY)))) {
|
|
119
149
|
try {
|
|
120
150
|
const { recordHookDenial } = await import('../memory/lessons.mjs');
|
|
121
151
|
await recordHookDenial(root, {
|
|
@@ -143,8 +173,24 @@ export async function agentHooksCommand(sub, args, flags) {
|
|
|
143
173
|
return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
// The label for a reader the run did not write, by the state that names why:
|
|
177
|
+
// `none` documents no hooks file, `other` is a reader this tool does not write,
|
|
178
|
+
// `skipped` was an adapter the caller did not ask for (ADR-107).
|
|
179
|
+
const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
|
|
180
|
+
|
|
146
181
|
async function installHooks(root, flags) {
|
|
147
182
|
const mode = writeMode(flags);
|
|
183
|
+
// `--harness` narrows the install to one reader (ADR-107). The id is validated
|
|
184
|
+
// against the same table the loop reads, so a typo fails before anything is
|
|
185
|
+
// planned; the remedy names the ids the way `agent-hook` does.
|
|
186
|
+
const selected = flags.harness;
|
|
187
|
+
if (selected !== undefined && adapter(selected) === null) {
|
|
188
|
+
return fail(
|
|
189
|
+
`unknown harness \`${selected}\``,
|
|
190
|
+
EXIT.USAGE,
|
|
191
|
+
`--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
148
194
|
// The delivery is a second entry in the same config, and a repository asks for it by
|
|
149
195
|
// declaring a `brief` key rather than by having the entry. Read through a dynamic
|
|
150
196
|
// import for the same reason the hook delivers through one.
|
|
@@ -155,8 +201,9 @@ async function installHooks(root, flags) {
|
|
|
155
201
|
listPromotedLessons(root).catch(() => []),
|
|
156
202
|
]);
|
|
157
203
|
const hasLessons = promotedLessons.length > 0;
|
|
204
|
+
const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
|
|
158
205
|
const plan = [];
|
|
159
|
-
for (const row of
|
|
206
|
+
for (const row of rows) {
|
|
160
207
|
const path = hookPath(row.id);
|
|
161
208
|
const existing = await readIfExists(join(root, path));
|
|
162
209
|
const result = rendered(row, existing, { prompt: optIn.optedIn, post: hasLessons });
|
|
@@ -178,15 +225,28 @@ async function installHooks(root, flags) {
|
|
|
178
225
|
|
|
179
226
|
// The readers this tool does not write an entry for, reported rather than skipped in
|
|
180
227
|
// silence: a command that writes four files and says nothing about the fifth harness
|
|
181
|
-
// in the repository reads as "that one is covered too".
|
|
228
|
+
// in the repository reads as "that one is covered too". A narrowed install adds a
|
|
229
|
+
// third state: an adapter that is this tool's but was not requested (ADR-107).
|
|
182
230
|
const others = [];
|
|
183
231
|
for (const r of hookRows()) {
|
|
184
232
|
if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
|
|
185
233
|
else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
|
|
234
|
+
else if (selected !== undefined && r.id !== selected) {
|
|
235
|
+
others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
|
|
236
|
+
}
|
|
186
237
|
}
|
|
187
238
|
|
|
188
239
|
const refused = plan.filter((p) => p.error);
|
|
189
240
|
|
|
241
|
+
// The shim every harness config points at: without it the entries above name a
|
|
242
|
+
// binary no harness has on PATH (review 0003 Q21). Its remedy installs the
|
|
243
|
+
// dependency the same way the git shims' does — a devDependency where the
|
|
244
|
+
// repository has a package.json, the release binary where it does not.
|
|
245
|
+
const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
|
|
246
|
+
const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
|
|
247
|
+
const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
|
|
248
|
+
const shimRow = { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction };
|
|
249
|
+
|
|
190
250
|
// The `--diff` pairs, read before anything is written so the before side is
|
|
191
251
|
// true in preview and in apply alike (R50).
|
|
192
252
|
const diffs =
|
|
@@ -194,7 +254,8 @@ async function installHooks(root, flags) {
|
|
|
194
254
|
? await Promise.all(
|
|
195
255
|
plan
|
|
196
256
|
.filter((p) => p.action !== 'unchanged')
|
|
197
|
-
.map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
|
|
257
|
+
.map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
|
|
258
|
+
.concat(shimAction === 'unchanged' ? [] : [{ path: AGENT_HOOK_SHIM, before: shimBefore, after: shimText }]),
|
|
198
259
|
)
|
|
199
260
|
: [];
|
|
200
261
|
|
|
@@ -206,6 +267,7 @@ async function installHooks(root, flags) {
|
|
|
206
267
|
root,
|
|
207
268
|
dryRun: true,
|
|
208
269
|
plan: plan.map(({ text, ...rest }) => rest),
|
|
270
|
+
shim: shimRow,
|
|
209
271
|
notWritten: others,
|
|
210
272
|
filesWritten: false,
|
|
211
273
|
...diffField(diffs, flags),
|
|
@@ -216,7 +278,8 @@ async function installHooks(root, flags) {
|
|
|
216
278
|
[
|
|
217
279
|
`repo ${root}`,
|
|
218
280
|
...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
|
|
219
|
-
|
|
281
|
+
`${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
|
|
282
|
+
...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
|
|
220
283
|
...promptLine,
|
|
221
284
|
'',
|
|
222
285
|
refused.length > 0
|
|
@@ -245,6 +308,14 @@ async function installHooks(root, flags) {
|
|
|
245
308
|
await journaledWriteFile(root, journal, join(root, p.path), p.text);
|
|
246
309
|
written.push(p);
|
|
247
310
|
}
|
|
311
|
+
// The shim is a file like the configs: unchanged installs leave it alone, and a
|
|
312
|
+
// changed one is journaled so `undo` removes or restores it with the configs.
|
|
313
|
+
let shimWritten = false;
|
|
314
|
+
if (shimAction !== 'unchanged') {
|
|
315
|
+
await mkdir(join(root, dirname(AGENT_HOOK_SHIM)), { recursive: true });
|
|
316
|
+
await journaledWriteFile(root, journal, join(root, AGENT_HOOK_SHIM), shimText, { mode: 0o755 });
|
|
317
|
+
shimWritten = true;
|
|
318
|
+
}
|
|
248
319
|
await commitJournal(root, 'agent-hooks install', journal);
|
|
249
320
|
|
|
250
321
|
const notes = [];
|
|
@@ -260,6 +331,7 @@ async function installHooks(root, flags) {
|
|
|
260
331
|
command: 'agent-hooks install',
|
|
261
332
|
root,
|
|
262
333
|
plan: plan.map(({ text, ...rest }) => rest),
|
|
334
|
+
shim: { ...shimRow, written: shimWritten },
|
|
263
335
|
notWritten: others,
|
|
264
336
|
written: written.map((p) => p.path),
|
|
265
337
|
filesWritten: true,
|
|
@@ -272,10 +344,11 @@ async function installHooks(root, flags) {
|
|
|
272
344
|
[
|
|
273
345
|
`repo ${root}`,
|
|
274
346
|
...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
|
|
275
|
-
|
|
347
|
+
`${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
|
|
348
|
+
...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
|
|
276
349
|
...promptLine,
|
|
277
350
|
'',
|
|
278
|
-
`WROTE ${written.length} hooks file(s); each one runs ${
|
|
351
|
+
`WROTE ${written.length} hooks file(s)${shimWritten ? ' and the shim' : ''}; each one runs the ${AGENT_HOOK_SHIM} shim before the tool call`,
|
|
279
352
|
].join('\n'),
|
|
280
353
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
281
354
|
);
|
|
@@ -290,10 +363,31 @@ async function installHooks(root, flags) {
|
|
|
290
363
|
async function hooksStatus(root, flags) {
|
|
291
364
|
const rows = [];
|
|
292
365
|
const problems = [];
|
|
366
|
+
// The plugin file this tool owns whole is compared against what `install` would write
|
|
367
|
+
// right now, so status reads the same opt-in install reads: a file that matches the
|
|
368
|
+
// preview is present, anything else is drift an install will rewrite.
|
|
369
|
+
const { promptOptIn } = await import('../agenthooks/prompt.mjs');
|
|
370
|
+
const optIn = await promptOptIn(root);
|
|
293
371
|
|
|
294
372
|
for (const row of adapters()) {
|
|
295
373
|
const path = hookPath(row.id);
|
|
296
374
|
const existing = await readIfExists(join(root, path));
|
|
375
|
+
if (row.file) {
|
|
376
|
+
if (existing === null) {
|
|
377
|
+
rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
|
|
378
|
+
} else {
|
|
379
|
+
const present = rendered(row, existing, { prompt: optIn.optedIn }).action === 'unchanged';
|
|
380
|
+
rows.push({
|
|
381
|
+
id: row.id,
|
|
382
|
+
path,
|
|
383
|
+
state: present ? 'present' : 'drift',
|
|
384
|
+
entries: present ? 1 : 0,
|
|
385
|
+
prompt: row.prompt ? (present && optIn.optedIn ? 'present' : 'absent') : null,
|
|
386
|
+
note: present ? null : 'differs from what install would write; run install to restore it',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
297
391
|
if (existing === null) {
|
|
298
392
|
rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
|
|
299
393
|
continue;
|
|
@@ -341,7 +435,7 @@ async function hooksStatus(root, flags) {
|
|
|
341
435
|
|
|
342
436
|
const lines = [`repo ${root}`];
|
|
343
437
|
for (const r of rows) {
|
|
344
|
-
const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
|
|
438
|
+
const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', drift: 'DRIFT ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
|
|
345
439
|
lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
|
|
346
440
|
if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
|
|
347
441
|
// The second event is a separate answer to a separate question: a harness can carry
|
package/src/commands/bench.mjs
CHANGED
|
@@ -217,6 +217,7 @@ export async function benchReportCommand(positional, flags) {
|
|
|
217
217
|
harness: flags.harness,
|
|
218
218
|
model: flags.model,
|
|
219
219
|
compare: compareItems.length === 2 ? compareItems : null,
|
|
220
|
+
crossGroup: Boolean(flags.crossGroup),
|
|
220
221
|
});
|
|
221
222
|
} catch (error) {
|
|
222
223
|
return fail(
|
|
@@ -405,7 +406,7 @@ export async function benchVariantsCommand(positional, flags) {
|
|
|
405
406
|
// and until a bench here shows that, every report calls the critic advisory.
|
|
406
407
|
export async function benchCriticCommand(positional, flags, commands = []) {
|
|
407
408
|
const root = resolve(flags.root ?? positional[0] ?? process.cwd());
|
|
408
|
-
const labelsPath = flags.labels ?? '.simt/critic-labels.jsonl';
|
|
409
|
+
const labelsPath = flags.labels ?? '.simt/local/critic-labels.jsonl';
|
|
409
410
|
const result = await criticBench(root, { labelsPath, flags, commands });
|
|
410
411
|
if (typeof result === 'number') return result;
|
|
411
412
|
|
package/src/commands/compare.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { basename, resolve } from 'node:path';
|
|
|
24
24
|
import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
|
|
25
25
|
import { provenanceDigest } from '../projection/project.mjs';
|
|
26
26
|
import { GATES } from '../hooks/gates.mjs';
|
|
27
|
-
import {
|
|
27
|
+
import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
|
|
28
28
|
import { discover } from '../compare/discover.mjs';
|
|
29
29
|
import { learnAcrossEstate, renderLearningLines } from '../compare/learn.mjs';
|
|
30
30
|
import { targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
@@ -60,12 +60,13 @@ export async function compareCommand(positional, flags) {
|
|
|
60
60
|
const expectedSha = await provenanceDigest();
|
|
61
61
|
const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
// process;
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// Parallel with a cap, in discovery order. 16 fingerprint+region computations
|
|
64
|
+
// each spawn a git process; unbounded parallelism is a burst of subprocesses
|
|
65
|
+
// with no speedup, and sequential builds are what took 48 s over MCP for 17
|
|
66
|
+
// repos (review 0003 Q38). The cap keeps the burst bounded while overlapping
|
|
67
|
+
// the spawn wait; the rows still come back in input order, so two runs diff
|
|
68
|
+
// cleanly and determinism of the output is untouched.
|
|
69
|
+
const rows = await buildRows(found.repos, context, { concurrency: resolveConcurrency(flags) });
|
|
69
70
|
const learning = await learnAcrossEstate(found.repos);
|
|
70
71
|
|
|
71
72
|
const summary = summarise(rows, {
|
package/src/commands/decide.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// a rulings skeleton, and structured `simt-affects` + `simt-supersedes` frontmatter
|
|
3
3
|
// (plan 0003 R34).
|
|
4
4
|
|
|
5
|
-
import { join
|
|
5
|
+
import { join } from 'node:path';
|
|
6
6
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
7
7
|
import { CLI } from '../name.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
|
|
9
9
|
import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
|
|
10
10
|
import { commitJournal } from '../journal.mjs';
|
|
11
11
|
import { diffField, emitDiffs } from '../diff.mjs';
|
|
@@ -15,9 +15,10 @@ export async function decideCommand(args, flags) {
|
|
|
15
15
|
const root = flags.root ?? process.cwd();
|
|
16
16
|
|
|
17
17
|
if (flags.check === true) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
18
|
+
// Q55: one resolution — the manifest key, then the detected directory —
|
|
19
|
+
// and the cutoff from the same config, never a hard-coded number or path.
|
|
20
|
+
const relDir = await decisionsDir(root);
|
|
21
|
+
const res = await lintDecisions(join(root, relDir), { historicalCutoff: await decisionsCutoff(root) });
|
|
21
22
|
|
|
22
23
|
if (isJson(flags)) {
|
|
23
24
|
emitJson({
|
|
@@ -9,7 +9,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
9
9
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
10
10
|
import { readManifest } from '../projection/manifest.mjs';
|
|
11
11
|
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
claimsVerification,
|
|
14
|
+
evidenceMode,
|
|
15
|
+
hasTestEvidence,
|
|
16
|
+
indexTreeId,
|
|
17
|
+
readLedger,
|
|
18
|
+
treeId,
|
|
19
|
+
} from '../ledger/index.mjs';
|
|
13
20
|
|
|
14
21
|
export async function evidenceCommand(args, flags) {
|
|
15
22
|
const root = flags.root ?? process.cwd();
|
|
@@ -72,14 +79,17 @@ async function checkClaim(root, file, flags) {
|
|
|
72
79
|
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
|
73
80
|
return EXIT.OK;
|
|
74
81
|
}
|
|
82
|
+
// A commit records the index, not the worktree: `git add -p` can commit bytes
|
|
83
|
+
// the worktree tree never had (review 0003 Q18). Match on the staged tree, with
|
|
84
|
+
// the worktree tree as the fallback when git cannot name the index.
|
|
75
85
|
let tree;
|
|
76
86
|
try {
|
|
77
|
-
tree = await treeId(root);
|
|
87
|
+
tree = (await indexTreeId(root)) ?? (await treeId(root));
|
|
78
88
|
} catch (error) {
|
|
79
89
|
return fail(`could not name this tree (${error.message})`, EXIT.UNSUPPORTED, 'run this inside a git repository');
|
|
80
90
|
}
|
|
81
91
|
const { entries } = await readLedger(root);
|
|
82
|
-
const supported = entries
|
|
92
|
+
const supported = hasTestEvidence(entries, tree);
|
|
83
93
|
const payload = { ok: supported || mode === 'warn', command: 'evidence check', claim: true, supported, mode, tree };
|
|
84
94
|
if (supported) {
|
|
85
95
|
if (isJson(flags)) emitJson(payload, { pretty: flags.pretty === true });
|
package/src/commands/gate.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// this tool knows how to read is compacted; the raw log is kept.
|
|
7
7
|
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
9
10
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
11
12
|
import { promisify } from 'node:util';
|
|
12
13
|
|
|
13
14
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
@@ -15,7 +16,8 @@ import { CLI } from '../name.mjs';
|
|
|
15
16
|
import { detectTestRunner } from '../bench/mine.mjs';
|
|
16
17
|
import { compactOutput } from '../index/compact.mjs';
|
|
17
18
|
import { readImportEdges, selectTests } from '../index/gate.mjs';
|
|
18
|
-
import { packageOf } from '../index/store.mjs';
|
|
19
|
+
import { langOf, packageOf } from '../index/store.mjs';
|
|
20
|
+
import { localRel } from '../state.mjs';
|
|
19
21
|
|
|
20
22
|
const exec = promisify(execFile);
|
|
21
23
|
|
|
@@ -31,31 +33,81 @@ export async function gateCommand(args, flags) {
|
|
|
31
33
|
}
|
|
32
34
|
if (changed.length === 0) {
|
|
33
35
|
if (flags.changed === true) {
|
|
36
|
+
// No change: nothing is unmatched and no language is implicated, so the
|
|
37
|
+
// graph is vacuously resolved and the verdict is still said out loud.
|
|
34
38
|
if (isJson(flags)) {
|
|
35
|
-
emitJson(
|
|
39
|
+
emitJson(
|
|
40
|
+
{ ok: true, command: 'gate', root, selected: [], unmatched: [], graph: 'resolved', verdict: 'nothing-selected', code: 0 },
|
|
41
|
+
{ pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 },
|
|
42
|
+
);
|
|
36
43
|
} else {
|
|
37
|
-
emit('selected 0', { maxBytes: flags.maxBytes ?? 0 });
|
|
44
|
+
emit('selected 0\nverdict nothing-selected', { maxBytes: flags.maxBytes ?? 0 });
|
|
38
45
|
}
|
|
39
46
|
return EXIT.OK;
|
|
40
47
|
}
|
|
41
48
|
return fail(`nothing changed`, EXIT.USAGE, `pass a path, or ${CLI} gate --changed`);
|
|
42
49
|
}
|
|
50
|
+
// Q40: an explicit path that is neither tracked nor on disk names nothing the
|
|
51
|
+
// gate could select from. That is a usage error, not an empty selection: the
|
|
52
|
+
// old answer (`ok: true`, no tests, exit 0) was indistinguishable from "this
|
|
53
|
+
// change is safe" and trained the reader to trust it.
|
|
54
|
+
const unknown = changed.filter((p) => !listed.files.includes(p) && !existsSync(join(root, p)));
|
|
55
|
+
if (unknown.length > 0) {
|
|
56
|
+
return fail(
|
|
57
|
+
`no such file: ${unknown.join(', ')}`,
|
|
58
|
+
EXIT.USAGE,
|
|
59
|
+
`pass a path git tracks, or ${CLI} gate --changed`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
43
62
|
const files = new Set(listed.files);
|
|
44
63
|
for (const file of changed) files.add(file);
|
|
45
|
-
const
|
|
64
|
+
const edges = await readImportEdges(root, files);
|
|
65
|
+
const selected = selectTests(changed, edges);
|
|
66
|
+
// Q40: the selection explains itself. `unmatched` names explicit paths the
|
|
67
|
+
// tracked tree does not carry (untracked but present, so the run proceeds);
|
|
68
|
+
// `graph` is `unresolved` when a language has edges but none of them reaches
|
|
69
|
+
// a tracked file (the Kotlin estate: thousands of specs, zero resolutions);
|
|
70
|
+
// `verdict` says the empty set out loud instead of leaving `selected: []` to
|
|
71
|
+
// be read as either safe or blind. `ok` stays as is until W27's opt-in.
|
|
72
|
+
const unmatched = changed.filter((p) => !listed.files.includes(p));
|
|
73
|
+
const graph = graphState(edges, files);
|
|
46
74
|
if (selected.length === 0) {
|
|
47
|
-
const payload = { ok: true, command: 'gate', root, selected, code: 0 };
|
|
75
|
+
const payload = { ok: true, command: 'gate', root, selected, unmatched, graph, verdict: 'nothing-selected', code: 0 };
|
|
48
76
|
if (isJson(flags)) {
|
|
49
77
|
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
50
78
|
return EXIT.OK;
|
|
51
79
|
}
|
|
52
|
-
|
|
80
|
+
const lines = ['selected 0', 'verdict nothing-selected'];
|
|
81
|
+
if (unmatched.length > 0) lines.push(`unmatched ${unmatched.join(', ')}`);
|
|
82
|
+
if (graph === 'unresolved') lines.push('graph unresolved');
|
|
83
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
53
84
|
return EXIT.OK;
|
|
54
85
|
}
|
|
55
|
-
return runSelected(root, flags, selected);
|
|
86
|
+
return runSelected(root, flags, selected, { unmatched, graph });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `resolved` unless some language present in the edges has total edges but
|
|
91
|
+
* zero reaching a tracked file. A language with no edges at all is not
|
|
92
|
+
* evidence of anything - an unimported file needs no resolution.
|
|
93
|
+
*/
|
|
94
|
+
function graphState(edges, files) {
|
|
95
|
+
const byLang = new Map();
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const lang = langOf(edge.src);
|
|
98
|
+
if (!lang) continue;
|
|
99
|
+
const stat = byLang.get(lang) ?? { total: 0, resolved: 0 };
|
|
100
|
+
stat.total += 1;
|
|
101
|
+
if (files.has(edge.dst)) stat.resolved += 1;
|
|
102
|
+
byLang.set(lang, stat);
|
|
103
|
+
}
|
|
104
|
+
for (const stat of byLang.values()) {
|
|
105
|
+
if (stat.total > 0 && stat.resolved === 0) return 'unresolved';
|
|
106
|
+
}
|
|
107
|
+
return 'resolved';
|
|
56
108
|
}
|
|
57
109
|
|
|
58
|
-
async function runSelected(root, flags, selected) {
|
|
110
|
+
async function runSelected(root, flags, selected, { unmatched = [], graph = 'resolved' } = {}) {
|
|
59
111
|
const cache = new Map();
|
|
60
112
|
const groups = new Map();
|
|
61
113
|
for (const file of selected) {
|
|
@@ -106,7 +158,18 @@ async function runSelected(root, flags, selected) {
|
|
|
106
158
|
}
|
|
107
159
|
const raw = await writeRaw(root, rawParts.join('\n'));
|
|
108
160
|
if (raw) text.push('', `raw ${raw}`);
|
|
109
|
-
const payload = {
|
|
161
|
+
const payload = {
|
|
162
|
+
ok: code === 0,
|
|
163
|
+
command: 'gate',
|
|
164
|
+
root,
|
|
165
|
+
selected,
|
|
166
|
+
unmatched,
|
|
167
|
+
graph,
|
|
168
|
+
verdict: 'selected',
|
|
169
|
+
code,
|
|
170
|
+
raw,
|
|
171
|
+
runs,
|
|
172
|
+
};
|
|
110
173
|
if (isJson(flags)) {
|
|
111
174
|
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
112
175
|
return code === 0 ? EXIT.OK : EXIT.FAIL;
|
|
@@ -116,12 +179,13 @@ async function runSelected(root, flags, selected) {
|
|
|
116
179
|
}
|
|
117
180
|
|
|
118
181
|
async function writeRaw(root, body) {
|
|
182
|
+
// Raw gate output is machine-local state (review 0003 Q19).
|
|
119
183
|
try {
|
|
120
|
-
const
|
|
184
|
+
const rel = localRel('gate', `gate-${Date.now()}.log`);
|
|
185
|
+
const dir = join(root, dirname(rel));
|
|
121
186
|
await mkdir(dir, { recursive: true });
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return `.simt/gate/${name}`;
|
|
187
|
+
await writeFile(join(root, rel), body);
|
|
188
|
+
return rel;
|
|
125
189
|
} catch {
|
|
126
190
|
return '';
|
|
127
191
|
}
|
|
@@ -129,8 +193,10 @@ async function writeRaw(root, body) {
|
|
|
129
193
|
|
|
130
194
|
async function tracked(root) {
|
|
131
195
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
196
|
+
// `-z`: without it git C-quotes a non-ASCII path (`src/\303\274n...`) and
|
|
197
|
+
// the reader's real filename never matches it (review 0003 Q39).
|
|
198
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
|
|
199
|
+
return { files: stdout.split('\0').filter(Boolean) };
|
|
134
200
|
} catch (error) {
|
|
135
201
|
return {
|
|
136
202
|
error: fail(
|
|
@@ -144,9 +210,9 @@ async function tracked(root) {
|
|
|
144
210
|
|
|
145
211
|
async function changedFiles(root) {
|
|
146
212
|
try {
|
|
147
|
-
const { stdout } = await exec('git', ['diff', '--name-only', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
|
|
148
|
-
const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, maxBuffer: 8 << 20 });
|
|
149
|
-
return { files: [...new Set([...stdout.split('\
|
|
213
|
+
const { stdout } = await exec('git', ['diff', '--name-only', '-z', 'HEAD'], { cwd: root, maxBuffer: 8 << 20 });
|
|
214
|
+
const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 8 << 20 });
|
|
215
|
+
return { files: [...new Set([...stdout.split('\0'), ...untracked.stdout.split('\0')].filter(Boolean))].sort() };
|
|
150
216
|
} catch (error) {
|
|
151
217
|
return {
|
|
152
218
|
error: fail(
|
package/src/commands/graph.mjs
CHANGED
|
@@ -31,16 +31,29 @@ export async function lintCommand(positional, flags) {
|
|
|
31
31
|
// client accept these files", and the second question needs no registry at all. A
|
|
32
32
|
// reader with no registry can still lint a skill directory, so the two are additive
|
|
33
33
|
// rather than one gating the other.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
//
|
|
35
|
+
// The pass runs only when asked for (`--spec`) and only against a verified
|
|
36
|
+
// registry: without it the default lint reported 37 warnings for a pass the
|
|
37
|
+
// payload claimed never ran (`spec: null`), and `ok` stayed true over errors
|
|
38
|
+
// the spec pass had just found (review 0003 Q35).
|
|
39
|
+
let specReport = { errors: [], warnings: [], checked: 0 };
|
|
40
|
+
if (spec !== null) {
|
|
41
|
+
if (registry && verified && workspace?.root) {
|
|
42
|
+
specReport = await lintSpec(registry, workspace.root);
|
|
43
|
+
} else {
|
|
44
|
+
warnings.push(
|
|
45
|
+
'spec pass skipped: it needs --spec against a registry verified on disk (see `verified` above)',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
errors.push(...specReport.errors);
|
|
38
50
|
warnings.push(...specReport.warnings);
|
|
51
|
+
const finalOk = ok && errors.length === 0;
|
|
39
52
|
|
|
40
53
|
if (isJson(flags)) {
|
|
41
54
|
emitJson(
|
|
42
55
|
{
|
|
43
|
-
ok,
|
|
56
|
+
ok: finalOk,
|
|
44
57
|
registry: path,
|
|
45
58
|
capabilities: registry?.capabilities?.length ?? 0,
|
|
46
59
|
skills: registry?.skills?.length ?? 0,
|
|
@@ -60,7 +73,7 @@ export async function lintCommand(positional, flags) {
|
|
|
60
73
|
},
|
|
61
74
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
62
75
|
);
|
|
63
|
-
return
|
|
76
|
+
return finalOk ? EXIT.OK : EXIT.REGISTRY;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
if (!registry) {
|