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.
Files changed (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -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
- export const CHECK_NON_BLOCKING = new Set(['orphaned']);
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
- export async function checkCommand(positional, flags) {
403
- const root = flags.root ?? positional[0] ?? process.cwd();
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
+ }
@@ -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
- export async function buildRow(path, context) {
59
- const name = path.split('/').filter(Boolean).pop() ?? path;
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])),
@@ -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
- writtenAgainst: [...new Set(text.match(ESTATE) ?? [])].sort(),
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'), 'a repository');
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} `);