simt-dev 0.2.1 → 0.3.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 +239 -0
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
package/src/commands/sync.mjs
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// three `.mdc` rules would let a reader believe a repo is fully current when
|
|
17
17
|
// only the file they happened to open is.
|
|
18
18
|
|
|
19
|
-
import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
-
import { dirname, join } from 'node:path';
|
|
19
|
+
import { mkdir, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
21
21
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
22
22
|
import { plan, checkTargets, checkTarget, provenanceDigest, loadRules, select, pointerTargets, pointerState } from '../projection/project.mjs';
|
|
23
23
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
@@ -27,6 +27,7 @@ import { surfacesLine } from '../projection/surfaces.mjs';
|
|
|
27
27
|
import { CLI } from '../name.mjs';
|
|
28
28
|
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
29
29
|
import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
30
|
+
import { resolvePacks } from '../packs.mjs';
|
|
30
31
|
|
|
31
32
|
/** Findings `sync` reports and does not fail on.
|
|
32
33
|
*
|
|
@@ -48,7 +49,7 @@ import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
|
48
49
|
* people learn to ignore. A *foreign* one — a real file or a real directory where the
|
|
49
50
|
* pointer belongs — is reported loudly with what is in the way, because that one is a
|
|
50
51
|
* decision somebody has to make. */
|
|
51
|
-
export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
|
|
52
|
+
export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-unpinned', 'budget']);
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Findings `check` reports and does not fail on.
|
|
@@ -68,7 +69,20 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned']);
|
|
|
68
69
|
*
|
|
69
70
|
* `orphaned` stays non-blocking in both. Nothing can satisfy it, in either command.
|
|
70
71
|
*/
|
|
71
|
-
|
|
72
|
+
// `pack-unpinned` is here and `pack-digest-mismatch` is not, which is the whole
|
|
73
|
+
// judgement. An unpinned pack still renders its rules correctly; what is missing
|
|
74
|
+
// is the record of which bytes were declared, and failing a gate over a field the
|
|
75
|
+
// tool does not require would fail every repository that vendored a pack and
|
|
76
|
+
// pinned it later. A pack whose bytes do not match its pin renders a region
|
|
77
|
+
// nobody can account for, so that one blocks.
|
|
78
|
+
//
|
|
79
|
+
// `budget` is here for the opposite reason, and it is a ruling rather than a patch
|
|
80
|
+
// (ADR-072). An over-budget finding says the projection is bigger than this repository
|
|
81
|
+
// said it would be, and the remedy is a decision about which rules to narrow — not a
|
|
82
|
+
// missing file. Nothing is cut: the projection is still in full mode, so a gate that
|
|
83
|
+
// failed here would be failing a repository for exceeding a ceiling it set itself,
|
|
84
|
+
// with no way to fix it except lowering the number it wrote down.
|
|
85
|
+
export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget']);
|
|
72
86
|
|
|
73
87
|
export async function syncCommand(positional, flags) {
|
|
74
88
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
@@ -399,8 +413,43 @@ async function formatterIgnoresManifest(root) {
|
|
|
399
413
|
.some((line) => line !== '' && !line.startsWith('#') && (line === MANIFEST_FILE || line === MANIFEST_LEGACY));
|
|
400
414
|
}
|
|
401
415
|
|
|
402
|
-
|
|
403
|
-
|
|
416
|
+
/**
|
|
417
|
+
* `gates.cwd` entries that do not resolve to a directory inside the repository.
|
|
418
|
+
*
|
|
419
|
+
* Reported here, not only where the gate runs: the dispatcher fails closed on the same
|
|
420
|
+
* condition, so a reader finds out on their next commit — after writing a commit, which
|
|
421
|
+
* is the wrong order. The finding names the gate and the path, and says which of the two
|
|
422
|
+
* things is wrong, because "no such directory" and "outside the repository" are repaired
|
|
423
|
+
* by different edits.
|
|
424
|
+
*
|
|
425
|
+
* @returns {Promise<{file: string, status: string, gate: string, detail: string, remedy: string}[]>}
|
|
426
|
+
*/
|
|
427
|
+
async function gateCwdFindings(root, manifest) {
|
|
428
|
+
const cwds = manifest?.gates?.cwd;
|
|
429
|
+
if (!cwds || typeof cwds !== 'object' || Array.isArray(cwds)) return [];
|
|
430
|
+
const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
|
|
431
|
+
const findings = [];
|
|
432
|
+
for (const name of Object.keys(cwds).sort()) {
|
|
433
|
+
if (!declared.includes(name)) continue;
|
|
434
|
+
const dir = String(cwds[name]);
|
|
435
|
+
const resolved = resolve(root, dir);
|
|
436
|
+
const inside = resolved === root || resolved.startsWith(`${root}${sep}`);
|
|
437
|
+
const isDir = inside && ((await stat(resolved).catch(() => null))?.isDirectory() ?? false);
|
|
438
|
+
if (isDir) continue;
|
|
439
|
+
findings.push({
|
|
440
|
+
file: MANIFEST_FILE,
|
|
441
|
+
status: inside ? 'gate-cwd-missing' : 'gate-cwd-outside',
|
|
442
|
+
gate: name,
|
|
443
|
+
detail: inside
|
|
444
|
+
? `gates.cwd["${name}"] is ${dir}, which is not a directory in this repository`
|
|
445
|
+
: `gates.cwd["${name}"] is ${dir}, which resolves outside the repository`,
|
|
446
|
+
remedy: `point gates.cwd["${name}"] at a directory in this repository, or remove it to run the gate at the root`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return findings;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function checkCommand(positional, flags) { const root = flags.root ?? positional[0] ?? process.cwd();
|
|
404
453
|
const findings = await checkTargets(root, flags);
|
|
405
454
|
const rules = await loadRules(root);
|
|
406
455
|
|
|
@@ -431,6 +480,13 @@ export async function checkCommand(positional, flags) {
|
|
|
431
480
|
findings.push({ file: MANIFEST_FILE, status: 'version-mismatch', detail: mismatch, remedy: `run: ${CLI} upgrade --yes` });
|
|
432
481
|
}
|
|
433
482
|
findings.push(...unrunnableGates(manifest, await readCapability(root)));
|
|
483
|
+
findings.push(...(await gateCwdFindings(root, manifest)));
|
|
484
|
+
// A pack the repository declares but cannot produce is reported here rather
|
|
485
|
+
// than when a rule happens to be missing, because that is the failure mode
|
|
486
|
+
// this guards: the region still renders, it renders SHORT, and `check` would
|
|
487
|
+
// pass. A reader whose rules were quietly halved is worse off than one whose
|
|
488
|
+
// gate is red, so this is a blocking finding, not a note.
|
|
489
|
+
findings.push(...(await resolvePacks(root)).findings);
|
|
434
490
|
// A manifest a formatter has rewritten is not wrong, and not editable-away by
|
|
435
491
|
// hand either: the next `init` puts simt-dev's own bytes back and the formatter
|
|
436
492
|
// takes them away again. Reporting it here is what stops that being a surprise
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// simt-dev tokens - what a reader loads, counted.
|
|
2
|
+
//
|
|
3
|
+
// Review 0002 S38 put the estate's always-on context at 90 KB per turn, 72% of it a
|
|
4
|
+
// paragraph an earlier root had already loaded, and could only say so by reading seven
|
|
5
|
+
// repositories by hand. This command is that reading, and it is the figure plan 0003's
|
|
6
|
+
// later slices are measured against: the context budget (S38 P7), the kernel split
|
|
7
|
+
// (R07) and the workspace dedupe (R11) all need a number that moves when the
|
|
8
|
+
// projection moves, or they are opinions.
|
|
9
|
+
//
|
|
10
|
+
// Three decisions keep the figure honest:
|
|
11
|
+
//
|
|
12
|
+
// 1. It is a READ. No write, no git object, no lock - the same ruling `compare`
|
|
13
|
+
// follows. An agent running this to orient itself must not be able to change what
|
|
14
|
+
// the next run will measure.
|
|
15
|
+
// 2. It exits 0. A measurement is not a verdict: a repository over budget is a fact
|
|
16
|
+
// about the repository, and a command that went red for it would train people to
|
|
17
|
+
// ignore the number it exists to print. The only failure is being pointed at a
|
|
18
|
+
// directory that holds no repository to measure.
|
|
19
|
+
// 3. It counts bytes and calls the tokens an approximation every time it prints one.
|
|
20
|
+
// See src/measure/context.mjs for what that costs and why.
|
|
21
|
+
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
25
|
+
import { CLI } from '../name.mjs';
|
|
26
|
+
import { discover } from '../compare/discover.mjs';
|
|
27
|
+
import { targetRoot } from '../workspace.mjs';
|
|
28
|
+
import {
|
|
29
|
+
ALWAYS_ON_BUDGET_BYTES,
|
|
30
|
+
BYTES_PER_TOKEN,
|
|
31
|
+
annotate,
|
|
32
|
+
measureRoot,
|
|
33
|
+
mcpTools,
|
|
34
|
+
totals,
|
|
35
|
+
} from '../measure/context.mjs';
|
|
36
|
+
import { measureSession } from '../measure/session.mjs';
|
|
37
|
+
|
|
38
|
+
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
39
|
+
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
40
|
+
function plain(n) {
|
|
41
|
+
return String(n);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function files(n) {
|
|
45
|
+
return n === 1 ? '1 file' : `${plain(n)} files`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function tokensCommand(positional, flags) {
|
|
49
|
+
if (flags.session !== undefined) {
|
|
50
|
+
const session = await measureSession(flags.session);
|
|
51
|
+
if (!session.ok) {
|
|
52
|
+
return fail(session.error, EXIT.CONFIG, session.remedy);
|
|
53
|
+
}
|
|
54
|
+
if (isJson(flags)) {
|
|
55
|
+
emitJson({
|
|
56
|
+
ok: true,
|
|
57
|
+
command: 'tokens',
|
|
58
|
+
mode: 'session',
|
|
59
|
+
session: session.path,
|
|
60
|
+
bytesPerToken: BYTES_PER_TOKEN,
|
|
61
|
+
rules: session.rules,
|
|
62
|
+
fileReads: session.fileReads,
|
|
63
|
+
toolOutput: session.toolOutput,
|
|
64
|
+
total: session.total,
|
|
65
|
+
});
|
|
66
|
+
return EXIT.OK;
|
|
67
|
+
}
|
|
68
|
+
const lines = [
|
|
69
|
+
`tokens ${session.path} (session)`,
|
|
70
|
+
`rules ${plain(session.rules.bytes)} B ~${plain(session.rules.tokens)} tokens (${plain(session.rules.share)}%)`,
|
|
71
|
+
`file reads ${plain(session.fileReads.bytes)} B ~${plain(session.fileReads.tokens)} tokens (${plain(session.fileReads.share)}%)`,
|
|
72
|
+
`tool output ${plain(session.toolOutput.bytes)} B ~${plain(session.toolOutput.tokens)} tokens (${plain(session.toolOutput.share)}%)`,
|
|
73
|
+
`total ${plain(session.total.bytes)} B ~${plain(session.total.tokens)} tokens`,
|
|
74
|
+
];
|
|
75
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
76
|
+
return EXIT.OK;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `targetRoot` owns the precedence, so `tokens`, `compare` and `upgrade` cannot
|
|
80
|
+
// disagree about which directory was named (ADR-014).
|
|
81
|
+
const target = await targetRoot(flags, positional);
|
|
82
|
+
const root = resolve(target.root);
|
|
83
|
+
|
|
84
|
+
const found = await discover(root);
|
|
85
|
+
if (flags.repos) {
|
|
86
|
+
if (found.error) {
|
|
87
|
+
return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
|
|
88
|
+
}
|
|
89
|
+
if (found.repos.length === 0) {
|
|
90
|
+
return fail(
|
|
91
|
+
`${root} contains no git repositories`,
|
|
92
|
+
EXIT.CONFIG,
|
|
93
|
+
`point --root at the directory holding the repositories, or at one repository without --repos`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
// `discover` sorts, and the order decides which root owns a shared paragraph. A
|
|
97
|
+
// second sort here would move duplicate bytes between roots and make the workspace
|
|
98
|
+
// figure irreproducible.
|
|
99
|
+
} else if (found.shape === 'workspace') {
|
|
100
|
+
return fail(
|
|
101
|
+
`${root} holds repositories rather than being one`,
|
|
102
|
+
EXIT.CONFIG,
|
|
103
|
+
`measure one with: ${CLI} tokens --root <path>, or every repository under it with: ${CLI} tokens --repos --root ${root}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const roots = flags.repos ? found.repos : [root];
|
|
107
|
+
|
|
108
|
+
const rows = [];
|
|
109
|
+
for (const path of roots) rows.push(await measureRoot(path));
|
|
110
|
+
annotate(rows);
|
|
111
|
+
|
|
112
|
+
const summary = totals(rows);
|
|
113
|
+
const mcp = mcpTools();
|
|
114
|
+
|
|
115
|
+
if (isJson(flags)) {
|
|
116
|
+
emitJson({
|
|
117
|
+
ok: true,
|
|
118
|
+
command: 'tokens',
|
|
119
|
+
root,
|
|
120
|
+
mode: flags.repos ? 'repositories' : 'repository',
|
|
121
|
+
source: target.source,
|
|
122
|
+
budget: { bytes: ALWAYS_ON_BUDGET_BYTES, bytesPerToken: BYTES_PER_TOKEN },
|
|
123
|
+
mcp,
|
|
124
|
+
roots: rows.map(({ documents, ...row }) => row),
|
|
125
|
+
totals: summary,
|
|
126
|
+
});
|
|
127
|
+
return EXIT.OK;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lines = [];
|
|
131
|
+
if (flags.repos) {
|
|
132
|
+
lines.push(`tokens ${root} ${plain(summary.roots)} repositories (${target.source})`);
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
const budget = row.budget.over ? ` over budget by ${plain(row.budget.overBy)} B` : '';
|
|
135
|
+
lines.push(
|
|
136
|
+
`${row.name} generated ${plain(row.generatedBytes)} B duplicate ${plain(row.redundancy.duplicateBytes)} B unique ${plain(row.redundancy.uniqueBytes)} B${budget}`,
|
|
137
|
+
);
|
|
138
|
+
for (const finding of row.findings) lines.push(`note ${row.name}: ${finding}`);
|
|
139
|
+
}
|
|
140
|
+
const share =
|
|
141
|
+
summary.generatedBytes === 0 ? 0 : Math.round((summary.duplicateBytes / summary.generatedBytes) * 100);
|
|
142
|
+
lines.push(
|
|
143
|
+
`totals generated ${plain(summary.generatedBytes)} B duplicate ${plain(summary.duplicateBytes)} B (${plain(share)}% of generated) unique ${plain(summary.uniqueBytes)} B repeat ${plain(summary.repeatBytes)} B ~${plain(summary.tokens)} tokens`,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
const row = rows[0];
|
|
147
|
+
lines.push(`tokens ${row.path}`);
|
|
148
|
+
lines.push(`state ${row.adoption} surfaces ${row.surfaces.length === 0 ? '(AGENTS.md only)' : row.surfaces.join(', ')}`);
|
|
149
|
+
for (const channel of row.channels) {
|
|
150
|
+
lines.push(
|
|
151
|
+
`${channel.channel.padEnd(9)}${files(channel.files)} ${plain(channel.generatedBytes)} B generated ${plain(channel.readerBytes)} B read`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const verdict = row.budget.over ? `over by ${plain(row.budget.overBy)} B` : 'within budget';
|
|
155
|
+
lines.push(
|
|
156
|
+
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
157
|
+
);
|
|
158
|
+
lines.push(
|
|
159
|
+
`redundant ${plain(row.redundancy.duplicateBytes)} B repeats an earlier repository ${plain(row.redundancy.repeatBytes)} B repeats an earlier document here ${plain(row.redundancy.uniqueBytes)} B unique of ${plain(row.redundancy.blockBytes)} B counted in blocks`,
|
|
160
|
+
);
|
|
161
|
+
for (const finding of row.findings) lines.push(`note ${finding}`);
|
|
162
|
+
}
|
|
163
|
+
lines.push(
|
|
164
|
+
`mcp tools/list ${plain(mcp.bytes)} B of ${plain(mcp.tools)} tools (the same for every repository, so it is not in any total)`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
168
|
+
return EXIT.OK;
|
|
169
|
+
}
|
package/src/compare/row.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
|
|
20
20
|
import { manifestDrift, readManifest } from '../projection/manifest.mjs';
|
|
21
21
|
import { loadRules, select } from '../projection/rules.mjs';
|
|
22
|
+
import { resolvePacks } from '../packs.mjs';
|
|
22
23
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
23
24
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
24
25
|
import { readHooksState, wantsShim } from './hooksstate.mjs';
|
|
@@ -34,6 +35,10 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
34
35
|
* @property {string[]} stack
|
|
35
36
|
* @property {string[]} facts
|
|
36
37
|
* @property {string[]} profiles profile ids that would be selected
|
|
38
|
+
* @property {{path: string, profiles: string[], status: string, detail: string|null}[]} packages
|
|
39
|
+
* one nested region per workspace package, sorted by path
|
|
40
|
+
* @property {{path: string, profiles: string[], status: string, detail: string|null}[]} packageProblems
|
|
41
|
+
* the nested regions that are not `current`; empty for an unadopted repository
|
|
37
42
|
* @property {string|null} pinnedSha
|
|
38
43
|
* @property {string|null} recordedSha
|
|
39
44
|
* @property {'current'|'drift'|'unmanaged'|'absent'|'malformed'} region
|
|
@@ -42,6 +47,8 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
42
47
|
* @property {boolean} hooksComplete every event installed, executable, not stale
|
|
43
48
|
* @property {string[]} gatesDeclared
|
|
44
49
|
* @property {string[]} gateProblems
|
|
50
|
+
* @property {{name: string, source: string|null, version: string|null, digest: string|null, ok: boolean}[]} packs
|
|
51
|
+
* the shared rule sets this repo declares, with the digest it has pinned for each
|
|
45
52
|
* @property {string[]} problems everything wrong, one line each; drives the exit code
|
|
46
53
|
* @property {string[]} notes worth knowing, but not wrong; does NOT drive the exit code
|
|
47
54
|
*/
|
|
@@ -55,8 +62,19 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
55
62
|
* @param {{expectedSha: string, gates: Set<string>}} context
|
|
56
63
|
* @returns {Promise<RepoRow>}
|
|
57
64
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A nested-region finding in the row's own vocabulary.
|
|
67
|
+
*
|
|
68
|
+
* `checkTargets` speaks in `file` because a finding is printed with the path it names;
|
|
69
|
+
* a row field is read by key, and a reader looking for `packages[].path` should not
|
|
70
|
+
* have to know that the finding underneath calls the same thing `file`. One mapper, so
|
|
71
|
+
* the two arrays cannot drift into different shapes.
|
|
72
|
+
*/
|
|
73
|
+
function packageEntry(f) {
|
|
74
|
+
return { path: f.file, profiles: f.profiles ?? [], status: f.status, detail: f.detail ?? null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function buildRow(path, context) { const name = path.split('/').filter(Boolean).pop() ?? path;
|
|
60
78
|
const problems = [];
|
|
61
79
|
const notes = [];
|
|
62
80
|
|
|
@@ -84,6 +102,12 @@ export async function buildRow(path, context) {
|
|
|
84
102
|
// state a rule written to four surfaces can drift into.
|
|
85
103
|
const region = findings.find((f) => f.file === PRIMARY_TARGET) ?? { status: 'absent' };
|
|
86
104
|
const ruleFindings = findings.filter((f) => f.surface !== undefined);
|
|
105
|
+
// A nested region is identified by its path, not by a field: `checkTargets` builds
|
|
106
|
+
// those findings from `checkRegion`, which knows nothing about monorepos, and the
|
|
107
|
+
// one thing this row needs from them is "which file, what state".
|
|
108
|
+
const packageFindings = findings
|
|
109
|
+
.filter((f) => f.file !== undefined && f.file.endsWith(`/${PRIMARY_TARGET}`))
|
|
110
|
+
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
87
111
|
const pointers = findings.filter((f) => f.status?.startsWith('pointer'));
|
|
88
112
|
|
|
89
113
|
// Adoption is settled BEFORE any problem is collected. Every gate below asks
|
|
@@ -105,6 +129,13 @@ export async function buildRow(path, context) {
|
|
|
105
129
|
// prose above it was still true.
|
|
106
130
|
const { selected: chosen } = select(await loadRules(path), fp.stack ?? [], fp.facts ?? []);
|
|
107
131
|
|
|
132
|
+
// The packs this repo shares with its siblings, with the digest it has pinned.
|
|
133
|
+
// A dashboard that reported `current: 14` while half their rules lived in a
|
|
134
|
+
// directory that was not there described an estate nobody had. The digest is
|
|
135
|
+
// the per-repo answer to "which bytes were these", which is what P4 asks for and
|
|
136
|
+
// what a reviewer comparing two repositories actually wants to see.
|
|
137
|
+
const { packs, findings: packFindings } = await resolvePacks(path);
|
|
138
|
+
|
|
108
139
|
const row = {
|
|
109
140
|
name,
|
|
110
141
|
path,
|
|
@@ -119,6 +150,12 @@ export async function buildRow(path, context) {
|
|
|
119
150
|
region: region.status,
|
|
120
151
|
regionDetail: region.detail ?? null,
|
|
121
152
|
surfaces: manifest.manifest?.surfaces ?? null,
|
|
153
|
+
// One entry per nested region, for the same reason `ruleFiles` is its own object:
|
|
154
|
+
// a monorepo can have a current root region and one stale package region, and a
|
|
155
|
+
// single `region` field cannot say which. Sorted by path, because `checkTargets`
|
|
156
|
+
// returns findings in the order the detector found packages and a dashboard that
|
|
157
|
+
// reorders rows between runs is a dashboard nobody diffs.
|
|
158
|
+
packages: packageFindings.map(packageEntry),
|
|
122
159
|
ruleFiles: {
|
|
123
160
|
total: ruleFindings.filter((f) => f.status !== 'orphaned').length,
|
|
124
161
|
current: ruleFindings.filter((f) => f.status === 'current').length,
|
|
@@ -130,6 +167,19 @@ export async function buildRow(path, context) {
|
|
|
130
167
|
orphaned: ruleFindings.filter((f) => f.status === 'orphaned').map((f) => f.file),
|
|
131
168
|
},
|
|
132
169
|
pointers: pointers.map((p) => ({ file: p.file, state: p.status })),
|
|
170
|
+
// A nested region that is not `current` is a problem for the same reason a stale
|
|
171
|
+
// rule file is: `check` exits 1 on it, and a row that reported the repository as
|
|
172
|
+
// current anyway is ADR-045's defect one level down. Gated on adoption, like every
|
|
173
|
+
// other gate here — an unadopted repo has no root region and therefore no nested
|
|
174
|
+
// findings to report, which is not a second defect for the same missing adoption.
|
|
175
|
+
packageProblems: adoption === 'adopted' ? packageFindings.filter((f) => f.status !== 'current').map(packageEntry) : [],
|
|
176
|
+
packs: packs.map((p) => ({
|
|
177
|
+
name: p.name,
|
|
178
|
+
source: p.source,
|
|
179
|
+
version: p.version,
|
|
180
|
+
digest: p.declared || null,
|
|
181
|
+
ok: p.ok,
|
|
182
|
+
})),
|
|
133
183
|
hooksRouted: hooks.routed,
|
|
134
184
|
hooksComplete: hooks.complete,
|
|
135
185
|
gatesDeclared: declaredGates(manifest.manifest),
|
|
@@ -163,6 +213,22 @@ export async function buildRow(path, context) {
|
|
|
163
213
|
problems.push(`${MANIFEST_FILE} ${manifest.error}`);
|
|
164
214
|
}
|
|
165
215
|
|
|
216
|
+
// Stale nested regions, named by path, with the remedy the finding already carries.
|
|
217
|
+
// A monorepo with four packages and one drifted region must not report `current`.
|
|
218
|
+
for (const f of row.packageProblems) {
|
|
219
|
+
problems.push(`${f.path}: ${f.detail ?? 'the package region is not current'}; run: ${CLI} sync --yes`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// `pack-unpinned` is a note here for the same reason it is non-blocking in
|
|
223
|
+
// `check`: the rules render, and only the record of which bytes they were is
|
|
224
|
+
// absent. A missing or mismatched pack is a problem, because that row would
|
|
225
|
+
// otherwise report a repository `current` while projecting fewer rules than the
|
|
226
|
+
// manifest declares - the defect ADR-045 was about, reached a different way.
|
|
227
|
+
for (const f of packFindings) {
|
|
228
|
+
if (f.status === 'pack-unpinned') notes.push(`${f.file}: ${f.detail}; ${f.remedy}`);
|
|
229
|
+
else problems.push(`${f.file}: ${f.detail}; ${f.remedy}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
166
232
|
// A generated field of the manifest is stale, and `check` refuses on it. This row
|
|
167
233
|
// said the repository was `current` anyway, in all fourteen adopted repositories at
|
|
168
234
|
// once, so a dashboard that reported `current: 14, problems: 0` was describing a
|
|
@@ -318,6 +384,11 @@ export function summarise(rows, extra = {}) {
|
|
|
318
384
|
malformed: count((r) => r.region === 'malformed'),
|
|
319
385
|
hooksRouted: count((r) => r.hooksRouted),
|
|
320
386
|
staleRuleFiles: rows.reduce((n, r) => n + (r.ruleFiles?.stale?.length ?? 0), 0),
|
|
387
|
+
// Counted here and not derived from `withProblems`: one monorepo with three drifted
|
|
388
|
+
// packages is one repository with three findings, and a reader asking "how much is
|
|
389
|
+
// stale" wants 3, not 1. `withProblems` still counts the repository once.
|
|
390
|
+
stalePackages: rows.reduce((n, r) => n + (r.packageProblems?.length ?? 0), 0),
|
|
391
|
+
packageRegions: rows.reduce((n, r) => n + (r.packages?.length ?? 0), 0),
|
|
321
392
|
withProblems: count((r) => r.problems.length > 0),
|
|
322
393
|
withOwnHooks: count((r) => (r.foreignHooks ?? []).length > 0),
|
|
323
394
|
shas: [...shas.entries()].sort((a, b) => (a[0] === '(none)' ? 1 : b[0] === '(none)' ? -1 : b[1] - a[1])),
|
package/src/decisions/index.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
22
22
|
import { CLI } from '../name.mjs';
|
|
23
|
+
import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
|
|
23
24
|
|
|
24
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
export const DECISIONS_DIR = resolve(HERE, '..', '..', 'docs', 'decisions');
|
|
@@ -240,7 +241,7 @@ export async function loadDecisions({ all = false } = {}) {
|
|
|
240
241
|
(m) => `ADR-${m[1]}`,
|
|
241
242
|
),
|
|
242
243
|
partial,
|
|
243
|
-
summary: summarise(text, partial, { all }),
|
|
244
|
+
summary: summarise(text, partial, { all, file }),
|
|
244
245
|
bytes: Buffer.byteLength(text),
|
|
245
246
|
});
|
|
246
247
|
}
|
|
@@ -288,7 +289,7 @@ export function byId(decisions) {
|
|
|
288
289
|
* answer "why is this here"; the reader who needs the rest asks.
|
|
289
290
|
* @returns {{ruled: string[], rulingNumbers: (number|null)[], rulingCount: number, rejected: string[], writtenAgainst: string[]}}
|
|
290
291
|
*/
|
|
291
|
-
function summarise(text, partial = [], { all = false } = {}) {
|
|
292
|
+
function summarise(text, partial = [], { all = false, file = null } = {}) {
|
|
292
293
|
// A section ends at the next `## ` — but not at one inside a fenced code block.
|
|
293
294
|
// ADR-007's entire ruling is a document skeleton in a fence whose headings are `##`,
|
|
294
295
|
// so the old lookahead ended the section at the first line of the ruling itself. It
|
|
@@ -446,7 +447,15 @@ const section = (name) => {
|
|
|
446
447
|
// Named rather than stripped. A record that cites this org's repositories is still
|
|
447
448
|
// the record; silently deleting the citation would hide which decisions were made in
|
|
448
449
|
// response to what, and would mangle sentences mid-clause.
|
|
449
|
-
|
|
450
|
+
//
|
|
451
|
+
// What is named is a repository, and the shape was wrong about that: every
|
|
452
|
+
// `simt-<word>` that was not `simt-dev` was reported as one, so the tool's own
|
|
453
|
+
// `simt-priority` was listed as a surveyed repository and the record that settled the
|
|
454
|
+
// kernel read as if it had been decided by looking at some other repository (ADR-080).
|
|
455
|
+
// A record whose subject is the prefix is arguing rather than citing, so it names none.
|
|
456
|
+
writtenAgainst: PREFIX_ARGUING.has(file)
|
|
457
|
+
? []
|
|
458
|
+
: [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name)))].sort(),
|
|
450
459
|
};
|
|
451
460
|
}
|
|
452
461
|
|
|
@@ -458,6 +467,10 @@ const section = (name) => {
|
|
|
458
467
|
* The skill registry's `source.repo` values are asserted to match this (S14); a name
|
|
459
468
|
* the registry adds that is not `simt-*` has to be added here or that assertion fails.
|
|
460
469
|
*
|
|
470
|
+
* The shape is not the whole answer, and treating it as one is what ADR-080 settles: a
|
|
471
|
+
* `simt-` name this tool owns is not a repository, and neither reader can tell that from
|
|
472
|
+
* the shape. `src/toolwords.mjs` is the list both of them share.
|
|
473
|
+
*
|
|
461
474
|
* Not global: callers reuse it with `exec`, and a `/g` flag would remember `lastIndex`.
|
|
462
475
|
*/
|
|
463
476
|
/**
|
|
@@ -486,7 +499,12 @@ export const ESTATE_NAME =
|
|
|
486
499
|
|
|
487
500
|
/** Replace estate repository names with a phrase a reader who does not have them can use. */
|
|
488
501
|
export function redactEstate(text) {
|
|
489
|
-
return text.replace(new RegExp(ESTATE_NAME.source, 'g'),
|
|
502
|
+
return String(text ?? '').replace(new RegExp(ESTATE_NAME.source, 'g'), (name) =>
|
|
503
|
+
// The tool's own names are left where they are. Redacting them turned a ruling about
|
|
504
|
+
// `simt-priority` into a sentence about `a repository`, which is how a reader learned
|
|
505
|
+
// that the record had surveyed something and not what it said (ADR-080).
|
|
506
|
+
TOOL_WORDS.has(name) ? name : 'a repository',
|
|
507
|
+
);
|
|
490
508
|
}
|
|
491
509
|
|
|
492
510
|
/**
|
|
@@ -506,7 +524,7 @@ export function redactEstate(text) {
|
|
|
506
524
|
* Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
|
|
507
525
|
* `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
|
|
508
526
|
*/
|
|
509
|
-
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|--[a-z-]+)';
|
|
527
|
+
const VERB = '(?:init|doctor|check|sync|upgrade|ci|compare|hooks|mcp|rules|skills|graph|tokens|harness|bench|--[a-z-]+)';
|
|
510
528
|
|
|
511
529
|
function redactCommand(text) {
|
|
512
530
|
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${VERB})\\b`, 'g'), `${CLI} `);
|