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
package/src/commands/sync.mjs
CHANGED
|
@@ -28,6 +28,10 @@ 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
30
|
import { resolvePacks } from '../packs.mjs';
|
|
31
|
+
import { computeLock, readLock, LOCK_FILE } from '../projection/lock.mjs';
|
|
32
|
+
import { runDeclaredChecks } from '../kernel/checks.mjs';
|
|
33
|
+
import { DECISIONS_DIR } from '../decisions/index.mjs';
|
|
34
|
+
import { lintDecisions } from '../memory/decide.mjs';
|
|
31
35
|
|
|
32
36
|
/** Findings `sync` reports and does not fail on.
|
|
33
37
|
*
|
|
@@ -49,7 +53,15 @@ import { resolvePacks } from '../packs.mjs';
|
|
|
49
53
|
* people learn to ignore. A *foreign* one — a real file or a real directory where the
|
|
50
54
|
* pointer belongs — is reported loudly with what is in the way, because that one is a
|
|
51
55
|
* decision somebody has to make. */
|
|
52
|
-
export const SYNC_NON_BLOCKING = new Set([
|
|
56
|
+
export const SYNC_NON_BLOCKING = new Set([
|
|
57
|
+
'pointer-unread',
|
|
58
|
+
'orphaned',
|
|
59
|
+
'pack-unpinned',
|
|
60
|
+
'budget',
|
|
61
|
+
'budget-cut',
|
|
62
|
+
'file-lines-warn',
|
|
63
|
+
'check-unbuilt',
|
|
64
|
+
]);
|
|
53
65
|
|
|
54
66
|
/**
|
|
55
67
|
* Findings `check` reports and does not fail on.
|
|
@@ -82,7 +94,11 @@ export const SYNC_NON_BLOCKING = new Set(['pointer-unread', 'orphaned', 'pack-un
|
|
|
82
94
|
// missing file. Nothing is cut: the projection is still in full mode, so a gate that
|
|
83
95
|
// failed here would be failing a repository for exceeding a ceiling it set itself,
|
|
84
96
|
// with no way to fix it except lowering the number it wrote down.
|
|
85
|
-
|
|
97
|
+
// `budget-cut` is here for the same reason and is the second half of the same ruling
|
|
98
|
+
// (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
|
|
99
|
+
// remedy is a decision about the number — and a cut the repository asked for by declaring
|
|
100
|
+
// a budget is the tool working, not a repository in need of a file.
|
|
101
|
+
export const CHECK_NON_BLOCKING = new Set(['orphaned', 'pack-unpinned', 'budget', 'budget-cut', 'file-lines-warn', 'check-unbuilt']);
|
|
86
102
|
|
|
87
103
|
export async function syncCommand(positional, flags) {
|
|
88
104
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
@@ -267,6 +283,18 @@ export async function syncCommand(positional, flags) {
|
|
|
267
283
|
await writeFile(t.path, t.text);
|
|
268
284
|
note(`wrote ${t.file}`);
|
|
269
285
|
}
|
|
286
|
+
const lock = await computeLock(root, flags);
|
|
287
|
+
const lockText = `${JSON.stringify(lock, null, 2)}\n`;
|
|
288
|
+
let previousLock = null;
|
|
289
|
+
try {
|
|
290
|
+
previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code !== 'ENOENT') throw error;
|
|
293
|
+
}
|
|
294
|
+
if (previousLock !== lockText) {
|
|
295
|
+
await writeFile(join(root, LOCK_FILE), lockText);
|
|
296
|
+
note(`wrote ${LOCK_FILE}`);
|
|
297
|
+
}
|
|
270
298
|
for (const p of pointerPlan) {
|
|
271
299
|
await mkdir(dirname(p.path), { recursive: true });
|
|
272
300
|
// Only a symlink this tool wrote is replaced, and only because it has no content:
|
|
@@ -495,6 +523,25 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
495
523
|
const drift = await manifestDrift(root, stack, await provenanceDigest());
|
|
496
524
|
if (drift) findings.push(drift);
|
|
497
525
|
}
|
|
526
|
+
if (flags.frozen) findings.push(...(await frozenFindings(root, flags)));
|
|
527
|
+
// The checkers a selected rule declared (ADR-088). They read the rule source,
|
|
528
|
+
// not the folded region, so a line that left the prompt is still the line that runs.
|
|
529
|
+
const fp = await fingerprint(root);
|
|
530
|
+
const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
|
|
531
|
+
findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
|
|
532
|
+
|
|
533
|
+
const decisionsDir = join(root, 'docs', 'decisions');
|
|
534
|
+
const decisionLint = await lintDecisions(decisionsDir, {
|
|
535
|
+
allowHistorical: resolve(decisionsDir) === DECISIONS_DIR,
|
|
536
|
+
});
|
|
537
|
+
findings.push(
|
|
538
|
+
...decisionLint.findings.map((f) => ({
|
|
539
|
+
file: ['docs', 'decisions', f.file].join('/'),
|
|
540
|
+
status: f.status,
|
|
541
|
+
detail: f.detail,
|
|
542
|
+
remedy: f.remedy,
|
|
543
|
+
})),
|
|
544
|
+
);
|
|
498
545
|
|
|
499
546
|
// `CHECK_NON_BLOCKING`, not the union. This is the gate whose exit code a repository
|
|
500
547
|
// acts on, so a finding it reports and does not fail on is a decision recorded in a
|
|
@@ -526,4 +573,41 @@ export async function checkCommand(positional, flags) { const root = flags.root
|
|
|
526
573
|
return bad.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
527
574
|
}
|
|
528
575
|
|
|
529
|
-
|
|
576
|
+
/**
|
|
577
|
+
* What `--frozen` adds to the gate. Nothing, when the lock matches. One finding
|
|
578
|
+
* otherwise, and the wording says which of the two failures it was (ADR-086).
|
|
579
|
+
*/
|
|
580
|
+
async function frozenFindings(root, flags) {
|
|
581
|
+
const { lock, error } = await readLock(root);
|
|
582
|
+
if (error) {
|
|
583
|
+
return [{ file: LOCK_FILE, status: 'lock-unreadable', detail: error, remedy: `run: ${CLI} sync --yes` }];
|
|
584
|
+
}
|
|
585
|
+
if (lock === null) {
|
|
586
|
+
return [{
|
|
587
|
+
file: LOCK_FILE,
|
|
588
|
+
status: 'lock-absent',
|
|
589
|
+
detail: 'no lock is recorded',
|
|
590
|
+
remedy: `run: ${CLI} sync --yes`,
|
|
591
|
+
}];
|
|
592
|
+
}
|
|
593
|
+
const next = await computeLock(root, flags);
|
|
594
|
+
if (next.inputs !== lock.inputs) {
|
|
595
|
+
return [{
|
|
596
|
+
file: LOCK_FILE,
|
|
597
|
+
status: 'lock-stale',
|
|
598
|
+
detail: 'the inputs changed and the lock was not rewritten',
|
|
599
|
+
remedy: `run: ${CLI} sync --yes`,
|
|
600
|
+
}];
|
|
601
|
+
}
|
|
602
|
+
if (next.outputs !== lock.outputs) {
|
|
603
|
+
return [{
|
|
604
|
+
file: LOCK_FILE,
|
|
605
|
+
status: 'lock-unexplained',
|
|
606
|
+
detail: 'the projection changed and the inputs did not',
|
|
607
|
+
remedy: `run: ${CLI} sync --yes`,
|
|
608
|
+
}];
|
|
609
|
+
}
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export { plan, checkTargets, checkTarget, select, frozenFindings };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `simt tests [path]` lists the tests that exercise a file (R24, ADR-092).
|
|
2
|
+
//
|
|
3
|
+
// The index already stores imports, but a test import that spans lines is not
|
|
4
|
+
// one of them. Test files are read back from their blobs, once, in a single
|
|
5
|
+
// batch. Coverage is read only from a fixed list of paths. History is the last
|
|
6
|
+
// 200 non-merge commits, which is a map and not the whole changelog.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
+
import { CLI } from '../name.mjs';
|
|
15
|
+
import { isTestPath } from '../bench/mine.mjs';
|
|
16
|
+
import { blobTexts, langOf, readEdges, readFiles, reindex } from '../index/store.mjs';
|
|
17
|
+
import { HISTORY, importsOf, parseCoverage, testEdges, testsFor } from '../index/tests.mjs';
|
|
18
|
+
|
|
19
|
+
const exec = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
const COVERAGE_PATHS = [
|
|
22
|
+
'coverage/lcov.info',
|
|
23
|
+
'lcov.info',
|
|
24
|
+
'coverage/coverage-final.json',
|
|
25
|
+
'coverage.json',
|
|
26
|
+
'jacoco.xml',
|
|
27
|
+
'build/reports/jacoco/test/jacocoTestReport.xml',
|
|
28
|
+
'coverage.out',
|
|
29
|
+
'cover.out',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export async function testsCommand(args, flags) {
|
|
33
|
+
if (args.length > 1) return fail(`unknown tests argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} tests [path]`);
|
|
34
|
+
const root = flags.root ?? process.cwd();
|
|
35
|
+
const loaded = await loadFiles(root);
|
|
36
|
+
if (loaded.error) return loaded.error;
|
|
37
|
+
const { files } = loaded;
|
|
38
|
+
const [stored, texts, coverage, commits] = await Promise.all([
|
|
39
|
+
readEdges(root),
|
|
40
|
+
testBlobs(root, files),
|
|
41
|
+
coverageTexts(root),
|
|
42
|
+
history(root),
|
|
43
|
+
]);
|
|
44
|
+
const paths = new Set(files.map((file) => file.path));
|
|
45
|
+
const imports = stored.filter((edge) => edge.kind === 'import');
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (!isTestPath(file.path)) continue;
|
|
48
|
+
const text = texts.get(file.blob);
|
|
49
|
+
if (!text) continue;
|
|
50
|
+
imports.push(...importsOf(file.path, text, langOf(file.path), paths));
|
|
51
|
+
}
|
|
52
|
+
const edges = testEdges({ files, imports, coverage, commits });
|
|
53
|
+
const target = args[0];
|
|
54
|
+
if (!target) return summarise(root, flags, edges);
|
|
55
|
+
if (!paths.has(target)) {
|
|
56
|
+
return fail(`not in the index: ${target}`, EXIT.USAGE, `pass a path ${CLI} index knows`);
|
|
57
|
+
}
|
|
58
|
+
const tests = testsFor(target, edges, { cochange: flags.cochange === true });
|
|
59
|
+
const payload = { ok: true, command: 'tests', root, path: target, tests };
|
|
60
|
+
if (isJson(flags)) {
|
|
61
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
62
|
+
return EXIT.OK;
|
|
63
|
+
}
|
|
64
|
+
const lines = [`tests ${tests.length}`, ...tests];
|
|
65
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
66
|
+
return EXIT.OK;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summarise(root, flags, edges) {
|
|
70
|
+
let test = 0;
|
|
71
|
+
let cochange = 0;
|
|
72
|
+
for (const edge of edges) {
|
|
73
|
+
if (edge.kind === 'test') test += 1;
|
|
74
|
+
else if (edge.kind === 'cochange') cochange += 1;
|
|
75
|
+
}
|
|
76
|
+
const payload = { ok: true, command: 'tests', root, test, cochange };
|
|
77
|
+
if (isJson(flags)) {
|
|
78
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
79
|
+
return EXIT.OK;
|
|
80
|
+
}
|
|
81
|
+
emit(`test ${test}\ncochange ${cochange}`, { maxBytes: flags.maxBytes ?? 0 });
|
|
82
|
+
return EXIT.OK;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function loadFiles(root) {
|
|
86
|
+
let files = await readFiles(root);
|
|
87
|
+
if (files.length === 0) {
|
|
88
|
+
try {
|
|
89
|
+
await reindex(root);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
error: fail(
|
|
93
|
+
`could not map tests in this repository (${error.message})`,
|
|
94
|
+
EXIT.UNSUPPORTED,
|
|
95
|
+
'run this inside a git repository',
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
files = await readFiles(root);
|
|
100
|
+
}
|
|
101
|
+
return { files };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function testBlobs(root, files) {
|
|
105
|
+
const blobs = [];
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (isTestPath(file.path) && file.blob) blobs.push(file.blob);
|
|
108
|
+
}
|
|
109
|
+
return blobTexts(root, blobs).catch(() => new Map());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function coverageTexts(root) {
|
|
113
|
+
const texts = [];
|
|
114
|
+
for (const path of COVERAGE_PATHS) {
|
|
115
|
+
try {
|
|
116
|
+
const text = await readFile(join(root, path), 'utf8');
|
|
117
|
+
if (parseCoverage(text).length > 0 || text.trim() !== '') texts.push(text);
|
|
118
|
+
} catch {
|
|
119
|
+
// Absent coverage is the usual case. It is not a failed map.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return texts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function history(root) {
|
|
126
|
+
let stdout = '';
|
|
127
|
+
try {
|
|
128
|
+
({ stdout } = await exec('git', ['log', '-n', String(HISTORY), '--no-merges', '--name-only', '--pretty=format:%x1e'], {
|
|
129
|
+
cwd: root,
|
|
130
|
+
maxBuffer: 16 << 20,
|
|
131
|
+
}));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const commits = [];
|
|
136
|
+
for (const block of stdout.split('\x1e')) {
|
|
137
|
+
const paths = [];
|
|
138
|
+
for (const line of block.split('\n')) {
|
|
139
|
+
const path = line.trim();
|
|
140
|
+
if (path) paths.push(path);
|
|
141
|
+
}
|
|
142
|
+
if (paths.length > 0) commits.push(paths);
|
|
143
|
+
}
|
|
144
|
+
return commits;
|
|
145
|
+
}
|
package/src/commands/tokens.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
totals,
|
|
35
35
|
} from '../measure/context.mjs';
|
|
36
36
|
import { measureSession } from '../measure/session.mjs';
|
|
37
|
+
import { sharedTotals } from '../projection/shared.mjs';
|
|
37
38
|
|
|
38
39
|
/** `1234567`, never `1,234,567`. `toLocaleString` output depends on the locale, and
|
|
39
40
|
* this command exists so two machines produce two numbers a pull request can diff. */
|
|
@@ -109,7 +110,17 @@ export async function tokensCommand(positional, flags) {
|
|
|
109
110
|
for (const path of roots) rows.push(await measureRoot(path));
|
|
110
111
|
annotate(rows);
|
|
111
112
|
|
|
112
|
-
const
|
|
113
|
+
const measured = totals(rows);
|
|
114
|
+
// The workspace copy, counted once (ADR-084). A figure that measured the regions and
|
|
115
|
+
// ignored the file the rules moved into would show a saving the reader's context never
|
|
116
|
+
// saw, so it is in the total from the first run rather than as an afterthought.
|
|
117
|
+
const shared = await sharedTotals(rows);
|
|
118
|
+
const summary = {
|
|
119
|
+
...measured,
|
|
120
|
+
sharedBytes: shared.bytes,
|
|
121
|
+
estateBytes: measured.generatedBytes + shared.bytes,
|
|
122
|
+
sharedFiles: shared.files,
|
|
123
|
+
};
|
|
113
124
|
const mcp = mcpTools();
|
|
114
125
|
|
|
115
126
|
if (isJson(flags)) {
|
|
@@ -142,6 +153,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
142
153
|
lines.push(
|
|
143
154
|
`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
155
|
);
|
|
156
|
+
// Only when there is a copy to count. The line a reader sees most is the one that
|
|
157
|
+
// says what their whole estate loads, and adding a second number to it that is
|
|
158
|
+
// usually zero is how a report stops being read.
|
|
159
|
+
if (summary.sharedBytes > 0) {
|
|
160
|
+
lines.push(
|
|
161
|
+
`shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) estate total ${plain(summary.estateBytes)} B`,
|
|
162
|
+
);
|
|
163
|
+
for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
|
|
164
|
+
}
|
|
145
165
|
} else {
|
|
146
166
|
const row = rows[0];
|
|
147
167
|
lines.push(`tokens ${row.path}`);
|
|
@@ -155,6 +175,15 @@ export async function tokensCommand(positional, flags) {
|
|
|
155
175
|
lines.push(
|
|
156
176
|
`always-on ${plain(row.generatedBytes)} B generated ~${plain(row.generatedTokens)} tokens budget ${plain(row.budget.bytes)} B ${verdict}`,
|
|
157
177
|
);
|
|
178
|
+
// The same reason as the `--repos` line, and it matters more here: measuring one
|
|
179
|
+
// repository is the default, so a reader who owns the workspace copy and never saw it
|
|
180
|
+
// counted would read a saving their own session does not get.
|
|
181
|
+
if (summary.sharedBytes > 0) {
|
|
182
|
+
lines.push(
|
|
183
|
+
`shared ${plain(summary.sharedBytes)} B carried once in ${plain(summary.sharedFiles.length)} user-level file(s) always-on for you: ${plain(summary.estateBytes)} B`,
|
|
184
|
+
);
|
|
185
|
+
for (const f of summary.sharedFiles) lines.push(` ${f.path} ${plain(f.bytes)} B ${plain(f.rules)} rule(s)`);
|
|
186
|
+
}
|
|
158
187
|
lines.push(
|
|
159
188
|
`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
189
|
);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// `simt why [path]` lists what governs a file (R25, ADR-093).
|
|
2
|
+
//
|
|
3
|
+
// With a path, one line per decision, rule, skill, gate and lesson. With no
|
|
4
|
+
// path, the Affects tokens that name nothing this tree contains. The file list
|
|
5
|
+
// is `git ls-files`. The index is not rebuilt: governance is the records, and
|
|
6
|
+
// writing the cache to answer a question would change the tree.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
15
|
+
import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
|
|
16
|
+
import {
|
|
17
|
+
decisionHits,
|
|
18
|
+
scopeHits,
|
|
19
|
+
scopePaths,
|
|
20
|
+
sortHits,
|
|
21
|
+
sourceRoots,
|
|
22
|
+
unresolvedAffects,
|
|
23
|
+
} from '../index/govern.mjs';
|
|
24
|
+
import { splitFrontmatter } from '../memory/decide.mjs';
|
|
25
|
+
import { listPromotedLessons } from '../memory/lessons.mjs';
|
|
26
|
+
|
|
27
|
+
const exec = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt-dev/profiles'];
|
|
30
|
+
|
|
31
|
+
export async function whyCommand(args, flags, commands = []) {
|
|
32
|
+
if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
|
|
33
|
+
const root = flags.root ?? process.cwd();
|
|
34
|
+
const listed = await tracked(root);
|
|
35
|
+
if (listed.error) return listed.error;
|
|
36
|
+
const files = new Set(listed.files);
|
|
37
|
+
const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
|
|
38
|
+
const records = await decisionRecords(root);
|
|
39
|
+
const target = args[0];
|
|
40
|
+
if (!target) return reportUnresolved(root, flags, unresolvedAffects(records, ctx));
|
|
41
|
+
if (!files.has(target)) return fail(`not in the tree: ${target}`, EXIT.USAGE, `pass a path git tracks`);
|
|
42
|
+
const corpus = await loadCorpus(root);
|
|
43
|
+
const { hits } = decisionHits(records, target, ctx);
|
|
44
|
+
const items = sortHits([...hits, ...scopeHits(target, corpus)]);
|
|
45
|
+
const payload = { ok: true, command: 'why', root, path: target, items };
|
|
46
|
+
if (isJson(flags)) {
|
|
47
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
48
|
+
return EXIT.OK;
|
|
49
|
+
}
|
|
50
|
+
const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
|
|
51
|
+
emit(lines.length > 0 ? lines.join('\n') : 'none', { maxBytes: flags.maxBytes ?? 0 });
|
|
52
|
+
return EXIT.OK;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function reportUnresolved(root, flags, unresolved) {
|
|
56
|
+
const payload = { ok: true, command: 'why', root, unresolved };
|
|
57
|
+
if (isJson(flags)) {
|
|
58
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
59
|
+
return EXIT.OK;
|
|
60
|
+
}
|
|
61
|
+
const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
|
|
62
|
+
emit([`unresolved ${unresolved.length}`, ...lines].join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
63
|
+
return EXIT.OK;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function tracked(root) {
|
|
67
|
+
try {
|
|
68
|
+
const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
|
|
69
|
+
return { files: stdout.split('\n').filter(Boolean) };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
error: fail(
|
|
73
|
+
`could not read what governs files here (${error.message})`,
|
|
74
|
+
EXIT.UNSUPPORTED,
|
|
75
|
+
'run this inside a git repository',
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function decisionRecords(root) {
|
|
82
|
+
const dir = join(root, 'docs', 'decisions');
|
|
83
|
+
let names = [];
|
|
84
|
+
try {
|
|
85
|
+
names = (await readdir(dir)).filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name)).sort();
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const records = [];
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
92
|
+
const { body } = splitFrontmatter(text);
|
|
93
|
+
const id = /^ADR-\d{3,}/i.exec(name)[0].toUpperCase();
|
|
94
|
+
const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
|
|
95
|
+
const title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim();
|
|
96
|
+
records.push({ id, title, text });
|
|
97
|
+
}
|
|
98
|
+
return records;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function loadCorpus(root) {
|
|
102
|
+
const [rules, skills, lessons, commands] = await Promise.all([
|
|
103
|
+
ruleScopes(root),
|
|
104
|
+
skillScopes(root),
|
|
105
|
+
lessonScopes(root),
|
|
106
|
+
gateCommands(root),
|
|
107
|
+
]);
|
|
108
|
+
const gates = Object.values(GATES).map((gate) => ({
|
|
109
|
+
id: gate.name,
|
|
110
|
+
detail: gate.why,
|
|
111
|
+
extensions: gate.kind === 'staged' ? FORMATTABLE : null,
|
|
112
|
+
}));
|
|
113
|
+
return { rules, skills, gates, lessons, commands };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function ruleScopes(root) {
|
|
117
|
+
const rules = [];
|
|
118
|
+
for (const dir of RULE_DIRS) {
|
|
119
|
+
let names = [];
|
|
120
|
+
try {
|
|
121
|
+
names = await readdir(join(root, dir));
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const name of names.sort()) {
|
|
126
|
+
if (!name.endsWith('.md') && !name.endsWith('.mdc')) continue;
|
|
127
|
+
const text = await readFile(join(root, dir, name), 'utf8');
|
|
128
|
+
const paths = scopePaths(text);
|
|
129
|
+
if (paths.length === 0) continue;
|
|
130
|
+
const id = /^simt-id:\s*(\S+)/m.exec(text)?.[1] ?? name;
|
|
131
|
+
const detail = /^simt-title:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? id;
|
|
132
|
+
rules.push({ id, detail, paths });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return rules;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function skillScopes(root) {
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(await readFile(join(root, 'graph', 'skills.graph.json'), 'utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(parsed?.skills)) return [];
|
|
146
|
+
return parsed.skills
|
|
147
|
+
.filter((skill) => Array.isArray(skill.paths) && skill.paths.length > 0)
|
|
148
|
+
.map((skill) => ({ id: String(skill.name ?? ''), detail: String(skill.summary ?? ''), paths: skill.paths }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function lessonScopes(root) {
|
|
152
|
+
return (await listPromotedLessons(root))
|
|
153
|
+
.filter((lesson) => lesson.paths.length > 0)
|
|
154
|
+
.map((lesson) => ({
|
|
155
|
+
id: lesson.id,
|
|
156
|
+
detail: lesson.title,
|
|
157
|
+
paragraph: lesson.paragraph,
|
|
158
|
+
paths: lesson.paths,
|
|
159
|
+
signature: lesson.signature,
|
|
160
|
+
keywords: lesson.keywords,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function gateCommands(root) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(await readFile(join(root, MANIFEST_FILE), 'utf8'));
|
|
167
|
+
const commands = parsed?.gates?.commands;
|
|
168
|
+
return commands && typeof commands === 'object' ? commands : {};
|
|
169
|
+
} catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|