simt-dev 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/commands/hooks.mjs
CHANGED
|
@@ -13,19 +13,20 @@
|
|
|
13
13
|
// foreign hook is found by asking git rather than by reading config.
|
|
14
14
|
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { chmod, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { isAbsolute, join, relative } from 'node:path';
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
|
|
20
20
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
21
21
|
import { MANIFEST, readManifest } from '../projection/manifest.mjs';
|
|
22
22
|
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
23
|
-
import { readHooksPath, readHooksState } from '../compare/hooksstate.mjs';
|
|
23
|
+
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
24
24
|
import { GATES } from '../hooks/gates.mjs';
|
|
25
25
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
26
26
|
import { runGates } from '../hooks/dispatcher.mjs';
|
|
27
27
|
import { CLI } from '../name.mjs';
|
|
28
28
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
29
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
29
30
|
|
|
30
31
|
const exec = promisify(execFile);
|
|
31
32
|
|
|
@@ -70,12 +71,6 @@ async function runHook(args, flags) {
|
|
|
70
71
|
return result.failed ? EXIT.FAIL : EXIT.OK;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
74
|
-
|
|
75
|
-
function gateCount(manifest, event) {
|
|
76
|
-
return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
74
|
/**
|
|
80
75
|
* The path to write into a chained shim: relative to the repository's top level
|
|
81
76
|
* wherever the hook lives inside it.
|
|
@@ -106,19 +101,6 @@ async function relativeTarget(root, target) {
|
|
|
106
101
|
return rel === '' || rel.startsWith('..') ? resolved : rel;
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
/**
|
|
110
|
-
* Whether a missing shim for this event is a defect.
|
|
111
|
-
*
|
|
112
|
-
* A repo that declares no gate for an event has no reason to want a shim for it,
|
|
113
|
-
* so its absence is the correct end state. A repo with NO manifest is different:
|
|
114
|
-
* we do not know what it wants, so we cannot rule the shim out - absence of
|
|
115
|
-
* knowledge is not a decision.
|
|
116
|
-
*/
|
|
117
|
-
function wantsShim(manifest, exists, event) {
|
|
118
|
-
if (!exists) return true;
|
|
119
|
-
return gateCount(manifest, event) > 0;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
104
|
async function installHooks(root, flags) {
|
|
123
105
|
const { manifest, exists, error } = await readManifest(root);
|
|
124
106
|
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
@@ -126,7 +108,8 @@ async function installHooks(root, flags) {
|
|
|
126
108
|
return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
|
|
127
109
|
}
|
|
128
110
|
|
|
129
|
-
const
|
|
111
|
+
const declaresGate = (event) => wantsShim(manifest, exists, event);
|
|
112
|
+
const state = await readHooksState(root, { declaresGate });
|
|
130
113
|
const current = state.current;
|
|
131
114
|
const wants = HOOKS_DIR;
|
|
132
115
|
|
|
@@ -139,30 +122,19 @@ async function installHooks(root, flags) {
|
|
|
139
122
|
// for a plan and then `--yes` - advice that printed a plan and wrote anyway.
|
|
140
123
|
const mode = writeMode(flags);
|
|
141
124
|
|
|
125
|
+
// The release this install renders, and the kind of repository it installs into: the
|
|
126
|
+
// remedy a shim prints when it cannot find the binary must name the install that
|
|
127
|
+
// actually works here — a devDependency where package.json exists, a pinned global
|
|
128
|
+
// install where there is no node_modules to pin in.
|
|
129
|
+
const version = packageVersion();
|
|
130
|
+
const npm = await hasNpm(root);
|
|
131
|
+
|
|
142
132
|
// Only install a shim for an event that declares at least one gate. A shim with
|
|
143
133
|
// an empty gate list enforces nothing, yet merely existing it REPLACES whatever
|
|
144
134
|
// git was running - so installing one over a working hand-written hook is a
|
|
145
135
|
// downgrade dressed as an upgrade. `init` already says this in prose; install
|
|
146
136
|
// has to agree, or the two commands describe different tools.
|
|
147
137
|
const wanted = EVENTS.filter((e) => gateCount(manifest, e) > 0);
|
|
148
|
-
if (wanted.length === 0) {
|
|
149
|
-
if (isJson(flags)) {
|
|
150
|
-
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
151
|
-
return EXIT.FAIL;
|
|
152
|
-
}
|
|
153
|
-
emit(
|
|
154
|
-
[
|
|
155
|
-
`repo ${root}`,
|
|
156
|
-
'',
|
|
157
|
-
`BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
|
|
158
|
-
' Installing shims anyway would stop running whatever git runs now',
|
|
159
|
-
' and replace it with a gate that checks nothing.',
|
|
160
|
-
`REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
|
|
161
|
-
].join('\n'),
|
|
162
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
163
|
-
);
|
|
164
|
-
return EXIT.FAIL;
|
|
165
|
-
}
|
|
166
138
|
|
|
167
139
|
// Foreign hooks on events this tool does NOT gate. Routing replaces the whole
|
|
168
140
|
// directory git reads from, so these have to be carried across explicitly or they
|
|
@@ -179,7 +151,21 @@ async function installHooks(root, flags) {
|
|
|
179
151
|
// Keying this on the event vocabulary instead of on the manifest cost a repository
|
|
180
152
|
// git-lfs hook: pre-push is a gated event in general, but this repo declares no
|
|
181
153
|
// pre-push gate, so nothing replaces its hook and routing would have dropped it.
|
|
182
|
-
|
|
154
|
+
// A chained shim and the hook it chains must be two different files.
|
|
155
|
+
//
|
|
156
|
+
// `foreignHooks` lists every executable file in the directory git reads hooks from -
|
|
157
|
+
// and when that directory is this tool's own, a file a person dropped in there is
|
|
158
|
+
// listed too. Chaining it means writing a shim at `.simt-hooks/<name>` whose TARGET is
|
|
159
|
+
// `.simt-hooks/<name>`: the same path. The shim overwrites the hook it was written to
|
|
160
|
+
// preserve and then runs itself, so the hook stops working and git recurses in a loop
|
|
161
|
+
// nothing reports. Confirmed at the previous release, which printed
|
|
162
|
+
// `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
|
|
163
|
+
//
|
|
164
|
+
// So such a hook is not chained. It is not stopped either - nothing overwrites it, so
|
|
165
|
+
// git keeps running it - and it is reported as what it is below.
|
|
166
|
+
const chainable = (event, target) => target !== fileFor(event);
|
|
167
|
+
|
|
168
|
+
const candidates = [
|
|
183
169
|
// Already chained by an earlier install, read back off disk: re-rendered from the
|
|
184
170
|
// file's own contents so the bytes cannot drift on a second run.
|
|
185
171
|
...(await Promise.all(
|
|
@@ -198,12 +184,71 @@ async function installHooks(root, flags) {
|
|
|
198
184
|
.filter((f) => gateCount(manifest, f.event) === 0)
|
|
199
185
|
.map(async (f) => ({ event: f.event, target: await relativeTarget(root, f.path) })),
|
|
200
186
|
)),
|
|
201
|
-
]
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const chained = candidates
|
|
190
|
+
.filter((c) => chainable(c.event, c.target))
|
|
191
|
+
.reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
|
|
192
|
+
|
|
193
|
+
// Hooks that keep running untouched because chaining them was impossible. Separate
|
|
194
|
+
// from `chained` because the notes below say opposite things about the two: one runs
|
|
195
|
+
// the hook for you, the other leaves it alone and it still runs. Reporting both as
|
|
196
|
+
// "stopped running" - which is what this did - was false for this one and would push an
|
|
197
|
+
// operator to restore a hook that is already running.
|
|
198
|
+
const leftRunning = candidates.filter((c) => !chainable(c.event, c.target));
|
|
199
|
+
|
|
200
|
+
// Shims this tool wrote for an event the manifest no longer gates.
|
|
201
|
+
//
|
|
202
|
+
// `install` only ever ADDED files, so dropping a gate from the manifest left its shim
|
|
203
|
+
// on disk: still routed through `core.hooksPath`, still `PRESENT` in `hooks status`,
|
|
204
|
+
// and enforcing nothing. That is the exact harm the refusal below is written to
|
|
205
|
+
// prevent - reached by the sanctioned path rather than by the blocked one.
|
|
206
|
+
//
|
|
207
|
+
// It was a dead end rather than only a defect. With the LAST gate removed there is
|
|
208
|
+
// nothing to install, so `install` refused and wrote nothing, leaving the shim, the
|
|
209
|
+
// routing, and a remedy - "add the gates this repo can actually run" - telling a
|
|
210
|
+
// reader to undo the change they had just made, for a gate that may be gone because
|
|
211
|
+
// the script it ran was deleted. The only way out was to re-declare the gate and
|
|
212
|
+
// delete the file by hand.
|
|
213
|
+
//
|
|
214
|
+
// Read from `state.installed`, which describes every shim this install is responsible
|
|
215
|
+
// for, and not from `state.staleOurs`: that loop skips a shim the moment it matches
|
|
216
|
+
// what this run would render, so a *current* shim for an ungated event never reaches
|
|
217
|
+
// it - which is the common case here and the one that matters. `ours` on each entry is
|
|
218
|
+
// what keeps a file a person wrote in this directory from being deleted on their
|
|
219
|
+
// behalf; `wanted` and `chained` are what this run writes, so what is left is exactly
|
|
220
|
+
// the set nothing else here touches.
|
|
221
|
+
const obsolete = state.installed.filter(
|
|
222
|
+
(i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Nothing to write and nothing to take away is the only refusal. It sits after
|
|
226
|
+
// `obsolete` because a repository whose last gate just left the manifest has
|
|
227
|
+
// something to take away, and blocking that one left no way out except undoing
|
|
228
|
+
// the change by hand - see the note on `obsolete`.
|
|
229
|
+
if (wanted.length === 0 && obsolete.length === 0) {
|
|
230
|
+
if (isJson(flags)) {
|
|
231
|
+
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
232
|
+
return EXIT.FAIL;
|
|
233
|
+
}
|
|
234
|
+
emit(
|
|
235
|
+
[
|
|
236
|
+
`repo ${root}`,
|
|
237
|
+
'',
|
|
238
|
+
`BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
|
|
239
|
+
' Installing shims anyway would stop running whatever git runs now',
|
|
240
|
+
' and replace it with a gate that checks nothing.',
|
|
241
|
+
`REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
|
|
242
|
+
].join('\n'),
|
|
243
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
244
|
+
);
|
|
245
|
+
return EXIT.FAIL;
|
|
246
|
+
}
|
|
202
247
|
|
|
203
248
|
const plan = [];
|
|
204
249
|
for (const event of wanted) {
|
|
205
250
|
const path = join(root, fileFor(event));
|
|
206
|
-
const source = renderShim(event);
|
|
251
|
+
const source = renderShim(event, { npm, version });
|
|
207
252
|
const existing = await readIfExists(path);
|
|
208
253
|
if (existing === source) {
|
|
209
254
|
plan.push({ file: fileFor(event), action: 'unchanged' });
|
|
@@ -224,7 +269,26 @@ async function installHooks(root, flags) {
|
|
|
224
269
|
note: `chained: runs ${c.target}`,
|
|
225
270
|
});
|
|
226
271
|
}
|
|
227
|
-
const
|
|
272
|
+
for (const h of obsolete) {
|
|
273
|
+
plan.push({
|
|
274
|
+
file: fileFor(h.event),
|
|
275
|
+
action: 'removed',
|
|
276
|
+
// Named in the plan because the reader approving this write is about to lose a
|
|
277
|
+
// file, and "REMOVED pre-push" does not say why - which is the difference between
|
|
278
|
+
// agreeing with it and discovering later that a gate had been dropped by accident.
|
|
279
|
+
note: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const routed = wanted.length > 0 || chained.length > 0;
|
|
283
|
+
|
|
284
|
+
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
285
|
+
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
286
|
+
// pointed at the directory would stop its own hooks running - the plan has to say so,
|
|
287
|
+
// because a plan that prints "core.hooksPath updated .simt-hooks -> .simt-hooks" while
|
|
288
|
+
// the command unsets it is worse than no plan.
|
|
289
|
+
const leaves = routed ? wants : null;
|
|
290
|
+
const pathAction =
|
|
291
|
+
leaves === current ? 'unchanged' : leaves === null ? 'removed' : current === null ? 'created' : 'updated';
|
|
228
292
|
|
|
229
293
|
// Refuse to take over routing git is already using without explicit consent.
|
|
230
294
|
// Two cases, and the second one is the one that bites: core.hooksPath already
|
|
@@ -238,7 +302,7 @@ async function installHooks(root, flags) {
|
|
|
238
302
|
? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
|
|
239
303
|
: [` existing ${HOOKS_PATH_KEY} = ${current}`];
|
|
240
304
|
if (isJson(flags)) {
|
|
241
|
-
emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet:
|
|
305
|
+
emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet: leaves, takeover: true, foreign: state.foreign, plan, filesWritten: false });
|
|
242
306
|
return EXIT.FAIL;
|
|
243
307
|
}
|
|
244
308
|
emit(
|
|
@@ -258,14 +322,20 @@ async function installHooks(root, flags) {
|
|
|
258
322
|
|
|
259
323
|
if (!mode.apply) {
|
|
260
324
|
if (isJson(flags)) {
|
|
261
|
-
|
|
325
|
+
// `leaves`, not `wants`. A plan that would remove the last shim also un-routes
|
|
326
|
+
// git, and a payload promising to set a directory the command is about to unset
|
|
327
|
+
// is the same lie in a shape a machine reads.
|
|
328
|
+
emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: leaves, plan, filesWritten: false });
|
|
262
329
|
return EXIT.OK;
|
|
263
330
|
}
|
|
264
331
|
emit(
|
|
265
332
|
renderPlan(
|
|
266
333
|
root,
|
|
267
334
|
current,
|
|
268
|
-
|
|
335
|
+
// `leaves`, for the same reason the `--json` payload uses it: this plan says
|
|
336
|
+
// REMOVED beside the last shim, and a reader of it is entitled to read that
|
|
337
|
+
// the routing is going too, rather than being told it is unchanged.
|
|
338
|
+
leaves,
|
|
269
339
|
pathAction,
|
|
270
340
|
plan,
|
|
271
341
|
mode.dryRun
|
|
@@ -281,7 +351,7 @@ async function installHooks(root, flags) {
|
|
|
281
351
|
await mkdir(join(root, HOOKS_DIR), { recursive: true });
|
|
282
352
|
for (const event of wanted) {
|
|
283
353
|
const path = join(root, fileFor(event));
|
|
284
|
-
await writeFile(path, renderShim(event));
|
|
354
|
+
await writeFile(path, renderShim(event, { npm, version }));
|
|
285
355
|
await chmod(path, 0o755);
|
|
286
356
|
}
|
|
287
357
|
for (const c of chained) {
|
|
@@ -289,7 +359,19 @@ async function installHooks(root, flags) {
|
|
|
289
359
|
await writeFile(path, renderChainedShim(c.event, c.target));
|
|
290
360
|
await chmod(path, 0o755);
|
|
291
361
|
}
|
|
292
|
-
|
|
362
|
+
for (const h of obsolete) {
|
|
363
|
+
await rm(join(root, fileFor(h.event)));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
367
|
+
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
368
|
+
// `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
|
|
369
|
+
// only way back, and this is the only place that knows the last shim just left.
|
|
370
|
+
if (routed) {
|
|
371
|
+
await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
|
|
372
|
+
} else if (current === wants) {
|
|
373
|
+
await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
|
|
374
|
+
}
|
|
293
375
|
|
|
294
376
|
const notes = [];
|
|
295
377
|
const chainedEvents = new Set(chained.map((c) => c.event));
|
|
@@ -299,28 +381,62 @@ async function installHooks(root, flags) {
|
|
|
299
381
|
// chained and genuinely stopped running. Saying "stopped running" about both would
|
|
300
382
|
// be wrong about the first, and it would push an operator to restore a hook that is
|
|
301
383
|
// already being run for them.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
384
|
+
if (chainedEvents.has(f.event)) {
|
|
385
|
+
notes.push(
|
|
386
|
+
`chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
|
|
387
|
+
);
|
|
388
|
+
} else if (leftRunning.some((c) => c.event === f.event)) {
|
|
389
|
+
// A third case, and the one that was misreported: a hook this tool could not chain
|
|
390
|
+
// because the shim would have had to run itself. Nothing overwrote it, so it still
|
|
391
|
+
// runs - which is the opposite of what the branch below says.
|
|
392
|
+
notes.push(
|
|
393
|
+
`${f.event} at ${f.path} (${f.bytes} bytes) is in this tool's own hooks directory, so it cannot be chained to itself and was left untouched; it keeps running`,
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
notes.push(
|
|
397
|
+
`stopped running ${f.event} at ${f.path} (${f.bytes} bytes); the file is still on disk, untouched - declare it in ${MANIFEST_FILE} gates.commands to have the dispatcher run it`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
307
400
|
}
|
|
308
401
|
if (state.foreign.length === 0 && takeover) {
|
|
309
402
|
notes.push(`replaced an existing core.${HOOKS_PATH_KEY} (${current}); the old hooks are still on disk, untouched`);
|
|
310
403
|
}
|
|
404
|
+
for (const h of obsolete) {
|
|
405
|
+
notes.push(
|
|
406
|
+
`removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (leaves === null && current !== null) {
|
|
410
|
+
notes.push(
|
|
411
|
+
`unset ${HOOKS_PATH_KEY}; git reads its hooks from .git/hooks again, so the repository's own hooks run`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
311
414
|
|
|
312
415
|
if (isJson(flags)) {
|
|
313
|
-
emitJson({ ok: true, command: 'hooks install', root, hooksPath:
|
|
416
|
+
emitJson({ ok: true, command: 'hooks install', root, hooksPath: leaves, previousHooksPath: current, plan, filesWritten: true, notes });
|
|
314
417
|
return EXIT.OK;
|
|
315
418
|
}
|
|
316
|
-
emit(
|
|
419
|
+
emit(
|
|
420
|
+
renderPlan(
|
|
421
|
+
root,
|
|
422
|
+
current,
|
|
423
|
+
leaves,
|
|
424
|
+
pathAction,
|
|
425
|
+
plan,
|
|
426
|
+
routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
|
|
427
|
+
manifest,
|
|
428
|
+
),
|
|
429
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
430
|
+
);
|
|
317
431
|
for (const n of notes) note(`note ${n}`);
|
|
318
432
|
note(`note never committed or pushed (ADR-001); review, then commit yourself`);
|
|
319
433
|
return EXIT.OK;
|
|
320
434
|
}
|
|
321
435
|
|
|
436
|
+
// `wants` is null when this install removes the last shim, and "null" is not what a
|
|
437
|
+
// reader should read in a plan that says REMOVED next to it.
|
|
322
438
|
function renderPlan(root, current, wants, pathAction, plan, headline, manifest) {
|
|
323
|
-
const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants}`, ''];
|
|
439
|
+
const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants ?? '(unset)'}`, ''];
|
|
324
440
|
for (const p of plan) lines.push(`${p.action.toUpperCase().padEnd(9)} ${p.file}${p.note ? ` (${p.note})` : ''}`);
|
|
325
441
|
lines.push('', headline);
|
|
326
442
|
lines.push(`gates preCommit = ${describeGates(manifest, 'preCommit')}`);
|
package/src/commands/index.mjs
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
// succeeds. An agent that calls `simt-dev compare` on an unreleased build must get a
|
|
7
7
|
// failure, not an empty result it will read as "no drift".
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
EXIT,
|
|
11
|
+
emit,
|
|
12
|
+
emitJson,
|
|
13
|
+
fail,
|
|
14
|
+
isJson,
|
|
15
|
+
jsonFailMode,
|
|
16
|
+
jsonIndentDefault,
|
|
17
|
+
} from '../output/index.mjs';
|
|
10
18
|
import { help } from './help.mjs';
|
|
11
19
|
import { lintCommand } from './graph.mjs';
|
|
12
20
|
import {
|
|
@@ -15,6 +23,7 @@ import {
|
|
|
15
23
|
briefCommand as skillsBrief,
|
|
16
24
|
showCommand as skillsShow,
|
|
17
25
|
whyCommand as skillsWhy,
|
|
26
|
+
evalCommand as skillsEval,
|
|
18
27
|
} from './skills.mjs';
|
|
19
28
|
import { syncCommand, checkCommand } from './sync.mjs';
|
|
20
29
|
import { initCommand } from './init.mjs';
|
|
@@ -25,6 +34,8 @@ import { upgradeCommand } from './upgrade.mjs';
|
|
|
25
34
|
import { mcpCommand } from './mcp.mjs';
|
|
26
35
|
import { installCommand } from './skillsinstall.mjs';
|
|
27
36
|
import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
|
|
37
|
+
import { tokensCommand } from './tokens.mjs';
|
|
38
|
+
import { harnessCommand } from './harness.mjs';
|
|
28
39
|
import { parse } from '../argv.mjs';
|
|
29
40
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
30
41
|
import { resolveWorkspace } from '../workspace.mjs';
|
|
@@ -42,11 +53,69 @@ const GROUPS = new Map([
|
|
|
42
53
|
['graph', 'lint'],
|
|
43
54
|
['rules', 'list'],
|
|
44
55
|
['hooks', 'status'],
|
|
56
|
+
['harness', 'detect'],
|
|
45
57
|
]);
|
|
46
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Every command this binary dispatches, in one place.
|
|
61
|
+
*
|
|
62
|
+
* It was a literal inside `dispatch`, which is why `help` and the CLI could disagree:
|
|
63
|
+
* nothing could enumerate the real list, so `--help` was a hand-maintained template and
|
|
64
|
+
* three working commands (`graph lint`, `hooks status`, `hooks run`) were never written
|
|
65
|
+
* into it. A list that cannot be read cannot be checked, and the reader of `--help` is
|
|
66
|
+
* an agent choosing a command — the one consumer with no way to discover the omission
|
|
67
|
+
* except by guessing.
|
|
68
|
+
*
|
|
69
|
+
* Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
|
|
70
|
+
* just correct: nothing in the map depends on the call.
|
|
71
|
+
*/
|
|
72
|
+
export const IMPLEMENTED = {
|
|
73
|
+
doctor: runDoctor,
|
|
74
|
+
'graph lint': lintCommand,
|
|
75
|
+
'skills index': skillsIndex,
|
|
76
|
+
'skills find': skillsFind,
|
|
77
|
+
'skills brief': skillsBrief,
|
|
78
|
+
'skills show': skillsShow,
|
|
79
|
+
'skills why': skillsWhy,
|
|
80
|
+
'skills eval': skillsEval,
|
|
81
|
+
'skills install': installCommand,
|
|
82
|
+
'rules list': rulesList,
|
|
83
|
+
'rules show': rulesShow,
|
|
84
|
+
'rules why': rulesWhy,
|
|
85
|
+
sync: syncCommand,
|
|
86
|
+
check: checkCommand,
|
|
87
|
+
init: initCommand,
|
|
88
|
+
ci: ciCommand,
|
|
89
|
+
'hooks install': (args, flags) => hooksCommand('install', args, flags),
|
|
90
|
+
'hooks status': (args, flags) => hooksCommand('status', args, flags),
|
|
91
|
+
'hooks run': (args, flags) => hooksCommand('run', args, flags),
|
|
92
|
+
compare: compareCommand,
|
|
93
|
+
tokens: tokensCommand,
|
|
94
|
+
'harness detect': harnessCommand,
|
|
95
|
+
upgrade: upgradeCommand,
|
|
96
|
+
mcp: mcpCommand,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The dispatchable command paths, sorted. The authoritative answer to "what can this
|
|
101
|
+
* binary do", as opposed to the answer `--help` gives.
|
|
102
|
+
*/
|
|
103
|
+
export const COMMANDS = Object.keys(IMPLEMENTED).sort();
|
|
104
|
+
|
|
47
105
|
export async function dispatch(argv) {
|
|
48
106
|
const { flags, positional } = parseOrExit(argv);
|
|
49
107
|
|
|
108
|
+
// The JSON shape is decided once, here, and every command inherits it. Threading a
|
|
109
|
+
// `--pretty` through twenty-five call sites would mean twenty-five places to forget,
|
|
110
|
+
// and the one that forgot would be the one a reader pipes into a script.
|
|
111
|
+
//
|
|
112
|
+
// Both halves are set before anything can fail, because a command that fails under
|
|
113
|
+
// `--json` has still produced a result: the reason it failed. Set here, an unknown
|
|
114
|
+
// command answers in the same shape a working one does, and the caller's parse
|
|
115
|
+
// never sees an empty document.
|
|
116
|
+
jsonIndentDefault(flags.pretty === true);
|
|
117
|
+
jsonFailMode(flags.json === true);
|
|
118
|
+
|
|
50
119
|
// Help is shown only when explicitly asked for. An unknown command must fail with
|
|
51
120
|
// a usage code, not print 40 lines of prose and exit 0 — an agent that mistyped
|
|
52
121
|
// needs to be told so, not handed a wall of text it may misread as output.
|
|
@@ -76,29 +145,6 @@ export async function dispatch(argv) {
|
|
|
76
145
|
);
|
|
77
146
|
}
|
|
78
147
|
const args = positional.slice(offset);
|
|
79
|
-
const IMPLEMENTED = {
|
|
80
|
-
doctor: runDoctor,
|
|
81
|
-
'graph lint': lintCommand,
|
|
82
|
-
'skills index': skillsIndex,
|
|
83
|
-
'skills find': skillsFind,
|
|
84
|
-
'skills brief': skillsBrief,
|
|
85
|
-
'skills show': skillsShow,
|
|
86
|
-
'skills why': skillsWhy,
|
|
87
|
-
'skills install': installCommand,
|
|
88
|
-
'rules list': rulesList,
|
|
89
|
-
'rules show': rulesShow,
|
|
90
|
-
'rules why': rulesWhy,
|
|
91
|
-
sync: syncCommand,
|
|
92
|
-
check: checkCommand,
|
|
93
|
-
init: initCommand,
|
|
94
|
-
ci: ciCommand,
|
|
95
|
-
'hooks install': (args, flags) => hooksCommand('install', args, flags),
|
|
96
|
-
'hooks status': (args, flags) => hooksCommand('status', args, flags),
|
|
97
|
-
'hooks run': (args, flags) => hooksCommand('run', args, flags),
|
|
98
|
-
compare: compareCommand,
|
|
99
|
-
upgrade: upgradeCommand,
|
|
100
|
-
mcp: mcpCommand,
|
|
101
|
-
};
|
|
102
148
|
const run = IMPLEMENTED[path];
|
|
103
149
|
if (run) return run(args, flags);
|
|
104
150
|
return fail(`unknown command \`${path}\``, EXIT.USAGE);
|
|
@@ -117,34 +163,20 @@ function commandPath(first, second) {
|
|
|
117
163
|
if (second === undefined) return `${first} ${group}`;
|
|
118
164
|
// Reject a second token that is not a subcommand of this group, rather than
|
|
119
165
|
// silently routing `simt-dev skills nonsense` to the default subcommand.
|
|
166
|
+
//
|
|
167
|
+
// Read from IMPLEMENTED rather than from a second list. The list existed because
|
|
168
|
+
// there was no way to enumerate what dispatch could run — `IMPLEMENTED` was an
|
|
169
|
+
// object literal inside `dispatch()` — and a hand-kept copy of it had already
|
|
170
|
+
// drifted: it carried `doctor`, which `commandPath` can never match, because
|
|
171
|
+
// `doctor` is not a group and this branch is only reached when `GROUPS.has(first)`.
|
|
172
|
+
// Two lists of the same fact is the defect S15 was about in the help text.
|
|
120
173
|
const key = `${first} ${second}`;
|
|
121
|
-
if (!STEPS.has(key) && !
|
|
174
|
+
if (!STEPS.has(key) && !(key in IMPLEMENTED)) {
|
|
122
175
|
return null;
|
|
123
176
|
}
|
|
124
177
|
return key;
|
|
125
178
|
}
|
|
126
179
|
|
|
127
|
-
/**
|
|
128
|
-
* Subcommands implemented now, so they must not be rejected as unknown.
|
|
129
|
-
* Implemented paths live here; declared-but-unimplemented ones live in STEPS.
|
|
130
|
-
*/
|
|
131
|
-
const KNOWN_SUBS = new Set([
|
|
132
|
-
'doctor',
|
|
133
|
-
'graph lint',
|
|
134
|
-
'skills index',
|
|
135
|
-
'skills find',
|
|
136
|
-
'skills brief',
|
|
137
|
-
'skills show',
|
|
138
|
-
'skills why',
|
|
139
|
-
'skills install',
|
|
140
|
-
'rules list',
|
|
141
|
-
'rules show',
|
|
142
|
-
'rules why',
|
|
143
|
-
'hooks install',
|
|
144
|
-
'hooks status',
|
|
145
|
-
'hooks run',
|
|
146
|
-
]);
|
|
147
|
-
|
|
148
180
|
function parseOrExit(argv) {
|
|
149
181
|
const parsed = parse(argv);
|
|
150
182
|
if (!parsed.ok) return { flags: {}, positional: [] };
|
|
@@ -183,29 +215,23 @@ async function runDoctor(positional, flags) {
|
|
|
183
215
|
const code = failure ? EXIT.CONFIG : EXIT.OK;
|
|
184
216
|
|
|
185
217
|
if (isJson(flags)) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
...(failure ?? {}),
|
|
204
|
-
},
|
|
205
|
-
null,
|
|
206
|
-
2,
|
|
207
|
-
)}\n`,
|
|
208
|
-
);
|
|
218
|
+
emitJson({
|
|
219
|
+
ok: code === EXIT.OK,
|
|
220
|
+
command: 'doctor',
|
|
221
|
+
root,
|
|
222
|
+
...result,
|
|
223
|
+
// The workspace answer is a fact about which directory the tool would
|
|
224
|
+
// act on next (ADR-014). It is printed in the text report; leaving it
|
|
225
|
+
// out of the JSON meant a machine-readable caller could not see it at
|
|
226
|
+
// all, which is the one-question-two-answers defect again.
|
|
227
|
+
workspace: {
|
|
228
|
+
root: ws.root ?? null,
|
|
229
|
+
source: ws.source,
|
|
230
|
+
detail: ws.detail,
|
|
231
|
+
error: ws.error ?? null,
|
|
232
|
+
},
|
|
233
|
+
...(failure ?? {}),
|
|
234
|
+
});
|
|
209
235
|
return code;
|
|
210
236
|
}
|
|
211
237
|
if (result.error) {
|