simt-dev 0.6.0 → 0.6.3
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 +71 -0
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
- 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/README.md +44 -0
- package/package.json +1 -1
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/stacks/android.md +7 -8
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/jvm.md +1 -1
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/src/agenthooks/decide.mjs +101 -10
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/shell.mjs +523 -0
- package/src/argv.mjs +54 -9
- package/src/ci/workflow.mjs +7 -5
- package/src/commands/agenthook.mjs +25 -3
- package/src/commands/ci.mjs +28 -3
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +14 -1
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/explain.mjs +52 -0
- package/src/commands/help.mjs +66 -17
- package/src/commands/hooks.mjs +208 -60
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/index.mjs +54 -100
- package/src/commands/init.mjs +82 -12
- package/src/commands/lease.mjs +136 -0
- package/src/commands/lessons.mjs +12 -0
- 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 +16 -1
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +3 -0
- package/src/commands/rules.mjs +145 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +293 -205
- package/src/commands/skillsinstall.mjs +100 -30
- package/src/commands/slice.mjs +353 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +110 -15
- package/src/commands/undo.mjs +106 -0
- package/src/commands/upgrade.mjs +70 -8
- package/src/completions.mjs +191 -0
- package/src/diff.mjs +254 -0
- package/src/explain.mjs +258 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +14 -5
- package/src/hooks/dispatcher.mjs +110 -0
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +14 -0
- package/src/ignore.mjs +133 -0
- package/src/index/persist.mjs +66 -9
- package/src/journal.mjs +326 -0
- package/src/lease.mjs +113 -0
- package/src/mcp/server.mjs +15 -1
- package/src/mcp/tools.mjs +296 -256
- package/src/memory/decide.mjs +6 -1
- package/src/memory/lessons.mjs +12 -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 +6 -0
- package/src/plugins.mjs +27 -6
- package/src/prbody.mjs +251 -0
- package/src/projection/manifest.mjs +13 -2
- package/src/projection/shared.mjs +4 -1
- package/src/secrets.mjs +231 -0
- package/src/status.mjs +152 -0
package/src/commands/sync.mjs
CHANGED
|
@@ -16,18 +16,21 @@
|
|
|
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
28
|
import { CLI } from '../name.mjs';
|
|
28
29
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
29
30
|
import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
30
31
|
import { resolvePacks } from '../packs.mjs';
|
|
32
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
33
|
+
import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
|
|
31
34
|
import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
|
|
32
35
|
import { runDeclaredChecks } from '../kernel/checks.mjs';
|
|
33
36
|
import { DECISIONS_DIR } from '../decisions/index.mjs';
|
|
@@ -249,6 +252,29 @@ export async function syncCommand(positional, flags) {
|
|
|
249
252
|
}
|
|
250
253
|
const manifestChanged = manifestPlan !== null;
|
|
251
254
|
|
|
255
|
+
// The `--diff` pairs, read before anything is written so the before side is
|
|
256
|
+
// true in preview and in apply alike (R50). Pointers carry no bytes, so the
|
|
257
|
+
// plan line naming the link target is already the whole story for them.
|
|
258
|
+
const diffs =
|
|
259
|
+
flags.diff === true
|
|
260
|
+
? await Promise.all([
|
|
261
|
+
...changed.map(async (t) => ({
|
|
262
|
+
path: t.file,
|
|
263
|
+
before: await readBefore(t.path),
|
|
264
|
+
after: t.text,
|
|
265
|
+
})),
|
|
266
|
+
...(manifestPlan === null
|
|
267
|
+
? []
|
|
268
|
+
: [
|
|
269
|
+
{
|
|
270
|
+
path: manifestPlan.file,
|
|
271
|
+
before: await readBefore(await manifestPath(root)),
|
|
272
|
+
after: manifestPlan.text,
|
|
273
|
+
},
|
|
274
|
+
]),
|
|
275
|
+
])
|
|
276
|
+
: [];
|
|
277
|
+
|
|
252
278
|
// Writes happen BEFORE output in every mode. An earlier build emitted `--json`
|
|
253
279
|
// first and returned, so `upgrade --yes --json` reported a plan and changed
|
|
254
280
|
// nothing — the exact combination automation reaches for.
|
|
@@ -265,11 +291,14 @@ export async function syncCommand(positional, flags) {
|
|
|
265
291
|
// A comment that enumerates which commands follow a rule is a claim about code
|
|
266
292
|
// this file does not own, and it was one commit out of date before anyone ran it.
|
|
267
293
|
if (mode.apply) {
|
|
294
|
+
// Every repo-file write below is journaled, so `undo` can restore this run
|
|
295
|
+
// (R49). Pointer symlinks carry no bytes and are not journaled.
|
|
296
|
+
const journal = [];
|
|
268
297
|
// The manifest first, with the rest: a gate that refuses on a stale stamp should
|
|
269
298
|
// not be able to read a half-finished write, and the manifest is the file every
|
|
270
299
|
// other write in this run is described by.
|
|
271
300
|
if (manifestPlan) {
|
|
272
|
-
await writeManifest(root, manifestPlan.manifest);
|
|
301
|
+
await writeManifest(root, manifestPlan.manifest, journal);
|
|
273
302
|
note(`wrote ${manifestPlan.file} (generated field(s) ${manifestPlan.stale.join(', ')})`);
|
|
274
303
|
}
|
|
275
304
|
for (const t of changed) {
|
|
@@ -280,7 +309,7 @@ export async function syncCommand(positional, flags) {
|
|
|
280
309
|
// derived from the target, never from the surface table, so a future surface with a
|
|
281
310
|
// deeper path needs no change here.
|
|
282
311
|
await mkdir(dirname(t.path), { recursive: true });
|
|
283
|
-
await
|
|
312
|
+
await journaledWriteFile(root, journal, t.path, t.text);
|
|
284
313
|
note(`wrote ${t.file}`);
|
|
285
314
|
}
|
|
286
315
|
const lock = await computeLock(root, flags);
|
|
@@ -292,9 +321,16 @@ export async function syncCommand(positional, flags) {
|
|
|
292
321
|
if (error.code !== 'ENOENT') throw error;
|
|
293
322
|
}
|
|
294
323
|
if (previousLock !== lockText) {
|
|
295
|
-
await
|
|
324
|
+
await journaledWriteFile(root, journal, join(root, LOCK_FILE), lockText);
|
|
296
325
|
note(`wrote ${LOCK_FILE}`);
|
|
297
326
|
}
|
|
327
|
+
await commitJournal(root, 'sync', journal);
|
|
328
|
+
// The regions moved, so the last `check` no longer describes this tree.
|
|
329
|
+
// Invalidated rather than refreshed: `sync` does not run gates, checkers
|
|
330
|
+
// or decisions lint, so it cannot know what the gate would now say (R48).
|
|
331
|
+
// Gated on the journal: a no-op apply changed nothing, and the record is
|
|
332
|
+
// still true.
|
|
333
|
+
if (journal.length > 0) await invalidateCheckCache(root);
|
|
298
334
|
for (const p of pointerPlan) {
|
|
299
335
|
await mkdir(dirname(p.path), { recursive: true });
|
|
300
336
|
// Only a symlink this tool wrote is replaced, and only because it has no content:
|
|
@@ -328,6 +364,7 @@ export async function syncCommand(positional, flags) {
|
|
|
328
364
|
pointers: pointersAdded,
|
|
329
365
|
written: mode.apply ? changed.length + pointersAdded.length + (manifestChanged ? 1 : 0) : 0,
|
|
330
366
|
dryRun: !mode.apply,
|
|
367
|
+
...diffField(diffs, flags),
|
|
331
368
|
},
|
|
332
369
|
{},
|
|
333
370
|
);
|
|
@@ -360,6 +397,8 @@ export async function syncCommand(positional, flags) {
|
|
|
360
397
|
);
|
|
361
398
|
}
|
|
362
399
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
400
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
401
|
+
emitDiffs(diffs, flags);
|
|
363
402
|
}
|
|
364
403
|
|
|
365
404
|
const idle = changed.length === 0 && pointerPlan.length === 0 && !manifestChanged;
|
|
@@ -387,7 +426,15 @@ export async function syncCommand(positional, flags) {
|
|
|
387
426
|
* @returns {Promise<{file: string, manifest: object, text: string,
|
|
388
427
|
* stale: string[], previousBytes: number}|null>}
|
|
389
428
|
*/
|
|
390
|
-
|
|
429
|
+
/**
|
|
430
|
+
* The manifest-only repair `sync` applies before rewriting regions (plan 0003 R47).
|
|
431
|
+
*
|
|
432
|
+
* Exported so `doctor --fix` applies exactly this repair instead of deriving its
|
|
433
|
+
* own: the generated fields have one author, and a fix that patched them any
|
|
434
|
+
* other way would be a second answer to what "current" means. Returns null when
|
|
435
|
+
* there is nothing to repair, under exactly the conditions `sync` writes under.
|
|
436
|
+
*/
|
|
437
|
+
export async function planManifestRepair(root, stack, sha) {
|
|
391
438
|
const drift = await manifestDrift(root, stack, sha);
|
|
392
439
|
if (drift === null) return null;
|
|
393
440
|
// A `reformatted` manifest is repaired only once the repository has declared that no
|
|
@@ -477,8 +524,14 @@ async function gateCwdFindings(root, manifest) {
|
|
|
477
524
|
return findings;
|
|
478
525
|
}
|
|
479
526
|
|
|
480
|
-
|
|
481
|
-
|
|
527
|
+
// --- core: the gate both the CLI and the MCP `check` tool answer from (Q15) ----
|
|
528
|
+
//
|
|
529
|
+
// The MCP tool rebuilt a smaller gate from `checkTargets` alone: no manifest,
|
|
530
|
+
// no gates, no pin, no lock, no declared checkers — 3 findings where the command
|
|
531
|
+
// reports 77. The command below renders these facts for terminals; the tool
|
|
532
|
+
// returns them as JSON. The CLI keeps its field names; the tool adopts them.
|
|
533
|
+
export async function checkRepo(root, opts = {}) {
|
|
534
|
+
const findings = await checkTargets(root, opts);
|
|
482
535
|
const rules = await loadRules(root);
|
|
483
536
|
|
|
484
537
|
// A rule file with no id would be projected without a traceable source, so it
|
|
@@ -494,10 +547,21 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
494
547
|
}
|
|
495
548
|
}
|
|
496
549
|
|
|
550
|
+
// The rules slot of the `--line` cache, captured before anything else pushes:
|
|
551
|
+
// regions and rule sources are the projection, and a new blocking status
|
|
552
|
+
// that arrives here defaults to red rather than needing a second edit.
|
|
553
|
+
const rulesOk = !findings.some((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
|
|
554
|
+
|
|
497
555
|
// A declared gate this repo cannot run fails closed on every commit once hooks are
|
|
498
556
|
// installed. Reported here rather than left to `hooks status`, so the repo is red
|
|
499
557
|
// before anyone installs anything rather than after.
|
|
500
|
-
const { manifest } = await readManifest(root);
|
|
558
|
+
const { manifest, exists } = await readManifest(root);
|
|
559
|
+
// The gates slot, counted — not just reported — so the line can print run/total.
|
|
560
|
+
// A cwd finding names a declared gate that will not run, so it counts with the
|
|
561
|
+
// unrunnable; an unknown-gate-cwd names no declared gate, so it does not.
|
|
562
|
+
let gatesRun = 0;
|
|
563
|
+
let gatesTotal = 0;
|
|
564
|
+
let hooksOk = true;
|
|
501
565
|
if (manifest) {
|
|
502
566
|
// The gate runs from whatever binary resolved it. For a repository without npm that
|
|
503
567
|
// is the release binary in `.simt/bin` (ADR-098), and the only thing that knows
|
|
@@ -507,8 +571,15 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
507
571
|
if (mismatch) {
|
|
508
572
|
findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
|
|
509
573
|
}
|
|
510
|
-
|
|
511
|
-
findings.push(...
|
|
574
|
+
const unrunnable = unrunnableGates(manifest, await readCapability(root));
|
|
575
|
+
findings.push(...unrunnable);
|
|
576
|
+
const cwdFindings = await gateCwdFindings(root, manifest);
|
|
577
|
+
findings.push(...cwdFindings);
|
|
578
|
+
gatesTotal = (manifest.gates?.preCommit ?? []).length + (manifest.gates?.prePush ?? []).length;
|
|
579
|
+
const notRun =
|
|
580
|
+
unrunnable.filter((f) => f.status === 'unrunnable').length + cwdFindings.length;
|
|
581
|
+
gatesRun = Math.max(0, gatesTotal - notRun);
|
|
582
|
+
hooksOk = (await hooksSummary(root, manifest, exists)).ok;
|
|
512
583
|
// A pack the repository declares but cannot produce is reported here rather
|
|
513
584
|
// than when a rule happens to be missing, because that is the failure mode
|
|
514
585
|
// this guards: the region still renders, it renders SHORT, and `check` would
|
|
@@ -523,7 +594,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
523
594
|
const drift = await manifestDrift(root, stack, await provenanceDigest());
|
|
524
595
|
if (drift) findings.push(drift);
|
|
525
596
|
}
|
|
526
|
-
if (
|
|
597
|
+
if (opts.frozen) findings.push(...(await frozenFindings(root, opts)));
|
|
527
598
|
// The checkers a selected rule declared (ADR-088). They read the rule source,
|
|
528
599
|
// not the folded region, so a line that left the prompt is still the line that runs.
|
|
529
600
|
const fp = await fingerprint(root);
|
|
@@ -547,16 +618,40 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
547
618
|
// acts on, so a finding it reports and does not fail on is a decision recorded in a
|
|
548
619
|
// set — not one inherited from the writer that shares this file (ADR-051).
|
|
549
620
|
const bad = findings.filter((f) => f.status !== 'current' && !CHECK_NON_BLOCKING.has(f.status));
|
|
621
|
+
// The `--line` cache is local state, not a write: counts and booleans under
|
|
622
|
+
// `.simt/`, never repo content, the same standing as the ledger and the
|
|
623
|
+
// index. Written on every run — red state is state — so a prompt never
|
|
624
|
+
// reports the tree as it was two checks ago.
|
|
625
|
+
await writeCheckCache(
|
|
626
|
+
root,
|
|
627
|
+
await buildCacheRecord(root, {
|
|
628
|
+
rulesOk,
|
|
629
|
+
hooksOk,
|
|
630
|
+
gatesRun,
|
|
631
|
+
gatesTotal,
|
|
632
|
+
blocking: bad.length,
|
|
633
|
+
warnings: findings.filter((f) => CHECK_NON_BLOCKING.has(f.status)).length,
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
return { ok: bad.length === 0, findings };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export async function checkCommand(positional, flags) {
|
|
640
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
641
|
+
const { ok, findings } = await checkRepo(root, flags);
|
|
550
642
|
const payload = {
|
|
551
|
-
ok
|
|
643
|
+
ok,
|
|
552
644
|
command: 'check',
|
|
553
645
|
root,
|
|
554
646
|
findings,
|
|
647
|
+
// Uniform with every writer's `--json --diff`: the gate carries no file
|
|
648
|
+
// plan, so its diff is always empty — pinned, not excused (R50).
|
|
649
|
+
...diffField([], flags),
|
|
555
650
|
};
|
|
556
651
|
|
|
557
652
|
if (isJson(flags)) {
|
|
558
653
|
emitJson(payload, {});
|
|
559
|
-
return
|
|
654
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
560
655
|
}
|
|
561
656
|
|
|
562
657
|
// Every finding that carries a remedy prints it. A finding with a fix the reader
|
|
@@ -570,7 +665,7 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
570
665
|
.join('\n'),
|
|
571
666
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
572
667
|
);
|
|
573
|
-
return
|
|
668
|
+
return ok ? EXIT.OK : EXIT.FAIL;
|
|
574
669
|
}
|
|
575
670
|
|
|
576
671
|
/**
|
|
@@ -0,0 +1,106 @@
|
|
|
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, and files the run created
|
|
5
|
+
// are deleted. A run that wrote nothing leaves no set, so there is nothing to
|
|
6
|
+
// restore and the command says so.
|
|
7
|
+
//
|
|
8
|
+
// The safety is the journal's, not the flag's: when any file in the set no
|
|
9
|
+
// longer matches the bytes the run left, the whole undo is refused — restoring
|
|
10
|
+
// the rest around a hand edit would silently merge two authors' intentions.
|
|
11
|
+
// Like every other writer this command previews without `--yes`.
|
|
12
|
+
|
|
13
|
+
import { EXIT, emit, emitJson, isJson, writeMode } from '../output/index.mjs';
|
|
14
|
+
import { CLI } from '../name.mjs';
|
|
15
|
+
import { listJournalSets, readJournalSet, undoLastJournalSet } from '../journal.mjs';
|
|
16
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { invalidateCheckCache } from '../status.mjs';
|
|
19
|
+
|
|
20
|
+
export async function undoCommand(positional, flags) {
|
|
21
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
22
|
+
const mode = writeMode(flags);
|
|
23
|
+
|
|
24
|
+
const [id] = await listJournalSets(root);
|
|
25
|
+
if (!id) {
|
|
26
|
+
if (isJson(flags)) {
|
|
27
|
+
emitJson({ ok: true, command: 'undo', root, set: null, restored: [], dryRun: !mode.apply });
|
|
28
|
+
return EXIT.OK;
|
|
29
|
+
}
|
|
30
|
+
emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
|
|
31
|
+
return EXIT.OK;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const set = await readJournalSet(root, id);
|
|
35
|
+
const paths = set.entries.map((e) => e.path);
|
|
36
|
+
// The `--diff` pairs: what the tree holds now against what the restore
|
|
37
|
+
// would put back. A null beforeBytes is a file the run created, so the
|
|
38
|
+
// restore deletes it — the after side is empty (R50).
|
|
39
|
+
const diffs =
|
|
40
|
+
flags.diff === true
|
|
41
|
+
? await Promise.all(
|
|
42
|
+
set.entries.map(async (e) => ({
|
|
43
|
+
path: e.path,
|
|
44
|
+
before: await readBefore(join(root, e.path)),
|
|
45
|
+
after: e.beforeBytes ?? null,
|
|
46
|
+
})),
|
|
47
|
+
)
|
|
48
|
+
: [];
|
|
49
|
+
|
|
50
|
+
if (!mode.apply) {
|
|
51
|
+
if (isJson(flags)) {
|
|
52
|
+
emitJson({ ok: true, command: 'undo', root, set: id, restores: paths, dryRun: true, ...diffField(diffs, flags) });
|
|
53
|
+
return EXIT.OK;
|
|
54
|
+
}
|
|
55
|
+
emit(
|
|
56
|
+
[`last write ${set.command} (${id})`, ...paths.map((p) => `restore ${p}`), '', `would restore ${paths.length} file(s); apply with: ${CLI} undo --yes`].join(
|
|
57
|
+
'\n',
|
|
58
|
+
),
|
|
59
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
60
|
+
);
|
|
61
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
62
|
+
emitDiffs(diffs, flags);
|
|
63
|
+
return EXIT.OK;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await undoLastJournalSet(root);
|
|
67
|
+
if (result.empty) {
|
|
68
|
+
if (isJson(flags)) {
|
|
69
|
+
emitJson({ ok: true, command: 'undo', root, set: null, restored: [] });
|
|
70
|
+
return EXIT.OK;
|
|
71
|
+
}
|
|
72
|
+
emit('nothing to undo: no journaled writes', { maxBytes: flags.maxBytes ?? 0 });
|
|
73
|
+
return EXIT.OK;
|
|
74
|
+
}
|
|
75
|
+
if (result.refused) {
|
|
76
|
+
if (isJson(flags)) {
|
|
77
|
+
emitJson({ ok: false, command: 'undo', root, set: result.set, refused: result.refused, restored: [] });
|
|
78
|
+
return EXIT.FAIL;
|
|
79
|
+
}
|
|
80
|
+
emit(
|
|
81
|
+
[
|
|
82
|
+
`refused ${result.set}`,
|
|
83
|
+
`touched ${result.refused.path} ${result.refused.detail}`,
|
|
84
|
+
'',
|
|
85
|
+
'nothing was restored; re-run the writing command or merge by hand, then undo again',
|
|
86
|
+
].join('\n'),
|
|
87
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
88
|
+
);
|
|
89
|
+
return EXIT.FAIL;
|
|
90
|
+
}
|
|
91
|
+
// The tree is back to before the run, so a `check` from after the run no
|
|
92
|
+
// longer describes it — the line goes back to "no cache" (R48). A restored
|
|
93
|
+
// set always restored something, so there is nothing to gate on.
|
|
94
|
+
await invalidateCheckCache(root);
|
|
95
|
+
if (isJson(flags)) {
|
|
96
|
+
emitJson({ ok: true, command: 'undo', root, set: result.set, restored: result.restored, ...diffField(diffs, flags) });
|
|
97
|
+
return EXIT.OK;
|
|
98
|
+
}
|
|
99
|
+
emit(
|
|
100
|
+
[`undone ${result.set}`, ...result.restored.map((p) => `restored ${p}`)].join('\n'),
|
|
101
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
102
|
+
);
|
|
103
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
104
|
+
emitDiffs(diffs, flags);
|
|
105
|
+
return EXIT.OK;
|
|
106
|
+
}
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -22,13 +22,16 @@
|
|
|
22
22
|
// silently behind is the failure `compare` exists to surface, and this command must
|
|
23
23
|
// not be the thing that creates it.
|
|
24
24
|
|
|
25
|
-
import { mkdir
|
|
25
|
+
import { mkdir } from 'node:fs/promises';
|
|
26
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
27
|
+
import { invalidateCheckCache } from '../status.mjs';
|
|
26
28
|
|
|
27
29
|
import { basename, dirname, resolve } from 'node:path';
|
|
28
30
|
|
|
29
31
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
30
32
|
import { provenanceDigest, plan } from '../projection/project.mjs';
|
|
31
|
-
import { MANIFEST, MANIFEST_COMMENT, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
33
|
+
import { MANIFEST, MANIFEST_COMMENT, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
34
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
32
35
|
import { FAULT_STATES, planUpgrade, summariseUpgrade } from '../upgrade/plan.mjs';
|
|
33
36
|
import { discover } from '../compare/discover.mjs';
|
|
34
37
|
import { resolveWorkspace, targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
@@ -69,13 +72,57 @@ export async function upgradeCommand(positional, flags) {
|
|
|
69
72
|
const sha = await provenanceDigest();
|
|
70
73
|
const mode = writeMode(flags);
|
|
71
74
|
const plans = [];
|
|
75
|
+
// The plan inputs, kept because the diff is built from them: `planUpgrade`
|
|
76
|
+
// keeps the verdict, not the bytes, and the apply path below re-plans per
|
|
77
|
+
// repo rather than reusing this loop's targets.
|
|
78
|
+
const inputs = new Map();
|
|
72
79
|
for (const repo of found.repos) {
|
|
73
80
|
const { manifest, exists, error } = await readManifest(repo);
|
|
74
81
|
const result = await plan(repo);
|
|
82
|
+
inputs.set(repo, { targets: result.targets, manifest });
|
|
75
83
|
plans.push(planUpgrade({ name: basename(repo), path: repo, manifest, manifestExists: exists, manifestError: error, targets: result.targets, sha }));
|
|
76
84
|
}
|
|
77
85
|
const summary = summariseUpgrade(plans);
|
|
78
86
|
|
|
87
|
+
// The `--diff` pairs, read before anything is written so the before side is
|
|
88
|
+
// true in preview and in apply alike (R50). Labels carry the repo name: one
|
|
89
|
+
// run diffs N repositories, and a bare filename would not say which. The
|
|
90
|
+
// manifest after-bytes are built the same way the apply path builds them —
|
|
91
|
+
// chosen keys kept, `$comment` refreshed, pin moved — so the diff shows the
|
|
92
|
+
// write rather than a second opinion about it.
|
|
93
|
+
const diffs =
|
|
94
|
+
flags.diff === true
|
|
95
|
+
? (
|
|
96
|
+
await Promise.all(
|
|
97
|
+
plans
|
|
98
|
+
.filter((p) => p.action === 'updated')
|
|
99
|
+
.map(async (p) => {
|
|
100
|
+
const input = inputs.get(p.path) ?? { targets: [], manifest: null };
|
|
101
|
+
const pairs = await Promise.all(
|
|
102
|
+
input.targets
|
|
103
|
+
.filter((t) => t.action !== 'unchanged' && t.action !== 'malformed')
|
|
104
|
+
.map(async (t) => ({
|
|
105
|
+
path: `${p.name}/${t.file}`,
|
|
106
|
+
before: await readBefore(t.path),
|
|
107
|
+
after: t.text,
|
|
108
|
+
})),
|
|
109
|
+
);
|
|
110
|
+
const { $comment, ...chosen } = input.manifest ?? {};
|
|
111
|
+
pairs.push({
|
|
112
|
+
path: `${p.name}/${MANIFEST_FILE}`,
|
|
113
|
+
before: await readBefore(await manifestPath(p.path)),
|
|
114
|
+
after: manifestText({
|
|
115
|
+
...MANIFEST_COMMENT,
|
|
116
|
+
...chosen,
|
|
117
|
+
projection: { ...(input.manifest?.projection ?? {}), simtDevSha: sha },
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
return pairs;
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
).flat()
|
|
124
|
+
: [];
|
|
125
|
+
|
|
79
126
|
// Writes happen BEFORE the report, in every output mode. An earlier version emitted
|
|
80
127
|
// the JSON payload and returned, which meant `--yes --json` reported a plan and
|
|
81
128
|
// changed nothing - the exact combination a script uses for automation.
|
|
@@ -96,11 +143,14 @@ export async function upgradeCommand(positional, flags) {
|
|
|
96
143
|
// Every planned target, not just AGENTS.md: a repo can have a current AGENTS.md
|
|
97
144
|
// and three stale `.mdc` rules, and restamping only the first leaves `check` red
|
|
98
145
|
// straight after a successful upgrade.
|
|
146
|
+
// One journal set per repo: a set belongs to one repository, and an
|
|
147
|
+
// `undo` in one consumer must never reach into another (R49).
|
|
148
|
+
const journal = [];
|
|
99
149
|
const { targets } = await plan(p.path);
|
|
100
150
|
for (const t of targets) {
|
|
101
151
|
if (t.action === 'unchanged' || t.action === 'malformed') continue;
|
|
102
152
|
await mkdir(dirname(t.path), { recursive: true });
|
|
103
|
-
await
|
|
153
|
+
await journaledWriteFile(p.path, journal, t.path, t.text);
|
|
104
154
|
writtenFiles.add(t.file);
|
|
105
155
|
}
|
|
106
156
|
const { manifest } = await readManifest(p.path);
|
|
@@ -110,11 +160,20 @@ export async function upgradeCommand(positional, flags) {
|
|
|
110
160
|
// forever, because `sync` does not own the manifest and nothing else rewrites
|
|
111
161
|
// it. An adopted repo would never have corrected it.
|
|
112
162
|
const { $comment, ...chosen } = manifest ?? {};
|
|
113
|
-
await writeManifest(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
await writeManifest(
|
|
164
|
+
p.path,
|
|
165
|
+
{
|
|
166
|
+
...MANIFEST_COMMENT,
|
|
167
|
+
...chosen,
|
|
168
|
+
projection: { ...(manifest?.projection ?? {}), simtDevSha: sha },
|
|
169
|
+
},
|
|
170
|
+
journal,
|
|
171
|
+
);
|
|
172
|
+
await commitJournal(p.path, 'upgrade', journal);
|
|
173
|
+
// The pin and regions moved: the last `check` of this repo no longer
|
|
174
|
+
// describes it (R48). Gated on the journal: an untouched repo keeps
|
|
175
|
+
// its record.
|
|
176
|
+
if (journal.length > 0) await invalidateCheckCache(p.path);
|
|
118
177
|
applied.push(p.name);
|
|
119
178
|
} catch (error) {
|
|
120
179
|
// Reported per repo rather than thrown: one unwritable repo must not hide the
|
|
@@ -133,6 +192,7 @@ export async function upgradeCommand(positional, flags) {
|
|
|
133
192
|
source: target.source,
|
|
134
193
|
sha,
|
|
135
194
|
dryRun: !mode.apply,
|
|
195
|
+
...diffField(diffs, flags),
|
|
136
196
|
written: applied.length > 0,
|
|
137
197
|
summary: { ...summary, applied, failed },
|
|
138
198
|
plans,
|
|
@@ -144,6 +204,8 @@ export async function upgradeCommand(positional, flags) {
|
|
|
144
204
|
}
|
|
145
205
|
|
|
146
206
|
emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
|
|
207
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
208
|
+
emitDiffs(diffs, flags);
|
|
147
209
|
|
|
148
210
|
for (const w of found.worktrees) {
|
|
149
211
|
// The same reasoning as `compare`: a worktree is its parent's HEAD elsewhere, so
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Shell completions, generated from the command registry (plan 0003 R51).
|
|
2
|
+
//
|
|
3
|
+
// The inputs are the two tables that already exist and are already checked:
|
|
4
|
+
// `COMMANDS` — the same list the dispatcher routes and `--help` is asserted
|
|
5
|
+
// against (review 0002 S15) — and the flag tables in argv.mjs. A command added
|
|
6
|
+
// to `IMPLEMENTED` therefore completes in all three shells with no second
|
|
7
|
+
// edit, which is exactly the property S15 demanded of `--help`.
|
|
8
|
+
//
|
|
9
|
+
// Deterministic by construction: every list is sorted and deduplicated before
|
|
10
|
+
// it is rendered, no timestamp, no host name, no environment is read. The same
|
|
11
|
+
// registry produces byte-identical output on any machine, so the generated
|
|
12
|
+
// script can be committed, diffed and reviewed like any other file.
|
|
13
|
+
//
|
|
14
|
+
// Two boundaries, both deliberate:
|
|
15
|
+
//
|
|
16
|
+
// - Positional VALUES beyond subcommand words are file completion. The
|
|
17
|
+
// registry knows words, not the skill catalogue or a path's tree, and a
|
|
18
|
+
// completion that guesses is worse than one that falls back to files.
|
|
19
|
+
// - bash's script stays 3.2-compatible: macOS ships /bin/bash 3.2, and a
|
|
20
|
+
// completion script that fails to parse there is a silent no-op.
|
|
21
|
+
|
|
22
|
+
import { BOOL_FLAGS, VALUE_FLAGS } from './argv.mjs';
|
|
23
|
+
|
|
24
|
+
/** The shells this command can render, sorted. */
|
|
25
|
+
export const SHELLS = ['bash', 'fish', 'zsh'];
|
|
26
|
+
|
|
27
|
+
/** Every flag name, sorted: `--budget`, `--root`, ... Flags are global. */
|
|
28
|
+
const FLAGS = [...new Set([...BOOL_FLAGS, ...VALUE_FLAGS])].sort().map((f) => `--${f}`);
|
|
29
|
+
|
|
30
|
+
/** The value-flag case pattern (`--root|--task|...`), for the prev-word check. */
|
|
31
|
+
const VALUE_CASE = [...VALUE_FLAGS].sort().map((f) => `--${f}`).join('|');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The registry as completions need it: the first words, and for each group the
|
|
35
|
+
* subcommands under it. Derived from `commands` rather than hand-listed, so a
|
|
36
|
+
* new command or subcommand arrives here without an edit.
|
|
37
|
+
*
|
|
38
|
+
* @param {string[]} commands sorted dispatchable paths, e.g. `['brief', 'skills index']`
|
|
39
|
+
* @returns {{top: string[], groups: Array<[string, string[]]>}}
|
|
40
|
+
*/
|
|
41
|
+
export function shape(commands) {
|
|
42
|
+
const top = new Set();
|
|
43
|
+
const subs = new Map();
|
|
44
|
+
for (const path of commands) {
|
|
45
|
+
const [first, second] = path.split(' ');
|
|
46
|
+
top.add(first);
|
|
47
|
+
if (second !== undefined) {
|
|
48
|
+
if (!subs.has(first)) subs.set(first, new Set());
|
|
49
|
+
subs.get(first).add(second);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
top: [...top].sort(),
|
|
54
|
+
groups: [...subs.entries()]
|
|
55
|
+
.map(([group, set]) => [group, [...set].sort()])
|
|
56
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The completion script for one shell.
|
|
62
|
+
*
|
|
63
|
+
* @param {'bash'|'fish'|'zsh'} shell
|
|
64
|
+
* @param {string[]} commands the dispatchable command paths (`COMMANDS`)
|
|
65
|
+
* @returns {string} the script, newline-terminated
|
|
66
|
+
*/
|
|
67
|
+
export function renderCompletions(shell, commands) {
|
|
68
|
+
const s = shape(commands);
|
|
69
|
+
if (shell === 'bash') return renderBash(s);
|
|
70
|
+
if (shell === 'zsh') return renderZsh(s);
|
|
71
|
+
if (shell === 'fish') return renderFish(s);
|
|
72
|
+
throw new Error(`unknown shell \`${shell}\`; one of: ${SHELLS.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderBash({ top, groups }) {
|
|
76
|
+
const cases = groups
|
|
77
|
+
.map(([group, subs]) => ` ${group}) subs='${subs.join(' ')}' ;;`)
|
|
78
|
+
.join('\n');
|
|
79
|
+
return [
|
|
80
|
+
'# bash completion for simt. Generated by `simt completions bash`; do not edit.',
|
|
81
|
+
'# Install: source <(simt completions bash), or drop it in /etc/bash_completion.d/.',
|
|
82
|
+
'# bash 3.2 compatible: macOS ships /bin/bash 3.2.',
|
|
83
|
+
'_simt() {',
|
|
84
|
+
' local cur prev subs flags top',
|
|
85
|
+
' cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
86
|
+
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
|
|
87
|
+
" subs=''",
|
|
88
|
+
'',
|
|
89
|
+
' # A flag that takes a value takes a word only the shell can name: paths.',
|
|
90
|
+
' case "$prev" in',
|
|
91
|
+
' --) return 0 ;;',
|
|
92
|
+
` ${VALUE_CASE}) return 0 ;;`,
|
|
93
|
+
' esac',
|
|
94
|
+
'',
|
|
95
|
+
` flags='${FLAGS.join(' ')}'`,
|
|
96
|
+
` top='${top.join(' ')}'`,
|
|
97
|
+
'',
|
|
98
|
+
' case "$cur" in',
|
|
99
|
+
' -*) COMPREPLY=( $(compgen -W "$flags" -- "$cur") ); return 0 ;;',
|
|
100
|
+
' esac',
|
|
101
|
+
' if [ "$COMP_CWORD" -eq 1 ]; then',
|
|
102
|
+
' COMPREPLY=( $(compgen -W "$top" -- "$cur") )',
|
|
103
|
+
' elif [ "$COMP_CWORD" -eq 2 ]; then',
|
|
104
|
+
' case "${COMP_WORDS[1]}" in',
|
|
105
|
+
cases,
|
|
106
|
+
' esac',
|
|
107
|
+
' if [ -n "$subs" ]; then',
|
|
108
|
+
' COMPREPLY=( $(compgen -W "$subs" -- "$cur") )',
|
|
109
|
+
' fi',
|
|
110
|
+
' fi',
|
|
111
|
+
'}',
|
|
112
|
+
'complete -o bashdefault -o default -F _simt simt',
|
|
113
|
+
'',
|
|
114
|
+
].join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderZsh({ top, groups }) {
|
|
118
|
+
const cases = groups
|
|
119
|
+
.map(([group, subs]) => ` ${group}) subs=( ${subs.join(' ')} ) ;;`)
|
|
120
|
+
.join('\n');
|
|
121
|
+
return [
|
|
122
|
+
'#compdef simt',
|
|
123
|
+
'# zsh completion for simt. Generated by `simt completions zsh`; do not edit.',
|
|
124
|
+
'# Install: save as `_simt` in a directory on $fpath and run `compinit`, or',
|
|
125
|
+
'# source this after compinit: `source <(simt completions zsh)`.',
|
|
126
|
+
'_simt() {',
|
|
127
|
+
' local cur prev',
|
|
128
|
+
' local -a top flags subs',
|
|
129
|
+
` top=( ${top.join(' ')} )`,
|
|
130
|
+
` flags=( ${FLAGS.join(' ')} )`,
|
|
131
|
+
' subs=()',
|
|
132
|
+
'',
|
|
133
|
+
' # A flag that takes a value takes a word only the shell can name: paths.',
|
|
134
|
+
' case "$prev" in',
|
|
135
|
+
` --|${VALUE_CASE})`,
|
|
136
|
+
' _files',
|
|
137
|
+
' return',
|
|
138
|
+
' ;;',
|
|
139
|
+
' esac',
|
|
140
|
+
'',
|
|
141
|
+
' if [[ "$cur" == -* ]]; then',
|
|
142
|
+
' compadd -- ${flags}',
|
|
143
|
+
' return',
|
|
144
|
+
' fi',
|
|
145
|
+
'',
|
|
146
|
+
' case "${words[2]}" in',
|
|
147
|
+
cases,
|
|
148
|
+
' esac',
|
|
149
|
+
'',
|
|
150
|
+
' if (( CURRENT == 2 )); then',
|
|
151
|
+
' compadd -- ${top}',
|
|
152
|
+
' elif (( CURRENT == 3 )) && (( ${#subs} )); then',
|
|
153
|
+
' compadd -- ${subs}',
|
|
154
|
+
' else',
|
|
155
|
+
' _files',
|
|
156
|
+
' fi',
|
|
157
|
+
'}',
|
|
158
|
+
'',
|
|
159
|
+
'if (( $+functions[compdef] )); then',
|
|
160
|
+
' compdef _simt simt',
|
|
161
|
+
'fi',
|
|
162
|
+
'',
|
|
163
|
+
].join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderFish({ top, groups }) {
|
|
167
|
+
const level1 = top.map(
|
|
168
|
+
(word) => `complete -c simt -n '__fish_use_subcommand' -a '${word}'`,
|
|
169
|
+
);
|
|
170
|
+
const level2 = groups.flatMap(([group, subs]) =>
|
|
171
|
+
subs.map(
|
|
172
|
+
(word) => `complete -c simt -n '__fish_seen_subcommand_from ${group}' -a '${word}'`,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const flagLines = FLAGS.map((flag) => {
|
|
176
|
+
const name = flag.slice(2);
|
|
177
|
+
return VALUE_FLAGS.has(name)
|
|
178
|
+
? `complete -c simt -l ${name} -r`
|
|
179
|
+
: `complete -c simt -l ${name}`;
|
|
180
|
+
});
|
|
181
|
+
return [
|
|
182
|
+
'# fish completion for simt. Generated by `simt completions fish`; do not edit.',
|
|
183
|
+
'# Install: simt completions fish > ~/.config/fish/completions/simt.fish',
|
|
184
|
+
...level1,
|
|
185
|
+
'',
|
|
186
|
+
...level2,
|
|
187
|
+
'',
|
|
188
|
+
...flagLines,
|
|
189
|
+
'',
|
|
190
|
+
].join('\n');
|
|
191
|
+
}
|