simt-dev 0.2.0__py3-none-any.whl
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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// `simt-dev skills` - tier 0 (index), tier 1 (brief), tier 2 (show), plus find/why.
|
|
2
|
+
//
|
|
3
|
+
// Implementation sequence steps 4 and 5.
|
|
4
|
+
//
|
|
5
|
+
// Output design (plan 0002 section 6): one fact per line, fixed field order, no
|
|
6
|
+
// wrapping, no colour when piped, bounded by --max-bytes with an explicit
|
|
7
|
+
// TRUNCATED line. The NOT SELECTED block is as important as the resolved block -
|
|
8
|
+
// the agent can see what exists and why it was excluded, and override in one
|
|
9
|
+
// token instead of guessing. Suppressing it is how a discovery tool becomes a
|
|
10
|
+
// black box.
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import { EXIT, emit, emitJson, fail, isJson, note as emitNote } from '../output/index.mjs';
|
|
17
|
+
import { loadRegistry, REGISTRY_PATH } from '../graph/load.mjs';
|
|
18
|
+
import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
|
|
19
|
+
import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
|
|
20
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
21
|
+
import { tokenize, rankSkills, closure, selectWithinBudget, resolveAlias, scoreSkill, selectionVerdict, stackCompatible } from '../resolve/resolve.mjs';
|
|
22
|
+
import { nearMatches } from '../resolve/near.mjs';
|
|
23
|
+
import { CLI } from '../name.mjs';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BUDGET = 20480;
|
|
26
|
+
|
|
27
|
+
/** git status paths, so path signals come from the real diff. */
|
|
28
|
+
export async function dirtyPaths(root) {
|
|
29
|
+
try {
|
|
30
|
+
const { execFile } = await import('node:child_process');
|
|
31
|
+
const { promisify } = await import('node:util');
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
const { stdout } = await run('git', ['-C', root, 'status', '--porcelain=v1'], { maxBuffer: 8 << 20 });
|
|
34
|
+
return stdout
|
|
35
|
+
.split('\n')
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((line) => line.slice(3).trim())
|
|
38
|
+
.filter((p) => !p.includes(' -> '));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parsePathList(value) {
|
|
45
|
+
return String(value ?? '')
|
|
46
|
+
.split(',')
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compact stack label: the tags that actually discriminate, not all of them. */
|
|
52
|
+
function stackLabel(tags = []) {
|
|
53
|
+
const DISCRIMINATING = [
|
|
54
|
+
'cloudflare-workers',
|
|
55
|
+
'connectiq',
|
|
56
|
+
'gradle',
|
|
57
|
+
'wear-os',
|
|
58
|
+
'react',
|
|
59
|
+
'react-router',
|
|
60
|
+
'typescript',
|
|
61
|
+
'python',
|
|
62
|
+
'less',
|
|
63
|
+
'hono',
|
|
64
|
+
'firebase',
|
|
65
|
+
'maplibre',
|
|
66
|
+
'vitest',
|
|
67
|
+
'playwright',
|
|
68
|
+
];
|
|
69
|
+
const kept = tags.filter((t) => DISCRIMINATING.includes(t));
|
|
70
|
+
return (kept.length > 0 ? kept : tags).slice(0, 2).join('+') || '-';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
74
|
+
|
|
75
|
+
// --- tier 0: index ------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export async function indexCommand(args, flags) {
|
|
78
|
+
const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
|
|
79
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
80
|
+
const workspace = await workspaceLine(flags);
|
|
81
|
+
|
|
82
|
+
if (isJson(flags)) {
|
|
83
|
+
emit(
|
|
84
|
+
`${JSON.stringify(
|
|
85
|
+
{
|
|
86
|
+
ok: true,
|
|
87
|
+
workspace,
|
|
88
|
+
skills: registry.skills.map((s) => ({
|
|
89
|
+
name: s.name,
|
|
90
|
+
summary: s.summary,
|
|
91
|
+
capabilities: s.capabilities,
|
|
92
|
+
stack: s.stack,
|
|
93
|
+
bytes: s.bytes,
|
|
94
|
+
deferred: Boolean(s.deferred),
|
|
95
|
+
})),
|
|
96
|
+
},
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
)}\n`,
|
|
100
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
101
|
+
);
|
|
102
|
+
return EXIT.OK;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fixed field order, name-sorted: the output is diffable across releases, so
|
|
106
|
+
// an agent can tell what changed in the catalogue between two runs.
|
|
107
|
+
const rows = [...registry.skills]
|
|
108
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
109
|
+
.map((s) => {
|
|
110
|
+
const name = s.name.padEnd(26);
|
|
111
|
+
const bytes = kb(s.bytes).padStart(7);
|
|
112
|
+
const stack = stackLabel(s.stack).padEnd(22);
|
|
113
|
+
return `${name} ${bytes} ${stack} ${s.summary}${s.deferred ? ' [deferred]' : ''}`;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Total body bytes, and the subset tier 0 actually costs an agent: a deferred
|
|
117
|
+
// skill costs one line here and nothing more until it is fetched. That gap is
|
|
118
|
+
// the whole argument for progressive disclosure, so it is printed.
|
|
119
|
+
const total = registry.skills.reduce((n, s) => n + s.bytes, 0);
|
|
120
|
+
const deferredCount = registry.skills.filter((s) => s.deferred).length;
|
|
121
|
+
emit(
|
|
122
|
+
[
|
|
123
|
+
`${registry.skills.length} skills, ${kb(total)} total, ${deferredCount} deferred`,
|
|
124
|
+
workspace,
|
|
125
|
+
'',
|
|
126
|
+
'name bytes stack summary',
|
|
127
|
+
...rows,
|
|
128
|
+
'',
|
|
129
|
+
`deferred entries return a pointer, not a body. Fetch one with: ${CLI} skills show <name>`,
|
|
130
|
+
].join('\n'),
|
|
131
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
132
|
+
);
|
|
133
|
+
return EXIT.OK;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Header label. Platform tags are named; facts are counted, because a repo can
|
|
138
|
+
* easily carry a dozen of them and the count is what tells the reader the gate
|
|
139
|
+
* had more to work with than the stack alone (ADR-013).
|
|
140
|
+
*/
|
|
141
|
+
function repoLine(repo) {
|
|
142
|
+
const stack = repo.stack.length > 0 ? repo.stack.join(',') : 'undetected';
|
|
143
|
+
return repo.facts.length > 0 ? `${stack} +${repo.facts.length} facts` : stack;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `--all` with no other signal: everything this stack can use, deferred excluded. */
|
|
147
|
+
async function emitAll(registry, repoStack, flags, warning) {
|
|
148
|
+
const repo = new Set(repoStack);
|
|
149
|
+
const usable = registry.skills.filter((s) => stackCompatible(s, repoStack));
|
|
150
|
+
const deferred = usable.filter((s) => s.deferred);
|
|
151
|
+
const inline = usable.filter((s) => !s.deferred);
|
|
152
|
+
|
|
153
|
+
if (isJson(flags)) {
|
|
154
|
+
emit(
|
|
155
|
+
`${JSON.stringify(
|
|
156
|
+
{
|
|
157
|
+
ok: true,
|
|
158
|
+
all: true,
|
|
159
|
+
repoStack,
|
|
160
|
+
inline: inline.map((s) => s.name),
|
|
161
|
+
deferred: deferred.map((s) => s.name),
|
|
162
|
+
...(warning ? { warning } : {}),
|
|
163
|
+
},
|
|
164
|
+
null,
|
|
165
|
+
2,
|
|
166
|
+
)}\n`,
|
|
167
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
168
|
+
);
|
|
169
|
+
return EXIT.OK;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const total = inline.reduce((n, s) => n + s.bytes, 0);
|
|
173
|
+
const lines = [
|
|
174
|
+
`ALL ${inline.length} skills for this stack, ${kb(total)} (deferred excluded, use skills show)`,
|
|
175
|
+
'',
|
|
176
|
+
...inline.map((s) => `${s.name.padEnd(26)} ${kb(s.bytes).padStart(7)} ${s.stack.join('+')}`),
|
|
177
|
+
];
|
|
178
|
+
if (warning) lines.push('', warning);
|
|
179
|
+
if (deferred.length > 0) {
|
|
180
|
+
lines.push('', `DEFERRED ${deferred.length}: ${deferred.map((s) => s.name).join(', ')}`);
|
|
181
|
+
}
|
|
182
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
183
|
+
return EXIT.OK;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- find ---------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export async function findCommand(args, flags) {
|
|
189
|
+
const query = args.join(' ').trim();
|
|
190
|
+
if (!query) return fail('find needs a query', EXIT.USAGE, `usage: ${CLI} skills find <query>`);
|
|
191
|
+
|
|
192
|
+
const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
|
|
193
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
194
|
+
|
|
195
|
+
const tokens = tokenize(query);
|
|
196
|
+
const workspace = await workspaceLine(flags);
|
|
197
|
+
const results = registry.skills
|
|
198
|
+
.map((skill) => ({ skill, ...scoreSkill(skill, { repoStack: [], taskTokens: tokens, paths: [] }) }))
|
|
199
|
+
.filter((r) => selectionVerdict(r.evidence, r.reasons) === null)
|
|
200
|
+
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
201
|
+
|
|
202
|
+
if (isJson(flags)) {
|
|
203
|
+
emit(`${JSON.stringify({ ok: true, query, workspace, results: results.map((r) => ({ name: r.skill.name, score: r.score, reasons: r.reasons })) }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
|
|
204
|
+
return EXIT.OK;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (results.length === 0) {
|
|
208
|
+
emit([`no skill matches "${query}"`, '', `run: ${CLI} skills index (the full catalogue)`].join('\n'));
|
|
209
|
+
return EXIT.OK;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
emit(
|
|
213
|
+
[
|
|
214
|
+
`${results.length} match${results.length === 1 ? '' : 'es'} for "${query}"`,
|
|
215
|
+
workspace,
|
|
216
|
+
'',
|
|
217
|
+
...results.map(
|
|
218
|
+
(r) =>
|
|
219
|
+
`${r.skill.name.padEnd(26)} ${kb(r.skill.bytes).padStart(7)} ${r.reasons.join(' ')}`,
|
|
220
|
+
),
|
|
221
|
+
].join('\n'),
|
|
222
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
223
|
+
);
|
|
224
|
+
return EXIT.OK;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- brief: tier 1 ------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
export async function briefCommand(args, flags) {
|
|
230
|
+
const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
|
|
231
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
232
|
+
|
|
233
|
+
const root = flags.root ?? process.cwd();
|
|
234
|
+
const repo = await fingerprint(root);
|
|
235
|
+
const { note: workspace, root: workspaceRoot } = await announceWorkspace(flags);
|
|
236
|
+
// stack ∪ facts: a skill may declare either, so the gate must see both.
|
|
237
|
+
const repoStack = repo.signals;
|
|
238
|
+
const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
|
|
239
|
+
const taskTokens = tokenize(flags.task ?? '');
|
|
240
|
+
const forced = parsePathList(flags.skill);
|
|
241
|
+
const budget = flags.budget ?? DEFAULT_BUDGET;
|
|
242
|
+
|
|
243
|
+
// A brief with no task and no paths resolves on stack alone, which produces a
|
|
244
|
+
// plausible-looking list the agent may act on. It is the most likely way this
|
|
245
|
+
// tool gets misused, so it warns rather than silently answering.
|
|
246
|
+
//
|
|
247
|
+
// Under `--json` the warning is a FIELD, not prose on stdout. It used to be
|
|
248
|
+
// emitted as text with no `isJson` check, so `brief --json` printed a paragraph
|
|
249
|
+
// and then nothing a caller could parse - and with `--all` the text came first
|
|
250
|
+
// and a second document followed, so stdout was neither one document nor none.
|
|
251
|
+
if (!flags.task && paths.length === 0 && forced.length === 0) {
|
|
252
|
+
const warning =
|
|
253
|
+
'no --task and no staged files, so selection can only use the repo signals. ' +
|
|
254
|
+
'pass --task "<what you are doing>" or stage files so the diff drives selection, ' +
|
|
255
|
+
'or pass --all to take everything for this stack.';
|
|
256
|
+
if (isJson(flags)) {
|
|
257
|
+
// `ok: true` because the command did what was asked; the answer being weak
|
|
258
|
+
// is what `warning` carries. Failing here would tell a caller the tool
|
|
259
|
+
// broke when in fact it is telling them their query was.
|
|
260
|
+
if (flags.all) return emitAll(registry, repoStack, flags, warning);
|
|
261
|
+
emit(
|
|
262
|
+
`${JSON.stringify(
|
|
263
|
+
{
|
|
264
|
+
ok: true,
|
|
265
|
+
repo: { root, stack: repoStack },
|
|
266
|
+
workspace,
|
|
267
|
+
task: null,
|
|
268
|
+
budget,
|
|
269
|
+
resolved: [],
|
|
270
|
+
deferred: [],
|
|
271
|
+
excluded: [],
|
|
272
|
+
warning,
|
|
273
|
+
},
|
|
274
|
+
null,
|
|
275
|
+
2,
|
|
276
|
+
)}\n`,
|
|
277
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
278
|
+
);
|
|
279
|
+
return EXIT.OK;
|
|
280
|
+
}
|
|
281
|
+
emit(
|
|
282
|
+
[
|
|
283
|
+
`repo ${root.split('/').pop()} (${repoLine(repo)})`,
|
|
284
|
+
'',
|
|
285
|
+
warning,
|
|
286
|
+
].join('\n'),
|
|
287
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
288
|
+
);
|
|
289
|
+
if (flags.all) return emitAll(registry, repoStack, flags, warning);
|
|
290
|
+
return EXIT.OK;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const context = { repoStack, taskTokens, paths };
|
|
294
|
+
const { selected, excluded } = rankSkills(registry.skills, context, { forced });
|
|
295
|
+
|
|
296
|
+
// Hard dependencies are pulled in even when they did not score, and even past
|
|
297
|
+
// budget: a skill that is useless without its dependency is worse than a large
|
|
298
|
+
// context, because it silently misleads.
|
|
299
|
+
const expanded = closure(selected, registry.skills);
|
|
300
|
+
const order = new Map(expanded.map((e, i) => [e.skill.name, i]));
|
|
301
|
+
expanded.sort((a, b) => (order.get(a.skill.name) - order.get(b.skill.name)));
|
|
302
|
+
|
|
303
|
+
// Budget, by one shared function. The loop this replaced existed here and in the MCP
|
|
304
|
+
// tool, and the copies had already drifted: this one skipped a `deferred` skill before
|
|
305
|
+
// looking at the budget and the other checked it after, so a deferred dependency was
|
|
306
|
+
// inlined by one command and not the other. `selectWithinBudget` is the whole
|
|
307
|
+
// arithmetic, and it is the only place that knows a dependency does not get to exceed
|
|
308
|
+
// the budget on its own — which is what let a 2,000-byte budget resolve 13,503 bytes.
|
|
309
|
+
const { picked, deferred, used: bytesUsed } = selectWithinBudget(expanded, {
|
|
310
|
+
budget,
|
|
311
|
+
all: flags.all === true,
|
|
312
|
+
});
|
|
313
|
+
const closureNames = new Set(expanded.map((e) => e.skill.name));
|
|
314
|
+
|
|
315
|
+
const excludedNames = new Set(excluded.map((e) => e.skill.name));
|
|
316
|
+
const lines = [];
|
|
317
|
+
|
|
318
|
+
if (isJson(flags)) {
|
|
319
|
+
emit(
|
|
320
|
+
`${JSON.stringify(
|
|
321
|
+
{
|
|
322
|
+
ok: true,
|
|
323
|
+
repo: { root, stack: repoStack },
|
|
324
|
+
workspace,
|
|
325
|
+
task: flags.task ?? null,
|
|
326
|
+
budget,
|
|
327
|
+
resolved: picked.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes, why: [...e.reasons, ...[...e.via].map((v) => `requires:${v}`)] })),
|
|
328
|
+
deferred: deferred.map((e) => ({ name: e.skill.name, bytes: e.skill.bytes })),
|
|
329
|
+
excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
|
|
330
|
+
},
|
|
331
|
+
null,
|
|
332
|
+
2,
|
|
333
|
+
)}\n`,
|
|
334
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
335
|
+
);
|
|
336
|
+
return EXIT.OK;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A body the packet cannot paste costs nothing. Charging the catalogue size
|
|
340
|
+
// is how a brief with no workspace reports "budget capped" and then prints
|
|
341
|
+
// the same remedy once per skill (S33).
|
|
342
|
+
const readable = new Set();
|
|
343
|
+
for (const entry of expanded) {
|
|
344
|
+
if (await bodyReadable(entry.skill, workspaceRoot, root)) readable.add(entry.skill.name);
|
|
345
|
+
}
|
|
346
|
+
const pointerOnly = expanded.length > 0 && readable.size === 0;
|
|
347
|
+
|
|
348
|
+
let shown = picked;
|
|
349
|
+
let shownDeferred = deferred;
|
|
350
|
+
let shownUsed = bytesUsed;
|
|
351
|
+
if (pointerOnly) {
|
|
352
|
+
const priced = expanded.map((e) =>
|
|
353
|
+
e.skill.deferred ? e : { ...e, skill: { ...e.skill, bytes: 0 } },
|
|
354
|
+
);
|
|
355
|
+
const again = selectWithinBudget(priced, { budget, all: flags.all === true });
|
|
356
|
+
shown = again.picked;
|
|
357
|
+
shownDeferred = again.deferred;
|
|
358
|
+
shownUsed = again.used;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
lines.push(
|
|
362
|
+
`RESOLVED ${shown.length} skills, ${kb(shownUsed)}${flags.all || shownUsed <= budget ? '' : ' (budget capped)'} - repo ${root.split('/').pop()} (${repoLine(repo)})`,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (pointerOnly) {
|
|
366
|
+
// One remedy, then a pointer per skill. The exclusion reasons stay available
|
|
367
|
+
// through `skills why`; printing every one of them is a second copy of the
|
|
368
|
+
// catalogue, which is what pushed this packet over the budget it was saving.
|
|
369
|
+
lines.push(`bodies: not on this machine — ${WORKSPACE_REMEDY}`, '');
|
|
370
|
+
for (const entry of shown) lines.push(pointerLine(entry));
|
|
371
|
+
if (shownDeferred.length > 0) {
|
|
372
|
+
lines.push('', `DEFERRED ${shownDeferred.length} (marked deferred)`);
|
|
373
|
+
for (const entry of shownDeferred) lines.push(pointerLine(entry));
|
|
374
|
+
}
|
|
375
|
+
const hidden = excluded.filter((e) => !closureNames.has(e.skill.name));
|
|
376
|
+
if (hidden.length > 0) {
|
|
377
|
+
lines.push('', `NOT SELECTED ${hidden.length}`, '', ` notes: ${CLI} skills why <name> | ${CLI} skills show <name>`);
|
|
378
|
+
}
|
|
379
|
+
lines.push('', 'OVERRIDE --skill <name> | --budget <bytes> | --all | --paths <globs>');
|
|
380
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
381
|
+
return EXIT.OK;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
lines.push(workspace);
|
|
385
|
+
lines.push('');
|
|
386
|
+
|
|
387
|
+
for (const entry of shown) {
|
|
388
|
+
const why = [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)];
|
|
389
|
+
lines.push(
|
|
390
|
+
`SKILL ${entry.skill.name.padEnd(26)} ${kb(entry.skill.bytes).padStart(7)} ${why.join(' ')}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (flags.plan) {
|
|
395
|
+
lines.push('', 'PLAN (fetch each body only when a step needs it)');
|
|
396
|
+
for (const entry of shown) {
|
|
397
|
+
lines.push(` ${CLI} skills show ${entry.skill.name}`);
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
lines.push('', 'BODIES');
|
|
401
|
+
for (const entry of shown) {
|
|
402
|
+
lines.push('', `--- ${entry.skill.name} (${entry.skill.summary})`);
|
|
403
|
+
lines.push(await readBody(entry.skill, workspaceRoot, root));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (shownDeferred.length > 0) {
|
|
408
|
+
lines.push('', `DEFERRED ${shownDeferred.length} (over ${kb(budget)} or marked deferred)`);
|
|
409
|
+
for (const entry of shownDeferred) {
|
|
410
|
+
const reason = entry.skill.deferred ? 'marked deferred' : 'over budget';
|
|
411
|
+
lines.push(` ${entry.skill.name.padEnd(26)} ${kb(entry.skill.bytes).padStart(7)} ${reason}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const notSelected = excluded.filter((e) => !closureNames.has(e.skill.name));
|
|
416
|
+
if (notSelected.length > 0) {
|
|
417
|
+
lines.push('', 'NOT SELECTED');
|
|
418
|
+
for (const { skill, reason } of notSelected) {
|
|
419
|
+
lines.push(` ${skill.name.padEnd(26)} ${reason}`);
|
|
420
|
+
}
|
|
421
|
+
lines.push('', ` notes: ${CLI} skills why <name> | ${CLI} skills show <name>`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
lines.push('', 'OVERRIDE --skill <name> | --budget <bytes> | --all | --paths <globs>');
|
|
425
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
426
|
+
return EXIT.OK;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- show: tier 2 -------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export async function showCommand(args, flags) {
|
|
432
|
+
const name = args[0];
|
|
433
|
+
if (!name) return fail('show needs a skill name', EXIT.USAGE, `usage: ${CLI} skills show <name>`);
|
|
434
|
+
|
|
435
|
+
const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
|
|
436
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
437
|
+
|
|
438
|
+
const skill = registry.skills.find((s) => s.name === name);
|
|
439
|
+
if (!skill) {
|
|
440
|
+
const near = nearMatches(name, registry.skills.map((s) => s.name));
|
|
441
|
+
return fail(
|
|
442
|
+
`no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
443
|
+
EXIT.USAGE,
|
|
444
|
+
`run: ${CLI} skills find <query>`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const source = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
449
|
+
const { note: workspace, root: workspaceRoot } = await announceWorkspace(flags);
|
|
450
|
+
// The repo is a location for a declared source too, so `show` reads a body the
|
|
451
|
+
// caller already has instead of reporting the body missing (ADR-015).
|
|
452
|
+
const root = flags.root ?? process.cwd();
|
|
453
|
+
const body = await sourcePath(skill, workspaceRoot, root);
|
|
454
|
+
const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
|
|
455
|
+
if (isJson(flags)) {
|
|
456
|
+
emit(
|
|
457
|
+
`${JSON.stringify({ ok: true, ...skill, workspace, body, bodyFrom }, null, 2)}\n`,
|
|
458
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
459
|
+
);
|
|
460
|
+
return EXIT.OK;
|
|
461
|
+
}
|
|
462
|
+
emit(
|
|
463
|
+
[
|
|
464
|
+
`${skill.name}`,
|
|
465
|
+
`${skill.summary}`,
|
|
466
|
+
'',
|
|
467
|
+
`capabilities ${skill.capabilities.join(', ')}`,
|
|
468
|
+
`stack ${skill.stack.join(', ')}`,
|
|
469
|
+
`keywords ${skill.keywords.join(', ')}`,
|
|
470
|
+
`bytes ${skill.bytes}`,
|
|
471
|
+
skill.deferred ? 'deferred true (large body; this is the pointer)' : null,
|
|
472
|
+
skill.requires?.length ? `requires ${skill.requires.join(', ')}` : null,
|
|
473
|
+
'',
|
|
474
|
+
`source ${source ? `${source.repo}/${source.path} (${source.role})` : 'none'}`,
|
|
475
|
+
`body ${body === null ? '(unavailable)' : `${bodyFrom} ${body}`}`,
|
|
476
|
+
workspace,
|
|
477
|
+
'',
|
|
478
|
+
await readBody(skill, workspaceRoot, root),
|
|
479
|
+
]
|
|
480
|
+
.filter((l) => l !== null)
|
|
481
|
+
.join('\n'),
|
|
482
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
483
|
+
);
|
|
484
|
+
return EXIT.OK;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- why ----------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
export async function whyCommand(args, flags) {
|
|
490
|
+
const name = args[0];
|
|
491
|
+
if (!name) return fail('why needs a skill name', EXIT.USAGE, `usage: ${CLI} skills why <name>`);
|
|
492
|
+
|
|
493
|
+
const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
|
|
494
|
+
if (!ok) return fail(errors[0], EXIT.REGISTRY);
|
|
495
|
+
|
|
496
|
+
const skill = registry.skills.find((s) => s.name === name);
|
|
497
|
+
if (!skill) {
|
|
498
|
+
const near = nearMatches(name, registry.skills.map((s) => s.name));
|
|
499
|
+
return fail(
|
|
500
|
+
`no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
501
|
+
EXIT.USAGE,
|
|
502
|
+
`run: ${CLI} skills find <query>`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const root = flags.root ?? process.cwd();
|
|
507
|
+
const repo = await fingerprint(root);
|
|
508
|
+
const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
|
|
509
|
+
const tokens = tokenize(flags.task ?? '');
|
|
510
|
+
|
|
511
|
+
const { score, reasons } = scoreSkill(skill, { repoStack: repo.signals, taskTokens: tokens, paths });
|
|
512
|
+
const compatible = stackCompatible(skill, repo.signals);
|
|
513
|
+
|
|
514
|
+
// Every other command honours `--json`; this one built its answer as text and
|
|
515
|
+
// emitted it, so `--json` was silently ignored and a caller got prose it could
|
|
516
|
+
// not parse. The doc carries the same facts as the text below, and `gate` is
|
|
517
|
+
// the field that makes it machine-readable rather than something a reader has
|
|
518
|
+
// to scrape: a skill that can never be selected in this repo is a decision, not
|
|
519
|
+
// a line of output.
|
|
520
|
+
if (isJson(flags)) {
|
|
521
|
+
const workspace = await resolveWorkspace(flags);
|
|
522
|
+
emitJson({
|
|
523
|
+
ok: true,
|
|
524
|
+
command: 'skills why',
|
|
525
|
+
skill: skill.name,
|
|
526
|
+
score,
|
|
527
|
+
compatible,
|
|
528
|
+
gate: compatible ? 'pass' : 'fail',
|
|
529
|
+
repo: { root, stack: repo.stack, facts: repo.facts, evidence: repo.evidence },
|
|
530
|
+
workspace: { root: workspace.root ?? null, source: workspace.source, detail: workspace.detail },
|
|
531
|
+
paths: paths.slice(0, 8),
|
|
532
|
+
taskTokens: tokens,
|
|
533
|
+
signals: reasons,
|
|
534
|
+
variants: (skill.variants ?? []).map((v) => ({ id: v.id, stack: v.stack, bytes: v.bytes })),
|
|
535
|
+
aliasOf: skill.aliasOf ?? null,
|
|
536
|
+
variantOf: skill.variantOf ?? null,
|
|
537
|
+
requires: skill.requires ?? [],
|
|
538
|
+
}, { maxBytes: flags.maxBytes ?? 0 });
|
|
539
|
+
return EXIT.OK;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const lines = [
|
|
543
|
+
`${skill.name} - score ${score}`,
|
|
544
|
+
'',
|
|
545
|
+
`repo stack ${repo.stack.join(', ') || 'undetected'}`,
|
|
546
|
+
`repo facts ${repo.facts.join(', ') || 'none'}`,
|
|
547
|
+
`repo evidence ${repo.evidence.join(' ') || 'none'}`,
|
|
548
|
+
await workspaceLine(flags),
|
|
549
|
+
`paths seen ${paths.length > 0 ? paths.slice(0, 8).join(' ') : '(none - pass --paths or stage files)'}`,
|
|
550
|
+
`task tokens ${tokens.length > 0 ? tokens.join(' ') : '(none - pass --task)'}`,
|
|
551
|
+
'',
|
|
552
|
+
`gate ${compatible ? 'pass (stack overlaps the repo)' : 'FAIL (no stack overlap, so it can never be selected)'}`,
|
|
553
|
+
`signals ${reasons.length > 0 ? reasons.join(' ') : '(none - this skill scores 0 for the current context)'}`,
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
if (skill.variants?.length > 0) {
|
|
557
|
+
lines.push('', 'variants (one body per stack):');
|
|
558
|
+
for (const v of skill.variants) lines.push(` ${v.id.padEnd(8)} ${v.stack.join('+').padEnd(30)} ${kb(v.bytes)}`);
|
|
559
|
+
}
|
|
560
|
+
if (skill.aliasOf) lines.push('', `alias-of ${skill.aliasOf} (retired name, resolves to that skill)`);
|
|
561
|
+
if (skill.variantOf) lines.push('', `variant-of ${skill.variantOf} (sibling group, one body per stack)`);
|
|
562
|
+
if (skill.requires?.length > 0) lines.push(`requires ${skill.requires.join(', ')}`);
|
|
563
|
+
|
|
564
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
565
|
+
return EXIT.OK;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// --- bodies -------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Resolve a skill's body to an absolute path.
|
|
572
|
+
*
|
|
573
|
+
* Sources are declared as `repo/path`, so finding one needs the workspace that
|
|
574
|
+
* CONTAINS the repos. That used to be `resolve(here, '..', '..', '..')`, which is
|
|
575
|
+
* right in the checkout and wrong in `node_modules` - so every source resolved to a
|
|
576
|
+
* path that could not exist and `skills` failed with 38 ENOENT lines. Now it comes
|
|
577
|
+
* from configuration, and `null` is a legitimate answer meaning "this installation
|
|
578
|
+
* has no workspace" (ADR-014).
|
|
579
|
+
*
|
|
580
|
+
* A second location exists and is not a fallback: the repo being read is itself a
|
|
581
|
+
* location for a declared source, matched on bytes rather than on hope (ADR-015).
|
|
582
|
+
* The canonical source is still tried first, so a workspace body always wins.
|
|
583
|
+
*/
|
|
584
|
+
export async function sourcePath(skill, workspaceRoot, root) {
|
|
585
|
+
const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
|
|
586
|
+
|
|
587
|
+
if (canonical) {
|
|
588
|
+
const found = await locateSource(canonical, { workspaceRoot, root });
|
|
589
|
+
if (found.path) return found.path;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const held = await sourceHeldLocally(skill, root, { workspaceRoot });
|
|
593
|
+
if (held && !held.ambiguous) return held.path;
|
|
594
|
+
|
|
595
|
+
// A skill with no canonical source, or one whose bytes match nothing, has no
|
|
596
|
+
// body. Say so rather than printing an empty section.
|
|
597
|
+
return canonical && workspaceRoot ? join(workspaceRoot, canonical.repo, canonical.path) : null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** A one-line pointer. The reasons stay, so a brief that cannot inline still says why. */
|
|
601
|
+
function pointerLine(entry) {
|
|
602
|
+
const why = [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)];
|
|
603
|
+
return `pointer ${entry.skill.name.padEnd(26)} ${why.join(' ')}`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** True when `readBody` would return the skill text rather than an explanation of its absence. */
|
|
607
|
+
async function bodyReadable(skill, workspaceRoot, root) {
|
|
608
|
+
const full = await sourcePath(skill, workspaceRoot, root);
|
|
609
|
+
return Boolean(full && existsSync(full));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export async function readBody(skill, workspaceRoot, root) {
|
|
613
|
+
const full = await sourcePath(skill, workspaceRoot, root);
|
|
614
|
+
if (!full) {
|
|
615
|
+
return `(body unavailable: this installation has no workspace and ${root ?? 'this directory'} does not hold ${skill.name}; ${WORKSPACE_REMEDY})`;
|
|
616
|
+
}
|
|
617
|
+
if (!existsSync(full)) return `(body unavailable: ${full} is not on this machine)`;
|
|
618
|
+
try {
|
|
619
|
+
const target = existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
|
|
620
|
+
return (await readFile(target, 'utf8')).trimEnd();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
return `(body unreadable: ${error.message})`;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Which source answered the workspace question, and what to do when none did.
|
|
628
|
+
* Printed once, not per skill - 38 identical remedies is the same noise as 38
|
|
629
|
+
* ENOENTs.
|
|
630
|
+
*/
|
|
631
|
+
async function workspaceLine(flags) {
|
|
632
|
+
const ws = await resolveWorkspace(flags);
|
|
633
|
+
if (ws.root) return `workspace ${ws.root} (${ws.source}: ${ws.detail})`;
|
|
634
|
+
// The wording states only the workspace fact. Whether a body is reachable is a
|
|
635
|
+
// different question with a different answer per skill, because the repo itself
|
|
636
|
+
// can hold a declared source (ADR-015) - claiming bodies are unavailable here
|
|
637
|
+
// would contradict the `body repo ...` line printed directly above it.
|
|
638
|
+
return `workspace none configured - set one with ${WORKSPACE_REMEDY}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Commands that print bodies or install them announce the workspace up front. */
|
|
642
|
+
async function announceWorkspace(flags) {
|
|
643
|
+
const note = await workspaceLine(flags);
|
|
644
|
+
const { root } = await resolveWorkspace(flags);
|
|
645
|
+
return { note, root };
|
|
646
|
+
}
|
|
647
|
+
|