simt-dev 0.1.1 → 0.2.1
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 +209 -7
- package/README.md +32 -5
- package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
- package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
- package/docs/decisions/ADR-003-test-breadth.md +4 -4
- package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
- package/docs/decisions/ADR-005-file-length.md +5 -5
- package/docs/decisions/ADR-006-coverage-bar.md +3 -3
- package/docs/decisions/ADR-007-section-skeleton.md +5 -5
- package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-010-runner-invocation.md +1 -1
- package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
- package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
- package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
- package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
- package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
- package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
- package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
- package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
- package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
- package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
- package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
- package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -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/README.md +20 -1
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +183 -67
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +156 -14
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +4 -4
- package/src/commands/sync.mjs +153 -11
- package/src/compare/discover.mjs +1 -1
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +36 -7
- package/src/decisions/index.mjs +51 -12
- package/src/graph/load.mjs +126 -15
- package/src/hooks/capability.mjs +49 -5
- package/src/hooks/dispatcher.mjs +51 -6
- package/src/hooks/gates.mjs +1 -3
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/hooks/where.mjs +1 -1
- package/src/identity.mjs +207 -0
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/conflicts.mjs +57 -6
- package/src/projection/manifest.mjs +248 -26
- package/src/projection/markers.mjs +3 -3
- package/src/projection/mdc.mjs +6 -15
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/surfaces.mjs +2 -2
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +4 -3
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +2 -2
- package/src/tsconfig.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/commands/init.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 2. scan for conflicts does it contradict a ruling
|
|
12
12
|
// 3. block on conflicts unless --accept-conflicts
|
|
13
13
|
// 4. plan everything manifest + region, dry
|
|
14
|
-
// 5. branch and tag
|
|
14
|
+
// 5. branch and tag an opt-in escape hatch (--backup), before the first write
|
|
15
15
|
// 6. write only with --yes
|
|
16
16
|
// Never commits, never pushes (ADR-001).
|
|
17
17
|
|
|
@@ -29,12 +29,22 @@ import { readHooksState } from '../compare/hooksstate.mjs';
|
|
|
29
29
|
import { planWorkflow, renderWorkflow, WORKFLOWS_DIR } from '../ci/workflow.mjs';
|
|
30
30
|
import { planDependabot, renderDependabot } from '../ci/dependabot.mjs';
|
|
31
31
|
import { planTsconfigBase, renderTsconfigBase, TSCONFIG_BASE_FILE } from '../tsconfig.mjs';
|
|
32
|
+
import {
|
|
33
|
+
IDENTITY_FILE,
|
|
34
|
+
INCLUDE_KEY,
|
|
35
|
+
ignoreIdentity,
|
|
36
|
+
includeOwnIdentity,
|
|
37
|
+
includesOwnIdentity,
|
|
38
|
+
renderIdentityFile,
|
|
39
|
+
resolveIdentity,
|
|
40
|
+
} from '../identity.mjs';
|
|
32
41
|
import { pointerState, pointerTargets } from '../projection/project.mjs';
|
|
33
42
|
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
34
43
|
import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
|
|
35
44
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
36
45
|
import { CLI } from '../name.mjs';
|
|
37
46
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
47
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
38
48
|
|
|
39
49
|
const run = promisify(execFile);
|
|
40
50
|
|
|
@@ -50,6 +60,11 @@ export async function initCommand(positional, flags) {
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
const sha = await provenanceDigest();
|
|
63
|
+
// The release this adoption records, and the kind of repository it is adopting: the
|
|
64
|
+
// version goes into the manifest pin and the rendered gate, and the kind decides how
|
|
65
|
+
// a repository without node_modules obtains the binary in CI.
|
|
66
|
+
const version = packageVersion();
|
|
67
|
+
const npm = await hasNpm(root);
|
|
53
68
|
const existing = await readManifest(root);
|
|
54
69
|
const { manifest: current, exists, error } = existing;
|
|
55
70
|
if (error) {
|
|
@@ -58,11 +73,15 @@ export async function initCommand(positional, flags) {
|
|
|
58
73
|
|
|
59
74
|
// What this repo can demonstrably run, not what its stack suggests. Without this the
|
|
60
75
|
// stack table proposes gates the repo has no way to execute — the adoption of
|
|
61
|
-
//
|
|
76
|
+
// a repository declared five and could run two, which would have blocked every
|
|
62
77
|
// commit once hooks were installed.
|
|
63
78
|
const capability = await readCapability(root);
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
// `facts` carries the ecosystem rows (has-cargo-toml, ...) — the same signal set that
|
|
80
|
+
// selects this repo's profiles, so one marker file reaches both the region the reader
|
|
81
|
+
// gets and the gates its commits run. Without it the ecosystem rows never fire here
|
|
82
|
+
// and a fresh Rust repo is adopted with gates it cannot run.
|
|
83
|
+
const { dropped, commands } = planGates(detected.stack, capability, detected.facts);
|
|
84
|
+
const manifest = buildManifest({ root, stack: detected.stack, sha, version, existing: current, capability, facts: detected.facts });
|
|
66
85
|
const findings = await scanRepo(root);
|
|
67
86
|
const summary = summarise(findings);
|
|
68
87
|
const projected = await plan(root, flags);
|
|
@@ -98,6 +117,26 @@ export async function initCommand(positional, flags) {
|
|
|
98
117
|
const ci = await planWorkflow(root);
|
|
99
118
|
const dependabot = await planDependabot(root);
|
|
100
119
|
const tsconfigBase = await planTsconfigBase(root);
|
|
120
|
+
// Per-developer, and planned with the rest so the operator sees it in the same
|
|
121
|
+
// preview that lists everything else. `exists` short-circuits the write: a
|
|
122
|
+
// repository that already has one keeps it exactly as it is.
|
|
123
|
+
const identity = await resolveIdentity(root);
|
|
124
|
+
const identityPlan = {
|
|
125
|
+
file: IDENTITY_FILE,
|
|
126
|
+
action: identity.exists ? 'unchanged' : identity.from === null ? 'skip' : 'created',
|
|
127
|
+
from: identity.from,
|
|
128
|
+
name: identity.name,
|
|
129
|
+
email: identity.email,
|
|
130
|
+
reason: identity.exists
|
|
131
|
+
? `${IDENTITY_FILE} already exists and is left exactly as it is`
|
|
132
|
+
: identity.from === null
|
|
133
|
+
? 'no identity is configured anywhere, and this tool does not invent one'
|
|
134
|
+
: null,
|
|
135
|
+
// Only worth fixing when this repository is the one being routed: an include
|
|
136
|
+
// pointing at another repository's identity file works here and breaks for the
|
|
137
|
+
// next person who clones without that sibling checkout.
|
|
138
|
+
include: await includesOwnIdentity(root),
|
|
139
|
+
};
|
|
101
140
|
|
|
102
141
|
const payload = {
|
|
103
142
|
ok: summary.conflicts === 0 || flags.acceptConflicts === true,
|
|
@@ -112,11 +151,18 @@ export async function initCommand(positional, flags) {
|
|
|
112
151
|
// adoption: an existing manifest keeps its gates on purpose, so its drops would
|
|
113
152
|
// describe proposals that were never made.
|
|
114
153
|
droppedGates: exists ? [] : dropped,
|
|
154
|
+
// Commands a FRESH manifest declares under gates.commands. The plan must show them:
|
|
155
|
+
// a command that lands in the manifest without appearing here is a silent write,
|
|
156
|
+
// and the reader would approve it by reading the diff afterwards — the review step
|
|
157
|
+
// this tool exists to make unnecessary. On an existing manifest nothing is seeded
|
|
158
|
+
// (the repo's declaration owns that field), so the empty list is deliberate.
|
|
159
|
+
seededCommands: exists ? [] : Object.entries(commands).map(([gate, command]) => ({ gate, command })),
|
|
115
160
|
surfaces: projected.surfaces,
|
|
116
161
|
surfaceSource: projected.surfaceSource,
|
|
117
162
|
pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
|
|
118
163
|
pointersBlocked: pointerBlocked,
|
|
119
164
|
ci: { file: ci.file, action: ci.action, bytes: ci.bytes, own: ci.own, reason: ci.reason },
|
|
165
|
+
identity: identityPlan,
|
|
120
166
|
dependabot: {
|
|
121
167
|
file: dependabot.file,
|
|
122
168
|
action: dependabot.action,
|
|
@@ -163,6 +209,24 @@ export async function initCommand(positional, flags) {
|
|
|
163
209
|
git: null,
|
|
164
210
|
};
|
|
165
211
|
|
|
212
|
+
// A surface this build does not know cannot be written to. Checked before the rule
|
|
213
|
+
// branch, and named for what it is: `init --surfaces` accepts a list, a reader
|
|
214
|
+
// mistypes one, and the misspelling used to be reported as an unprojectable rule with
|
|
215
|
+
// a remedy about substitution keys — the one problem on this path that had no
|
|
216
|
+
// substitution keys anywhere near it.
|
|
217
|
+
const surfaceProblems = projected.surfaceProblems ?? [];
|
|
218
|
+
if (surfaceProblems.length > 0) {
|
|
219
|
+
payload.ok = false;
|
|
220
|
+
payload.blockedBy = 'surface-problem';
|
|
221
|
+
for (const p of surfaceProblems) note(`PROBLEM ${p}`);
|
|
222
|
+
if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
223
|
+
return fail(
|
|
224
|
+
`${surfaceProblems.length} surface(s) cannot be resolved (${surfaceProblems[0]})`,
|
|
225
|
+
EXIT.FAIL,
|
|
226
|
+
`pass a known surface id to --surfaces, or omit it to take the default`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
166
230
|
// A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
|
|
167
231
|
// without it would leave the family half-retired, and the user would have no way
|
|
168
232
|
// to tell which half from the diff.
|
|
@@ -187,7 +251,7 @@ export async function initCommand(positional, flags) {
|
|
|
187
251
|
if (isJson(flags)) {
|
|
188
252
|
emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
189
253
|
} else {
|
|
190
|
-
emit(render(payload, manifest));
|
|
254
|
+
emit(render(payload, manifest, { npm, version }));
|
|
191
255
|
note('');
|
|
192
256
|
note(
|
|
193
257
|
`blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
|
|
@@ -208,7 +272,7 @@ export async function initCommand(positional, flags) {
|
|
|
208
272
|
payload.ok = false;
|
|
209
273
|
payload.blockedBy = 'malformed-region';
|
|
210
274
|
if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
211
|
-
else emit(render(payload, manifest));
|
|
275
|
+
else emit(render(payload, manifest, { npm, version }));
|
|
212
276
|
return fail(
|
|
213
277
|
`${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
|
|
214
278
|
EXIT.FAIL,
|
|
@@ -220,7 +284,7 @@ export async function initCommand(positional, flags) {
|
|
|
220
284
|
if (isJson(flags)) {
|
|
221
285
|
if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
|
|
222
286
|
} else {
|
|
223
|
-
emit(render(payload, manifest));
|
|
287
|
+
emit(render(payload, manifest, { npm, version }));
|
|
224
288
|
}
|
|
225
289
|
if (!apply) {
|
|
226
290
|
note('');
|
|
@@ -232,13 +296,68 @@ export async function initCommand(positional, flags) {
|
|
|
232
296
|
return EXIT.OK;
|
|
233
297
|
}
|
|
234
298
|
|
|
235
|
-
|
|
299
|
+
// The escape hatch is opt-in (ADR-063): plan 0002 §8.2 item 3 said "always", and the
|
|
300
|
+
// tool complied, but consumers kept reporting the branch switch as a surprise. The
|
|
301
|
+
// default now writes in place on the branch the reader already has — `git diff` still
|
|
302
|
+
// shows the full adoption, and nothing is tagged or branched. `--backup` restores the
|
|
303
|
+
// tag and the branch for readers who want the named undo path.
|
|
304
|
+
const git = flags.backup
|
|
305
|
+
? await establishEscapeHatch(root)
|
|
306
|
+
: { branch: null, tag: null, from: null, notes: [] };
|
|
236
307
|
payload.git = git;
|
|
237
308
|
for (const message of git.notes) note(`note ${message}`);
|
|
309
|
+
if (!flags.backup) {
|
|
310
|
+
note(
|
|
311
|
+
'in place no backup tag or adopt branch created; the adoption is written on your current branch - git diff shows it and git checkout -- . discards it; re-run with --backup for the tag and the branch',
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
// The operations that succeeded, not just the ones that failed.
|
|
315
|
+
//
|
|
316
|
+
// With --backup, `init` moves the reader onto a different branch and writes a tag
|
|
317
|
+
// into their repository. Both were once reported only under `--json`, so the run —
|
|
318
|
+
// the one almost everybody reads — did not say that the branch under them had
|
|
319
|
+
// changed. A reader who ran the documented escape hatch and then committed would be
|
|
320
|
+
// committing to a branch they had not chosen, and nothing in the output told them
|
|
321
|
+
// that had happened.
|
|
322
|
+
//
|
|
323
|
+
// The plan's heading is "Nothing is silent", so whatever the run does to git state —
|
|
324
|
+
// or deliberately does not — the output names it and the command that undoes it.
|
|
325
|
+
// `notes` carried the failures because a failure is the kind of thing an operator
|
|
326
|
+
// notices; a success is not.
|
|
327
|
+
if (git.tag !== null) {
|
|
328
|
+
note(`tagged ${git.tag} at the pre-adoption commit; \`git reset --hard ${git.tag}\` returns this repository to what it was`);
|
|
329
|
+
}
|
|
330
|
+
if (git.branch !== null && git.from !== null) {
|
|
331
|
+
note(`branch switched to ${git.branch}; ${git.from} is untouched and \`git checkout ${git.from}\` returns to it`);
|
|
332
|
+
}
|
|
238
333
|
|
|
239
334
|
await writeManifest(root, manifest);
|
|
240
335
|
note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
|
|
241
336
|
|
|
337
|
+
// Identity last, and for the same reason the gate is written after the manifest:
|
|
338
|
+
// the things that change what this repository does come first, and the one that
|
|
339
|
+
// only records who is doing them comes once there is something to be committing.
|
|
340
|
+
//
|
|
341
|
+
// Three separate refusals, each of which is the correct answer rather than a
|
|
342
|
+
// failure to try harder. An existing file is not this tool's to rewrite. An
|
|
343
|
+
// unconfigured identity is not one to invent - a placeholder is an identity git
|
|
344
|
+
// would use. And the include is only repointed when it is missing or wrong,
|
|
345
|
+
// because replacing it when it is already right would be a no-op that reads as
|
|
346
|
+
// a change.
|
|
347
|
+
if (payload.identity.action === 'created') {
|
|
348
|
+
await writeFile(join(root, IDENTITY_FILE), renderIdentityFile(identity));
|
|
349
|
+
await ignoreIdentity(root);
|
|
350
|
+
note(`wrote ${IDENTITY_FILE} (${identity.from === 'last-commit' ? 'from the last commit in this repository' : 'from the identity configured on this machine'}); gitignored, because it is yours and not the repository's`);
|
|
351
|
+
} else if (payload.identity.action === 'unchanged') {
|
|
352
|
+
note(`kept ${IDENTITY_FILE} (${payload.identity.reason})`);
|
|
353
|
+
} else {
|
|
354
|
+
note(`skipped ${IDENTITY_FILE} ${payload.identity.reason}`);
|
|
355
|
+
}
|
|
356
|
+
if (!payload.identity.include) {
|
|
357
|
+
await includeOwnIdentity(root);
|
|
358
|
+
note(`set ${INCLUDE_KEY} = ../${IDENTITY_FILE} this repository's own identity file`);
|
|
359
|
+
}
|
|
360
|
+
|
|
242
361
|
// Written after the manifest so the gate exists in the same adoption that declares
|
|
243
362
|
// what the gates are. Skipped with its reason printed rather than silently: a repo
|
|
244
363
|
// with no `.github/workflows` has no CI surface to attach to, and inventing one
|
|
@@ -250,7 +369,7 @@ export async function initCommand(positional, flags) {
|
|
|
250
369
|
// guaranteed to be misled.
|
|
251
370
|
if (payload.ci.action === 'create') {
|
|
252
371
|
await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
|
|
253
|
-
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own }));
|
|
372
|
+
await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
|
|
254
373
|
note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
|
|
255
374
|
}
|
|
256
375
|
if (payload.dependabot.action === 'create') {
|
|
@@ -369,13 +488,14 @@ async function scanRepo(root) {
|
|
|
369
488
|
}
|
|
370
489
|
|
|
371
490
|
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
491
|
+
* The opt-in escape hatch before the first write, taken when --backup is passed
|
|
492
|
+
* (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
|
|
493
|
+
* are best-effort: a repo that is not a git repo, or has no commits yet, must still be
|
|
374
494
|
* adoptable. Failure is reported as a note rather than blocking, because refusing
|
|
375
495
|
* to initialise an unborn repo would be worse than proceeding without a backup.
|
|
376
496
|
*/
|
|
377
497
|
async function establishEscapeHatch(root) {
|
|
378
|
-
const result = { branch: null, tag: null, notes: [] };
|
|
498
|
+
const result = { branch: null, tag: null, from: null, notes: [] };
|
|
379
499
|
const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
380
500
|
if (!isRepo.ok) {
|
|
381
501
|
result.notes.push('not a git work tree; no backup branch or tag created');
|
|
@@ -403,6 +523,10 @@ async function establishEscapeHatch(root) {
|
|
|
403
523
|
|
|
404
524
|
const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
405
525
|
if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
|
|
526
|
+
// Recorded rather than re-read by the caller: only this function knows what the
|
|
527
|
+
// branch was before it moved it, and a second `rev-parse` would answer with the new
|
|
528
|
+
// value and make every "you were on X" line read as "you were on Y".
|
|
529
|
+
result.from = current.stdout.trim();
|
|
406
530
|
const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
|
|
407
531
|
if (branch.ok) result.branch = ADOPT_BRANCH;
|
|
408
532
|
else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
|
|
@@ -423,7 +547,7 @@ async function git(cwd, args) {
|
|
|
423
547
|
}
|
|
424
548
|
|
|
425
549
|
/** Every line is a decision with a reason. Nothing is silent. */
|
|
426
|
-
function render(payload, manifest) {
|
|
550
|
+
function render(payload, manifest, { npm = true, version } = {}) {
|
|
427
551
|
const lines = [
|
|
428
552
|
`repo ${payload.root}`,
|
|
429
553
|
`stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
|
|
@@ -444,6 +568,18 @@ function render(payload, manifest) {
|
|
|
444
568
|
`coverage=${manifest.limits.coverage ?? 'undeclared (ADR-006)'} ` +
|
|
445
569
|
`fileLines=${manifest.limits.fileLines.warn}/${manifest.limits.fileLines.error} (ADR-005)`,
|
|
446
570
|
);
|
|
571
|
+
// The identity line, with its source named. "Created" is a statement about somebody's
|
|
572
|
+
// name landing in a file, so where the name came from is not a detail of the line.
|
|
573
|
+
const id = payload.identity;
|
|
574
|
+
if (id) {
|
|
575
|
+
const verb = id.action === 'skip' ? 'skip' : id.action;
|
|
576
|
+
const detail = id.action === 'unchanged'
|
|
577
|
+
? id.reason
|
|
578
|
+
: id.action === 'skip'
|
|
579
|
+
? id.reason
|
|
580
|
+
: `${id.name} <${id.email}> from ${id.from === 'last-commit' ? "this repository's last commit" : 'the identity configured on this machine'}`;
|
|
581
|
+
lines.push(` ${verb.padEnd(9)} ${id.file}${detail ? `\n ${detail}` : ''}`);
|
|
582
|
+
}
|
|
447
583
|
// Every gate the stack suggested and this repo cannot run is named, with the reason.
|
|
448
584
|
// A silently shorter gate list reads as "this repo needs no formatting check", when
|
|
449
585
|
// the truth is "declaring one would block every commit". The remedy is stated because
|
|
@@ -452,6 +588,12 @@ function render(payload, manifest) {
|
|
|
452
588
|
lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
|
|
453
589
|
lines.push(` remedy: ${d.remedy}`);
|
|
454
590
|
}
|
|
591
|
+
// A seed is a write, and the same rule as for dropped gates applies: the reader must
|
|
592
|
+
// see the line or they approve it from the diff afterwards. Naming the manifest field
|
|
593
|
+
// keeps the line actionable — this is where the reader edits it if the default is wrong.
|
|
594
|
+
for (const s of payload.seededCommands ?? []) {
|
|
595
|
+
lines.push(` ${'seeded'.padEnd(9)} gates.commands["${s.gate}"] = ${s.command}`);
|
|
596
|
+
}
|
|
455
597
|
lines.push(
|
|
456
598
|
` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
|
|
457
599
|
`repo-local profiles are selected by the same matcher (ADR-013)`,
|
|
@@ -494,7 +636,7 @@ function render(payload, manifest) {
|
|
|
494
636
|
// a reader can tell "this repo does not use GitHub Actions" from "this repo was
|
|
495
637
|
// not offered the gate".
|
|
496
638
|
if (payload.ci.action === 'create') {
|
|
497
|
-
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own }).length} bytes`);
|
|
639
|
+
lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own, npm, version }).length} bytes`);
|
|
498
640
|
lines.push(
|
|
499
641
|
payload.ci.own
|
|
500
642
|
? ` this repository is simt-dev's own; ${CLI} ci --yes refreshes this copy when the gate changes`
|
package/src/commands/rules.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
18
18
|
import { loadRules, select } from '../projection/project.mjs';
|
|
19
|
+
import { loadMdcRules } from '../projection/mdc.mjs';
|
|
19
20
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
20
21
|
import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
|
|
21
22
|
import { nearMatches } from '../resolve/near.mjs';
|
|
@@ -23,9 +24,18 @@ import { CLI } from '../name.mjs';
|
|
|
23
24
|
|
|
24
25
|
const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `simt-applies` is the string "always" for core and an array for profiles.
|
|
29
|
+
*
|
|
30
|
+
* An opt-in rule has an EMPTY array, and `-` is a true reading of that which answers
|
|
31
|
+
* none of the questions a reader has. `simt-opt-in` is why it is empty — detection cannot
|
|
32
|
+
* distinguish a repository with the problem from one without it, so the repository
|
|
33
|
+
* declares it — and that is the fact worth printing. It only became visible when the
|
|
34
|
+
* per-surface rules entered this catalogue; before that the rule was not listed at all.
|
|
35
|
+
*/
|
|
27
36
|
function appliesLabel(rule) {
|
|
28
37
|
if (rule.applies === 'always') return 'always';
|
|
38
|
+
if (rule.optIn === true) return 'opt-in (the repo declares it)';
|
|
29
39
|
if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
|
|
30
40
|
return '-';
|
|
31
41
|
}
|
|
@@ -38,7 +48,37 @@ function sorted(rules) {
|
|
|
38
48
|
async function load(root) {
|
|
39
49
|
const rules = await loadRules(root ?? null);
|
|
40
50
|
const broken = rules.filter((r) => r.error);
|
|
41
|
-
return { rules, broken };
|
|
51
|
+
return { rules: [...rules, ...(await loadPerSurfaceRules())], broken };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `.mdc` rules, in the shape this file's three commands print.
|
|
56
|
+
*
|
|
57
|
+
* `loadRules` reads `rules/*.md` and `rules/stacks/*.md`, which is every rule that is a
|
|
58
|
+
* *stack profile* — and the four per-surface rules are not that. They are projected by
|
|
59
|
+
* `ruleBodies` inside `desiredRegion` rather than by `select`, so they were invisible to
|
|
60
|
+
* `rules list`, `rules show` and `rules why`: a reader could be told by an agent to run
|
|
61
|
+
* `simt rules why test-gate` and be met with "no rule with that id", for a rule that was
|
|
62
|
+
* in their own `AGENTS.md` at the time.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT added to `loadRules`. That function's result is `select`ed and
|
|
65
|
+
* `render`ed into the region, so putting these there would project every one of them
|
|
66
|
+
* twice — once as a profile section and once as `## Process: <id>` — which is the
|
|
67
|
+
* duplication ADR-046 exists to prevent. The catalogue is the right layer for this: it is
|
|
68
|
+
* a view, and a view may be wider than what one projection writes.
|
|
69
|
+
*/
|
|
70
|
+
async function loadPerSurfaceRules() {
|
|
71
|
+
const mdc = await loadMdcRules();
|
|
72
|
+
return mdc.map((r) => ({
|
|
73
|
+
...r,
|
|
74
|
+
// These have no `simt-title`; their `description` is what a reader sees in the
|
|
75
|
+
// generated file, so it is the title here rather than a column of blanks.
|
|
76
|
+
title: r.title ?? r.description ?? null,
|
|
77
|
+
// `mdc` rather than `central`: the column exists so a reader can tell where a rule
|
|
78
|
+
// came from, and "central" is true of all of them and useless for this distinction.
|
|
79
|
+
origin: 'per-surface',
|
|
80
|
+
fallback: false,
|
|
81
|
+
}));
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
// --- list ---------------------------------------------------------------------
|
|
@@ -182,7 +222,7 @@ export async function whyCommand(args, flags) {
|
|
|
182
222
|
if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
|
|
183
223
|
|
|
184
224
|
const { rules, broken } = await load(flags.root ?? null);
|
|
185
|
-
const { ok, decisions, errors } = await loadDecisions();
|
|
225
|
+
const { ok, decisions, errors } = await loadDecisions({ all: flags.all === true });
|
|
186
226
|
if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
|
|
187
227
|
|
|
188
228
|
return what.match(/^ADR-\d{3}$/i)
|
|
@@ -342,6 +382,17 @@ async function adrDirection(adr, rules, decisions, flags) {
|
|
|
342
382
|
if (effective.summary.ruled.length > 0) {
|
|
343
383
|
lines.push('', replacement ? 'ruled now' : 'ruled');
|
|
344
384
|
for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
|
|
385
|
+
// The count, because the excerpt above is two rulings and a record may carry eight.
|
|
386
|
+
// Without it the header reads as a claim about everything the record settled, which
|
|
387
|
+
// is how a reader goes looking for a ruling that was never printed and concludes it
|
|
388
|
+
// does not exist.
|
|
389
|
+
const shown = effective.summary.ruled.length;
|
|
390
|
+
const total = effective.summary.rulingCount ?? shown;
|
|
391
|
+
if (total > shown) {
|
|
392
|
+
lines.push(
|
|
393
|
+
` (${shown} of ${total} rulings shown; the rest: ${CLI} rules why ${effective.id} --all)`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
345
396
|
}
|
|
346
397
|
if (effective.summary.rejected.length > 0) {
|
|
347
398
|
// The rejected alternatives are the part a reader cannot reconstruct. A ruling is
|
package/src/commands/skills.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Implementation sequence steps 4 and 5.
|
|
4
4
|
//
|
|
5
5
|
// Output design (plan 0002 section 6): one fact per line, fixed field order, no
|
|
6
|
-
// wrapping, no
|
|
6
|
+
// wrapping, no ANSI on any stream, bounded by --max-bytes with an explicit
|
|
7
7
|
// TRUNCATED line. The NOT SELECTED block is as important as the resolved block -
|
|
8
8
|
// the agent can see what exists and why it was excluded, and override in one
|
|
9
9
|
// token instead of guessing. Suppressing it is how a discovery tool becomes a
|
|
@@ -14,9 +14,10 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
|
|
16
16
|
import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
|
|
17
|
-
import { loadRegistry
|
|
17
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
18
18
|
import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
|
|
20
|
+
import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
20
21
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
21
22
|
import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
|
|
22
23
|
import { nearMatches } from '../resolve/near.mjs';
|
|
@@ -48,6 +49,22 @@ export function parsePathList(value) {
|
|
|
48
49
|
.filter(Boolean);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Read a list flag in both the forms this tool ships.
|
|
54
|
+
*
|
|
55
|
+
* `--skill` accepts `--skill a --skill b` and `--skill a,b`, and arrives here as an
|
|
56
|
+
* array in the first case and a string in the second. `String(['a','b'])` happens to
|
|
57
|
+
* produce `'a,b'`, so the old one-line reader would have kept working by accident — and
|
|
58
|
+
* would have started throwing the moment a skill name contained the character the
|
|
59
|
+
* join inserts. Both shapes are handled rather than one being relied upon, because
|
|
60
|
+
* relying on it is the same class of bug as the one being fixed here.
|
|
61
|
+
*/
|
|
62
|
+
export function listFlag(value) {
|
|
63
|
+
return (Array.isArray(value) ? value : [value])
|
|
64
|
+
.flatMap((v) => parsePathList(v))
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
/** Compact stack label: the tags that actually discriminate, not all of them. */
|
|
52
69
|
function stackLabel(tags = []) {
|
|
53
70
|
const DISCRIMINATING = [
|
|
@@ -75,8 +92,8 @@ const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
|
75
92
|
// --- tier 0: index ------------------------------------------------------------
|
|
76
93
|
|
|
77
94
|
export async function indexCommand(args, flags) {
|
|
78
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
79
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
95
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
96
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
80
97
|
const workspace = await workspaceLine(flags);
|
|
81
98
|
|
|
82
99
|
if (isJson(flags)) {
|
|
@@ -189,8 +206,8 @@ export async function findCommand(args, flags) {
|
|
|
189
206
|
const query = args.join(' ').trim();
|
|
190
207
|
if (!query) return fail('find needs a query', EXIT.USAGE, `usage: ${CLI} skills find <query>`);
|
|
191
208
|
|
|
192
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
193
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
209
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
210
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
194
211
|
|
|
195
212
|
const tokens = tokenize(query);
|
|
196
213
|
const workspace = await workspaceLine(flags);
|
|
@@ -227,8 +244,8 @@ export async function findCommand(args, flags) {
|
|
|
227
244
|
// --- brief: tier 1 ------------------------------------------------------------
|
|
228
245
|
|
|
229
246
|
export async function briefCommand(args, flags) {
|
|
230
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
231
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
247
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
248
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
232
249
|
|
|
233
250
|
const root = flags.root ?? process.cwd();
|
|
234
251
|
const repo = await fingerprint(root);
|
|
@@ -237,7 +254,7 @@ export async function briefCommand(args, flags) {
|
|
|
237
254
|
const repoStack = repo.signals;
|
|
238
255
|
const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
|
|
239
256
|
const taskTokens = tokenize(flags.task ?? '');
|
|
240
|
-
const forced =
|
|
257
|
+
const forced = listFlag(flags.skill);
|
|
241
258
|
const budget = flags.budget ?? DEFAULT_BUDGET;
|
|
242
259
|
|
|
243
260
|
// A brief with no task and no paths resolves on stack alone, which produces a
|
|
@@ -432,8 +449,8 @@ export async function showCommand(args, flags) {
|
|
|
432
449
|
const name = args[0];
|
|
433
450
|
if (!name) return fail('show needs a skill name', EXIT.USAGE, `usage: ${CLI} skills show <name>`);
|
|
434
451
|
|
|
435
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
436
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
452
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
453
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
437
454
|
|
|
438
455
|
const skill = registry.skills.find((s) => s.name === name);
|
|
439
456
|
if (!skill) {
|
|
@@ -490,8 +507,8 @@ export async function whyCommand(args, flags) {
|
|
|
490
507
|
const name = args[0];
|
|
491
508
|
if (!name) return fail('why needs a skill name', EXIT.USAGE, `usage: ${CLI} skills why <name>`);
|
|
492
509
|
|
|
493
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
494
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
510
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
511
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
495
512
|
|
|
496
513
|
const skill = registry.skills.find((s) => s.name === name);
|
|
497
514
|
if (!skill) {
|
|
@@ -565,6 +582,117 @@ export async function whyCommand(args, flags) {
|
|
|
565
582
|
return EXIT.OK;
|
|
566
583
|
}
|
|
567
584
|
|
|
585
|
+
// --- eval ---------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The gold corpus when the reader names none. A checkout holds one at this fixed path;
|
|
589
|
+
* `test/` is not in the published files, so an installed copy does not. That absence is
|
|
590
|
+
* a fact about where this copy runs, reported as such (ADR-061's tiered resolution is
|
|
591
|
+
* the same shape: explicit path, then the package's own file, then an honest absence).
|
|
592
|
+
*/
|
|
593
|
+
const DEFAULT_CORPUS = join(PACKAGE_ROOT, 'test', 'fixtures', 'eval-corpus.json');
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `simt skills eval [corpus]` - measure the resolver against the gold corpus.
|
|
597
|
+
*
|
|
598
|
+
* Plan 0002 section 9: "Reports precision and recall and fails on regression." The
|
|
599
|
+
* corpus states, per case, which skills must come back (`select`) and which must not
|
|
600
|
+
* (`reject`); a case that names no selection is exhaustive, and anything it returns is
|
|
601
|
+
* a miss. The exit code rests on `clean`, because the two ratios can both read 1.0
|
|
602
|
+
* while an exhaustive case returns a skill neither list named (see src/resolve/eval.mjs).
|
|
603
|
+
*/
|
|
604
|
+
export async function evalCommand(positional, flags) {
|
|
605
|
+
const corpusPath = positional[0] ?? (existsSync(DEFAULT_CORPUS) ? DEFAULT_CORPUS : null);
|
|
606
|
+
|
|
607
|
+
if (corpusPath === null) {
|
|
608
|
+
return fail(
|
|
609
|
+
`no eval corpus: ${DEFAULT_CORPUS} is not on this machine (test data does not ship in the package)`,
|
|
610
|
+
EXIT.FAIL,
|
|
611
|
+
`run: ${CLI} skills eval <path-to-corpus> (a file of cases with select and reject lists)`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let corpus;
|
|
616
|
+
try {
|
|
617
|
+
corpus = JSON.parse(await readFile(corpusPath, 'utf8'));
|
|
618
|
+
} catch (error) {
|
|
619
|
+
return fail(
|
|
620
|
+
`${corpusPath}: unreadable or invalid JSON (${error.message})`,
|
|
621
|
+
EXIT.FAIL,
|
|
622
|
+
`run: ${CLI} skills eval <path-to-corpus>`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
if (!Array.isArray(corpus.cases) || corpus.cases.length === 0) {
|
|
626
|
+
return fail(
|
|
627
|
+
`${corpusPath}: no cases (a corpus is {"cases": [{"name", "repo", "task", "select", "reject"}]})`,
|
|
628
|
+
EXIT.FAIL,
|
|
629
|
+
`run: ${CLI} skills eval <path-to-corpus>`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// The corpus is measured against the registry it cites, so it needs the same
|
|
634
|
+
// resolution and the same failure as every other skills command.
|
|
635
|
+
const { registry, errors, ok, path } = await loadRegistry(null, flags);
|
|
636
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
637
|
+
|
|
638
|
+
const unknown = unknownSkills(registry, corpus);
|
|
639
|
+
const score = scoreCorpus(registry, corpus);
|
|
640
|
+
const okRun = unknown.length === 0 && score.clean;
|
|
641
|
+
|
|
642
|
+
if (isJson(flags)) {
|
|
643
|
+
emit(
|
|
644
|
+
`${JSON.stringify(
|
|
645
|
+
{
|
|
646
|
+
ok: okRun,
|
|
647
|
+
command: 'skills eval',
|
|
648
|
+
corpus: corpusPath,
|
|
649
|
+
registry: path,
|
|
650
|
+
cases: score.cases.map((c) => ({
|
|
651
|
+
name: c.name,
|
|
652
|
+
selected: c.selected,
|
|
653
|
+
missing: c.missing,
|
|
654
|
+
unexpected: c.unexpected,
|
|
655
|
+
})),
|
|
656
|
+
unknownSkills: unknown,
|
|
657
|
+
precision: score.precision,
|
|
658
|
+
recall: score.recall,
|
|
659
|
+
},
|
|
660
|
+
null,
|
|
661
|
+
2,
|
|
662
|
+
)}\n`,
|
|
663
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
664
|
+
);
|
|
665
|
+
return okRun ? EXIT.OK : EXIT.FAIL;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const failed = score.cases.filter((c) => c.missing.length > 0 || c.unexpected.length > 0);
|
|
669
|
+
const lines = [
|
|
670
|
+
`corpus ${corpusPath} (${score.cases.length} cases)`,
|
|
671
|
+
`registry ${path}`,
|
|
672
|
+
`${score.cases.length - failed.length} pass, ${failed.length} fail`,
|
|
673
|
+
`precision ${score.precision.toFixed(3)} recall ${score.recall.toFixed(3)}`,
|
|
674
|
+
'',
|
|
675
|
+
];
|
|
676
|
+
|
|
677
|
+
for (const c of score.cases) {
|
|
678
|
+
if (c.missing.length === 0 && c.unexpected.length === 0) {
|
|
679
|
+
lines.push(`PASS ${c.name}`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
lines.push(`FAIL ${c.name}`);
|
|
683
|
+
for (const name of c.missing) lines.push(` missed ${name}`);
|
|
684
|
+
for (const name of c.unexpected) lines.push(` unexpected ${name}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (unknown.length > 0) {
|
|
688
|
+
lines.push('', `FAIL corpus cites skills the registry does not declare: ${unknown.join(', ')}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
lines.push('', okRun ? 'OK the resolver meets the gold corpus' : 'REGRESSION: the resolver no longer meets the gold corpus');
|
|
692
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
693
|
+
return okRun ? EXIT.OK : EXIT.FAIL;
|
|
694
|
+
}
|
|
695
|
+
|
|
568
696
|
// --- bodies -------------------------------------------------------------------
|
|
569
697
|
|
|
570
698
|
/**
|
|
@@ -581,7 +709,7 @@ export async function whyCommand(args, flags) {
|
|
|
581
709
|
* location for a declared source, matched on bytes rather than on hope (ADR-015).
|
|
582
710
|
* The canonical source is still tried first, so a workspace body always wins.
|
|
583
711
|
*/
|
|
584
|
-
|
|
712
|
+
async function sourcePath(skill, workspaceRoot, root) {
|
|
585
713
|
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
586
714
|
|
|
587
715
|
if (canonical) {
|
|
@@ -25,7 +25,7 @@ import { existsSync } from 'node:fs';
|
|
|
25
25
|
import { dirname, join } from 'node:path';
|
|
26
26
|
|
|
27
27
|
import { EXIT, emit, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
28
|
-
import { loadRegistry,
|
|
28
|
+
import { loadRegistry, measureSkill } from '../graph/load.mjs';
|
|
29
29
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
30
30
|
import { readCapability } from '../hooks/capability.mjs';
|
|
31
31
|
import { MANIFEST, buildManifest, readManifest, writeManifest } from '../projection/manifest.mjs';
|
|
@@ -39,7 +39,7 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
39
39
|
/**
|
|
40
40
|
* Where skills live in this repo. The estate already has a convention - a
|
|
41
41
|
* canonical directory plus symlink fan-out - and it is not the same directory in
|
|
42
|
-
* every repo (`.agents/skills` in
|
|
42
|
+
* every repo (`.agents/skills` in some, `.cursor/skills` in others). So the
|
|
43
43
|
* existing layout is respected and only invented when there is none.
|
|
44
44
|
*/
|
|
45
45
|
export function skillsDir(root, entries) {
|
|
@@ -59,8 +59,8 @@ export async function installCommand(args, flags) {
|
|
|
59
59
|
);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const { registry, errors, ok } = await loadRegistry(
|
|
63
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
62
|
+
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
63
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
64
64
|
|
|
65
65
|
const skill = registry.skills.find((s) => s.name === name);
|
|
66
66
|
if (!skill) {
|