simt-dev 0.6.2 → 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 +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- 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-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- 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 +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- 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 +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- 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 +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- 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 +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- 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 +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- 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 +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- 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 +82 -21
- 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/shared.mjs +4 -1
- 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 +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `simt status --line`
|
|
2
|
+
//
|
|
3
|
+
// One line from the last `check`, for shell prompts, tmux and agent status
|
|
4
|
+
// lines: `simt PASS ✓rules ✓hooks 3/3gates`. The line is the gate state *as
|
|
5
|
+
// of the last check* — it reads only `.simt/check.json` plus one
|
|
6
|
+
// `git rev-parse HEAD`, so it answers in milliseconds while `check` itself
|
|
7
|
+
// takes the time projection takes.
|
|
8
|
+
//
|
|
9
|
+
// Bare `status` is a usage error, the way `plan` without a subcommand is: the
|
|
10
|
+
// only mode this command has is `--line`, and running something else by
|
|
11
|
+
// default would invent a second answer to what the status is.
|
|
12
|
+
|
|
13
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
+
import { CLI } from '../name.mjs';
|
|
15
|
+
import { currentHead, readCheckCache, renderStatusLine } from '../status.mjs';
|
|
16
|
+
|
|
17
|
+
export async function statusCommand(args, flags) {
|
|
18
|
+
const root = flags.root ?? args[0] ?? process.cwd();
|
|
19
|
+
if (flags.line !== true) {
|
|
20
|
+
return fail('status needs --line', EXIT.USAGE, `run: ${CLI} status --line --root ${root}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const record = await readCheckCache(root);
|
|
24
|
+
if (record === null) {
|
|
25
|
+
// Informational, not a failure: a repository that never ran `check` — or
|
|
26
|
+
// one a writer just invalidated — has no state to report, and a prompt
|
|
27
|
+
// that errored until the first check would train its reader to ignore it.
|
|
28
|
+
if (isJson(flags)) {
|
|
29
|
+
emitJson({ ok: true, command: 'status', root, line: null, cached: false, stale: false });
|
|
30
|
+
return EXIT.OK;
|
|
31
|
+
}
|
|
32
|
+
emit('simt no cache - run check');
|
|
33
|
+
return EXIT.OK;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const head = await currentHead(root);
|
|
37
|
+
// Stale when HEAD moved since the check ran. When either side is unknown
|
|
38
|
+
// there is nothing to compare, so the record stands as written.
|
|
39
|
+
const stale = head !== null && record.head !== null && head !== record.head;
|
|
40
|
+
const line = renderStatusLine(record, stale);
|
|
41
|
+
const code = record.blocking === 0 ? EXIT.OK : EXIT.FAIL;
|
|
42
|
+
|
|
43
|
+
if (isJson(flags)) {
|
|
44
|
+
emitJson({
|
|
45
|
+
ok: code === EXIT.OK,
|
|
46
|
+
command: 'status',
|
|
47
|
+
root,
|
|
48
|
+
line,
|
|
49
|
+
cached: true,
|
|
50
|
+
stale,
|
|
51
|
+
rulesOk: record.rulesOk,
|
|
52
|
+
hooksOk: record.hooksOk,
|
|
53
|
+
gatesRun: record.gatesRun,
|
|
54
|
+
gatesTotal: record.gatesTotal,
|
|
55
|
+
blocking: record.blocking,
|
|
56
|
+
warnings: record.warnings,
|
|
57
|
+
});
|
|
58
|
+
return code;
|
|
59
|
+
}
|
|
60
|
+
emit(line);
|
|
61
|
+
return code;
|
|
62
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -16,21 +16,23 @@
|
|
|
16
16
|
// three `.mdc` rules would let a reader believe a repo is fully current when
|
|
17
17
|
// only the file they happened to open is.
|
|
18
18
|
|
|
19
|
-
import { mkdir, readFile, stat, symlink, unlink
|
|
19
|
+
import { mkdir, readFile, stat, symlink, unlink } from 'node:fs/promises';
|
|
20
20
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
21
21
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
22
22
|
import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
|
|
23
23
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
|
-
import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText } from '../projection/manifest.mjs';
|
|
24
|
+
import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManifest, withGenerated, manifestText, manifestPath } from '../projection/manifest.mjs';
|
|
25
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
25
26
|
import { readCapability } from '../hooks/capability.mjs';
|
|
26
27
|
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
27
|
-
import { CLI } from '../name.mjs';
|
|
28
|
-
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
28
|
+
import { CLI, LOCK_FILE, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
29
29
|
import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
30
30
|
import { resolvePacks } from '../packs.mjs';
|
|
31
|
-
import {
|
|
31
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
32
|
+
import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
|
|
33
|
+
import { computeLock, readLock } from '../projection/lock.mjs';
|
|
32
34
|
import { runDeclaredChecks } from '../kernel/checks.mjs';
|
|
33
|
-
import {
|
|
35
|
+
import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
|
|
34
36
|
import { lintDecisions } from '../memory/decide.mjs';
|
|
35
37
|
|
|
36
38
|
/** Findings `sync` reports and does not fail on.
|
|
@@ -61,6 +63,12 @@ export const SYNC_NON_BLOCKING = new Set([
|
|
|
61
63
|
'budget-cut',
|
|
62
64
|
'file-lines-warn',
|
|
63
65
|
'check-unbuilt',
|
|
66
|
+
// Review 0003 Q28: the tsconfig strictness findings. The base file is repo-owned —
|
|
67
|
+
// init no longer writes it, so neither can `sync` — and both findings' remedies are
|
|
68
|
+
// a decision about the repo's own compiler policy, not a missing file. Blocking on
|
|
69
|
+
// them would fail every fresh TypeScript adoption over a file the tool does not write.
|
|
70
|
+
'inert',
|
|
71
|
+
'inconsistent',
|
|
64
72
|
]);
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -98,7 +106,21 @@ export const SYNC_NON_BLOCKING = new Set([
|
|
|
98
106
|
// (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
|
|
99
107
|
// remedy is a decision about the number — and a cut the repository asked for by declaring
|
|
100
108
|
// a budget is the tool working, not a repository in need of a file.
|
|
101
|
-
|
|
109
|
+
// `inert` and `inconsistent` are here for review 0003 Q28: the base file is repo-owned,
|
|
110
|
+
// so the gate reports the state of the repo's own compiler policy without failing it.
|
|
111
|
+
// A fresh TypeScript adoption has neither file in a state the tool can repair, and a
|
|
112
|
+
// gate that reddens on a policy decision is one the repo satisfies by choosing, not by
|
|
113
|
+
// running a command.
|
|
114
|
+
export const CHECK_NON_BLOCKING = new Set([
|
|
115
|
+
'orphaned',
|
|
116
|
+
'pack-unpinned',
|
|
117
|
+
'budget',
|
|
118
|
+
'budget-cut',
|
|
119
|
+
'file-lines-warn',
|
|
120
|
+
'check-unbuilt',
|
|
121
|
+
'inert',
|
|
122
|
+
'inconsistent',
|
|
123
|
+
]);
|
|
102
124
|
|
|
103
125
|
export async function syncCommand(positional, flags) {
|
|
104
126
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
@@ -150,6 +172,10 @@ export async function syncCommand(positional, flags) {
|
|
|
150
172
|
);
|
|
151
173
|
}
|
|
152
174
|
|
|
175
|
+
// A dangling cross-reference does not stop the projection: the region renders, and one
|
|
176
|
+
// unreachable reference is a defect to report, not a reason to withhold the rest.
|
|
177
|
+
for (const p of result.crossRefs ?? []) note(`PROBLEM ${p}`);
|
|
178
|
+
|
|
153
179
|
const broken = result.targets.filter((t) => t.action === 'malformed');
|
|
154
180
|
if (broken.length > 0) {
|
|
155
181
|
const first = broken[0];
|
|
@@ -249,6 +275,29 @@ export async function syncCommand(positional, flags) {
|
|
|
249
275
|
}
|
|
250
276
|
const manifestChanged = manifestPlan !== null;
|
|
251
277
|
|
|
278
|
+
// The `--diff` pairs, read before anything is written so the before side is
|
|
279
|
+
// true in preview and in apply alike (R50). Pointers carry no bytes, so the
|
|
280
|
+
// plan line naming the link target is already the whole story for them.
|
|
281
|
+
const diffs =
|
|
282
|
+
flags.diff === true
|
|
283
|
+
? await Promise.all([
|
|
284
|
+
...changed.map(async (t) => ({
|
|
285
|
+
path: t.file,
|
|
286
|
+
before: await readBefore(t.path),
|
|
287
|
+
after: t.text,
|
|
288
|
+
})),
|
|
289
|
+
...(manifestPlan === null
|
|
290
|
+
? []
|
|
291
|
+
: [
|
|
292
|
+
{
|
|
293
|
+
path: manifestPlan.file,
|
|
294
|
+
before: await readBefore(await manifestPath(root)),
|
|
295
|
+
after: manifestPlan.text,
|
|
296
|
+
},
|
|
297
|
+
]),
|
|
298
|
+
])
|
|
299
|
+
: [];
|
|
300
|
+
|
|
252
301
|
// Writes happen BEFORE output in every mode. An earlier build emitted `--json`
|
|
253
302
|
// first and returned, so `upgrade --yes --json` reported a plan and changed
|
|
254
303
|
// nothing — the exact combination automation reaches for.
|
|
@@ -265,11 +314,14 @@ export async function syncCommand(positional, flags) {
|
|
|
265
314
|
// A comment that enumerates which commands follow a rule is a claim about code
|
|
266
315
|
// this file does not own, and it was one commit out of date before anyone ran it.
|
|
267
316
|
if (mode.apply) {
|
|
317
|
+
// Every repo-file write below is journaled, so `undo` can restore this run
|
|
318
|
+
// (R49). Pointer symlinks carry no bytes and are not journaled.
|
|
319
|
+
const journal = [];
|
|
268
320
|
// The manifest first, with the rest: a gate that refuses on a stale stamp should
|
|
269
321
|
// not be able to read a half-finished write, and the manifest is the file every
|
|
270
322
|
// other write in this run is described by.
|
|
271
323
|
if (manifestPlan) {
|
|
272
|
-
await writeManifest(root, manifestPlan.manifest);
|
|
324
|
+
await writeManifest(root, manifestPlan.manifest, journal);
|
|
273
325
|
note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
|
|
274
326
|
}
|
|
275
327
|
for (const t of changed) {
|
|
@@ -278,23 +330,20 @@ export async function syncCommand(positional, flags) {
|
|
|
278
330
|
// mkdir; `sync` did not, so the first `sync` after a surface was added failed with
|
|
279
331
|
// ENOENT on the second target and had already written the first. The parent is
|
|
280
332
|
// derived from the target, never from the surface table, so a future surface with a
|
|
281
|
-
// deeper path needs no change here.
|
|
333
|
+
// deeper path needs no change here. The lock is one of these targets (Q29): it
|
|
334
|
+
// reaches this loop like every other file, so a deleted lock is a write the plan
|
|
335
|
+
// shows and the journal records.
|
|
282
336
|
await mkdir(dirname(t.path), { recursive: true });
|
|
283
|
-
await
|
|
337
|
+
await journaledWriteFile(root, journal, t.path, t.text);
|
|
284
338
|
note(`wrote ${t.file}`);
|
|
285
339
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
if (previousLock !== lockText) {
|
|
295
|
-
await writeFile(join(root, LOCK_FILE), lockText);
|
|
296
|
-
note(`wrote ${LOCK_FILE}`);
|
|
297
|
-
}
|
|
340
|
+
await commitJournal(root, 'sync', journal);
|
|
341
|
+
// The regions moved, so the last `check` no longer describes this tree.
|
|
342
|
+
// Invalidated rather than refreshed: `sync` does not run gates, checkers
|
|
343
|
+
// or decisions lint, so it cannot know what the gate would now say (R48).
|
|
344
|
+
// Gated on the journal: a no-op apply changed nothing, and the record is
|
|
345
|
+
// still true.
|
|
346
|
+
if (journal.length > 0) await invalidateCheckCache(root);
|
|
298
347
|
for (const p of pointerPlan) {
|
|
299
348
|
await mkdir(dirname(p.path), { recursive: true });
|
|
300
349
|
// Only a symlink this tool wrote is replaced, and only because it has no content:
|
|
@@ -328,6 +377,7 @@ export async function syncCommand(positional, flags) {
|
|
|
328
377
|
pointers: pointersAdded,
|
|
329
378
|
written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
|
|
330
379
|
dryRun: !mode.apply,
|
|
380
|
+
...diffField(diffs, flags),
|
|
331
381
|
},
|
|
332
382
|
{},
|
|
333
383
|
);
|
|
@@ -360,6 +410,8 @@ export async function syncCommand(positional, flags) {
|
|
|
360
410
|
);
|
|
361
411
|
}
|
|
362
412
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
413
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
414
|
+
emitDiffs(diffs, flags);
|
|
363
415
|
}
|
|
364
416
|
|
|
365
417
|
const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
|
|
@@ -387,7 +439,15 @@ export async function syncCommand(positional, flags) {
|
|
|
387
439
|
* @returns {Promise<{file: string, manifest: object, text: string,
|
|
388
440
|
* stale: string[], previousBytes: number}|null>}
|
|
389
441
|
*/
|
|
390
|
-
|
|
442
|
+
/**
|
|
443
|
+
* The manifest-only repair `sync` applies before rewriting regions (plan 0003 R47).
|
|
444
|
+
*
|
|
445
|
+
* Exported so `doctor --fix` applies exactly this repair instead of deriving its
|
|
446
|
+
* own: the generated fields have one author, and a fix that patched them any
|
|
447
|
+
* other way would be a second answer to what "current" means. Returns null when
|
|
448
|
+
* there is nothing to repair, under exactly the conditions `sync` writes under.
|
|
449
|
+
*/
|
|
450
|
+
export async function planManifestRepair(root, stack, sha) {
|
|
391
451
|
const drift = await manifestDrift(root, stack, sha);
|
|
392
452
|
if (drift === null) return null;
|
|
393
453
|
// A `reformatted` manifest is repaired only once the repository has declared that no
|
|
@@ -477,8 +537,14 @@ async function gateCwdFindings(root, manifest) {
|
|
|
477
537
|
return findings;
|
|
478
538
|
}
|
|
479
539
|
|
|
480
|
-
|
|
481
|
-
|
|
540
|
+
// --- core: the gate both the CLI and the MCP `check` tool answer from (Q15) ----
|
|
541
|
+
//
|
|
542
|
+
// The MCP tool rebuilt a smaller gate from `checkTargets` alone: no manifest,
|
|
543
|
+
// no gates, no pin, no lock, no declared checkers — 3 findings where the command
|
|
544
|
+
// reports 77. The command below renders these facts for terminals; the tool
|
|
545
|
+
// returns them as JSON. The CLI keeps its field names; the tool adopts them.
|
|
546
|
+
export async function checkRepo(root, opts = {}) {
|
|
547
|
+
const findings = await checkTargets(root, opts);
|
|
482
548
|
const rules = await loadRules(root);
|
|
483
549
|
|
|
484
550
|
// A rule file with no id would be projected without a traceable source, so it
|
|
@@ -494,10 +560,21 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
494
560
|
}
|
|
495
561
|
}
|
|
496
562
|
|
|
563
|
+
// The rules slot of the `--line` cache, captured before anything else pushes:
|
|
564
|
+
// regions and rule sources are the projection, and a new blocking status
|
|
565
|
+
// that arrives here defaults to red rather than needing a second edit.
|
|
566
|
+
const rulesOk = !findings.some((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
|
|
567
|
+
|
|
497
568
|
// A declared gate this repo cannot run fails closed on every commit once hooks are
|
|
498
569
|
// installed. Reported here rather than left to `hooks status`, so the repo is red
|
|
499
570
|
// before anyone installs anything rather than after.
|
|
500
|
-
const { manifest } = await readManifest(root);
|
|
571
|
+
const { manifest, exists } = await readManifest(root);
|
|
572
|
+
// The gates slot, counted — not just reported — so the line can print run/total.
|
|
573
|
+
// A cwd finding names a declared gate that will not run, so it counts with the
|
|
574
|
+
// unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
|
|
575
|
+
let gatesRun = 0;
|
|
576
|
+
let gatesTotal = 0;
|
|
577
|
+
let hooksOk = true;
|
|
501
578
|
if (manifest) {
|
|
502
579
|
// The gate runs from whatever binary resolved it. For a repository without npm that
|
|
503
580
|
// is the release binary in `.simt/bin` (ADR-098), and the only thing that knows
|
|
@@ -507,8 +584,15 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
507
584
|
if (mismatch) {
|
|
508
585
|
findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
|
|
509
586
|
}
|
|
510
|
-
|
|
511
|
-
findings.push(...
|
|
587
|
+
const unrunnable = unrunnableGates(manifest, await readCapability(root));
|
|
588
|
+
findings.push(...unrunnable);
|
|
589
|
+
const cwdFindings = await gateCwdFindings(root, manifest);
|
|
590
|
+
findings.push(...cwdFindings);
|
|
591
|
+
gatesTotal = (manifest.gates?.preCommit ?? []).length + (manifest.gates?.prePush ?? []).length;
|
|
592
|
+
const notRun =
|
|
593
|
+
unrunnable.filter((f) => f.status === 'unrunnable').length + cwdFindings.length;
|
|
594
|
+
gatesRun = Math.max(0, gatesTotal - notRun);
|
|
595
|
+
hooksOk = (await hooksSummary(root, manifest, exists)).ok;
|
|
512
596
|
// A pack the repository declares but cannot produce is reported here rather
|
|
513
597
|
// than when a rule happens to be missing, because that is the failure mode
|
|
514
598
|
// this guards: the region still renders, it renders SHORT, and `check` would
|
|
@@ -523,20 +607,22 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
523
607
|
const drift = await manifestDrift(root, stack, await provenanceDigest());
|
|
524
608
|
if (drift) findings.push(drift);
|
|
525
609
|
}
|
|
526
|
-
if (
|
|
610
|
+
if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
|
|
527
611
|
// The checkers a selected rule declared (ADR-088). They read the rule source,
|
|
528
612
|
// not the folded region, so a line that left the prompt is still the line that runs.
|
|
529
613
|
const fp = await fingerprint(root);
|
|
530
614
|
const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
|
|
531
615
|
findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
|
|
532
616
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
617
|
+
// Q55: one resolution shared with `decide --check`, so the two commands
|
|
618
|
+
// cannot disagree about whose records these are or which are historical.
|
|
619
|
+
const relDecisionsDir = await decisionsDir(root);
|
|
620
|
+
const decisionLint = await lintDecisions(join(root, relDecisionsDir), {
|
|
621
|
+
historicalCutoff: await decisionsCutoff(root),
|
|
536
622
|
});
|
|
537
623
|
findings.push(
|
|
538
624
|
...decisionLint.findings.map((f) => ({
|
|
539
|
-
file: [
|
|
625
|
+
file: [relDecisionsDir, f.file].join('/'),
|
|
540
626
|
status: f.status,
|
|
541
627
|
detail: f.detail,
|
|
542
628
|
remedy: f.remedy,
|
|
@@ -547,16 +633,40 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
547
633
|
// acts on, so a finding it reports and does not fail on is a decision recorded in a
|
|
548
634
|
// set — not one inherited from the writer that shares this file (ADR-051).
|
|
549
635
|
const bad = findings.filter((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
|
|
636
|
+
// The `--line` cache is local state, not a write: counts and booleans under
|
|
637
|
+
// `.simt/`, never repo content, the same standing as the ledger and the
|
|
638
|
+
// index. Written on every run — red state is state — so a prompt never
|
|
639
|
+
// reports the tree as it was two checks ago.
|
|
640
|
+
await writeCheckCache(
|
|
641
|
+
root,
|
|
642
|
+
await buildCacheRecord(root, {
|
|
643
|
+
rulesOk,
|
|
644
|
+
hooksOk,
|
|
645
|
+
gatesRun,
|
|
646
|
+
gatesTotal,
|
|
647
|
+
blocking: bad.length,
|
|
648
|
+
warnings: findings.filter((f) => CHECK_NON_BLOCKING.has(f.status)).length,
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
return { ok: bad.length === 0, findings };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export async function checkCommand(positional, flags) {
|
|
655
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
656
|
+
const { ok, findings } = await checkRepo(root, flags);
|
|
550
657
|
const payload = {
|
|
551
|
-
ok
|
|
658
|
+
ok,
|
|
552
659
|
command: 'check',
|
|
553
660
|
root,
|
|
554
661
|
findings,
|
|
662
|
+
// Uniform with every writer's `--json --diff`: the gate carries no file
|
|
663
|
+
// plan, so its diff is always empty — pinned, not excused (R50).
|
|
664
|
+
...diffField([], flags),
|
|
555
665
|
};
|
|
556
666
|
|
|
557
667
|
if (isJson(flags)) {
|
|
558
668
|
emitJson(payload, {});
|
|
559
|
-
return
|
|
669
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
560
670
|
}
|
|
561
671
|
|
|
562
672
|
// Every finding that carries a remedy prints it. A finding with a fix the reader
|
|
@@ -570,7 +680,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
570
680
|
.join('\n'),
|
|
571
681
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
572
682
|
);
|
|
573
|
-
return
|
|
683
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
574
684
|
}
|
|
575
685
|
|
|
576
686
|
/**
|
package/src/commands/tests.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { promisify } from 'node:util';
|
|
|
13
13
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
14
|
import { CLI } from '../name.mjs';
|
|
15
15
|
import { isTestPath } from '../bench/mine.mjs';
|
|
16
|
-
import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
|
|
16
|
+
import { blobTexts, isIndexStale, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
|
|
17
17
|
import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
|
|
18
18
|
|
|
19
19
|
const exec = promisify(execFile);
|
|
@@ -84,7 +84,10 @@ function summarise(root, flags, edges) {
|
|
|
84
84
|
|
|
85
85
|
async function loadFiles(root) {
|
|
86
86
|
let files = await readFiles(root);
|
|
87
|
-
|
|
87
|
+
// An empty index has nothing to answer from; a stale one has yesterday's
|
|
88
|
+
// tree (review 0003 Q42). Either way the refresh is incremental, so a
|
|
89
|
+
// current repository pays for one `git ls-files -s`, not a rebuild.
|
|
90
|
+
if (files.length === 0 || (await isIndexStale(root))) {
|
|
88
91
|
try {
|
|
89
92
|
await reindex(root);
|
|
90
93
|
} catch (error) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `simt undo [--yes] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Restore the last journaled set of writes (plan 0003 R49): every file the last
|
|
4
|
+
// writing run changed goes back to its pre-run bytes, files the run created
|
|
5
|
+
// are deleted, and git config keys the run set go back to their pre-run
|
|
6
|
+
// values (review 0003 Q22). A run that wrote nothing leaves no set, so there
|
|
7
|
+
// is nothing to restore and the command says so.
|
|
8
|
+
//
|
|
9
|
+
// The safety is the journal's, not the flag's: when any file in the set no
|
|
10
|
+
// longer matches the bytes the run left, the whole undo is refused — restoring
|
|
11
|
+
// the rest around a hand edit would silently merge two authors' intentions.
|
|
12
|
+
// Like every other writer this command previews without `--yes`.
|
|
13
|
+
|
|
14
|
+
import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
|
|
15
|
+
import { CLI } from '../name.mjs';
|
|
16
|
+
import { beforeText, entryLabel, listJournalSets, readGitValue, readJournalSet, undoLastJournalSet } from '../journal.mjs';
|
|
17
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { invalidateCheckCache } from '../status.mjs';
|
|
20
|
+
|
|
21
|
+
export async function undoCommand(positional, flags) {
|
|
22
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
23
|
+
const mode = writeMode(flags);
|
|
24
|
+
|
|
25
|
+
const [id] = await listJournalSets(root);
|
|
26
|
+
if (!id) {
|
|
27
|
+
if (isJson(flags)) {
|
|
28
|
+
emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
|
|
29
|
+
return EXIT.OK;
|
|
30
|
+
}
|
|
31
|
+
emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
|
|
32
|
+
return EXIT.OK;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const set = await readJournalSet(root, id);
|
|
36
|
+
const labels = set.entries.map(entryLabel);
|
|
37
|
+
// The `--diff` pairs: what the tree holds now against what the restore
|
|
38
|
+
// would put back. A null before is a file the run created (or a key it
|
|
39
|
+
// set from unset), so the restore deletes it — the after side is empty
|
|
40
|
+
// (R50).
|
|
41
|
+
const diffs =
|
|
42
|
+
flags.diff === true
|
|
43
|
+
? await Promise.all(
|
|
44
|
+
set.entries.map(async (e) => {
|
|
45
|
+
if (e.kind === 'git-config') {
|
|
46
|
+
return { path: entryLabel(e), before: await readGitValue(root, e.key), after: e.before ?? null };
|
|
47
|
+
}
|
|
48
|
+
return { path: e.path, before: await readBefore(join(root, e.path)), after: beforeText(e) };
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
if (!mode.apply) {
|
|
54
|
+
if (isJson(flags)) {
|
|
55
|
+
emitJson({ ok: true, command: 'undo', root, set: id, restores: labels, dryRun: true, ...diffField(diffs, flags) });
|
|
56
|
+
return EXIT.OK;
|
|
57
|
+
}
|
|
58
|
+
const files = set.entries.filter((e) => e.kind !== 'git-config').length;
|
|
59
|
+
const keys = set.entries.length - files;
|
|
60
|
+
emit(
|
|
61
|
+
[
|
|
62
|
+
`last write ${set.command} (${id})`,
|
|
63
|
+
...labels.map((p) => `restore ${p}`),
|
|
64
|
+
'',
|
|
65
|
+
`would restore ${files} file(s)${keys > 0 ? ` and ${keys} git config key(s)` : ''}; apply with: ${CLI} undo --yes`,
|
|
66
|
+
].join('\n'),
|
|
67
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
68
|
+
);
|
|
69
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
70
|
+
emitDiffs(diffs, flags);
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await undoLastJournalSet(root);
|
|
75
|
+
if (result.empty) {
|
|
76
|
+
if (isJson(flags)) {
|
|
77
|
+
emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
|
|
78
|
+
return EXIT.OK;
|
|
79
|
+
}
|
|
80
|
+
emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
|
|
81
|
+
return EXIT.OK;
|
|
82
|
+
}
|
|
83
|
+
if (result.refused) {
|
|
84
|
+
if (isJson(flags)) {
|
|
85
|
+
emitJson({ ok: false, command: 'undo', root, set: result.set, refused: result.refused, restored: [] });
|
|
86
|
+
return EXIT.FAIL;
|
|
87
|
+
}
|
|
88
|
+
emit(
|
|
89
|
+
[
|
|
90
|
+
`refused ${result.set}`,
|
|
91
|
+
`touched ${result.refused.path} ${result.refused.detail}`,
|
|
92
|
+
'',
|
|
93
|
+
'nothing was restored; re-run the writing command or merge by hand, then undo again',
|
|
94
|
+
].join('\n'),
|
|
95
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
96
|
+
);
|
|
97
|
+
return EXIT.FAIL;
|
|
98
|
+
}
|
|
99
|
+
// The tree is back to before the run, so a `check` from after the run no
|
|
100
|
+
// longer describes it — the line goes back to "no cache" (R48). A restored
|
|
101
|
+
// set always restored something, so there is nothing to gate on.
|
|
102
|
+
await invalidateCheckCache(root);
|
|
103
|
+
if (isJson(flags)) {
|
|
104
|
+
emitJson({ ok: true, command: 'undo', root, set: result.set, restored: result.restored, ...diffField(diffs, flags) });
|
|
105
|
+
return EXIT.OK;
|
|
106
|
+
}
|
|
107
|
+
emit(
|
|
108
|
+
[`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
|
|
109
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
110
|
+
);
|
|
111
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
112
|
+
emitDiffs(diffs, flags);
|
|
113
|
+
return EXIT.OK;
|
|
114
|
+
}
|