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/memory/decide.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { execFile } from 'node:child_process';
|
|
|
7
7
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
+
import { journaledWriteFile } from '../journal.mjs';
|
|
10
11
|
|
|
11
12
|
const exec = promisify(execFile);
|
|
12
13
|
|
|
@@ -360,9 +361,13 @@ export async function scaffoldDecision(root, options = {}) {
|
|
|
360
361
|
supersedes,
|
|
361
362
|
});
|
|
362
363
|
|
|
364
|
+
// A caller-supplied journal records the scaffold so `undo` can restore it
|
|
365
|
+
// (R49). Callers without one (probes, tests staging fixtures) keep the old
|
|
366
|
+
// behaviour exactly.
|
|
363
367
|
if (options.write) {
|
|
364
368
|
await mkdir(decisionsDir, { recursive: true });
|
|
365
|
-
await
|
|
369
|
+
if (options.journal) await journaledWriteFile(root, options.journal, fullPath, text);
|
|
370
|
+
else await writeFile(fullPath, text, 'utf8');
|
|
366
371
|
}
|
|
367
372
|
|
|
368
373
|
return {
|
package/src/memory/lessons.mjs
CHANGED
|
@@ -642,6 +642,17 @@ export async function promoteLesson(root, options = {}) {
|
|
|
642
642
|
const relPath = `${LESSONS_DIR}/${num}-${slug}.md`;
|
|
643
643
|
const markdown = renderLessonMarkdown(target, options);
|
|
644
644
|
|
|
645
|
+
// The `--diff` before-side, read before anything is written so the diff is true
|
|
646
|
+
// in preview and in apply alike (R50). Always a creation in practice — the
|
|
647
|
+
// number is fresh — but a hand-placed file at the same path would be
|
|
648
|
+
// overwritten, and a diff that claimed a creation there would lie about it.
|
|
649
|
+
let before = null;
|
|
650
|
+
try {
|
|
651
|
+
before = await readFile(join(root, relPath), 'utf8');
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error.code !== 'ENOENT') throw error;
|
|
654
|
+
}
|
|
655
|
+
|
|
645
656
|
if (options.apply) {
|
|
646
657
|
const fullPath = join(root, relPath);
|
|
647
658
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
@@ -656,6 +667,7 @@ export async function promoteLesson(root, options = {}) {
|
|
|
656
667
|
candidate: target,
|
|
657
668
|
path: relPath,
|
|
658
669
|
markdown,
|
|
670
|
+
before,
|
|
659
671
|
written: Boolean(options.apply),
|
|
660
672
|
};
|
|
661
673
|
}
|
package/src/output/index.mjs
CHANGED
|
@@ -144,20 +144,90 @@ export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
|
144
144
|
* the only consumer is a person: the compact document saves them nothing and costs them
|
|
145
145
|
* the diff they were about to read.
|
|
146
146
|
*/
|
|
147
|
+
function withSchemaVersion(value) {
|
|
148
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
|
|
149
|
+
? { schemaVersion: SCHEMA_VERSION, ...value }
|
|
150
|
+
: value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stringifyJson(value, pretty) {
|
|
154
|
+
return pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Shrink a payload to a byte ceiling by emptying its longest arrays first.
|
|
159
|
+
*
|
|
160
|
+
* Byte-cutting JSON produces a document that parses or exits non-zero, never both —
|
|
161
|
+
* `why --json --max-bytes 120` used to exit 0 with half a token on stdout (Q10). An
|
|
162
|
+
* importer owns nothing here: the trimmed arrays are named in `truncated`, so a reader
|
|
163
|
+
* knows exactly which list lost how many items, and the document still parses.
|
|
164
|
+
*
|
|
165
|
+
* Returns the fitted payload, or null when no array-trimming fits (the caller then
|
|
166
|
+
* answers with the `MAX_BYTES` error envelope instead).
|
|
167
|
+
*/
|
|
168
|
+
function fitJsonArrays(payload, maxBytes, pretty) {
|
|
169
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
170
|
+
const slots = [];
|
|
171
|
+
const walk = (node, path) => {
|
|
172
|
+
if (Array.isArray(node)) {
|
|
173
|
+
if (node.length > 0) slots.push({ node, path, length: node.length });
|
|
174
|
+
node.forEach((item, i) => walk(item, `${path}[${i}]`));
|
|
175
|
+
} else if (node !== null && typeof node === 'object') {
|
|
176
|
+
for (const key of Object.keys(node)) walk(node[key], path === '' ? key : `${path}.${key}`);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
walk(payload, '');
|
|
180
|
+
if (slots.length === 0) return null;
|
|
181
|
+
slots.sort((a, b) => b.length - a.length);
|
|
182
|
+
const candidate = structuredClone(payload);
|
|
183
|
+
const trimmed = [];
|
|
184
|
+
const sizeOf = (extra) =>
|
|
185
|
+
Buffer.byteLength(stringifyJson(extra ? { ...candidate, truncated: extra } : candidate, pretty));
|
|
186
|
+
const segmentsOf = (path) =>
|
|
187
|
+
path.split('.').flatMap((part) => {
|
|
188
|
+
const m = /^(.*?)\[(\d+)\]$/.exec(part);
|
|
189
|
+
return m ? [m[1], Number(m[2])] : [part];
|
|
190
|
+
});
|
|
191
|
+
for (const slot of slots) {
|
|
192
|
+
const at = segmentsOf(slot.path);
|
|
193
|
+
let target = candidate;
|
|
194
|
+
for (let i = 0; i < at.length - 1; i += 1) target = target[at[i]];
|
|
195
|
+
if (target === null || typeof target !== 'object') continue;
|
|
196
|
+
target[at[at.length - 1]] = [];
|
|
197
|
+
trimmed.push({ field: slot.path, omitted: slot.length });
|
|
198
|
+
const extra = trimmed.length === 1
|
|
199
|
+
? { field: trimmed[0].field, omitted: trimmed[0].omitted, limit: maxBytes }
|
|
200
|
+
: { fields: trimmed, limit: maxBytes };
|
|
201
|
+
if (sizeOf(extra) <= maxBytes) return { ...candidate, truncated: extra };
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
147
206
|
export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
|
|
148
|
-
const payload =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// The same ceiling and the same announcement `emit` applies: a reader who asked for a
|
|
154
|
-
// bounded answer gets a bounded answer and is told how much is missing, rather than a
|
|
155
|
-
// half-parsed document it believes is whole.
|
|
207
|
+
const payload = withSchemaVersion(value);
|
|
208
|
+
const text = `${stringifyJson(payload, pretty)}\n`;
|
|
209
|
+
// JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
|
|
210
|
+
// true. Arrays shrink with a `truncated` record naming what lost how much, and a
|
|
211
|
+
// document no trimming can fit becomes a `MAX_BYTES` error envelope (Q10, ADR-105).
|
|
156
212
|
if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
const fitted = fitJsonArrays(payload, maxBytes, pretty);
|
|
214
|
+
if (fitted !== null) {
|
|
215
|
+
stream.write(`${stringifyJson(fitted, pretty)}\n`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const envelope = withSchemaVersion({
|
|
219
|
+
...(payload !== null && typeof payload === 'object' && !Array.isArray(payload) && typeof payload.command === 'string'
|
|
220
|
+
? { command: payload.command }
|
|
221
|
+
: {}),
|
|
222
|
+
ok: false,
|
|
223
|
+
error: {
|
|
224
|
+
code: 'MAX_BYTES',
|
|
225
|
+
message: `output exceeded the --max-bytes ceiling of ${maxBytes} bytes`,
|
|
226
|
+
remedy: 'rerun with --max-bytes 0 for the whole document',
|
|
227
|
+
},
|
|
228
|
+
truncated: { omitted: Buffer.byteLength(text) - maxBytes, limit: maxBytes },
|
|
229
|
+
});
|
|
230
|
+
stream.write(`${stringifyJson(envelope, pretty)}\n`);
|
|
161
231
|
return false;
|
|
162
232
|
}
|
|
163
233
|
stream.write(text);
|
package/src/package-version.mjs
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
// must match: npm pins the node_modules copy for us, but nothing pins a binary, so
|
|
15
15
|
// the gate compares the running version against the pin and refuses on a mismatch.
|
|
16
16
|
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
17
18
|
import { readFileSync } from 'node:fs';
|
|
18
19
|
import { stat } from 'node:fs/promises';
|
|
19
|
-
import { join } from 'node:path';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
20
22
|
|
|
21
23
|
import { MANIFEST_FILE } from './name.mjs';
|
|
22
24
|
|
|
@@ -38,6 +40,52 @@ export function packageVersion() {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** The directory this package is installed in, whatever resolved the import. */
|
|
44
|
+
function packageRootDir() {
|
|
45
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The short commit this checkout is at, or null outside a git checkout (an
|
|
50
|
+
* installed tarball or wheel has no `.git`). Read-only and best-effort: a
|
|
51
|
+
* version string must never fail because the VCS is absent (Q05).
|
|
52
|
+
*/
|
|
53
|
+
export function checkoutSha() {
|
|
54
|
+
try {
|
|
55
|
+
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
56
|
+
cwd: packageRootDir(),
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
}).trim();
|
|
60
|
+
return /^[0-9a-f]+$/.test(sha) ? sha : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `--version` prints: the package version, plus the checkout SHA when run
|
|
68
|
+
* from a source tree past its tag. A `main` 26 commits past v0.6.2 reporting
|
|
69
|
+
* bare `0.6.2` is the drift Q05 closes: the number alone claims a release the
|
|
70
|
+
* bytes are not (Q05).
|
|
71
|
+
*/
|
|
72
|
+
export function describeVersion() {
|
|
73
|
+
const version = packageVersion();
|
|
74
|
+
const sha = checkoutSha();
|
|
75
|
+
if (!sha) return `simt-dev ${version}`;
|
|
76
|
+
try {
|
|
77
|
+
const tag = execFileSync('git', ['describe', '--tags', '--exact-match'], {
|
|
78
|
+
cwd: packageRootDir(),
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
81
|
+
}).trim();
|
|
82
|
+
if (tag === `v${version}`) return `simt-dev ${version}`;
|
|
83
|
+
} catch {
|
|
84
|
+
// Not exactly on the release tag: fall through to the SHA suffix.
|
|
85
|
+
}
|
|
86
|
+
return `simt-dev ${version} (${sha})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
/**
|
|
42
90
|
* Whether a repository has a package.json, i.e. whether it pins simt-dev as a
|
|
43
91
|
* devDependency (npm) or must download the release binary (ADR-098). Async because the render
|
package/src/packs/manage.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// copied in and pinned by digest, never fetched at render time.
|
|
15
15
|
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdir, readdir, readFile, stat
|
|
17
|
+
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
18
19
|
import { createHash } from 'node:crypto';
|
|
19
20
|
import { basename, dirname, join } from 'node:path';
|
|
20
21
|
import { promisify } from 'node:util';
|
|
@@ -545,8 +546,14 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
545
546
|
return { pack: linted.pack, ok: true, published: true, skipped: false, planned, findings };
|
|
546
547
|
}
|
|
547
548
|
|
|
548
|
-
/**
|
|
549
|
-
|
|
549
|
+
/**
|
|
550
|
+
* Write scaffold files into a directory that must not already exist.
|
|
551
|
+
*
|
|
552
|
+
* `root` is the repository the journal set belongs to — usually the scaffold's
|
|
553
|
+
* own parent, so `undo` runs where the files live. Recorded (R49) because a
|
|
554
|
+
* scaffold is repo files like any other write.
|
|
555
|
+
*/
|
|
556
|
+
export async function writeScaffold(dir, files, root = dirname(dir)) {
|
|
550
557
|
try {
|
|
551
558
|
await stat(dir);
|
|
552
559
|
throw new Error(
|
|
@@ -555,9 +562,11 @@ export async function writeScaffold(dir, files) {
|
|
|
555
562
|
} catch (error) {
|
|
556
563
|
if (error?.message?.startsWith('refusing to scaffold')) throw error;
|
|
557
564
|
}
|
|
565
|
+
const journal = [];
|
|
558
566
|
for (const file of files) {
|
|
559
567
|
const abs = join(dir, file.path);
|
|
560
568
|
await mkdir(dirname(abs), { recursive: true });
|
|
561
|
-
await
|
|
569
|
+
await journaledWriteFile(root, journal, abs, file.text);
|
|
562
570
|
}
|
|
571
|
+
await commitJournal(root, 'pack new', journal);
|
|
563
572
|
}
|
package/src/plan/index.mjs
CHANGED
|
@@ -632,6 +632,12 @@ export async function planDone(root, id, { sha = null, test = null, state = null
|
|
|
632
632
|
line: found.index + 1,
|
|
633
633
|
from,
|
|
634
634
|
to: cells[slice.stateIdx],
|
|
635
|
+
// The `--diff` sides for the one changed row (plan 0003 R50). The full file
|
|
636
|
+
// is not carried: one table row is the whole write, and a context-less hunk
|
|
637
|
+
// over the row says exactly what changed. Captured pre-write, so the diff
|
|
638
|
+
// is true in preview and in apply alike.
|
|
639
|
+
beforeLine: original,
|
|
640
|
+
afterLine: newLine,
|
|
635
641
|
state: format.readState(display),
|
|
636
642
|
wouldChange,
|
|
637
643
|
changed: wouldChange && !dryRun,
|
package/src/plugins.mjs
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
// leaves the machine it was built on. The command says so rather than pretending.
|
|
25
25
|
|
|
26
26
|
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
27
|
-
import { basename, join } from 'node:path';
|
|
27
|
+
import { basename, dirname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { readBefore } from './diff.mjs';
|
|
28
30
|
import { hookDocument } from './agenthooks/render.mjs';
|
|
29
31
|
import { desiredRegion } from './projection/rules.mjs';
|
|
30
32
|
import { PACKAGE_NAME, BIN_REL } from './name.mjs';
|
|
@@ -32,6 +34,15 @@ import { PACKAGE_NAME, BIN_REL } from './name.mjs';
|
|
|
32
34
|
/** The kinds `simt plugin` builds, in the order the help line names them. */
|
|
33
35
|
export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The package this code ships in: the runtime a generated package copies. Resolved
|
|
39
|
+
* from the module URL, not from the consumer's cwd, so `plugin` built with
|
|
40
|
+
* `--root <consumer>` still ships this tool rather than the consumer's files (Q03).
|
|
41
|
+
*/
|
|
42
|
+
function packageRoot() {
|
|
43
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
/** Where the runtime copy lands inside a package, relative to the package root. */
|
|
36
47
|
function runtimeDir(kind) {
|
|
37
48
|
return kind === 'cursor' ? join('.cursor', 'simt') : '.';
|
|
@@ -71,7 +82,7 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
|
71
82
|
* it is many files; this returns only the handful that are authored here.
|
|
72
83
|
*/
|
|
73
84
|
async function packageFiles(kind, { root = process.cwd(), out } = {}) {
|
|
74
|
-
const pkg = JSON.parse(await readFile(join(
|
|
85
|
+
const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
|
|
75
86
|
const command = `${entryPoint(kind)} agent-hook pre-tool-use --harness ${kind}${rootFlag(kind)}`;
|
|
76
87
|
const files = [];
|
|
77
88
|
|
|
@@ -128,12 +139,21 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
128
139
|
throw new Error(`unknown package kind \`${kind}\`; one of: ${PLUGIN_KINDS.join(', ')}`);
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
const pkg = JSON.parse(await readFile(join(
|
|
142
|
+
const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
|
|
132
143
|
const files = await packageFiles(kind, { root, out });
|
|
133
144
|
const notes = [
|
|
134
145
|
'the skill registry is workspace data (ADR-061) and is not shipped; run `simt skills index` in the profile to list what a workspace would offer',
|
|
135
146
|
];
|
|
136
147
|
|
|
148
|
+
// The `--diff` before-sides, read before anything is written so the diff is
|
|
149
|
+
// true in preview and in apply alike (R50). Always captured: the files are a
|
|
150
|
+
// handful of small generated configs. The runtime copy below is excluded — it
|
|
151
|
+
// is a mechanical copy of this tool's own sources, not planned content, and
|
|
152
|
+
// diffing it would reprint the tool into the review.
|
|
153
|
+
for (const f of files) {
|
|
154
|
+
f.before = await readBefore(join(out, f.path));
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
if (apply) {
|
|
138
158
|
const runtimeTarget = join(out, runtimeDir(kind));
|
|
139
159
|
await mkdir(runtimeTarget, { recursive: true });
|
|
@@ -142,10 +162,11 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
142
162
|
// dies on the first MCP call: the server reads templates/ and rules/, which a
|
|
143
163
|
// bin+src-only copy leaves behind. The `files` field is the definition of "a working
|
|
144
164
|
// copy of this tool", so it is the set a package ships.
|
|
165
|
+
const runtimeRoot = packageRoot();
|
|
145
166
|
for (const part of pkg.files) {
|
|
146
|
-
await cp(join(
|
|
167
|
+
await cp(join(runtimeRoot, part), join(runtimeTarget, part), { recursive: true });
|
|
147
168
|
}
|
|
148
|
-
await cp(join(
|
|
169
|
+
await cp(join(runtimeRoot, 'package.json'), join(runtimeTarget, 'package.json'));
|
|
149
170
|
for (const f of files) {
|
|
150
171
|
const target = join(out, f.path);
|
|
151
172
|
await mkdir(join(target, '..'), { recursive: true });
|
|
@@ -158,7 +179,7 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
158
179
|
out,
|
|
159
180
|
apply,
|
|
160
181
|
runtime: { target: runtimeDir(kind), parts: [...pkg.files, 'package.json'] },
|
|
161
|
-
files
|
|
182
|
+
files,
|
|
162
183
|
notes,
|
|
163
184
|
};
|
|
164
185
|
}
|
package/src/prbody.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The `simt pr-body` engine (plan 0003 R55).
|
|
2
|
+
//
|
|
3
|
+
// A pull request body assembled from what the tool already knows, rather than
|
|
4
|
+
// what its author remembers to write: the evidence block from the ledger (R17),
|
|
5
|
+
// the decision records the diff touches, the gate state from the last check,
|
|
6
|
+
// and the residual risk in ADR-008's verdict vocabulary. Read-only throughout:
|
|
7
|
+
// it names the tree, reads the ledger, reads the check cache and diffs names,
|
|
8
|
+
// and writes nothing, so it needs no preview, no `--yes` and no journal entry.
|
|
9
|
+
//
|
|
10
|
+
// The base defaults to the branch's upstream fork point (`origin/main`, then
|
|
11
|
+
// `main`), and `--base` names anything else. When no base resolves, the body
|
|
12
|
+
// falls back to the working-tree diff against HEAD: a report that refused
|
|
13
|
+
// without a base would be useless on a repository with a single branch.
|
|
14
|
+
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
|
|
20
|
+
import { readLedger, treeId } from './ledger/index.mjs';
|
|
21
|
+
import { readCheckCache } from './status.mjs';
|
|
22
|
+
import { readManifest } from './projection/manifest.mjs';
|
|
23
|
+
import { splitFrontmatter } from './memory/decide.mjs';
|
|
24
|
+
|
|
25
|
+
const exec = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
const DECISIONS_DIR = join('docs', 'decisions');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ref the body diffs against: the override when given, else the first
|
|
31
|
+
* candidate that shares history with HEAD. Returns the ref, or null when the
|
|
32
|
+
* repository has a single branch and the body should diff the worktree.
|
|
33
|
+
*/
|
|
34
|
+
async function resolveBase(root, override = null) {
|
|
35
|
+
if (override !== null && override !== undefined && String(override) !== '') {
|
|
36
|
+
try {
|
|
37
|
+
await exec('git', ['rev-parse', '--verify', '--quiet', `${override}^{commit}`], { cwd: root });
|
|
38
|
+
return { base: String(override), error: null };
|
|
39
|
+
} catch {
|
|
40
|
+
return { base: null, error: `unknown base ${override}` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
44
|
+
try {
|
|
45
|
+
await exec('git', ['merge-base', 'HEAD', candidate], { cwd: root });
|
|
46
|
+
return { base: candidate, error: null };
|
|
47
|
+
} catch {
|
|
48
|
+
// Not present or shares no history; the next candidate may.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { base: null, error: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every path the pull request would carry: the branch range when there is a
|
|
56
|
+
* base, plus whatever the worktree adds on top. Sorted, deduplicated, and
|
|
57
|
+
* without `.simt/` state: the ledger and the check cache prove the body, they
|
|
58
|
+
* are not its content.
|
|
59
|
+
*/
|
|
60
|
+
async function changedPaths(root, base) {
|
|
61
|
+
const paths = new Set();
|
|
62
|
+
const add = (stdout) => {
|
|
63
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (trimmed !== '' && !trimmed.startsWith('.simt/')) paths.add(trimmed);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
if (base !== null) {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await exec('git', ['diff', '--name-only', `${base}...HEAD`], {
|
|
71
|
+
cwd: root,
|
|
72
|
+
maxBuffer: 16 << 20,
|
|
73
|
+
});
|
|
74
|
+
add(stdout);
|
|
75
|
+
} catch {
|
|
76
|
+
// An unborn HEAD or an unreadable range contributes nothing; the worktree
|
|
77
|
+
// reads below still answer.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const args of [
|
|
81
|
+
['diff', '--name-only', 'HEAD'],
|
|
82
|
+
['ls-files', '--others', '--exclude-standard'],
|
|
83
|
+
]) {
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await exec('git', args, { cwd: root, maxBuffer: 16 << 20 });
|
|
86
|
+
add(stdout);
|
|
87
|
+
} catch {
|
|
88
|
+
// Not a repository, or no commit yet: the body reports the absence.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...paths].sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The decision records the diff touches: one entry per `docs/decisions/*.md`
|
|
96
|
+
* in the changed paths, carrying the id from the filename and the title and
|
|
97
|
+
* status from the file itself. Sorted by id; a file that will not parse is
|
|
98
|
+
* reported with what it has rather than dropping the row.
|
|
99
|
+
*/
|
|
100
|
+
async function changedDecisions(root, paths) {
|
|
101
|
+
const names = paths.filter((p) => p.startsWith(`${DECISIONS_DIR}/`) && p.toLowerCase().endsWith('.md'));
|
|
102
|
+
try {
|
|
103
|
+
await readdir(join(root, DECISIONS_DIR));
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const name of names.sort()) {
|
|
109
|
+
const id = /^ADR-\d{3,}/i.exec(name.slice(DECISIONS_DIR.length + 1))?.[0].toUpperCase() ?? name;
|
|
110
|
+
let title = id;
|
|
111
|
+
let status = 'unknown';
|
|
112
|
+
try {
|
|
113
|
+
const text = await readFile(join(root, name), 'utf8');
|
|
114
|
+
const { body } = splitFrontmatter(text);
|
|
115
|
+
const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
|
|
116
|
+
title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim() || id;
|
|
117
|
+
status = /^Status:\s*(.+)$/im.exec(body)?.[1].trim() ?? 'unknown';
|
|
118
|
+
} catch {
|
|
119
|
+
// An unreadable record is still a changed decision; the body says so
|
|
120
|
+
// rather than silently omitting a file the diff names.
|
|
121
|
+
}
|
|
122
|
+
out.push({ path: name, id, title, status });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Assemble the whole body. Never throws for a missing ledger, cache or base:
|
|
129
|
+
* each absence is a line in the report, because a PR body whose evidence is
|
|
130
|
+
* missing is precisely the case the residual-risk section exists to say out loud.
|
|
131
|
+
*/
|
|
132
|
+
export async function buildPrBody(root, { base = null } = {}) {
|
|
133
|
+
const resolved = await resolveBase(root, base);
|
|
134
|
+
if (resolved.error !== null) return { error: resolved.error };
|
|
135
|
+
|
|
136
|
+
let tree = null;
|
|
137
|
+
let treeError = null;
|
|
138
|
+
try {
|
|
139
|
+
tree = await treeId(root);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
treeError = String(error.message ?? error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let entries = [];
|
|
145
|
+
let ledgerErrors = [];
|
|
146
|
+
try {
|
|
147
|
+
const ledger = await readLedger(root);
|
|
148
|
+
entries = tree === null ? [] : ledger.entries.filter((e) => e.tree === tree);
|
|
149
|
+
ledgerErrors = ledger.errors;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
ledgerErrors = [String(error.message ?? error)];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cache = await readCheckCache(root).catch(() => null);
|
|
155
|
+
const { manifest } = await readManifest(root).catch(() => ({ manifest: null }));
|
|
156
|
+
const declared = [
|
|
157
|
+
...(manifest?.gates?.preCommit ?? []),
|
|
158
|
+
...(manifest?.gates?.prePush ?? []),
|
|
159
|
+
];
|
|
160
|
+
const evidenceMode = manifest?.gates?.evidence;
|
|
161
|
+
|
|
162
|
+
const paths = await changedPaths(root, resolved.base);
|
|
163
|
+
const decisions = await changedDecisions(root, paths);
|
|
164
|
+
|
|
165
|
+
const passing = entries.filter((e) => e.exit === 0);
|
|
166
|
+
const unreviewed = [];
|
|
167
|
+
if (tree === null) unreviewed.push(`the tree could not be named (${treeError ?? 'not a git repository'})`);
|
|
168
|
+
else if (passing.length === 0) unreviewed.push('no passing run is recorded for this tree');
|
|
169
|
+
if (cache === null) unreviewed.push('gate state unmeasured (no check has run)');
|
|
170
|
+
for (const error of ledgerErrors) unreviewed.push(`ledger unreadable: ${error}`);
|
|
171
|
+
|
|
172
|
+
// ADR-008's vocabulary, exactly: BLOCK for a known failure, APPROVE WITH
|
|
173
|
+
// RESIDUAL RISK when the review budget ran out before something was read,
|
|
174
|
+
// APPROVE only when everything was. The constraint travels with the verdict:
|
|
175
|
+
// a residual-risk body never converts a known failure into an approval.
|
|
176
|
+
const blocking = cache?.blocking ?? 0;
|
|
177
|
+
const verdict = blocking > 0 ? 'BLOCK' : unreviewed.length > 0 ? 'APPROVE WITH RESIDUAL RISK' : 'APPROVE';
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
error: null,
|
|
181
|
+
base: resolved.base,
|
|
182
|
+
tree,
|
|
183
|
+
evidence: { entries, errors: ledgerErrors },
|
|
184
|
+
decisions,
|
|
185
|
+
paths,
|
|
186
|
+
gates: {
|
|
187
|
+
declared,
|
|
188
|
+
evidenceMode: evidenceMode ?? null,
|
|
189
|
+
run: cache === null ? null : { gatesRun: cache.gatesRun, gatesTotal: cache.gatesTotal },
|
|
190
|
+
rulesOk: cache?.rulesOk ?? null,
|
|
191
|
+
hooksOk: cache?.hooksOk ?? null,
|
|
192
|
+
blocking,
|
|
193
|
+
warnings: cache?.warnings ?? 0,
|
|
194
|
+
cached: cache !== null,
|
|
195
|
+
},
|
|
196
|
+
verdict,
|
|
197
|
+
unreviewed,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The markdown body: four sections in a fixed order — evidence, changed
|
|
203
|
+
* decisions, gates, residual risk — so a snapshot can pin it byte for byte.
|
|
204
|
+
*/
|
|
205
|
+
export function renderPrBody(report) {
|
|
206
|
+
const lines = [];
|
|
207
|
+
lines.push('## Evidence');
|
|
208
|
+
lines.push('');
|
|
209
|
+
if (report.tree === null) {
|
|
210
|
+
lines.push('No tree recorded: run this inside a git repository.');
|
|
211
|
+
} else if (report.evidence.entries.length === 0) {
|
|
212
|
+
lines.push(`tree ${report.tree}`);
|
|
213
|
+
lines.push('No run is recorded for this tree.');
|
|
214
|
+
} else {
|
|
215
|
+
lines.push(`tree ${report.tree}`);
|
|
216
|
+
lines.push('');
|
|
217
|
+
for (const e of report.evidence.entries) {
|
|
218
|
+
lines.push(`- ${e.exit === 0 ? 'pass' : 'fail'} \`${e.cmd}\` (exit ${e.exit}, ${e.summary})`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const error of report.evidence.errors) lines.push(`- ledger error: ${error}`);
|
|
222
|
+
lines.push('');
|
|
223
|
+
lines.push('## Changed decisions');
|
|
224
|
+
lines.push('');
|
|
225
|
+
if (report.decisions.length === 0) {
|
|
226
|
+
lines.push(`None${report.base === null ? ' (worktree diff against HEAD)' : ` (range ${report.base}...HEAD, plus worktree)`}.`);
|
|
227
|
+
} else {
|
|
228
|
+
for (const d of report.decisions) lines.push(`- ${d.id} — ${d.title} (${d.status})`);
|
|
229
|
+
}
|
|
230
|
+
lines.push('');
|
|
231
|
+
lines.push('## Gates');
|
|
232
|
+
lines.push('');
|
|
233
|
+
if (report.gates.cached) {
|
|
234
|
+
const g = report.gates;
|
|
235
|
+
lines.push(`- rules: ${g.rulesOk ? 'ok' : 'failing'}, hooks: ${g.hooksOk ? 'ok' : 'failing'}`);
|
|
236
|
+
lines.push(`- gates run: ${g.run.gatesRun}/${g.run.gatesTotal}, blocking: ${g.blocking}, warnings: ${g.warnings}`);
|
|
237
|
+
if (g.declared.length > 0) lines.push(`- declared: ${g.declared.join(', ')}`);
|
|
238
|
+
} else {
|
|
239
|
+
lines.push('No check has run: gate state unmeasured.');
|
|
240
|
+
}
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push('## Residual risk (ADR-008)');
|
|
243
|
+
lines.push('');
|
|
244
|
+
lines.push(`Verdict: ${report.verdict}`);
|
|
245
|
+
if (report.unreviewed.length > 0) {
|
|
246
|
+
lines.push('');
|
|
247
|
+
lines.push('Not reviewed:');
|
|
248
|
+
for (const item of report.unreviewed) lines.push(`- ${item}`);
|
|
249
|
+
}
|
|
250
|
+
return `${lines.join('\n')}\n`;
|
|
251
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
19
19
|
import { existsSync } from 'node:fs';
|
|
20
20
|
import { basename, join, resolve } from 'node:path';
|
|
21
|
+
import { journaledWriteFile } from '../journal.mjs';
|
|
21
22
|
// gates.mjs imports nothing, so this direction cannot cycle. It is the gate table
|
|
22
23
|
// this file already selects from; duplicating the runnability rules here would be two
|
|
23
24
|
// answers to "can this repo run that gate", which is precisely the kind of drift this
|
|
@@ -225,6 +226,12 @@ export function gateRunnable(gate, capability = {}) {
|
|
|
225
226
|
: { runnable: true, why: `runs: ${declared}` };
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
if (gate.kind === 'builtin') {
|
|
230
|
+
// The one kind with no external requirement: the implementation ships inside
|
|
231
|
+
// the binary that is reading this manifest (ADR-100). Saying otherwise would
|
|
232
|
+
// make `check` report a gate as unrunnable that runs on every commit.
|
|
233
|
+
return { runnable: true, why: 'runs inside the tool itself; nothing to install' };
|
|
234
|
+
}
|
|
228
235
|
if (gate.kind === 'command') {
|
|
229
236
|
return { runnable: false, why: `no command is declared for it in gates.commands` };
|
|
230
237
|
}
|
|
@@ -851,13 +858,17 @@ export async function readManifest(root) {
|
|
|
851
858
|
}
|
|
852
859
|
}
|
|
853
860
|
|
|
854
|
-
export async function writeManifest(root, manifest) {
|
|
861
|
+
export async function writeManifest(root, manifest, journal = null) {
|
|
855
862
|
// A repository still holding the old name gets it moved, not shadowed. Two manifests
|
|
856
863
|
// in one repo is a manifest nobody can reason about, and the second would be the one
|
|
857
864
|
// that is never read - so the window closes by moving the file, not by leaving both.
|
|
858
865
|
const previous = join(root, MANIFEST_LEGACY);
|
|
859
866
|
const fresh = join(root, MANIFEST);
|
|
860
867
|
if (existsSync(previous) && !existsSync(fresh)) await rename(previous, fresh);
|
|
861
|
-
|
|
868
|
+
// A journaled manifest is one `undo` can restore, like every other repo file a
|
|
869
|
+
// command writes (R49). Callers that span repos or the repo boundary (shared,
|
|
870
|
+
// bench) pass nothing and keep the old behaviour exactly.
|
|
871
|
+
if (journal) await journaledWriteFile(root, journal, fresh, manifestText(manifest));
|
|
872
|
+
else await writeFile(fresh, manifestText(manifest));
|
|
862
873
|
return fresh;
|
|
863
874
|
}
|
|
@@ -307,7 +307,10 @@ export async function sharedState(path, rules, meta) {
|
|
|
307
307
|
* user-level AGENTS.md is the most likely file in a reader's home directory to contain
|
|
308
308
|
* something they wrote themselves. Only the bytes between the markers are ours.
|
|
309
309
|
*/
|
|
310
|
-
|
|
310
|
+
// Exported so `shared install --diff` previews the same bytes the write applies:
|
|
311
|
+
// a diff rendered from a second implementation of this injection would be a
|
|
312
|
+
// second opinion about what "current" means (plan 0003 R50).
|
|
313
|
+
export function injectShared(before, rules, meta) {
|
|
311
314
|
return inject(before, sharedBody(rules), meta, { compose: composeShared });
|
|
312
315
|
}
|
|
313
316
|
|