simt-dev 0.2.1 → 0.2.2

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 (61) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  3. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  4. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  5. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  6. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  7. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  8. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  9. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  10. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  11. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  12. package/package.json +1 -1
  13. package/rules/core.md +1 -0
  14. package/rules/stacks/android.md +2 -0
  15. package/rules/stacks/cloudflare-workers.md +2 -0
  16. package/rules/stacks/connectiq.md +1 -0
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/dart.md +2 -0
  19. package/rules/stacks/deno.md +2 -0
  20. package/rules/stacks/dotnet.md +2 -0
  21. package/rules/stacks/elixir.md +2 -0
  22. package/rules/stacks/go.md +2 -0
  23. package/rules/stacks/jvm.md +2 -0
  24. package/rules/stacks/node.md +2 -0
  25. package/rules/stacks/python.md +2 -0
  26. package/rules/stacks/react-native.md +2 -0
  27. package/rules/stacks/react-web.md +2 -0
  28. package/rules/stacks/ruby.md +2 -0
  29. package/rules/stacks/rust.md +2 -0
  30. package/rules/stacks/swift.md +2 -0
  31. package/rules/stacks/typescript.md +2 -0
  32. package/rules/stacks/uv.md +2 -0
  33. package/src/argv.mjs +19 -0
  34. package/src/commands/compare.mjs +15 -0
  35. package/src/commands/graph.mjs +45 -21
  36. package/src/commands/harness.mjs +150 -0
  37. package/src/commands/help.mjs +15 -1
  38. package/src/commands/index.mjs +43 -25
  39. package/src/commands/init.mjs +12 -8
  40. package/src/commands/mcp.mjs +20 -5
  41. package/src/commands/rules.mjs +42 -57
  42. package/src/commands/skills.mjs +67 -92
  43. package/src/commands/skillsinstall.mjs +17 -2
  44. package/src/commands/sync.mjs +62 -6
  45. package/src/commands/tokens.mjs +138 -0
  46. package/src/compare/row.mjs +73 -2
  47. package/src/graph/spec.mjs +227 -0
  48. package/src/graph/speccheck.mjs +68 -0
  49. package/src/hooks/dispatcher.mjs +35 -3
  50. package/src/mcp/schema.mjs +139 -0
  51. package/src/mcp/server.mjs +197 -20
  52. package/src/mcp/tools.mjs +288 -1
  53. package/src/measure/context.mjs +371 -0
  54. package/src/monorepo.mjs +339 -0
  55. package/src/output/index.mjs +78 -2
  56. package/src/packs.mjs +182 -0
  57. package/src/projection/manifest.mjs +88 -0
  58. package/src/projection/project.mjs +140 -9
  59. package/src/projection/rules.mjs +288 -20
  60. package/src/projection/surfaces.mjs +133 -15
  61. package/src/projection/targets.mjs +37 -0
@@ -2,6 +2,8 @@
2
2
  simt-id: dart
3
3
  simt-title: Dart
4
4
  simt-applies: [has-pubspec]
