simt-dev 0.7.0 → 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 +158 -872
- package/README.md +19 -133
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.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-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 +10 -2
- package/rules/core.md +7 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/shim.mjs +1 -1
- package/src/api.mjs +29 -0
- package/src/argv.mjs +29 -3
- 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 +199 -35
- package/src/bench/probes.mjs +99 -62
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +17 -4
- package/src/bench/run.mjs +242 -142
- package/src/brief/assemble.mjs +38 -3
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +3 -4
- package/src/commands/agenthook.mjs +4 -5
- package/src/commands/bench.mjs +190 -17
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +16 -12
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +125 -17
- package/src/commands/hooks.mjs +100 -30
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +4 -7
- package/src/commands/index.mjs +273 -188
- package/src/commands/init.mjs +2 -1
- package/src/commands/lease.mjs +6 -7
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +14 -9
- 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 +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +4 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +33 -16
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +28 -26
- package/src/commands/skillscompile.mjs +4 -5
- package/src/commands/skillsinstall.mjs +2 -2
- package/src/commands/slice.mjs +21 -10
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +13 -11
- package/src/commands/tests.mjs +6 -9
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +7 -3
- package/src/commands/why.mjs +9 -8
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/graph/load.mjs +4 -1
- package/src/hooks/capability.mjs +3 -3
- package/src/hooks/dispatcher.mjs +198 -43
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/shim.mjs +5 -2
- package/src/identity.mjs +29 -1
- package/src/journal.mjs +78 -9
- package/src/ledger/index.mjs +30 -1
- 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 +7 -10
- package/src/mcp/server.mjs +70 -16
- package/src/mcp/tools.mjs +45 -7
- package/src/measure/session.mjs +3 -2
- 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/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +20 -34
- package/src/projection/rules.mjs +4 -3
- package/src/repo-root.mjs +19 -0
- package/src/resolve/fingerprint.mjs +19 -15
- package/src/review/critic.mjs +2 -1
- package/src/toolwords.mjs +3 -0
- package/src/workspace.mjs +55 -10
- package/docs/decisions/README.md +0 -600
package/src/measure/session.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { CLI } from '../name.mjs';
|
|
11
12
|
import { tokens } from './context.mjs';
|
|
12
13
|
|
|
13
14
|
const RULE_PATH_RE =
|
|
@@ -53,14 +54,14 @@ export async function measureSession(transcriptPath) {
|
|
|
53
54
|
return {
|
|
54
55
|
ok: false,
|
|
55
56
|
error: '--session requires a local transcript path',
|
|
56
|
-
remedy:
|
|
57
|
+
remedy: `pass a local JSONL or JSON transcript path: ${CLI} tokens --session <path>`,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
|
|
60
61
|
return {
|
|
61
62
|
ok: false,
|
|
62
63
|
error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
|
|
63
|
-
remedy:
|
|
64
|
+
remedy: `pass a local file path on this machine: ${CLI} tokens --session ./session.jsonl`,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The runtime floor, in one place both entry points read (review 0003 Q54).
|
|
2
|
+
//
|
|
3
|
+
// `package.json` declares `engines.node` and `pyproject.toml` declares a python
|
|
4
|
+
// version; both are advisory to the installer, so an older runtime reached the CLI
|
|
5
|
+
// and failed somewhere inside it — a syntax error, a missing API — instead of the one
|
|
6
|
+
// sentence that says what to install. This module is deliberately tiny and has no
|
|
7
|
+
// imports: the bootstrap that calls it must not load the tool that cannot run.
|
|
8
|
+
|
|
9
|
+
/** The oldest Node this package runs on. Mirrors `package.json` `engines.node`. */
|
|
10
|
+
export const MIN_NODE = '22.20.0';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `current` is at least `minimum`, compared component by component.
|
|
14
|
+
*
|
|
15
|
+
* A string compare is wrong ("9.0.0" > "22.20.0"), and `process.versions.node` may
|
|
16
|
+
* carry a pre-release suffix that `Number()` would turn into NaN, so the numeric
|
|
17
|
+
* prefix of each component is what counts.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} current a version such as `22.20.0` or `23.1.0-rc.1`
|
|
20
|
+
* @param {string} [minimum]
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function satisfiesMinimum(current, minimum = MIN_NODE) {
|
|
24
|
+
const parse = (value) =>
|
|
25
|
+
String(value)
|
|
26
|
+
.split('.')
|
|
27
|
+
.map((part) => Number.parseInt(part, 10))
|
|
28
|
+
.map((part) => (Number.isFinite(part) ? part : 0));
|
|
29
|
+
const have = parse(current);
|
|
30
|
+
const want = parse(minimum);
|
|
31
|
+
for (let i = 0; i < Math.max(have.length, want.length); i += 1) {
|
|
32
|
+
const a = have[i] ?? 0;
|
|
33
|
+
const b = want[i] ?? 0;
|
|
34
|
+
if (a !== b) return a > b;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
package/src/output/index.mjs
CHANGED
|
@@ -92,7 +92,27 @@ export function writeMode(flags) {
|
|
|
92
92
|
* Truncation is always announced with an explicit line, never a silent ellipsis:
|
|
93
93
|
* an agent that cannot tell a truncated answer from a complete one will guess.
|
|
94
94
|
*/
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Answer one command's result on the surface the caller asked for.
|
|
97
|
+
*
|
|
98
|
+
* Every command that has both a document and a text report was choosing between them by
|
|
99
|
+
* hand, in the same five lines, in more than a dozen files - and a command that chose
|
|
100
|
+
* slightly differently printed a report where a caller had asked for JSON, which is how a
|
|
101
|
+
* script gets prose (review 0003 Q63).
|
|
102
|
+
*
|
|
103
|
+
* The renderer is a thunk because the surface is decided first: a command asked for JSON
|
|
104
|
+
* must not pay to render text nobody reads, and a caller cannot forget the branch.
|
|
105
|
+
*/
|
|
106
|
+
export function present(payload, renderText, flags) {
|
|
107
|
+
if (isJson(flags)) {
|
|
108
|
+
emitJson(payload);
|
|
109
|
+
return 'json';
|
|
110
|
+
}
|
|
111
|
+
emit(renderText());
|
|
112
|
+
return 'text';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function emit(text, { maxBytes = maxBytesDefault, stream = process.stdout } = {}) {
|
|
96
116
|
const body = text.endsWith('\n') ? text : `${text}\n`;
|
|
97
117
|
const bytes = Buffer.byteLength(body);
|
|
98
118
|
if (maxBytes > 0 && bytes > maxBytes) {
|
|
@@ -131,6 +151,19 @@ export const SCHEMA_VERSION = 1;
|
|
|
131
151
|
*/
|
|
132
152
|
let prettyDefault = process.stdout.isTTY === true;
|
|
133
153
|
|
|
154
|
+
// The byte ceiling is one flag with one reader: the envelope. Commands used to pass
|
|
155
|
+
// `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
|
|
156
|
+
// a different number, or to forget and answer past the ceiling the caller set (review 0003
|
|
157
|
+
// Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
|
|
158
|
+
// flag itself is read.
|
|
159
|
+
let maxBytesDefault = 0;
|
|
160
|
+
|
|
161
|
+
/** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
|
|
162
|
+
export function maxBytesLimit(bytes) {
|
|
163
|
+
const value = Number(bytes);
|
|
164
|
+
maxBytesDefault = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
135
168
|
prettyDefault = pretty;
|
|
136
169
|
}
|
|
@@ -203,8 +236,83 @@ function fitJsonArrays(payload, maxBytes, pretty) {
|
|
|
203
236
|
return null;
|
|
204
237
|
}
|
|
205
238
|
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Warnings a run accumulated, carried into the JSON document it prints.
|
|
241
|
+
*
|
|
242
|
+
* The same reason stderr is separate from stdout: a caller reading a document cannot
|
|
243
|
+
* see a note printed beside it. A command that ignored half of what it was asked still
|
|
244
|
+
* has to succeed or fail the same way, so the note belongs IN the result, and the text
|
|
245
|
+
* mode keeps the stderr line it already had (review 0003 Q62).
|
|
246
|
+
*/
|
|
247
|
+
let accumulated = [];
|
|
248
|
+
|
|
249
|
+
// The path the dispatcher resolved. A command should not restate it: the ones that did
|
|
250
|
+
// restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
|
|
251
|
+
// and a document whose `command` disagrees with the argv that produced it is worse than
|
|
252
|
+
// one with no `command` at all (review 0003 Q65).
|
|
253
|
+
let canonicalCommand = null;
|
|
254
|
+
|
|
255
|
+
/** Set the command path every JSON document will carry, or clear it with null. */
|
|
256
|
+
export function jsonCommand(path) {
|
|
257
|
+
canonicalCommand = path ?? null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A document cannot name the exit code before the command has returned one. The
|
|
261
|
+
// dispatcher captures what a command emits, then writes it once with the code attached;
|
|
262
|
+
// writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
|
|
263
|
+
let captured = null;
|
|
264
|
+
|
|
265
|
+
/** Collect documents instead of writing them, so the dispatcher can add the exit code. */
|
|
266
|
+
export function jsonCaptureStart() {
|
|
267
|
+
captured = [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The captured documents and a reset, or an empty list when nothing was captured. */
|
|
271
|
+
export function jsonCaptureTake() {
|
|
272
|
+
const docs = captured;
|
|
273
|
+
captured = null;
|
|
274
|
+
return docs;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Record a warning for the document this run prints. */
|
|
278
|
+
export function warn(message) {
|
|
279
|
+
accumulated = [...accumulated, message];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Drop what a previous run in this process recorded.
|
|
284
|
+
*
|
|
285
|
+
* The collector is per process and a process can dispatch more than once - the MCP
|
|
286
|
+
* server and the tests both do. Without this, one command's notice appeared in the
|
|
287
|
+
* next command's document, which parity between the two surfaces caught (review 0003
|
|
288
|
+
* Q62).
|
|
289
|
+
*/
|
|
290
|
+
export function clearWarnings() {
|
|
291
|
+
accumulated = [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The warnings this run has recorded, in order. */
|
|
295
|
+
export function warnings() {
|
|
296
|
+
return accumulated;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = maxBytesDefault } = {}) {
|
|
300
|
+
// `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
|
|
301
|
+
// and a document cannot mean two things by one name. The collision is named here
|
|
302
|
+
// rather than bent into either shape (review 0003 Q62).
|
|
303
|
+
const carried =
|
|
304
|
+
accumulated.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
|
|
305
|
+
? { ...value, notices: accumulated }
|
|
306
|
+
: value;
|
|
307
|
+
const stamped =
|
|
308
|
+
canonicalCommand !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
|
|
309
|
+
? { ...carried, command: canonicalCommand }
|
|
310
|
+
: carried;
|
|
311
|
+
if (captured !== null) {
|
|
312
|
+
captured.push({ value: stamped });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
const payload = withSchemaVersion(stamped);
|
|
208
316
|
const text = `${stringifyJson(payload, pretty)}\n`;
|
|
209
317
|
// JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
|
|
210
318
|
// true. Arrays shrink with a `truncated` record naming what lost how much, and a
|
package/src/packs/manage.mjs
CHANGED
|
@@ -511,6 +511,23 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
511
511
|
return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// A preview is a preview. The registry read below is the one network call in this
|
|
515
|
+
// command, and running it to print a plan made `pack publish` — a preview by default
|
|
516
|
+
// — a network operation that failed offline without `--yes` (review 0003 Q58).
|
|
517
|
+
if (!apply) {
|
|
518
|
+
return {
|
|
519
|
+
pack: linted.pack,
|
|
520
|
+
ok: true,
|
|
521
|
+
published: false,
|
|
522
|
+
skipped: false,
|
|
523
|
+
planned: [
|
|
524
|
+
`npm view ${pkg}@${pack.version} (check whether it is already live)`,
|
|
525
|
+
`npm publish (in ${dir})`,
|
|
526
|
+
],
|
|
527
|
+
findings,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
514
531
|
// Idempotent, the way the release twin is: a version that is already live is
|
|
515
532
|
// skipped rather than republished, so a re-run after an interruption continues
|
|
516
533
|
// where it stopped.
|
|
@@ -534,9 +551,6 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
534
551
|
}
|
|
535
552
|
|
|
536
553
|
const planned = [`npm publish (in ${dir})`];
|
|
537
|
-
if (!apply) {
|
|
538
|
-
return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
|
|
539
|
-
}
|
|
540
554
|
try {
|
|
541
555
|
await exec('npm', ['publish'], { cwd: dir });
|
|
542
556
|
} catch (error) {
|
package/src/packs.mjs
CHANGED
|
@@ -72,8 +72,26 @@ export async function packDigest(dir, { exclude = [] } = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
74
74
|
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
75
|
-
if (entry.isDirectory())
|
|
76
|
-
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
await walk(join(d, entry.name), rel);
|
|
77
|
+
} else if (entry.isSymbolicLink()) {
|
|
78
|
+
// A link is not vendored bytes, and the loader follows it: the old walk skipped
|
|
79
|
+
// links while the projection read through them, so a rule reached by a link was
|
|
80
|
+
// projected without being covered by the pin (review 0003 Q58). A link to a
|
|
81
|
+
// file is hashed through its target, because `readFile` below follows it. A link
|
|
82
|
+
// to a directory is refused: recursing would let a pack's digest describe a tree
|
|
83
|
+
// it does not carry.
|
|
84
|
+
const target = await stat(join(d, entry.name)).catch(() => null);
|
|
85
|
+
if (target?.isDirectory()) {
|
|
86
|
+
const error = new Error(
|
|
87
|
+
`${rel} is a symbolic link to a directory; a pack is vendored bytes, and its digest cannot cover a tree it does not carry`,
|
|
88
|
+
);
|
|
89
|
+
error.code = 'PACK_SYMLINK';
|
|
90
|
+
error.path = rel;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (!excluded.has(rel)) files.push(rel);
|
|
94
|
+
} else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
|
|
77
95
|
}
|
|
78
96
|
};
|
|
79
97
|
await walk(dir);
|
package/src/projection/lock.mjs
CHANGED
|
@@ -17,6 +17,11 @@ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
|
|
|
17
17
|
import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
|
|
18
18
|
import { readManifest } from './manifest.mjs';
|
|
19
19
|
|
|
20
|
+
/** The lock's own format. Bumped only when a reader must interpret an old file
|
|
21
|
+
* differently; an absent field means 1, because every lock written before this
|
|
22
|
+
* field existed was written by the only format there has been. */
|
|
23
|
+
const LOCK_VERSION = 1;
|
|
24
|
+
|
|
20
25
|
/** Manifest keys that change what the region contains. Anything else in the file is
|
|
21
26
|
* the repository's own notes, and hashing it would make a comment fail the lock. */
|
|
22
27
|
const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
|
|
@@ -39,7 +44,7 @@ export async function computeLock(root, flags = {}, manifest = undefined) {
|
|
|
39
44
|
const outputs = createHash('sha256').update(desired.body).digest('hex');
|
|
40
45
|
const hasVariants =
|
|
41
46
|
variants && typeof variants === 'object' && Object.keys(variants).length > 0;
|
|
42
|
-
return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
|
|
47
|
+
return hasVariants ? { version: LOCK_VERSION, inputs, outputs, variants } : { version: LOCK_VERSION, inputs, outputs };
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -71,6 +76,11 @@ export async function readLock(root) {
|
|
|
71
76
|
if (text === null) return { lock: null, error: null };
|
|
72
77
|
try {
|
|
73
78
|
const lock = JSON.parse(text);
|
|
79
|
+
// Absent means 1; a version this build does not know is a lock it cannot judge,
|
|
80
|
+
// and reading it anyway would report drift for a format change.
|
|
81
|
+
if (lock.version !== undefined && (!Number.isInteger(lock.version) || lock.version < 1)) {
|
|
82
|
+
return { lock: null, error: 'version must be an integer of at least 1' };
|
|
83
|
+
}
|
|
74
84
|
if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
|
|
75
85
|
return { lock: null, error: 'must be an object with inputs and outputs' };
|
|
76
86
|
}
|
|
@@ -32,31 +32,17 @@ import { runScript } from '../ecosystem/node-runner.mjs';
|
|
|
32
32
|
// and the manifest name is used a dozen times below in remedy strings that name this
|
|
33
33
|
// file by name.
|
|
34
34
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
35
|
+
import { GENERATED_KEYS_OF, KEYS, loadManifest, manifestPath } from '../manifest/schema.mjs';
|
|
35
36
|
import { CLI } from '../name.mjs';
|
|
36
37
|
import { packageVersion } from '../package-version.mjs';
|
|
37
38
|
export const MANIFEST = MANIFEST_FILE;
|
|
38
39
|
|
|
40
|
+
// Still exported from here because this is where the manifest lived: every caller that
|
|
41
|
+
// resolved its path through this module keeps working, and the implementation moved.
|
|
42
|
+
export { manifestPath };
|
|
43
|
+
|
|
39
44
|
export { MANIFEST_LEGACY };
|
|
40
45
|
|
|
41
|
-
/**
|
|
42
|
-
* The manifest a repository has, which is not necessarily the one this tool would write.
|
|
43
|
-
*
|
|
44
|
-
* The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
|
|
45
|
-
* `join(root, MANIFEST)` and would have reported an adopted repository as unadopted the
|
|
46
|
-
* day the constant moved - and "no manifest" means "never adopted", which is the answer
|
|
47
|
-
* that offers to re-adopt a repo that is already done. Fourteen repositories carry the
|
|
48
|
-
* old name on disk.
|
|
49
|
-
*
|
|
50
|
-
* So the old name is read as a fallback and the new one is written. `writeManifest`
|
|
51
|
-
* renames rather than creating a second file, which is how the window closes: the first
|
|
52
|
-
* `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
|
|
53
|
-
*/
|
|
54
|
-
export async function manifestPath(root) {
|
|
55
|
-
const next = join(root, MANIFEST);
|
|
56
|
-
if (existsSync(next)) return next;
|
|
57
|
-
const previous = join(root, MANIFEST_LEGACY);
|
|
58
|
-
return existsSync(previous) ? previous : next;
|
|
59
|
-
}
|
|
60
46
|
|
|
61
47
|
|
|
62
48
|
/**
|
|
@@ -433,7 +419,7 @@ function ecosystemCommands(facts = [], capability = null) {
|
|
|
433
419
|
*/
|
|
434
420
|
export function buildManifest({ root, stack = [], sha, version, existing = null, capability = null, surfaces = undefined, facts = [] }) {
|
|
435
421
|
const gates = defaultGates(stack, facts);
|
|
436
|
-
const limits = defaultLimits(
|
|
422
|
+
const limits = defaultLimits();
|
|
437
423
|
|
|
438
424
|
// A re-init must never silently weaken a repo's own declaration: existing
|
|
439
425
|
// limits and gates win over defaults, and only the projection stamp is refreshed.
|
|
@@ -745,7 +731,10 @@ export function declaredShared(manifest) {
|
|
|
745
731
|
return { level, rules };
|
|
746
732
|
}
|
|
747
733
|
|
|
748
|
-
|
|
734
|
+
// Derived from the table rather than restated: a key this tool writes is a `tool` key
|
|
735
|
+
// marked generated, and a second list here is the drift this module exists to end.
|
|
736
|
+
export const GENERATED_KEYS = GENERATED_KEYS_OF;
|
|
737
|
+
|
|
749
738
|
|
|
750
739
|
// The sub-keys of `projection` this tool writes. `projection` is a generated key, but
|
|
751
740
|
// not wholesale: the repository owns sub-keys this writer does not emit (`place` is the
|
|
@@ -892,20 +881,17 @@ export function manifestText(manifest) {
|
|
|
892
881
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
893
882
|
}
|
|
894
883
|
|
|
895
|
-
/**
|
|
884
|
+
/**
|
|
885
|
+
* The manifest in the shape the callers already read, now answered by the one loader
|
|
886
|
+
* (review 0003 Q69). `error` is the unreadable-file case only: a manifest with a value
|
|
887
|
+
* the schema cannot use is still a manifest, and refusing it here would turn every
|
|
888
|
+
* reader into a second validator.
|
|
889
|
+
*
|
|
890
|
+
* @returns {Promise<{manifest: object|null, exists: boolean, error: string|null}>}
|
|
891
|
+
*/
|
|
896
892
|
export async function readManifest(root) {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
try {
|
|
900
|
-
text = await readFile(path, 'utf8');
|
|
901
|
-
} catch {
|
|
902
|
-
return { manifest: null, exists: false, error: null };
|
|
903
|
-
}
|
|
904
|
-
try {
|
|
905
|
-
return { manifest: JSON.parse(text), exists: true, error: null };
|
|
906
|
-
} catch (error) {
|
|
907
|
-
return { manifest: null, exists: true, error: `unparseable: ${error.message}` };
|
|
908
|
-
}
|
|
893
|
+
const { manifest, exists, problems } = await loadManifest(root);
|
|
894
|
+
return { manifest, exists, error: exists && manifest === null ? (problems[0] ?? 'unparseable') : null };
|
|
909
895
|
}
|
|
910
896
|
|
|
911
897
|
export async function writeManifest(root, manifest, journal = null) {
|
package/src/projection/rules.mjs
CHANGED
|
@@ -27,10 +27,11 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
27
27
|
|
|
28
28
|
import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
29
29
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
-
import { foldsRules,
|
|
30
|
+
import { foldsRules, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
|
|
31
31
|
import { ruleTargets } from './targets.mjs';
|
|
32
32
|
import { compose } from './markers.mjs';
|
|
33
|
-
import { declaredBudget, declaredShared,
|
|
33
|
+
import { declaredBudget, declaredShared, readManifest } from './manifest.mjs';
|
|
34
|
+
import { loadManifest } from '../manifest/schema.mjs';
|
|
34
35
|
import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
|
|
35
36
|
import { packToBudget } from '../kernel/budget.mjs';
|
|
36
37
|
import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
|
|
@@ -255,7 +256,7 @@ export function isEligible(rule, signals, optedIn) {
|
|
|
255
256
|
export async function readDeclared(root) {
|
|
256
257
|
let parsed = {};
|
|
257
258
|
try {
|
|
258
|
-
parsed =
|
|
259
|
+
parsed = (await loadManifest(root)).manifest ?? {};
|
|
259
260
|
} catch {
|
|
260
261
|
parsed = {};
|
|
261
262
|
}
|
package/src/repo-root.mjs
CHANGED
|
@@ -50,6 +50,25 @@ export function repoRoot(cwd) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The directory a command works on: `--root`, else the first positional when the command
|
|
55
|
+
* takes one, else the current directory.
|
|
56
|
+
*
|
|
57
|
+
* This precedence is a user-visible contract, not an implementation detail: it decides
|
|
58
|
+
* which directory `--root .` and a bare positional mean, and thirty-four commands had
|
|
59
|
+
* their own copy of it. The copies drifted - some resolved the answer, some did not; some
|
|
60
|
+
* read `args[0]`, some `positional[0]` - so the same argv could name two directories
|
|
61
|
+
* depending on which command read it (review 0003 Q65).
|
|
62
|
+
*
|
|
63
|
+
* The answer is returned verbatim, not resolved: a caller that wants an absolute path
|
|
64
|
+
* says so, and a command that reports the root it was given still reports what the reader
|
|
65
|
+
* typed. `repoRoot` answers the different question of which repository that directory
|
|
66
|
+
* belongs to.
|
|
67
|
+
*/
|
|
68
|
+
export function commandRoot(flags = {}, positional = []) {
|
|
69
|
+
return flags.root ?? positional[0] ?? process.cwd();
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
/**
|
|
54
73
|
* Whether git on this repository compares paths case-insensitively, which is
|
|
55
74
|
* the one filesystem fact a path comparison must honour: on a case-insensitive
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
|
-
import {
|
|
22
|
+
import { join } from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { matchesGlob } from './resolve.mjs';
|
|
25
25
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
26
|
+
import { loadManifest } from '../manifest/schema.mjs';
|
|
26
27
|
|
|
27
28
|
/** tag -> human-readable marker(s) that imply it. First match wins per tag. */
|
|
28
29
|
const MARKERS = [
|
|
@@ -225,6 +226,12 @@ export async function fingerprint(root) {
|
|
|
225
226
|
}
|
|
226
227
|
const names = new Set(entries);
|
|
227
228
|
|
|
229
|
+
// One walk answers every `glob` fact. Each candidate used to walk the tree again, so
|
|
230
|
+
// a repository with seven globs paid for seven identical walks - the same directories,
|
|
231
|
+
// in the same order, for the same answer (review 0003 Q67).
|
|
232
|
+
let nested = null;
|
|
233
|
+
const nestedPaths = async () => (nested ??= await listNested(root));
|
|
234
|
+
|
|
228
235
|
for (const [tag, markers] of MARKERS) {
|
|
229
236
|
const hit = markers.find((m) => names.has(m));
|
|
230
237
|
if (hit) {
|
|
@@ -249,7 +256,7 @@ export async function fingerprint(root) {
|
|
|
249
256
|
break;
|
|
250
257
|
}
|
|
251
258
|
if (fact.kind === 'glob') {
|
|
252
|
-
const hit = (await
|
|
259
|
+
const hit = findNestedIn(await nestedPaths(), candidate)[0];
|
|
253
260
|
if (hit) {
|
|
254
261
|
facts.add(fact.name);
|
|
255
262
|
factEvidence[fact.name] ??= hit;
|
|
@@ -320,9 +327,9 @@ export async function fingerprint(root) {
|
|
|
320
327
|
: names.has(MANIFEST_LEGACY)
|
|
321
328
|
? MANIFEST_LEGACY
|
|
322
329
|
: null;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
330
|
+
// One reader, so a file that does not parse is a missing declaration rather than an
|
|
331
|
+
// object whose only key is "error" pretending to be this repo manifest (Q69).
|
|
332
|
+
const manifest = manifestName === null ? null : (await loadManifest(root)).manifest;
|
|
326
333
|
|
|
327
334
|
const stackList = [...stack].sort();
|
|
328
335
|
const factList = [...facts].sort();
|
|
@@ -339,14 +346,6 @@ export async function fingerprint(root) {
|
|
|
339
346
|
};
|
|
340
347
|
}
|
|
341
348
|
|
|
342
|
-
async function readManifest(path) {
|
|
343
|
-
try {
|
|
344
|
-
return JSON.parse(await readFile(path, 'utf8'));
|
|
345
|
-
} catch {
|
|
346
|
-
return { error: `${basename(path)} unparseable` };
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
349
|
async function isDir(path) {
|
|
351
350
|
try {
|
|
352
351
|
return (await stat(path)).isDirectory();
|
|
@@ -364,7 +363,7 @@ async function isDir(path) {
|
|
|
364
363
|
* are skipped for the reason the extension sample skips them: detection is not a
|
|
365
364
|
* scan of vendored output.
|
|
366
365
|
*/
|
|
367
|
-
async function
|
|
366
|
+
async function listNested(root) {
|
|
368
367
|
const found = [];
|
|
369
368
|
const walk = async (dir, prefix, depth) => {
|
|
370
369
|
let items;
|
|
@@ -378,7 +377,7 @@ async function findNested(root, pattern) {
|
|
|
378
377
|
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
379
378
|
if (item.isDirectory()) {
|
|
380
379
|
if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
|
|
381
|
-
} else
|
|
380
|
+
} else {
|
|
382
381
|
found.push(rel);
|
|
383
382
|
}
|
|
384
383
|
}
|
|
@@ -387,6 +386,11 @@ async function findNested(root, pattern) {
|
|
|
387
386
|
return found.sort();
|
|
388
387
|
}
|
|
389
388
|
|
|
389
|
+
/** The paths in the one walk that match a shape. */
|
|
390
|
+
function findNestedIn(paths, pattern) {
|
|
391
|
+
return paths.filter((rel) => matchesGlob(rel, pattern)).sort();
|
|
392
|
+
}
|
|
393
|
+
|
|
390
394
|
/**
|
|
391
395
|
* Which extensions this repository is written in.
|
|
392
396
|
*
|
package/src/review/critic.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
|
|
|
33
33
|
const exec = promisify(execFile);
|
|
34
34
|
|
|
35
35
|
import { LOCAL_DIR } from '../state.mjs';
|
|
36
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
36
37
|
|
|
37
38
|
// Locked on-disk beneath machine-local state, like the distilled cache (R38) —
|
|
38
39
|
// proposals keyed by content digest, never projected and never committed.
|
|
@@ -216,7 +217,7 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
|
|
|
216
217
|
if (args.length > 0) {
|
|
217
218
|
return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
|
|
218
219
|
}
|
|
219
|
-
const root = resolve(flags
|
|
220
|
+
const root = resolve(commandRoot(flags));
|
|
220
221
|
const listed = await tracked(root);
|
|
221
222
|
if (listed.error) return listed.error;
|
|
222
223
|
|
package/src/toolwords.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { LESSON_KEYS } from './memory/lessons.mjs';
|
|
|
23
23
|
import { DECISION_KEYS } from './memory/decide.mjs';
|
|
24
24
|
import { PROMPT_WORDS } from './mcp/prompts.mjs';
|
|
25
25
|
import { AGENT_STEM, SKILL_STEM } from './agents/render.mjs';
|
|
26
|
+
import { CORPUS_KIND } from './bench/corpus.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* `simt-` names that belong to this tool and never to a repository.
|
|
@@ -50,6 +51,8 @@ export const TOOL_WORDS = new Set([
|
|
|
50
51
|
AGENT_STEM,
|
|
51
52
|
// The companion skill the agent reads on demand; its stem is the directory name.
|
|
52
53
|
SKILL_STEM,
|
|
54
|
+
// The kind stamp this tool writes into every sealed bench corpus document it exports.
|
|
55
|
+
CORPUS_KIND,
|
|
53
56
|
]);
|
|
54
57
|
|
|
55
58
|
/**
|
package/src/workspace.mjs
CHANGED
|
@@ -38,7 +38,8 @@ export const PACKAGE_ROOT = resolve(HERE, '..');
|
|
|
38
38
|
// other LOCKED on-disk names, and a second copy here is a name that can drift from the
|
|
39
39
|
// list of names that are allowed to move. Imported as well as re-exported, because a
|
|
40
40
|
// bare `export { X } from` does not bind X here and every use below would fail.
|
|
41
|
-
import { CONFIG_DIR } from './name.mjs';
|
|
41
|
+
import { CONFIG_DIR, MANIFEST_FILE } from './name.mjs';
|
|
42
|
+
import { manifestPath, readManifest } from './projection/manifest.mjs';
|
|
42
43
|
export { CONFIG_DIR };
|
|
43
44
|
export const CONFIG_FILE = 'config.json';
|
|
44
45
|
|
|
@@ -133,14 +134,25 @@ async function looksLikeWorkspace(dir) {
|
|
|
133
134
|
/**
|
|
134
135
|
* Resolve the workspace.
|
|
135
136
|
*
|
|
136
|
-
* Precedence, highest first: `--workspace`,
|
|
137
|
-
*
|
|
138
|
-
*
|
|
137
|
+
* Precedence, highest first (ADR-119): `--workspace`, the repository's own manifest
|
|
138
|
+
* declaration, `SIMT_DEV_WORKSPACE` - a value that names a directory is honoured only
|
|
139
|
+
* where the manifest declares a workspace, because the variable is a pointer INTO a
|
|
140
|
+
* workspace the repo opted into, not one imposed on a repo that never declared one;
|
|
141
|
+
* a value that declines is honoured from anywhere, because a decline points at
|
|
142
|
+
* nothing - the manifest's value, the user config file, then the sibling heuristic
|
|
143
|
+
* of this package, which is accepted only if it actually contains a git repository.
|
|
139
144
|
*
|
|
145
|
+
* A relative manifest value resolves against the manifest's directory, not the
|
|
146
|
+
* process's, so `workspace: ".."` means the same thing from any subdirectory.
|
|
147
|
+
*
|
|
148
|
+
* @param {{repoRoot?: string}} [options] `repoRoot` names the repository whose manifest
|
|
149
|
+
* declares the workspace; it defaults to `process.cwd()`, which is the repository
|
|
150
|
+
* for the way every command is run.
|
|
140
151
|
* @returns {Promise<{root: string|null, source: string, detail: string, searched: string[], error: string|null}>}
|
|
141
152
|
*/
|
|
142
153
|
export async function resolveWorkspace(flags = {}, options = {}) {
|
|
143
154
|
const env = options.env ?? process.env;
|
|
155
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
144
156
|
const searched = configPaths(options);
|
|
145
157
|
|
|
146
158
|
// The config file is read FIRST, before precedence is applied, and a parse failure
|
|
@@ -169,16 +181,49 @@ export async function resolveWorkspace(flags = {}, options = {}) {
|
|
|
169
181
|
);
|
|
170
182
|
}
|
|
171
183
|
|
|
184
|
+
// The repository's own declaration (ADR-119). It is read before the environment
|
|
185
|
+
// variable because it is what the variable is allowed to point into: a repo that
|
|
186
|
+
// declared no workspace is a single repo with no workspace, and an environment
|
|
187
|
+
// variable cannot turn it into a member of a workspace nobody reviewed.
|
|
188
|
+
const manifest = await readManifest(repoRoot);
|
|
189
|
+
if (manifest.error) {
|
|
190
|
+
return {
|
|
191
|
+
root: null,
|
|
192
|
+
source: 'repo',
|
|
193
|
+
detail: await manifestPath(repoRoot),
|
|
194
|
+
searched,
|
|
195
|
+
error: manifest.error,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
let repoValue = null;
|
|
199
|
+
if (manifest.exists && typeof manifest.manifest.workspace === 'string' && manifest.manifest.workspace.trim() !== '') {
|
|
200
|
+
const off = disabled(manifest.manifest.workspace, 'repo', `${MANIFEST_FILE} workspace`, searched);
|
|
201
|
+
if (off) return off; // the repo declined; the environment variable cannot enforce one
|
|
202
|
+
repoValue = manifest.manifest.workspace;
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
if (env.SIMT_DEV_WORKSPACE) {
|
|
173
|
-
|
|
174
|
-
|
|
206
|
+
const off = disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched);
|
|
207
|
+
if (off) return off; // a decline points at nothing, so it needs no declaration to work
|
|
208
|
+
if (repoValue !== null) {
|
|
209
|
+
return {
|
|
175
210
|
root: absolute(env.SIMT_DEV_WORKSPACE),
|
|
176
211
|
source: 'env',
|
|
177
212
|
detail: 'SIMT_DEV_WORKSPACE',
|
|
178
213
|
searched,
|
|
179
214
|
error: null,
|
|
180
|
-
}
|
|
181
|
-
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (repoValue !== null) {
|
|
220
|
+
return {
|
|
221
|
+
root: absolute(repoValue, repoRoot),
|
|
222
|
+
source: 'repo',
|
|
223
|
+
detail: `${MANIFEST_FILE} workspace`,
|
|
224
|
+
searched,
|
|
225
|
+
error: null,
|
|
226
|
+
};
|
|
182
227
|
}
|
|
183
228
|
|
|
184
229
|
if (user.exists && typeof user.config.workspace === 'string' && user.config.workspace.length > 0) {
|
|
@@ -245,11 +290,11 @@ export const WORKSPACE_REMEDY = (() => {
|
|
|
245
290
|
return `--workspace <path>, the SIMT_DEV_WORKSPACE environment variable, or ${where}`;
|
|
246
291
|
})();
|
|
247
292
|
|
|
248
|
-
function absolute(p) {
|
|
293
|
+
function absolute(p, base = process.cwd()) {
|
|
249
294
|
const expanded = p.startsWith('~/')
|
|
250
295
|
? join(homedir(), p.slice(2))
|
|
251
296
|
: p;
|
|
252
|
-
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
297
|
+
return isAbsolute(expanded) ? expanded : resolve(base, expanded);
|
|
253
298
|
}
|
|
254
299
|
|
|
255
300
|
/**
|