simt-dev 0.6.3 → 0.7.1
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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.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-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- 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 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- 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 +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/journal.mjs
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
|
-
// The write journal and `undo` (plan 0003 R49).
|
|
1
|
+
// The write journal and `undo` (plan 0003 R49, entry kinds review 0003 Q22).
|
|
2
2
|
//
|
|
3
|
-
// Every
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Every write a command performs is recorded as one entry in the run's set:
|
|
4
|
+
// `file` entries hold the pre-run bytes as base64 with the pre-run permission
|
|
5
|
+
// bits (binary-safe, unlike the version-1 utf8 spelling), and `git-config`
|
|
6
|
+
// entries hold one config key's before and after values, so `undo` restores
|
|
7
|
+
// the routing a run changed as well as the bytes. `commitJournal` folds each
|
|
8
|
+
// path and each key to a single entry — first before, last after — and `undo`
|
|
9
|
+
// restores entries in reverse, skipping the ones already at their pre-run
|
|
10
|
+
// state so a crashed undo resumes instead of refusing its own progress. The
|
|
11
|
+
// write-ahead marker records that intent; verification still precedes any
|
|
12
|
+
// restore on a fresh undo, so a refusal changes nothing.
|
|
8
13
|
//
|
|
9
14
|
// Three deliberate boundaries, each a failure this module refuses to be:
|
|
10
15
|
//
|
|
11
16
|
// - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
|
|
12
17
|
// rename (the same stale spelling survives in bench/probes.mjs comments).
|
|
13
18
|
// The journal lives under STATE_DIR like every other per-repo state.
|
|
14
|
-
// - Nothing under `.simt/` is journaled
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// repair anything.
|
|
19
|
+
// - Nothing under `.simt/` is journaled, one named exception: the shim
|
|
20
|
+
// directory. The journal recording itself is recursion; the ledger, index
|
|
21
|
+
// and candidates are evidence and caches, and restoring those to an earlier
|
|
22
|
+
// byte-shape would corrupt R17 rather than repair anything. The shims are
|
|
23
|
+
// the opposite of that: git's routing points at them, and the undo that
|
|
24
|
+
// restores the routing must restore the shims with it (review 0003 Q22;
|
|
25
|
+
// ADR-122 moved them under STATE_DIR). `journaledWriteFile` performs the
|
|
26
|
+
// other writes unrecorded.
|
|
18
27
|
// - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
|
|
19
28
|
// the record shape is file bytes — pointer changes are reported by the
|
|
20
29
|
// command as before and are not undone. Removals are journaled as entries
|
|
@@ -32,15 +41,30 @@
|
|
|
32
41
|
// a second `undo` naturally targets the set before it, and the untouched-check
|
|
33
42
|
// refuses when the two sets overlap on a file the first undo already restored.
|
|
34
43
|
|
|
44
|
+
import { execFile } from 'node:child_process';
|
|
35
45
|
import { createHash } from 'node:crypto';
|
|
36
|
-
import { cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
46
|
+
import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
37
47
|
import { dirname, join, relative, sep } from 'node:path';
|
|
48
|
+
import { promisify } from 'node:util';
|
|
38
49
|
|
|
39
50
|
import { STATE_DIR } from './name.mjs';
|
|
51
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
52
|
+
import { HOOKS_DIR } from './hooks/shim.mjs';
|
|
40
53
|
|
|
41
|
-
const
|
|
54
|
+
const exec = promisify(execFile);
|
|
55
|
+
|
|
56
|
+
const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
|
|
57
|
+
const JOURNAL_DIR_LEGACY = '.simt/journal';
|
|
42
58
|
const SET_SUFFIX = '.json';
|
|
43
59
|
const UNDONE_SUFFIX = '.undone.json';
|
|
60
|
+
/** A crashed `undo` leaves its intent behind under this suffix; the next undo
|
|
61
|
+
* of the same set resumes it instead of starting over (review 0003 Q22). A
|
|
62
|
+
* marker is history, not a set, so the set listing excludes it. */
|
|
63
|
+
const UNDOING_SUFFIX = '.undoing.json';
|
|
64
|
+
/** Sets that predate entry kinds store `beforeBytes` as utf8 text and carry no
|
|
65
|
+
* `kind`; they are still restorable, through the legacy branch of the restore
|
|
66
|
+
* below. `commitJournal` only writes version 2. */
|
|
67
|
+
const SET_VERSION = 2;
|
|
44
68
|
|
|
45
69
|
function digest(bytes) {
|
|
46
70
|
return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
|
|
@@ -51,18 +75,49 @@ function rel(root, path) {
|
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
/**
|
|
54
|
-
* Read the current bytes of a path, or null when it does
|
|
55
|
-
* failure propagates: an unreadable file is fail-closed,
|
|
78
|
+
* Read the current bytes and permission bits of a path, or null when it does
|
|
79
|
+
* not exist. Any other failure propagates: an unreadable file is fail-closed,
|
|
80
|
+
* not treated as absent.
|
|
56
81
|
*/
|
|
57
82
|
async function readOrNull(path) {
|
|
58
83
|
try {
|
|
59
|
-
|
|
84
|
+
const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
|
|
85
|
+
return { bytes, mode: st.mode & 0o777 };
|
|
60
86
|
} catch (error) {
|
|
61
87
|
if (error.code === 'ENOENT') return null;
|
|
62
88
|
throw error;
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Record a file the run created with an exclusive create (`O_EXCL`).
|
|
94
|
+
*
|
|
95
|
+
* The create itself is the proof nothing was there before, so `before` is
|
|
96
|
+
* null by construction rather than by re-reading: a check-then-record would
|
|
97
|
+
* reopen the race the exclusive flag just closed. For ordinary overwrites use
|
|
98
|
+
* `journaledWriteFile`, which snapshots `before` itself.
|
|
99
|
+
*/
|
|
100
|
+
export function recordCreatedFile(root, acc, path, text) {
|
|
101
|
+
if (relative(root, path).startsWith('..')) {
|
|
102
|
+
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
103
|
+
}
|
|
104
|
+
acc.push(fileEntry(rel(root, path), null, Buffer.from(text, 'utf8')));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A `file` entry: the pre-run bytes as base64 (binary-safe, unlike the
|
|
108
|
+
* version-1 utf8 spelling) with the pre-run permission bits, so `undo`
|
|
109
|
+
* restores both. Removals carry `afterDigest: null` and are re-created. */
|
|
110
|
+
function fileEntry(path, before, after) {
|
|
111
|
+
return {
|
|
112
|
+
kind: 'file',
|
|
113
|
+
path,
|
|
114
|
+
beforeDigest: before === null ? null : digest(before.bytes),
|
|
115
|
+
afterDigest: after === null ? null : digest(after),
|
|
116
|
+
beforeBytes: before === null ? null : before.bytes.toString('base64'),
|
|
117
|
+
modeBefore: before === null ? null : before.mode,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
66
121
|
/**
|
|
67
122
|
* Write a repo file and record the write in the run's set.
|
|
68
123
|
*
|
|
@@ -70,23 +125,30 @@ async function readOrNull(path) {
|
|
|
70
125
|
* @param {Array} acc the run's set, created by the caller and committed once
|
|
71
126
|
* @param {string} path absolute path to write
|
|
72
127
|
* @param {string} text the bytes to write
|
|
128
|
+
* @param {{mode?: number}} [opts] permission bits to set after the write, recorded
|
|
129
|
+
* in the entry — a shim written then chmodded is one change, not two, and
|
|
130
|
+
* `undo` must restore the mode the run found, not just the bytes
|
|
73
131
|
*/
|
|
74
|
-
export async function journaledWriteFile(root, acc, path, text) {
|
|
132
|
+
export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
|
|
75
133
|
if (relative(root, path).startsWith('..')) {
|
|
76
134
|
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
77
135
|
}
|
|
78
136
|
const before = await readOrNull(path);
|
|
79
|
-
// Internal state is written, not recorded — see the module header
|
|
80
|
-
|
|
137
|
+
// Internal state is written, not recorded — see the module header, which
|
|
138
|
+
// names the shim directory as the one exception under STATE_DIR.
|
|
139
|
+
const r = rel(root, path);
|
|
140
|
+
const internal =
|
|
141
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
81
142
|
await mkdir(dirname(path), { recursive: true });
|
|
82
|
-
|
|
143
|
+
// tmp + rename, the way this module writes its own set files: a crash
|
|
144
|
+
// mid-write leaves the old bytes in place, never a half-written file
|
|
145
|
+
// (review 0003 Q25). The mode lands on the tmp, so the rename carries it.
|
|
146
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
147
|
+
await writeFile(tmp, text, 'utf8');
|
|
148
|
+
if (mode !== undefined) await chmod(tmp, mode);
|
|
149
|
+
await rename(tmp, path);
|
|
83
150
|
if (!internal) {
|
|
84
|
-
acc.push(
|
|
85
|
-
path: rel(root, path),
|
|
86
|
-
beforeDigest: before === null ? null : digest(before),
|
|
87
|
-
afterDigest: digest(Buffer.from(text, 'utf8')),
|
|
88
|
-
beforeBytes: before === null ? null : before.toString('utf8'),
|
|
89
|
-
});
|
|
151
|
+
acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
|
|
90
152
|
}
|
|
91
153
|
}
|
|
92
154
|
|
|
@@ -128,22 +190,12 @@ export async function journaledCopyDir(root, acc, src, dest) {
|
|
|
128
190
|
const prev = before.get(path) ?? null;
|
|
129
191
|
before.delete(path);
|
|
130
192
|
const after = await readOrNull(abs);
|
|
131
|
-
if (prev !== null && after !== null && prev.equals(after)) continue;
|
|
132
|
-
acc.push(
|
|
133
|
-
path,
|
|
134
|
-
beforeDigest: prev === null ? null : digest(prev),
|
|
135
|
-
afterDigest: digest(after),
|
|
136
|
-
beforeBytes: prev === null ? null : prev.toString('utf8'),
|
|
137
|
-
});
|
|
193
|
+
if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
|
|
194
|
+
acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
|
|
138
195
|
}
|
|
139
196
|
// Files the copy removed (an overwrite that drops a name) are removals.
|
|
140
197
|
for (const [path, prev] of before) {
|
|
141
|
-
acc.push(
|
|
142
|
-
path,
|
|
143
|
-
beforeDigest: digest(prev),
|
|
144
|
-
afterDigest: null,
|
|
145
|
-
beforeBytes: prev.toString('utf8'),
|
|
146
|
-
});
|
|
198
|
+
acc.push(fileEntry(path, prev, null));
|
|
147
199
|
}
|
|
148
200
|
}
|
|
149
201
|
|
|
@@ -158,16 +210,49 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
158
210
|
}
|
|
159
211
|
const before = await readOrNull(path);
|
|
160
212
|
if (before === null) return;
|
|
161
|
-
const
|
|
213
|
+
const r = rel(root, path);
|
|
214
|
+
const internal =
|
|
215
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
162
216
|
await unlink(path);
|
|
163
217
|
if (!internal) {
|
|
164
|
-
acc.push(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
218
|
+
acc.push(fileEntry(rel(root, path), before, null));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Read one git config value in a repo, or null when unset. */
|
|
223
|
+
export async function readGitValue(root, key) {
|
|
224
|
+
try {
|
|
225
|
+
const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
|
|
226
|
+
return stdout.replace(/\n$/, '');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error.code === 1) return null;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Set (or, with `value: null`, unset) one git config key and record it as a
|
|
235
|
+
* `git-config` entry, so `undo` restores the routing a run changed — a shim
|
|
236
|
+
* without its `core.hooksPath` is a hook that no longer runs (review 0003
|
|
237
|
+
* Q22). A set to the value already there records nothing: there is no state
|
|
238
|
+
* to restore.
|
|
239
|
+
*
|
|
240
|
+
* @returns {Promise<object|null>} the entry, or null when nothing changed
|
|
241
|
+
*/
|
|
242
|
+
export async function journaledGitConfig(root, acc, key, value) {
|
|
243
|
+
if (typeof key !== 'string' || key === '' || /[\s]/.test(key)) {
|
|
244
|
+
throw new Error(`journal refuses an unparsable git config key: ${key}`);
|
|
245
|
+
}
|
|
246
|
+
const before = await readGitValue(root, key);
|
|
247
|
+
if (before === value) return null;
|
|
248
|
+
if (value === null) {
|
|
249
|
+
await exec('git', ['config', '--unset', key], { cwd: root });
|
|
250
|
+
} else {
|
|
251
|
+
await exec('git', ['config', key, value], { cwd: root });
|
|
170
252
|
}
|
|
253
|
+
const entry = { kind: 'git-config', key, before, after: value };
|
|
254
|
+
acc.push(entry);
|
|
255
|
+
return entry;
|
|
171
256
|
}
|
|
172
257
|
|
|
173
258
|
/**
|
|
@@ -179,14 +264,19 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
179
264
|
*/
|
|
180
265
|
/**
|
|
181
266
|
* The .gitignore bytes `ignoreJournal` would write for `text`, or null when
|
|
182
|
-
* `text` already ignores
|
|
267
|
+
* `text` already ignores machine-local state. One owner for the line
|
|
183
268
|
* spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
|
|
184
269
|
* there, and `doctor --fix --diff` previews it — all through this function so
|
|
185
270
|
* the three cannot disagree about what counts as ignored (plan 0003 R50).
|
|
271
|
+
*
|
|
272
|
+
* The ignored line is the whole local area (review 0003 Q19), not just the
|
|
273
|
+
* journal: every machine-local write lives under it, and one line covers them.
|
|
186
274
|
*/
|
|
187
275
|
export function journalIgnoreNext(text) {
|
|
188
|
-
const line = `${
|
|
189
|
-
|
|
276
|
+
const line = `${LOCAL_DIR}/`;
|
|
277
|
+
const legacy = `${JOURNAL_DIR_LEGACY}/`;
|
|
278
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
279
|
+
if (lines.includes(line) || lines.includes(legacy)) return null;
|
|
190
280
|
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
191
281
|
return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
|
|
192
282
|
}
|
|
@@ -212,6 +302,93 @@ function setName(command, at) {
|
|
|
212
302
|
return `${stamp}-${command}-${process.pid}.json`;
|
|
213
303
|
}
|
|
214
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Fold one run's entries so each path (each config key) appears once: the
|
|
307
|
+
* first before, the last after. Two writes to one path used to refuse `undo`
|
|
308
|
+
* outright, although the run's net effect is a single restore. A create
|
|
309
|
+
* followed by a removal in the same run is no change at all and folds away;
|
|
310
|
+
* unequal ghosts — a removal folded with a write — fold to the write's after
|
|
311
|
+
* with the removal's before.
|
|
312
|
+
*/
|
|
313
|
+
function coalesceEntries(acc) {
|
|
314
|
+
const order = [];
|
|
315
|
+
const folded = new Map();
|
|
316
|
+
for (const entry of acc) {
|
|
317
|
+
const slot = entry.kind === 'git-config' ? `\0${entry.key}` : entry.path;
|
|
318
|
+
const prev = folded.get(slot);
|
|
319
|
+
if (prev === undefined) {
|
|
320
|
+
order.push(slot);
|
|
321
|
+
folded.set(slot, entry);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (entry.kind === 'git-config') {
|
|
325
|
+
folded.set(slot, { ...entry, before: prev.before });
|
|
326
|
+
} else {
|
|
327
|
+
folded.set(slot, { ...entry, beforeDigest: prev.beforeDigest, beforeBytes: prev.beforeBytes, modeBefore: prev.modeBefore });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return order.map((slot) => folded.get(slot)).filter((entry) => {
|
|
331
|
+
if (entry.kind === 'git-config') return entry.before !== entry.after;
|
|
332
|
+
// Created then removed (or set back byte-for-byte, mode aside): nothing to restore.
|
|
333
|
+
return entry.beforeDigest !== null || entry.afterDigest !== null;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Move the sets that predate the move into `.simt/local/journal` (review 0003
|
|
339
|
+
* Q19's fallback directory) into the current directory, and take the legacy
|
|
340
|
+
* directory down when the move empties it. While the current directory is
|
|
341
|
+
* absent, `listJournalSets` falls back to the legacy one; once it exists, the
|
|
342
|
+
* two are disjoint and the legacy sets are invisible to `undo` — a repo that
|
|
343
|
+
* accumulated history under the old path loses it on its first write after the
|
|
344
|
+
* move (plan 0005 H3). `commitJournal` is the only writer, so the migration
|
|
345
|
+
* rides on it and heals on the next journal activity. Every legacy set
|
|
346
|
+
* predates every current set — the current directory is created by this
|
|
347
|
+
* writer — so the move cannot disturb recency order. A name present in both
|
|
348
|
+
* keeps both copies when the bytes differ: a set file is history, and a
|
|
349
|
+
* collision is not a reason to drop one of the two records. A crash mid-move
|
|
350
|
+
* splits the sets between the two directories; the move is idempotent, so the
|
|
351
|
+
* next call finishes it.
|
|
352
|
+
*/
|
|
353
|
+
async function migrateLegacyJournal(root) {
|
|
354
|
+
const legacy = join(root, JOURNAL_DIR_LEGACY);
|
|
355
|
+
let names;
|
|
356
|
+
try {
|
|
357
|
+
names = await readdir(legacy);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error.code !== 'ENOENT') throw error;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const dir = join(root, JOURNAL_DIR);
|
|
363
|
+
await mkdir(dir, { recursive: true });
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
// Sets, `.undone.json` history and `.undoing.json` markers all end in
|
|
366
|
+
// `.json`; a stray file of any other kind keeps the directory in place.
|
|
367
|
+
if (!name.endsWith(SET_SUFFIX)) continue;
|
|
368
|
+
const from = join(legacy, name);
|
|
369
|
+
const to = join(dir, name);
|
|
370
|
+
try {
|
|
371
|
+
await stat(to);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code !== 'ENOENT') throw error;
|
|
374
|
+
await rename(from, to);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// The name is taken: equal bytes are a duplicate of the same set (drop the
|
|
378
|
+
// legacy copy); differing bytes are a collision (keep both — the current
|
|
379
|
+
// copy shadows the legacy one in the listing).
|
|
380
|
+
const [a, b] = await Promise.all([readFile(from), readFile(to)]);
|
|
381
|
+
if (a.equals(b)) await unlink(from);
|
|
382
|
+
}
|
|
383
|
+
// `rmdir` fails when the directory is not empty, which is exactly the
|
|
384
|
+
// safety: a stray file the move does not take keeps the directory.
|
|
385
|
+
try {
|
|
386
|
+
await rmdir(legacy);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST') throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
215
392
|
/**
|
|
216
393
|
* Persist a run's set. Writes nothing when the run changed nothing, so the
|
|
217
394
|
* last set on disk is always the last run that wrote.
|
|
@@ -219,12 +396,14 @@ function setName(command, at) {
|
|
|
219
396
|
* @returns {Promise<string|null>} the set id (file name), or null
|
|
220
397
|
*/
|
|
221
398
|
export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
|
|
222
|
-
|
|
399
|
+
await migrateLegacyJournal(root);
|
|
400
|
+
const entries = coalesceEntries(acc);
|
|
401
|
+
if (entries.length === 0) return null;
|
|
223
402
|
await ignoreJournal(root);
|
|
224
403
|
const dir = join(root, JOURNAL_DIR);
|
|
225
404
|
await mkdir(dir, { recursive: true });
|
|
226
405
|
const id = setName(command, at);
|
|
227
|
-
const body = `${JSON.stringify({ version:
|
|
406
|
+
const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
|
|
228
407
|
const tmp = join(dir, `${id}.tmp-${process.pid}`);
|
|
229
408
|
await writeFile(tmp, body, 'utf8');
|
|
230
409
|
await rename(tmp, join(dir, id));
|
|
@@ -246,71 +425,180 @@ export async function journalHygiene(root) {
|
|
|
246
425
|
} catch {
|
|
247
426
|
text = '';
|
|
248
427
|
}
|
|
249
|
-
const
|
|
250
|
-
return { sets, ignored:
|
|
428
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
429
|
+
return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
|
|
251
430
|
}
|
|
252
431
|
|
|
253
432
|
/** Set ids newest-first, undone sets excluded. */
|
|
254
433
|
export async function listJournalSets(root) {
|
|
434
|
+
// The pre-move directory is a fallback for one release (review 0003 Q19);
|
|
435
|
+
// the sets in it move into the current directory on the next write
|
|
436
|
+
// (`migrateLegacyJournal`, plan 0005 H3), so the fallback only serves a
|
|
437
|
+
// repo that has not written since the move.
|
|
255
438
|
let names;
|
|
256
439
|
try {
|
|
257
440
|
names = await readdir(join(root, JOURNAL_DIR));
|
|
258
441
|
} catch (error) {
|
|
259
|
-
if (error.code
|
|
260
|
-
|
|
442
|
+
if (error.code !== 'ENOENT') throw error;
|
|
443
|
+
try {
|
|
444
|
+
names = await readdir(join(root, JOURNAL_DIR_LEGACY));
|
|
445
|
+
} catch (legacyError) {
|
|
446
|
+
if (legacyError.code === 'ENOENT') return [];
|
|
447
|
+
throw legacyError;
|
|
448
|
+
}
|
|
261
449
|
}
|
|
262
450
|
// An undone set ends in `.undone.json`, which itself ends in `.json` — the
|
|
263
451
|
// suffix test alone would list history as current and `undo` would restore
|
|
264
|
-
// the same set twice. Exclude the undone
|
|
265
|
-
|
|
452
|
+
// the same set twice. Exclude the undone (and the crash markers, which are
|
|
453
|
+
// intent, not sets) first.
|
|
454
|
+
return names
|
|
455
|
+
.filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX) && !n.endsWith(UNDOING_SUFFIX))
|
|
456
|
+
.sort()
|
|
457
|
+
.reverse();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The directory holding set `id`, preferring the post-move one. */
|
|
461
|
+
async function journalDirFor(root, id) {
|
|
462
|
+
try {
|
|
463
|
+
await stat(join(root, JOURNAL_DIR, id));
|
|
464
|
+
return JOURNAL_DIR;
|
|
465
|
+
} catch {
|
|
466
|
+
return JOURNAL_DIR_LEGACY;
|
|
467
|
+
}
|
|
266
468
|
}
|
|
267
469
|
|
|
268
470
|
export async function readJournalSet(root, id) {
|
|
269
|
-
const
|
|
270
|
-
|
|
471
|
+
const dir = await journalDirFor(root, id);
|
|
472
|
+
const raw = await readFile(join(root, dir, id), 'utf8');
|
|
473
|
+
const set = JSON.parse(raw);
|
|
474
|
+
if (set.version !== SET_VERSION) {
|
|
475
|
+
// Version 1 stored `beforeBytes` as utf8 text and knew no kinds: read it
|
|
476
|
+
// through the legacy branch of the restore rather than refusing history.
|
|
477
|
+
return { ...set, entries: set.entries.map((e) => ({ ...e, kind: e.kind ?? 'file-legacy' })) };
|
|
478
|
+
}
|
|
479
|
+
return set;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The pre-run bytes of an entry as a string, for previews and diffs. */
|
|
483
|
+
export function beforeText(entry) {
|
|
484
|
+
if (entry.kind === 'git-config' || entry.beforeBytes === null || entry.beforeBytes === undefined) return entry.before ?? null;
|
|
485
|
+
if (entry.kind === 'file-legacy') return entry.beforeBytes;
|
|
486
|
+
return Buffer.from(entry.beforeBytes, 'base64').toString('utf8');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The display name of an entry: a path for files, the key for config. */
|
|
490
|
+
export function entryLabel(entry) {
|
|
491
|
+
return entry.kind === 'git-config' ? `git config ${entry.key}` : entry.path;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The digest of a file's current bytes, or null when absent. */
|
|
495
|
+
function currentDigest(state) {
|
|
496
|
+
return state === null ? null : digest(state.bytes);
|
|
271
497
|
}
|
|
272
498
|
|
|
273
499
|
/**
|
|
274
|
-
*
|
|
275
|
-
*
|
|
500
|
+
* Whether an entry is already at its pre-run state — restored by an earlier
|
|
501
|
+
* attempt, or hand-set back to exactly what the run found. Restoring it again
|
|
502
|
+
* would be a no-op write, so a resumed undo skips it instead of refusing it.
|
|
503
|
+
*/
|
|
504
|
+
async function alreadyRestored(root, entry) {
|
|
505
|
+
if (entry.kind === 'git-config') {
|
|
506
|
+
return (await readGitValue(root, entry.key)) === entry.before;
|
|
507
|
+
}
|
|
508
|
+
const state = await readOrNull(join(root, entry.path));
|
|
509
|
+
return currentDigest(state) === entry.beforeDigest;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Whether an entry is still at the state the run left: the precondition for
|
|
513
|
+
* restoring it. Anything else is somebody's edit, and the undo is refused. */
|
|
514
|
+
async function untouchedSince(root, entry) {
|
|
515
|
+
if (entry.kind === 'git-config') {
|
|
516
|
+
return (await readGitValue(root, entry.key)) === entry.after;
|
|
517
|
+
}
|
|
518
|
+
const state = await readOrNull(join(root, entry.path));
|
|
519
|
+
return currentDigest(state) === entry.afterDigest;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function entryBytes(entry) {
|
|
523
|
+
if (entry.beforeBytes === null) return null;
|
|
524
|
+
if (entry.kind === 'file-legacy') return Buffer.from(entry.beforeBytes, 'utf8');
|
|
525
|
+
return Buffer.from(entry.beforeBytes, 'base64');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Restore one entry: bytes and permission bits for files, the value for config. */
|
|
529
|
+
async function restoreEntry(root, entry) {
|
|
530
|
+
if (entry.kind === 'git-config') {
|
|
531
|
+
await exec('git', entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before], {
|
|
532
|
+
cwd: root,
|
|
533
|
+
});
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const abs = join(root, entry.path);
|
|
537
|
+
if (entry.afterDigest === null) {
|
|
538
|
+
// The run removed this path: re-create it with its pre-run bytes.
|
|
539
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
540
|
+
await writeFile(abs, entryBytes(entry));
|
|
541
|
+
} else if (entry.beforeDigest === null) {
|
|
542
|
+
// The run created this path: remove it.
|
|
543
|
+
await unlink(abs);
|
|
544
|
+
} else {
|
|
545
|
+
await writeFile(abs, entryBytes(entry));
|
|
546
|
+
}
|
|
547
|
+
if (entry.modeBefore !== null && entry.modeBefore !== undefined) {
|
|
548
|
+
await chmod(abs, entry.modeBefore);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Restore one set: verify every entry is untouched since, then restore all in
|
|
554
|
+
* reverse — later writes in the run unwind first, so overlapping restores land
|
|
555
|
+
* in pre-run order.
|
|
556
|
+
*
|
|
557
|
+
* The intent lands on disk before the first restore (the write-ahead marker),
|
|
558
|
+
* so a crash mid-undo is resumable: entries already at their pre-run state
|
|
559
|
+
* are skipped, the rest are verified against the run's after-state and
|
|
560
|
+
* restored, and anything at neither refuses the whole undo. Verification
|
|
561
|
+
* still precedes any restore when there is no marker, so a refusal on a fresh
|
|
562
|
+
* undo changes nothing.
|
|
276
563
|
*
|
|
277
|
-
* @returns {Promise<{restored: string[]}|{refused: {path: string, detail: string}}>}
|
|
564
|
+
* @returns {Promise<{restored: string[], resumed: boolean}|{refused: {path: string, detail: string}}>}
|
|
278
565
|
*/
|
|
279
566
|
async function undoJournalSet(root, id) {
|
|
280
567
|
const set = await readJournalSet(root, id);
|
|
568
|
+
const dir = await journalDirFor(root, id);
|
|
569
|
+
const marker = join(root, dir, `${id}${UNDOING_SUFFIX}`);
|
|
570
|
+
const resumed = (await readOrNull(marker)) !== null;
|
|
281
571
|
for (const entry of set.entries) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (now !== entry.afterDigest) {
|
|
572
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
573
|
+
if (!(await untouchedSince(root, entry))) {
|
|
574
|
+
const label = entryLabel(entry);
|
|
286
575
|
return {
|
|
287
576
|
refused: {
|
|
288
|
-
path:
|
|
577
|
+
path: label,
|
|
289
578
|
detail:
|
|
290
|
-
|
|
291
|
-
?
|
|
292
|
-
:
|
|
579
|
+
entry.kind === 'git-config'
|
|
580
|
+
? `git config ${entry.key} changed after the journaled write`
|
|
581
|
+
: (await readOrNull(join(root, entry.path))) === null
|
|
582
|
+
? 'the file was deleted after the journaled write'
|
|
583
|
+
: 'the file changed after the journaled write',
|
|
293
584
|
},
|
|
294
585
|
};
|
|
295
586
|
}
|
|
296
587
|
}
|
|
588
|
+
// The marker is the intent, written before anything is restored: a crash
|
|
589
|
+
// from here on resumes instead of refusing the files already done.
|
|
590
|
+
const tmp = `${marker}.tmp-${process.pid}`;
|
|
591
|
+
await writeFile(tmp, `${JSON.stringify({ version: 1, set: id, at: Date.now() }, null, 2)}\n`, 'utf8');
|
|
592
|
+
await rename(tmp, marker);
|
|
297
593
|
const restored = [];
|
|
298
|
-
for (const entry of set.entries) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
303
|
-
await writeFile(abs, entry.beforeBytes, 'utf8');
|
|
304
|
-
} else if (entry.beforeBytes === null) {
|
|
305
|
-
// The run created this path: remove it.
|
|
306
|
-
await unlink(abs);
|
|
307
|
-
} else {
|
|
308
|
-
await writeFile(abs, entry.beforeBytes, 'utf8');
|
|
309
|
-
}
|
|
310
|
-
restored.push(entry.path);
|
|
594
|
+
for (const entry of [...set.entries].reverse()) {
|
|
595
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
596
|
+
await restoreEntry(root, entry);
|
|
597
|
+
restored.push(entryLabel(entry));
|
|
311
598
|
}
|
|
312
|
-
await
|
|
313
|
-
|
|
599
|
+
await unlink(marker);
|
|
600
|
+
await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
|
|
601
|
+
return { restored, resumed };
|
|
314
602
|
}
|
|
315
603
|
|
|
316
604
|
/**
|