5
+ simt-priority: convention
6
+ simt-scope: ["pubspec.yaml", "**/*.dart"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Dart
@@ -2,6 +2,8 @@
2
2
  simt-id: deno
3
3
  simt-title: Deno
4
4
  simt-applies: [has-deno-config]
5
+ simt-priority: convention
6
+ simt-scope: ["deno.json", "deno.jsonc", "**/*.ts"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Deno
@@ -2,6 +2,8 @@
2
2
  simt-id: dotnet
3
3
  simt-title: .NET
4
4
  simt-applies: [has-nuget]
5
+ simt-priority: convention
6
+ simt-scope: ["*.csproj", "*.sln", "*.fsproj"]
5
7
  ---
6
8
 
7
9
  # Stack profile: .NET
@@ -2,6 +2,8 @@
2
2
  simt-id: elixir
3
3
  simt-title: Elixir
4
4
  simt-applies: [has-mix]
5
+ simt-priority: convention
6
+ simt-scope: ["mix.exs", "lib/**/*.ex"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Elixir
@@ -2,6 +2,8 @@
2
2
  simt-id: go
3
3
  simt-title: Go
4
4
  simt-applies: [has-go-mod]
5
+ simt-priority: convention
6
+ simt-scope: ["go.mod", "**/*.go"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Go
@@ -6,6 +6,8 @@ simt-title: JVM backend
6
6
  # or Ktor service received rules about Compose, Wear OS application ids and
7
7
  # instrumentation tests — advice with nothing to apply it to (S28, ADR-047).
8
8
  simt-applies: [gradle, has-maven-pom, kotlin, java]
9
+ simt-priority: convention
10
+ simt-scope: ["build.gradle", "build.gradle.kts", "pom.xml", "settings.gradle", "settings.gradle.kts"]
9
11
  ---
10
12
 
11
13
  # Stack profile: JVM backend
@@ -2,6 +2,8 @@
2
2
  simt-id: node
3
3
  simt-title: Node (general)
4
4
  simt-applies: [node]
5
+ simt-priority: convention
6
+ simt-scope: ["package.json", "tsconfig.json", "jsconfig.json", ".nvmrc"]
5
7
  simt-fallback: true
6
8
  ---
7
9
 
@@ -2,6 +2,8 @@
2
2
  simt-id: python
3
3
  simt-title: Python
4
4
  simt-applies: [python]
5
+ simt-priority: convention
6
+ simt-scope: ["pyproject.toml", "requirements.txt", "setup.py", "*.py"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Python
@@ -6,6 +6,8 @@ simt-title: React Native
6
6
  # CSS logical properties, a browser matrix, WCAG reflow at 400 % — none of which a
7
7
  # native surface has (S28, ADR-047).
8
8
  simt-applies: [react-native]
9
+ simt-priority: convention
10
+ simt-scope: ["app.json", "metro.config.js", "ios/**", "android/**"]
9
11
  ---
10
12
 
11
13
  # Stack profile: React Native
@@ -6,6 +6,8 @@ simt-title: React web
6
6
  # listing the bare language tags (`typescript`, `vitest`, `node-web`, `less`) put
7
7
  # this profile on backend and static repositories that share them (S28, ADR-047).
8
8
  simt-applies: [react-dom, react-router, maplibre, firebase]
9
+ simt-priority: convention
10
+ simt-scope: ["src/**/*.{ts,tsx,js,jsx}", "index.html"]
9
11
  ---
10
12
 
11
13
  # Stack profile: React web
@@ -2,6 +2,8 @@
2
2
  simt-id: ruby
3
3
  simt-title: Ruby
4
4
  simt-applies: [has-gemfile]
5
+ simt-priority: convention
6
+ simt-scope: ["Gemfile", "**/*.rb"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Ruby
@@ -2,6 +2,8 @@
2
2
  simt-id: rust
3
3
  simt-title: Rust
4
4
  simt-applies: [has-cargo-toml]
5
+ simt-priority: convention
6
+ simt-scope: ["Cargo.toml", "src/**/*.rs"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Rust
@@ -2,6 +2,8 @@
2
2
  simt-id: swift
3
3
  simt-title: Swift
4
4
  simt-applies: [has-swift-package]
5
+ simt-priority: convention
6
+ simt-scope: ["Package.swift", "**/*.swift"]
5
7
  ---
6
8
 
7
9
  # Stack profile: Swift
@@ -7,6 +7,8 @@ simt-title: TypeScript
7
7
  # repositories are selected for. Two fallbacks both apply when nothing more specific
8
8
  # matched.
9
9
  simt-applies: [typescript]
10
+ simt-priority: convention
11
+ simt-scope: ["tsconfig.json", "*.ts", "*.tsx"]
10
12
  simt-fallback: true
11
13
  ---
12
14
 
@@ -5,6 +5,8 @@ simt-title: uv
5
5
  # `uv.lock` in its body while selecting on any Python marker, so a Poetry project was
6
6
  # told to run a tool manager it does not use (S28, ADR-047).
7
7
  simt-applies: [has-uv-lock]
8
+ simt-priority: convention
9
+ simt-scope: ["uv.lock", "pyproject.toml"]
8
10
  ---
9
11
 
10
12
  # Stack profile: uv
package/src/argv.mjs CHANGED
@@ -34,9 +34,23 @@ export const BOOL_FLAGS = new Set([
34
34
  'plan',
35
35
  'help',
36
36
  'version',
37
+ // JSON output: indent it. Off by default because a pipe is not a person, and the
38
+ // bytes an agent pays for indentation are bytes it never reads.
39
+ 'pretty',
37
40
  'accept-conflicts',
38
41
  'backup',
39
42
  'list',
43
+ // tokens: measure every repository under the root, not just the one. Plan 0003 R06
44
+ // names this flag `--workspace`, and that name has been a VALUE flag since plan
45
+ // 0002 - it is where the skill sources live. One flag with two meanings is the
46
+ // silent-ignore defect this file's header refuses, so the meter's switch is named
47
+ // for what it selects.
48
+ 'repos',
49
+ // harness detect: also report the directories in the user's home. Off by default
50
+ // because the home directory belongs to the reader rather than the repository, and a
51
+ // command whose output changes because something was installed elsewhere is one a
52
+ // reader stops trusting.
53
+ 'user',
40
54
  ]);
41
55
 
42
56
  export const VALUE_FLAGS = new Set([
@@ -52,6 +66,11 @@ export const VALUE_FLAGS = new Set([
52
66
  'variant',
53
67
  'from',
54
68
  'surfaces',
69
+ // graph lint: which specification to check the skills against. A value rather than a
70
+ // boolean because "lint against the spec" would not say WHICH spec, and a lint that
71
+ // silently applied an unnamed set of rules is the decoration this repository exists
72
+ // to refuse. One is on offer today, so the command rejects any other by name.
73
+ 'spec',
55
74
  ]);
56
75
 
57
76
  /**
@@ -98,6 +98,8 @@ export async function compareCommand(positional, flags) {
98
98
  noRegion: summary.noRegion,
99
99
  malformed: summary.malformed,
100
100
  staleRuleFiles: summary.staleRuleFiles,
101
+ packageRegions: summary.packageRegions,
102
+ stalePackages: summary.stalePackages,
101
103
  hooksRouted: summary.hooksRouted,
102
104
  withProblems: summary.withProblems,
103
105
  withOwnHooks: summary.withOwnHooks,
@@ -155,6 +157,14 @@ function render(rows, summary, found) {
155
157
  `repos ${summary.repos} (${summary.adopted} adopted, ${summary.unmanaged} unmanaged${summary.broken > 0 ? `, ${summary.broken} broken` : ''})`,
156
158
  `region ${summary.current} current, ${summary.drifted} drifted, ${summary.noRegion} with no region, ${summary.malformed} malformed`,
157
159
  `rules ${summary.staleRuleFiles === 0 ? 'every rule file current' : `${summary.staleRuleFiles} rule file${summary.staleRuleFiles === 1 ? '' : 's'} not current`}`,
160
+ // One line, and only when there is a package region to report. A workspace of
161
+ // single-repository members is the common case and must not grow a line that
162
+ // always reads "none".
163
+ ...(summary.packageRegions > 0
164
+ ? [
165
+ `packages ${summary.stalePackages === 0 ? `every package region current (${summary.packageRegions})` : `${summary.stalePackages} of ${summary.packageRegions} package region${summary.packageRegions === 1 ? '' : 's'} not current`}`,
166
+ ]
167
+ : []),
158
168
  `hooks ${summary.hooksRouted}/${summary.adopted} adopted repos route the shared dispatcher`,
159
169
  ` ${summary.withOwnHooks} repo${summary.withOwnHooks === 1 ? '' : 's'} run their own hooks; adopting replaces them (a note, not a problem)`,
160
170
  `problems ${summary.withProblems} repo${summary.withProblems === 1 ? '' : 's'}`,
@@ -167,6 +177,11 @@ function render(rows, summary, found) {
167
177
  lines.push(` adoption ${r.adoption}${r.id && r.id !== r.name ? ` (id ${r.id})` : ''}`);
168
178
  lines.push(` stack ${r.stack.length > 0 ? r.stack.join(', ') : '(none)'}${r.declaredStack.length > 0 ? ` (manifest declares ${r.declaredStack.join(', ')})` : ''}`);
169
179
  if (r.facts.length > 0) lines.push(` facts ${r.facts.join(', ')}`);
180
+ // Per package, and only for a repository that has packages: a single-repository
181
+ // member must not print an empty list of packages on every line of every run.
182
+ for (const p of r.packages) {
183
+ lines.push(` package ${p.path} ${p.status}${p.profiles.length > 0 ? ` (${p.profiles.join(', ')})` : ''}`);
184
+ }
170
185
  lines.push(` region ${r.region}${r.regionDetail ? ` (${r.regionDetail})` : ''}`);
171
186
  // The rule files are the answer to "does every editor see the same rule?".
172
187
  lines.push(
@@ -6,11 +6,18 @@
6
6
  // Output is line-oriented and bounded (plan 0002 section 6). A lint failure names
7
7
  // the offending node, the file it disagrees with, and both numbers.
8
8
 
9
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
9
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
10
10
  import { loadRegistry } from '../graph/load.mjs';
11
+ import { lintSpec } from '../graph/speccheck.mjs';
12
+ import { SPEC, SPEC_SOURCE } from '../graph/spec.mjs';
11
13
  import { CLI } from '../name.mjs';
12
14
 
13
15
  export async function lintCommand(positional, flags) {
16
+ const spec = flags.spec ?? null;
17
+ if (spec !== null && spec !== SPEC) {
18
+ return fail(`unknown --spec "${spec}"`, EXIT.USAGE, `the one specification this lints is: ${SPEC}`);
19
+ }
20
+
14
21
  // The resolved path comes back from the loader, because when no positional is given it
15
22
  // falls back to the member that holds the registry in this workspace (ADR-061) - and the
16
23
  // reader should see where it actually looked, not a constant that no longer exists.
@@ -19,27 +26,38 @@ export async function lintCommand(positional, flags) {
19
26
  flags,
20
27
  );
21
28
 
29
+ // The conformance pass is separate from the registry pass on purpose. The registry
30
+ // answers "does the bookkeeping match the files"; the specification answers "would a
31
+ // client accept these files", and the second question needs no registry at all. A
32
+ // reader with no registry can still lint a skill directory, so the two are additive
33
+ // rather than one gating the other.
34
+ const specReport = registry && workspace?.root
35
+ ? await lintSpec(registry, workspace.root)
36
+ : { errors: [], warnings: [], checked: 0 };
37
+ errors.push(...specReport.errors);
38
+ warnings.push(...specReport.warnings);
39
+
22
40
  if (isJson(flags)) {
23
- emit(
24
- `${JSON.stringify(
25
- {
26
- ok,
27
- registry: path,
28
- capabilities: registry?.capabilities?.length ?? 0,
29
- skills: registry?.skills?.length ?? 0,
30
- // `verified: false` means the byte counts in the registry were NOT
31
- // checked against files on disk. It is not a failure - it is the difference
32
- // between "the registry is right" and "the registry could not be checked",
33
- // and an agent that cannot see the difference will trust a number nobody
34
- // measured (ADR-014).
35
- verified,
36
- workspace,
37
- errors,
38
- warnings,
39
- },
40
- null,
41
- 2,
42
- )}\n`,
41
+ emitJson(
42
+ {
43
+ ok,
44
+ registry: path,
45
+ capabilities: registry?.capabilities?.length ?? 0,
46
+ skills: registry?.skills?.length ?? 0,
47
+ // `verified: false` means the byte counts in the registry were NOT
48
+ // checked against files on disk. It is not a failure - it is the difference
49
+ // between "the registry is right" and "the registry could not be checked",
50
+ // and an agent that cannot see the difference will trust a number nobody
51
+ // measured (ADR-014).
52
+ verified,
53
+ workspace,
54
+ // Present only when asked for, so a reader of the default output is not told
55
+ // about a pass that never ran. `null` is the honest value; `true` would be a
56
+ // claim about conformance nobody checked.
57
+ spec: spec === null ? null : { name: SPEC, source: SPEC_SOURCE, checked: specReport.checked },
58
+ errors,
59
+ warnings,
60
+ },
43
61
  { maxBytes: flags.maxBytes ?? 0 },
44
62
  );
45
63
  return ok ? EXIT.OK : EXIT.REGISTRY;
@@ -57,6 +75,12 @@ export async function lintCommand(positional, flags) {
57
75
  `nodes ${registry.skills.length} skills, ${registry.capabilities.length} capabilities`,
58
76
  `verified ${verified ? 'byte counts checked against disk' : 'NOT checked - see WARN below'}`,
59
77
  ];
78
+ if (spec !== null) {
79
+ lines.push(
80
+ `spec ${specReport.checked} skills checked against ${SPEC_SOURCE}` +
81
+ `${specReport.checked === 0 ? ' (none had a readable SKILL.md)' : ''}`,
82
+ );
83
+ }
60
84
 
61
85
  const deferred = registry.skills.filter((s) => s.deferred);
62
86
  lines.push(
@@ -0,0 +1,150 @@
1
+ // `simt harness detect` - which harnesses this repository (and this user) is set up
2
+ // for, and which of them simt-dev has a channel for.
3
+ //
4
+ // Why this is a command and not a sentence in the docs: the question "which harness
5
+ // reads my rules" has two halves that used to be answered in different places, and the
6
+ // second half was answered by a reader's guess. What is present on disk is observable
7
+ // and belongs to the tool; what a given harness actually loads is a vendor fact, and a
8
+ // vendor fact is only trustworthy when it names its source. So the table that says "we
9
+ // have a channel for this" and the table that says "we have not verified one" are kept
10
+ // apart, and this command reports both without collapsing them into a verdict.
11
+ //
12
+ // It exits 0 whatever it finds. A harness with no channel is a fact about coverage, not
13
+ // a defect in the repository: the rules in AGENTS.md still reach whichever harnesses
14
+ // read that file, and a reader who wanted a failing command could not tell the
15
+ // difference between "no channel" and "broken".
16
+
17
+ import { stat } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ import { EXIT, emit, emitJson, isJson, note } from '../output/index.mjs';
22
+ import { SURFACES, CANDIDATE_DIRS } from '../projection/surfaces.mjs';
23
+
24
+ /**
25
+ * `simt-dev harness detect`. The subcommand is validated by dispatch, which rejects a
26
+ * second token that is not in the group registry rather than silently routing to the
27
+ * default; this function therefore has exactly one thing to do and no second spelling
28
+ * to keep in step with it.
29
+ *
30
+ * @param {string[]} positional arguments after the command path
31
+ * @param {object} flags
32
+ * @returns {Promise<number>} process exit code
33
+ */
34
+ export async function harnessCommand(positional, flags) {
35
+ const root = flags.root ?? process.cwd();
36
+
37
+ const repo = await scan(root);
38
+ // Only on request. The home directory is the reader's, not the repository's, and a
39
+ // command that reports both by default is a command whose output changes because
40
+ // somebody installed something elsewhere.
41
+ const user = flags.user === true ? await scan(homedir()) : null;
42
+
43
+ if (isJson(flags)) {
44
+ emitJson({
45
+ ok: true,
46
+ command: 'harness detect',
47
+ root,
48
+ repo,
49
+ ...(user === null ? {} : { user }),
50
+ });
51
+ return EXIT.OK;
52
+ }
53
+
54
+ emit(render(root, repo, user));
55
+ if (repo.rows.length === 0) {
56
+ note(
57
+ `note no harness configuration directory found in ${root}. That is a repository ` +
58
+ 'nobody has set an agent up in - not a finding.',
59
+ );
60
+ }
61
+ const unclaimed = countUnclaimed(repo);
62
+ if (unclaimed > 0) {
63
+ note(
64
+ `note ${unclaimed} present configur${unclaimed === 1 ? 'ation has' : 'ations have'} ` +
65
+ 'no rule channel in this release. Each one is reported as present, not as ' +
66
+ 'unsupported: whether it reads AGENTS.md is a vendor question this tool does not ' +
67
+ 'answer without a source, and a guess here would be the reader\'s problem later.',
68
+ );
69
+ }
70
+ return EXIT.OK;
71
+ }
72
+
73
+ /**
74
+ * Every harness directory present under one root, sorted by directory name.
75
+ *
76
+ * A directory whose stat fails for any reason other than absence is treated as absent
77
+ * rather than raised: a permissions error on one candidate must not make the whole
78
+ * command fail, and the difference between "could not look" and "not there" is one
79
+ * note in the output rather than a second failure mode nobody asked for.
80
+ */
81
+ async function scan(root) {
82
+ const rows = [];
83
+ for (const s of SURFACES) {
84
+ for (const dir of s.configDirs ?? []) {
85
+ if (await present(root, dir)) {
86
+ rows.push(row(dir, s.label, { id: s.id, verified: s.verified ?? null, readsAgentsMd: s.readsAgentsMd, rule: s.rule }));
87
+ }
88
+ }
89
+ }
90
+ for (const c of CANDIDATE_DIRS) {
91
+ if (await present(root, c.dir)) rows.push(row(c.dir, c.harness, {}));
92
+ }
93
+ rows.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
94
+ return { root, rows };
95
+ }
96
+
97
+ async function present(root, dir) {
98
+ try {
99
+ return (await stat(join(root, dir))).isDirectory();
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /** One present directory, and what - if anything - this tool can project into it. */
106
+ function row(dir, label, surface) {
107
+ const id = surface.id ?? null;
108
+ return {
109
+ dir,
110
+ label,
111
+ surface: id,
112
+ // `channel: false` is the honest half of the answer: the directory exists and this
113
+ // release has no rule file to write there. Reported as its own field rather than
114
+ // left implicit, because "we found it and did nothing" and "we found nothing" look
115
+ // identical in a list of names.
116
+ channel: id !== null && surface.rule != null,
117
+ readsAgentsMd: surface.readsAgentsMd ?? null,
118
+ verified: surface.verified ?? null,
119
+ };
120
+ }
121
+
122
+ function countUnclaimed(scan) {
123
+ return scan.rows.filter((r) => r.surface === null).length;
124
+ }
125
+
126
+ /**
127
+ * One line per present directory, in the order the scanner found them.
128
+ *
129
+ * `surface` is printed when there is one, and `AGENTS.md` is named when the surface
130
+ * loads it - because the answer a reader actually wants is "do my rules reach this",
131
+ * and the channel column alone does not answer it.
132
+ */
133
+ function render(root, repo, user) {
134
+ const lines = [`repo ${root}`];
135
+ lines.push(...block(repo, ' '));
136
+ if (user !== null) {
137
+ lines.push(`user ${user.root}`);
138
+ lines.push(...block(user, ' '));
139
+ }
140
+ return `${lines.join('\n')}\n`;
141
+ }
142
+
143
+ function block(scan, pad) {
144
+ if (scan.rows.length === 0) return [`${pad}(none present)`];
145
+ return scan.rows.map((r) => {
146
+ const through = r.surface === null ? 'no rule channel in this release' : r.channel ? 'projected rule file' : 'AGENTS.md';
147
+ const agents = r.readsAgentsMd === 'all' ? ' (reads AGENTS.md)' : r.readsAgentsMd === 'some' ? ' (some readers read AGENTS.md)' : '';
148
+ return `${pad}${r.dir.padEnd(12)} ${r.label.padEnd(38)} ${through}${agents}`;
149
+ });
150
+ }
@@ -46,11 +46,20 @@ ${CLI} ci simt-dev's own .github files: create or refresh th
46
46
  ${CLI} compare org-wide matrix across every sibling repo
47
47
  (read-only; exits non-zero on defects, not on
48
48
  repos that have not adopted yet)
49
+ ${CLI} tokens [--repos] what a reader loads at session start, in bytes
50
+ (read-only; exits 0 whatever it finds, because a
51
+ measurement is not a verdict; --repos measures every
52
+ repository under the root and what repeats an earlier)
53
+ ${CLI} harness detect which harnesses this repo (and, with --user, you)
54
+ are set up for, and which have a rule channel here
55
+ (presence only: a directory found is not a claim about
56
+ what its owner reads; exits 0 whatever it finds)
49
57
  ${CLI} mcp the read-only surface as MCP tools over stdio
50
58
  (--list prints it without reading stdin; init, sync,
51
59
  upgrade and the installers stay CLI-only by design)`;
52
60
 
53
- const FLAGS = `--json structured output
61
+ const FLAGS = `--json structured output (compact; one document, one line)
62
+ --pretty indent --json for a human
54
63
  --max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
55
64
  --budget <n> context budget for skills brief (default 20480)
56
65
  --plan emit a fetch plan instead of inlining skill bodies
@@ -74,6 +83,11 @@ const FLAGS = `--json structured output
74
83
  --accept-conflicts init: adopt rules that collide with hand-written ones
75
84
  --backup init: tag the pre-adoption commit and branch onto
76
85
  simt-dev/adopt before writing (the undo path)
86
+ --spec <name> graph lint: also check every skill against a named
87
+ specification (one exists: agentskills)
88
+ --user harness detect: also report the directories in your home
89
+ --repos tokens: measure every repository under the root, not
90
+ just the one, and report what repeats an earlier one
77
91
  --help this text
78
92
  --version the version, and the SHA it was built from`;
79
93
 
@@ -6,7 +6,15 @@
6
6
  // succeeds. An agent that calls `simt-dev compare` on an unreleased build must get a
7
7
  // failure, not an empty result it will read as "no drift".
8
8
 
9
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
9
+ import {
10
+ EXIT,
11
+ emit,
12
+ emitJson,
13
+ fail,
14
+ isJson,
15
+ jsonFailMode,
16
+ jsonIndentDefault,
17
+ } from '../output/index.mjs';
10
18
  import { help } from './help.mjs';
11
19
  import { lintCommand } from './graph.mjs';
12
20
  import {
@@ -26,6 +34,8 @@ import { upgradeCommand } from './upgrade.mjs';
26
34
  import { mcpCommand } from './mcp.mjs';
27
35
  import { installCommand } from './skillsinstall.mjs';
28
36
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
37
+ import { tokensCommand } from './tokens.mjs';
38
+ import { harnessCommand } from './harness.mjs';
29
39
  import { parse } from '../argv.mjs';
30
40
  import { fingerprint } from '../resolve/fingerprint.mjs';
31
41
  import { resolveWorkspace } from '../workspace.mjs';
@@ -43,6 +53,7 @@ const GROUPS = new Map([
43
53
  ['graph', 'lint'],
44
54
  ['rules', 'list'],
45
55
  ['hooks', 'status'],
56
+ ['harness', 'detect'],
46
57
  ]);
47
58
 
48
59
  /**
@@ -58,7 +69,7 @@ const GROUPS = new Map([
58
69
  * Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
59
70
  * just correct: nothing in the map depends on the call.
60
71
  */
61
- const IMPLEMENTED = {
72
+ export const IMPLEMENTED = {
62
73
  doctor: runDoctor,
63
74
  'graph lint': lintCommand,
64
75
  'skills index': skillsIndex,
@@ -79,6 +90,8 @@ const IMPLEMENTED = {
79
90
  'hooks status': (args, flags) => hooksCommand('status', args, flags),
80
91
  'hooks run': (args, flags) => hooksCommand('run', args, flags),
81
92
  compare: compareCommand,
93
+ tokens: tokensCommand,
94
+ 'harness detect': harnessCommand,
82
95
  upgrade: upgradeCommand,
83
96
  mcp: mcpCommand,
84
97
  };
@@ -92,6 +105,17 @@ export const COMMANDS = Object.keys(IMPLEMENTED).sort();
92
105
  export async function dispatch(argv) {
93
106
  const { flags, positional } = parseOrExit(argv);
94
107
 
108
+ // The JSON shape is decided once, here, and every command inherits it. Threading a
109
+ // `--pretty` through twenty-five call sites would mean twenty-five places to forget,
110
+ // and the one that forgot would be the one a reader pipes into a script.
111
+ //
112
+ // Both halves are set before anything can fail, because a command that fails under
113
+ // `--json` has still produced a result: the reason it failed. Set here, an unknown
114
+ // command answers in the same shape a working one does, and the caller's parse
115
+ // never sees an empty document.
116
+ jsonIndentDefault(flags.pretty === true);
117
+ jsonFailMode(flags.json === true);
118
+
95
119
  // Help is shown only when explicitly asked for. An unknown command must fail with
96
120
  // a usage code, not print 40 lines of prose and exit 0 — an agent that mistyped
97
121
  // needs to be told so, not handed a wall of text it may misread as output.
@@ -191,29 +215,23 @@ async function runDoctor(positional, flags) {
191
215
  const code = failure ? EXIT.CONFIG : EXIT.OK;
192
216
 
193
217
  if (isJson(flags)) {
194
- emit(
195
- `${JSON.stringify(
196
- {
197
- ok: code === EXIT.OK,
198
- command: 'doctor',
199
- root,
200
- ...result,
201
- // The workspace answer is a fact about which directory the tool would
202
- // act on next (ADR-014). It is printed in the text report; leaving it
203
- // out of the JSON meant a machine-readable caller could not see it at
204
- // all, which is the one-question-two-answers defect again.
205
- workspace: {
206
- root: ws.root ?? null,
207
- source: ws.source,
208
- detail: ws.detail,
209
- error: ws.error ?? null,
210
- },
211
- ...(failure ?? {}),
212
- },
213
- null,
214
- 2,
215
- )}\n`,
216
- );
218
+ emitJson({
219
+ ok: code === EXIT.OK,
220
+ command: 'doctor',
221
+ root,
222
+ ...result,
223
+ // The workspace answer is a fact about which directory the tool would
224
+ // act on next (ADR-014). It is printed in the text report; leaving it
225
+ // out of the JSON meant a machine-readable caller could not see it at
226
+ // all, which is the one-question-two-answers defect again.
227
+ workspace: {
228
+ root: ws.root ?? null,
229
+ source: ws.source,
230
+ detail: ws.detail,
231
+ error: ws.error ?? null,
232
+ },
233
+ ...(failure ?? {}),
234
+ });
217
235
  return code;
218
236
  }
219
237
  if (result.error) {