simt-dev 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/commands/skills.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// token instead of guessing. Suppressing it is how a discovery tool becomes a
|
|
10
10
|
// black box.
|
|
11
11
|
|
|
12
|
-
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
|
|
@@ -20,6 +20,7 @@ import { unknownSkills, scoreCorpus } from '../resolve/eval.mjs';
|
|
|
20
20
|
import { PACKAGE_ROOT, resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
21
21
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
22
22
|
import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
|
|
23
|
+
import { localSkillNodes } from '../resolve/local.mjs';
|
|
23
24
|
import { nearMatches } from '../resolve/near.mjs';
|
|
24
25
|
import { CLI } from '../name.mjs';
|
|
25
26
|
import { parsePathList } from '../argv.mjs';
|
|
@@ -51,6 +52,265 @@ export function listFlag(value) {
|
|
|
51
52
|
.filter(Boolean);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
|
|
56
|
+
//
|
|
57
|
+
// `skills_brief`, `skills_find`, `skills_show` and `skills_why` in the MCP
|
|
58
|
+
// surface used to rebuild these answers from the same modules with different
|
|
59
|
+
// loops, and the copies had already drifted: the budget loop inlined a deferred
|
|
60
|
+
// dependency on one surface and not the other, and the payloads disagreed on
|
|
61
|
+
// field names (`resolved`/`selected`, `reasons`/`why`, `signals`). The commands
|
|
62
|
+
// below render these facts for terminals; the tools return them as JSON. The
|
|
63
|
+
// CLI keeps its field names; the tools adopt them.
|
|
64
|
+
//
|
|
65
|
+
// A core throws when it cannot answer: the message names the problem, `remedy`
|
|
66
|
+
// names the next command, and `exitCode` is the exit the CLI leaves with. The
|
|
67
|
+
// MCP handler turns the same throw into an error result, so both surfaces
|
|
68
|
+
// report the same failure for the same cause.
|
|
69
|
+
function skillsFailure(message, remedy, exitCode) {
|
|
70
|
+
throw Object.assign(new Error(message), { remedy, exitCode });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function skillsRegistry(args) {
|
|
74
|
+
const { registry, errors, ok } = await loadRegistry(null, args);
|
|
75
|
+
if (!ok) skillsFailure(errors[0], WORKSPACE_REMEDY, EXIT.REGISTRY);
|
|
76
|
+
return registry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The named skill: the registry first, then the repo's own skills, because a skill
|
|
81
|
+
* the repo installed is addressable by the same name (plan 0005 S3). The local lookup
|
|
82
|
+
* costs a fingerprint plus a directory scan, so it runs only when the registry did
|
|
83
|
+
* not answer.
|
|
84
|
+
*/
|
|
85
|
+
async function findSkill(registry, root, name, verb) {
|
|
86
|
+
if (!name) skillsFailure(`${verb} needs a skill name`, `usage: ${CLI} skills ${verb} <name>`, EXIT.USAGE);
|
|
87
|
+
const skill = registry.skills.find((s) => s.name === name);
|
|
88
|
+
if (skill) return skill;
|
|
89
|
+
const repoStack = (await fingerprint(root)).signals;
|
|
90
|
+
const local = (await localSkillNodes(root, registry, repoStack)).find((s) => s.name === name);
|
|
91
|
+
if (local) return local;
|
|
92
|
+
const near = nearMatches(name, registry.skills.map((s) => s.name));
|
|
93
|
+
skillsFailure(
|
|
94
|
+
`no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
95
|
+
`run: ${CLI} skills find <query>`,
|
|
96
|
+
EXIT.USAGE,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The JSON-safe skill facts both surfaces spread: absent lists read as empty, not missing. */
|
|
101
|
+
function skillFacts(skill) {
|
|
102
|
+
return {
|
|
103
|
+
...skill,
|
|
104
|
+
capabilities: skill.capabilities ?? [],
|
|
105
|
+
stack: skill.stack ?? [],
|
|
106
|
+
keywords: skill.keywords ?? [],
|
|
107
|
+
requires: skill.requires ?? [],
|
|
108
|
+
variants: skill.variants ?? [],
|
|
109
|
+
aliasOf: skill.aliasOf ?? null,
|
|
110
|
+
variantOf: skill.variantOf ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Tier 1: the selection the task resolves to, with the text packet's display sets. */
|
|
115
|
+
export async function skillsBriefFacts(root, args = {}) {
|
|
116
|
+
const registry = await skillsRegistry(args);
|
|
117
|
+
const repo = await fingerprint(root);
|
|
118
|
+
const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
|
|
119
|
+
const repoStack = repo.signals;
|
|
120
|
+
const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
|
|
121
|
+
const taskTokens = tokenize(args.task ?? '');
|
|
122
|
+
const forced = listFlag(args.skill);
|
|
123
|
+
const budget = args.budget ?? DEFAULT_BUDGET;
|
|
124
|
+
const task = args.task ?? null;
|
|
125
|
+
const repoFacts = { root, stack: repoStack, facts: repo.facts };
|
|
126
|
+
|
|
127
|
+
// The repo's own skills rank alongside the registry, so every command agrees on
|
|
128
|
+
// the names a reader in this repo can address (plan 0005 S3).
|
|
129
|
+
const pool = [...registry.skills, ...(await localSkillNodes(root, registry, repoStack))];
|
|
130
|
+
|
|
131
|
+
// A brief with no task and no paths resolves on stack alone, which produces a
|
|
132
|
+
// plausible-looking list the agent may act on. It warns rather than silently
|
|
133
|
+
// answering; under `--json` the warning is a field, not prose on stdout.
|
|
134
|
+
if (!args.task && paths.length === 0 && forced.length === 0) {
|
|
135
|
+
const warning =
|
|
136
|
+
'no --task and no staged files, so selection can only use the repo signals. ' +
|
|
137
|
+
'pass --task "<what you are doing>" or stage files so the diff drives selection, ' +
|
|
138
|
+
'or pass --all to take everything for this stack.';
|
|
139
|
+
if (args.all) {
|
|
140
|
+
const usable = pool.filter((s) => stackCompatible(s, repoStack));
|
|
141
|
+
return {
|
|
142
|
+
kind: 'all',
|
|
143
|
+
repoStack,
|
|
144
|
+
inline: usable.filter((s) => !s.deferred),
|
|
145
|
+
deferred: usable.filter((s) => s.deferred),
|
|
146
|
+
warning,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
kind: 'brief',
|
|
151
|
+
repo: repoFacts,
|
|
152
|
+
workspace,
|
|
153
|
+
task,
|
|
154
|
+
budget,
|
|
155
|
+
inlineBytes: 0,
|
|
156
|
+
resolved: [],
|
|
157
|
+
deferred: [],
|
|
158
|
+
excluded: [],
|
|
159
|
+
shown: [],
|
|
160
|
+
shownDeferred: [],
|
|
161
|
+
shownUsed: 0,
|
|
162
|
+
pointerOnly: false,
|
|
163
|
+
warning,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const context = { repoStack, taskTokens, paths };
|
|
168
|
+
const { selected, excluded } = rankSkills(pool, context, { forced });
|
|
169
|
+
|
|
170
|
+
// Hard dependencies are pulled in even when they did not score, and even past
|
|
171
|
+
// budget: a skill that is useless without its dependency is worse than a large
|
|
172
|
+
// context, because it silently misleads.
|
|
173
|
+
const expanded = closure(selected, pool);
|
|
174
|
+
const { picked, deferred, used: inlineBytes } = selectWithinBudget(expanded, {
|
|
175
|
+
budget,
|
|
176
|
+
all: args.all === true,
|
|
177
|
+
});
|
|
178
|
+
const closureNames = new Set(expanded.map((e) => e.skill.name));
|
|
179
|
+
|
|
180
|
+
const plain = (entry) => ({
|
|
181
|
+
name: entry.skill.name,
|
|
182
|
+
bytes: entry.skill.bytes,
|
|
183
|
+
summary: entry.skill.summary,
|
|
184
|
+
why: [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)],
|
|
185
|
+
});
|
|
186
|
+
// A body the packet cannot paste costs nothing. Charging the catalogue size
|
|
187
|
+
// is how a brief with no workspace reports "budget capped" and then prints
|
|
188
|
+
// the same remedy once per skill.
|
|
189
|
+
const readable = new Set();
|
|
190
|
+
for (const entry of expanded) {
|
|
191
|
+
if (await bodyReadable(entry.skill, workspaceRoot, root)) readable.add(entry.skill.name);
|
|
192
|
+
}
|
|
193
|
+
const pointerOnly = expanded.length > 0 && readable.size === 0;
|
|
194
|
+
|
|
195
|
+
let shown = picked;
|
|
196
|
+
let shownDeferred = deferred;
|
|
197
|
+
let shownUsed = inlineBytes;
|
|
198
|
+
if (pointerOnly) {
|
|
199
|
+
const priced = expanded.map((e) =>
|
|
200
|
+
e.skill.deferred ? e : { ...e, skill: { ...e.skill, bytes: 0 } },
|
|
201
|
+
);
|
|
202
|
+
const again = selectWithinBudget(priced, { budget, all: args.all === true });
|
|
203
|
+
shown = again.picked;
|
|
204
|
+
shownDeferred = again.deferred;
|
|
205
|
+
shownUsed = again.used;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
kind: 'brief',
|
|
210
|
+
repo: repoFacts,
|
|
211
|
+
workspace,
|
|
212
|
+
workspaceRoot,
|
|
213
|
+
task,
|
|
214
|
+
budget,
|
|
215
|
+
inlineBytes,
|
|
216
|
+
resolved: picked.map(plain),
|
|
217
|
+
deferred: deferred.map((e) => ({
|
|
218
|
+
name: e.skill.name,
|
|
219
|
+
bytes: e.skill.bytes,
|
|
220
|
+
reason: e.skill.deferred ? 'marked deferred' : 'over budget',
|
|
221
|
+
})),
|
|
222
|
+
excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
|
|
223
|
+
shown,
|
|
224
|
+
shownDeferred,
|
|
225
|
+
shownUsed,
|
|
226
|
+
pointerOnly,
|
|
227
|
+
excludedEntries: excluded.filter((e) => !closureNames.has(e.skill.name)),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Raw search across capability, keyword and summary.
|
|
233
|
+
*
|
|
234
|
+
* Local nodes are admitted on `score > 0`, not the `selectionVerdict` rule the
|
|
235
|
+
* registry nodes keep: that rule exists to compensate for find having no repo, and
|
|
236
|
+
* a local skill has one - its presence in the repo is the scoping signal the rule
|
|
237
|
+
* stands in for.
|
|
238
|
+
*/
|
|
239
|
+
export async function skillsFindFacts(root, query, args = {}) {
|
|
240
|
+
const q = String(query ?? '').trim();
|
|
241
|
+
if (!q) skillsFailure('find needs a query', `usage: ${CLI} skills find <query>`, EXIT.USAGE);
|
|
242
|
+
const registry = await skillsRegistry(args);
|
|
243
|
+
const tokens = tokenize(q);
|
|
244
|
+
const context = { repoStack: [], taskTokens: tokens, paths: [] };
|
|
245
|
+
const results = [
|
|
246
|
+
...registry.skills
|
|
247
|
+
.map((skill) => ({ skill, ...scoreSkill(skill, context) }))
|
|
248
|
+
.filter((r) => selectionVerdict(r.evidence, r.reasons) === null),
|
|
249
|
+
...(await localSkillNodes(root, registry, (await fingerprint(root)).signals))
|
|
250
|
+
.map((skill) => ({ skill, ...scoreSkill(skill, context) }))
|
|
251
|
+
.filter((r) => r.score > 0),
|
|
252
|
+
].sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
253
|
+
return {
|
|
254
|
+
query: q,
|
|
255
|
+
results: results.map((r) => ({ name: r.skill.name, score: r.score, bytes: r.skill.bytes, summary: r.skill.summary, reasons: r.reasons })),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Tier 2: one skill body and the metadata needed to judge it. */
|
|
260
|
+
export async function skillsShowFacts(root, name, args = {}) {
|
|
261
|
+
const registry = await skillsRegistry(args);
|
|
262
|
+
const skill = await findSkill(registry, root, name, 'show');
|
|
263
|
+
const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
|
|
264
|
+
// The repo is a location for a declared source too, so `show` reads a body the
|
|
265
|
+
// caller already has instead of reporting the body missing.
|
|
266
|
+
const body = await sourcePath(skill, workspaceRoot, root);
|
|
267
|
+
// A local file is canonicalized by realpath, while a declared repo source keeps the
|
|
268
|
+
// caller's spelling: on a symlinked prefix (macOS `/var`) the label must accept the
|
|
269
|
+
// canonical root too, or a repo body is mislabelled `workspace`.
|
|
270
|
+
let realRoot = root;
|
|
271
|
+
try {
|
|
272
|
+
realRoot = await realpath(root);
|
|
273
|
+
} catch {
|
|
274
|
+
/* root does not exist; the label falls back to the un-canonical comparison */
|
|
275
|
+
}
|
|
276
|
+
const bodyFrom =
|
|
277
|
+
body === null ? 'none' : body.startsWith(`${root}/`) || body.startsWith(`${realRoot}/`) ? 'repo' : 'workspace';
|
|
278
|
+
return {
|
|
279
|
+
skill: skillFacts(skill),
|
|
280
|
+
workspace,
|
|
281
|
+
body,
|
|
282
|
+
bodyFrom,
|
|
283
|
+
bodyText: await readBody(skill, workspaceRoot, root),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The signal chain for one skill in one repository. */
|
|
288
|
+
export async function skillsWhyFacts(root, name, args = {}) {
|
|
289
|
+
const registry = await skillsRegistry(args);
|
|
290
|
+
const skill = await findSkill(registry, root, name, 'why');
|
|
291
|
+
const repo = await fingerprint(root);
|
|
292
|
+
const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
|
|
293
|
+
const tokens = tokenize(args.task ?? '');
|
|
294
|
+
const { score, reasons } = scoreSkill(skill, { repoStack: repo.signals, taskTokens: tokens, paths });
|
|
295
|
+
const compatible = stackCompatible(skill, repo.signals);
|
|
296
|
+
const ws = await resolveWorkspace(args);
|
|
297
|
+
return {
|
|
298
|
+
skill: skill.name,
|
|
299
|
+
score,
|
|
300
|
+
compatible,
|
|
301
|
+
gate: compatible ? 'pass' : 'fail',
|
|
302
|
+
repo: { root, stack: repo.stack, facts: repo.facts, evidence: repo.evidence },
|
|
303
|
+
workspace: { root: ws.root ?? null, source: ws.source, detail: ws.detail },
|
|
304
|
+
paths: paths.slice(0, 8),
|
|
305
|
+
taskTokens: tokens,
|
|
306
|
+
signals: reasons,
|
|
307
|
+
variants: (skill.variants ?? []).map((v) => ({ id: v.id, stack: v.stack, bytes: v.bytes })),
|
|
308
|
+
aliasOf: skill.aliasOf ?? null,
|
|
309
|
+
variantOf: skill.variantOf ?? null,
|
|
310
|
+
requires: skill.requires ?? [],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
54
314
|
/** Compact stack label: the tags that actually discriminate, not all of them. */
|
|
55
315
|
function stackLabel(tags = []) {
|
|
56
316
|
const DISCRIMINATING = [
|
|
@@ -142,11 +402,8 @@ function repoLine(repo) {
|
|
|
142
402
|
}
|
|
143
403
|
|
|
144
404
|
/** `--all` with no other signal: everything this stack can use, deferred excluded. */
|
|
145
|
-
async function
|
|
146
|
-
const
|
|
147
|
-
const usable = registry.skills.filter((s) => stackCompatible(s, repoStack));
|
|
148
|
-
const deferred = usable.filter((s) => s.deferred);
|
|
149
|
-
const inline = usable.filter((s) => !s.deferred);
|
|
405
|
+
async function emitAllFacts(facts, flags) {
|
|
406
|
+
const { inline, deferred, repoStack, warning } = facts;
|
|
150
407
|
|
|
151
408
|
if (isJson(flags)) {
|
|
152
409
|
emitJson(
|
|
@@ -179,22 +436,19 @@ async function emitAll(registry, repoStack, flags, warning) {
|
|
|
179
436
|
// --- find ---------------------------------------------------------------------
|
|
180
437
|
|
|
181
438
|
export async function findCommand(args, flags) {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
439
|
+
const root = flags.root ?? process.cwd();
|
|
440
|
+
let facts;
|
|
441
|
+
try {
|
|
442
|
+
facts = await skillsFindFacts(root, args.join(' '), flags);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
445
|
+
}
|
|
446
|
+
const { query, results } = facts;
|
|
189
447
|
const workspace = await workspaceLine(flags);
|
|
190
|
-
const results = registry.skills
|
|
191
|
-
.map((skill) => ({ skill, ...scoreSkill(skill, { repoStack: [], taskTokens: tokens, paths: [] }) }))
|
|
192
|
-
.filter((r) => selectionVerdict(r.evidence, r.reasons) === null)
|
|
193
|
-
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
194
448
|
|
|
195
449
|
if (isJson(flags)) {
|
|
196
450
|
emitJson(
|
|
197
|
-
{ ok: true, query, workspace, results
|
|
451
|
+
{ ok: true, query, workspace, results },
|
|
198
452
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
199
453
|
);
|
|
200
454
|
return EXIT.OK;
|
|
@@ -212,7 +466,7 @@ export async function findCommand(args, flags) {
|
|
|
212
466
|
'',
|
|
213
467
|
...results.map(
|
|
214
468
|
(r) =>
|
|
215
|
-
`${r.
|
|
469
|
+
`${r.name.padEnd(26)} ${kb(r.bytes).padStart(7)} ${r.reasons.join(' ')}`,
|
|
216
470
|
),
|
|
217
471
|
].join('\n'),
|
|
218
472
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
@@ -221,128 +475,44 @@ export async function findCommand(args, flags) {
|
|
|
221
475
|
}
|
|
222
476
|
|
|
223
477
|
// --- brief: tier 1 ------------------------------------------------------------
|
|
224
|
-
|
|
225
478
|
export async function briefCommand(args, flags) {
|
|
226
|
-
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
227
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
228
|
-
|
|
229
479
|
const root = flags.root ?? process.cwd();
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const forced = listFlag(flags.skill);
|
|
237
|
-
const budget = flags.budget ?? DEFAULT_BUDGET;
|
|
480
|
+
let facts;
|
|
481
|
+
try {
|
|
482
|
+
facts = await skillsBriefFacts(root, flags);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
485
|
+
}
|
|
238
486
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// is what `warning` carries. Failing here would tell a caller the tool
|
|
255
|
-
// broke when in fact it is telling them their query was.
|
|
256
|
-
if (flags.all) return emitAll(registry, repoStack, flags, warning);
|
|
257
|
-
emitJson(
|
|
258
|
-
{
|
|
259
|
-
ok: true,
|
|
260
|
-
repo: { root, stack: repoStack },
|
|
261
|
-
workspace,
|
|
262
|
-
task: null,
|
|
263
|
-
budget,
|
|
264
|
-
resolved: [],
|
|
265
|
-
deferred: [],
|
|
266
|
-
excluded: [],
|
|
267
|
-
warning,
|
|
268
|
-
},
|
|
269
|
-
);
|
|
270
|
-
return EXIT.OK;
|
|
271
|
-
}
|
|
487
|
+
if (facts.kind === 'all') {
|
|
488
|
+
return emitAllFacts(facts, flags);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// `ok: true` because the command did what was asked; the answer being weak
|
|
492
|
+
// is what `warning` carries. Failing here would tell a caller the tool
|
|
493
|
+
// broke when in fact it is telling them their query was.
|
|
494
|
+
if (isJson(flags)) {
|
|
495
|
+
const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspaceRoot, kind, ...doc } = facts;
|
|
496
|
+
emitJson({ ok: true, ...doc });
|
|
497
|
+
return EXIT.OK;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (facts.warning) {
|
|
501
|
+
const repo = await fingerprint(root);
|
|
272
502
|
emit(
|
|
273
503
|
[
|
|
274
504
|
`repo ${root.split('/').pop()} (${repoLine(repo)})`,
|
|
275
505
|
'',
|
|
276
|
-
warning,
|
|
506
|
+
facts.warning,
|
|
277
507
|
].join('\n'),
|
|
278
508
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
279
509
|
);
|
|
280
|
-
if (flags.all) return emitAll(registry, repoStack, flags, warning);
|
|
281
510
|
return EXIT.OK;
|
|
282
511
|
}
|
|
283
512
|
|
|
284
|
-
const
|
|
285
|
-
const { selected, excluded } = rankSkills(registry.skills, context, { forced });
|
|
286
|
-
|
|
287
|
-
// Hard dependencies are pulled in even when they did not score, and even past
|
|
288
|
-
// budget: a skill that is useless without its dependency is worse than a large
|
|
289
|
-
// context, because it silently misleads.
|
|
290
|
-
const expanded = closure(selected, registry.skills);
|
|
291
|
-
const order = new Map(expanded.map((e, i) => [e.skill.name, i]));
|
|
292
|
-
expanded.sort((a, b) => (order.get(a.skill.name) - order.get(b.skill.name)));
|
|
293
|
-
|
|
294
|
-
// Budget, by one shared function. The loop this replaced existed here and in the MCP
|
|
295
|
-
// tool, and the copies had already drifted: this one skipped a `deferred` skill before
|
|
296
|
-
// looking at the budget and the other checked it after, so a deferred dependency was
|
|
297
|
-
// inlined by one command and not the other. `selectWithinBudget` is the whole
|
|
298
|
-
// arithmetic, and it is the only place that knows a dependency does not get to exceed
|
|
299
|
-
// the budget on its own — which is what let a 2,000-byte budget resolve 13,503 bytes.
|
|
300
|
-
const { picked, deferred, used: bytesUsed } = selectWithinBudget(expanded, {
|
|
301
|
-
budget,
|
|
302
|
-
all: flags.all === true,
|
|
303
|
-
});
|
|
304
|
-
const closureNames = new Set(expanded.map((e) => e.skill.name));
|
|
305
|
-
|
|
306
|
-
const excludedNames = new Set(excluded.map((e) => e.skill.name));
|
|
513
|
+
const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspace, workspaceRoot, budget } = facts;
|
|
307
514
|
const lines = [];
|
|
308
|
-
|
|
309
|
-
if (isJson(flags)) {
|
|
310
|
-
emitJson(
|
|
311
|
-
{
|
|
312
|
-
ok: true,
|
|
313
|
-
repo: { root, stack: repoStack },
|
|
314
|
-
workspace,
|
|
315
|
-
task: flags.task ?? null,
|
|
316
|
-
budget,
|
|
317
|
-
resolved: picked.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes, why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)] })),
|
|
318
|
-
deferred: deferred.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes })),
|
|
319
|
-
excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
|
|
320
|
-
},
|
|
321
|
-
);
|
|
322
|
-
return EXIT.OK;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// A body the packet cannot paste costs nothing. Charging the catalogue size
|
|
326
|
-
// is how a brief with no workspace reports "budget capped" and then prints
|
|
327
|
-
// the same remedy once per skill (S33).
|
|
328
|
-
const readable = new Set();
|
|
329
|
-
for (const entry of expanded) {
|
|
330
|
-
if (await bodyReadable(entry.skill, workspaceRoot, root)) readable.add(entry.skill.name);
|
|
331
|
-
}
|
|
332
|
-
const pointerOnly = expanded.length > 0 && readable.size === 0;
|
|
333
|
-
|
|
334
|
-
let shown = picked;
|
|
335
|
-
let shownDeferred = deferred;
|
|
336
|
-
let shownUsed = bytesUsed;
|
|
337
|
-
if (pointerOnly) {
|
|
338
|
-
const priced = expanded.map((e) =>
|
|
339
|
-
e.skill.deferred ? e : { ...e, skill: { ...e.skill, bytes: 0 } },
|
|
340
|
-
);
|
|
341
|
-
const again = selectWithinBudget(priced, { budget, all: flags.all === true });
|
|
342
|
-
shown = again.picked;
|
|
343
|
-
shownDeferred = again.deferred;
|
|
344
|
-
shownUsed = again.used;
|
|
345
|
-
}
|
|
515
|
+
const repo = await fingerprint(root);
|
|
346
516
|
|
|
347
517
|
lines.push(
|
|
348
518
|
`RESOLVED ${shown.length} skills, ${kb(shownUsed)}${flags.all || shownUsed <= budget ? '' : ' (budget capped)'} - repo ${root.split('/').pop()} (${repoLine(repo)})`,
|
|
@@ -358,7 +528,7 @@ export async function briefCommand(args, flags) {
|
|
|
358
528
|
lines.push('', `DEFERRED ${shownDeferred.length} (marked deferred)`);
|
|
359
529
|
for (const entry of shownDeferred) lines.push(pointerLine(entry));
|
|
360
530
|
}
|
|
361
|
-
const hidden =
|
|
531
|
+
const hidden = excludedEntries;
|
|
362
532
|
if (hidden.length > 0) {
|
|
363
533
|
lines.push('', `NOT SELECTED ${hidden.length}`, '', ` notes: ${CLI} skills why <name> | ${CLI} skills show <name>`);
|
|
364
534
|
}
|
|
@@ -398,7 +568,7 @@ export async function briefCommand(args, flags) {
|
|
|
398
568
|
}
|
|
399
569
|
}
|
|
400
570
|
|
|
401
|
-
const notSelected =
|
|
571
|
+
const notSelected = excludedEntries;
|
|
402
572
|
if (notSelected.length > 0) {
|
|
403
573
|
lines.push('', 'NOT SELECTED');
|
|
404
574
|
for (const { skill, reason } of notSelected) {
|
|
@@ -415,31 +585,17 @@ export async function briefCommand(args, flags) {
|
|
|
415
585
|
// --- show: tier 2 -------------------------------------------------------------
|
|
416
586
|
|
|
417
587
|
export async function showCommand(args, flags) {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const skill = registry.skills.find((s) => s.name === name);
|
|
425
|
-
if (!skill) {
|
|
426
|
-
const near = nearMatches(name, registry.skills.map((s) => s.name));
|
|
427
|
-
return fail(
|
|
428
|
-
`no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
429
|
-
EXIT.USAGE,
|
|
430
|
-
`run: ${CLI} skills find <query>`,
|
|
431
|
-
);
|
|
588
|
+
const root = flags.root ?? process.cwd();
|
|
589
|
+
let facts;
|
|
590
|
+
try {
|
|
591
|
+
facts = await skillsShowFacts(root, args[0], flags);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
432
594
|
}
|
|
433
|
-
|
|
595
|
+
const { skill, workspace, body, bodyFrom, bodyText } = facts;
|
|
434
596
|
const source = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
435
|
-
const { note: workspace, root: workspaceRoot } = await announceWorkspace(flags);
|
|
436
|
-
// The repo is a location for a declared source too, so `show` reads a body the
|
|
437
|
-
// caller already has instead of reporting the body missing (ADR-015).
|
|
438
|
-
const root = flags.root ?? process.cwd();
|
|
439
|
-
const body = await sourcePath(skill, workspaceRoot, root);
|
|
440
|
-
const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
|
|
441
597
|
if (isJson(flags)) {
|
|
442
|
-
emitJson({ ok: true, ...skill, workspace, body, bodyFrom }, { maxBytes: flags.maxBytes ?? 0 });
|
|
598
|
+
emitJson({ ok: true, ...skill, workspace, body, bodyFrom, bodyText }, { maxBytes: flags.maxBytes ?? 0 });
|
|
443
599
|
return EXIT.OK;
|
|
444
600
|
}
|
|
445
601
|
emit(
|
|
@@ -458,7 +614,7 @@ export async function showCommand(args, flags) {
|
|
|
458
614
|
`body ${body === null ? '(unavailable)' : `${bodyFrom} ${body}`}`,
|
|
459
615
|
workspace,
|
|
460
616
|
'',
|
|
461
|
-
|
|
617
|
+
bodyText,
|
|
462
618
|
]
|
|
463
619
|
.filter((l) => l !== null)
|
|
464
620
|
.join('\n'),
|
|
@@ -470,29 +626,13 @@ export async function showCommand(args, flags) {
|
|
|
470
626
|
// --- why ----------------------------------------------------------------------
|
|
471
627
|
|
|
472
628
|
export async function whyCommand(args, flags) {
|
|
473
|
-
const name = args[0];
|
|
474
|
-
if (!name) return fail('why needs a skill name', EXIT.USAGE, `usage: ${CLI} skills why <name>`);
|
|
475
|
-
|
|
476
|
-
const { registry, errors, ok } = await loadRegistry(null, flags);
|
|
477
|
-
if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
|
|
478
|
-
|
|
479
|
-
const skill = registry.skills.find((s) => s.name === name);
|
|
480
|
-
if (!skill) {
|
|
481
|
-
const near = nearMatches(name, registry.skills.map((s) => s.name));
|
|
482
|
-
return fail(
|
|
483
|
-
`no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
484
|
-
EXIT.USAGE,
|
|
485
|
-
`run: ${CLI} skills find <query>`,
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
629
|
const root = flags.root ?? process.cwd();
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
630
|
+
let facts;
|
|
631
|
+
try {
|
|
632
|
+
facts = await skillsWhyFacts(root, args[0], flags);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
|
|
635
|
+
}
|
|
496
636
|
|
|
497
637
|
// Every other command honours `--json`; this one built its answer as text and
|
|
498
638
|
// emitted it, so `--json` was silently ignored and a caller got prose it could
|
|
@@ -501,48 +641,31 @@ export async function whyCommand(args, flags) {
|
|
|
501
641
|
// to scrape: a skill that can never be selected in this repo is a decision, not
|
|
502
642
|
// a line of output.
|
|
503
643
|
if (isJson(flags)) {
|
|
504
|
-
|
|
505
|
-
emitJson({
|
|
506
|
-
ok: true,
|
|
507
|
-
command: 'skills why',
|
|
508
|
-
skill: skill.name,
|
|
509
|
-
score,
|
|
510
|
-
compatible,
|
|
511
|
-
gate: compatible ? 'pass' : 'fail',
|
|
512
|
-
repo: { root, stack: repo.stack, facts: repo.facts, evidence: repo.evidence },
|
|
513
|
-
workspace: { root: workspace.root ?? null, source: workspace.source, detail: workspace.detail },
|
|
514
|
-
paths: paths.slice(0, 8),
|
|
515
|
-
taskTokens: tokens,
|
|
516
|
-
signals: reasons,
|
|
517
|
-
variants: (skill.variants ?? []).map((v) => ({ id: v.id, stack: v.stack, bytes: v.bytes })),
|
|
518
|
-
aliasOf: skill.aliasOf ?? null,
|
|
519
|
-
variantOf: skill.variantOf ?? null,
|
|
520
|
-
requires: skill.requires ?? [],
|
|
521
|
-
}, { maxBytes: flags.maxBytes ?? 0 });
|
|
644
|
+
emitJson({ ok: true, command: 'skills why', ...facts }, { maxBytes: flags.maxBytes ?? 0 });
|
|
522
645
|
return EXIT.OK;
|
|
523
646
|
}
|
|
524
647
|
|
|
525
648
|
const lines = [
|
|
526
|
-
`${skill
|
|
649
|
+
`${facts.skill} - score ${facts.score}`,
|
|
527
650
|
'',
|
|
528
|
-
`repo stack ${repo.stack.join(', ') || 'undetected'}`,
|
|
529
|
-
`repo facts ${repo.facts.join(', ') || 'none'}`,
|
|
530
|
-
`repo evidence ${repo.evidence.join(' ') || 'none'}`,
|
|
651
|
+
`repo stack ${facts.repo.stack.join(', ') || 'undetected'}`,
|
|
652
|
+
`repo facts ${facts.repo.facts.join(', ') || 'none'}`,
|
|
653
|
+
`repo evidence ${facts.repo.evidence.join(' ') || 'none'}`,
|
|
531
654
|
await workspaceLine(flags),
|
|
532
|
-
`paths seen ${paths.length > 0 ? paths.
|
|
533
|
-
`task tokens ${
|
|
655
|
+
`paths seen ${facts.paths.length > 0 ? facts.paths.join(' ') : '(none - pass --paths or stage files)'}`,
|
|
656
|
+
`task tokens ${facts.taskTokens.length > 0 ? facts.taskTokens.join(' ') : '(none - pass --task)'}`,
|
|
534
657
|
'',
|
|
535
|
-
`gate ${compatible ? 'pass (stack overlaps the repo)' : 'FAIL (no stack overlap, so it can never be selected)'}`,
|
|
536
|
-
`signals ${
|
|
658
|
+
`gate ${facts.compatible ? 'pass (stack overlaps the repo)' : 'FAIL (no stack overlap, so it can never be selected)'}`,
|
|
659
|
+
`signals ${facts.signals.length > 0 ? facts.signals.join(' ') : '(none - this skill scores 0 for the current context)'}`,
|
|
537
660
|
];
|
|
538
661
|
|
|
539
|
-
if (
|
|
662
|
+
if (facts.variants?.length > 0) {
|
|
540
663
|
lines.push('', 'variants (one body per stack):');
|
|
541
|
-
for (const v of
|
|
664
|
+
for (const v of facts.variants) lines.push(` ${v.id.padEnd(8)} ${v.stack.join('+').padEnd(30)} ${kb(v.bytes)}`);
|
|
542
665
|
}
|
|
543
|
-
if (
|
|
544
|
-
if (
|
|
545
|
-
if (
|
|
666
|
+
if (facts.aliasOf) lines.push('', `alias-of ${facts.aliasOf} (retired name, resolves to that skill)`);
|
|
667
|
+
if (facts.variantOf) lines.push('', `variant-of ${facts.variantOf} (sibling group, one body per stack)`);
|
|
668
|
+
if (facts.requires?.length > 0) lines.push(`requires ${facts.requires.join(', ')}`);
|
|
546
669
|
|
|
547
670
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
548
671
|
return EXIT.OK;
|
|
@@ -668,7 +791,7 @@ async function bodyReadable(skill, workspaceRoot, root) {
|
|
|
668
791
|
return Boolean(full && existsSync(full));
|
|
669
792
|
}
|
|
670
793
|
|
|
671
|
-
|
|
794
|
+
async function readBody(skill, workspaceRoot, root) {
|
|
672
795
|
const full = await sourcePath(skill, workspaceRoot, root);
|
|
673
796
|
if (!full) {
|
|
674
797
|
return `(body unavailable: this installation has no workspace and ${root ?? 'this directory'} does not hold ${skill.name}; ${WORKSPACE_REMEDY})`;
|