simt-dev 0.2.0 → 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.
- package/CHANGELOG.md +375 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- 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/README.md +17 -0
- package/package.json +1 -2
- 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 +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -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 +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/rules/stacks/uv.md
CHANGED
|
@@ -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
|
@@ -12,23 +12,48 @@
|
|
|
12
12
|
|
|
13
13
|
import { EXIT, fail } from './output/index.mjs';
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// `--quiet`, `--semantic` and `--mermaid` were here and were read by nothing (review
|
|
16
|
+
// 0001 filed it; it survived every change to this file since). Each was a plan's
|
|
17
|
+
// intention rather than a feature: `--semantic` was to be an explicitly non-canonical
|
|
18
|
+
// escape hatch, `--mermaid` belonged to a `skills graph` command that was never built,
|
|
19
|
+
// and `--quiet` was to shorten `help`. The plans keep those records; the parser does not
|
|
20
|
+
// need to keep a promise no code makes.
|
|
21
|
+
//
|
|
22
|
+
// An accepted flag that changes nothing is worse than an absent one. `--nonsense` exits
|
|
23
|
+
// 2 and tells the reader the flag does not exist; `--quiet` exits 0 and tells them
|
|
24
|
+
// nothing happened, which is indistinguishable from the output being quiet on purpose.
|
|
25
|
+
// Removing them makes `test/help.test.mjs`'s "every flag is described" assertion able to
|
|
26
|
+
// mean something, which it could not while three accepted flags had no description.
|
|
27
|
+
|
|
28
|
+
export const BOOL_FLAGS = new Set([
|
|
16
29
|
'json',
|
|
17
30
|
'all',
|
|
18
31
|
'dry-run',
|
|
19
32
|
'yes',
|
|
20
33
|
'check',
|
|
21
|
-
'quiet',
|
|
22
34
|
'plan',
|
|
23
|
-
'semantic',
|
|
24
|
-
'mermaid',
|
|
25
35
|
'help',
|
|
26
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',
|
|
27
40
|
'accept-conflicts',
|
|
41
|
+
'backup',
|
|
28
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',
|
|
29
54
|
]);
|
|
30
55
|
|
|
31
|
-
const VALUE_FLAGS = new Set([
|
|
56
|
+
export const VALUE_FLAGS = new Set([
|
|
32
57
|
'budget',
|
|
33
58
|
'max-bytes',
|
|
34
59
|
'paths',
|
|
@@ -41,8 +66,28 @@ const VALUE_FLAGS = new Set([
|
|
|
41
66
|
'variant',
|
|
42
67
|
'from',
|
|
43
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',
|
|
44
74
|
]);
|
|
45
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Flags that accumulate rather than overwrite.
|
|
78
|
+
*
|
|
79
|
+
* `--skill` was documented as "(repeatable)" and was not: `flags.skill = value` meant
|
|
80
|
+
* `--skill a --skill b` resolved to `b`, with `a` silently dropped. No error, no
|
|
81
|
+
* warning, and a brief that quietly lacked a skill the caller named — which is the one
|
|
82
|
+
* failure mode a tool whose job is to be explainable cannot have (ADR-009, "the answer
|
|
83
|
+
* is a function of what was asked").
|
|
84
|
+
*
|
|
85
|
+
* Comma separation still works, because the consumer splits on commas either way. This
|
|
86
|
+
* is a superset of the old behaviour, not a replacement: `--skill a,b` parsed before it
|
|
87
|
+
* parses now.
|
|
88
|
+
*/
|
|
89
|
+
export const REPEATABLE_FLAGS = new Set(['skill']);
|
|
90
|
+
|
|
46
91
|
/**
|
|
47
92
|
* @param {string[]} argv raw arguments, excluding node and the script path
|
|
48
93
|
* @returns {{ok: true, value: object} | {ok: false, code: number}}
|
|
@@ -96,7 +141,10 @@ export function parse(argv) {
|
|
|
96
141
|
}
|
|
97
142
|
flags[camel(raw)] = n;
|
|
98
143
|
} else {
|
|
99
|
-
|
|
144
|
+
const key = camel(raw);
|
|
145
|
+
flags[key] = REPEATABLE_FLAGS.has(raw)
|
|
146
|
+
? [...(Array.isArray(flags[key]) ? flags[key] : []), value]
|
|
147
|
+
: value;
|
|
100
148
|
}
|
|
101
149
|
continue;
|
|
102
150
|
}
|
package/src/ci/dependabot.mjs
CHANGED
|
@@ -142,7 +142,7 @@ function header(own) {
|
|
|
142
142
|
# only writer here, so an entry is added deliberately rather than as a side effect.
|
|
143
143
|
`;
|
|
144
144
|
}
|
|
145
|
-
return `# Written by simt-dev when this repo was adopted. Yours from here:
|
|
145
|
+
return `# Written by simt-dev when this repo was adopted. Yours from here: ${CLI}
|
|
146
146
|
# creates this file once and never rewrites it, so your own cadence, ignore rules
|
|
147
147
|
# and groups stay yours.
|
|
148
148
|
#
|
package/src/ci/workflow.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { PACKAGE_ROOT } from '../workspace.mjs';
|
|
|
50
50
|
import { PACKAGE_NAME, WORKFLOW_FILE_NAME } from '../name.mjs';
|
|
51
51
|
import { renderLocator } from '../locate.mjs';
|
|
52
52
|
import { CLI } from '../name.mjs';
|
|
53
|
+
import { packageVersion } from '../package-version.mjs';
|
|
53
54
|
|
|
54
55
|
/** Directory whose presence means this repo runs GitHub Actions. */
|
|
55
56
|
export const WORKFLOWS_DIR = '.github/workflows';
|
|
@@ -169,12 +170,16 @@ function skeleton(name, steps, { matrix = null } = {}) {
|
|
|
169
170
|
const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
|
|
170
171
|
return `name: ${name}
|
|
171
172
|
|
|
173
|
+
# Pull requests only, and that is a decision rather than a default.
|
|
174
|
+
#
|
|
175
|
+
# A gate that runs on every push re-runs itself for every commit that pushes a
|
|
176
|
+
# branch forward, including the ones nobody asked it about, and its verdict has to
|
|
177
|
+
# be cleared before the next one lands. \`pull_request\` asks one question — may this
|
|
178
|
+
# merge — and answers it once. \`main\` here is the branch protection list, not a
|
|
179
|
+
# trigger: the gate that has to pass is the one the pull request runs, and the one
|
|
180
|
+
# on \`main\` is whatever CI the repository already has there.
|
|
172
181
|
on:
|
|
173
182
|
pull_request:
|
|
174
|
-
push:
|
|
175
|
-
branches:
|
|
176
|
-
- main
|
|
177
|
-
- master
|
|
178
183
|
|
|
179
184
|
# Read-only is enough to check a repo, and a gate job does not need to write to it.
|
|
180
185
|
permissions:
|
|
@@ -235,7 +240,7 @@ function indent(text, spaces) {
|
|
|
235
240
|
* @param {{own?: boolean}} [options]
|
|
236
241
|
* @returns {string} YAML, LF-terminated
|
|
237
242
|
*/
|
|
238
|
-
export function renderWorkflow({ own = false } = {}) {
|
|
243
|
+
export function renderWorkflow({ own = false, npm = true, version } = {}) {
|
|
239
244
|
if (own) {
|
|
240
245
|
const floor = nodeFloor();
|
|
241
246
|
return `${header(true)}${skeleton(
|
|
@@ -267,17 +272,34 @@ export function renderWorkflow({ own = false } = {}) {
|
|
|
267
272
|
)}\n`;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
275
|
+
const v = version ?? packageVersion();
|
|
276
|
+
// The install step is the whole of S35's consumer CI: a repository with npm pins
|
|
277
|
+
// simt-dev as a devDependency and `npm ci` restores it; one without (Gradle,
|
|
278
|
+
// Connect IQ) has no node_modules to pin in, so CI installs the same pinned global
|
|
279
|
+
// binary the machine remedy names. The two must agree, or the gate passes in one
|
|
280
|
+
// place and cannot start in the other.
|
|
281
|
+
const install = npm
|
|
282
|
+
? [
|
|
283
|
+
' - name: install dependencies',
|
|
284
|
+
' # `npm ci` needs a lockfile; a repo without one is still gated, not skipped.',
|
|
285
|
+
' run: npm ci || npm install',
|
|
286
|
+
]
|
|
287
|
+
: [
|
|
288
|
+
' - name: install the pinned gate binary',
|
|
289
|
+
' # No node_modules to pin in, so a pinned global install: the same bytes',
|
|
290
|
+
' # the machine remedy names, so the gate cannot pass locally and fail here.',
|
|
291
|
+
` run: npm i -g ${PACKAGE_NAME}@${v}`,
|
|
292
|
+
];
|
|
293
|
+
const remedy = npm
|
|
294
|
+
? `REMEDY run: npm i -D ${PACKAGE_NAME} (the gate runs from the pinned dependency)`
|
|
295
|
+
: `REMEDY run: npm i -g ${PACKAGE_NAME}@${v} (the gate runs from a pinned global install)`;
|
|
296
|
+
const locator = renderLocator('check --root "$ROOT"', { remedy });
|
|
273
297
|
return `${header(false)}${skeleton(
|
|
274
298
|
`${CLI} check`,
|
|
275
299
|
[
|
|
276
300
|
uses('checkout'),
|
|
277
301
|
setupNode(nodeFloor()),
|
|
278
|
-
|
|
279
|
-
' # \`npm ci\` needs a lockfile; a repo without one is still gated, not skipped.',
|
|
280
|
-
' run: npm ci || npm install',
|
|
302
|
+
...install,
|
|
281
303
|
` - name: ${CLI} check`,
|
|
282
304
|
' run: |',
|
|
283
305
|
indent(`set -eu\nROOT="\${GITHUB_WORKSPACE}"\n${locator}`, 10),
|
package/src/commands/compare.mjs
CHANGED
|
@@ -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(
|
package/src/commands/graph.mjs
CHANGED
|
@@ -6,41 +6,68 @@
|
|
|
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';
|
|
10
|
-
import { loadRegistry
|
|
9
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
10
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
11
|
+
import { lintSpec } from '../graph/speccheck.mjs';
|
|
12
|
+
import { SPEC, SPEC_SOURCE } from '../graph/spec.mjs';
|
|
13
|
+
import { CLI } from '../name.mjs';
|
|
11
14
|
|
|
12
15
|
export async function lintCommand(positional, flags) {
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
+
|
|
21
|
+
// The resolved path comes back from the loader, because when no positional is given it
|
|
22
|
+
// falls back to the member that holds the registry in this workspace (ADR-061) - and the
|
|
23
|
+
// reader should see where it actually looked, not a constant that no longer exists.
|
|
24
|
+
const { ok, registry, errors, warnings, verified, workspace, path } = await loadRegistry(
|
|
25
|
+
positional[0] ?? null,
|
|
26
|
+
flags,
|
|
27
|
+
);
|
|
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);
|
|
15
39
|
|
|
16
40
|
if (isJson(flags)) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
},
|
|
37
61
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
38
62
|
);
|
|
39
63
|
return ok ? EXIT.OK : EXIT.REGISTRY;
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
if (!registry) {
|
|
43
|
-
|
|
67
|
+
// The default remedy for this exit code is `run: simt-dev graph lint`, which from
|
|
68
|
+
// inside `graph lint` is circular: the reader just ran it and got this. Name the
|
|
69
|
+
// argument the command takes, because an explicit path is the way out (ADR-061).
|
|
70
|
+
return fail(errors[0] ?? `${path}: unreadable`, EXIT.REGISTRY, `run: ${CLI} graph lint <path-to-registry>`);
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
const lines = [
|
|
@@ -48,6 +75,12 @@ export async function lintCommand(positional, flags) {
|
|
|
48
75
|
`nodes ${registry.skills.length} skills, ${registry.capabilities.length} capabilities`,
|
|
49
76
|
`verified ${verified ? 'byte counts checked against disk' : 'NOT checked - see WARN below'}`,
|
|
50
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
|
+
}
|
|
51
84
|
|
|
52
85
|
const deferred = registry.skills.filter((s) => s.deferred);
|
|
53
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
|
+
}
|
package/src/commands/help.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import { CLI } from '../name.mjs';
|
|
|
4
4
|
|
|
5
5
|
const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
|
|
6
6
|
(dry-run unless --yes; conflicts block without
|
|
7
|
-
--accept-conflicts;
|
|
7
|
+
--accept-conflicts; in place unless --backup;
|
|
8
|
+
never commits or pushes)
|
|
8
9
|
${CLI} doctor classify a repo and check config health
|
|
9
10
|
${CLI} check the gate: projection drift, manifest validity, policy
|
|
10
11
|
${CLI} sync [--yes] regenerate the projection (idempotent)
|
|
@@ -19,6 +20,10 @@ ${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
|
|
|
19
20
|
${CLI} skills show <name> tier-2 body (respects --max-bytes)
|
|
20
21
|
${CLI} skills why <name> the signal chain that selected it
|
|
21
22
|
${CLI} skills find <query> search across capability and keyword
|
|
23
|
+
${CLI} skills eval [corpus] measure the resolver against a gold corpus
|
|
24
|
+
(reports precision and recall; exits non-zero on
|
|
25
|
+
regression; without an argument, the corpus in this
|
|
26
|
+
checkout)
|
|
22
27
|
${CLI} skills install <name> pin a skill into the repo as a local override
|
|
23
28
|
(dry-run unless --yes; a multi-variant skill needs
|
|
24
29
|
--variant or --from rather than a guess)
|
|
@@ -26,20 +31,35 @@ ${CLI} skills install <name> pin a skill into the repo as a local override
|
|
|
26
31
|
${CLI} rules list the rule catalogue (--root marks what applies to a repo)
|
|
27
32
|
${CLI} rules show <id> one rule body
|
|
28
33
|
${CLI} rules why <id|ADR-NNN> the decision that settled it, and whether it is still in force
|
|
34
|
+
(--all prints every ruling, not the first two)
|
|
29
35
|
${CLI} hooks install [--yes] install the shared dispatcher; report what it would run
|
|
30
36
|
(dry-run unless --yes; refuses to take over routing git
|
|
31
37
|
already uses, and --yes is that consent)
|
|
38
|
+
${CLI} hooks status what each git event routes to, and whether it is wired
|
|
39
|
+
${CLI} hooks run <event> run one event's gates now, as git would
|
|
40
|
+
(pre-commit, pre-push, post-merge, ...)
|
|
41
|
+
${CLI} graph lint check the skill registry against the skills on disk
|
|
42
|
+
(byte counts, dangling edges, unresolvable variants)
|
|
32
43
|
${CLI} ci simt-dev's own .github files: create or refresh them
|
|
33
44
|
(dry-run unless --yes; refreshes the templates' own copies
|
|
34
45
|
and no repository else's)
|
|
35
46
|
${CLI} compare org-wide matrix across every sibling repo
|
|
36
47
|
(read-only; exits non-zero on defects, not on
|
|
37
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)
|
|
38
57
|
${CLI} mcp the read-only surface as MCP tools over stdio
|
|
39
58
|
(--list prints it without reading stdin; init, sync,
|
|
40
59
|
upgrade and the installers stay CLI-only by design)`;
|
|
41
60
|
|
|
42
|
-
const FLAGS = `--json structured output
|
|
61
|
+
const FLAGS = `--json structured output (compact; one document, one line)
|
|
62
|
+
--pretty indent --json for a human
|
|
43
63
|
--max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
|
|
44
64
|
--budget <n> context budget for skills brief (default 20480)
|
|
45
65
|
--plan emit a fetch plan instead of inlining skill bodies
|
|
@@ -55,9 +75,21 @@ const FLAGS = `--json structured output
|
|
|
55
75
|
file, then the sibling heuristic if it verifies)
|
|
56
76
|
--dry-run report what would change, change nothing
|
|
57
77
|
--yes assume yes; required in automation, never prompts
|
|
58
|
-
--
|
|
78
|
+
--check report drift and write nothing (sync, ci)
|
|
79
|
+
--all ignore a cap: every ruling of a record, or the skills budget
|
|
59
80
|
--variant <id> which body of a multi-variant skill to use
|
|
60
|
-
--from <repo/path> install from a declared source by name
|
|
81
|
+
--from <repo/path> install from a declared source by name
|
|
82
|
+
--list print the MCP tool surface without reading stdin (mcp)
|
|
83
|
+
--accept-conflicts init: adopt rules that collide with hand-written ones
|
|
84
|
+
--backup init: tag the pre-adoption commit and branch onto
|
|
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
|
|
91
|
+
--help this text
|
|
92
|
+
--version the version, and the SHA it was built from`;
|
|
61
93
|
|
|
62
94
|
const PRINCIPLES = `design rules:
|
|
63
95
|
deterministic same repo + same task = same answer, forever
|
|
@@ -78,7 +110,7 @@ export async function help(argv) {
|
|
|
78
110
|
[
|
|
79
111
|
'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
|
|
80
112
|
'',
|
|
81
|
-
|
|
113
|
+
`usage: ${CLI} <command> [args] [flags]`,
|
|
82
114
|
'',
|
|
83
115
|
'commands:',
|
|
84
116
|
COMMANDS,
|