simt-dev 0.3.0 → 0.4.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 +172 -0
- package/README.md +138 -74
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -2
- package/rules/core.md +2 -2
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +34 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// The shared half of a workspace's rules, and the user-level files that carry it.
|
|
2
|
+
//
|
|
3
|
+
// Review 0002 S38 measured seven repositories carrying 90 KB of always-on rules of
|
|
4
|
+
// which 64 KB — 72% — was a paragraph an earlier root had already loaded. The fix is not
|
|
5
|
+
// a smaller region; it is carrying the repeated half once, in a file every reader in the
|
|
6
|
+
// estate loads before they open a repository.
|
|
7
|
+
//
|
|
8
|
+
// FOUR DECISIONS, each with a cost. All four are settled in ADR-084; they are written out
|
|
9
|
+
// here because the code is where the next change will look.
|
|
10
|
+
//
|
|
11
|
+
// 1. THE SHARED UNIT IS A RULE, NOT A PARAGRAPH. `simt rules show <id>` prints one
|
|
12
|
+
// rule; a rule split across a user file and a region cannot be read by one command,
|
|
13
|
+
// and the review's own accept criterion says every rule stays reachable. The meter's
|
|
14
|
+
// `paragraphs()` is the unit that REPORTS redundancy, not the unit that stores it.
|
|
15
|
+
//
|
|
16
|
+
// 2. A RULE IS SHAREABLE ONLY IF EVERY ROOT IN THE WORKSPACE SHIPS THE SAME BYTES.
|
|
17
|
+
// Union would put a python rule in front of a java repository; the intersection is
|
|
18
|
+
// the only set that cannot. Bytes, not ids: a rule two roots render differently —
|
|
19
|
+
// inferability having dropped a line in one of them — is not shareable, and comparing
|
|
20
|
+
// the rendered frame is how that is found rather than assumed.
|
|
21
|
+
//
|
|
22
|
+
// 3. THE IDS LIVE IN EACH REPOSITORY'S MANIFEST, not in the shared file and not in the
|
|
23
|
+
// workspace config. A repository's region must be a pure function of that
|
|
24
|
+
// repository, or `check` in CI — where nobody has installed anything — disagrees
|
|
25
|
+
// with the reader's own session. That also means `sync` never reads `$HOME`, which
|
|
26
|
+
// `harness.mjs`'s comment forbids for anything a projection depends on.
|
|
27
|
+
//
|
|
28
|
+
// 4. THE TARGET LIST IS THE LOADER TABLE'S, not a second list of paths. Plan 0003 R11
|
|
29
|
+
// named `~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md` and `~/.gemini/GEMINI.md`; the
|
|
30
|
+
// table (`src/projection/loaders.mjs`, every row citing a vendor document) records
|
|
31
|
+
// `~/.claude/rules/*.md` and `~/.gemini/config/rules/*.md` instead, and records no
|
|
32
|
+
// user-level file at all for Cursor or Copilot. A path this tool writes to and a
|
|
33
|
+
// reader's harness never loads buys nothing and costs a directory.
|
|
34
|
+
|
|
35
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
import { CLI } from '../name.mjs';
|
|
40
|
+
import { caps, loader } from './loaders.mjs';
|
|
41
|
+
import { composeShared, diff as diffRegion, inject, parse } from './markers.mjs';
|
|
42
|
+
import { byPriorityThenId, desiredRegion, provenanceDigest } from './rules.mjs';
|
|
43
|
+
import { discover } from '../compare/discover.mjs';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The filename this tool claims inside a reader's flat user rules directory.
|
|
47
|
+
*
|
|
48
|
+
* `simt` rather than `simt-dev`, for the reason in `src/name.mjs`: a command name appears
|
|
49
|
+
* in text this tool writes and nowhere else, while the on-disk names stay locked. Naming
|
|
50
|
+
* the file for the command that wrote it is what lets a reader who finds it in their home
|
|
51
|
+
* directory tell which tool put it there.
|
|
52
|
+
*/
|
|
53
|
+
const SHARED_BASENAME = 'simt.md';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A path from the loader table, under the reader's home directory.
|
|
57
|
+
*
|
|
58
|
+
* The table spells these two ways — `~/.config/opencode/AGENTS.md` and the bare
|
|
59
|
+
* `.claude/rules` directory — so both are joined onto `homedir()` rather than one being
|
|
60
|
+
* special-cased. A relative path left alone would resolve against the process's working
|
|
61
|
+
* directory, which is a repository, and a user-level file written there is a file in
|
|
62
|
+
* nobody's home directory at all.
|
|
63
|
+
*/
|
|
64
|
+
function inHome(path) {
|
|
65
|
+
if (process.env.CODEX_HOME !== undefined && process.env.CODEX_HOME !== '' && path.startsWith('~/.codex/')) {
|
|
66
|
+
return join(process.env.CODEX_HOME, path.slice('~/.codex/'.length));
|
|
67
|
+
}
|
|
68
|
+
return path.startsWith('~/') ? join(homedir(), path.slice(2)) : join(homedir(), path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this the file the reader's harness reads in preference to the ones below it?
|
|
73
|
+
*
|
|
74
|
+
* A fact about the reader's machine rather than about the table, which is why it is asked
|
|
75
|
+
* here and nowhere else: Codex reads `AGENTS.override.md` when it is non-empty and
|
|
76
|
+
* `AGENTS.md` otherwise, and writing an override where the reader has none would shadow
|
|
77
|
+
* the global file they do have — silently, and for every repository in the estate.
|
|
78
|
+
*/
|
|
79
|
+
async function nonEmptyFile(path) {
|
|
80
|
+
try {
|
|
81
|
+
const info = await stat(path);
|
|
82
|
+
return info.isFile() && info.size > 0;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The smallest byte cap that applies to the file this tool would write.
|
|
90
|
+
*
|
|
91
|
+
* Token budgets are deliberately not compared against bytes: Antigravity's 20,000-token
|
|
92
|
+
* cap for global plus always-on rules is a real limit the table records, and 20,000
|
|
93
|
+
* compared against a byte count would be a number with no meaning.
|
|
94
|
+
*/
|
|
95
|
+
function fileCap(row) {
|
|
96
|
+
return (
|
|
97
|
+
caps(row)
|
|
98
|
+
.filter((c) => c.appliesTo === 'a single file' || c.appliesTo === 'the merged chain')
|
|
99
|
+
.sort((a, b) => a.bytes - b.bytes)[0] ?? null
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Where a surface's rules can be carried once per reader, or `null` when they cannot be.
|
|
105
|
+
*
|
|
106
|
+
* Read from the loader table rather than written here, because that is the one place a
|
|
107
|
+
* path comes with the document that says a harness loads it. Two answers are deliberate:
|
|
108
|
+
*
|
|
109
|
+
* - a surface with no `scope: 'user'` row (Cursor, Copilot) yields `null`. Those
|
|
110
|
+
* harnesses keep user rules in their own settings, which is not a file this tool can
|
|
111
|
+
* project, so `shared` prints the block for a reader to paste instead of writing
|
|
112
|
+
* somewhere that would do nothing.
|
|
113
|
+
* - the `pointer` surfaces (`claude-md`, `gemini-md`) have no loader row at all, so they
|
|
114
|
+
* yield `null` too. The plan named `~/.claude/CLAUDE.md` and `~/.gemini/GEMINI.md`;
|
|
115
|
+
* the table records no such user-level file for either harness, and a guessed path is
|
|
116
|
+
* worse than a printed block — the file would be written, and never read.
|
|
117
|
+
*
|
|
118
|
+
* Where a harness records more than one global file, the one that is already there wins:
|
|
119
|
+
* which of them is read is a fact about the reader's machine, and the fallback is written
|
|
120
|
+
* only when none of them exists.
|
|
121
|
+
*
|
|
122
|
+
* @param {string[]} surfaces the surface ids selected across the workspace
|
|
123
|
+
* @returns {Promise<{surface: string, label: string, path: string|null, why: string,
|
|
124
|
+
* cap: {bytes: number, appliesTo: string, why: string}|null}[]>}
|
|
125
|
+
*/
|
|
126
|
+
export async function sharedTargets(surfaces) {
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const id of [...surfaces].sort()) {
|
|
129
|
+
const row = loader(id);
|
|
130
|
+
if (row === null) {
|
|
131
|
+
out.push({
|
|
132
|
+
surface: id,
|
|
133
|
+
label: id,
|
|
134
|
+
path: null,
|
|
135
|
+
cap: null,
|
|
136
|
+
why: `no user-level instruction file is recorded for ${id}; its user rules are configured in the app, not in a file`,
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const users = row.start.filter((s) => s.scope === 'user' && s.atStart === true);
|
|
141
|
+
const flat = users.find((u) => u.tree === 'flat');
|
|
142
|
+
if (flat !== undefined) {
|
|
143
|
+
out.push({
|
|
144
|
+
surface: id,
|
|
145
|
+
label: row.label,
|
|
146
|
+
// A flat tree names a directory and a glob; the filename inside it is this tool's
|
|
147
|
+
// to choose, which is why this path is the only one in the module that is not in
|
|
148
|
+
// the table verbatim. Nothing to choose between here: the reader's own files in
|
|
149
|
+
// that directory load alongside ours rather than instead of them.
|
|
150
|
+
path: join(inHome(flat.dir ?? flat.path), SHARED_BASENAME),
|
|
151
|
+
why: flat.why,
|
|
152
|
+
cap: fileCap(row),
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const singles = users
|
|
157
|
+
.filter((u) => u.tree === 'single')
|
|
158
|
+
.map((u) => ({ ...u, path: inHome(u.path) }));
|
|
159
|
+
const present = [];
|
|
160
|
+
for (const candidate of singles) {
|
|
161
|
+
if (await nonEmptyFile(candidate.path)) present.push(candidate);
|
|
162
|
+
}
|
|
163
|
+
const chosen = present[0] ?? singles[singles.length - 1];
|
|
164
|
+
if (chosen === undefined) {
|
|
165
|
+
out.push({
|
|
166
|
+
surface: id,
|
|
167
|
+
label: id,
|
|
168
|
+
path: null,
|
|
169
|
+
cap: fileCap(row),
|
|
170
|
+
why: `${row.label} keeps user rules in its own settings, not in a file this tool can write`,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const alternates = singles.filter((u) => u.path !== chosen.path).map((u) => u.path);
|
|
175
|
+
out.push({
|
|
176
|
+
surface: id,
|
|
177
|
+
label: row.label,
|
|
178
|
+
path: chosen.path,
|
|
179
|
+
why:
|
|
180
|
+
alternates.length === 0 || chosen === singles[0]
|
|
181
|
+
? chosen.why
|
|
182
|
+
: `${chosen.why}; written here rather than ${alternates.join(' or ')} because it is the one ${row.label} reads on this machine`,
|
|
183
|
+
cap: fileCap(row),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What every root in a workspace would carry, as `{id: rule}` per root.
|
|
191
|
+
*
|
|
192
|
+
* Each root is asked the question it already answers for `sync`, and nothing is read out
|
|
193
|
+
* of a sibling's tree beyond its own projection: the only reason this module exists is to
|
|
194
|
+
* compare roots, and it does that by asking each one rather than by reconstructing what
|
|
195
|
+
* each one would say.
|
|
196
|
+
*
|
|
197
|
+
* @returns {Promise<{path: string, desired: object, shareable: Map<string, object>}[]>}
|
|
198
|
+
*/
|
|
199
|
+
export async function sharedCandidates(roots, flags) {
|
|
200
|
+
const rows = [];
|
|
201
|
+
for (const path of roots) {
|
|
202
|
+
const desired = await desiredRegion(path, flags ?? {});
|
|
203
|
+
rows.push({ path, desired, shareable: new Map(desired.shareable.map((r) => [r.id, r])) });
|
|
204
|
+
}
|
|
205
|
+
return rows;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The rules every root carries with identical bytes, plus the ones that nearly qualify.
|
|
210
|
+
*
|
|
211
|
+
* `divergent` is not a diagnostic nobody reads: a rule two roots carry differently is
|
|
212
|
+
* almost always inferability having dropped a line in one of them, and the answer is to
|
|
213
|
+
* leave it in both regions rather than to teach this module which root was right. The two
|
|
214
|
+
* reasons are kept apart because they are different facts: a rule one root does not carry
|
|
215
|
+
* at all, and a rule two roots render differently, need different words in the report —
|
|
216
|
+
* "differs across 1 repositories" describes neither.
|
|
217
|
+
*
|
|
218
|
+
* @param {{shareable: Map<string, object>}[]} rows
|
|
219
|
+
* @returns {{shared: object[], divergent: {id: string, roots: string[], reason: 'bytes'|'coverage'}[]}}
|
|
220
|
+
*/
|
|
221
|
+
export function intersect(rows) {
|
|
222
|
+
if (rows.length === 0) return { shared: [], divergent: [] };
|
|
223
|
+
// The union of every root's ids, not the first root's: a rule only beta selects would
|
|
224
|
+
// otherwise never be named, and the report would leave a reader guessing whether it
|
|
225
|
+
// moved or stayed.
|
|
226
|
+
const ids = [...new Set(rows.flatMap((row) => [...row.shareable.keys()]))].sort();
|
|
227
|
+
const shared = [];
|
|
228
|
+
const divergent = [];
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
const carriers = rows.filter((row) => row.shareable.has(id));
|
|
231
|
+
const rule = carriers[0].shareable.get(id);
|
|
232
|
+
const same = carriers.every((row) => row.shareable.get(id).rendered === rule.rendered);
|
|
233
|
+
if (carriers.length === rows.length && same) {
|
|
234
|
+
// Sorted in the order a region already keeps its rules, so the shared file and a
|
|
235
|
+
// region's index are read in the same order and neither is sorted twice.
|
|
236
|
+
shared.push({ id, ...rule });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
divergent.push({
|
|
240
|
+
id,
|
|
241
|
+
roots: carriers.map((row) => row.path),
|
|
242
|
+
reason: carriers.length === rows.length ? 'bytes' : 'coverage',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return { shared: shared.sort((a, b) => byPriorityThenId(a, b)), divergent };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The document the user-level file carries.
|
|
250
|
+
*
|
|
251
|
+
* One lead paragraph, and it is the paragraph a reader needs: these rules are here rather
|
|
252
|
+
* than in a repository, which is why a rule they expect in AGENTS.md is not in it. Below
|
|
253
|
+
* the lead the frames are byte-identical to what the regions used to carry, which is what
|
|
254
|
+
* lets a reader — or `check` — compare one copy against the other rather than against a
|
|
255
|
+
* reconstruction of it.
|
|
256
|
+
*/
|
|
257
|
+
export function sharedBody(rules) {
|
|
258
|
+
const lead =
|
|
259
|
+
'The rules every repository in this workspace shares, carried once here rather than in each ' +
|
|
260
|
+
"repository. A repository's own region carries what is left and names anything here it does " +
|
|
261
|
+
`not carry. Read one of these from any repository in the workspace with: ${CLI} rules show <id>`;
|
|
262
|
+
return rules.length === 0 ? lead : `${lead}\n\n${rules.map((r) => r.rendered).join('\n\n')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The provenance a copy is stamped with. Same digest `sync` stamps, so one sha answers
|
|
266
|
+
* both questions: a copy of the user file written by a different release of these rules
|
|
267
|
+
* is stale, and there is nothing else in the comparison that would notice. */
|
|
268
|
+
export async function sharedMeta(rules) {
|
|
269
|
+
return { sha: await provenanceDigest(), sources: rules.map((r) => r.source) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Is the copy in the reader's home the bytes this workspace would write today?
|
|
274
|
+
*
|
|
275
|
+
* `diff` rather than a string compare, because the file may carry the reader's own prose
|
|
276
|
+
* above the region and a region that matches is current whether or not the bytes around it
|
|
277
|
+
* do. A whole-file comparison would report a stale copy for a file this tool only partly
|
|
278
|
+
* owns, and the reader would be told to run a command that changes nothing.
|
|
279
|
+
*
|
|
280
|
+
* @returns {Promise<{state: 'current'|'stale'|'absent'|'unreadable', action?: string,
|
|
281
|
+
* region?: string, reason?: string}>}
|
|
282
|
+
*/
|
|
283
|
+
export async function sharedState(path, rules, meta) {
|
|
284
|
+
let text;
|
|
285
|
+
try {
|
|
286
|
+
text = await readFile(path, 'utf8');
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error.code === 'ENOENT') return { state: 'absent' };
|
|
289
|
+
return { state: 'unreadable', reason: error.message };
|
|
290
|
+
}
|
|
291
|
+
// A file with no region at all is absent, not stale: the reader has never installed one,
|
|
292
|
+
// and the remedy for that is the same command with a different sentence.
|
|
293
|
+
if (parse(text).status === 'absent') return { state: 'absent' };
|
|
294
|
+
const probe = diffRegion(text, sharedBody(rules), meta, { compose: composeShared });
|
|
295
|
+
return {
|
|
296
|
+
state: probe.action === 'unchanged' ? 'current' : 'stale',
|
|
297
|
+
action: probe.action,
|
|
298
|
+
region: probe.region,
|
|
299
|
+
reason: probe.action === 'malformed' ? probe.reason : undefined,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The write, with the file's current bytes as an argument.
|
|
305
|
+
*
|
|
306
|
+
* `inject` rather than `writeFile` for the reason the whole module exists in one line: the
|
|
307
|
+
* user-level AGENTS.md is the most likely file in a reader's home directory to contain
|
|
308
|
+
* something they wrote themselves. Only the bytes between the markers are ours.
|
|
309
|
+
*/
|
|
310
|
+
function injectShared(before, rules, meta) {
|
|
311
|
+
return inject(before, sharedBody(rules), meta, { compose: composeShared });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Write one user-level copy, creating the directory when the table named one.
|
|
316
|
+
*
|
|
317
|
+
* Idempotent: the same bytes come back `unchanged` and nothing is written, which is what
|
|
318
|
+
* lets `install` be run on every machine in an estate without anyone diffing their home
|
|
319
|
+
* directories afterwards.
|
|
320
|
+
*
|
|
321
|
+
* @returns {Promise<{path: string, action: string, created: boolean}>}
|
|
322
|
+
*/
|
|
323
|
+
export async function writeSharedFile(path, rules, meta) {
|
|
324
|
+
let before = '';
|
|
325
|
+
try {
|
|
326
|
+
before = await readFile(path, 'utf8');
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error.code !== 'ENOENT') throw error;
|
|
329
|
+
}
|
|
330
|
+
const injected = injectShared(before, rules, meta);
|
|
331
|
+
if (injected.action === 'unchanged') return { path, action: 'unchanged', created: false };
|
|
332
|
+
await mkdir(dirname(path), { recursive: true });
|
|
333
|
+
await writeFile(path, injected.text);
|
|
334
|
+
return { path, action: injected.action, created: before === '' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Are the rules this repository handed over actually in the reader's copy?
|
|
339
|
+
*
|
|
340
|
+
* A subset check rather than equality, and the difference matters: the file carries the
|
|
341
|
+
* union of what every repository in the workspace declared, so a file with extra rules is
|
|
342
|
+
* current for this repository while an exact comparison would call it stale and send the
|
|
343
|
+
* reader to reinstall it on every sibling. What must hold is the direction that costs
|
|
344
|
+
* something — every rule THIS repository stopped carrying is still there, byte for byte.
|
|
345
|
+
*
|
|
346
|
+
* @returns {Promise<{state: 'current'|'stale'|'absent'|'unreadable', missing: string[],
|
|
347
|
+
* checked: number, reason?: string}>}
|
|
348
|
+
*/
|
|
349
|
+
export async function sharedCoverage(path, rules) {
|
|
350
|
+
let text;
|
|
351
|
+
try {
|
|
352
|
+
text = await readFile(path, 'utf8');
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error.code === 'ENOENT') return { state: 'absent', missing: [], checked: 0 };
|
|
355
|
+
return { state: 'unreadable', missing: [], checked: 0, reason: error.message };
|
|
356
|
+
}
|
|
357
|
+
const parsed = parse(text);
|
|
358
|
+
if (parsed.status === 'absent') return { state: 'absent', missing: [], checked: 0 };
|
|
359
|
+
if (parsed.status === 'malformed') {
|
|
360
|
+
// Reported as unreadable rather than as missing: the rules may be in there, but a file
|
|
361
|
+
// with two regions cannot be checked, and "run shared install" would not fix it.
|
|
362
|
+
return { state: 'unreadable', missing: [], checked: 0, reason: parsed.reason };
|
|
363
|
+
}
|
|
364
|
+
const missing = rules.filter((r) => !parsed.body.includes(r.rendered)).map((r) => r.id);
|
|
365
|
+
return {
|
|
366
|
+
state: missing.length === 0 ? 'current' : 'stale',
|
|
367
|
+
missing,
|
|
368
|
+
checked: rules.length,
|
|
369
|
+
bytes: Buffer.byteLength(parsed.body),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The bytes the reader pays once for the whole workspace (ADR-084).
|
|
375
|
+
*
|
|
376
|
+
* Counted ONCE per file and counted at all, which is the whole reason this exists: a
|
|
377
|
+
* report that measured seven regions and ignored the file the rules moved into would
|
|
378
|
+
* show a 72% saving that the reader's context never saw. The accept criterion for R11 is
|
|
379
|
+
* a number, and a number that leaves out the copy is not the reader's number.
|
|
380
|
+
*
|
|
381
|
+
* The whole file is counted, not the span between the markers, because the prose a reader
|
|
382
|
+
* keeps around the region is also always-on for that reader — and because the alternative
|
|
383
|
+
* needs a second definition of "generated bytes" to agree with `measureRoot`'s.
|
|
384
|
+
*
|
|
385
|
+
* @param {{surfaces: string[], desired?: object}[]} rows as `measureRoot` returns them
|
|
386
|
+
* @returns {Promise<{bytes: number, files: {path: string, bytes: number, rules: number}[]}>}
|
|
387
|
+
*/
|
|
388
|
+
export async function sharedTotals(rows) {
|
|
389
|
+
const surfaces = [...new Set(rows.flatMap((row) => row.surfaces ?? []))];
|
|
390
|
+
const files = [];
|
|
391
|
+
for (const t of await sharedTargets(surfaces)) {
|
|
392
|
+
if (t.path === null) continue;
|
|
393
|
+
let text;
|
|
394
|
+
try {
|
|
395
|
+
text = await readFile(t.path, 'utf8');
|
|
396
|
+
} catch (error) {
|
|
397
|
+
// ENOENT is the normal state of a reader who has not installed a workspace copy, and
|
|
398
|
+
// reporting it as a fault would make `tokens` fail over something it is measuring.
|
|
399
|
+
if (error.code === 'ENOENT') continue;
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
const parsed = parse(text);
|
|
403
|
+
files.push({
|
|
404
|
+
path: t.path,
|
|
405
|
+
bytes: Buffer.byteLength(text),
|
|
406
|
+
rules: parsed.status === 'ok' ? countFrames(parsed.body) : 0,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return { bytes: files.reduce((n, f) => n + f.bytes, 0), files };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** One shared rule in a file: `## Stack: <id>`, `## Core` or `## Process: <id>`. */
|
|
413
|
+
function countFrames(body) {
|
|
414
|
+
return body.split('\n').filter((line) => /^## (Stack: |Core$|Process: )/.test(line)).length;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Every repository under `root`, in `discover`'s order.
|
|
419
|
+
*
|
|
420
|
+
* Not re-sorted here, deliberately: that order decides which root owns a shared paragraph
|
|
421
|
+
* in `tokens --repos`, and ADR-084's accept criterion is measured in that number.
|
|
422
|
+
*
|
|
423
|
+
* @returns {Promise<{roots: string[], error: string|null}>}
|
|
424
|
+
*/
|
|
425
|
+
export async function sharedRoots(root) {
|
|
426
|
+
const found = await discover(root);
|
|
427
|
+
return { roots: found.repos, error: found.error ?? null };
|
|
428
|
+
}
|
|
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { diff, parse, inject } from './markers.mjs';
|
|
19
19
|
import { ruleName } from './mdc.mjs';
|
|
20
20
|
import { foldsRules, frontmatterBlock, POINTERS, rulePath, SURFACES } from './surfaces.mjs';
|
|
21
|
+
import { foldChecks } from '../kernel/checks.mjs';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* A managed rule file is a GENERATED FILE, unlike AGENTS.md.
|
|
@@ -169,7 +170,7 @@ export async function scopedTargets(root, rules, sha, resolved, notes) {
|
|
|
169
170
|
alwaysApply: false,
|
|
170
171
|
scope: rule.scope,
|
|
171
172
|
};
|
|
172
|
-
targets.push(...(await ruleTargets(root, scoped, rule.body, sha, resolved, notes)).targets);
|
|
173
|
+
targets.push(...(await ruleTargets(root, scoped, foldChecks(rule.body).body, sha, resolved, notes)).targets);
|
|
173
174
|
}
|
|
174
175
|
return { targets };
|
|
175
176
|
}
|
package/src/toolwords.mjs
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
import { KNOWN_KEYS } from './projection/mdc.mjs';
|
|
18
18
|
import { PROFILE_KEYS } from './projection/rules.mjs';
|
|
19
19
|
import { INFERABLE_MARKER } from './kernel/inferability.mjs';
|
|
20
|
+
import { CHECK_MARKER } from './kernel/checks.mjs';
|
|
21
|
+
import { VARIANTS_MARKER } from './kernel/variants.mjs';
|
|
22
|
+
import { LESSON_KEYS } from './memory/lessons.mjs';
|
|
23
|
+
import { DECISION_KEYS } from './memory/decide.mjs';
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* `simt-` names that belong to this tool and never to a repository.
|
|
@@ -30,7 +34,11 @@ import { INFERABLE_MARKER } from './kernel/inferability.mjs';
|
|
|
30
34
|
export const TOOL_WORDS = new Set([
|
|
31
35
|
...KNOWN_KEYS,
|
|
32
36
|
...PROFILE_KEYS,
|
|
37
|
+
...LESSON_KEYS,
|
|
38
|
+
...DECISION_KEYS,
|
|
33
39
|
INFERABLE_MARKER,
|
|
40
|
+
CHECK_MARKER,
|
|
41
|
+
VARIANTS_MARKER,
|
|
34
42
|
'simt-dev',
|
|
35
43
|
'simt-dev-check',
|
|
36
44
|
'simt-hooks',
|