simt-dev 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +296 -77
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/journal.mjs
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
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
|
//
|
|
@@ -32,15 +37,29 @@
|
|
|
32
37
|
// a second `undo` naturally targets the set before it, and the untouched-check
|
|
33
38
|
// refuses when the two sets overlap on a file the first undo already restored.
|
|
34
39
|
|
|
40
|
+
import { execFile } from 'node:child_process';
|
|
35
41
|
import { createHash } from 'node:crypto';
|
|
36
|
-
import { cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
42
|
+
import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
37
43
|
import { dirname, join, relative, sep } from 'node:path';
|
|
44
|
+
import { promisify } from 'node:util';
|
|
38
45
|
|
|
39
46
|
import { STATE_DIR } from './name.mjs';
|
|
47
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
40
48
|
|
|
41
|
-
const
|
|
49
|
+
const exec = promisify(execFile);
|
|
50
|
+
|
|
51
|
+
const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
|
|
52
|
+
const JOURNAL_DIR_LEGACY = '.simt/journal';
|
|
42
53
|
const SET_SUFFIX = '.json';
|
|
43
54
|
const UNDONE_SUFFIX = '.undone.json';
|
|
55
|
+
/** A crashed `undo` leaves its intent behind under this suffix; the next undo
|
|
56
|
+
* of the same set resumes it instead of starting over (review 0003 Q22). A
|
|
57
|
+
* marker is history, not a set, so the set listing excludes it. */
|
|
58
|
+
const UNDOING_SUFFIX = '.undoing.json';
|
|
59
|
+
/** Sets that predate entry kinds store `beforeBytes` as utf8 text and carry no
|
|
60
|
+
* `kind`; they are still restorable, through the legacy branch of the restore
|
|
61
|
+
* below. `commitJournal` only writes version 2. */
|
|
62
|
+
const SET_VERSION = 2;
|
|
44
63
|
|
|
45
64
|
function digest(bytes) {
|
|
46
65
|
return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
|
|
@@ -51,18 +70,49 @@ function rel(root, path) {
|
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/**
|
|
54
|
-
* Read the current bytes of a path, or null when it does
|
|
55
|
-
* failure propagates: an unreadable file is fail-closed,
|
|
73
|
+
* Read the current bytes and permission bits of a path, or null when it does
|
|
74
|
+
* not exist. Any other failure propagates: an unreadable file is fail-closed,
|
|
75
|
+
* not treated as absent.
|
|
56
76
|
*/
|
|
57
77
|
async function readOrNull(path) {
|
|
58
78
|
try {
|
|
59
|
-
|
|
79
|
+
const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
|
|
80
|
+
return { bytes, mode: st.mode & 0o777 };
|
|
60
81
|
} catch (error) {
|
|
61
82
|
if (error.code === 'ENOENT') return null;
|
|
62
83
|
throw error;
|
|
63
84
|
}
|
|
64
85
|
}
|
|
65
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Record a file the run created with an exclusive create (`O_EXCL`).
|
|
89
|
+
*
|
|
90
|
+
* The create itself is the proof nothing was there before, so `before` is
|
|
91
|
+
* null by construction rather than by re-reading: a check-then-record would
|
|
92
|
+
* reopen the race the exclusive flag just closed. For ordinary overwrites use
|
|
93
|
+
* `journaledWriteFile`, which snapshots `before` itself.
|
|
94
|
+
*/
|
|
95
|
+
export function recordCreatedFile(root, acc, path, text) {
|
|
96
|
+
if (relative(root, path).startsWith('..')) {
|
|
97
|
+
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
98
|
+
}
|
|
99
|
+
acc.push(fileEntry(rel(root, path), null, Buffer.from(text, 'utf8')));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A `file` entry: the pre-run bytes as base64 (binary-safe, unlike the
|
|
103
|
+
* version-1 utf8 spelling) with the pre-run permission bits, so `undo`
|
|
104
|
+
* restores both. Removals carry `afterDigest: null` and are re-created. */
|
|
105
|
+
function fileEntry(path, before, after) {
|
|
106
|
+
return {
|
|
107
|
+
kind: 'file',
|
|
108
|
+
path,
|
|
109
|
+
beforeDigest: before === null ? null : digest(before.bytes),
|
|
110
|
+
afterDigest: after === null ? null : digest(after),
|
|
111
|
+
beforeBytes: before === null ? null : before.bytes.toString('base64'),
|
|
112
|
+
modeBefore: before === null ? null : before.mode,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
66
116
|
/**
|
|
67
117
|
* Write a repo file and record the write in the run's set.
|
|
68
118
|
*
|
|
@@ -70,8 +120,11 @@ async function readOrNull(path) {
|
|
|
70
120
|
* @param {Array} acc the run's set, created by the caller and committed once
|
|
71
121
|
* @param {string} path absolute path to write
|
|
72
122
|
* @param {string} text the bytes to write
|
|
123
|
+
* @param {{mode?: number}} [opts] permission bits to set after the write, recorded
|
|
124
|
+
* in the entry — a shim written then chmodded is one change, not two, and
|
|
125
|
+
* `undo` must restore the mode the run found, not just the bytes
|
|
73
126
|
*/
|
|
74
|
-
export async function journaledWriteFile(root, acc, path, text) {
|
|
127
|
+
export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
|
|
75
128
|
if (relative(root, path).startsWith('..')) {
|
|
76
129
|
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
77
130
|
}
|
|
@@ -79,14 +132,15 @@ export async function journaledWriteFile(root, acc, path, text) {
|
|
|
79
132
|
// Internal state is written, not recorded — see the module header.
|
|
80
133
|
const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
|
|
81
134
|
await mkdir(dirname(path), { recursive: true });
|
|
82
|
-
|
|
135
|
+
// tmp + rename, the way this module writes its own set files: a crash
|
|
136
|
+
// mid-write leaves the old bytes in place, never a half-written file
|
|
137
|
+
// (review 0003 Q25). The mode lands on the tmp, so the rename carries it.
|
|
138
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
139
|
+
await writeFile(tmp, text, 'utf8');
|
|
140
|
+
if (mode !== undefined) await chmod(tmp, mode);
|
|
141
|
+
await rename(tmp, path);
|
|
83
142
|
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
|
-
});
|
|
143
|
+
acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
|
|
90
144
|
}
|
|
91
145
|
}
|
|
92
146
|
|
|
@@ -128,22 +182,12 @@ export async function journaledCopyDir(root, acc, src, dest) {
|
|
|
128
182
|
const prev = before.get(path) ?? null;
|
|
129
183
|
before.delete(path);
|
|
130
184
|
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
|
-
});
|
|
185
|
+
if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
|
|
186
|
+
acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
|
|
138
187
|
}
|
|
139
188
|
// Files the copy removed (an overwrite that drops a name) are removals.
|
|
140
189
|
for (const [path, prev] of before) {
|
|
141
|
-
acc.push(
|
|
142
|
-
path,
|
|
143
|
-
beforeDigest: digest(prev),
|
|
144
|
-
afterDigest: null,
|
|
145
|
-
beforeBytes: prev.toString('utf8'),
|
|
146
|
-
});
|
|
190
|
+
acc.push(fileEntry(path, prev, null));
|
|
147
191
|
}
|
|
148
192
|
}
|
|
149
193
|
|
|
@@ -161,13 +205,44 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
161
205
|
const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
|
|
162
206
|
await unlink(path);
|
|
163
207
|
if (!internal) {
|
|
164
|
-
acc.push(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
208
|
+
acc.push(fileEntry(rel(root, path), before, null));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read one git config value in a repo, or null when unset. */
|
|
213
|
+
export async function readGitValue(root, key) {
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
|
|
216
|
+
return stdout.replace(/\n$/, '');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code === 1) return null;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set (or, with `value: null`, unset) one git config key and record it as a
|
|
225
|
+
* `git-config` entry, so `undo` restores the routing a run changed — a shim
|
|
226
|
+
* without its `core.hooksPath` is a hook that no longer runs (review 0003
|
|
227
|
+
* Q22). A set to the value already there records nothing: there is no state
|
|
228
|
+
* to restore.
|
|
229
|
+
*
|
|
230
|
+
* @returns {Promise<object|null>} the entry, or null when nothing changed
|
|
231
|
+
*/
|
|
232
|
+
export async function journaledGitConfig(root, acc, key, value) {
|
|
233
|
+
if (typeof key !== 'string' || key === '' || /[\s]/.test(key)) {
|
|
234
|
+
throw new Error(`journal refuses an unparsable git config key: ${key}`);
|
|
170
235
|
}
|
|
236
|
+
const before = await readGitValue(root, key);
|
|
237
|
+
if (before === value) return null;
|
|
238
|
+
if (value === null) {
|
|
239
|
+
await exec('git', ['config', '--unset', key], { cwd: root });
|
|
240
|
+
} else {
|
|
241
|
+
await exec('git', ['config', key, value], { cwd: root });
|
|
242
|
+
}
|
|
243
|
+
const entry = { kind: 'git-config', key, before, after: value };
|
|
244
|
+
acc.push(entry);
|
|
245
|
+
return entry;
|
|
171
246
|
}
|
|
172
247
|
|
|
173
248
|
/**
|
|
@@ -179,14 +254,19 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
179
254
|
*/
|
|
180
255
|
/**
|
|
181
256
|
* The .gitignore bytes `ignoreJournal` would write for `text`, or null when
|
|
182
|
-
* `text` already ignores
|
|
257
|
+
* `text` already ignores machine-local state. One owner for the line
|
|
183
258
|
* spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
|
|
184
259
|
* there, and `doctor --fix --diff` previews it — all through this function so
|
|
185
260
|
* the three cannot disagree about what counts as ignored (plan 0003 R50).
|
|
261
|
+
*
|
|
262
|
+
* The ignored line is the whole local area (review 0003 Q19), not just the
|
|
263
|
+
* journal: every machine-local write lives under it, and one line covers them.
|
|
186
264
|
*/
|
|
187
265
|
export function journalIgnoreNext(text) {
|
|
188
|
-
const line = `${
|
|
189
|
-
|
|
266
|
+
const line = `${LOCAL_DIR}/`;
|
|
267
|
+
const legacy = `${JOURNAL_DIR_LEGACY}/`;
|
|
268
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
269
|
+
if (lines.includes(line) || lines.includes(legacy)) return null;
|
|
190
270
|
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
191
271
|
return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
|
|
192
272
|
}
|
|
@@ -212,6 +292,38 @@ function setName(command, at) {
|
|
|
212
292
|
return `${stamp}-${command}-${process.pid}.json`;
|
|
213
293
|
}
|
|
214
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Fold one run's entries so each path (each config key) appears once: the
|
|
297
|
+
* first before, the last after. Two writes to one path used to refuse `undo`
|
|
298
|
+
* outright, although the run's net effect is a single restore. A create
|
|
299
|
+
* followed by a removal in the same run is no change at all and folds away;
|
|
300
|
+
* unequal ghosts — a removal folded with a write — fold to the write's after
|
|
301
|
+
* with the removal's before.
|
|
302
|
+
*/
|
|
303
|
+
function coalesceEntries(acc) {
|
|
304
|
+
const order = [];
|
|
305
|
+
const folded = new Map();
|
|
306
|
+
for (const entry of acc) {
|
|
307
|
+
const slot = entry.kind === 'git-config' ? `\0${entry.key}` : entry.path;
|
|
308
|
+
const prev = folded.get(slot);
|
|
309
|
+
if (prev === undefined) {
|
|
310
|
+
order.push(slot);
|
|
311
|
+
folded.set(slot, entry);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (entry.kind === 'git-config') {
|
|
315
|
+
folded.set(slot, { ...entry, before: prev.before });
|
|
316
|
+
} else {
|
|
317
|
+
folded.set(slot, { ...entry, beforeDigest: prev.beforeDigest, beforeBytes: prev.beforeBytes, modeBefore: prev.modeBefore });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return order.map((slot) => folded.get(slot)).filter((entry) => {
|
|
321
|
+
if (entry.kind === 'git-config') return entry.before !== entry.after;
|
|
322
|
+
// Created then removed (or set back byte-for-byte, mode aside): nothing to restore.
|
|
323
|
+
return entry.beforeDigest !== null || entry.afterDigest !== null;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
215
327
|
/**
|
|
216
328
|
* Persist a run's set. Writes nothing when the run changed nothing, so the
|
|
217
329
|
* last set on disk is always the last run that wrote.
|
|
@@ -219,12 +331,13 @@ function setName(command, at) {
|
|
|
219
331
|
* @returns {Promise<string|null>} the set id (file name), or null
|
|
220
332
|
*/
|
|
221
333
|
export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
|
|
222
|
-
|
|
334
|
+
const entries = coalesceEntries(acc);
|
|
335
|
+
if (entries.length === 0) return null;
|
|
223
336
|
await ignoreJournal(root);
|
|
224
337
|
const dir = join(root, JOURNAL_DIR);
|
|
225
338
|
await mkdir(dir, { recursive: true });
|
|
226
339
|
const id = setName(command, at);
|
|
227
|
-
const body = `${JSON.stringify({ version:
|
|
340
|
+
const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
|
|
228
341
|
const tmp = join(dir, `${id}.tmp-${process.pid}`);
|
|
229
342
|
await writeFile(tmp, body, 'utf8');
|
|
230
343
|
await rename(tmp, join(dir, id));
|
|
@@ -246,71 +359,177 @@ export async function journalHygiene(root) {
|
|
|
246
359
|
} catch {
|
|
247
360
|
text = '';
|
|
248
361
|
}
|
|
249
|
-
const
|
|
250
|
-
return { sets, ignored:
|
|
362
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
363
|
+
return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
|
|
251
364
|
}
|
|
252
365
|
|
|
253
366
|
/** Set ids newest-first, undone sets excluded. */
|
|
254
367
|
export async function listJournalSets(root) {
|
|
368
|
+
// The pre-move directory is a fallback for one release (review 0003 Q19).
|
|
255
369
|
let names;
|
|
256
370
|
try {
|
|
257
371
|
names = await readdir(join(root, JOURNAL_DIR));
|
|
258
372
|
} catch (error) {
|
|
259
|
-
if (error.code
|
|
260
|
-
|
|
373
|
+
if (error.code !== 'ENOENT') throw error;
|
|
374
|
+
try {
|
|
375
|
+
names = await readdir(join(root, JOURNAL_DIR_LEGACY));
|
|
376
|
+
} catch (legacyError) {
|
|
377
|
+
if (legacyError.code === 'ENOENT') return [];
|
|
378
|
+
throw legacyError;
|
|
379
|
+
}
|
|
261
380
|
}
|
|
262
381
|
// An undone set ends in `.undone.json`, which itself ends in `.json` — the
|
|
263
382
|
// suffix test alone would list history as current and `undo` would restore
|
|
264
|
-
// the same set twice. Exclude the undone
|
|
265
|
-
|
|
383
|
+
// the same set twice. Exclude the undone (and the crash markers, which are
|
|
384
|
+
// intent, not sets) first.
|
|
385
|
+
return names
|
|
386
|
+
.filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX) && !n.endsWith(UNDOING_SUFFIX))
|
|
387
|
+
.sort()
|
|
388
|
+
.reverse();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The directory holding set `id`, preferring the post-move one. */
|
|
392
|
+
async function journalDirFor(root, id) {
|
|
393
|
+
try {
|
|
394
|
+
await stat(join(root, JOURNAL_DIR, id));
|
|
395
|
+
return JOURNAL_DIR;
|
|
396
|
+
} catch {
|
|
397
|
+
return JOURNAL_DIR_LEGACY;
|
|
398
|
+
}
|
|
266
399
|
}
|
|
267
400
|
|
|
268
401
|
export async function readJournalSet(root, id) {
|
|
269
|
-
const
|
|
270
|
-
|
|
402
|
+
const dir = await journalDirFor(root, id);
|
|
403
|
+
const raw = await readFile(join(root, dir, id), 'utf8');
|
|
404
|
+
const set = JSON.parse(raw);
|
|
405
|
+
if (set.version !== SET_VERSION) {
|
|
406
|
+
// Version 1 stored `beforeBytes` as utf8 text and knew no kinds: read it
|
|
407
|
+
// through the legacy branch of the restore rather than refusing history.
|
|
408
|
+
return { ...set, entries: set.entries.map((e) => ({ ...e, kind: e.kind ?? 'file-legacy' })) };
|
|
409
|
+
}
|
|
410
|
+
return set;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The pre-run bytes of an entry as a string, for previews and diffs. */
|
|
414
|
+
export function beforeText(entry) {
|
|
415
|
+
if (entry.kind === 'git-config' || entry.beforeBytes === null || entry.beforeBytes === undefined) return entry.before ?? null;
|
|
416
|
+
if (entry.kind === 'file-legacy') return entry.beforeBytes;
|
|
417
|
+
return Buffer.from(entry.beforeBytes, 'base64').toString('utf8');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The display name of an entry: a path for files, the key for config. */
|
|
421
|
+
export function entryLabel(entry) {
|
|
422
|
+
return entry.kind === 'git-config' ? `git config ${entry.key}` : entry.path;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The digest of a file's current bytes, or null when absent. */
|
|
426
|
+
function currentDigest(state) {
|
|
427
|
+
return state === null ? null : digest(state.bytes);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether an entry is already at its pre-run state — restored by an earlier
|
|
432
|
+
* attempt, or hand-set back to exactly what the run found. Restoring it again
|
|
433
|
+
* would be a no-op write, so a resumed undo skips it instead of refusing it.
|
|
434
|
+
*/
|
|
435
|
+
async function alreadyRestored(root, entry) {
|
|
436
|
+
if (entry.kind === 'git-config') {
|
|
437
|
+
return (await readGitValue(root, entry.key)) === entry.before;
|
|
438
|
+
}
|
|
439
|
+
const state = await readOrNull(join(root, entry.path));
|
|
440
|
+
return currentDigest(state) === entry.beforeDigest;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Whether an entry is still at the state the run left: the precondition for
|
|
444
|
+
* restoring it. Anything else is somebody's edit, and the undo is refused. */
|
|
445
|
+
async function untouchedSince(root, entry) {
|
|
446
|
+
if (entry.kind === 'git-config') {
|
|
447
|
+
return (await readGitValue(root, entry.key)) === entry.after;
|
|
448
|
+
}
|
|
449
|
+
const state = await readOrNull(join(root, entry.path));
|
|
450
|
+
return currentDigest(state) === entry.afterDigest;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function entryBytes(entry) {
|
|
454
|
+
if (entry.beforeBytes === null) return null;
|
|
455
|
+
if (entry.kind === 'file-legacy') return Buffer.from(entry.beforeBytes, 'utf8');
|
|
456
|
+
return Buffer.from(entry.beforeBytes, 'base64');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Restore one entry: bytes and permission bits for files, the value for config. */
|
|
460
|
+
async function restoreEntry(root, entry) {
|
|
461
|
+
if (entry.kind === 'git-config') {
|
|
462
|
+
await exec('git', entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before], {
|
|
463
|
+
cwd: root,
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const abs = join(root, entry.path);
|
|
468
|
+
if (entry.afterDigest === null) {
|
|
469
|
+
// The run removed this path: re-create it with its pre-run bytes.
|
|
470
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
471
|
+
await writeFile(abs, entryBytes(entry));
|
|
472
|
+
} else if (entry.beforeDigest === null) {
|
|
473
|
+
// The run created this path: remove it.
|
|
474
|
+
await unlink(abs);
|
|
475
|
+
} else {
|
|
476
|
+
await writeFile(abs, entryBytes(entry));
|
|
477
|
+
}
|
|
478
|
+
if (entry.modeBefore !== null && entry.modeBefore !== undefined) {
|
|
479
|
+
await chmod(abs, entry.modeBefore);
|
|
480
|
+
}
|
|
271
481
|
}
|
|
272
482
|
|
|
273
483
|
/**
|
|
274
|
-
* Restore one set: verify every
|
|
275
|
-
*
|
|
484
|
+
* Restore one set: verify every entry is untouched since, then restore all in
|
|
485
|
+
* reverse — later writes in the run unwind first, so overlapping restores land
|
|
486
|
+
* in pre-run order.
|
|
276
487
|
*
|
|
277
|
-
*
|
|
488
|
+
* The intent lands on disk before the first restore (the write-ahead marker),
|
|
489
|
+
* so a crash mid-undo is resumable: entries already at their pre-run state
|
|
490
|
+
* are skipped, the rest are verified against the run's after-state and
|
|
491
|
+
* restored, and anything at neither refuses the whole undo. Verification
|
|
492
|
+
* still precedes any restore when there is no marker, so a refusal on a fresh
|
|
493
|
+
* undo changes nothing.
|
|
494
|
+
*
|
|
495
|
+
* @returns {Promise<{restored: string[], resumed: boolean}|{refused: {path: string, detail: string}}>}
|
|
278
496
|
*/
|
|
279
497
|
async function undoJournalSet(root, id) {
|
|
280
498
|
const set = await readJournalSet(root, id);
|
|
499
|
+
const dir = await journalDirFor(root, id);
|
|
500
|
+
const marker = join(root, dir, `${id}${UNDOING_SUFFIX}`);
|
|
501
|
+
const resumed = (await readOrNull(marker)) !== null;
|
|
281
502
|
for (const entry of set.entries) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (now !== entry.afterDigest) {
|
|
503
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
504
|
+
if (!(await untouchedSince(root, entry))) {
|
|
505
|
+
const label = entryLabel(entry);
|
|
286
506
|
return {
|
|
287
507
|
refused: {
|
|
288
|
-
path:
|
|
508
|
+
path: label,
|
|
289
509
|
detail:
|
|
290
|
-
|
|
291
|
-
?
|
|
292
|
-
:
|
|
510
|
+
entry.kind === 'git-config'
|
|
511
|
+
? `git config ${entry.key} changed after the journaled write`
|
|
512
|
+
: (await readOrNull(join(root, entry.path))) === null
|
|
513
|
+
? 'the file was deleted after the journaled write'
|
|
514
|
+
: 'the file changed after the journaled write',
|
|
293
515
|
},
|
|
294
516
|
};
|
|
295
517
|
}
|
|
296
518
|
}
|
|
519
|
+
// The marker is the intent, written before anything is restored: a crash
|
|
520
|
+
// from here on resumes instead of refusing the files already done.
|
|
521
|
+
const tmp = `${marker}.tmp-${process.pid}`;
|
|
522
|
+
await writeFile(tmp, `${JSON.stringify({ version: 1, set: id, at: Date.now() }, null, 2)}\n`, 'utf8');
|
|
523
|
+
await rename(tmp, marker);
|
|
297
524
|
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);
|
|
525
|
+
for (const entry of [...set.entries].reverse()) {
|
|
526
|
+
if (await alreadyRestored(root, entry)) continue;
|
|
527
|
+
await restoreEntry(root, entry);
|
|
528
|
+
restored.push(entryLabel(entry));
|
|
311
529
|
}
|
|
312
|
-
await
|
|
313
|
-
|
|
530
|
+
await unlink(marker);
|
|
531
|
+
await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
|
|
532
|
+
return { restored, resumed };
|
|
314
533
|
}
|
|
315
534
|
|
|
316
535
|
/**
|
package/src/kernel/card.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { ECOSYSTEMS } from '../projection/manifest.mjs';
|
|
26
|
+
import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
|
|
@@ -93,10 +94,12 @@ const FOCUSED = [
|
|
|
93
94
|
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
94
95
|
// or jest takes a file argument through the script; one with no such script gets the
|
|
95
96
|
// built-in runner, which is the only answer that needs nothing installed to already
|
|
96
|
-
// be true.
|
|
97
|
-
template: (s, f, scripts) => {
|
|
97
|
+
// be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
|
|
98
|
+
template: (s, f, scripts, runner) => {
|
|
98
99
|
const test = scripts?.test ?? '';
|
|
99
|
-
|
|
100
|
+
if (!/vitest|jest/.test(test)) return 'node --test {file}';
|
|
101
|
+
const run = runner?.run ?? 'npm';
|
|
102
|
+
return `${run} test -- {file}`;
|
|
100
103
|
},
|
|
101
104
|
},
|
|
102
105
|
];
|
|
@@ -123,15 +126,32 @@ function text(value) {
|
|
|
123
126
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
/** The repository's own `package.json` scripts,
|
|
129
|
+
/** The repository's own `package.json` scripts, plus the manager that runs them. */
|
|
127
130
|
async function readScripts(root) {
|
|
128
131
|
try {
|
|
129
132
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
130
|
-
|
|
133
|
+
const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
134
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
135
|
+
// never an assumption of npm — the card, the gates and CI read one answer.
|
|
136
|
+
const lockfiles = [];
|
|
137
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
138
|
+
try {
|
|
139
|
+
await readFile(join(root, file), 'utf8');
|
|
140
|
+
lockfiles.push(file);
|
|
141
|
+
} catch {
|
|
142
|
+
// Absent is the normal case; only present files name the runner.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const runner = resolveNodeRunner({
|
|
146
|
+
facts: [],
|
|
147
|
+
lockfiles,
|
|
148
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
149
|
+
});
|
|
150
|
+
return { scripts, runner };
|
|
131
151
|
} catch {
|
|
132
152
|
// No package.json is a normal shape (a Python or Go repository), and an unreadable one
|
|
133
153
|
// is reported by the manifest path. Either way the card has no scripts to draw on.
|
|
134
|
-
return {};
|
|
154
|
+
return { scripts: {}, runner: null };
|
|
135
155
|
}
|
|
136
156
|
}
|
|
137
157
|
|
|
@@ -145,7 +165,7 @@ async function readScripts(root) {
|
|
|
145
165
|
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
146
166
|
* so the card and the gate list cannot name two different commands for one repository.
|
|
147
167
|
*/
|
|
148
|
-
function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
168
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
|
|
149
169
|
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
150
170
|
if (declared) return declared;
|
|
151
171
|
|
|
@@ -154,11 +174,11 @@ function resolveVerb(verb, { manifest, facts, stack, scripts }) {
|
|
|
154
174
|
// is the one a hook and CI run, which is what the card is reporting.
|
|
155
175
|
const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
|
|
156
176
|
for (const name of scriptNames) {
|
|
157
|
-
if (text(scripts?.[name])) return
|
|
177
|
+
if (text(scripts?.[name])) return runScript(runner?.id ?? 'npm', name);
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
if (verb === 'build') {
|
|
161
|
-
if (text(scripts?.build)) return 'npm
|
|
181
|
+
if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
|
|
162
182
|
for (const row of BUILDS) {
|
|
163
183
|
if (row.fact && facts.has(row.fact)) return row.command;
|
|
164
184
|
if (row.stack && stack.has(row.stack)) return row.command;
|
|
@@ -217,11 +237,16 @@ function pickDroppable(lines) {
|
|
|
217
237
|
export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
|
|
218
238
|
const factSet = new Set(facts);
|
|
219
239
|
const stackSet = new Set(stack);
|
|
220
|
-
const scripts = await readScripts(root);
|
|
240
|
+
const { scripts, runner } = await readScripts(root);
|
|
241
|
+
// Q31: the lockfile facts name the runner even when `readScripts` saw no
|
|
242
|
+
// lockfile (a fixture with a bare package.json keeps npm; a pnpm checkout
|
|
243
|
+
// resolves pnpm from its fact). Facts win inside the resolver already.
|
|
244
|
+
const { resolveNodeRunner: resolveRunner } = await import('../ecosystem/node-runner.mjs');
|
|
245
|
+
const effective = runner ?? resolveRunner({ facts: factSet });
|
|
221
246
|
|
|
222
247
|
const lines = [];
|
|
223
248
|
for (const verb of VERBS) {
|
|
224
|
-
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
|
|
249
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
|
|
225
250
|
if (command) lines.push({ label: verb, command, verb });
|
|
226
251
|
}
|
|
227
252
|
for (const entry of FOCUSED) {
|
|
@@ -231,7 +256,7 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
|
|
|
231
256
|
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
232
257
|
lines.push({
|
|
233
258
|
label: `focused test (${entry.kind})`,
|
|
234
|
-
command: entry.template(stackSet, factSet, scripts),
|
|
259
|
+
command: entry.template(stackSet, factSet, scripts, effective),
|
|
235
260
|
kind: entry.kind,
|
|
236
261
|
verb: 'focused',
|
|
237
262
|
});
|